Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -199,6 +199,36 @@ async function attachSwConsoleBestEffort(
|
|||||||
*
|
*
|
||||||
* Idempotent — only the first matching offscreen target is attached.
|
* 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 browser - Puppeteer browser handle.
|
||||||
* @param extensionId - The resolved extension id.
|
* @param extensionId - The resolved extension id.
|
||||||
* @param offConsole - Accumulating string buffer for offscreen log lines.
|
* @param offConsole - Accumulating string buffer for offscreen log lines.
|
||||||
@@ -209,44 +239,131 @@ function registerOffscreenConsoleAttach(
|
|||||||
offConsole: string[],
|
offConsole: string[],
|
||||||
): void {
|
): void {
|
||||||
let offscreenAttached = false;
|
let offscreenAttached = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Targetcreated handler — checks each new target for the offscreen
|
* Check whether a target matches the offscreen-document criteria.
|
||||||
* extension URL pattern, attaches the console listener on the first
|
*
|
||||||
* match.
|
* 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 onTargetCreated = async (
|
const isOffscreenTarget = (
|
||||||
target: { type: () => string; url: () => string; asPage: () => Promise<Page> },
|
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> => {
|
): Promise<void> => {
|
||||||
if (offscreenAttached) {
|
if (offscreenAttached) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = target.url();
|
offscreenAttached = true;
|
||||||
if (
|
try {
|
||||||
target.type() === 'background_page' &&
|
const offPage = await target.asPage();
|
||||||
url.includes(extensionId) &&
|
/**
|
||||||
url.includes('offscreen')
|
* Per-message callback — same tag format as the SW attach
|
||||||
) {
|
* (`[off:<type>] <text>`).
|
||||||
offscreenAttached = true;
|
*/
|
||||||
try {
|
const onOffConsole = (msg: { type: () => string; text: () => string }): void => {
|
||||||
const offPage = await target.asPage();
|
const line = `[off:${msg.type()}] ${msg.text()}`;
|
||||||
/**
|
offConsole.push(line);
|
||||||
* Per-message callback — same tag format as the SW attach
|
process.stderr.write(line + '\n');
|
||||||
* (`[off:<type>] <text>`).
|
};
|
||||||
*/
|
offPage.on('console', onOffConsole);
|
||||||
const onOffConsole = (msg: { type: () => string; text: () => string }): void => {
|
process.stderr.write(
|
||||||
const line = `[off:${msg.type()}] ${msg.text()}`;
|
`(launch: offscreen console attached — url=${target.url()})\n`,
|
||||||
offConsole.push(line);
|
);
|
||||||
process.stderr.write(line + '\n');
|
} catch (offAttachErr) {
|
||||||
};
|
process.stderr.write(
|
||||||
offPage.on('console', onOffConsole);
|
`(launch: offscreen console attach skipped — ${String(offAttachErr)})\n`,
|
||||||
} catch (offAttachErr) {
|
);
|
||||||
process.stderr.write(
|
|
||||||
`(launch: offscreen console attach skipped — ${String(offAttachErr)})\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
browser.on('targetcreated', onTargetCreated);
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
67
tests/uat/spike-diagnose-offscreen-target.ts
Normal file
67
tests/uat/spike-diagnose-offscreen-target.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// tests/uat/spike-diagnose-offscreen-target.ts — Plan-04-04 debug
|
||||||
|
// session-2 disambiguation helper.
|
||||||
|
//
|
||||||
|
// Single-purpose diagnostic script: launch the harness, prime the
|
||||||
|
// recording via assertA2 (so the offscreen exists with active
|
||||||
|
// MediaRecorder), then enumerate `browser.targets()` and print each
|
||||||
|
// target's `type()` + `url()`. Used to discover why the production
|
||||||
|
// offscreen target predicate in `launch.ts:registerOffscreenConsoleAttach`
|
||||||
|
// fails to match: empirical inspection of what Puppeteer actually
|
||||||
|
// reports for an MV3 offscreen document.
|
||||||
|
//
|
||||||
|
// Operation: `tsx tests/uat/spike-diagnose-offscreen-target.ts`.
|
||||||
|
// Wall-clock: ~5s (no idle, no SW kill). Exits 0 if the offscreen
|
||||||
|
// target is discoverable; 1 if no target with the offscreen URL
|
||||||
|
// is found.
|
||||||
|
|
||||||
|
import { launchHarnessBrowser } from './lib/launch';
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
process.stdout.write('\nMokosh debug session-2 — offscreen target diagnostic\n');
|
||||||
|
process.stdout.write('='.repeat(72) + '\n');
|
||||||
|
|
||||||
|
const handles = await launchHarnessBrowser();
|
||||||
|
process.stdout.write(`Diagnostic: extensionId=${handles.extensionId}\n\n`);
|
||||||
|
|
||||||
|
// Prime recording (offscreen comes alive here).
|
||||||
|
process.stdout.write('Diagnostic: priming via assertA2 to spawn offscreen\n');
|
||||||
|
const a2Result = await handles.harnessPage.evaluate(async () => {
|
||||||
|
const harness = (
|
||||||
|
window as unknown as {
|
||||||
|
__mokoshHarness: { assertA2: () => Promise<{ passed: boolean; error?: string }> };
|
||||||
|
}
|
||||||
|
).__mokoshHarness;
|
||||||
|
return harness.assertA2();
|
||||||
|
});
|
||||||
|
process.stdout.write(`Diagnostic: assertA2.passed=${a2Result.passed}\n\n`);
|
||||||
|
|
||||||
|
// Wait a moment for the offscreen target to materialize fully.
|
||||||
|
await new Promise((res) => setTimeout(res, 1_000));
|
||||||
|
|
||||||
|
// Enumerate every target.
|
||||||
|
const allTargets = handles.browser.targets();
|
||||||
|
process.stdout.write(`Diagnostic: browser.targets() count = ${allTargets.length}\n`);
|
||||||
|
process.stdout.write('-'.repeat(72) + '\n');
|
||||||
|
for (const target of allTargets) {
|
||||||
|
const targetType = target.type();
|
||||||
|
const targetUrl = target.url();
|
||||||
|
process.stdout.write(` type=${targetType.padEnd(20)} url=${targetUrl}\n`);
|
||||||
|
}
|
||||||
|
process.stdout.write('-'.repeat(72) + '\n');
|
||||||
|
|
||||||
|
// Identify any target that matches the offscreen URL pattern.
|
||||||
|
const offscreenCandidates = allTargets.filter((t) => t.url().includes('/src/offscreen/'));
|
||||||
|
process.stdout.write(`\nDiagnostic: targets matching '/src/offscreen/' = ${offscreenCandidates.length}\n`);
|
||||||
|
for (const candidate of offscreenCandidates) {
|
||||||
|
process.stdout.write(
|
||||||
|
` CANDIDATE: type=${candidate.type()} url=${candidate.url()}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await handles.browser.close();
|
||||||
|
|
||||||
|
return offscreenCandidates.length > 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await main();
|
||||||
|
process.exit(code);
|
||||||
Reference in New Issue
Block a user