ktg-plugin-marketplace/plugins/voyage/tests/playground/voyage-playground.test.mjs
Kjell Tore Guttormsen 224517f205 feat(voyage): implement J/K keyboard navigation + Esc + aria-live announces
Step 20 of v4.3 playground plan. Document-level keydown handler:
  - J = next annotation (next sorted-by-line draft, wraps)
  - K = prev annotation (wraps)
  - ] = toggle sidebar visibility
  - Escape = clear active anchor + sidebar list selection

Active annotation gets yellow-tint (Step 18 setActiveAnchor) and the
matching gutter-badge receives focus + scrollIntoView. Aria-live region
announces position + target: "Annotering 3 av 7: <target> — <snippet>".

Skips input/textarea/select/contenteditable so playground never steals
keystrokes from form fields. Modifier keys (Ctrl/Alt/Meta) pass through
to browser shortcuts. Wired in init() after dashboard nav.

Trace: SC2 (WCAG AA keyboard), SC6, research/04 Dim 2 + Insight 5 +
Recommendation keyboard-navigation.
2026-05-10 17:10:59 +02:00

301 lines
15 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');
// v4.3 Step 10 — Norwegian skip-link: "Hopp til hovedinnhold"
assert.match(text, /class="skip-link"[^>]*href="#main-content"/);
assert.match(text, /Hopp til hovedinnhold/);
});
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');
// v4.3 Step 10 — text re-localized to Norwegian; semantic check via class.
assert.match(text, /class="skip-link"/, 'skip-link class 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');
});
// --- v4.3 Sesjon 3 — Step 14 (dashboard) + Step 15 (drill-down + URL routing) ----
test('voyage-playground.html declares fleet-grid container (v4.3 Step 14 dashboard)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /fleet-grid/, 'fleet-grid container required for dashboard layout');
});
test('voyage-playground.html declares fleet-tile (v4.3 Step 14 dashboard)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /fleet-tile/, 'fleet-tile required for per-artifact dashboard cell');
});
test('voyage-playground.html declares renderDashboard JS function (v4.3 Step 14)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function renderDashboard\b/, 'renderDashboard function required');
});
test('voyage-playground.html declares dashboard status vocabulary (v4.3 Step 14)', () => {
const text = readFileSync(HTML, 'utf-8');
// Status vocabulary per plan: complete, in-progress, blocked, missing, stale
assert.match(text, /'complete'/, 'status complete required');
assert.match(text, /'in-progress'/, 'status in-progress required');
assert.match(text, /'blocked'/, 'status blocked required');
assert.match(text, /'missing'/, 'status missing required');
assert.match(text, /'stale'/, 'status stale required');
});
test('voyage-playground.html declares renderArtifactDetail JS function (v4.3 Step 15 drill-down)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function renderArtifactDetail\b/, 'renderArtifactDetail function required for drill-down');
});
test('voyage-playground.html declares URLSearchParams routing (v4.3 Step 15)', () => {
const text = readFileSync(HTML, 'utf-8');
// Presence-only: URLSearchParams already used at line 810 for project-key
// derivation; Step 15 adds ?project= dashboard/detail routing.
assert.match(text, /URLSearchParams/, 'URLSearchParams required for ?project= routing');
});
test('voyage-playground.html declares data-action="back-to-dashboard" (v4.3 Step 15 back-nav)', () => {
const text = readFileSync(HTML, 'utf-8');
// Stricter than Step 14 wording — must appear as data-action attribute
// somewhere in the JS template, not only in HTML comments.
assert.match(text, /data-action="back-to-dashboard"/, 'data-action="back-to-dashboard" required for return-nav handler');
});
test('voyage-playground.html declares popstate handler (v4.3 Step 15 back/forward)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /'popstate'/, 'popstate listener required for browser back/forward');
});
test('voyage-playground.html declares VOYAGE_ANCHOR_RE constant (v4.3 Step 16 anchor allowlist)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /VOYAGE_ANCHOR_RE\s*=\s*\/\^/, 'VOYAGE_ANCHOR_RE regex constant required');
assert.match(text, /VOYAGE_ANCHOR_ATTR_RE\s*=\s*\//, 'VOYAGE_ANCHOR_ATTR_RE constant required');
assert.match(text, /VOYAGE_ANCHOR_ID_RE\s*=\s*\/\^ANN-/, 'VOYAGE_ANCHOR_ID_RE constant required');
});
test('voyage-playground.html anchor regex matches Node-side allowlist (v4.3 Step 16 cross-file sync)', () => {
const html = readFileSync(HTML, 'utf-8');
const node = readFileSync(join(ROOT, 'lib', 'parsers', 'anchor-parser.mjs'), 'utf-8');
const htmlMatch = html.match(/voyage:anchor[^/]+/)?.[0];
const nodeMatch = node.match(/voyage:anchor[^/]+/)?.[0];
assert.equal(htmlMatch, nodeMatch, 'first voyage:anchor token in HTML must mirror Node-side parser exactly');
});
test('voyage-playground.html declares parseAnchor validator (v4.3 Step 16)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+parseAnchor\s*\(\s*line\s*\)/, 'parseAnchor(line) function required');
});
test('voyage-playground.html declares relocateAnchorsToBlockBoundaries pure function (v4.3 Step 17)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+relocateAnchorsToBlockBoundaries\s*\(\s*text\s*,\s*anchors\s*\)/,
'relocateAnchorsToBlockBoundaries(text, anchors) pure function required');
});
test('voyage-playground.html declares .voyage-anchor-badge gutter component (v4.3 Step 18)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /\.voyage-anchor-badge\s*\{/, '.voyage-anchor-badge CSS class required');
assert.match(text, /position:\s*absolute/, '.voyage-anchor-badge must use absolute positioning');
assert.match(text, /var\(--color-scope-voyage\)/, 'badge must use --color-scope-voyage token');
});
test('voyage-playground.html declares .voyage-anchor-active yellow-tint highlight (v4.3 Step 18)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /\.voyage-anchor-active\s*\{/, '.voyage-anchor-active CSS class required');
assert.match(text, /rgba\(255,\s*235,\s*59,\s*0\.25\)/, 'yellow-tint rgba(255, 235, 59, 0.25) required');
});
test('voyage-playground.html does NOT contain v4.2 pencil-icon references (v4.3 Step 18 cleanup)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.doesNotMatch(text, /voyage-pencil-btn/, 'pencil-btn class must be removed');
assert.doesNotMatch(text, /injectPencilIcons/, 'injectPencilIcons function must be replaced by injectAnchorBadges');
});
test('voyage-playground.html declares injectAnchorBadges JS function (v4.3 Step 18)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+injectAnchorBadges\s*\(\s*\)/, 'injectAnchorBadges() function required');
});
test('voyage-playground.html declares voyage-sidebar hidden-by-default (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /id="voyage-sidebar"[\s\S]{0,200}aria-hidden="true"/, 'voyage-sidebar must default aria-hidden="true"');
});
test('voyage-playground.html declares data-action="toggle-sidebar" on FAB (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /data-action="toggle-sidebar"/, 'data-action="toggle-sidebar" required on FAB toggle button');
});
test('voyage-playground.html declares voyage-jumplist + count "X av N" (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /id="voyage-jumplist"/, 'voyage-jumplist ordered list required');
assert.match(text, /id="voyage-jumplist-count"/, 'voyage-jumplist-count container required');
assert.match(text, /' av '/, '"X av N" jumplist count format string required in JS');
});
test('voyage-playground.html declares filter-buttons Alle/Åpne/Resolved (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /data-filter="all"/, 'filter button data-filter="all" required');
assert.match(text, /data-filter="open"/, 'filter button data-filter="open" required');
assert.match(text, /data-filter="resolved"/, 'filter button data-filter="resolved" required');
});
test('voyage-playground.html declares renderAnnotationList JS function (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+renderAnnotationList\s*\(\s*\)/, 'renderAnnotationList() function required');
});
test('voyage-playground.html declares wireKeyboardNav with j/k/]/Escape (v4.3 Step 20)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+wireKeyboardNav\s*\(\s*\)/, 'wireKeyboardNav() function required');
assert.match(text, /e\.key === 'j'/, "'j' key handler required");
assert.match(text, /e\.key === 'k'/, "'k' key handler required");
assert.match(text, /e\.key === '\]'/, "']' key (toggle-sidebar) required");
assert.match(text, /e\.key === 'Escape'/, "'Escape' key handler required");
});
test('voyage-playground.html keyboard nav skips inputs/textareas (v4.3 Step 20)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /matches\([^)]*input[^)]*textarea/, 'input/textarea matches() guard required');
});
test('voyage-playground.html keyboard nav announces via aria-live region (v4.3 Step 20)', () => {
const text = readFileSync(HTML, 'utf-8');
// The wireKeyboardNav body contains announce(... ' av ' ...) for nav-position announce
assert.match(text, /announce\('Annotering '/, 'aria-live announce on annotation navigation required');
});