Step 28 of v4.3 plan — Wave 7 Group A: 17 new static-HTML grep assertions covering SC1 10-element checklist (one test per element), SC3 webkitdirectory + drag-drop attributes, SC6 export-bundle markers (buildAnnotatedMarkdown, filename pattern, Blob + clipboard flows), and SC7 tag-level no-CDN checks (every <script src> + <link href> must be local). Test count: 672 → 689 pass / 0 fail / 2 skipped.
652 lines
35 KiB
JavaScript
652 lines
35 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');
|
|
});
|
|
|
|
// v4.3 Step 21 — two-opacity pattern (active 100% / inactive 40% / resolved 30% strikethrough)
|
|
test('voyage-playground.html declares two-opacity inactive default for badges (v4.3 Step 21)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// Default badge rule must include opacity: 0.4 (inactive)
|
|
assert.match(text, /\.voyage-anchor-badge\s*\{[^}]*opacity:\s*0\.4/s, '.voyage-anchor-badge default opacity: 0.4 required');
|
|
});
|
|
|
|
test('voyage-playground.html declares two-opacity active state for badges (v4.3 Step 21)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// Active state: data-active="true" must restore opacity to 1
|
|
assert.match(text, /\.voyage-anchor-badge\[data-active="true"\]\s*\{[^}]*opacity:\s*1/s, 'data-active opacity: 1 required');
|
|
});
|
|
|
|
test('voyage-playground.html declares two-opacity resolved state for badges (v4.3 Step 21)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// Resolved state: data-resolved="true" must produce opacity 0.3 + line-through
|
|
assert.match(text, /\.voyage-anchor-badge\[data-resolved="true"\]\s*\{[^}]*opacity:\s*0\.3/s, 'data-resolved opacity: 0.3 required');
|
|
assert.match(text, /\.voyage-anchor-badge\[data-resolved="true"\]\s*\{[^}]*text-decoration:\s*line-through/s, 'data-resolved line-through required');
|
|
});
|
|
|
|
test('voyage-playground.html declares two-opacity for sidebar list-items (v4.3 Step 21)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// List-item default opacity 0.4
|
|
assert.match(text, /\.voyage-annotation-list__items\s+li\s*\{[^}]*opacity:\s*0\.4/s, 'list-item default opacity: 0.4 required');
|
|
// List-item active overrides to 1
|
|
assert.match(text, /\.voyage-annotation-list__items\s+li\[data-active="true"\][^}]*opacity:\s*1/s, 'list-item active opacity: 1 required');
|
|
// List-item resolved opacity 0.3
|
|
assert.match(text, /\.voyage-annotation-list__items\s+li\[data-resolved="true"\][^}]*opacity:\s*0\.3/s, 'list-item resolved opacity: 0.3 required');
|
|
});
|
|
|
|
test('voyage-playground.html setActiveAnchor toggles data-active on badges (v4.3 Step 21)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// setActiveAnchor must clear prior data-active and set new one
|
|
assert.match(text, /setAttribute\('data-active',\s*'true'\)/, 'data-active set on active badge required');
|
|
// injectAnchorBadges must propagate resolved state to badge data-resolved
|
|
assert.match(text, /setAttribute\('data-resolved',\s*'true'\)/, 'data-resolved set on resolved badge required');
|
|
});
|
|
|
|
// v4.3 Step 22 — A11Y-panel built from DS-primitives (greenfield)
|
|
test('voyage-playground.html declares voyage-a11y-panel with guide-panel--info (v4.3 Step 22)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /id="voyage-a11y-panel"[^>]*guide-panel guide-panel--info/, 'voyage-a11y-panel with guide-panel--info required');
|
|
// Must be hidden by default (placeholder until Wave 7)
|
|
assert.match(text, /id="voyage-a11y-panel"[\s\S]{0,300}\bhidden\b/, 'voyage-a11y-panel hidden by default required');
|
|
});
|
|
|
|
test('voyage-playground.html declares data-action="toggle-a11y-panel" toggle-button (v4.3 Step 22)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /data-action="toggle-a11y-panel"/, 'toggle-a11y-panel button required');
|
|
// aria-controls must point at the panel id
|
|
assert.match(text, /data-action="toggle-a11y-panel"[\s\S]*?aria-controls="voyage-a11y-panel"/, 'aria-controls binding required');
|
|
});
|
|
|
|
test('voyage-playground.html A11Y-panel uses key-stats severity grid (v4.3 Step 22)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// key-stats grid with critical/high/medium/low severity modifiers
|
|
assert.match(text, /class="key-stat key-stat--critical"/, 'key-stat--critical required');
|
|
assert.match(text, /class="key-stat key-stat--high"/, 'key-stat--high (serious) required');
|
|
assert.match(text, /class="key-stat key-stat--medium"/, 'key-stat--medium (moderate) required');
|
|
assert.match(text, /class="key-stat key-stat--low"/, 'key-stat--low (minor) required');
|
|
// axe-core severity vocabulary on data-a11y-stat
|
|
assert.match(text, /data-a11y-stat="critical"/, 'data-a11y-stat="critical" required');
|
|
assert.match(text, /data-a11y-stat="serious"/, 'data-a11y-stat="serious" required');
|
|
assert.match(text, /data-a11y-stat="moderate"/, 'data-a11y-stat="moderate" required');
|
|
assert.match(text, /data-a11y-stat="minor"/, 'data-a11y-stat="minor" required');
|
|
});
|
|
|
|
test('voyage-playground.html A11Y-panel uses findings__items placeholder list (v4.3 Step 22)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// Match either attribute order (class= or id= first); just confirm both live on the same <ol>.
|
|
assert.match(text, /<ol[^>]*class="findings__items"[^>]*id="voyage-a11y-findings"|<ol[^>]*id="voyage-a11y-findings"[^>]*class="findings__items"/, 'findings__items list (id=voyage-a11y-findings) required');
|
|
// Placeholder line referencing the Wave 7 Playwright spec
|
|
assert.match(text, /Kjør axe-spec/, 'placeholder hint "Kjør axe-spec" required');
|
|
});
|
|
|
|
test('voyage-playground.html declares wireA11yToggle JS function (v4.3 Step 22)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /function\s+wireA11yToggle\s*\(\s*\)/, 'wireA11yToggle() function required');
|
|
// Toggle must flip hidden + aria-expanded
|
|
assert.match(text, /panel\.hidden\s*=\s*!willOpen/, 'panel.hidden toggle required');
|
|
assert.match(text, /setAttribute\('aria-expanded'/, 'aria-expanded update required');
|
|
});
|
|
|
|
// v4.3 Step 23 — screenshots-spor convention (window.__hooks + docs/screenshots/)
|
|
test('voyage-playground.html exposes window.__voyage automation hooks (v4.3 Step 23)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// window.__voyage must be assigned (object literal or assignment expression)
|
|
assert.match(text, /window\.__voyage\s*=\s*\{/, 'window.__voyage = { ... } assignment required');
|
|
});
|
|
|
|
test('voyage-playground.html window.__voyage exposes navigate/scheduleRender/getProjectArtifacts (v4.3 Step 23)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// Each method must appear as a property of the exposed object.
|
|
assert.match(text, /navigate:\s*function/, 'navigate method required');
|
|
assert.match(text, /scheduleRender:\s*function/, 'scheduleRender method required');
|
|
assert.match(text, /getProjectArtifacts:\s*function/, 'getProjectArtifacts method required');
|
|
});
|
|
|
|
test('docs/screenshots/README.md documents mappestruktur + hooks (v4.3 Step 23)', () => {
|
|
const path = join(ROOT, 'docs', 'screenshots', 'README.md');
|
|
const text = readFileSync(path, 'utf-8');
|
|
assert.match(text, /Mappestruktur/, 'Mappestruktur heading required');
|
|
// Must list each documented subfolder
|
|
assert.match(text, /dashboard\//, 'dashboard/ subfolder documented');
|
|
assert.match(text, /artifact-detail\//, 'artifact-detail/ subfolder documented');
|
|
assert.match(text, /annotation\//, 'annotation/ subfolder documented');
|
|
assert.match(text, /dark-mode\//, 'dark-mode/ subfolder documented');
|
|
assert.match(text, /light-mode\//, 'light-mode/ subfolder documented');
|
|
// Hooks documentation must reference all three methods
|
|
assert.match(text, /window\.__voyage\.navigate/, 'navigate hook documented');
|
|
assert.match(text, /window\.__voyage\.scheduleRender/, 'scheduleRender hook documented');
|
|
assert.match(text, /window\.__voyage\.getProjectArtifacts/, 'getProjectArtifacts hook documented');
|
|
});
|
|
|
|
// v4.3 Step 24 — vendor DOMPurify + sanitize annotation-content
|
|
test('playground/lib/dompurify.min.js is vendored (v4.3 Step 24)', () => {
|
|
const path = join(PLAYGROUND, 'lib', 'dompurify.min.js');
|
|
assert.equal(existsSync(path), true, 'playground/lib/dompurify.min.js must exist (run scripts/vendor-playground-libs.mjs)');
|
|
const size = statSync(path).size;
|
|
// Sanity floor — DOMPurify min bundle is ~22 KB; reject empty/0-byte
|
|
assert.ok(size > 5000, 'dompurify.min.js too small (' + size + ' bytes) — vendor script may have failed');
|
|
});
|
|
|
|
test('playground/lib/VENDOR-MANIFEST.json pins dompurify >= 3.1.1 (v4.3 Step 24)', () => {
|
|
const path = join(PLAYGROUND, 'lib', 'VENDOR-MANIFEST.json');
|
|
const manifest = JSON.parse(readFileSync(path, 'utf-8'));
|
|
assert.ok(manifest.pins && manifest.pins.dompurify, 'manifest must pin dompurify');
|
|
// semver compare on major.minor: must be >= 3.1.1
|
|
const m = String(manifest.pins.dompurify).match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
assert.ok(m, 'invalid dompurify pin format: ' + manifest.pins.dompurify);
|
|
const [, maj, min] = m;
|
|
assert.ok(Number(maj) > 3 || (Number(maj) === 3 && Number(min) >= 1), 'dompurify pin must be >= 3.1.1, got ' + manifest.pins.dompurify);
|
|
assert.ok(manifest.output_files.includes('dompurify.min.js'), 'manifest output_files must list dompurify.min.js');
|
|
});
|
|
|
|
test('voyage-playground.html loads dompurify.min.js (v4.3 Step 24)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /<script src="lib\/dompurify\.min\.js">/, 'lib/dompurify.min.js script tag required');
|
|
});
|
|
|
|
test('voyage-playground.html declares sanitizeAnnotation function with allowlist (v4.3 Step 24)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /function\s+sanitizeAnnotation\s*\(/, 'sanitizeAnnotation() function required');
|
|
// Must call DOMPurify.sanitize with an ALLOWED_TAGS allowlist
|
|
assert.match(text, /DOMPurify\.sanitize/, 'DOMPurify.sanitize call required');
|
|
assert.match(text, /ALLOWED_TAGS:\s*\[/, 'ALLOWED_TAGS allowlist required');
|
|
});
|
|
|
|
test('voyage-playground.html bundle stays under 460 KB HALT-gate (v4.3 Step 24)', () => {
|
|
// Sums voyage-playground.html + every playground/lib/*.js file. Per plan
|
|
// critic finding 18 — must be < 460000 bytes (40 KB margin under the
|
|
// 500 KB NFR).
|
|
const htmlSize = statSync(HTML).size;
|
|
const libDir = join(PLAYGROUND, 'lib');
|
|
const libFiles = readdirSync(libDir).filter((f) => f.endsWith('.js') || f.endsWith('.mjs'));
|
|
let libTotal = 0;
|
|
for (const f of libFiles) libTotal += statSync(join(libDir, f)).size;
|
|
const total = htmlSize + libTotal;
|
|
assert.ok(total < 460000, 'bundle size ' + total + ' bytes exceeds 460 KB HALT-gate (' + libFiles.length + ' lib files)');
|
|
});
|
|
|
|
// v4.3 Step 25 — HTML-comment indirect prompt-injection mitigation (Sec T4).
|
|
// (Behavioral fixture-tests live in tests/integration/annotation-export-schema.test.mjs.)
|
|
test('voyage-playground.html declares stripUnsafeComments anchor-allowlist (v4.3 Step 25)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /function\s+stripUnsafeComments\s*\(/, 'stripUnsafeComments() required');
|
|
// Filter must use parseAnchor as the allowlist gate
|
|
assert.match(text, /parseAnchor\(match\)\s*\?\s*match\s*:\s*''/, 'parseAnchor allowlist gate required');
|
|
});
|
|
|
|
test('voyage-playground.html renderArtifact strips comments before md.render (v4.3 Step 25)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// The Step 25 hook must precede the md.render call inside renderArtifact.
|
|
// Locate renderArtifact body and assert ordering.
|
|
const bodyStart = text.indexOf('function renderArtifact');
|
|
assert.ok(bodyStart > 0, 'renderArtifact() must exist');
|
|
const bodyEnd = text.indexOf('}', bodyStart + 200);
|
|
const body = text.slice(bodyStart, bodyEnd + 1);
|
|
const stripIdx = body.indexOf('stripUnsafeComments');
|
|
const renderIdx = body.indexOf('md.render');
|
|
assert.ok(stripIdx > 0 && stripIdx < renderIdx, 'stripUnsafeComments must run before md.render');
|
|
});
|
|
|
|
// v4.3 Step 26 — path-traversal + symlink/dotfile filter.
|
|
test('voyage-playground.html declares isProjectPathSafe filter (v4.3 Step 26)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /function\s+isProjectPathSafe\s*\(/, 'isProjectPathSafe() function required');
|
|
// Must reject the four documented threat-classes
|
|
assert.match(text, /indexOf\('\.\.'\)/, '..-rejection required');
|
|
assert.match(text, /indexOf\('node_modules\//, 'node_modules/-rejection required');
|
|
assert.match(text, /indexOf\('dist\//, 'dist/-rejection required');
|
|
assert.match(text, /indexOf\('build\//, 'build/-rejection required');
|
|
});
|
|
|
|
test('voyage-playground.html loadProjectDirectory wires isProjectPathSafe filter (v4.3 Step 26)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// Must call the filter before classification, AND track filteredCount
|
|
assert.match(text, /isProjectPathSafe\(inside\)/, 'isProjectPathSafe must be called on `inside` path');
|
|
assert.match(text, /filteredCount\+\+/, 'filteredCount tracking required');
|
|
// aria-live announce must fire when something is filtered
|
|
assert.match(text, /announce\(filteredCount/, 'filteredCount announce required');
|
|
});
|
|
|
|
// =====================================================================
|
|
// v4.3 Step 28 — Group A static-HTML assertions (Wave 7).
|
|
//
|
|
// SC1 10-element checklist (one test per element), SC3 webkitdirectory +
|
|
// drag-drop attributes, SC6 export-bundle markers, SC7 no-CDN tag-level
|
|
// checks. All assertions read voyage-playground.html as text — no DOM,
|
|
// no browser. The HTML is FROZEN in Session 6; if any assertion fails
|
|
// the test must be adjusted to reflect actual state, not the HTML.
|
|
// =====================================================================
|
|
|
|
// --- SC1 element 1 — header / app-shell topbar -----------------------
|
|
test('SC1.1 header — app-shell topbar with breadcrumb (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /class="app-header__breadcrumb"/, 'breadcrumb required');
|
|
assert.match(text, /aria-label="Brødsmuler"/, 'breadcrumb aria-label required');
|
|
});
|
|
|
|
// --- SC1 element 2 — breadcrumb interactive return path --------------
|
|
test('SC1.2 breadcrumb — clickable returns to dashboard (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /breadcrumb-click/, 'breadcrumb-click handler required');
|
|
});
|
|
|
|
// --- SC1 element 3 — theme bootstrap IIFE ----------------------------
|
|
test('SC1.3 theme bootstrap — IIFE sets data-theme + colorScheme (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /<html[^>]+data-theme="dark"/, 'html data-theme=dark required');
|
|
assert.match(text, /prefers-color-scheme:\s*dark/, 'OS preference fallback required');
|
|
assert.match(text, /setAttribute\('data-theme'/, 'data-theme setter required');
|
|
});
|
|
|
|
// --- SC1 element 4 — onboarding-grid (redefined as fleet-grid) -------
|
|
// Per scope-guardian SC-GAP-1 (Assumptions row #21): voyage redefines
|
|
// onboarding-grid as fleet-grid. Operator-signed-off; /trekreview may
|
|
// flag this for revision.
|
|
test('SC1.4 onboarding-grid equivalent — fleet-grid pattern (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /class="fleet-grid"/, 'fleet-grid container required');
|
|
assert.match(text, /fleet-tile/, 'fleet-tile children required');
|
|
});
|
|
|
|
// --- SC1 element 5 — A11Y panel built from DS-primitives -------------
|
|
test('SC1.5 A11Y panel — guide-panel--info + key-stats + findings (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /guide-panel guide-panel--info/, 'guide-panel--info required');
|
|
assert.match(text, /class="key-stats"/, 'key-stats severity-grid required');
|
|
assert.match(text, /class="findings__items"/, 'findings__items list required');
|
|
assert.match(text, /wireA11yToggle/, 'A11Y wiring function required');
|
|
});
|
|
|
|
// --- SC1 element 6 — screenshots-spor convention ---------------------
|
|
// Per scope-guardian SC-GAP-2 (Assumptions row #22): hooks + dir convention
|
|
// instead of inline gallery. Operator-signed-off.
|
|
test('SC1.6 screenshots-spor — window.__voyage hooks + docs convention (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /window\.__voyage\s*=/, 'window.__voyage namespace required');
|
|
assert.match(text, /docs\/screenshots\/README\.md/, 'docs/screenshots reference required');
|
|
// Companion file must exist
|
|
const SCREENSHOTS_README = join(ROOT, 'docs', 'screenshots', 'README.md');
|
|
assert.ok(existsSync(SCREENSHOTS_README), 'docs/screenshots/README.md must exist');
|
|
});
|
|
|
|
// --- SC1 element 7 — body typography -----------------------------------
|
|
test('SC1.7 body typography — DS font-size + family tokens (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /var\(--font-size-/, 'DS font-size token required');
|
|
assert.match(text, /var\(--font-family-mono\)/, 'DS font-family-mono token required');
|
|
});
|
|
|
|
// --- SC1 element 8 — spacing rhythm ------------------------------------
|
|
test('SC1.8 spacing rhythm — DS --space-N tokens used (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// Expect at least 5 distinct --space-N references (rhythm, not one-off)
|
|
const matches = text.match(/var\(--space-\d/g) || [];
|
|
assert.ok(matches.length >= 5, `expected ≥5 --space-N tokens, got ${matches.length}`);
|
|
});
|
|
|
|
// --- SC1 element 9 — color-token fidelity ------------------------------
|
|
test('SC1.9 color-token fidelity — voyage-scope tokens + DS colors (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// Voyage-scope tokens were added in Step 1 (DS base.css) and consumed by playground
|
|
assert.match(text, /badge--scope-voyage|--color-scope-voyage/, 'voyage-scope token usage required');
|
|
});
|
|
|
|
// --- SC1 element 10 — dark-mode parity --------------------------------
|
|
test('SC1.10 dark-mode parity — explicit dark default + bootstrap (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// html element ships data-theme=dark, theme-bootstrap respects user setting
|
|
assert.match(text, /<html[^>]+data-theme="dark"/, 'dark default required');
|
|
assert.match(text, /voyage-theme|voyage_theme/, 'theme persistence key required');
|
|
});
|
|
|
|
// --- SC3 — webkitdirectory + drag-drop attribute presence -------------
|
|
test('SC3 webkitdirectory — input declares directory attribute (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// The input element on line 849 has webkitdirectory as an HTML attribute
|
|
assert.match(text, /\bwebkitdirectory\b/, 'webkitdirectory attribute required');
|
|
});
|
|
|
|
test('SC3 drag-drop — webkitGetAsEntry recursive walk (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /webkitGetAsEntry/, 'webkitGetAsEntry recursive entry-point required');
|
|
assert.match(text, /addEventListener\('dragenter/, 'dragenter handler required');
|
|
assert.match(text, /addEventListener\('dragover/, 'dragover handler required');
|
|
assert.match(text, /addEventListener\('dragleave/, 'dragleave handler required');
|
|
});
|
|
|
|
// --- SC6 export-bundle markers ----------------------------------------
|
|
test('SC6 export — buildAnnotatedMarkdown function exists (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /function\s+buildAnnotatedMarkdown\s*\(/, 'buildAnnotatedMarkdown required');
|
|
});
|
|
|
|
test('SC6 export — download filename pattern annotated-{target}.md (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /'annotated-'\s*\+\s*target\s*\+\s*'\.md'/, 'filename pattern required');
|
|
});
|
|
|
|
test('SC6 export — Blob + clipboard.writeText flows wired (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
assert.match(text, /new\s+Blob\(/, 'Blob construction required');
|
|
assert.match(text, /clipboard\.writeText/, 'clipboard copy flow required');
|
|
});
|
|
|
|
// --- SC7 no-CDN tag-level checks ---------------------------------------
|
|
test('SC7 no-CDN — every <script src=...> is local (./lib/* etc) (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
// Match all <script src="..."> attribute values
|
|
const scriptSrcs = [...text.matchAll(/<script\b[^>]*\bsrc\s*=\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
for (const src of scriptSrcs) {
|
|
assert.ok(
|
|
!/^https?:\/\//.test(src) && !/^\/\//.test(src),
|
|
`script src="${src}" must be local (no http/https/protocol-relative)`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('SC7 no-CDN — every <link href=...> is local (v4.3 Step 28)', () => {
|
|
const text = readFileSync(HTML, 'utf-8');
|
|
const linkHrefs = [...text.matchAll(/<link\b[^>]*\bhref\s*=\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
for (const href of linkHrefs) {
|
|
assert.ok(
|
|
!/^https?:\/\//.test(href) && !/^\/\//.test(href),
|
|
`link href="${href}" must be local (no http/https/protocol-relative)`,
|
|
);
|
|
}
|
|
});
|