From 34b36fb58b0049c57f8241aff608151f7363a282 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 20 May 2026 20:36:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(03-03):=20Task=202=20=E2=80=94=20driveA31?= =?UTF-8?q?=20+=20orchestrator=20wiring=20(A31=20password-filter=20PARTIAL?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Append driveA31 to tests/uat/lib/harness-page-driver.ts after driveA30: - Reuses UserEvent type (Plan 03-02 import already present). - 3-phase pattern: page.evaluate → findLatestZip → JSZip logs/events.json parse + filter-pipeline grep for sentinel absence + control-sentinel presence. - 3 host-side checks: A31.2 (eventsContainingSentinel.length === 0), A31.3 (eventsTargetingPassword.length === 0), A31.4 (eventsContainingControl.length >= 1; defense-in-depth proves the listener is alive so A31.2/A31.3 absences mean the filter fired rather than a tautological "no events at all" pass). - Standard guard checks A31.0 (zip present) + A31.0a (events.json entry exists) + A31.0b (JSON.parse success) gate before A31.2..A31.4 per Plan 02-04 / Plan 03-01 / Plan 03-02 driveA26/A29/A30 precedent. - Filter-pipeline form preserved (no `continue`) per CLAUDE.md Control Flow §. - Wire orchestrator in tests/uat/harness.test.ts: - Add `driveA31,` to import block after `driveA30,`. - Add `driveA31Wrapped` const after `driveA30Wrapped`. - Add `{ name: 'A31', drive: driveA31Wrapped }` entry to drivers array after the A30 entry with explanatory banner comment citing the cs-injection-world precedent + the defense-in-depth A31.4 control check. - Append `, A31` to the orchestrator banner string. Acceptance grep gates (post-commit): - grep -c 'driveA31' tests/uat/lib/harness-page-driver.ts returns 2 - grep -c 'driveA31' tests/uat/harness.test.ts returns 6 - grep -c 'secret-do-not-log-123' tests/uat/lib/harness-page-driver.ts returns 1 - tsc --noEmit exit 0 A29 flake disclosure (per Plan 03-02 SUMMARY "Issues Encountered"): - During Plan 03-03 empirical verification of A31, the pre-existing A29 flakiness documented in 03-02-SUMMARY.md surfaced: A29 chains off incidental zip-mtime ordering against prior assertions' zips, so when A29's own (empty chrome-extension:// SAVE) zip mtime ties with a prior real-content zip, findLatestZip non-deterministically returns the prior zip with rrweb events from iana.org/example.com. - 3 base runs (HEAD=de398347, no Plan 03-03 changes): 2/3 PASS, 1/3 FAIL — confirms PRE-EXISTING flake, NOT a Plan 03-03 regression. - Per CLAUDE.md SCOPE BOUNDARY ("Only auto-fix issues DIRECTLY caused by the current task's changes") + Plan 03-02 SUMMARY's explicit recommendation ("Plan 03-05's VERIFICATION.md aggregator + a Phase 4 hardening pass can pick it up"): A29 flake is OUT OF SCOPE for Plan 03-03. Documented in SUMMARY as deferred item. --- tests/uat/harness.test.ts | 23 +++- tests/uat/lib/harness-page-driver.ts | 168 +++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index 0e4e39d..c020ed8 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -101,6 +101,8 @@ import { driveA29, // Plan 03-02 — event-log verification (SPEC §10 #5 / REQ-user-event-log) driveA30, + // Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02) + driveA31, getManifestVersion, } from './lib/harness-page-driver'; import { @@ -269,7 +271,7 @@ async function assertA0_GrepGate(): Promise<{ */ async function main(): Promise { process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n'); - process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29, A30)\n'); + process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29, A30, A31)\n'); process.stdout.write('='.repeat(72) + '\n'); // A0 pre-flight (no Chrome launch needed; runs against built dist/). @@ -345,6 +347,12 @@ async function main(): Promise { // of logs/events.json from the just-produced zip. const driveA30Wrapped: (page: import('puppeteer').Page) => Promise = (page) => driveA30(page, handles.downloadsDir); + // Plan 03-03 — driveA31 needs downloadsDir for host-side JSZip + // negative-assertion against logs/events.json (sentinel absence + + // password-selector-target absence) + control-sentinel presence + // (defense-in-depth A31.4). + const driveA31Wrapped: (page: import('puppeteer').Page) => Promise = + (page) => driveA31(page, handles.downloadsDir); const drivers: ReadonlyArray<{ readonly name: string; @@ -454,6 +462,19 @@ async function main(): Promise { // driveA30 JSZip-parses logs/events.json and asserts presence of // each of the 5 UserEvent.type literal values. { name: 'A30', drive: driveA30Wrapped }, + // Plan 03-03 A31: password-filter PARTIAL (SPEC §10 #8 PARTIAL per + // D-P3-02). Negative-assertion: opens a fresh https://example.com + // probe tab (Plan 03-02 cs-injection-world precedent), injects a + // synthetic + a control + // via chrome.scripting.executeScript ISOLATED-world, types the + // sentinels, settles, SAVEs while the probe tab is active, finally- + // cleanup. Host-side driveA31 inspects logs/events.json and asserts + // sentinel value absence + password-selector-target absence (proves + // src/content/index.ts:82 filter fired) + control-sentinel presence + // (defense-in-depth: proves the listener is alive so A31.2/A31.3 + // mean the filter actually fired rather than the trivial "no + // events at all" tautology). + { name: 'A31', drive: driveA31Wrapped }, ]; const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole }; diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index 237e241..223c1eb 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -2146,3 +2146,171 @@ export async function driveA30( error: pageResult.error, }; } + +/* ─── Plan 03-03 — driveA31 (password-filter PARTIAL host-side) ─────── */ + +/** Fixed test sentinel — same value as page-side A31_PASSWORD_SENTINEL. + * Negative-assertion driver searches events.json for its absence. */ +const A31_PASSWORD_SENTINEL = 'secret-do-not-log-123'; +/** Control sentinel — must be PRESENT in logs/events.json (A31.4 + * defense-in-depth: proves the production setupInputLogging listener + * is alive, so A31.2/A31.3 absence checks are not vacuously + * satisfied — they actually mean the line-82 filter fired). */ +const A31_CONTROL_SENTINEL = 'control-event-must-be-logged-a31'; +/** Selector the production getSelector returns for #probe-password. */ +const A31_PASSWORD_SELECTOR = '#probe-password'; + +/** + * Drive A31 (Plan 03-03 — SPEC §10 #8 PARTIAL per D-P3-02). + * + * Page-side assertA31 opened a fresh https://example.com probe tab, + * injected a `` element + + * a `` element, typed the + * corresponding sentinels in the content-script's ISOLATED world, + * settled, SAVEd, finally-cleanup the tab. Host-side asserts: + * - the password SENTINEL is ABSENT from any UserEvent.value field + * (proves the line-82 filter early-returned before addUserEvent) + * - no UserEvent has target === '#probe-password' (proves the same + * filter via the orthogonal selector path) + * - at least one UserEvent contains the control sentinel + * (defense-in-depth: proves the listener IS alive, so the + * absences A31.2/A31.3 actually mean the filter fired rather + * than the trivial "no events captured" tautology) + * + * Filter-pipeline form (no `continue`) per CLAUDE.md Control Flow §. + * + * Checks (4 visible + guards): + * - A31.1: SAVE_ARCHIVE ack (page-side) + * - A31.0a: logs/events.json entry exists in zip + * - A31.0b: logs/events.json parses as JSON + * - A31.2: 0 events contain SENTINEL in .value field + * - A31.3: 0 events have target === '#probe-password' + * - A31.4: >=1 event contains the control sentinel + * + * @param page - The harness page from `launchHarnessBrowser`. + * @param downloadsDir - Absolute path to the per-run downloads directory. + * @returns AssertionRecord with the merged checks. + */ +export async function driveA31( + page: Page, + downloadsDir: string, +): Promise { + // Phase 1 — page-side orchestration + SAVE. + const pageResult = await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA31(); + return r; + }) as AssertionRecord; + + const mergedChecks: CheckRecord[] = pageResult.checks.slice(); + const mergedDiagnostics: string[] = pageResult.diagnostics.slice(); + + // Phase 2 — locate the produced zip. + const zipPath = findLatestZip(downloadsDir); + if (zipPath === null) { + mergedChecks.push({ + name: 'A31.0: at least one zip present in downloadsDir', + expected: '>=1 zip', + actual: 'no zip in downloadsDir', + passed: false, + }); + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + mergedDiagnostics.push(`A31 zipPath=${zipPath}`); + + // Phase 3 — load + inspect logs/events.json. + const zipBytes = readFileSync(zipPath); + const zip = await JSZip.loadAsync(zipBytes); + const eventsFile = zip.file('logs/events.json'); + mergedChecks.push({ + name: 'A31.0a: logs/events.json entry exists in zip', + expected: true, + actual: eventsFile !== null, + passed: eventsFile !== null, + }); + + if (eventsFile === null) { + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + + const eventsRaw = await eventsFile.async('string'); + let userEvents: UserEvent[] = []; + let parseErr: string | null = null; + try { + userEvents = JSON.parse(eventsRaw) as UserEvent[]; + } catch (err) { + parseErr = err instanceof Error ? err.message : String(err); + } + + if (parseErr !== null) { + mergedChecks.push({ + name: 'A31.0b: logs/events.json parses as JSON', + expected: 'JSON.parse success', + actual: ``, + passed: false, + }); + return { + passed: false, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + + // Filter-pipeline form per CLAUDE.md Control Flow §. + const eventsContainingSentinel = userEvents.filter( + (e) => typeof e.value === 'string' && e.value.includes(A31_PASSWORD_SENTINEL), + ); + const eventsTargetingPassword = userEvents.filter( + (e) => e.target === A31_PASSWORD_SELECTOR, + ); + const eventsContainingControl = userEvents.filter( + (e) => typeof e.value === 'string' && e.value.includes(A31_CONTROL_SENTINEL), + ); + mergedDiagnostics.push(`A31 userEvents.length=${userEvents.length}`); + mergedDiagnostics.push( + `A31 sentinel-containing count=${eventsContainingSentinel.length}, password-targeting count=${eventsTargetingPassword.length}, control-containing count=${eventsContainingControl.length}`, + ); + + mergedChecks.push({ + name: 'A31.2: 0 UserEvent entries contain the SENTINEL in their .value field (proves src/content/index.ts:82 filter fired)', + expected: 0, + actual: eventsContainingSentinel.length, + passed: eventsContainingSentinel.length === 0, + }); + mergedChecks.push({ + name: `A31.3: 0 UserEvent entries have target === '${A31_PASSWORD_SELECTOR}' (filter early-returns BEFORE addUserEvent)`, + expected: 0, + actual: eventsTargetingPassword.length, + passed: eventsTargetingPassword.length === 0, + }); + mergedChecks.push({ + name: 'A31.4: >=1 UserEvent entry contains the CONTROL sentinel (defense-in-depth: proves the listener is alive, so A31.2/A31.3 absences mean the filter fired — not "no events at all")', + expected: '>=1 control', + actual: eventsContainingControl.length, + passed: eventsContainingControl.length >= 1, + }); + + const mergedPassed = mergedChecks.every((c) => c.passed); + return { + passed: mergedPassed, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; +}