Block Runner 0.9
+Turn a finished interface into a registered block with clear controls, native markup, and a source trail reviewers can inspect.
+ +


Grid
One
Two
', 'img'],
+ ['figure image', '
';
+ const reference = refs(html)('img');
+ const proposal = (width: JsonValue, height: JsonValue) => ({ structure: [{ id: 'image', block: 'core/image', sourceRef: reference, attributes: { width, height } }] });
+ const changed = await author(html, { author: { name: 'example/proposal' }, proposal: proposal(641, 480) });
+ expect(changed.ok).toBe(false);
+ expect(changed.items.map((item) => item.reason).join('\n')).toMatch(/changes source content without an explicit source decision/i);
+ for (const value of [640.5, 0, -640, Infinity, true, {}, Number.MAX_SAFE_INTEGER + 1, '640px']) {
+ const rejected = await author(html, { author: { name: 'example/proposal' }, proposal: proposal(value, 480) });
+ expect(rejected.ok, `${String(value)}: ${JSON.stringify(rejected.items)}`).toBe(false);
+ }
+ const reviewed = await author(html, { author: { name: 'example/proposal' }, proposal: {
+ ...proposal('641', 480),
+ sourceDecisions: [{ action: 'replace', sourceRef: reference, node: 'image', attribute: 'width', value: '641', reason: 'Approved image crop.' }],
+ } });
+ expect(reviewed.ok, JSON.stringify(reviewed.items)).toBe(true);
+ expect(reviewed.package!.canonicalPlan!.structure[0]!.attributes).toMatchObject({ width: '641', height: '480' });
+ expect(reviewed.package!.canonicalPlan!.sourceDecisions).toEqual(expect.arrayContaining([
+ expect.objectContaining({ action: 'replace', attribute: 'width', original: '640', value: '641' }),
+ ]));
+ });
+
it('accounts for identical source units by reference and audits href aliases', async () => {
const html = 'Discard
Read'; const evidence = collectSourceEvidence(html).structure; @@ -245,6 +290,84 @@ describe('author proposal boundary', () => { validateSourceContent(html, compileRegisteredBlock(plan).template); }); + it('binds the complete self-contained utility hero handoff through proposal and canonical-plan routes', async () => { + const { html, sourcePath } = await source('utility/hero-handoff.html'); + const css = await readFile(path.join(benchmarkRoot, 'utility', 'hero.css'), 'utf8'); + const ref = refs(html); + const proposal = { structure: [{ id: 'hero', block: 'core/group', sourceRef: ref('section'), children: [ + { id: 'hero-grid', block: 'core/group', sourceRef: ref('div', 0), children: [ + { id: 'copy', block: 'core/group', sourceRef: ref('div', 1), children: [ + { id: 'eyebrow', block: 'core/paragraph', sourceRef: ref('p', 0) }, + { id: 'title', block: 'core/heading', sourceRef: ref('h1') }, + { id: 'lede', block: 'core/paragraph', sourceRef: ref('p', 1) }, + { id: 'buttons', block: 'core/buttons', sourceRef: ref('div', 2), children: [ + { id: 'download', block: 'core/button', sourceRef: ref('a', 0) }, + { id: 'guide', block: 'core/button', sourceRef: ref('a', 1) }, + ] }, + ] }, + { id: 'image', block: 'core/image', sourceRef: ref('figure') }, + ] }, + ] }], fields: [ + { id: 'eyebrow-content', label: 'Eyebrow', mode: 'editable' as const, node: 'eyebrow', attribute: 'content' }, + { id: 'title-content', label: 'Title', mode: 'editable' as const, node: 'title', attribute: 'content' }, + { id: 'lede-content', label: 'Body', mode: 'editable' as const, node: 'lede', attribute: 'content' }, + { id: 'download-text', label: 'Download CTA', mode: 'editable' as const, node: 'download', attribute: 'text' }, + { id: 'guide-text', label: 'Guide CTA', mode: 'editable' as const, node: 'guide', attribute: 'text' }, + { id: 'image-url', label: 'Product image URL', mode: 'editable' as const, node: 'image', attribute: 'url' }, + { id: 'image-alt', label: 'Product image alt text', mode: 'editable' as const, node: 'image', attribute: 'alt' }, + ], locking: { mode: 'contentOnly' as const } }; + const options = { + sourcePath, + assetRoot: path.dirname(sourcePath), + author: { name: 'block-runner/hero', styles: { mode: 'css' as const, css, foundation: 'component' as const } }, + proposal, + }; + const first = await author(html, options); + expect(first.ok, JSON.stringify(first.items)).toBe(true); + const plan = first.package!.canonicalPlan!; + const second = await author(html, options); + expect(second.ok, JSON.stringify(second.items)).toBe(true); + expect(plan).toEqual(second.package!.canonicalPlan); + const nodes = nodesById(plan.structure); + expect(nodes.get('eyebrow')!.attributes).toMatchObject({ content: 'Block Runner 0.9' }); + expect(nodes.get('title')!.attributes).toMatchObject({ content: 'Build a WordPress block your team can keep editing.' }); + expect(nodes.get('lede')!.attributes).toMatchObject({ content: 'Turn a finished interface into a registered block with clear controls, native markup, and a source trail reviewers can inspect.' }); + expect(nodes.get('download')!.attributes).toMatchObject({ text: 'Download the testing release', url: '/download' }); + expect(nodes.get('guide')!.attributes).toMatchObject({ text: 'Read the authoring guide', url: '/docs/authoring' }); + expect(nodes.get('image')!.attributes).toMatchObject({ alt: 'Aurora dashboard with color tokens, release receipts, and a completed activation check', caption: 'Native controls stay with the block, not in a screenshot.' }); + expect(plan.fields).toEqual([ + expect.objectContaining({ id: 'eyebrow-content', mode: 'editable', node: 'eyebrow', attribute: 'content' }), + expect.objectContaining({ id: 'title-content', mode: 'editable', node: 'title', attribute: 'content' }), + expect.objectContaining({ id: 'lede-content', mode: 'editable', node: 'lede', attribute: 'content' }), + expect.objectContaining({ id: 'download-text', mode: 'editable', node: 'download', attribute: 'text' }), + expect.objectContaining({ id: 'guide-text', mode: 'editable', node: 'guide', attribute: 'text' }), + expect.objectContaining({ id: 'image-url', mode: 'editable', node: 'image', attribute: 'url' }), + expect.objectContaining({ id: 'image-alt', mode: 'editable', node: 'image', attribute: 'alt' }), + ]); + expect(plan.sourceDecisions).toBeUndefined(); + expect(plan.source).toMatchObject({ entry: sourcePath }); + expect(plan.assets).toEqual(expect.arrayContaining([expect.objectContaining({ + source: path.join(path.dirname(sourcePath), 'assets/aurora-dashboard.svg'), + uses: [expect.objectContaining({ node: 'image', attribute: 'url' })], + })])); + expect(plan.structure[0]!.children![0]!.children!.map((node) => node.id)).toEqual(['copy', 'image']); + expect(plan.locking).toEqual({ mode: 'contentOnly' }); + const roundtrip = await author(html, { + sourcePath, + assetRoot: path.dirname(sourcePath), + author: options.author, + plan, + }); + expect(roundtrip.ok, JSON.stringify(roundtrip.items)).toBe(true); + expect(roundtrip.package!.canonicalPlan).toEqual(plan); + validateSourceContent(html, compileRegisteredBlock(plan).template); + const tampered = structuredClone(plan); + tampered.coverage!.styles.find((entry) => entry.transportSelector && entry.nativeTargets?.length)!.transportSelector = '.unrelated-target'; + const rejected = await author(html, { sourcePath, assetRoot: path.dirname(sourcePath), author: options.author, plan: tampered }); + expect(rejected.ok).toBe(false); + expect(rejected.package).toBeUndefined(); + }); + it('binds the semantic cards hierarchy without dropping content', async () => { const { html, sourcePath } = await source('semantic/cards.html'); const ref = refs(html); diff --git a/test/authoring.generate.test.ts b/test/authoring.generate.test.ts index 710bb4e..26b20f1 100644 --- a/test/authoring.generate.test.ts +++ b/test/authoring.generate.test.ts @@ -131,6 +131,7 @@ describe('registered-block source compiler', () => { const style = sourceFile(first.files, 'style.scss').content; expect(style).toContain(`style emitter v${REGISTERED_BLOCK_STYLE_EMITTER_VERSION}`); + expect(style).not.toContain('max-width: none !important;'); expect(style).toContain('border: 1px solid #111;'); expect(style).toContain('color: var(--wp--preset--color--primary);'); expect(sourceFile(first.files, 'editor.scss').content).not.toContain('border: 1px solid #111;'); @@ -140,6 +141,28 @@ describe('registered-block source compiler', () => { } }); + it('preserves a source outer canvas around a centered max-width inner group', () => { + const input = plan(); + input.structure = [{ + id: 'canvas', block: 'core/group', children: [{ + id: 'layout', block: 'core/group', attributes: { className: 'mx-auto max-w-screen' }, children: [{ + id: 'copy', block: 'core/paragraph', attributes: { className: 'source-gap' }, + }], + }], + }]; + input.styles.rules = [ + { kind: 'style', selector: '.mx-auto', declarations: [ + { property: 'margin-left', value: 'auto' }, { property: 'margin-right', value: 'auto' }, + ] }, + { kind: 'style', selector: '.max-w-screen', declarations: [{ property: 'max-width', value: '80rem' }] }, + { kind: 'style', selector: '.source-gap', declarations: [{ property: 'margin-top', value: '1.5rem' }] }, + ]; + + const style = sourceFile(compileRegisteredBlock(input).files, 'style.scss').content; + expect(style).toContain('.wp-block-acme-callout {\n width: 100%;\n max-width: none !important;'); + expect(style).toContain('.wp-block-acme-callout .source-gap { margin-top: 1.5rem; }'); + }); + it('uses the full pinned WordPress schema, including support-value constraints', () => { expect(() => validateBlockMetadata({ apiVersion: 3, diff --git a/test/authoring.receipts.test.ts b/test/authoring.receipts.test.ts index 13786ae..c5bc4bc 100644 --- a/test/authoring.receipts.test.ts +++ b/test/authoring.receipts.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import Ajv2020 from 'ajv/dist/2020.js'; +import { author } from '../src/author/index.js'; import { executeFixture } from '../scripts/authoring-runner.js'; import { AUTHORING_DIMENSIONS, AUTHORING_RUNTIME_ARTIFACTS, authoringHashes, hashFile, @@ -14,7 +15,7 @@ import { } from '../scripts/authoring/score.js'; const suiteDirectory = path.resolve('benchmarks/authoring'); -const plan = () => ({ +const unboundPlan = () => ({ version: 1, generatorVersion: '0.9.0', target: { name: 'acme/receipt-unit-test', title: 'Receipt unit test' }, structure: [{ id: 'copy', block: 'core/paragraph', attributes: { content: 'Candidate' } }], fields: [], locking: { mode: 'none' }, styles: { strategy: 'native', outcomes: [] }, @@ -27,9 +28,32 @@ async function setup(negative = false) { const run = path.join(root, 'run'); await mkdir(plans); const suite = loadAuthoringSuite(suiteDirectory); - const fixture = suite.fixtures.find((candidate) => negative ? candidate.expectedStatus === 'unsupported' : candidate.expectedStatus === 'scored')!; + const fixture = suite.fixtures.find((candidate) => negative + ? candidate.expectedStatus === 'unsupported' + : candidate.id === 'cards-semantic')!; const hashes = authoringHashes(suite); - const candidate = plan(); + const sourceReference = fixture.source?.path; + if (!sourceReference) throw new Error(`fixture ${fixture.id} has no source path`); + const sourcePath = path.resolve(suiteDirectory, sourceReference); + const source = await readFile(sourcePath, 'utf8'); + const sourceDependencies = Array.isArray(fixture.sourceDependencies) + ? fixture.sourceDependencies.filter((dependency): dependency is { path: string } => typeof dependency === 'object' + && dependency !== null && !Array.isArray(dependency) && typeof (dependency as { path?: unknown }).path === 'string') + : []; + const stylesheet = await Promise.all(sourceDependencies + .filter((dependency) => dependency.path.endsWith('.css')) + .map((dependency) => readFile(path.resolve(suiteDirectory, dependency.path), 'utf8'))); + const analysis = negative + ? undefined + : await author(source, { + sourcePath, + author: { + name: 'acme/receipt-unit-test', + ...(stylesheet.length ? { styles: { mode: 'css' as const, css: stylesheet.join('') } } : {}), + }, + }); + if (!negative) expect(analysis?.ok, JSON.stringify(analysis?.items)).toBe(true); + const candidate = analysis?.package!.canonicalPlan ?? unboundPlan(); const input = path.join(plans, `${fixture.id}.json`); await writeFile(input, JSON.stringify(candidate)); const execute = (worker?: string, timeout?: number) => executeFixture(fixture, suiteDirectory, run, worker, hashes, plans, timeout); @@ -69,7 +93,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('records an absent runtime as blocked with no invented observations', async () => { const f = await setup(); - const receipt = f.execute(); + const receipt = await f.execute(); expect(receipt.status).toBe('blocked'); expect(receipt.environment).toEqual({ wordpress: null, theme: null, browser: null }); for (const key of Object.keys(AUTHORING_RUNTIME_ARTIFACTS)) expect(receipt.provenance?.[key]).toBeNull(); @@ -80,10 +104,10 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('gives the worker candidate-local source and plan snapshots', async () => { const f = await setup(); const worker = await f.worker("fs.writeFileSync('worker-observation.json', JSON.stringify({ source: fs.readFileSync(arg('--source'), 'utf8'), plan: fs.readFileSync(arg('--candidate-plan'), 'utf8'), dependencyArgs: args.filter((value) => value === '--source-dependency').length })); fs.writeFileSync(arg('--result'), JSON.stringify({status:'blocked',artifacts:{observation:{path:'worker-observation.json'}}}));"); - const receipt = f.execute(worker); + const receipt = await f.execute(worker); expect(receipt.status).toBe('blocked'); const observation = JSON.parse(await readFile(path.join(f.run, 'receipts', receipt.artifacts!.observation!.path), 'utf8')); - expect(observation.source).toContain('hero'); + expect(observation.source).toContain('plans-heading'); expect(JSON.parse(observation.plan)).toEqual(f.candidate); expect(observation.dependencyArgs).toBe(0); }); @@ -91,7 +115,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('retains malformed plan input as an engine error without generating source', async () => { const f = await setup(); await writeFile(f.input, '{"broken":'); - const receipt = f.execute(); + const receipt = await f.execute(); expect(receipt).toMatchObject({ status: 'engine_error', error: { kind: 'engine' } }); expect(existsSync(path.join(f.run, 'candidates', f.fixture.id))).toBe(false); expect(await readFile(path.join(f.run, 'receipts', receipt.artifacts!.candidateInput!.path), 'utf8')).toBe('{"broken":'); @@ -100,9 +124,11 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('counts only a real compiler refusal as the unsupported interaction outcome', async () => { const f = await setup(true); - Object.assign(f.candidate.structure[0]!.attributes, { onClick: 'requesting executable behavior' }); + const attributes = f.candidate.structure[0]?.attributes; + if (!attributes) throw new Error('unsupported fixture root attributes are missing'); + Object.assign(attributes, { onClick: 'requesting executable behavior' }); await writeFile(f.input, JSON.stringify(f.candidate)); - const receipt = f.execute(); + const receipt = await f.execute(); expect(receipt).toMatchObject({ status: 'unsupported', failClosed: { warningCode: 'BR_UNSUPPORTED_INTERACTION', noInteractiveRuntime: true } }); expect(existsSync(path.join(f.run, 'candidates', f.fixture.id))).toBe(false); expect(validateAuthoringReceipt(f.fixture, receipt, suiteDirectory, path.join(f.run, 'receipts'))).toEqual([]); @@ -121,7 +147,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', 'reserved-artifact': "fs.writeFileSync('evidence.txt','test'); fs.writeFileSync(arg('--result'),JSON.stringify({status:'blocked',artifacts:{generatedSourceManifest:{path:'evidence.txt'}}}));", 'changed-source': "fs.appendFileSync('edit.js','\\n// changed source'); fs.writeFileSync(arg('--result'),JSON.stringify({status:'blocked'}));", }[kind]!; - const receipt = f.execute(await f.worker(body), kind === 'timeout' ? 100 : 10000); + const receipt = await f.execute(await f.worker(body), kind === 'timeout' ? 100 : 10000); expect(receipt).toMatchObject({ status: 'engine_error', error: { kind: 'engine' } }); expect(receipt.artifacts).toHaveProperty('workerStdout'); expect(receipt.artifacts).toHaveProperty('workerStderr'); @@ -136,7 +162,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('does not manufacture a runtime from a worker that merely echoes requested settings', async () => { const f = await setup(); - const receipt = f.execute(await f.worker("fs.writeFileSync('style-ledger.json',JSON.stringify({version:1,entries:[]})); fs.writeFileSync(arg('--result'), JSON.stringify({status:'scored', environment:{wordpress:'7.1',browser:'chromium'}}));")); + const receipt = await f.execute(await f.worker("fs.writeFileSync('style-ledger.json',JSON.stringify({version:1,entries:[]})); fs.writeFileSync(arg('--result'), JSON.stringify({status:'scored', environment:{wordpress:'7.1',browser:'chromium'}}));")); const failures = validateAuthoringReceipt(f.fixture, receipt, suiteDirectory, path.join(f.run, 'receipts')); expect(receipt.environment).toEqual({ wordpress: null, theme: null, browser: null }); expect(failures).toContain('runtime observation requires artifact wordpressInventory'); @@ -145,7 +171,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('validates inventory shape and hashes independently from worker pass claims', async () => { const f = await setup(); - const receipt: AuthoringReceipt = f.execute(); + const receipt: AuthoringReceipt = await f.execute(); const receiptDirectory = path.join(f.run, 'receipts'); const add = async (name: string, value: unknown) => { const file = path.join(receiptDirectory, `${name}.json`); diff --git a/test/authoring.runner.test.ts b/test/authoring.runner.test.ts index cb8b574..177eac8 100644 --- a/test/authoring.runner.test.ts +++ b/test/authoring.runner.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; +import { author } from '../src/author/index.js'; import { materializeCandidate } from '../scripts/authoring-runner.js'; import { hashFile } from '../scripts/authoring/score.js'; import type { AuthoringFixture } from '../scripts/authoring/score.js'; @@ -22,29 +23,78 @@ describe('registered authoring candidate materialization', () => { expect(Buffer.from([0xff]).toString('utf8')).toBe(Buffer.from([0xfe]).toString('utf8')); expect(hashFile(first)).not.toBe(hashFile(second)); }); - it('runs the real compiler and does not substitute the expected scoring plan', async () => { + it('runs source-bound authoring before the real compiler and does not substitute the expected scoring plan', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-real-candidate-')); - const fixture = { id: 'one', source: { path: 'source.html' }, plan: 'expected.json' } as AuthoringFixture; - await writeFile(path.join(root, 'source.html'), 'Source design
'; + const css = '.notice { color: red; }'; + await writeFile(path.join(root, 'source.html'), source); + await writeFile(path.join(root, 'source.css'), css); + const analysis = await author(source, { + sourcePath: path.join(root, 'source.html'), + author: { name: 'acme/notice', styles: { mode: 'css', css } }, + }); + expect(analysis.ok, JSON.stringify(analysis.items)).toBe(true); + const plan = structuredClone(analysis.package!.canonicalPlan!); + plan.coverage!.styles = []; + const candidatePlan = path.join(root, 'candidate.json'); + await writeFile(candidatePlan, JSON.stringify(plan)); + + await expect(materializeCandidate(fixture, root, path.join(root, 'generated'), path.join(root, 'receipts'), candidatePlan)) + .rejects.toThrow(/complete source declaration and asset coverage/i); + expect(existsSync(path.join(root, 'generated'))).toBe(false); + }); + it('snapshots declared source dependencies inside the candidate for the worker', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-source-snapshot-')); const fixture = { @@ -55,16 +105,21 @@ describe('registered authoring candidate materialization', () => { plan: 'expected.json', } as AuthoringFixture; await mkdir(path.join(root, 'sources'), { recursive: true }); - await writeFile(path.join(root, 'sources/design.html'), '