diff --git a/README.md b/README.md index 20e144d..17a2234 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,14 @@ my-case/ See [`fixtures/sample`](fixtures/sample) for working examples. +When present, `meta.json` must contain a JSON object. `id`, `title`, and +`notes` must be strings, while `tags` must be an array containing only strings. +All fields may be omitted: the fixture directory name supplies `id` and +`title`, with `[]` and an empty string used for `tags` and `notes`. Invalid +metadata stops inspection with an error that names the fixture's `meta.json` +file and the field that needs correction. Pipes and line breaks in these fields +are escaped or normalized when Markdown reports are rendered. + ## API ```js diff --git a/src/fixtures.js b/src/fixtures.js index d89adca..d28f69b 100644 --- a/src/fixtures.js +++ b/src/fixtures.js @@ -13,11 +13,13 @@ export async function discoverFixtures(rootDir) { } export async function loadFixture(dir) { + const metaPath = join(dir, 'meta.json'); const [html, expected, meta] = await Promise.all([ readFile(join(dir, 'input.html'), 'utf8'), readFile(join(dir, 'expected.txt'), 'utf8'), - readOptionalJson(join(dir, 'meta.json')) + readOptionalJson(metaPath) ]); + validateMetadata(meta, metaPath); return { id: meta.id ?? basename(dir), title: meta.title ?? basename(dir), @@ -29,6 +31,20 @@ export async function loadFixture(dir) { }; } +function validateMetadata(meta, path) { + if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) { + throw new Error(`${path}: fixture metadata must be a JSON object`); + } + for (const field of ['id', 'title', 'notes']) { + if (meta[field] !== undefined && typeof meta[field] !== 'string') { + throw new Error(`${path}: ${field} must be a string`); + } + } + if (meta.tags !== undefined && (!Array.isArray(meta.tags) || meta.tags.some((tag) => typeof tag !== 'string'))) { + throw new Error(`${path}: tags must be an array of strings`); + } +} + async function readOptionalJson(path) { try { return JSON.parse(await readFile(path, 'utf8')); diff --git a/src/report.js b/src/report.js index 3d6b19e..a7b2564 100644 --- a/src/report.js +++ b/src/report.js @@ -27,7 +27,7 @@ export function renderMarkdownReport(report) { '|---|---:|---|---|' ]; for (const result of report.results) { - lines.push(`| ${result.id} | ${result.passed ? 'pass' : 'fail'} | ${result.tags.join(', ')} | ${result.notes.replace(/\|/g, '\\|')} |`); + lines.push(`| ${markdownCell(result.id)} | ${result.passed ? 'pass' : 'fail'} | ${markdownCell(result.tags.join(', '))} | ${markdownCell(result.notes)} |`); } for (const result of report.results.filter((item) => !item.passed)) { lines.push('', `## Diff: ${result.id}`, '', '```diff'); @@ -39,6 +39,10 @@ export function renderMarkdownReport(report) { return `${lines.join('\n')}\n`; } +function markdownCell(value) { + return String(value).replace(/\r?\n|\r/g, '
').replace(/\|/g, '\\|'); +} + export async function writeReport(report, outputDir) { await mkdir(outputDir, { recursive: true }); const json = join(outputDir, 'plainforge-report.json'); diff --git a/test/fixtures.test.js b/test/fixtures.test.js index 808738e..79c04e4 100644 --- a/test/fixtures.test.js +++ b/test/fixtures.test.js @@ -1,6 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { discoverFixtures, inspectFixtures } from '../src/index.js'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { discoverFixtures, inspectFixtures, loadFixture } from '../src/index.js'; test('discoverFixtures loads fixture metadata in stable order', async () => { const fixtures = await discoverFixtures('fixtures/sample'); @@ -13,3 +16,50 @@ test('inspectFixtures passes bundled sample fixtures', async () => { assert.equal(report.summary.total, 3); assert.equal(report.summary.failed, 0); }); + +test('loadFixture preserves metadata defaults when meta.json or fields are omitted', async () => { + const root = await mkdtemp(join(tmpdir(), 'plainforge-fixture-')); + const fixtureDir = join(root, 'default-case'); + try { + await mkdir(fixtureDir); + await writeFile(join(fixtureDir, 'input.html'), '

Expected

'); + await writeFile(join(fixtureDir, 'expected.txt'), 'Expected'); + assert.deepEqual( + (({ id, title, tags, notes }) => ({ id, title, tags, notes }))(await loadFixture(fixtureDir)), + { id: 'default-case', title: 'default-case', tags: [], notes: '' } + ); + await writeFile(join(fixtureDir, 'meta.json'), '{}'); + assert.equal((await loadFixture(fixtureDir)).id, 'default-case'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('loadFixture rejects malformed metadata with the metadata path', async () => { + const root = await mkdtemp(join(tmpdir(), 'plainforge-fixture-')); + const fixtureDir = join(root, 'invalid-case'); + try { + await mkdir(fixtureDir); + await writeFile(join(fixtureDir, 'input.html'), '

Expected

'); + await writeFile(join(fixtureDir, 'expected.txt'), 'Expected'); + const invalidMetadata = [ + ['null', 'fixture metadata must be a JSON object'], + ['[]', 'fixture metadata must be a JSON object'], + ['{"id":1}', 'id must be a string'], + ['{"title":false}', 'title must be a string'], + ['{"notes":{}}', 'notes must be a string'], + ['{"tags":"docs"}', 'tags must be an array of strings'], + ['{"tags":["docs",1]}', 'tags must be an array of strings'] + ]; + for (const [json, message] of invalidMetadata) { + await writeFile(join(fixtureDir, 'meta.json'), json); + await assert.rejects(loadFixture(fixtureDir), (error) => { + assert.match(error.message, /invalid-case\/meta\.json/); + assert.match(error.message, new RegExp(message)); + return true; + }); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/report.test.js b/test/report.test.js index f259ff0..43eb2a9 100644 --- a/test/report.test.js +++ b/test/report.test.js @@ -16,6 +16,15 @@ test('renderMarkdownReport summarizes results', () => { assert.match(markdown, /\| case \| pass \| x \| note \|/); }); +test('renderMarkdownReport escapes pipes and normalizes newlines in every user-controlled cell', () => { + const report = createReport([ + { id: 'case|one\ncontinued', tags: ['docs|api', 'line\nbreak'], notes: 'first|line\r\nsecond', passed: true, diff: [] } + ], 'readable'); + const markdown = renderMarkdownReport(report); + assert.match(markdown, /\| case\\\|one
continued \| pass \| docs\\\|api, line
break \| first\\\|line
second \|/); + assert.equal(markdown.split('\n').filter((line) => line.startsWith('| ')).length, 2); +}); + test('writeReport writes json and markdown reports', async () => { const dir = await mkdtemp(join(tmpdir(), 'plainforge-')); try {