chore(voyage): release v5.0.0 — remove bespoke playground + /trekrevise + Handover 8; render produced artifacts to HTML + link, annotate via /playground

The v4.2/v4.3 bespoke playground SPA (~388 KB), the /trekrevise command,
Handover 8 (annotation → revision), the supporting lib/ modules
(anchor-parser, annotation-digest, markdown-write, revision-guard), the
Playwright e2e suite, and the @playwright/test / @axe-core/playwright
devDeps are removed. A browser walkthrough found the playground borderline
unusable, and it duplicated the official /playground plugin's
document-critique / diff-review templates.

In their place: scripts/render-artifact.mjs — a small, zero-dependency
renderer that turns a brief/plan/review .md into a self-contained,
design-system-styled, zero-network .html (frontmatter folded into a
<details> block). /trekbrief, /trekplan, and /trekreview call it on their
last step and print the file:// link; to annotate, run /playground
(document-critique) on the .md and paste the generated prompt back.

Resolves the v4.3.1-deferred findings as moot (their target files are
deleted). npm test green: 509 tests, 507 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-12 14:05:07 +02:00
commit 916d30f63e
96 changed files with 620 additions and 14716 deletions

View file

@ -1,98 +1,122 @@
// tests/scripts/render-artifact.test.mjs
// CLI renderer contract — brief SC1 (zero-network) + SC11 (self-eat).
//
// Verifies:
// 1. CLI produces a non-empty .html file from a valid input.md
// 2. Output has DOCTYPE + closing </html> + inlined <style> + inlined <script>
// 3. Output contains NO http:// or https:// URLs (zero-network constraint)
// 4. Output title comes from frontmatter (slug or task)
// 5. Two invocations on the same input produce byte-identical output
// Covers scripts/render-artifact.mjs — the v5.0.0 self-contained HTML
// renderer that /trekbrief, /trekplan, /trekreview call at the end of their
// run to produce a browser-readable view of the just-written artifact.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { buildHtml, renderMarkdown, render, parseArgs } from '../../scripts/render-artifact.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..', '..');
const RENDERER = join(ROOT, 'scripts', 'render-artifact.mjs');
const FIX_BRIEF = join(ROOT, 'tests', 'fixtures', 'annotation', 'annotation-brief.md');
const SAMPLE = `---
type: trekplan
plan_version: "1.7"
task: "Render-artifact smoke test"
slug: render-smoke
---
function runRender(input, out) {
return execFileSync('node', [RENDERER, input, '--out', out], { encoding: 'utf-8' });
}
# Render-artifact smoke test
function sha256(p) {
return createHash('sha256').update(readFileSync(p)).digest('hex');
}
A paragraph with **bold**, \`inline code\`, and a [link](https://example.com).
test('render-artifact CLI exits 0 and produces a non-empty .html file', () => {
const dir = mkdtempSync(join(tmpdir(), 'voyage-render-'));
## Steps
- top item
- nested item
- second top item
1. ordered one
2. ordered two
\`\`\`js
const x = 1;
\`\`\`
> a blockquote line
| Col A | Col B |
|-------|-------|
| 1 | 2 |
`;
test('buildHtml produces a complete self-contained HTML document', () => {
const html = buildHtml('plan.md', SAMPLE);
assert.ok(html.startsWith('<!DOCTYPE html>'), 'must start with doctype');
assert.ok(html.includes('</html>'), 'must close html');
assert.ok(html.includes('<style>'), 'must inline a stylesheet');
// Zero external network references.
assert.ok(!/<link[^>]+href=/i.test(html), 'no external <link> stylesheets');
assert.ok(!/<script[^>]+src=/i.test(html), 'no external <script src>');
assert.ok(!/https?:\/\/(?!example\.com)/.test(html.replace(/<style>[\s\S]*?<\/style>/, '')), 'no unexpected http(s) URLs outside example link');
});
test('buildHtml folds frontmatter into a <details> block', () => {
const html = buildHtml('plan.md', SAMPLE);
assert.ok(html.includes('<details class="frontmatter">'), 'frontmatter wrapped in <details>');
assert.ok(html.includes('plan_version'), 'frontmatter content preserved');
// Frontmatter must NOT leak into the rendered body as a literal "---" rule.
const bodyOnly = html.split('</details>')[1] || '';
assert.ok(!bodyOnly.startsWith('\n<hr>'), 'frontmatter fence should not become an <hr>');
});
test('buildHtml derives the <title> from frontmatter task', () => {
const html = buildHtml('plan.md', SAMPLE);
assert.match(html, /<title>Render-artifact smoke test<\/title>/);
});
test('renderMarkdown renders headings, code fences, lists, tables, blockquotes', () => {
const out = renderMarkdown(SAMPLE.split('---\n').slice(2).join('---\n'));
assert.match(out, /<h1>Render-artifact smoke test<\/h1>/);
assert.match(out, /<h2>Steps<\/h2>/);
assert.match(out, /<pre><code class="language-js">/);
assert.ok(out.includes('const x = 1;'), 'code fence body preserved');
assert.match(out, /<ul><li>top item<ul><li>nested item<\/li><\/ul><\/li>/);
assert.match(out, /<ol><li>ordered one<\/li><li>ordered two<\/li><\/ol>/);
assert.match(out, /<blockquote>a blockquote line<\/blockquote>/);
assert.match(out, /<table>[\s\S]*<th>Col A<\/th>[\s\S]*<td>1<\/td>[\s\S]*<\/table>/);
assert.match(out, /<strong>bold<\/strong>/);
assert.match(out, /<code>inline code<\/code>/);
assert.match(out, /<a href="https:\/\/example\.com">link<\/a>/);
});
test('renderMarkdown escapes HTML in body and code', () => {
const out = renderMarkdown('A <tag> & "quote".\n\n```\n<script>alert(1)</script>\n```\n');
assert.ok(!out.includes('<tag>'), 'raw tag escaped');
assert.ok(out.includes('&lt;tag&gt;'), 'tag rendered as entity');
assert.ok(out.includes('&lt;script&gt;alert(1)&lt;/script&gt;'), 'code-fence content escaped');
});
test('render() is deterministic — two runs byte-identical', () => {
const dir = mkdtempSync(join(tmpdir(), 'render-artifact-'));
try {
const out = join(dir, 'brief.html');
const stdout = runRender(FIX_BRIEF, out);
assert.match(stdout, /render-artifact: wrote/, 'CLI should announce written path');
assert.ok(existsSync(out), 'output file must exist');
assert.ok(statSync(out).size > 0, 'output file must be non-empty');
const md = join(dir, 'plan.md');
writeFileSync(md, SAMPLE);
const out1 = render(md, join(dir, 'a.html'));
const out2 = render(md, join(dir, 'b.html'));
assert.ok(existsSync(out1) && existsSync(out2));
assert.equal(readFileSync(out1, 'utf-8'), readFileSync(out2, 'utf-8'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('render-artifact output has DOCTYPE + closing </html> + inlined <style> + inlined <script>', () => {
const dir = mkdtempSync(join(tmpdir(), 'voyage-render-'));
test('render() defaults output to <input-basename>.html', () => {
const dir = mkdtempSync(join(tmpdir(), 'render-artifact-'));
try {
const out = join(dir, 'brief.html');
runRender(FIX_BRIEF, out);
const html = readFileSync(out, 'utf-8');
assert.match(html, /^<!DOCTYPE html>/i, 'must start with DOCTYPE');
assert.match(html, /<\/html>\s*$/, 'must end with </html>');
assert.match(html, /<style>[\s\S]+<\/style>/, 'must inline <style>');
assert.match(html, /<script>[\s\S]+<\/script>/, 'must inline <script>');
const md = join(dir, 'review.md');
writeFileSync(md, '# Review\n\nok\n');
const out = render(md);
assert.equal(out, join(dir, 'review.html'));
assert.ok(existsSync(out));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('render-artifact output contains NO http:// or https:// URLs (zero-network SC1)', () => {
const dir = mkdtempSync(join(tmpdir(), 'voyage-render-'));
try {
const out = join(dir, 'brief.html');
runRender(FIX_BRIEF, out);
const html = readFileSync(out, 'utf-8');
assert.ok(!/https?:\/\//.test(html), 'output must contain no http:// or https:// URLs');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('render-artifact output title derives from frontmatter task/slug', () => {
const dir = mkdtempSync(join(tmpdir(), 'voyage-render-'));
try {
const out = join(dir, 'brief.html');
runRender(FIX_BRIEF, out);
const html = readFileSync(out, 'utf-8');
// annotation-brief.md has task: "Demo task for annotation round-trip fixture"
// and slug: annotation-brief-demo. Either should appear in <title>.
assert.match(html, /<title>[^<]*(Demo task for annotation round-trip fixture|annotation-brief-demo)[^<]*<\/title>/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('render-artifact is deterministic (two invocations -> byte-identical sha256)', () => {
const dir = mkdtempSync(join(tmpdir(), 'voyage-render-'));
try {
const a = join(dir, 'brief-a.html');
const b = join(dir, 'brief-b.html');
runRender(FIX_BRIEF, a);
runRender(FIX_BRIEF, b);
assert.strictEqual(sha256(a), sha256(b), 'same input must produce byte-identical output');
} finally {
rmSync(dir, { recursive: true, force: true });
}
test('parseArgs handles --out and positional input', () => {
assert.deepEqual(parseArgs(['x.md']), { input: 'x.md', out: null, help: false });
assert.deepEqual(parseArgs(['x.md', '--out', 'y.html']), { input: 'x.md', out: 'y.html', help: false });
assert.equal(parseArgs(['--help']).help, true);
});