From d5fa440e2686d78a835c7feb48557468629e27cf Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 21 Sep 2026 07:24:46 -0400 Subject: [PATCH] test(mobile): probe why Chrome never asks for the artifact image CI's read was decisive: on Chrome 152 only the CSS background was requested, while the `` reported complete with a zero naturalWidth and a resolved currentSrc. A request that went out and failed cannot produce both readings, so the next probe asks the frame rather than the network. On abort it now reads, inside the artifact frame: readyState, the init script's own moment, document.images.length, every `performance.getEntriesByType('resource')` name, the navigation entry types, and for #remote its src, isConnected, complete, naturalWidth, currentSrc and the outcome of decode(). A resource entry for a URL the rig never saw would mean the request left the frame and died before reaching it. Then it issues a `new Image()` at a URL that has never existed and reports two seconds later whether the rig saw it. That splits the two live explanations: if the fresh request is seen and the artifact's was not, the frame can fetch and the parser-inserted element is the cause; if neither is seen, requests from this frame are not reaching the rig at all. Subframe document commits are counted from mount, because a second parse is a new window and leaves nothing behind to count, and a second parse could be meeting a failure the first cached. `cdp sent` was empty on CI even for a request Playwright did record, so the page's own session is blind to the frame. Chromium isolates sandboxed iframes into their own process, srcdoc included, so flattened Target.setAutoAttach now puts each child target on the same connection with Network.enable on the child, and the attached list reports whether the frame is a separate target at all. The navigation arm gets the same reading, since CI showed it fails on its own rather than behind the aborted image arms. Verified by forcing the abort rather than assumed. Locally the reading prints one subframe parse, decode resolved, every resource the document fetched, and `fresh ... issued true seen true`, with the attached list empty, which is consistent with this Chrome not isolating the frame and its page session seeing the requests. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...obile-web-app-html-preview-render.test.mjs | 9 +- ...mobile-web-app-preview-frame-readiness.mjs | 13 +- .../mobile-web-app-preview-image-evidence.mjs | 117 ++++++++++++++++++ .../mobile-web-app-preview-request-log.mjs | 24 +++- 4 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 config/scripts/mobile-web-app-preview-image-evidence.mjs diff --git a/config/scripts/mobile-web-app-html-preview-render.test.mjs b/config/scripts/mobile-web-app-html-preview-render.test.mjs index 9ce903b3f3d..fc794241d71 100644 --- a/config/scripts/mobile-web-app-html-preview-render.test.mjs +++ b/config/scripts/mobile-web-app-html-preview-render.test.mjs @@ -34,6 +34,7 @@ import { } from './mobile-web-app-render-harness.mjs' import { createCspReportSink, reportedDirectives } from './mobile-web-app-preview-csp-reports.mjs' import { recordRequestsTo } from './mobile-web-app-preview-request-log.mjs' +import { watchImageEvidence } from './mobile-web-app-preview-image-evidence.mjs' import { ARTIFACT_RGB, ENTRY_SOURCE, @@ -246,7 +247,10 @@ async function open( const page = await context.newPage() // Subscribed before the first navigation, so a request made during load is in the log. Cheap // while an arm passes: it fills arrays, and only an abort asks them to speak. - const describeRequests = await recordRequestsTo(page, SECURE_ORIGIN) + const requestLog = await recordRequestsTo(page, SECURE_ORIGIN) + // Asked only when an arm has aborted, so the fresh-image probe and its wait cost a failing run + // and never a passing one. + const describeRequests = watchImageEvidence(page, SECURE_ORIGIN, requestLog) try { const navigations = [] const popups = [] @@ -371,7 +375,8 @@ async function open( await settleAfterMount(page, navigations, expectNavigation, signal, { frame: artifactFrame, browserVersion, - arm + arm, + describeRequests }) const result = { page, diff --git a/config/scripts/mobile-web-app-preview-frame-readiness.mjs b/config/scripts/mobile-web-app-preview-frame-readiness.mjs index dd95bb8e2f2..c994afa1fc6 100644 --- a/config/scripts/mobile-web-app-preview-frame-readiness.mjs +++ b/config/scripts/mobile-web-app-preview-frame-readiness.mjs @@ -206,7 +206,7 @@ async function describeAdmittedImages(page, readImageHits, describeRequests) { const reading = image === false ? 'the reading never answered' : image // What the browser said about the requests themselves, which is where a request that never // reached the rig's route handler is distinguishable from one the page never made. - const requests = describeRequests?.() ?? 'no request log for this arm' + const requests = (await describeRequests?.(frame)) ?? 'no request log for this arm' return `the arm recorded ${JSON.stringify(readImageHits())} of ${JSON.stringify(ADMITTED_IMAGE_PATHS)}; #remote ${JSON.stringify(reading)}; ${requests}` } @@ -288,9 +288,14 @@ export async function waitForRecordedNavigation( } if (Date.now() - since > sampleEveryMs) { since = Date.now() - latest = await describePreviewFrame(page, reading?.frame, reading?.browserVersion).catch( - (error) => `the reading itself failed: ${String(error).split('\n')[0]}` - ) + latest = await describePreviewFrame(page, reading?.frame, reading?.browserVersion) + .then(async (frameReading) => { + // The same evidence the images arm prints. A navigation arm that produced nothing is + // asking the same question of the same frame, and on CI this one fails on its own. + const evidence = await reading?.describeRequests?.(reading?.frame) + return evidence ? `${frameReading} | ${evidence}` : frameReading + }) + .catch((error) => `the reading itself failed: ${String(error).split('\n')[0]}`) } await page.waitForTimeout(10) } diff --git a/config/scripts/mobile-web-app-preview-image-evidence.mjs b/config/scripts/mobile-web-app-preview-image-evidence.mjs new file mode 100644 index 00000000000..223101e0b31 --- /dev/null +++ b/config/scripts/mobile-web-app-preview-image-evidence.mjs @@ -0,0 +1,117 @@ +/** + * Why an image the policy admits was never fetched, asked of the frame that should have fetched it. + * + * The network log says whether a request happened. This says what the document thinks happened, + * which is the other half: on CI's Chrome 152 the `` reported `complete` with a zero + * `naturalWidth` and a resolved `currentSrc` while no request was ever made, and those two readings + * cannot both be true of a request that went out and failed. + * + * Every reading here is taken only when an arm has already aborted, so none of it costs a passing + * run. The frame is reached through Playwright's CDP evaluate, which answers in a sandboxed frame + * whose own scripts are blocked. + */ + +/** Two seconds: long enough for a request to reach the rig's own in-process route handler. */ +const FRESH_IMAGE_MS = 2000 + +/** + * Every document commit in a subframe, counted from now. + * + * Subscribed at mount rather than read at the abort, because a parse leaves nothing behind to count: + * a second document is a new window, so the init script's own timestamp is overwritten rather than + * appended. Two commits would mean the artifact parsed twice and the second parse could be meeting a + * failure the first one cached. + */ +function recordFrameParses(page) { + const parses = [] + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame()) { + return + } + parses.push({ url: frame.url(), at: Math.round(performance.now()) }) + }) + return () => [...parses] +} + +/** + * What the frame says about its images, and whether a request made right now is seen. + * + * The fresh image is the part that splits the two live explanations. Its URL has never existed, so + * nothing can have cached a failure for it; if the rig sees that request and not the artifact's, + * the frame can fetch and something about the parser-inserted element is the cause, and if the rig + * sees neither, requests from this frame are not reaching the rig at all. + */ +async function describeImageEvidence(page, frame, { originPrefix, requestLog, parses }) { + if (!frame) { + return `no frame to ask; ${requestLog.describe()}` + } + const inFrame = await frame + .evaluate(async () => { + const remote = document.getElementById('remote') + const decode = remote + ? await remote.decode().then( + () => 'resolved', + (error) => `rejected ${error.name}` + ) + : 'no element' + return { + readyState: document.readyState, + initAt: window.__initAt ?? null, + images: document.images.length, + // Every subresource this document actually fetched, from the document's own side. An entry + // here for a URL the rig never saw would mean the request left the frame and died before it. + resources: performance.getEntriesByType('resource').map((one) => one.name), + navigations: performance.getEntriesByType('navigation').map((one) => one.type), + remote: remote + ? { + src: remote.getAttribute('src'), + isConnected: remote.isConnected, + complete: remote.complete, + naturalWidth: remote.naturalWidth, + currentSrc: remote.currentSrc, + decode + } + : null + } + }) + .catch((error) => `the reading itself failed: ${String(error).split('\n')[0]}`) + + const freshUrl = `${originPrefix}/fresh-${String(Date.now())}.png` + const issued = await frame + .evaluate((url) => { + const image = new Image() + image.src = url + globalThis.__freshImage = image + return true + }, freshUrl) + .catch((error) => `the request itself failed: ${String(error).split('\n')[0]}`) + await new Promise((resolve) => { + const timer = setTimeout(resolve, FRESH_IMAGE_MS) + timer.unref?.() + }) + const fresh = await frame + .evaluate(() => { + const image = globalThis.__freshImage + return image ? { complete: image.complete, naturalWidth: image.naturalWidth } : null + }) + .catch(() => null) + + return [ + `frame ${JSON.stringify(inFrame)}`, + `subframe parses ${JSON.stringify(parses())}`, + `fresh ${JSON.stringify(freshUrl)} issued ${JSON.stringify(issued)} seen ${String(requestLog.asked().includes(freshUrl))} element ${JSON.stringify(fresh)}`, + requestLog.describe() + ].join('; ') +} + +/** + * Subscribes now and hands back the reading, so a caller wires one thing rather than three. + * + * The subscription is the only part that has to happen at mount; everything it reports is asked for + * later, and only by an arm that aborted. + */ +export function watchImageEvidence(page, originPrefix, requestLog) { + const parses = recordFrameParses(page) + return async (frame) => + await describeImageEvidence(page, frame, { originPrefix, requestLog, parses }) +} diff --git a/config/scripts/mobile-web-app-preview-request-log.mjs b/config/scripts/mobile-web-app-preview-request-log.mjs index ba21126a91c..5ee208d7774 100644 --- a/config/scripts/mobile-web-app-preview-request-log.mjs +++ b/config/scripts/mobile-web-app-preview-request-log.mjs @@ -22,6 +22,8 @@ export async function recordRequestsTo(page, originPrefix) { // Only this origin's ids, because `Network.loadingFailed` carries a request id and no URL, and an // unfiltered list would report every other request on the page as this arm's evidence. const ours = new Set() + /** Every child target this session attached to, which says whether the frame is out of process. */ + const attached = [] page.on('request', (request) => { if (request.url().startsWith(originPrefix)) { @@ -40,6 +42,21 @@ export async function recordRequestsTo(page, originPrefix) { .catch(() => null) if (cdp) { await cdp.send('Network.enable').catch(() => {}) + // Chromium isolates sandboxed iframes into their own process, srcdoc included, so the page's own + // session sees none of the frame's requests: `cdp sent` came back empty on CI even for a request + // Playwright did record. Flattened auto-attach puts each child target on this same connection, + // and `Network.enable` on the child is what makes its requests visible here. + cdp.on('Target.attachedToTarget', (event) => { + attached.push({ type: event.targetInfo?.type ?? null, url: event.targetInfo?.url ?? null }) + cdp.send('Network.enable', {}, event.sessionId).catch(() => {}) + }) + await cdp + .send('Target.setAutoAttach', { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true + }) + .catch(() => {}) cdp.on('Network.requestWillBeSent', (event) => { if (!event.request?.url?.startsWith(originPrefix)) { return @@ -65,6 +82,9 @@ export async function recordRequestsTo(page, originPrefix) { }) } - return () => - `asked ${JSON.stringify(asked)}; failed ${JSON.stringify(failed)}; cdp ${cdp ? 'on' : 'off'} sent ${JSON.stringify(sent)}; cdp loadingFailed ${JSON.stringify(loadingFailed)}` + return { + asked: () => [...asked], + describe: () => + `asked ${JSON.stringify(asked)}; failed ${JSON.stringify(failed)}; cdp ${cdp ? 'on' : 'off'} attached ${JSON.stringify(attached)} sent ${JSON.stringify(sent)}; cdp loadingFailed ${JSON.stringify(loadingFailed)}` + } }