test(mobile): time the artifact image against the frame's attachment

CI's second read showed the frame did issue the request -- it has a
resource-timing entry and decode rejected with EncodingError -- while the rig
saw only the CSS background, and a fresh image created later from the same
frame was both issued and seen. The remaining question is whether the entry
starts before anything was listening to that frame.

So the entry is now reported in full for the element under test:
responseStatus, transferSize, encodedBodySize, nextHopProtocol, startTime and
duration. A zero status with a zero transferSize is a fetch that reached the
network stack and came back with nothing, which is what an unintercepted
request looks like once `.invalid` fails to resolve.

Both sides of the comparison get a wall clock: `Target.attachedToTarget` and
Playwright's own `frameattached` now carry the moment they fired, and every
recorded request carries the moment it was seen. An entry that starts before
the attachment is the race stated rather than inferred.

Abort path only; the passing run is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-21 07:41:09 -04:00
parent d5fa440e26
commit b5e82065f3
2 changed files with 31 additions and 4 deletions
@@ -61,6 +61,21 @@ async function describeImageEvidence(page, frame, { originPrefix, requestLog, pa
// 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),
// The same entry in full for the element under test. A zero `responseStatus` with a zero
// `transferSize` is a fetch that reached the network stack and came back with no response,
// which is what a request the rig never intercepted looks like once the host cannot resolve;
// `startTime` is what an attachment time is early or late against.
remoteTiming: performance
.getEntriesByType('resource')
.filter((one) => one.name === remote?.src)
.map((one) => ({
responseStatus: one.responseStatus ?? null,
transferSize: one.transferSize,
encodedBodySize: one.encodedBodySize,
nextHopProtocol: one.nextHopProtocol,
startTime: Math.round(one.startTime),
duration: Math.round(one.duration)
})),
navigations: performance.getEntriesByType('navigation').map((one) => one.type),
remote: remote
? {
@@ -27,9 +27,15 @@ export async function recordRequestsTo(page, originPrefix) {
page.on('request', (request) => {
if (request.url().startsWith(originPrefix)) {
asked.push(request.url())
asked.push({ url: request.url(), at: Math.round(performance.now()) })
}
})
// When this page first had any frame at all, so an attachment time has something to be early or
// late against.
const frameAttached = []
page.on('frameattached', (frame) => {
frameAttached.push({ url: frame.url(), at: Math.round(performance.now()) })
})
page.on('requestfailed', (request) => {
if (request.url().startsWith(originPrefix)) {
failed.push({ url: request.url(), errorText: request.failure()?.errorText ?? null })
@@ -47,7 +53,13 @@ export async function recordRequestsTo(page, originPrefix) {
// 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 })
// The moment, not just the fact: a request whose resource-timing entry starts before this was
// issued by a frame nothing was listening to yet, which is a different bug from a refusal.
attached.push({
type: event.targetInfo?.type ?? null,
url: event.targetInfo?.url ?? null,
at: Math.round(performance.now())
})
cdp.send('Network.enable', {}, event.sessionId).catch(() => {})
})
await cdp
@@ -83,8 +95,8 @@ export async function recordRequestsTo(page, originPrefix) {
}
return {
asked: () => [...asked],
asked: () => asked.map((one) => one.url),
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)}`
`asked ${JSON.stringify(asked)}; failed ${JSON.stringify(failed)}; frameAttached ${JSON.stringify(frameAttached)}; cdp ${cdp ? 'on' : 'off'} attached ${JSON.stringify(attached)} sent ${JSON.stringify(sent)}; cdp loadingFailed ${JSON.stringify(loadingFailed)}`
}
}