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