mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
test(mobile-web): act on the HTML preview frame only once input reaches it (#33336)
* test(mobile-web): act on the HTML preview frame only once the browser routes input into it Chromium runs the sandboxed preview frame in its own process. The rig decided an arm was ready once the artifact had parsed, but the embedder can still be showing the frame's white before its surface is composited, and still keep pointer input over the frame for itself until it holds the frame's hit-test region. A click in that window landed on the embedder's <iframe>, Playwright reported success, and the arm waited for a navigation that never came (or read white as pixelBefore). The rig now waits for the artifact's paint before reading pixelBefore, and before acting moves the pointer over the frame until the embedder stops receiving it. * test(mobile-web): name the arm in the input-routing timeout and trim history comments
This commit is contained in:
@@ -3,18 +3,20 @@
|
||||
*/
|
||||
import { recordRequestsTo } from './mobile-web-app-preview-request-log.mjs'
|
||||
import { watchImageEvidence } from './mobile-web-app-preview-image-evidence.mjs'
|
||||
import { artifact } from './mobile-web-app-preview-artifact-fixture.mjs'
|
||||
import { ARTIFACT_RGB, artifact } from './mobile-web-app-preview-artifact-fixture.mjs'
|
||||
import { pollReportsUntil } from './mobile-web-app-preview-csp-reports.mjs'
|
||||
import { describePreviewFrame, untilAborted } from './mobile-web-app-preview-frame-diagnosis.mjs'
|
||||
import {
|
||||
previewFrame,
|
||||
settleAfterMount,
|
||||
waitForFrameInputRouting,
|
||||
waitForLoadedFrame
|
||||
} from './mobile-web-app-preview-frame-readiness.mjs'
|
||||
import {
|
||||
probePreviewPixel,
|
||||
readPreviewArm,
|
||||
readPreviewToggles
|
||||
readPreviewToggles,
|
||||
waitForPaintedPreview
|
||||
} from './mobile-web-app-preview-frame-readings.mjs'
|
||||
|
||||
/**
|
||||
@@ -129,6 +131,19 @@ export async function openPreviewArm(
|
||||
document.addEventListener('securitypolicyviolation', (event) => {
|
||||
window.__violations.push(`${event.violatedDirective} ${event.blockedURI || 'inline'}`)
|
||||
})
|
||||
// Pointer input the embedder kept for itself over the frame; see `waitForFrameInputRouting`.
|
||||
if (window === window.top) {
|
||||
window.__embedderFramePointer = 0
|
||||
document.addEventListener(
|
||||
'mousemove',
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLIFrameElement) {
|
||||
window.__embedderFramePointer += 1
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
})
|
||||
// The nonce in the document's own URL: the policy this response carries names a report endpoint
|
||||
// with the same nonce, which is how a report from a `srcdoc` frame with no URL of its own is
|
||||
@@ -179,7 +194,11 @@ export async function openPreviewArm(
|
||||
// produced, and by then the top frame is mid-navigation and the iframe has blanked to its own
|
||||
// background. So the precondition "there was a rendered artifact to tap" is this reading, and the
|
||||
// one below is only meaningful for a case that did nothing.
|
||||
const pixelBefore = await probePreviewPixel(page, clip)
|
||||
// A `'load'` arm's frame leaves the artifact on purpose, so only the others wait for its paint.
|
||||
const pixelBefore =
|
||||
frameReady === 'load'
|
||||
? await probePreviewPixel(page, clip)
|
||||
: await waitForPaintedPreview(page, clip, ARTIFACT_RGB, signal)
|
||||
// Sampled before the action as well, because the toggle's whole claim is that it changes.
|
||||
const togglesBefore = await readPreviewToggles(page)
|
||||
let actError = null
|
||||
@@ -189,6 +208,7 @@ export async function openPreviewArm(
|
||||
if (!artifactFrame || artifactFrame.isDetached()) {
|
||||
throw new Error('The loaded preview frame is missing before its action')
|
||||
}
|
||||
await waitForFrameInputRouting(page, clip, signal, arm)
|
||||
await act({ page, frame: artifactFrame }).catch((error) => {
|
||||
actError = String(error).split('\n')[0]
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { describePreviewFrame, untilAborted } from './mobile-web-app-preview-frame-diagnosis.mjs'
|
||||
import { pollReportsUntil } from './mobile-web-app-preview-csp-reports.mjs'
|
||||
|
||||
const POLL_MS = 25
|
||||
export const POLL_MS = 25
|
||||
const EVALUATE_MS = 1000
|
||||
|
||||
/** The mounted preview frame, or null before one exists. */
|
||||
@@ -26,7 +26,7 @@ export function previewFrame(page) {
|
||||
return page.frames().find((one) => one !== page.mainFrame()) ?? null
|
||||
}
|
||||
|
||||
const abandonAfter = (ms) =>
|
||||
export const abandonAfter = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), ms)
|
||||
timer.unref?.()
|
||||
@@ -152,6 +152,40 @@ export async function waitForLoadedFrame(
|
||||
return previewFrame(page)
|
||||
}
|
||||
|
||||
/** How long a painted frame may go on handing its input to the embedder before the arm gives up. */
|
||||
const INPUT_ROUTING_MS = 10_000
|
||||
|
||||
/**
|
||||
* Returns once a pointer over the frame reaches the frame rather than the embedder.
|
||||
*
|
||||
* Painted is not routed either. Chromium sends input into the frame's own process only once the
|
||||
* browser holds that frame's hit-test region, and until then an event at the frame's coordinates
|
||||
* is the embedder's, targeted at its `<iframe>` element. Playwright's checks all pass inside the
|
||||
* frame, so its click reports landing while nothing in the artifact is activated.
|
||||
*
|
||||
* So the pointer is moved onto the frame until the embedder's own count of events on an `<iframe>`
|
||||
* stops rising. Two alternating points, because a move to where the pointer already is need not
|
||||
* dispatch anything. An engine that keeps the frame in-process never shows the embedder those
|
||||
* events, so it passes on the first move.
|
||||
*/
|
||||
export async function waitForFrameInputRouting(page, clip, signal, arm) {
|
||||
const started = Date.now()
|
||||
const embedderSaw = async () => await page.evaluate(() => window.__embedderFramePointer ?? 0)
|
||||
for (let step = 0; !signal?.aborted; step += 1) {
|
||||
const before = await embedderSaw()
|
||||
await page.mouse.move(clip.x + (step % 2), clip.y)
|
||||
if ((await embedderSaw()) === before) {
|
||||
return
|
||||
}
|
||||
if (Date.now() - started > INPUT_ROUTING_MS) {
|
||||
throw new Error(
|
||||
`[html-preview-render] ${arm} still handed its pointer input to the embedder after ${String(Date.now() - started)}ms`
|
||||
)
|
||||
}
|
||||
await abandonAfter(POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** The two the widened `img-src` admits; the font beside them stays an absence. */
|
||||
const ADMITTED_IMAGE_PATHS = ['/css-bg.png', '/img.png']
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
import { PNG } from 'pngjs'
|
||||
import { reportedDirectives } from './mobile-web-app-preview-csp-reports.mjs'
|
||||
import { previewFrame } from './mobile-web-app-preview-frame-readiness.mjs'
|
||||
import { POLL_MS, abandonAfter, previewFrame } from './mobile-web-app-preview-frame-readiness.mjs'
|
||||
|
||||
/**
|
||||
* One RGB triple from the page, clipped to where the frame sits.
|
||||
@@ -27,6 +27,25 @@ export async function probePreviewPixel(page, clip) {
|
||||
return `${png.data[0]},${png.data[1]},${png.data[2]}`
|
||||
}
|
||||
|
||||
/** Long enough for a loaded runner to composite a parsed frame; a frame that never paints still reds. */
|
||||
const PAINT_MS = 10_000
|
||||
|
||||
/**
|
||||
* The pixel once the frame has painted `expected`, or the last reading when it never does.
|
||||
*
|
||||
* Parsed is not painted: Chromium runs the sandboxed frame in its own process, and the embedder
|
||||
* composites that process's surface a frame or more after the document is complete.
|
||||
*/
|
||||
export async function waitForPaintedPreview(page, clip, expected, signal) {
|
||||
const started = Date.now()
|
||||
let pixel = await probePreviewPixel(page, clip)
|
||||
while (pixel !== expected && !signal?.aborted && Date.now() - started < PAINT_MS) {
|
||||
await abandonAfter(POLL_MS)
|
||||
pixel = await probePreviewPixel(page, clip)
|
||||
}
|
||||
return pixel
|
||||
}
|
||||
|
||||
/** What the toolbar emits into the DOM, not what the component was handed: react-native-web
|
||||
* forwards `aria-*` and drops `accessibilityState` on the floor, so a selected state that reads
|
||||
* fine in the test renderer can reach a screen reader as nothing at all. */
|
||||
|
||||
Reference in New Issue
Block a user