diff --git a/dev/test/cli.test.ts b/dev/test/cli.test.ts index ab29e88..7d9680b 100644 --- a/dev/test/cli.test.ts +++ b/dev/test/cli.test.ts @@ -445,6 +445,17 @@ describe('CLI', () => { expect(report.items[0]?.reason).toBe('could not parse intent JSON'); }); + it('preserves an output sentinel when intent assembly fails', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'block-runner-cli-')); + const outPath = path.join(dir, 'out.html'); + await writeFile(outPath, 'Keep existing output'); + const result = await runCli(['assemble', '-', '--out', outPath], + '{"blocks":[{"block":"core/paragraph","text":"First"},{"text":"Second"}]}'); + expect(result.code).toBe(1); + expect(result.stdout).toContain('blocks[1]: expected a node with a non-empty block name'); + expect(await readFile(outPath, 'utf8')).toBe('Keep existing output'); + }); + it('rejects styling flags on assemble and points callers to convert', async () => { for (const args of [ ['assemble', '-', '--styling', 'strict'], diff --git a/dev/test/intent.test.ts b/dev/test/intent.test.ts index f3d265e..08e0c77 100644 --- a/dev/test/intent.test.ts +++ b/dev/test/intent.test.ts @@ -1,8 +1,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; -import { realize, validate } from '../../src/index.js'; +import { describe, expect, it, vi } from 'vitest'; +import { assemble, realize, validate } from '../../src/index.js'; import { getWp } from '../../src/headless/wp.js'; +import * as media from '../../src/media/resolver.js'; +import type { IntentNode } from '../../src/types.js'; const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures'); @@ -142,7 +144,62 @@ describe('intent assembly', () => { const report = await realize('{"blocks":[{}]}'); expect(report.ok).toBe(false); - expect(report.items[0]?.reason).toBe('intent parsed but contained no blocks'); + expect(report.items[0]?.reason).toContain('blocks[0]: expected a node with a non-empty block name'); + }); + + it.each([ + [[{ block: 'core/paragraph', text: 'First' }, { text: 'Second' }], 'blocks[1]'], + [[{ block: 'core/group', children: [null] }], 'blocks[0].children[0]'], + [[{ block: 'core/group', children: {} }], 'blocks[0].children'], + [[{ block: 'core/group', children: null }], 'blocks[0].children'], + [[{ block: '' }], 'blocks[0]'], + [[{ block: 'core/list', items: ['First', { text: 'Second' }] }], 'blocks[0].items[1]'], + [[{ block: 'core/list', items: 'First' }], 'blocks[0].items'], + [[{ block: 'core/table', rows: [['First'], [{ text: 'Second' }]] }], 'blocks[0].rows[1][0]'], + [[{ block: 'core/table', rows: ['First'] }], 'blocks[0].rows[0]'], + [[{ block: 'core/table', rows: {} }], 'blocks[0].rows'], + ])('rejects malformed consumed input %j at %s', async (nodes, location) => { + const report = await realize(JSON.stringify({ blocks: nodes }), { sourcePath: 'intent.json' }); + expect(report.ok).toBe(false); + expect(report.output).toBe(''); + expect(report.items[0]).toMatchObject({ status: 'invalid', source: { path: 'intent.json' } }); + expect(report.items[0].reason).toContain(`${location}: expected`); + await expect(assemble(nodes as IntentNode[])).rejects.toThrow(`${location}: expected`); + }); + + it('rejects malformed input before constructing the media resolver', async () => { + const resolver = vi.spyOn(media, 'createMediaResolver'); + try { + const report = await realize(JSON.stringify({ blocks: [ + { block: 'core/image', url: 'photo.jpg' }, { text: 'Missing name' }, + ] })); + expect(report.ok).toBe(false); + expect(resolver).not.toHaveBeenCalled(); + } finally { + resolver.mockRestore(); + } + }); + + it.each([ + '{"block":"core/paragraph","text":"Text"}', + '[{"block":"core/paragraph","text":"Text"}]', + '{"blocks":[{"block":"core/paragraph","text":"Text"}]}', + '```json\n{"blocks":[{"block":"core/paragraph","text":"Text"}]}\n```', + ])('keeps valid input wrappers', async (raw) => { + expect((await realize(raw)).ok).toBe(true); + }); + + it('keeps explicit tables and ignores unused list shorthand', async () => { + const report = await realize(JSON.stringify({ blocks: [ + { block: 'core/list', items: { unused: true }, children: [{ block: 'core/list-item', text: 'Kept' }] }, + { block: 'core/table', attrs: { body: [{ cells: [{ content: 'Body only', tag: 'td' }] }] } }, + { block: 'core/table', rows: [['Header'], ['Cell']] }, + ] })); + expect(report.ok).toBe(true); + expect(report.output).toContain('Kept'); + expect(report.output).toContain('Body only'); + expect(report.output).toContain('Header'); + expect(report.output).toContain('Cell'); }); it('warns on an unregistered block name without failing the run', async () => { diff --git a/src/intent/index.ts b/src/intent/index.ts index c647d23..5d24741 100644 --- a/src/intent/index.ts +++ b/src/intent/index.ts @@ -74,21 +74,23 @@ export async function assemble(nodes: IntentNode[]): Promise { return assembleWithWp(nodes, await getWp()); } -async function assembleWithWp(nodes: IntentNode[], wp: WpModules): Promise { +async function assembleWithWp(nodes: IntentNode[], wp: WpModules, intentPath = 'blocks'): Promise { + if (!Array.isArray(nodes)) throw new Error(`${intentPath}: expected an array of block nodes`); const out: WpBlock[] = []; - for (const node of nodes) { - const block = await assembleNode(node, wp); - if (block) out.push(block); + for (const [index, node] of nodes.entries()) { + out.push(await assembleNode(node, wp, `${intentPath}[${index}]`)); } return out; } -async function assembleNode(node: IntentNode, wp: WpModules): Promise { - if (!node || typeof node.block !== 'string') return null; +async function assembleNode(node: IntentNode, wp: WpModules, intentPath: string): Promise { + if (!node || typeof node.block !== 'string' || !node.block.trim()) { + throw new Error(`${intentPath}: expected a node with a non-empty block name`); + } const name = node.block; const text = node.text; const attrs: Record = { ...(node.attrs ?? {}) }; - const children = await assembleWithWp(node.children ?? [], wp); + const children = await assembleWithWp(node.children === undefined ? [] : node.children, wp, `${intentPath}.children`); switch (name) { case 'core/heading': @@ -116,8 +118,12 @@ async function assembleNode(node: IntentNode, wp: WpModules): Promise wp.createBlock('core/list-item', { content: item }, [])); + if (items.length === 0 && node.items !== undefined) { + if (!Array.isArray(node.items)) throw new Error(`${intentPath}.items: expected an array of strings`); + items = node.items.map((item, index) => { + if (typeof item !== 'string') throw new Error(`${intentPath}.items[${index}]: expected a string`); + return wp.createBlock('core/list-item', { content: item }, []); + }); } return wp.createBlock(name, attrs, items); } @@ -141,12 +147,19 @@ async function assembleNode(node: IntentNode, wp: WpModules): Promise 0) { - const toCells = (row: string[], tag: 'th' | 'td'): { cells: { content: string; tag: string }[] } => ({ - cells: row.map((content) => ({ content, tag })), - }); - attrs.head = [toCells(node.rows[0], 'th')]; - attrs.body = node.rows.slice(1).map((row) => toCells(row, 'td')); + if (node.rows !== undefined && !Array.isArray(node.rows)) { + throw new Error(`${intentPath}.rows: expected an array of string arrays`); + } + if (node.rows && node.rows.length > 0) { + const toCells = (row: string[], index: number, tag: 'th' | 'td'): { cells: { content: string; tag: string }[] } => { + if (!Array.isArray(row)) throw new Error(`${intentPath}.rows[${index}]: expected an array of strings`); + return { cells: row.map((content, cell) => { + if (typeof content !== 'string') throw new Error(`${intentPath}.rows[${index}][${cell}]: expected a string`); + return { content, tag }; + }) }; + }; + attrs.head = [toCells(node.rows[0], 0, 'th')]; + attrs.body = node.rows.slice(1).map((row, index) => toCells(row, index + 1, 'td')); } return wp.createBlock(name, attrs, []); } @@ -191,6 +204,7 @@ export async function realize(rawIntent: string, options: AssembleOptions = {}): try { const config = await loadConfig(options); const wp = await getWp(); + const blocks = await assembleWithWp(extracted.tree.blocks, wp); const warnings = unknownBlockWarnings(extracted.tree.blocks, wp, options.sourcePath); if (config.styling !== DEFAULT_CONFIG.styling) { warnings.push({ @@ -200,7 +214,6 @@ export async function realize(rawIntent: string, options: AssembleOptions = {}): source: options.sourcePath ? { path: options.sourcePath } : undefined, }); } - const blocks = await assembleWithWp(extracted.tree.blocks, wp); if (blocks.length === 0) { return invalidInputReport('intent parsed but contained no blocks', options); }