diff --git a/plugins/voyage/tests/fixtures/playground/v43-export-bundle.json b/plugins/voyage/tests/fixtures/playground/v43-export-bundle.json new file mode 100644 index 0000000..200fa0b --- /dev/null +++ b/plugins/voyage/tests/fixtures/playground/v43-export-bundle.json @@ -0,0 +1,25 @@ +{ + "schema_version": 1, + "exported_at": "2026-05-10T18:00:00Z", + "target_artifact": "plan", + "target_filename": "annotated-plan.md", + "annotations": [ + { + "id": "ANN-0001", + "target_artifact": "plan", + "target_anchor": "step-1-sentinel-touch", + "intent": "question", + "comment": "Should this sentinel use a deterministic timestamp?", + "timestamp": "2026-05-10T18:01:00Z" + }, + { + "id": "ANN-0002", + "target_artifact": "plan", + "target_anchor": "step-2-sentinel-touch-paired", + "intent": "fix", + "comment": "Step 2 manifest should reference Step 1 in must_contain.", + "timestamp": "2026-05-10T18:02:00Z" + } + ], + "annotation_digest": "PLACEHOLDER_OVERWRITTEN_AT_TEST_TIME" +} diff --git a/plugins/voyage/tests/fixtures/playground/v43-plan-pre-annotate.md b/plugins/voyage/tests/fixtures/playground/v43-plan-pre-annotate.md new file mode 100644 index 0000000..f334698 --- /dev/null +++ b/plugins/voyage/tests/fixtures/playground/v43-plan-pre-annotate.md @@ -0,0 +1,69 @@ +--- +plan_version: 1.7 +profile: balanced +revision: 0 +--- + +# v4.3 fixture — pre-annotate plan + +Minimal plan used by Group C tests to seed an annotated round-trip. +Two anchors target `Step 1` and `Step 2` so the export-bundle has at +least 2 ANN-IDs to canonicalize for `annotation_digest`. + +## Context + +Fixture only — not executed. Anchors below match the v4.2 anchor format +`` and +sit on their own line surrounded by blank lines (block-boundary rule). + +## Implementation Plan + +### Step 1: Sentinel touch + + + +- **Files:** `tmp/sentinel-1.txt` (new) +- **Changes:** Create the sentinel file with the literal content "step-1". +- **Reuses:** none. +- **Test first:** none — sentinel-only step. +- **Verify:** `test -f tmp/sentinel-1.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 1"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-1.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 1" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 2: Sentinel touch (paired) + + + +- **Files:** `tmp/sentinel-2.txt` (new) +- **Changes:** Create the sentinel file with the literal content "step-2". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-2.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 2"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-2.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 2" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +## Verification + +- Both sentinel files exist after execution. diff --git a/plugins/voyage/tests/integration/annotation-export-schema.test.mjs b/plugins/voyage/tests/integration/annotation-export-schema.test.mjs index b8570d5..bafdb97 100644 --- a/plugins/voyage/tests/integration/annotation-export-schema.test.mjs +++ b/plugins/voyage/tests/integration/annotation-export-schema.test.mjs @@ -161,3 +161,97 @@ test('isProjectPathSafe — fixture FileList survives filter to brief.md only (v const survivors = fixture.filter(isProjectPathSafe); assert.deepEqual(survivors, ['brief.md'], 'only brief.md should survive the filter'); }); + +// ===================================================================== +// Group C — v4.3 Step 29 export-bundle schema validation (Wave 7). +// +// Verifies the JSON shape that /trekrevise consumes when an operator +// applies a playground-exported annotation batch back into the source +// artifact. The shape comes from buildAnnotatedMarkdown + +// downloadAnnotatedBlob (markdown export — primary) but the +// trekrevise-side reader (lib/parsers/anchor-parser.mjs + +// lib/parsers/annotation-digest.mjs) accepts a parallel JSON payload +// with the same canonical fields. The fixture in +// tests/fixtures/playground/v43-export-bundle.json is the contract. +// ===================================================================== + +import { computeAnnotationDigest } from '../../lib/parsers/annotation-digest.mjs'; + +const FIXTURES = join(ROOT, 'tests', 'fixtures', 'playground'); +const BUNDLE = join(FIXTURES, 'v43-export-bundle.json'); +const PLAN_FIXTURE = join(FIXTURES, 'v43-plan-pre-annotate.md'); + +test('Group C.1 — export bundle JSON parses (v4.3 Step 29)', () => { + const raw = readFileSync(BUNDLE, 'utf-8'); + const bundle = JSON.parse(raw); // throws on parse error + assert.equal(typeof bundle, 'object', 'bundle must be object'); + assert.ok(bundle !== null, 'bundle must not be null'); +}); + +test('Group C.2 — export bundle has required top-level keys (v4.3 Step 29)', () => { + const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); + for (const key of ['schema_version', 'exported_at', 'target_artifact', 'annotations', 'annotation_digest']) { + assert.ok(key in bundle, `required key missing: ${key}`); + } + assert.equal(bundle.schema_version, 1, 'schema_version must be 1'); + assert.ok(['brief', 'plan', 'review', 'artifact'].includes(bundle.target_artifact), 'target_artifact must be one of brief|plan|review|artifact'); + assert.ok(Array.isArray(bundle.annotations), 'annotations must be array'); +}); + +test('Group C.3 — every annotation has id + target_anchor + intent (v4.3 Step 29)', () => { + const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); + assert.ok(bundle.annotations.length >= 2, 'fixture must include ≥2 annotations'); + for (const a of bundle.annotations) { + assert.match(a.id, /^ANN-\d{4}$/, `id ${a.id} must match ANN-NNNN`); + assert.equal(typeof a.target_anchor, 'string', 'target_anchor must be string'); + assert.ok(VOYAGE_ANCHOR_INTENTS.includes(a.intent), `intent ${a.intent} must be one of fix|change|question|block`); + } +}); + +test('Group C.4 — empty-export edge case produces valid bundle (v4.3 Step 29)', () => { + // Mirror the export-shape with zero annotations (download button still works + // — produces a bundle with annotations=[] and digest of empty canonical). + const emptyBundle = { + schema_version: 1, + exported_at: '2026-05-10T00:00:00Z', + target_artifact: 'brief', + target_filename: 'annotated-brief.md', + annotations: [], + annotation_digest: computeAnnotationDigest([]), + }; + // Round-trip: serialize + parse must equal + const roundTripped = JSON.parse(JSON.stringify(emptyBundle)); + assert.deepEqual(roundTripped, emptyBundle, 'empty bundle must round-trip'); + assert.equal(emptyBundle.annotations.length, 0, 'annotations array must be empty'); + assert.match(emptyBundle.annotation_digest, /^[0-9a-f]{16}$/, 'digest must be 16-hex-char prefix'); +}); + +test('Group C.5 — annotation_digest is order-independent (v4.3 Step 29)', () => { + const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); + const ascending = computeAnnotationDigest(bundle.annotations); + const reversed = computeAnnotationDigest([...bundle.annotations].reverse()); + assert.equal(ascending, reversed, 'digest must be deterministic regardless of input order'); +}); + +// SC6 — annotation_digest SHA-256 validity (per scope-guardian SC-GAP-3). +test('Group C.6 — annotation_digest is valid 16-hex-char SHA-256 prefix (v4.3 Step 29 / SC-GAP-3)', () => { + const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); + // Recompute the digest server-side and verify it matches the canonical form. + // The fixture stores PLACEHOLDER_OVERWRITTEN_AT_TEST_TIME — the canonical + // value comes from computeAnnotationDigest(annotations). + const canonical = computeAnnotationDigest(bundle.annotations); + assert.match(canonical, /^[0-9a-f]{16}$/, 'digest must be 16-hex-char prefix of SHA-256'); + // Determinism: two calls with the same input MUST produce identical output + const second = computeAnnotationDigest(bundle.annotations); + assert.equal(canonical, second, 'digest must be deterministic'); +}); + +test('Group C.7 — fixture plan parses with anchors at block boundaries (v4.3 Step 29)', () => { + const planText = readFileSync(PLAN_FIXTURE, 'utf-8'); + // Frontmatter declares revision: 0 — the entry point for /trekrevise + assert.match(planText, /^---\s*$/m, 'YAML frontmatter required'); + assert.match(planText, /^revision:\s*0\s*$/m, 'revision: 0 required (round-trip seed)'); + // Both anchors present in canonical format + assert.match(planText, //, 'ANN-0001 anchor required'); + assert.match(planText, //, 'ANN-0002 anchor required'); +}); diff --git a/plugins/voyage/tests/playground/voyage-playground-structure.test.mjs b/plugins/voyage/tests/playground/voyage-playground-structure.test.mjs new file mode 100644 index 0000000..aff31b1 --- /dev/null +++ b/plugins/voyage/tests/playground/voyage-playground-structure.test.mjs @@ -0,0 +1,75 @@ +// tests/playground/voyage-playground-structure.test.mjs +// v4.3 Step 29 — Group B structural assertions for the voyage playground. +// +// Group B verifies that DS-token classes, theme-toggle wiring, and the +// sidebar-tab/keyboard pattern are present in voyage-playground.html. +// All assertions are static-grep (no DOM, no browser). Companion to: +// - tests/playground/voyage-playground.test.mjs (Group A — SC1/3/6/7) +// - tests/integration/annotation-export-schema.test.mjs (Group C — SC6) + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..', '..'); +const HTML = join(ROOT, 'playground', 'voyage-playground.html'); + +// --- DS-token classes present ---------------------------------------- +test('Group B — DS badge--scope-voyage class present (v4.3 Step 29)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /badge--scope-voyage/, 'badge--scope-voyage required'); +}); + +test('Group B — DS guide-panel + key-stats classes present (v4.3 Step 29)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /class="guide-panel/, 'guide-panel base class required'); + assert.match(text, /class="key-stats/, 'key-stats class required'); +}); + +test('Group B — DS fleet-grid + fleet-tile classes present (v4.3 Step 29)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /class="fleet-grid"/, 'fleet-grid required'); + assert.match(text, /class="fleet-tile/, 'fleet-tile required'); +}); + +// --- Theme-toggle wired --------------------------------------------- +test('Group B — theme-toggle button has data-action attribute (v4.3 Step 29)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /data-action="toggle-theme"/, 'data-action=toggle-theme required'); + assert.match(text, /aria-label="Bytt tema"/, 'theme-toggle aria-label required'); +}); + +test('Group B — wireThemeToggle handler exists (v4.3 Step 29)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /function\s+wireThemeToggle\s*\(/, 'wireThemeToggle function required'); +}); + +test('Group B — theme persistence to localStorage (v4.3 Step 29)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /(voyage-theme|voyage_theme)/, 'theme localStorage key required'); +}); + +// --- Sidebar-tab / keyboard pattern --------------------------------- +test('Group B — sidebar role=tablist with aria-selected (v4.3 Step 29)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /role="tablist"/, 'role=tablist required'); + assert.match(text, /aria-selected="(true|false)"/, 'aria-selected attribute required'); +}); + +test('Group B — keyboard nav J/K + Esc handlers wired (v4.3 Step 29)', () => { + const text = readFileSync(HTML, 'utf-8'); + // Step 20 — J/K navigation + Esc dismiss + assert.match(text, /(keydown|keypress|keyup)/, 'keyboard event listener required'); + assert.match(text, /(['"]j['"]|['"]J['"]|KeyJ)/, 'J navigation required'); + assert.match(text, /(['"]k['"]|['"]K['"]|KeyK)/, 'K navigation required'); +}); + +test('Group B — anchor-ID format ANN-NNNN matches Node-side parser (v4.3 Step 29)', () => { + const text = readFileSync(HTML, 'utf-8'); + // Mirror of lib/parsers/anchor-parser.mjs ID_RE (^ANN-\d{4}$) + assert.match(text, /\/\^ANN-\\d\{4\}\$\//, 'VOYAGE_ANCHOR_ID_RE pattern required'); + assert.match(text, /function\s+parseAnchor\s*\(/, 'parseAnchor function required'); +});