Files
msd-core/tests/ui-interaction-capture.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

779 lines
40 KiB
JavaScript

'use strict';
// Nothing here reads a source module of the kinds no-source-grep tracks
// (.cjs/.cts/.js/.mjs/.mts/.ts), so there is no site to suppress and the file
// carries no allow-test-rule marker.
/**
* workflow.ui_interaction_capture — #4223
*
* Enhancement shape approved at triage: a new default-off key on the EXISTING
* `ui` capability (ADR-894's one-owner invariant — `ui` already owns
* msd-ui-auditor, so no new capability directory may claim it), with the
* auditor's <screenshot_approach> branching on it and falling back to today's
* Playwright-only path when the key is off or no Chrome binary resolves.
*
* Risk zone under test (in order):
* 1. Containment — with the key off, or no Chrome, or no dev server, the
* driver is never invoked and the static path is untouched.
* 2. The key must not parse-and-do-nothing: the orchestrator reads it and
* hands it down; the registry, schema and config layers all know it.
* 3. Lifecycle honesty — the daemon is stopped whenever it was started (trapped,
* not merely placed last), a failed capture is never counted, and the status
* line says what happened.
* 4. Bounded and confined — every driver call runs under a ceiling (a hung
* npx fetch or Chrome launch cannot wedge the audit), and the driver may
* write only under the capture directory (--workspace, never
* --allowUnrestrictedPaths).
* 5. The gitignore gate covers the capture directory as a whole, and an
* existing .gitignore is upgraded rather than left as written.
*
* The auditor carries no msd_run resolver, so the key travels through the
* <config> block /msd-ui-review builds; the fence under test consumes the
* INTERACTION_CAPTURE, SCREENSHOT_DIR and DEV_URL variables the surrounding
* prose defines and is executed here under bash with a stub driver on PATH.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { splitLines } = require('../msd-core/bin/lib/text-lines.cjs');
const { createTempProject, cleanup, runMsdTools } = require('./helpers.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
// The stub driver never blocks, so one fence run is a handful of sh spawns; distinct
// from PROBE (a single probe) and BUILD (a compiler) — its own class, bounded generously.
const FENCE_RUN_TIMEOUT_MS = 30000;
const realRegistry = require('../msd-core/bin/lib/capability-registry.cjs');
const { isValidConfigKey } = require('../msd-core/bin/lib/config-schema.cjs');
const { loadConfig } = require('../msd-core/bin/lib/config-loader.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const CAP_ID = 'ui';
const KEY = 'workflow.ui_interaction_capture';
const AGENT = 'msd-ui-auditor';
const AUDITOR_PATH = path.join(REPO_ROOT, 'agents', `${AGENT}.md`);
const UI_REVIEW_PATH = path.join(REPO_ROOT, 'msd-core', 'workflows', 'ui-review.md');
const MANIFEST_PATH = path.join(REPO_ROOT, 'capabilities', CAP_ID, 'capability.json');
const DOCS_CONFIG_PATH = path.join(REPO_ROOT, 'docs', 'CONFIGURATION.md');
const HOWTO_PATH = path.join(REPO_ROOT, 'docs', 'how-to', 'enable-ui-interaction-capture.md');
const OPEN_ANCHOR = '<!-- msd:ui-interaction-capture -->';
const CLOSE_ANCHOR = '<!-- /msd:ui-interaction-capture -->';
const FENCE = '`'.repeat(3);
function readManifest() {
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
}
/**
* The bash fences inside <screenshot_approach>, split into the static block
* (before the interaction anchor) and the interaction block (inside it).
* Line-based on purpose: no unbounded regex over file content, no ad-hoc
* markdown parsing, and splitLines() handles CRLF checkouts.
*/
function screenshotApproachFences() {
const lines = splitLines(fs.readFileSync(AUDITOR_PATH, 'utf8'));
const out = { static: [], interaction: [] };
let inSection = false;
let inInteraction = false;
let inFence = false;
for (const line of lines) {
if (!inSection) {
if (line.includes('<screenshot_approach>')) inSection = true;
continue;
}
if (line.includes('</screenshot_approach>')) break;
if (line.includes(OPEN_ANCHOR)) { inInteraction = true; continue; }
if (line.includes(CLOSE_ANCHOR)) { inInteraction = false; continue; }
if (!inFence) {
if (line.trim() === `${FENCE}bash`) inFence = true;
continue;
}
if (line.trim() === FENCE) { inFence = false; continue; }
(inInteraction ? out.interaction : out.static).push(line);
}
assert.ok(out.static.length > 0, '<screenshot_approach> must keep its static bash fence');
assert.ok(out.interaction.length > 0, `${OPEN_ANCHOR} must wrap a bash fence`);
return out;
}
describe('ui capability owns workflow.ui_interaction_capture', () => {
test('manifestDeclaresTheKeyAsADefaultOffBoolean', () => {
const cap = readManifest();
const slice = cap.config[KEY];
assert.ok(slice, `${MANIFEST_PATH} must declare ${KEY}`);
assert.equal(slice.type, 'boolean');
assert.equal(slice.default, false, 'the approved shape is default-off');
assert.ok(typeof slice.description === 'string' && slice.description.length > 0);
});
test('theKeyLivesOnTheCapabilityThatAlreadyOwnsTheAuditor', () => {
// ADR-894 one-owner invariant: a NEW capability directory could not also
// claim msd-ui-auditor, which is why triage required the existing manifest.
const cap = readManifest();
assert.ok(cap.agents.includes(AGENT), `${CAP_ID} must own ${AGENT}`);
assert.equal(realRegistry.byAgent[AGENT], CAP_ID, 'the generated registry must agree on the owner');
assert.equal(cap.activationKey, undefined,
'the key gates one section of one agent, never the whole ui capability');
});
test('generatedRegistryCarriesTheSliceUnderTheUiOwner', () => {
const entry = realRegistry.configSchema[KEY];
assert.ok(entry, 'capability-registry.cjs must be regenerated after the manifest change');
assert.equal(entry.owner, CAP_ID);
assert.equal(entry.type, 'boolean');
assert.equal(entry.default, false);
assert.deepEqual(realRegistry.capabilities[CAP_ID].config[KEY], readManifest().config[KEY]);
});
test('configSchemaAcceptsTheKey', () => {
assert.equal(isValidConfigKey(KEY), true);
});
});
describe('config layer round-trips the key', () => {
test('configSetAcceptsAndPersistsTrue', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runMsdTools(`config-set ${KEY} true`, tmpDir);
assert.ok(result.success, `config-set must accept ${KEY}: ${result.error}`);
const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf8'));
assert.equal(cfg.workflow?.ui_interaction_capture, true);
});
test('configSetRejectsANonBoolean', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runMsdTools(`config-set ${KEY} banana`, tmpDir);
assert.ok(!result.success, 'a boolean slice must reject a non-boolean');
});
test('absentKeyResolvesToFalse', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
assert.equal(loadConfig(tmpDir).workflow?.ui_interaction_capture, false);
});
for (const [label, value] of [['stringTrue', '"true"'], ['numberOne', '1'], ['nullValue', 'null']]) {
test(`handWrittenNonBooleanResolvesToFalse_${label}`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'),
`{"workflow":{"ui_interaction_capture":${value}}}`);
assert.equal(loadConfig(tmpDir).workflow?.ui_interaction_capture, false,
`${label} must fall to the slice default, not survive as truthy`);
});
}
});
describe('/msd-ui-review hands the key to the auditor', () => {
test('orchestratorReadsTheKeyAndPassesItInTheConfigBlock', () => {
const src = fs.readFileSync(UI_REVIEW_PATH, 'utf8');
assert.ok(src.includes(`config-get ${KEY}`), 'ui-review.md must read the key through msd_run');
// Lowercase placeholder, like the block's `{phase_dir}` / `{padded_phase}` siblings —
// the block is a prompt template the orchestrator fills, not a bash heredoc.
assert.ok(src.includes('interaction_capture: {interaction_capture}'),
'the spawn prompt <config> block must carry interaction_capture');
// Normalised to a literal true/false before it is handed down, so the
// auditor's fence only ever compares against "true".
assert.ok(src.includes('[ "$INTERACTION_CAPTURE" = "true" ] || INTERACTION_CAPTURE="false"'));
});
test('auditorNeverReadsConfigItself', () => {
// The auditor carries no msd_run resolver; the key must arrive by value.
const src = fs.readFileSync(AUDITOR_PATH, 'utf8');
// Prose may NAME msd_run (this section explains why it is absent); an invocation
// or a resolver definition is what must not appear.
assert.ok(!/msd_run (query|runtime-identity)|msd_run\(\)/.test(src),
'msd-ui-auditor.md must not grow a msd_run dependency for this');
assert.ok(src.includes('interaction_capture'), 'the auditor must name the <config> field it consumes');
});
});
describe('<screenshot_approach> keeps the two paths apart', () => {
test('staticFenceIsPlaywrightOnlyAndInteractionFenceIsChromeDevtoolsOnly', () => {
const { static: staticFence, interaction } = screenshotApproachFences();
const staticText = staticFence.join('\n');
const interactionText = interaction.join('\n');
assert.ok(staticText.includes('npx playwright screenshot'), 'the static path stays on Playwright');
assert.ok(!staticText.includes('chrome-devtools'),
'the static fence must not reference the driver — key off means today\'s path, byte for byte');
assert.ok(interactionText.includes('chrome-devtools'));
assert.ok(!interactionText.includes('playwright'),
'the interaction fence adds captures; it never replaces the Playwright ones');
});
test('interactionFenceGatesOnTheHandedDownValueAndAChromeBinary', () => {
const text = screenshotApproachFences().interaction.join('\n');
assert.ok(text.includes('if [ "$INTERACTION_CAPTURE" != "true" ]'));
assert.ok(text.includes('elif [ -z "$CHROME_BIN" ]'));
assert.ok(text.includes('--isolated'), 'a throwaway profile, never the shared chrome-devtools-mcp one');
assert.ok(text.includes('--workspace "$INTERACTION_DIR"'), 'the driver is confined to the capture directory');
const code = screenshotApproachFences().interaction.filter((l) => !/^\s*#/.test(l)).join('\n');
assert.ok(!code.includes('--allowUnrestrictedPaths'), 'deprecated in 1.9.0, and it lifts every path restriction');
assert.ok(text.includes('--usageStatistics=false'));
assert.ok(text.includes('--sessionId $CDT_SESSION'), 'a per-run daemon session, so audits never stop each other');
});
});
// ---------------------------------------------------------------------------
// Behavioural: run the interaction fence under bash with a stub driver on PATH.
// ---------------------------------------------------------------------------
const HAS_BASH = (() => {
const r = spawnSync('bash', ['-c', 'exit 0'], { encoding: 'utf8', timeout: PROBE_TIMEOUT_MS });
return !r.error && r.status === 0;
})();
/** Absolute paths of the coreutils the fence needs, so PATH can hold only the stubs. */
function coreutilPaths() {
const r = spawnSync('bash', ['-c', 'for c in sed head mkdir rm tr date sleep; do command -v "$c" || exit 1; done'],
{ encoding: 'utf8', timeout: PROBE_TIMEOUT_MS });
assert.equal(r.status, 0, `coreutils must resolve: ${r.stderr}`);
const [sed, head, mkdir, rm, tr, date, sleep] = r.stdout.trim().split(/\r?\n/);
return { sed, head, mkdir, rm, tr, date, sleep };
}
const STUB_NPX = `#!/bin/sh
# argv: -y -p chrome-devtools-mcp@<range> chrome-devtools --sessionId <hex-id> <cmd> [args...]
printf '%s\\n' "$*" >> "$STUB_LOG"
[ "$1" = "-y" ] && [ "$2" = "-p" ] && [ "$4" = "chrome-devtools" ] && [ "$5" = "--sessionId" ] || { echo "stub npx: unexpected argv: $*" >&2; exit 66; }
case "$6" in *[!0-9a-fA-F-]*|"") echo "stub npx: sessionId not hex/dashes: $6" >&2; exit 68 ;; esac
cmd="$7"
shift 7
# STUB_HANG=<verb>: that verb never returns — the fence's ceiling is what ends it. Deliberately a
# CHILD holding stdout under this sh (the npx -> sh -> client shape), never an exec: a kill that
# reaches only this pid leaves the child blocking the fence's $(...) capture.
[ "\${STUB_HANG:-}" = "$cmd" ] && { "$STUB_SLEEP" 300; exit 0; }
[ "\${STUB_ORPHAN:-}" = "$cmd" ] && { "$STUB_SLEEP" 300 & exit 0; }
[ -n "\${STUB_DELAY:-}" ] && "$STUB_SLEEP" "$STUB_DELAY"
case "$cmd" in
resize_page)
[ "\${STUB_FAIL_RESIZE:-0}" = 1 ] && exit 1
exit 0 ;;
start)
[ "\${STUB_FAIL_START:-0}" = 1 ] && exit 1
exit 0 ;;
new_page)
if [ "\${STUB_FAIL_NEWPAGE:-0}" = 1 ]; then printf '## Pages\\n1: about:blank\\n'; exit 0; fi
nl='\\n'; [ "\${STUB_CRLF:-0}" = 1 ] && nl='\\r\\n'
printf "## Pages\${nl}1: about:blank\${nl}2: %s [selected]\${nl}" "$1"
exit "\${STUB_NEWPAGE_RC:-0}" ;;
press_key)
[ "\${STUB_FAIL_PRESS:-0}" = 1 ] && exit 1
exit 0 ;;
take_screenshot|take_snapshot)
f=""
while [ $# -gt 0 ]; do [ "$1" = "--filePath" ] && f="$2"; shift; done
[ -n "$f" ] || { echo "stub: $cmd without --filePath" >&2; exit 67; }
if [ "$cmd" = take_screenshot ] && [ "\${STUB_FAIL_SCREENSHOT:-0}" = 1 ]; then : > "$f"; exit 1; fi
if [ "$cmd" = take_snapshot ] && [ "\${STUB_FAIL_SNAPSHOT:-0}" = 1 ]; then exit 1; fi
printf 'STUB' > "$f"; exit 0 ;;
*) exit 0 ;;
esac
`;
/**
* Run the interaction fence. `opts.chrome` puts a stub google-chrome on PATH;
* `opts.env` is merged over the minimal environment. Returns stdout, the
* driver invocation log (one argv line per call) and the final status value.
*/
function runInteractionFence(t, opts = {}) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-interaction-'));
t.after(() => cleanup(tmp));
const bin = path.join(tmp, 'bin');
fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, 'npx'), STUB_NPX, { mode: 0o755 });
if (opts.chrome) fs.writeFileSync(path.join(bin, 'google-chrome'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const log = path.join(tmp, 'driver.log');
fs.writeFileSync(log, '');
const cu = coreutilPaths();
// The fence's watchdog is an exec'd bash that resolves `sleep` through the EXPORTED PATH, where a
// shell function is invisible — so PATH is exported below, and the stub dir carries `sleep` as a
// portable exec-wrapper script (an exec keeps the pid, so a kill aimed at it reaches the real sleep).
// `opts.brokenSleep`: that clock cannot launch (stands in for an msys fork failure under load).
fs.writeFileSync(path.join(bin, 'sleep'),
opts.brokenSleep ? '#!/bin/sh\nexit 1\n' : `#!/bin/sh\nexec ${JSON.stringify(cu.sleep)} "$@"\n`, { mode: 0o755 });
// Functions are looked up before PATH on every platform, so the fence sees
// real coreutils while PATH holds nothing but the stubs (#4176's harness note).
const script = [
'#!/bin/bash',
`export PATH=${JSON.stringify(bin)}`,
`sed() { ${JSON.stringify(cu.sed)} "$@"; }`,
`head() { ${JSON.stringify(cu.head)} "$@"; }`,
`mkdir() { ${JSON.stringify(cu.mkdir)} "$@"; }`,
`rm() { ${JSON.stringify(cu.rm)} "$@"; }`,
`tr() { ${JSON.stringify(cu.tr)} "$@"; }`,
`date() { ${JSON.stringify(cu.date)} "$@"; }`,
// Opt-in strict mode: an agent runner MAY execute the fence under errexit + pipefail,
// and the unconditional stop must still be reached on a failed navigation.
...(opts.strict ? ['set -e -o pipefail'] : []),
// `opts.failAfter`: a bare failing command right after the first fence line containing
// it — the future edit the EXIT trap exists for, since no shipped line fails bare today.
// `opts.injectAfter`: {marker, line} — an arbitrary line after the first fence line
// containing the marker (the subshell-copy control below).
...screenshotApproachFences().interaction.flatMap((line) =>
(opts.failAfter && line.includes(opts.failAfter)) ? [line, 'false']
: (opts.injectAfter && line.includes(opts.injectAfter.marker)) ? [line, opts.injectAfter.line]
: [line]),
'printf "FINAL_STATUS=%s\\n" "$INTERACTION_STATUS"',
'',
].join('\n');
const scriptPath = path.join(tmp, 'fence.sh');
fs.writeFileSync(scriptPath, script);
const env = {
STUB_LOG: log,
STUB_SLEEP: cu.sleep,
HOME: tmp,
TMPDIR: tmp,
...(opts.env || {}),
};
const r = spawnSync('bash', [scriptPath], { encoding: 'utf8', timeout: FENCE_RUN_TIMEOUT_MS, env, cwd: tmp });
const calls = fs.readFileSync(log, 'utf8').split(/\r?\n/).filter(Boolean);
const status = (r.stdout.match(/^FINAL_STATUS=(.*)$/m) || [])[1];
return { tmp, r, calls, status, stdout: r.stdout };
}
function shotsDir(t) {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-shots-'));
t.after(() => cleanup(d));
return d;
}
describe('interaction fence (bash, stub driver)', { skip: HAS_BASH ? false : 'bash not on PATH' }, () => {
test('keyOffInvokesNothingAndSaysSo', (t) => {
const dir = shotsDir(t);
const out = runInteractionFence(t, { chrome: true, env: { INTERACTION_CAPTURE: 'false', SCREENSHOT_DIR: dir } });
assert.equal(out.r.status, 0, out.r.stderr);
assert.deepEqual(out.calls, [], 'the driver must never run with the key off');
assert.match(out.stdout, /Interaction capture: off/);
assert.equal(out.status, 'off');
assert.ok(!fs.existsSync(path.join(dir, 'interaction')), 'no directory is created with the key off');
});
test('absentValueMeansOff', (t) => {
const out = runInteractionFence(t, { chrome: true, env: { SCREENSHOT_DIR: shotsDir(t) } });
assert.deepEqual(out.calls, []);
assert.equal(out.status, 'off');
});
test('noDevServerSkipsBeforeTouchingTheDriver', (t) => {
// The static block sets SCREENSHOT_DIR only when it reached a dev server.
const out = runInteractionFence(t, { chrome: true, env: { INTERACTION_CAPTURE: 'true' } });
assert.deepEqual(out.calls, []);
assert.equal(out.status, 'skipped (no dev server reached)');
assert.match(out.stdout, /reached no dev server/);
});
const HOST_HAS_FIXED_PATH_CHROME =
fs.existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') ||
(process.env.PROGRAMFILES && fs.existsSync(path.join(process.env.PROGRAMFILES, 'Google', 'Chrome', 'Application', 'chrome.exe')));
test('noChromeSkipsWithTheRemedyAndInvokesNothing', {
skip: HOST_HAS_FIXED_PATH_CHROME ? 'host has Chrome at a fixed install path the fence probes' : false,
}, (t) => {
const dir = shotsDir(t);
const out = runInteractionFence(t, { chrome: false, env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: dir } });
assert.deepEqual(out.calls, [], 'no Chrome means the driver is never fetched or started');
assert.equal(out.status, 'skipped (no Chrome binary resolved)');
assert.match(out.stdout, /set CHROME_BIN/);
});
test('chromeBinOverrideWinsOverDiscovery', (t) => {
const dir = shotsDir(t);
const out = runInteractionFence(t, {
chrome: false,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: dir, CHROME_BIN: '/opt/custom/chrome', DEV_URL: 'http://localhost:5173' },
});
const start = out.calls.find((c) => c.includes(' start '));
assert.ok(start, `start must run: ${out.calls.join(' | ')}`);
assert.ok(start.includes('-e /opt/custom/chrome'), start);
});
test('happyPathDrivesTheLifecycleAndCountsOnlyRealFiles', (t) => {
const dir = shotsDir(t);
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: dir, DEV_URL: 'http://localhost:3999/' },
});
assert.equal(out.r.status, 0, out.r.stderr);
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.equal(verbs[0], 'start', 'the daemon starts first');
assert.equal(verbs[verbs.length - 1], 'stop', 'and is stopped last — it does not self-reap');
// The workspace and every --filePath are the same path the fence joins with a literal `/`
// (relative when SCREENSHOT_DIR is — dialect-free on Git Bash, where an absolute msys path
// would mean nothing to a Windows-native daemon).
assert.ok(out.calls[0].includes(`--isolated --workspace ${dir}/interaction --usageStatistics=false`), out.calls[0]);
assert.ok(!out.calls[0].includes('--allowUnrestrictedPaths'), out.calls[0]);
assert.ok(out.calls[0].includes('-p chrome-devtools-mcp@^1.9.0 '), 'documented floor by default — --workspace needs 1.9.0');
// Every file the driver is asked to write lies inside the workspace it was confined to.
for (const c of out.calls.filter((x) => x.includes('--filePath '))) {
const target = c.split('--filePath ')[1].split(' ')[0];
assert.ok(target.startsWith(`${dir}/interaction/`), `write outside the workspace: ${c}`);
}
assert.equal(verbs.filter((v) => v === 'stop').length, 1, 'stop is issued exactly once — never from a subshell copy');
assert.ok(out.calls.some((c) => c.includes(' new_page http://localhost:3999/ --timeout 30000')), 'navigates to the resolved dev URL, time-bounded');
const sessions = new Set(out.calls.map((c) => c.split(' ')[5]));
assert.equal(sessions.size, 1, `every call must address one daemon session: ${[...sessions].join(',')}`);
assert.match([...sessions][0], /^[0-9]+-[0-9]+-[0-9]+$/,
'epoch-BASHPID-RANDOM, every part present — the CLI accepts hex and dashes only');
// Every later command addresses the page new_page marked [selected].
for (const c of out.calls.filter((x) => / (resize_page|take_snapshot|take_screenshot|press_key|list_console_messages) /.test(x))) {
assert.ok(/ (resize_page|take_snapshot|take_screenshot|press_key|list_console_messages) 2( |$)/.test(c), `pageId 2 expected: ${c}`);
}
assert.ok(out.calls.some((c) => c.includes(' resize_page 2 1440 900')));
assert.ok(out.calls.some((c) => c.includes(' press_key 2 Tab')), 'the focus-ring interaction always runs');
const idir = path.join(dir, 'interaction');
for (const f of ['baseline.png', 'focus-first.png', 'snapshot.txt', 'console.txt']) {
assert.ok(fs.existsSync(path.join(idir, f)), `${f} must exist`);
}
// The fence joins with a literal `/` ("$SCREENSHOT_DIR/interaction"); path.join would
// put a backslash there on Windows and the strings would differ by separator alone.
assert.equal(out.status, `captured (2 state(s), 0 failed) in ${dir}/interaction`);
});
test('versionOverrideFlowsIntoTheNpxSpec', (t) => {
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), CHROME_DEVTOOLS_MCP_VERSION: '9.9.9' },
});
assert.ok(out.calls[0].includes('-p chrome-devtools-mcp@9.9.9 '), out.calls[0]);
});
test('devUrlDefaultsToTheStaticBlocksPort', (t) => {
const out = runInteractionFence(t, { chrome: true, env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t) } });
assert.ok(out.calls.some((c) => c.includes(' new_page http://localhost:3000')), out.calls.join(' | '));
});
test('failedCaptureIsNotCountedLeavesNoFileAndStillStops', (t) => {
const dir = shotsDir(t);
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: dir, STUB_FAIL_SCREENSHOT: '1' },
});
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.equal(verbs[verbs.length - 1], 'stop');
assert.equal(out.status, 'not captured (driver or capture failure)');
assert.ok(!fs.existsSync(path.join(dir, 'interaction', 'baseline.png')), 'a zero-byte capture is removed, not counted');
assert.match(out.stdout, /interaction capture FAILED: baseline/);
});
test('snapshotFailureCountsAsAFailedStepAndRemovesAStaleSnapshot', (t) => {
const dir = shotsDir(t);
// A reused directory can hold a snapshot from an earlier run; its uids belong to a
// page this run never saw.
fs.mkdirSync(path.join(dir, 'interaction'));
fs.writeFileSync(path.join(dir, 'interaction', 'snapshot.txt'), 'uid=9_9 stale');
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: dir, STUB_FAIL_SNAPSHOT: '1' },
});
assert.match(out.stdout, /interaction step FAILED: take_snapshot/);
assert.ok(!fs.existsSync(path.join(dir, 'interaction', 'snapshot.txt')), 'stale uids must not survive a failed snapshot');
assert.match(out.status, /^captured \(2 state\(s\), 1 failed\)/, 'two clean screenshots must not read as 0 failed');
});
test('crlfDriverOutputStillYieldsThePageId', (t) => {
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_CRLF: '1' },
});
assert.ok(out.calls.some((c) => c.includes(' resize_page 2 1440 900')), `page id must parse from CRLF output: ${out.calls.join(' | ')}`);
assert.match(out.status, /^captured \(2 state\(s\), 0 failed\)/);
});
test('newPageThatPrintsAPageLineButExitsNonZeroIsAFailedNavigation', (t) => {
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_NEWPAGE_RC: '7' },
});
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.deepEqual(verbs, ['start', 'new_page', 'stop'], 'partial output must not be mistaken for a page id');
assert.match(out.stdout, /new_page FAILED/);
assert.equal(out.status, 'not captured (driver or capture failure)');
});
test('underErrexitAndPipefailAFailedNewPageStillReachesStop', (t) => {
const out = runInteractionFence(t, {
chrome: true,
strict: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_NEWPAGE_RC: '7' },
});
assert.equal(out.r.status, 0, `the block must not abort: ${out.r.stderr}`);
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.deepEqual(verbs, ['start', 'new_page', 'stop']);
});
test('underErrexitAndPipefailTheHappyPathIsUnchanged', (t) => {
const out = runInteractionFence(t, {
chrome: true,
strict: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t) },
});
assert.equal(out.r.status, 0, out.r.stderr);
assert.match(out.status, /^captured \(2 state\(s\), 0 failed\)/);
});
test('pressKeyFailureCountsAndSkipsTheFocusCapture', (t) => {
const dir = shotsDir(t);
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: dir, STUB_FAIL_PRESS: '1' },
});
assert.match(out.stdout, /interaction step FAILED: press_key Tab/);
assert.ok(!fs.existsSync(path.join(dir, 'interaction', 'focus-first.png')));
assert.match(out.status, /^captured \(1 state\(s\), 1 failed\)/);
});
test('newPageWithoutASelectedPageStopsTheDaemonAndCapturesNothing', (t) => {
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_FAIL_NEWPAGE: '1' },
});
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.deepEqual(verbs, ['start', 'new_page', 'stop'], verbs.join(','));
assert.match(out.stdout, /new_page FAILED/);
assert.equal(out.status, 'not captured (driver or capture failure)');
});
test('failedStartNeverIssuesStop', (t) => {
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_FAIL_START: '1' },
});
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.deepEqual(verbs, ['start'], 'stop only follows a start that succeeded');
assert.match(out.stdout, /start FAILED/);
assert.equal(out.status, 'not captured (driver or capture failure)');
});
test('everyDriverInvocationGoesThroughTheBoundedWrapper', () => {
// Static: the only bare `$CDT` is the wrapper's own spawn; every call site names a ceiling.
const lines = screenshotApproachFences().interaction;
const bare = lines.filter((l) => /\$CDT /.test(l) && !/\$CDT "\$@" &/.test(l) && !/^\s*#/.test(l));
assert.deepEqual(bare, [], `unbounded driver call(s): ${bare.join(' | ')}`);
const calls = lines.filter((l) => /(^|[\s(!])cdt /.test(l) && !/^\s*#/.test(l) && !/^\s*cdt\(\)/.test(l));
assert.ok(calls.length >= 7, `expected the seven driver verbs plus stop through the wrapper, saw ${calls.length}`);
for (const c of calls) assert.match(c, /cdt "\$CDT_T_(START|STEP)" /, `call without a ceiling: ${c}`);
});
test('aHungStartIsKilledAtItsCeilingAndReportedAsAFailedStart', (t) => {
const started = Date.now();
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_HANG: 'start', CHROME_DEVTOOLS_START_TIMEOUT: '1' },
});
// 1 s ceiling + 2 s KILL grace, with slack — never the stub's 300 s or the harness's 30 s cap.
assert.ok(Date.now() - started < 8000, `the ceiling, not the stub, ended the call (${Date.now() - started} ms)`);
assert.deepEqual(out.calls.map((c) => c.split(' ')[6]), ['start'], 'a start that never returned is a failed start: no stop');
assert.match(out.stdout, /start FAILED/);
assert.equal(out.status, 'not captured (driver or capture failure)');
});
test('aHungCaptureIsKilledAtItsCeilingAndStopIsStillReached', (t) => {
const dir = shotsDir(t);
const started = Date.now();
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: dir, STUB_HANG: 'take_screenshot', CHROME_DEVTOOLS_STEP_TIMEOUT: '1' },
});
// two hung captures: 2 x (1 s ceiling + 2 s KILL grace), with slack
assert.ok(Date.now() - started < 12000, `two hung captures at a 1 s ceiling (${Date.now() - started} ms)`);
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.equal(verbs[verbs.length - 1], 'stop', 'a timed-out step still reaches the stop');
assert.match(out.stdout, /interaction capture FAILED: baseline/);
assert.ok(!fs.existsSync(path.join(dir, 'interaction', 'baseline.png')));
assert.equal(out.status, 'not captured (driver or capture failure)');
});
test('aHungNewPageWhoseChildHoldsStdoutIsStillCutOffAtTheCeiling', (t) => {
// new_page is the one verb whose output the fence captures with $(...): the substitution ends
// only when every writer closes stdout, so killing the driver's parent alone would block here
// for the stub's full 300 s (measured on the pre-fix wrapper against a real npx tree).
const started = Date.now();
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_HANG: 'new_page', CHROME_DEVTOOLS_STEP_TIMEOUT: '1' },
});
assert.ok(Date.now() - started < 8000, `the process-group kill must release the capture (${Date.now() - started} ms)`);
assert.deepEqual(out.calls.map((c) => c.split(' ')[6]), ['start', 'new_page', 'stop'], out.calls.join(' | '));
assert.match(out.stdout, /new_page FAILED/);
assert.equal(out.status, 'not captured (driver or capture failure)');
});
test('aNewPageWhoseLeaderExitsWhileAChildHoldsStdoutIsStillCutOffAtTheCeiling', (t) => {
// The driver's leader exits at once but leaves a child holding the $(...) pipe: the
// substitution stays open-ended unless the watchdog keeps watching the process GROUP
// rather than the leader pid it was handed. Negative-controlled: a leader-pid poll stands
// down the moment the leader is gone and this blocks for the stub's full 300 s.
const started = Date.now();
const out = runInteractionFence(t, {
chrome: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_ORPHAN: 'new_page', CHROME_DEVTOOLS_STEP_TIMEOUT: '1' },
});
assert.ok(Date.now() - started < 8000, `the group must be watched, not the leader (${Date.now() - started} ms)`);
assert.deepEqual(out.calls.map((c) => c.split(' ')[6]), ['start', 'new_page', 'stop'], out.calls.join(' | '));
assert.match(out.stdout, /new_page FAILED/);
assert.equal(out.status, 'not captured (driver or capture failure)');
});
test('aWatchdogWhoseClockCannotLaunchStandsDownInsteadOfKillingTheStep', (t) => {
// The watchdog's clock is `sleep`. If it cannot launch (an msys fork failure under load, an
// absent binary), the watchdog must stand down — never fire at once and kill a healthy driver
// call. The stub takes a real driver call's shape (a few hundred ms, not the instant exit a
// premature kill would miss). Negative-controlled: a `sleep & wait $!` watchdog (the trap form
// this replaced) fires at once here and `start` reads as failed.
const out = runInteractionFence(t, {
chrome: true,
brokenSleep: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_DELAY: '0.3' },
});
assert.equal(out.r.status, 0, out.r.stderr);
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.deepEqual([verbs[0], verbs[verbs.length - 1]], ['start', 'stop'], verbs.join(','));
assert.match(out.status, /^captured \(2 state\(s\), 0 failed\)/, 'a watchdog without a clock must not kill the step');
});
test('underErrexitAnAbortAfterStartStillReachesStopThroughTheTrap', (t) => {
const out = runInteractionFence(t, {
chrome: true,
strict: true,
failAfter: 'ishot baseline',
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t) },
});
assert.notEqual(out.r.status, 0, 'the injected bare failure must abort the block under errexit');
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.equal(verbs[verbs.length - 1], 'stop', `the EXIT trap owes the stop: ${verbs.join(',')}`);
assert.equal(verbs.filter((v) => v === 'stop').length, 1, 'once — the flag makes the trap a no-op after an in-order stop');
});
test('aSubshellCopyOfTheFenceStateNeverIssuesAStop', (t) => {
// A driver call can run in a subshell that inherits CDT_STARTED=1 (a $(...) capture).
// A cdt_stop reached in one of them — CI's ubuntu job hit that through a timing race — must be
// inert: only the shell that installed the EXIT trap may issue the stop. Deterministic stand-in
// for the race: call cdt_stop from a subshell right after the trap is installed.
const out = runInteractionFence(t, {
chrome: true,
injectAfter: { marker: 'trap cdt_stop EXIT', line: '( cdt_stop )' },
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t) },
});
assert.equal(out.r.status, 0, out.r.stderr);
const verbs = out.calls.map((c) => c.split(' ')[6]);
assert.equal(verbs.filter((v) => v === 'stop').length, 1, `one stop, from the installing shell only: ${verbs.join(',')}`);
assert.equal(verbs[1], 'new_page', 'the subshell call must not have stopped the daemon before the capture ran');
assert.match(out.status, /^captured \(2 state\(s\), 0 failed\)/);
});
test('aFailedResizeIsCountedAndTheCapturesStillRun', (t) => {
const out = runInteractionFence(t, {
chrome: true,
strict: true,
env: { INTERACTION_CAPTURE: 'true', SCREENSHOT_DIR: shotsDir(t), STUB_FAIL_RESIZE: '1' },
});
assert.equal(out.r.status, 0, out.r.stderr);
assert.match(out.stdout, /interaction step FAILED: resize_page/);
assert.match(out.status, /^captured \(2 state\(s\), 1 failed\)/, 'the resize is a step, not the audit');
});
});
// ---------------------------------------------------------------------------
// The gitignore gate: run it under bash against a fresh and a pre-existing file.
// ---------------------------------------------------------------------------
function gitignoreGateFence() {
const lines = splitLines(fs.readFileSync(AUDITOR_PATH, 'utf8'));
const out = [];
let inSection = false;
let inFence = false;
for (const line of lines) {
if (!inSection) { if (line.includes('<gitignore_gate>')) inSection = true; continue; }
if (line.includes('</gitignore_gate>')) break;
if (!inFence) { if (line.trim() === `${FENCE}bash`) inFence = true; continue; }
if (line.trim() === FENCE) break;
out.push(line);
}
assert.ok(out.length > 0, '<gitignore_gate> must carry a bash fence');
return out;
}
function runGitignoreGate(t, seed) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-gitignore-'));
t.after(() => cleanup(tmp));
if (seed !== undefined) {
fs.mkdirSync(path.join(tmp, '.planning', 'ui-reviews'), { recursive: true });
fs.writeFileSync(path.join(tmp, '.planning', 'ui-reviews', '.gitignore'), seed);
}
const script = path.join(tmp, 'gate.sh');
fs.writeFileSync(script, ['#!/bin/bash', 'set -e', ...gitignoreGateFence(), ''].join('\n'));
const run = () => spawnSync('bash', [script], { encoding: 'utf8', timeout: FENCE_RUN_TIMEOUT_MS, cwd: tmp });
const read = () => splitLines(fs.readFileSync(path.join(tmp, '.planning', 'ui-reviews', '.gitignore'), 'utf8')).filter(Boolean);
return { tmp, run, read };
}
describe('gitignore gate (bash)', { skip: HAS_BASH ? false : 'bash not on PATH' }, () => {
const REQUIRED = ['*.png', '*.webp', '*.jpg', '*.jpeg', '*.gif', '*.bmp', '*.tiff', 'interaction/'];
test('aFreshFileCoversTheImagesAndTheCaptureDirectory', (t) => {
const g = runGitignoreGate(t);
const r = g.run();
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created \.planning\/ui-reviews\/\.gitignore/);
const lines = g.read();
for (const p of REQUIRED) assert.ok(lines.includes(p), `${p} must be ignored`);
assert.ok(lines.includes('interaction/'), 'snapshot.txt and console.txt are covered by the directory, not by an extension');
});
test('anExistingImageOnlyFileGainsTheCaptureDirectoryAndKeepsItsOwnLines', (t) => {
// The shape every project that ran an audit before interaction capture existed has on disk.
const seed = '# Screenshot files — never commit binary assets\n*.png\n*.webp\n*.jpg\n*.jpeg\n*.gif\n*.bmp\n*.tiff\n';
const g = runGitignoreGate(t, seed);
const r = g.run();
assert.equal(r.status, 0, r.stderr);
assert.doesNotMatch(r.stdout, /Created/, 'an existing file is upgraded in place, never recreated');
const lines = g.read();
assert.equal(lines[0], '# Screenshot files — never commit binary assets', 'the user\'s file keeps its own header');
assert.ok(lines.includes('interaction/'), 'a pre-existing file must not stay image-only');
assert.equal(lines.filter((l) => l === '*.png').length, 1, 'present patterns are not duplicated');
});
test('theGateIsIdempotent', (t) => {
const g = runGitignoreGate(t);
assert.equal(g.run().status, 0);
const once = g.read();
assert.equal(g.run().status, 0);
assert.deepEqual(g.read(), once, 'a second run appends nothing');
});
});
describe('documentation', () => {
test('configurationMdDocumentsTheKeyAsDefaultOff', () => {
const row = splitLines(fs.readFileSync(DOCS_CONFIG_PATH, 'utf8'))
.find((l) => l.startsWith(`| \`${KEY}\``));
assert.ok(row, `docs/CONFIGURATION.md must carry a row for ${KEY}`);
assert.ok(row.includes('| boolean |'), row);
assert.ok(row.includes('| `false` |'), row);
assert.ok(row.includes('enable-ui-interaction-capture.md'), 'the row must link the how-to');
});
test('howToExistsAndNamesBothOffSwitches', () => {
const src = fs.readFileSync(HOWTO_PATH, 'utf8');
assert.ok(src.includes(`config-set ${KEY} true`));
assert.ok(src.includes(`config-set ${KEY} false`));
assert.ok(src.includes('CHROME_BIN'));
});
test('howToNamesTheFloorTheCeilingsAndTheGitignoreCoverage', () => {
const src = fs.readFileSync(HOWTO_PATH, 'utf8');
assert.ok(src.includes('`^1.9.0`'), 'the floor the fence resolves by default');
assert.ok(!src.includes('^1.8.0'), 'no stale floor');
assert.ok(src.includes('CHROME_DEVTOOLS_START_TIMEOUT'));
assert.ok(src.includes('CHROME_DEVTOOLS_STEP_TIMEOUT'));
assert.ok(src.includes('`interaction/`'), 'the directory the gitignore gate covers');
});
});