Move the three load-bearing prototype files from `tests/uat/prototype/`
to their production paths under `tests/uat/`, leaving the architectural
narrative (research findings, BLOCKER citations, falsification table
references) intact. No behavioral changes — A6 still PASSES 5/5 in ~7s
end-to-end from the new paths.
File moves (git mv preserves history):
- tests/uat/prototype/extension-page-harness.html
→ tests/uat/extension-page-harness.html
- tests/uat/prototype/extension-page-harness.ts
→ tests/uat/extension-page-harness.ts
- tests/uat/prototype/a6.test.ts
→ tests/uat/a6.test.ts
The `tests/uat/prototype/` directory is now empty (git does not track
empty directories; will not appear in subsequent `git status`).
Path-reference updates inside the moved files:
- tests/uat/extension-page-harness.html: `<p>` line referencing the
chrome-extension:// URL updated to drop `/prototype/`.
- tests/uat/extension-page-harness.ts: file-header docstring rewritten
to cite Plan 01-13 / Approach B / inheritance from c647f61. The
load-bearing architectural-finding comment block (MV3 SW dynamic-
import block falsification, Approach-B chrome.* surface summary)
is REWORDED but its semantic content + research citations are
PRESERVED — every load-bearing fact survives the rename.
- tests/uat/a6.test.ts:
* File-header rewritten to position the file as Plan 01-13's
standalone single-assertion entry point (preserves the future-
proof rationale: this entry stays around forever for fast TDD
iteration on A6 even after Wave 3 folds A6 into the orchestrator
harness.test.ts).
* REPO_ROOT resolvePath chain corrected from `..,..,..` to `..,..`
— the file is now two directory levels above the repo root
instead of three. Without this fix DIST_TEST_DIR would resolve
to a path one level above the actual repo root and
assertBundlePresent would throw. **VERIFIED by running the
driver: build path resolves correctly.**
* harnessUrl constant updated to drop `/prototype/` from the
chrome-extension://<id>/tests/uat/extension-page-harness.html
URL — must match the rollup emission path in dist-test/.
* Stdout labels updated: 'PROTOTYPE A6 result' → 'A6 result',
'Plan 01-11 PROTOTYPE — A6 ... feasibility test' → 'Plan 01-13
— A6 (Bug B canonical) standalone driver'. Inside the docstrings
the historical 'originally landed as 01-11 prototype' provenance
is preserved per the plan's contract.
vite.test.config.ts:
- `rollupOptions.input` renamed `prototype_harness` → `extension_page_harness`
pointing at the new production path. crxjs emits the harness HTML
to `dist-test/tests/uat/extension-page-harness.html` (verified by
`ls dist-test/tests/uat/`).
- The `modulePreload: { polyfill: false }` line is PRESERVED — this
is the CRITICAL SW FIX per 01-11-SUMMARY (disabling the polyfill
is what makes the test bundle's offscreen-side dynamic import work
without crashing in non-DOM contexts that incorrectly try to call
document.querySelector).
- File-header comment §4 and the inline `define.__MOKOSH_UAT__` comment
are PRESERVED — load-bearing rationale for the dedicated build-time
token (vs `import.meta.env.MODE === 'test'` which collides with
vitest).
Verification (all GREEN):
- `npm run build:test` — exit 0; dist-test/ emits
`tests/uat/extension-page-harness.html` and `assets/extension_page_harness-*.js`.
- `npx tsx tests/uat/a6.test.ts` — exits 0 with "A6 result: PASS";
5/5 checks GREEN (SETUP: badge becomes REC; A6.1 badge==''; A6.2
popup==''; A6.3 notif delta==0; A6.4 isRecording=false). End-to-end
runtime ~7s headless on this workstation.
- `npx tsc --noEmit` — exit 0 (root tsconfig + tests/uat/tsconfig.json).
- `npx vitest run` — 92/92 GREEN; the moves do not touch any vitest-
discovered files.
- `npm run build` — exit 0; Tier-1 grep gate stays GREEN
(the moves do not touch production code).
Wave 2 (next): build out `tests/uat/lib/{launch,assertions,harness-page-
driver}.ts` around the extension-page architecture; rewrite
`tests/uat/a6.test.ts` to use the shared lib (still PASSES 5/5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
// tests/uat/a6.test.ts — Plan 01-13 standalone A6 entry point.
|
|
//
|
|
// Puppeteer-driven single-assertion driver for A6 (Bug B canonical).
|
|
// Originally landed as the Plan 01-11 prototype at commit c647f61;
|
|
// Plan 01-13 Wave 1 promoted this file from `tests/uat/prototype/` to
|
|
// the production path without behavioral change. Wave 2 will refactor
|
|
// the launch + console-capture + result-print plumbing into reusable
|
|
// lib helpers (`tests/uat/lib/{launch,assertions,harness-page-driver}
|
|
// .ts`) and rewrite this driver against them; Wave 3 folds A6 into
|
|
// `tests/uat/harness.test.ts` as the assertion of record for `npm run
|
|
// test:uat`. This standalone entry is RETAINED throughout for fast
|
|
// TDD iteration on the A6 contract (`npx tsx tests/uat/a6.test.ts` —
|
|
// ~7s end-to-end vs the orchestrator's ~60-90s for all 14).
|
|
//
|
|
// Assertion contract — A6 (Bug B canonical): when the offscreen
|
|
// recorder fires RECORDING_ERROR{error: 'user-stopped-sharing'}
|
|
// (simulated via dispatchEvent('ended') on the active video track per
|
|
// 01-11 RESEARCH §7 BLOCKER — track.stop() does NOT fire 'ended' per
|
|
// W3C spec), the SW state machine routes through setIdleMode (NOT
|
|
// setErrorMode): badge becomes empty, popup empties, isRecording=false,
|
|
// NO recovery notification fires. The prototype verified this PASSES
|
|
// 5/5 today AND FAILS on local revert of the Bug B fix at
|
|
// src/background/index.ts:776 — both halves of the RED-on-regression
|
|
// demo land in the Wave 3B commit body as the canonical TDD canon.
|
|
//
|
|
// Usage:
|
|
// tsx tests/uat/a6.test.ts
|
|
// HEADLESS=0 tsx tests/uat/a6.test.ts # debug view
|
|
//
|
|
// Pre-flight: requires `dist-test/` from `npm run build:test`. The test
|
|
// will fail loudly if the bundle is missing.
|
|
//
|
|
// References:
|
|
// - eyeo's MV3 testing journey (uses extension-internal test page +
|
|
// bidirectional messaging):
|
|
// https://developer.chrome.com/blog/eyeos-journey-to-testing-mv3-service%20worker-suspension
|
|
// - Chrome MV3 E2E testing official guide:
|
|
// https://developer.chrome.com/docs/extensions/mv3/end-to-end-testing/
|
|
|
|
import { existsSync, statSync } from 'node:fs';
|
|
import { dirname, resolve as resolvePath } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import puppeteer, { type Browser, type Page } from 'puppeteer';
|
|
|
|
// Plan 01-13 Wave 1: this file lives at `tests/uat/a6.test.ts` (was
|
|
// `tests/uat/prototype/a6.test.ts` pre-Wave-1). Repo root is two
|
|
// directory levels up — was three pre-Wave-1. The resolvePath chain
|
|
// MUST stay in sync with the on-disk location or `DIST_TEST_DIR` will
|
|
// resolve to the wrong path and `assertBundlePresent` will throw.
|
|
const HARNESS_FILE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = resolvePath(HARNESS_FILE_DIR, '..', '..');
|
|
const DIST_TEST_DIR = resolvePath(REPO_ROOT, 'dist-test');
|
|
|
|
/** Per-check record returned by the harness page. */
|
|
interface CheckRecord {
|
|
name: string;
|
|
expected: unknown;
|
|
actual: unknown;
|
|
passed: boolean;
|
|
}
|
|
|
|
/** Result returned by `window.__mokoshHarness.assertA6()`. */
|
|
interface HarnessAssertionResult {
|
|
passed: boolean;
|
|
name: string;
|
|
checks: CheckRecord[];
|
|
diagnostics: string[];
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Verify the test bundle is present; fail loudly if missing.
|
|
*
|
|
* @throws If dist-test/ is missing or not a directory.
|
|
*/
|
|
function assertBundlePresent(): void {
|
|
if (!existsSync(DIST_TEST_DIR)) {
|
|
throw new Error(
|
|
`dist-test/ missing at ${DIST_TEST_DIR} — run \`npm run build:test\` first.`,
|
|
);
|
|
}
|
|
if (!statSync(DIST_TEST_DIR).isDirectory()) {
|
|
throw new Error(`dist-test/ at ${DIST_TEST_DIR} is not a directory.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Launch Chrome with the test bundle loaded as an unpacked MV3
|
|
* extension. Returns the browser handle + resolved extension id.
|
|
*
|
|
* Bumps `protocolTimeout` from the default 30s to 90s so the
|
|
* end-to-end assertion (which does several sendMessage round-trips
|
|
* + waits for badge transitions) has enough headroom on slow CI
|
|
* runners without the assertion call itself timing out at the CDP layer.
|
|
*
|
|
* @returns Browser handle + extension id.
|
|
*/
|
|
async function launchChrome(): Promise<{
|
|
browser: Browser;
|
|
extensionId: string;
|
|
}> {
|
|
const headless = process.env.HEADLESS !== '0';
|
|
const browser = await puppeteer.launch({
|
|
enableExtensions: [DIST_TEST_DIR],
|
|
headless,
|
|
pipe: true,
|
|
protocolTimeout: 90_000,
|
|
args: [
|
|
'--no-sandbox',
|
|
// We do NOT need --auto-select-desktop-capture-source for the
|
|
// prototype because the fake getDisplayMedia bypasses the picker
|
|
// entirely. Including it would be a no-op.
|
|
],
|
|
});
|
|
|
|
// Resolve extension id. browser.extensions() returns a Map<id, Extension>
|
|
// populated asynchronously after the extension's manifest loads. Poll
|
|
// for up to 5s with a clear diagnostic on timeout.
|
|
const POLL_TIMEOUT_MS = 5_000;
|
|
const POLL_INTERVAL_MS = 100;
|
|
const pollStart = Date.now();
|
|
let extensionsMap = await browser.extensions();
|
|
while (extensionsMap.size === 0 && Date.now() - pollStart < POLL_TIMEOUT_MS) {
|
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
extensionsMap = await browser.extensions();
|
|
}
|
|
const entries = [...extensionsMap];
|
|
if (entries.length === 0) {
|
|
await browser.close();
|
|
throw new Error(
|
|
`No extensions loaded after ${POLL_TIMEOUT_MS}ms — dist-test/ malformed?`,
|
|
);
|
|
}
|
|
const [extensionId] = entries[0];
|
|
return { browser, extensionId };
|
|
}
|
|
|
|
/**
|
|
* Pretty-print the harness assertion result for stdout.
|
|
*
|
|
* @param result - The structured result from assertA6().
|
|
*/
|
|
function printResult(result: HarnessAssertionResult): void {
|
|
process.stdout.write('\n');
|
|
process.stdout.write('='.repeat(72) + '\n');
|
|
process.stdout.write(`A6 result: ${result.passed ? 'PASS' : 'FAIL'}\n`);
|
|
process.stdout.write(`Assertion: ${result.name}\n`);
|
|
if (result.error !== undefined) {
|
|
process.stdout.write(`Top-level error: ${result.error}\n`);
|
|
}
|
|
process.stdout.write('\nChecks:\n');
|
|
for (const check of result.checks) {
|
|
const mark = check.passed ? '[PASS]' : '[FAIL]';
|
|
process.stdout.write(` ${mark} ${check.name}\n`);
|
|
process.stdout.write(` expected: ${JSON.stringify(check.expected)}\n`);
|
|
process.stdout.write(` actual: ${JSON.stringify(check.actual)}\n`);
|
|
}
|
|
process.stdout.write('\nDiagnostics:\n');
|
|
for (const diag of result.diagnostics) {
|
|
process.stdout.write(` - ${diag}\n`);
|
|
}
|
|
process.stdout.write('='.repeat(72) + '\n');
|
|
}
|
|
|
|
/**
|
|
* Main prototype entry point. Returns the process exit code.
|
|
*
|
|
* @returns 0 on PASS, 1 on FAIL.
|
|
*/
|
|
async function main(): Promise<number> {
|
|
process.stdout.write('\nMokosh Plan 01-13 — A6 (Bug B canonical) standalone driver\n');
|
|
process.stdout.write('Architecture: extension-internal page + bridge + synthetic stream\n');
|
|
process.stdout.write('='.repeat(72) + '\n');
|
|
|
|
assertBundlePresent();
|
|
process.stdout.write(`Bundle: ${DIST_TEST_DIR}\n`);
|
|
|
|
process.stdout.write('Launching Chrome...\n');
|
|
const { browser, extensionId } = await launchChrome();
|
|
process.stdout.write(`Extension id: ${extensionId}\n`);
|
|
|
|
// Diagnostic capture buffers — flushed on result print.
|
|
const consoleLines: string[] = [];
|
|
|
|
let exitCode = 1;
|
|
try {
|
|
// Open the prototype harness page. The page lives at the test-build
|
|
// path (vite.test.config.ts adds it as a rollup input).
|
|
const harnessUrl = `chrome-extension://${extensionId}/tests/uat/extension-page-harness.html`;
|
|
process.stdout.write(`Opening: ${harnessUrl}\n`);
|
|
|
|
// Open a 'victim' page first — production code calls
|
|
// chrome.tabs.query({active:true}) and demands a tab with .url
|
|
// (the operator's recording-target page). The harness page itself
|
|
// is a chrome-extension:// URL which has no .url surfaced (without
|
|
// 'tabs' permission). We open a real http URL in a separate tab
|
|
// and bring it to front before REQUEST_PERMISSIONS fires.
|
|
const victimPage = await browser.newPage();
|
|
await victimPage.goto('about:blank');
|
|
// about:blank has tab.url === 'about:blank' (truthy), so production
|
|
// tab.id + tab.url check passes.
|
|
|
|
const page: Page = await browser.newPage();
|
|
page.on('console', (msg) => {
|
|
const line = `[page:${msg.type()}] ${msg.text()}`;
|
|
consoleLines.push(line);
|
|
process.stderr.write(line + '\n');
|
|
});
|
|
page.on('pageerror', (err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
const line = `[page:ERROR] ${msg}`;
|
|
consoleLines.push(line);
|
|
process.stderr.write(line + '\n');
|
|
});
|
|
|
|
// Also capture SW console logs (where production logger.* writes).
|
|
// The SW target appears when the extension loads — wait briefly,
|
|
// then attach a worker handle and forward console events.
|
|
try {
|
|
const swTarget = await browser.waitForTarget(
|
|
(t) => t.type() === 'service_worker' && t.url().includes(extensionId),
|
|
{ timeout: 10_000 },
|
|
);
|
|
const sw = await swTarget.worker();
|
|
if (sw !== null) {
|
|
sw.on('console', (msg) => {
|
|
const line = `[sw:${msg.type()}] ${msg.text()}`;
|
|
consoleLines.push(line);
|
|
process.stderr.write(line + '\n');
|
|
});
|
|
}
|
|
} catch (swAttachErr) {
|
|
process.stderr.write(
|
|
`(note: SW console attach skipped — ${String(swAttachErr)})\n`,
|
|
);
|
|
}
|
|
|
|
await page.goto(harnessUrl, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 10_000,
|
|
});
|
|
process.stdout.write('Page loaded; waiting for window.__mokoshHarness...\n');
|
|
|
|
// The harness page's bundled script installs window.__mokoshHarness
|
|
// on module-load. Wait for the bootstrap to land.
|
|
await page.waitForFunction(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where window types are loose.
|
|
() => (window as any).__mokoshHarness !== undefined,
|
|
{ timeout: 5_000 },
|
|
);
|
|
process.stdout.write('Harness page ready; invoking assertA6()...\n\n');
|
|
|
|
// Try also attaching to offscreen target console logs once it appears.
|
|
let offscreenAttached = false;
|
|
browser.on('targetcreated', async (target) => {
|
|
if (offscreenAttached) return;
|
|
const url = target.url();
|
|
if (
|
|
target.type() === 'background_page' &&
|
|
url.includes(extensionId) &&
|
|
url.includes('offscreen')
|
|
) {
|
|
offscreenAttached = true;
|
|
try {
|
|
const offPage = await target.asPage();
|
|
offPage.on('console', (msg) => {
|
|
const line = `[off:${msg.type()}] ${msg.text()}`;
|
|
consoleLines.push(line);
|
|
process.stderr.write(line + '\n');
|
|
});
|
|
} catch (offAttachErr) {
|
|
process.stderr.write(
|
|
`(note: offscreen console attach skipped — ${String(offAttachErr)})\n`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Bring the victim page to front so chrome.tabs.query({active:true})
|
|
// returns it (not the harness page) when production startVideoCapture
|
|
// runs. The harness page can still be evaluated against — Puppeteer's
|
|
// page handle doesn't care about active-tab state.
|
|
await victimPage.bringToFront();
|
|
|
|
// Run the end-to-end A6 assertion. The page-side code does all the
|
|
// orchestration — Puppeteer is just the trigger + result reader.
|
|
const result = await page.evaluate(async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context.
|
|
const harness = (window as any).__mokoshHarness;
|
|
const r = await harness.assertA6();
|
|
return r;
|
|
}) as HarnessAssertionResult;
|
|
|
|
printResult(result);
|
|
exitCode = result.passed ? 0 : 1;
|
|
} catch (err) {
|
|
process.stderr.write(`\n*** Top-level harness error: ${String(err)}\n`);
|
|
if (consoleLines.length > 0) {
|
|
process.stderr.write('\nCaptured console (last 50 lines):\n');
|
|
for (const line of consoleLines.slice(-50)) {
|
|
process.stderr.write(` ${line}\n`);
|
|
}
|
|
}
|
|
exitCode = 1;
|
|
} finally {
|
|
try {
|
|
await browser.close();
|
|
} catch (closeErr) {
|
|
process.stderr.write(`(non-fatal: browser close threw: ${String(closeErr)})\n`);
|
|
}
|
|
}
|
|
|
|
return exitCode;
|
|
}
|
|
|
|
const code = await main();
|
|
process.exit(code);
|