v5.0.0 added a read-only HTML render. v5.0.1 deleted that and pointed at /playground document-critique, which pre-generates Claude's suggestions and asks the operator to approve/reject them. The operator asked for the opposite — a surface where THEY drive every annotation. v5.0.2 lands it. scripts/annotate.mjs (~430 lines, zero deps) takes any artifact .md and writes a self-contained HTML next to it. The HTML renders the document with line numbers, lets the operator click any line to add their own note (inline textarea, save with Cmd+Enter or button), keeps a sidebar of all notes (editable + deletable + persisted in localStorage per artifact path), and exposes Copy Prompt to gather every note into one structured prompt. Operator copies, pastes back, Claude revises the .md. The three producing commands now run annotate.mjs at their last step and print the file:// link with explicit "Click any line to add YOUR OWN note" instructions. The v5.0.1 /playground document-critique line is gone. npm test green: 516 tests, 514 pass, 0 fail, 2 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
167 lines
7.7 KiB
JavaScript
167 lines
7.7 KiB
JavaScript
// tests/scripts/annotate.test.mjs
|
|
// Covers scripts/annotate.mjs — the v5.0.2 operator-annotation HTML
|
|
// generator. The producing commands call it to print a file:// link the
|
|
// operator opens in a browser, where they click lines, write their own
|
|
// notes, copy a structured prompt, and paste back into Claude.
|
|
//
|
|
// What we pin:
|
|
// • Output is a complete, self-contained HTML document.
|
|
// • Zero external network references in the static HTML.
|
|
// • The embedded inline <script> parses as valid JavaScript.
|
|
// • Source document content + artifact path are embedded verbatim
|
|
// (so the browser-side app can render exactly the same lines).
|
|
// • HTML special chars and code-fence content are escaped — no raw
|
|
// <script>-injection from the source .md.
|
|
// • render() is deterministic — two runs produce byte-identical output.
|
|
// • Default output path is <input-basename>.html next to the input.
|
|
|
|
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, 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);
|
|
// Strip the script section (the embedded clipboard-API safe link list may
|
|
// technically include example.com in inline content; that's allowed).
|
|
// Static HTML structure must not have any <link href=...> or <script src=...>.
|
|
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');
|
|
// Function() parses but doesn't execute references to document/localStorage,
|
|
// so a SyntaxError here is the only failure mode we want to surface.
|
|
assert.doesNotThrow(() => new Function(m[1]), 'inline script must parse without SyntaxError');
|
|
});
|
|
|
|
test('buildHtml embeds the artifact path and source lines verbatim', () => {
|
|
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');
|
|
// The DOC_LINES JSON literal must contain the actual content of the source.
|
|
assert.match(html, /DOC_LINES\s*=\s*\[/);
|
|
assert.ok(html.includes('Operator-annotation smoke test'),
|
|
'source document content must round-trip into the embedded DOC_LINES');
|
|
assert.ok(html.includes('inline code'),
|
|
'inline content from the source markdown must appear in DOC_LINES');
|
|
});
|
|
|
|
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);
|
|
// The raw <script> from the title must NOT appear unescaped anywhere
|
|
// outside our own inline <script>.
|
|
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.ok(titleMatch[1].includes('<script>') || titleMatch[1].includes('alert'),
|
|
'title must be HTML-escaped');
|
|
});
|
|
|
|
test('buildHtml escapes source-document content so a malicious .md cannot inject code', () => {
|
|
// The embedded DOC_LINES is a JSON literal, which already escapes anything
|
|
// dangerous. But the script then renders content into the DOM — the inline
|
|
// renderer escapes everything before DOM-insertion. This test pins the
|
|
// JSON-level escaping invariant.
|
|
const md = '# Heading\n\n<img src=x onerror="alert(1)">\n';
|
|
const html = buildHtml('/abs/path/brief.md', md);
|
|
// The dangerous attribute must NOT appear as a live HTML construct outside
|
|
// a JSON string. Easiest pin: the literal substring should appear inside
|
|
// quoted JSON (with backslash-escaped quotes), and the raw construct
|
|
// `onerror="alert(1)"` should not appear with unescaped double quotes
|
|
// outside the script-embedded JSON literal.
|
|
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
|
assert.ok(m, 'must contain script');
|
|
// The JSON encoding of `"` is `\"`, so we expect at least the escaped form.
|
|
assert.ok(m[1].includes('onerror=\\"alert(1)\\"') || m[1].includes("onerror=\\'alert(1)\\'"),
|
|
'dangerous source attribute should be JSON-escaped in DOC_LINES');
|
|
// Static HTML outside <script> must not carry the live attribute either.
|
|
const outsideScript = html.replace(/<script>[\s\S]*?<\/script>/, '');
|
|
assert.ok(!/onerror\s*=\s*"alert/i.test(outsideScript),
|
|
'static HTML must not carry a live onerror attribute');
|
|
});
|
|
|
|
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 output contains the operator-driven copy-paste loop affordances', () => {
|
|
// Pin the user-visible affordances that v5.0.2 promised:
|
|
// - Per-line click target (gutter '+' marker)
|
|
// - "Your annotations" sidebar
|
|
// - "Copy Prompt" button
|
|
// - "Clear all" button (so the operator can reset state)
|
|
// - localStorage persistence
|
|
const html = buildHtml('/abs/path/brief.md', SAMPLE);
|
|
assert.ok(html.includes('Your annotations'), 'must show a "Your annotations" sidebar');
|
|
assert.ok(html.includes('Copy Prompt'), 'must have a "Copy Prompt" button');
|
|
assert.ok(html.includes('Clear all'), 'must have a "Clear all" affordance');
|
|
assert.ok(html.includes('localStorage'), 'must persist state in localStorage');
|
|
assert.ok(html.includes('Click any line'), 'must tell the operator how to start');
|
|
});
|