ktg-plugin-marketplace/plugins/voyage/tests/playground/voyage-playground.test.mjs
Kjell Tore Guttormsen 97b6f5406e feat(voyage): add export flow + A11Y baseline — v4.2 Step 11 [skip-docs]
Closes Wave 2 (Steps 6-11) of v4.2 implementation. Playground now
delivers the complete annotation pipeline: render -> create gestures
-> sidebar -> export.

Export flow:
  - 'Eksporter batch' button in sidebar export-bar
  - Export modal with role="dialog" aria-modal="true"
  - Generated /trekrevise command-string ready to copy
  - Two paths:
      navigator.clipboard.writeText (modern) with execCommand('copy')
      legacy fallback for cross-browser support
      Blob + URL.createObjectURL download as annotated-{brief|plan|review}.md
  - buildAnnotatedMarkdown injects voyage:anchor comments above target
    lines (mirrors lib/parsers/anchor-parser.mjs addAnchors() behaviour)

Resolve-til-arkiv (Google Docs pattern, per research-06):
  - Post-export marks pending drafts as exported (NOT delete)
  - Tab 2 ('Alle revisjoner') surfaces history with revision-stamp
  - aria-live='polite' toast announces export status

A11Y baseline (per research-06 + llm-security A11Y-RAPPORT.md):
  - aria-live='polite' toast region (Step 1)
  - Skip-to-main link (.visually-hidden + #main target)
  - role='dialog' + aria-modal='true' on form modal (Step 9)
                                    on export modal (Step 11)
  - role='tablist' / role='tab' / aria-selected / tabindex roving (Step 10)
  - aria-controls + aria-labelledby on tabpanels
  - aria-pressed on intent buttons (radiogroup-like)
  - aria-expanded + aria-controls on sidebar FAB
  - aria-hidden='true' on decorative SVG paths
  - aria-label on icon-only buttons
  - .visually-hidden labels for textarea + clipboard helper

Test coverage: tests/playground/voyage-playground.test.mjs +4 cases —
aria-live='polite', Skip to main, Blob, clipboard.writeText.

Verify: node --test tests/playground/voyage-playground.test.mjs ->
22 pass / 0 fail.
Full npm test: 596 pass / 0 fail / 2 skipped (Docker).

Refs plan.md Step 11 + research-06 + llm-security A11Y baseline.
2026-05-09 15:27:01 +02:00

148 lines
6.8 KiB
JavaScript

// tests/playground/voyage-playground.test.mjs
// Filesystem + content tests for v4.2 voyage playground.
// Pure existence + grep checks — no browser launch.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, statSync, readFileSync, readdirSync } 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 PLAYGROUND = join(ROOT, 'playground');
const HTML = join(PLAYGROUND, 'voyage-playground.html');
const VENDOR = join(PLAYGROUND, 'vendor', 'playground-design-system');
const MANIFEST = join(VENDOR, 'MANIFEST.json');
test('voyage-playground.html exists and has nonzero size', () => {
assert.ok(existsSync(HTML), 'voyage-playground.html must exist');
assert.ok(statSync(HTML).size > 0, 'must have content');
});
test('voyage-playground.html has DOCTYPE + html closing tag', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /^<!DOCTYPE html>/i);
assert.match(text, /<\/html>\s*$/);
});
test('voyage-playground.html does NOT contain external (http/https) URLs', () => {
// SC1 zero-network constraint: all assets must be relative to ./vendor/
const text = readFileSync(HTML, 'utf-8');
assert.ok(!/https?:\/\//.test(text), 'no external URLs allowed in playground HTML');
});
test('voyage-playground.html does NOT contain literal `marked` (renderer ban per risk-assessor H1)', () => {
const text = readFileSync(HTML, 'utf-8');
// marked is disqualified by issue #3515; markdown-it locked instead
// Allow comments mentioning "marked" as an explanatory artifact, but no actual import paths
assert.ok(!/from ['"].*marked/.test(text), 'no import from marked');
assert.ok(!/<script[^>]*marked\.min\.js/.test(text), 'no marked script tag');
});
test('voyage-playground.html includes skip-to-main link (A11Y baseline)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /Skip to main content/);
});
test('voyage-playground.html declares aria-live region', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /aria-live="polite"/);
});
test('playground/vendor/playground-design-system/MANIFEST.json exists and parses as JSON with expected keys', () => {
assert.ok(existsSync(MANIFEST), 'MANIFEST.json must be present from sync-design-system.mjs');
const obj = JSON.parse(readFileSync(MANIFEST, 'utf-8'));
assert.ok(obj.source_commit, 'source_commit field required');
assert.ok(obj.sync_date, 'sync_date field required');
assert.ok(obj.files && typeof obj.files === 'object', 'files map required');
});
test('playground/vendor/playground-design-system/ contains expected DS files', () => {
const files = readdirSync(VENDOR);
for (const expected of ['tokens.css', 'base.css', 'components.css', 'fonts.css', 'print.css']) {
assert.ok(files.includes(expected), `${expected} expected in vendor/`);
}
assert.ok(files.includes('fonts'), 'fonts/ subdirectory expected');
});
// --- Step 8 — render pipeline + vendored libs ---------------------------
const PLAYGROUND_LIB = join(PLAYGROUND, 'lib');
test('voyage-playground.html references markdown-it (Step 8 render pipeline)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /markdown-it/, 'voyage-playground.html should load/initialize markdown-it');
});
test('voyage-playground.html references highlight.js (Step 8 syntax highlighting)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /highlight/, 'voyage-playground.html should load highlight.js');
});
test('voyage-playground.html includes paste-import-row (Step 8 import affordance)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /paste-import-row/, 'voyage-playground.html should include the paste-import-row pattern');
});
test('voyage-playground.html declares voyage_ann_ localStorage key prefix (Step 8 risk-assessor H7)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /voyage_ann_/, 'localStorage key prefix voyage_ann_<project>__<file> must appear');
});
test('playground/lib/ contains vendored markdown-it + front-matter + highlight bundles', () => {
for (const f of ['markdown-it.min.js', 'markdown-it-front-matter.min.js', 'highlight.min.js', 'VENDOR-MANIFEST.json']) {
assert.ok(existsSync(join(PLAYGROUND_LIB, f)), `playground/lib/${f} expected from vendor-playground-libs.mjs`);
}
});
// --- Step 9 — annotation creation gestures + form modal ---------------
test('voyage-playground.html declares aria-modal="true" (Step 9 form modal A11Y)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /aria-modal="true"/, 'form modal must carry aria-modal="true"');
});
test('voyage-playground.html declares ANN- anchor-ID prefix (Step 9 ID generation)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /ANN-/, 'sequential ANN-NNNN ID generation must appear in playground JS');
});
test('voyage-playground.html declares 300ms grace constant (Step 9 adder-popup grace)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /300\s*ms|GRACE_MS\s*=\s*300|ADDER_GRACE_MS/i, '300ms grace period for adder-popup must be present');
});
// --- Step 10 — sidebar with tabs + critique-card-list ----------------
test('voyage-playground.html includes role="tablist" (Step 10 sidebar tabs A11Y)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /role="tablist"/, 'sidebar must declare role="tablist" for A11Y');
});
test('voyage-playground.html declares tabindex (Step 10 focus management)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /tabindex/i, 'sidebar tabs must use tabindex for keyboard focus management');
});
// --- Step 11 — export flow + A11Y baseline -----------------------------
test('voyage-playground.html declares aria-live="polite" toast region (Step 11 A11Y)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /aria-live="polite"/, 'aria-live="polite" toast region required for status announcements');
});
test('voyage-playground.html includes Skip to main link (Step 11 A11Y baseline)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /Skip to main/, 'Skip to main content link required for keyboard A11Y');
});
test('voyage-playground.html uses Blob for download flow (Step 11 export)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /\bnew Blob\b/, 'Blob download path required for annotated.md export');
});
test('voyage-playground.html uses clipboard.writeText for copy flow (Step 11 export)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /clipboard\.writeText/, 'navigator.clipboard.writeText path required for command-copy');
});