voyage/tests/scripts/annotate.test.mjs
Kjell Tore Guttormsen af5de3fcb8
fix(ui): English labels where Voyage shows Norwegian to every user
Operator decision 56 (2026-10-17): the product shows English.

- Annotation HTML: the intent buttons, sidebar badges and the copied
  prompt read Fix / Change / Question. The stored keys stay
  fiks / endre / spørsmål, so annotations saved earlier still load.
- /trekreview project-memory approval: "Add the line" / "Not now".
- README, CLAUDE.md, docs/architecture.md, package.json and plugin.json
  describe what the product shows; README's hollow-answer line and the
  "anslag" in the profile section are English; a Norwegian code comment
  in profile-validator.mjs translated.

The Norwegian hollow-answer list in approval-log.mjs and trekbrief.md
stays: it matches what a user types. Both new tests failed on the code
before the change. Suite 2606 (2604/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:00:34 +02:00

221 lines
10 KiB
JavaScript

// tests/scripts/annotate.test.mjs
// Covers scripts/annotate.mjs — the v5.0.3 operator-annotation HTML
// generator. UX modelled on an existing annotation surface (pencil
// toggle, intent buttons, form popover, selection-anchoring, localStorage
// persistence, structured markdown export).
//
// What we pin:
// • Output is a complete, self-contained HTML document.
// • No external <link href=> or <script src=>.
// • The embedded inline <script> parses as valid JavaScript.
// • The artifact path is embedded (used as the localStorage key + prompt context).
// • The markdown source is rendered to proper HTML (h1/p/li etc.), not as raw lines.
// • HTML metacharacters in the title are escaped (XSS).
// • Inline content from a hostile .md never appears as a live attribute.
// • render() is deterministic — two runs produce byte-identical output.
// • Default output path is <input-basename>.html next to the input.
// • The v5.0.3 affordances are wired into the HTML: pencil-toggle, form
// popover with three intent buttons (Fix/Change/Question), annotations
// sidebar, Copy Prompt button, Clear all, localStorage persistence.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildHtml, renderMarkdown, render, parseArgs } from '../../scripts/annotate.mjs';
const SAMPLE = `---
type: trekplan
plan_version: "1.7"
task: "Operator-annotation smoke test"
slug: annotate-smoke
---
# Operator-annotation smoke test
This is a paragraph with **bold**, \`inline code\`, and a [link](https://example.com).
## Steps
- first item
- second item
\`\`\`js
const x = 1;
\`\`\`
> a blockquote
`;
test('buildHtml produces a complete self-contained HTML document', () => {
const html = buildHtml('/abs/path/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');
assert.ok(html.includes('<script>'), 'must inline the app script');
});
test('buildHtml has zero external network references in static HTML', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
assert.ok(!/<link[^>]+href\s*=/i.test(html), 'no external <link href> stylesheets');
assert.ok(!/<script[^>]+src\s*=/i.test(html), 'no external <script src>');
});
test('buildHtml embeds the inline <script> as parseable JavaScript', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
const m = html.match(/<script>([\s\S]*?)<\/script>/);
assert.ok(m, 'must contain a <script> block');
assert.doesNotThrow(() => new Function(m[1]), 'inline script must parse without SyntaxError');
});
test('buildHtml embeds the artifact path (used as localStorage key + prompt context)', () => {
const html = buildHtml('/abs/projects/2026-05-13-foo/brief.md', SAMPLE);
assert.ok(html.includes('/abs/projects/2026-05-13-foo/brief.md'),
'artifact path must appear in the HTML so the script can use it as the localStorage key + prompt context');
});
test('buildHtml renders the markdown source to proper article HTML', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
// Headings, paragraph content, list items, code fence — all present as HTML.
assert.ok(html.includes('<h1 data-anchor-id='), 'top-level heading rendered as <h1>');
assert.ok(html.includes('<h2 data-anchor-id='), '## heading rendered as <h2>');
assert.ok(html.includes('Operator-annotation smoke test'), 'h1 text preserved');
assert.ok(html.includes('<li data-anchor-id='), 'list items rendered with anchor ids');
assert.ok(html.includes('first item'), 'list content preserved');
assert.ok(html.includes('<pre data-anchor-id='), 'code fence rendered with anchor');
assert.ok(html.includes('const x = 1;'), 'code fence body preserved (escaped)');
assert.ok(html.includes('<blockquote data-anchor-id='), 'blockquote rendered with anchor');
});
test('buildHtml escapes HTML metacharacters in the title (XSS surface)', () => {
const md = '---\ntype: trekbrief\ntask: "<script>alert(1)</script>"\n---\n\n# Foo\n';
const html = buildHtml('/abs/path/brief.md', md);
const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/);
assert.ok(titleMatch, 'must have a title');
assert.ok(!titleMatch[1].includes('<script>'), 'title must not carry a raw <script> tag');
assert.match(titleMatch[1], /&lt;script&gt;/, 'title must be HTML-escaped');
});
test('hostile inline content cannot inject as live HTML attributes', () => {
const md = '# Heading\n\nA paragraph with <img src=x onerror="alert(1)"> embedded.\n';
const html = buildHtml('/abs/path/brief.md', md);
// The article body must not carry a live onerror="..." attribute (the renderer
// HTML-escapes everything in the body, so `<` → `&lt;`).
const articleMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/);
assert.ok(articleMatch, 'must have article body');
assert.ok(!/onerror\s*=\s*"alert/i.test(articleMatch[1]),
'article body must not carry a live onerror attribute');
assert.ok(articleMatch[1].includes('&lt;img'),
'hostile <img> must be escaped to &lt;img');
});
test('render() is deterministic — two runs byte-identical', () => {
const dir = mkdtempSync(join(tmpdir(), 'claude-annotate-'));
try {
const md = join(dir, 'plan.md');
writeFileSync(md, SAMPLE);
const a = render(md, join(dir, 'a.html'));
const b = render(md, join(dir, 'b.html'));
assert.ok(existsSync(a) && existsSync(b));
assert.equal(readFileSync(a, 'utf-8'), readFileSync(b, 'utf-8'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('render() defaults output to <input-basename>.html next to input', () => {
const dir = mkdtempSync(join(tmpdir(), 'claude-annotate-'));
try {
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('parseArgs handles --out, positional input, and --help', () => {
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);
});
test('buildHtml wires the v5.0.3 operator-driven annotation affordances', () => {
// Pin every UX-critical affordance modelled on the reference annotation surface:
// - Pencil-toggle button (annotation mode on/off)
// - Form popover with three intent buttons (Fix/Change/Question)
// - Annotations sidebar (Your annotations + Clear all + Copy Prompt)
// - Selection capture (window.getSelection())
// - Section context auto-detection (findSection)
// - localStorage persistence (voyage-annotate:v2:...)
// - Annotatable elements (data-anchor-id on h1-h6, p, li, td, blockquote, pre)
const html = buildHtml('/abs/path/brief.md', SAMPLE);
// Toggle
assert.ok(html.includes('ann-toggle'), 'must have the pencil-toggle button');
assert.ok(html.includes('Annotation mode: ON'), 'must label the toggle state');
// Form + intents (the three CSS classes for selected state)
assert.ok(html.includes('data-intent="fiks"'), 'must have the Fix intent button');
assert.ok(html.includes('data-intent="endre"'), 'must have the Change intent button');
assert.ok(html.includes('data-intent="spørsmål"'), 'must have the Question intent button');
// Form popover
assert.ok(html.includes('ann-form'), 'must have the form popover');
assert.ok(html.includes('ann-form-comment'), 'must have a comment textarea');
assert.ok(html.includes('ann-form-save'), 'must have a Save button');
// Sidebar
assert.ok(html.includes('ann-panel'), 'must have the annotations sidebar');
assert.ok(html.includes('Your annotations'), 'sidebar must title the list');
assert.ok(html.includes('Clear all'), 'sidebar must offer Clear all');
assert.ok(html.includes('Copy Prompt'), 'sidebar must offer Copy Prompt');
// Selection + section
assert.ok(html.includes('window.getSelection'), 'must capture selection');
assert.ok(html.includes('findSection'), 'must auto-detect section context');
// Persistence
assert.ok(html.includes("'voyage-annotate:v2:'"), 'must use the v2 localStorage key prefix');
// Anchor coverage
const anchors = (html.match(/data-anchor-id="anch-/g) || []).length;
assert.ok(anchors >= 5, 'must emit data-anchor-id on enough elements (got ' + anchors + ')');
});
test('buildHtml shows the intents in English and keeps the stored intent keys (operator decision 56)', () => {
const html = buildHtml('/abs/path/brief.md', SAMPLE);
// What the reader sees: button text, sidebar badge labels, the copied prompt.
assert.ok(html.includes('data-intent="fiks"') && html.includes('>Fix</button>'), 'Fix button over the fiks key');
assert.ok(html.includes('data-intent="endre"') && html.includes('>Change</button>'), 'Change button over the endre key');
assert.ok(html.includes('data-intent="spørsmål"') && html.includes('>Question</button>'), 'Question button over the spørsmål key');
assert.ok(html.includes("fiks: 'Fix', endre: 'Change', 'spørsmål': 'Question'"), 'sidebar and prompt labels are English');
assert.ok(html.includes('**Fix**') && html.includes('**Change**') && html.includes('**Question**'), 'the copied prompt names the English intents');
for (const word of ['>Fiks<', '>Endre<', '>Spørsmål<', "'Fiks'", "'Endre'", "'Spørsmål'", '**Fiks**', '**Endre**', '**Spørsmål**']) {
assert.ok(!html.includes(word), `no Norwegian label left: ${word}`);
}
});
test('renderMarkdown produces headings, lists, code, table, blockquote with anchors', () => {
const html = renderMarkdown(`# H1
## H2
- a
- b
1. one
2. two
| Col | Val |
|-----|-----|
| x | 1 |
\`\`\`
plain code
\`\`\`
> quote
`);
assert.match(html, /<h1 data-anchor-id="anch-0">H1<\/h1>/);
assert.match(html, /<h2 data-anchor-id="anch-1">H2<\/h2>/);
assert.match(html, /<ul><li data-anchor-id=/);
assert.match(html, /<ol><li data-anchor-id=/);
assert.match(html, /<table>[\s\S]*<th data-anchor-id=/);
assert.match(html, /<pre data-anchor-id=/);
assert.match(html, /<blockquote data-anchor-id=/);
});