// tests/scripts/annotate.test.mjs // Covers scripts/annotate.mjs — the v5.0.3 operator-annotation HTML // generator. UX modelled on claude-code-100x/build-site.js (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 or "\n---\n\n# Foo\n'; const html = buildHtml('/abs/path/brief.md', md); const titleMatch = html.match(/([\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], /<script>/, '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 `<` → `<`). 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('<img'), 'hostile <img> must be escaped to <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 claude-code-100x/build-site.js: // - Pencil-toggle button (annotation mode on/off) // - Form popover with three intent buttons (Fiks/Endre/Spørsmål) // - 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 Fiks intent button'); assert.ok(html.includes('data-intent="endre"'), 'must have Endre intent button'); assert.ok(html.includes('data-intent="spørsmål"'), 'must have Spørsmål 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('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=/); });