Plan-04-04 debug session-2 root cause: the offscreen-console capture
in tests/uat/lib/launch.ts:registerOffscreenConsoleAttach matched zero
offscreen targets across 4 spike runs, creating a critical observability
gap that prevented disambiguation of Plan 04-04 Wave 0 spike failure
mode.
Empirical investigation (tests/uat/spike-diagnose-offscreen-target.ts,
NEW): when chrome.offscreen.createDocument fires, Puppeteer's
`targetcreated` event fires with `type='other'` and `url=''` BEFORE the
CDP target metadata stabilizes. The previous filter (whether
`background_page` or `page`) never matched at event time. By the time
the metadata stabilizes (visible via `browser.targets()`), the
target's type is `'background_page'` (not `'page'` — MV2's
background_page type IS still used by Chrome's CDP for invisible
extension documents, despite MV3 abolishing classic background pages).
Fix:
- Match the offscreen target by URL pattern (load-bearing criterion;
type field is intentionally unchecked because it's unreliable at
targetcreated time).
- Bind to BOTH `targetcreated` AND `targetchanged` events (the latter
fires when the URL stabilizes after navigation).
- Add a `browser.targets()` enumeration race-free safety net for
cases where the offscreen target exists at registration time.
Verification: tests/uat/spike-diagnose-offscreen-target.ts now emits
`(launch: offscreen console attached — url=chrome-extension://.../src/offscreen/index.html)`
followed by `[off:log] [OS:Recorder] Recording started ...` (zero such
lines in any prior spike run).
Test-infra correctness fix; ZERO production source changes. FORBIDDEN_HOOK_STRINGS
inventory unchanged at 12 entries. No new test-only `__MOKOSH_UAT__` symbols.
References:
- .planning/debug/sw-offscreen-persistence-investigation-session-2.md
(session-2 debug note documenting empirical root cause)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
544 lines
23 KiB
TypeScript
544 lines
23 KiB
TypeScript
// tests/uat/lib/launch.ts — Plan 01-13 Wave 2.
|
|
//
|
|
// Approach-B harness launch helper. Inherits the Puppeteer launch +
|
|
// victim-page-bringToFront + harness-page-open pattern from the proven
|
|
// `tests/uat/a6.test.ts` driver (originally landed as Plan 01-11
|
|
// prototype at commit c647f61; promoted to production paths by 01-13
|
|
// Wave 1). Refactored into a reusable helper so Wave 3's 13 assertion
|
|
// drivers share the same setup overhead — one Chrome launch + one
|
|
// harness page + one victim page per `npm run test:uat` run.
|
|
//
|
|
// Architectural commitments (per 01-11-SUMMARY.md, DO NOT REGRESS):
|
|
// - Drive Chrome FROM INSIDE: `harnessPage` runs at
|
|
// `chrome-extension://<id>/tests/uat/extension-page-harness.html`
|
|
// with full chrome.* API access (Approach B; sw.evaluate fallback
|
|
// was falsified per SUMMARY §2 — only chrome.{loadTimes,csi}
|
|
// surfaced through CDP).
|
|
// - `victimPage` is a brought-to-front about:blank tab so the
|
|
// production `chrome.tabs.query({active:true})` sees a real tab
|
|
// with a `.url` (Plan 01-13 retains the `tabs` permission gap as
|
|
// out-of-scope; A2 + similar tests send `START_RECORDING` directly
|
|
// to offscreen, bypassing the SW's `startVideoCapture` which needs
|
|
// the tabs permission to read `tab.url`). Workaround documented in
|
|
// the plan's resolved-open-questions table row 2.
|
|
// - Downloads land in a per-run tmp dir (`mkdtempSync`) so A5 polling
|
|
// does not collide with operator downloads. Configured via CDP
|
|
// `Browser.setDownloadBehavior` on the harness page's CDP session.
|
|
// - SW + offscreen consoles forwarded to `swConsole` / `offConsole`
|
|
// accumulating string buffers. Offscreen attach via
|
|
// `browser.on('targetcreated')` is OPPORTUNISTIC per the prototype
|
|
// pattern — offscreen targets appear asynchronously when
|
|
// `chrome.offscreen.createDocument` runs from inside the harness
|
|
// page; the harness must not block waiting for them.
|
|
// - NO `--auto-select-desktop-capture-source` flag: unreliable in
|
|
// `--headless=new` per 01-11-SUMMARY falsification 4. The synthetic
|
|
// `installFakeDisplayMedia` (offscreen-hooks.ts eager install)
|
|
// bypasses Chrome's picker entirely.
|
|
//
|
|
// References:
|
|
// - puppeteer.launch options:
|
|
// https://pptr.dev/api/puppeteer.launchoptions
|
|
// - puppeteer.Browser.extensions():
|
|
// https://pptr.dev/api/puppeteer.browser.extensions
|
|
// - CDP Browser.setDownloadBehavior (per-context download path):
|
|
// https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setDownloadBehavior
|
|
// - puppeteer CDP session helper:
|
|
// https://pptr.dev/api/puppeteer.cdpsession
|
|
// - Node fs.mkdtempSync:
|
|
// https://nodejs.org/api/fs.html#fsmkdtempsyncprefix-options
|
|
|
|
import { existsSync, mkdtempSync, statSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
import puppeteer, { type Browser, type Page } from 'puppeteer';
|
|
|
|
/** Repo root resolved from this file's location (tests/uat/lib/launch.ts). */
|
|
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');
|
|
|
|
/** Time bounds for the various polling/attach steps. Keep in sync with
|
|
* the comments below — each value has a rationale, not a guess. */
|
|
const EXTENSION_ID_POLL_TIMEOUT_MS = 5_000;
|
|
const EXTENSION_ID_POLL_INTERVAL_MS = 100;
|
|
const HARNESS_BOOTSTRAP_TIMEOUT_MS = 5_000;
|
|
const HARNESS_GOTO_TIMEOUT_MS = 10_000;
|
|
const SW_TARGET_ATTACH_TIMEOUT_MS = 10_000;
|
|
/** Bumped from the puppeteer default 30s to give the assertions
|
|
* several sendMessage round-trips of CDP headroom on slow CI runners. */
|
|
const PROTOCOL_TIMEOUT_MS = 90_000;
|
|
|
|
/**
|
|
* Handles returned by `launchHarnessBrowser`. The caller owns the
|
|
* `browser` and is responsible for calling `browser.close()` in a
|
|
* `finally` block. `downloadsDir` is created by this function and is
|
|
* deliberately NOT cleaned up automatically — failing tests benefit
|
|
* from the operator inspecting the downloads dir post-mortem.
|
|
*/
|
|
export interface HarnessHandles {
|
|
readonly browser: Browser;
|
|
readonly extensionId: string;
|
|
readonly harnessPage: Page;
|
|
readonly victimPage: Page;
|
|
readonly downloadsDir: string;
|
|
/** Accumulating SW console log lines, format `[sw:<type>] <text>`. */
|
|
readonly swConsole: string[];
|
|
/** Accumulating offscreen console log lines, format `[off:<type>] <text>`. */
|
|
readonly offConsole: string[];
|
|
}
|
|
|
|
/**
|
|
* Options for `launchHarnessBrowser`. All fields optional; defaults
|
|
* apply (`headless: process.env.HEADLESS !== '0'`; `downloadsDir` ←
|
|
* fresh mkdtempSync).
|
|
*/
|
|
export interface LaunchOptions {
|
|
/** Override `--headless=new`; useful for visual debugging. */
|
|
readonly headless?: boolean;
|
|
/** Override the auto-created downloads dir; useful for cross-run debugging. */
|
|
readonly downloadsDir?: string;
|
|
}
|
|
|
|
/**
|
|
* Verify the test bundle is present at `dist-test/`; fail loudly with
|
|
* an actionable error if missing. The harness cannot launch without
|
|
* the bundle so failing early avoids confusing puppeteer errors.
|
|
*
|
|
* @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.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Poll `browser.extensions()` until at least one extension is loaded
|
|
* or the timeout elapses. Returns the first extension's id.
|
|
*
|
|
* @param browser - Puppeteer browser handle.
|
|
* @returns The resolved extension id string.
|
|
* @throws If no extension loads within `EXTENSION_ID_POLL_TIMEOUT_MS`.
|
|
*/
|
|
async function resolveExtensionIdWithPolling(browser: Browser): Promise<string> {
|
|
const pollStart = Date.now();
|
|
let extensionsMap = await browser.extensions();
|
|
while (
|
|
extensionsMap.size === 0 &&
|
|
Date.now() - pollStart < EXTENSION_ID_POLL_TIMEOUT_MS
|
|
) {
|
|
await new Promise((resolve) => setTimeout(resolve, EXTENSION_ID_POLL_INTERVAL_MS));
|
|
extensionsMap = await browser.extensions();
|
|
}
|
|
const entries = [...extensionsMap];
|
|
if (entries.length === 0) {
|
|
throw new Error(
|
|
`No extensions loaded after ${EXTENSION_ID_POLL_TIMEOUT_MS}ms — dist-test/ malformed?`,
|
|
);
|
|
}
|
|
const [extensionId] = entries[0];
|
|
return extensionId;
|
|
}
|
|
|
|
/**
|
|
* Attach a SW console listener that forwards every console event to
|
|
* the provided buffer (both for in-memory diagnostic capture AND for
|
|
* stderr streaming so the operator sees live logs during a hung
|
|
* assertion). Best-effort: if the SW target cannot be found inside
|
|
* `SW_TARGET_ATTACH_TIMEOUT_MS`, the failure is logged to stderr but
|
|
* the harness continues (the assertion may still pass — many
|
|
* assertions do not need SW console data).
|
|
*
|
|
* @param browser - Puppeteer browser handle.
|
|
* @param extensionId - The resolved extension id.
|
|
* @param swConsole - Accumulating string buffer to push log lines into.
|
|
*/
|
|
async function attachSwConsoleBestEffort(
|
|
browser: Browser,
|
|
extensionId: string,
|
|
swConsole: string[],
|
|
): Promise<void> {
|
|
try {
|
|
const swTarget = await browser.waitForTarget(
|
|
(t) => t.type() === 'service_worker' && t.url().includes(extensionId),
|
|
{ timeout: SW_TARGET_ATTACH_TIMEOUT_MS },
|
|
);
|
|
const sw = await swTarget.worker();
|
|
if (sw !== null) {
|
|
/**
|
|
* Named callback per project style — every chrome.* console event
|
|
* formatted with a leading `[sw:<type>]` tag for grep-ability.
|
|
*/
|
|
const onSwConsole = (msg: { type: () => string; text: () => string }): void => {
|
|
const line = `[sw:${msg.type()}] ${msg.text()}`;
|
|
swConsole.push(line);
|
|
process.stderr.write(line + '\n');
|
|
};
|
|
sw.on('console', onSwConsole);
|
|
}
|
|
} catch (swAttachErr) {
|
|
process.stderr.write(
|
|
`(launch: SW console attach skipped — ${String(swAttachErr)})\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a `targetcreated` listener that lazily attaches the
|
|
* offscreen console once it appears. The offscreen target is created
|
|
* later (when the harness page calls `chrome.offscreen.createDocument`),
|
|
* so we cannot wait for it eagerly; instead we register the listener
|
|
* upfront and let it fire when the offscreen target spawns.
|
|
*
|
|
* Idempotent — only the first matching offscreen target is attached.
|
|
*
|
|
* Target.type() discrimination: empirically verified via the
|
|
* `spike-diagnose-offscreen-target.ts` helper at debug session-2 time
|
|
* (2026-05-21). Despite MV3 abolishing classic MV2 background pages,
|
|
* Chrome's CDP STILL reports an extension's offscreen document with
|
|
* `targetInfo.type='background_page'` (Puppeteer's TargetType maps this
|
|
* to `TargetType.BACKGROUND_PAGE` verbatim per
|
|
* node_modules/puppeteer-core/lib/puppeteer/cdp/Target.js:107-108).
|
|
* Observed `browser.targets()` enumeration with active offscreen
|
|
* recording:
|
|
*
|
|
* type=background_page url=chrome-extension://{id}/src/offscreen/index.html
|
|
*
|
|
* Plan-04-04 debug session-2 root cause + fix:
|
|
* - The PREVIOUS implementation only registered a `targetcreated`
|
|
* listener — but Puppeteer fires `targetcreated` at the moment the
|
|
* target is initially created, with `type='other'` and `url=''`,
|
|
* BEFORE the CDP target metadata stabilizes. By the time the
|
|
* listener checks `target.type()` / `target.url()` they're still
|
|
* unset. The filter (regardless of which type it checked) never
|
|
* matched, producing 0 `[off:*]` log lines across all spike runs.
|
|
* - The new implementation adds an existing-targets enumeration via
|
|
* `browser.targets()` AFTER a brief settle — at that point all
|
|
* targets have finalized URLs + types and the offscreen is reliably
|
|
* discoverable.
|
|
* - The `targetcreated` listener is retained as a safety net for
|
|
* environments where the offscreen is created LATER (post-launch),
|
|
* but it uses the same URL-pattern predicate that works whether or
|
|
* not the type field has stabilized — the URL is the load-bearing
|
|
* match criterion; the type check is defense-in-depth.
|
|
*
|
|
* @param browser - Puppeteer browser handle.
|
|
* @param extensionId - The resolved extension id.
|
|
* @param offConsole - Accumulating string buffer for offscreen log lines.
|
|
*/
|
|
function registerOffscreenConsoleAttach(
|
|
browser: Browser,
|
|
extensionId: string,
|
|
offConsole: string[],
|
|
): void {
|
|
let offscreenAttached = false;
|
|
|
|
/**
|
|
* Check whether a target matches the offscreen-document criteria.
|
|
*
|
|
* Match predicate: URL includes both the extension id AND the
|
|
* canonical offscreen path '/src/offscreen/' (which is
|
|
* `chrome.runtime.getURL('src/offscreen/index.html')` per
|
|
* src/background/index.ts:239). The URL is the load-bearing match
|
|
* criterion; the type field is intentionally NOT checked here
|
|
* because:
|
|
* - At `targetcreated` time, `type()` returns `'other'` and
|
|
* `url()` is empty; the listener may fire before metadata
|
|
* stabilizes, so type-based matching at event time is unreliable.
|
|
* - At `browser.targets()` enumeration time, the offscreen target's
|
|
* type is `'background_page'` (NOT `'page'`; see function docstring
|
|
* above for the empirical evidence).
|
|
* - Either way, the URL pattern is unique to the offscreen document
|
|
* and uncollidable with the harness page (whose URL ends with
|
|
* `/tests/uat/extension-page-harness.html`), the welcome page
|
|
* (`/src/welcome/welcome.html`), the victim page (`file://...`),
|
|
* or the SW (whose URL ends with `/service-worker-loader.js`).
|
|
*
|
|
* Both `url.includes` checks are needed:
|
|
* - url-includes-extension-id rules out the harness victim page
|
|
* (a file:// URL with no extension id);
|
|
* - url-includes-'/src/offscreen/' rules out every other
|
|
* extension-page realm (harness, welcome, popup).
|
|
*/
|
|
const isOffscreenTarget = (
|
|
target: { type: () => string; url: () => string },
|
|
): boolean => {
|
|
const url = target.url();
|
|
return url.includes(extensionId) && url.includes('/src/offscreen/');
|
|
};
|
|
|
|
/**
|
|
* Attach the console listener to a confirmed offscreen page target.
|
|
* Idempotent via the closed-over `offscreenAttached` flag.
|
|
*/
|
|
const attachToOffscreen = async (
|
|
target: { asPage: () => Promise<Page>; url: () => string },
|
|
): Promise<void> => {
|
|
if (offscreenAttached) {
|
|
return;
|
|
}
|
|
offscreenAttached = true;
|
|
try {
|
|
const offPage = await target.asPage();
|
|
/**
|
|
* Per-message callback — same tag format as the SW attach
|
|
* (`[off:<type>] <text>`).
|
|
*/
|
|
const onOffConsole = (msg: { type: () => string; text: () => string }): void => {
|
|
const line = `[off:${msg.type()}] ${msg.text()}`;
|
|
offConsole.push(line);
|
|
process.stderr.write(line + '\n');
|
|
};
|
|
offPage.on('console', onOffConsole);
|
|
process.stderr.write(
|
|
`(launch: offscreen console attached — url=${target.url()})\n`,
|
|
);
|
|
} catch (offAttachErr) {
|
|
process.stderr.write(
|
|
`(launch: offscreen console attach skipped — ${String(offAttachErr)})\n`,
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Generic target handler — checks any target for the offscreen
|
|
* extension URL pattern, attaches the console listener on the first
|
|
* match. Idempotent via the closed-over `offscreenAttached` flag.
|
|
*
|
|
* Bound to BOTH `targetcreated` and `targetchanged` events:
|
|
* - `targetcreated` fires on initial target creation (but at that
|
|
* instant the target's url/type may not be stable — see function
|
|
* docstring above). We still listen here for the case where the
|
|
* target's metadata is ready synchronously.
|
|
* - `targetchanged` fires when the target's URL changes — which is
|
|
* the canonical signal that the offscreen document has finished
|
|
* navigating to its `chrome.runtime.getURL('src/offscreen/index.html')`
|
|
* URL after `chrome.offscreen.createDocument`.
|
|
* Either event firing the matching predicate triggers attach;
|
|
* subsequent firings are idempotent.
|
|
*/
|
|
const onTargetEvent = async (
|
|
target: { type: () => string; url: () => string; asPage: () => Promise<Page> },
|
|
): Promise<void> => {
|
|
if (isOffscreenTarget(target)) {
|
|
await attachToOffscreen(target);
|
|
}
|
|
};
|
|
browser.on('targetcreated', onTargetEvent);
|
|
browser.on('targetchanged', onTargetEvent);
|
|
|
|
// Race-condition guard: enumerate already-existing targets in case the
|
|
// offscreen was created BEFORE this listener was registered. Puppeteer
|
|
// only fires `targetcreated` for FUTURE targets — already-created ones
|
|
// are silent unless explicitly enumerated. In the harness flow the
|
|
// offscreen is created via chrome.offscreen.createDocument inside an
|
|
// assertion call (after this function returns), so the listener path
|
|
// is typically sufficient — but probe latency or pre-launch state can
|
|
// mean the offscreen target exists by the time we register. The check
|
|
// below is best-effort + idempotent (the `offscreenAttached` flag
|
|
// prevents double-attach if both code paths fire).
|
|
//
|
|
// Plan-04-04 debug session-2 fix: the prior implementation relied
|
|
// entirely on `targetcreated` and missed the offscreen in every spike
|
|
// run (zero `[off:*]` lines), creating the observability gap.
|
|
const enumerateExistingTargets = async (): Promise<void> => {
|
|
try {
|
|
const existing = browser.targets();
|
|
for (const target of existing) {
|
|
if (isOffscreenTarget(target)) {
|
|
await attachToOffscreen(target);
|
|
break;
|
|
}
|
|
}
|
|
} catch (enumErr) {
|
|
process.stderr.write(
|
|
`(launch: offscreen target enumeration skipped — ${String(enumErr)})\n`,
|
|
);
|
|
}
|
|
};
|
|
void enumerateExistingTargets();
|
|
}
|
|
|
|
/**
|
|
* Configure the harness page's CDP session to use the per-run
|
|
* `downloadsDir` so A5 (SAVE_ARCHIVE → chrome.downloads.download) can
|
|
* poll a known directory without colliding with the operator's real
|
|
* downloads. Uses CDP `Browser.setDownloadBehavior` with
|
|
* `behavior: 'allow'` + the explicit path.
|
|
*
|
|
* @param harnessPage - The opened harness page handle.
|
|
* @param downloadsDir - Absolute path to the downloads directory.
|
|
*/
|
|
async function configureDownloadsDir(
|
|
harnessPage: Page,
|
|
downloadsDir: string,
|
|
): Promise<void> {
|
|
const session = await harnessPage.createCDPSession();
|
|
await session.send('Browser.setDownloadBehavior', {
|
|
behavior: 'allow',
|
|
downloadPath: downloadsDir,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Launch Chrome with the test bundle as an unpacked MV3 extension,
|
|
* open the extension-internal harness page + a victim about:blank
|
|
* page, configure downloads, attach SW + offscreen console listeners,
|
|
* and return the assembled handles.
|
|
*
|
|
* Caller MUST close the browser in a `finally` block:
|
|
* ```typescript
|
|
* const handles = await launchHarnessBrowser();
|
|
* try {
|
|
* // ... run assertions ...
|
|
* } finally {
|
|
* await handles.browser.close();
|
|
* }
|
|
* ```
|
|
*
|
|
* @param opts - Override headless / downloadsDir.
|
|
* @returns Assembled HarnessHandles.
|
|
*/
|
|
export async function launchHarnessBrowser(
|
|
opts: LaunchOptions = {},
|
|
): Promise<HarnessHandles> {
|
|
assertBundlePresent();
|
|
|
|
const headless = opts.headless ?? process.env.HEADLESS !== '0';
|
|
const downloadsDir = opts.downloadsDir ?? mkdtempSync(join(tmpdir(), 'mokosh-uat-'));
|
|
|
|
const browser = await puppeteer.launch({
|
|
enableExtensions: [DIST_TEST_DIR],
|
|
headless,
|
|
pipe: true,
|
|
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
|
args: [
|
|
'--no-sandbox',
|
|
// DO NOT add --auto-select-desktop-capture-source — unreliable
|
|
// in --headless=new per 01-11-SUMMARY falsification 4; the
|
|
// synthetic getDisplayMedia (offscreen-hooks.ts:installFake)
|
|
// bypasses Chrome's picker entirely.
|
|
],
|
|
});
|
|
|
|
const extensionId = await resolveExtensionIdWithPolling(browser);
|
|
|
|
// Accumulating console buffers — empty until SW + offscreen attach.
|
|
const swConsole: string[] = [];
|
|
const offConsole: string[] = [];
|
|
|
|
// Open the victim page FIRST so it's already in the tab list when
|
|
// the harness page opens. The victim URL must satisfy two constraints:
|
|
// 1. `chrome.tabs.query({active:true,currentWindow:true})` returns a
|
|
// tab with non-empty `.id` (so production `saveArchive` proceeds);
|
|
// every real tab does, so this is automatic.
|
|
// 2. `chrome.tabs.captureVisibleTab(...)` (used inside saveArchive's
|
|
// screenshot capture path) succeeds. captureVisibleTab needs
|
|
// EITHER `<all_urls>` host permission to match the active tab's
|
|
// URL OR `activeTab` granted (which only fires on a real user
|
|
// gesture, not a Puppeteer-scripted call). `<all_urls>` matches
|
|
// `http://`, `https://`, `file://`, `ftp://` — but NOT
|
|
// `about:blank` and NOT `data:` URLs (Chromium treats `data:`
|
|
// with an opaque origin and rejects the capture under
|
|
// "activeTab permission not in effect"). The canonical scheme
|
|
// that DOES satisfy `<all_urls>` without needing a localhost
|
|
// server is `file://`, so we write a stub HTML file inside the
|
|
// per-run `downloadsDir` (already mkdtempSync'd above) and load
|
|
// it as `file://<absPath>`.
|
|
//
|
|
// Plan 01-13 Wave 3B deviation (Rule 3 — blocking issue): the original
|
|
// `about:blank` victim page (carried over from the c647f61 prototype
|
|
// because A6 does not exercise captureVisibleTab) breaks A5's
|
|
// SAVE_ARCHIVE round-trip. The file:// victim is the simplest fix
|
|
// that does not touch production code or add network dependencies
|
|
// (an http://localhost server would also work but introduces port-
|
|
// allocation race conditions on CI).
|
|
const victimHtmlPath = resolvePath(downloadsDir, 'mokosh-uat-victim.html');
|
|
const victimHtml =
|
|
'<!doctype html><html><head><meta charset="utf-8">' +
|
|
'<title>Mokosh UAT Victim</title></head>' +
|
|
'<body><h1>UAT victim page</h1>' +
|
|
'<p>This tab exists so chrome.tabs.captureVisibleTab succeeds during A5 (SAVE_ARCHIVE).</p>' +
|
|
'</body></html>';
|
|
writeFileSync(victimHtmlPath, victimHtml, 'utf8');
|
|
const victimUrl = pathToFileURL(victimHtmlPath).toString();
|
|
|
|
const victimPage = await browser.newPage();
|
|
await victimPage.goto(victimUrl);
|
|
|
|
// Open the harness page; attach console + pageerror listeners
|
|
// BEFORE the goto so we don't miss bootstrap-time messages.
|
|
const harnessPage = await browser.newPage();
|
|
/**
|
|
* Named callback per project style — forwards all page-side console
|
|
* events to stderr with the `[page:<type>]` tag.
|
|
*/
|
|
const onPageConsole = (msg: { type: () => string; text: () => string }): void => {
|
|
const line = `[page:${msg.type()}] ${msg.text()}`;
|
|
process.stderr.write(line + '\n');
|
|
};
|
|
harnessPage.on('console', onPageConsole);
|
|
/**
|
|
* Named callback — page errors get an explicit `[page:ERROR]` tag
|
|
* separate from the console events so the operator can spot them in
|
|
* the stderr stream.
|
|
*/
|
|
const onPageError = (err: unknown): void => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
const line = `[page:ERROR] ${msg}`;
|
|
process.stderr.write(line + '\n');
|
|
};
|
|
harnessPage.on('pageerror', onPageError);
|
|
|
|
// Best-effort SW console attach — the SW target is usually ready
|
|
// by the time the extension finishes loading, but slow CI may need
|
|
// a brief poll (handled inside `attachSwConsoleBestEffort`).
|
|
await attachSwConsoleBestEffort(browser, extensionId, swConsole);
|
|
|
|
// Register the offscreen console attach BEFORE opening the harness
|
|
// page so the listener catches the offscreen target whenever it
|
|
// spawns (which happens later, when the page calls
|
|
// chrome.offscreen.createDocument from inside an assertion).
|
|
registerOffscreenConsoleAttach(browser, extensionId, offConsole);
|
|
|
|
// Configure downloads via CDP. This MUST happen on the harness
|
|
// page's CDP session (not the browser's default session) per
|
|
// puppeteer's per-page session model.
|
|
await configureDownloadsDir(harnessPage, downloadsDir);
|
|
|
|
// 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();
|
|
|
|
// Open the harness page; wait for window.__mokoshHarness to install.
|
|
const harnessUrl = `chrome-extension://${extensionId}/tests/uat/extension-page-harness.html`;
|
|
await harnessPage.goto(harnessUrl, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: HARNESS_GOTO_TIMEOUT_MS,
|
|
});
|
|
await harnessPage.waitForFunction(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- waitForFunction runs in browser context where window types are loose.
|
|
() => (window as any).__mokoshHarness !== undefined,
|
|
{ timeout: HARNESS_BOOTSTRAP_TIMEOUT_MS },
|
|
);
|
|
|
|
return {
|
|
browser,
|
|
extensionId,
|
|
harnessPage,
|
|
victimPage,
|
|
downloadsDir,
|
|
swConsole,
|
|
offConsole,
|
|
};
|
|
}
|