Skip to content

Commit 1235216

Browse files
authored
fix(ios): double-check off-screen click refusals against a direct element read (#1566)
* fix(ios): double-check off-screen click refusals against a direct element read #1542: after an AX-free scroll on iOS, the off-screen interaction guard can refuse a click even though the target is genuinely on-screen, because it trusts a scroll-container ancestor's rect from the bulk accessibility tree, which a keyboard-dismiss content-offset correction can leave stale/corrupted while the target's own rect is already correct. When the guard is about to refuse on iOS, it now takes a single fresh, tree-independent XCUITest read of the target element (querySelector) and trusts that read's live `hittable` + rect-vs-root-viewport signal instead, if it positively confirms on-screen. Any failure to unambiguously re-resolve the element (no id/label, not found, ambiguous, transport error) fails closed exactly as before. Genuinely off-screen targets, and every other platform, are unchanged: the backend method is gated to local (non-provider) iOS sessions only, and only ever runs on the about-to-fail path. The decision itself is a pure function (decideOffscreenRefusalDoubleCheck in mobile-snapshot-semantics.ts) with counterfactual-proven tests: hardcoding it to always trust the bulk verdict turns the rescue test red, and hardcoding it to always trust the direct read (including on "unavailable") turns the fail-closed/genuine-refusal test red. Live-validated on a fresh-boot iOS simulator: checkout-form.ad 2/2 passes (previously failing at step 11), gesture-lab.ad 2/2 (regression), and the Android checkout-form/gesture-lab suite passes unchanged, proving no cross-platform behavior change. * fix(ios): tap the live rect after a rescued offscreen refusal; collapse the double-check to one backend hook Review blockers 1+2 (interleaved by design — the soundness fix is expressed through the collapsed hook's contract): 1. SOUNDNESS: a rescued refusal now returns the node PATCHED WITH THE LIVE RECT the backend confirmed, and every downstream use (tap point, response) reads from that returned node — never the original. In the frozen-tree manifestation (the whole bulk tree pinned at pre-gesture values), the original rect can be stale even when the rescue verdict is correct; tapping it would have silently landed at the wrong coordinate. New regression: offscreen-double-check.test.ts's frozen-tree case, with a counterfactual (revert to computing the point from the pre-guard node) proven red then reverted. 2. SURFACE: collapsed to ONE optional backend hook, `confirmOffscreenTargetVisible?(context, node, rootViewport): Promise<Rect | null>` — conceptually a boolean, but returns the live rect so item 1's fix has something to act on. Deleted decideOffscreenRefusalDoubleCheck, the OffscreenRefusalDoubleCheckSignal/Reading ADT, and resolution.ts's dual-signal reconciliation shell: the bulk side was hardcoded 'off-screen' at the only call site, so the two-signal model was dead weight. The shared guard is now: bulk-off-screen -> ask the hook -> a live rect proceeds (patched), anything else (including no hook) throws exactly as before. The pure geometry boundary that decision reduces to (`isConfirmedOnScreenProbe` in mobile-snapshot-semantics.ts, replacing the deleted ADT) is unit-tested with two counterfactuals: ignoring `hittable` and ignoring the viewport containment check each turn a test red (proved, then reverted). `throwIfOffscreenInteractionTarget` is now exported (ADR 0011 registry honesty, see the contracts commit) and directly unit-tested in resolution.test.ts, mirroring the existing tryResolveRefNode pattern. * refactor(ios): direct-ios-selector.ts back to pure gate/parse; reuse queryDirectIosSelector Review blocker 3 (BOUNDARIES): - direct-ios-selector.ts no longer does any runner I/O — it's back to pure gate/parse (readSimpleIosSelectorTarget, deriveDirectIosNodeSelector, isDirectIosSelectorFallbackError) plus the ONE shared eligibility predicate, isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization }). Both the direct-selector tap fast path and the new offscreen double-check probe call this same function; the one behavioral difference between them (the tap fast path skips a session with a pending postGestureStabilization, the double-check does not) is now an explicit parameter instead of two separately-written gates. - The probe I/O moved to a new sibling, src/daemon/offscreen-target-probe.ts, which reuses selector-runtime.ts's `queryDirectIosSelector` (now exported and decoupled from SelectorRuntimeParams — it takes a session + a bare {key, value} selector + AppleRunnerRequestOptions) rather than opening a second querySelector client. Node extraction (`readDirectIosSelectorNode`, the one `as SnapshotNode` cast) stays singular, inside selector-runtime.ts. - interaction-runtime.ts wires confirmOffscreenTargetVisible only when isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization: false }) — deliberately NOT skipping a pending post-gesture stabilization, since that is exactly the window the double-check exists to cover. * docs(contracts): name the iOS offscreen rescue hook as part of the guarantee matrix Review blocker 4 (GUARANTEE HONESTY): the shared offscreen cell (RUNTIME_TREE_SHARED_GUARANTEES.offscreen, used by runtime-selector and runtime-ref) and the native-ref path's offscreen cell still named isNodeVisibleOnScreen as sole enforcement after #1542's double-check landed — that understates what actually enforces the guarantee now. Both cells' `via` now point at throwIfOffscreenInteractionTarget (exported from resolution.ts in the prior commit for exactly this), the real end-to-end enforcement point: isNodeVisibleOnScreen is the bulk-tree decision it starts from, and on iOS a would-be refusal can still be confirmed via the optional AgentDeviceBackend.confirmOffscreenTargetVisible hook before erroring. The cell's comment states the rescue-only, fail-closed shape explicitly per ADR 0011's matrix rules — this does not weaken the cell, it extends its description to match reality. iOS rescue policy stays OUT of resolution.ts's shared docstrings (the "spine"): this registry file is where per-path enforcement detail belongs, and the optional-method wiring in interaction-runtime.ts remains the only cross-platform touch. The registry's own gate test (interaction-guarantees.test.ts) still passes: every `via` resolves to a real exported symbol. * test(ios): move #1542 offscreen double-check tests out of interaction.test.ts Review blocker 5 (TEST HOMES): AGENTS.md forbids adding to daemon/handlers/__tests__/interaction.test.ts (it predates the test-mirrors-source-topology rule and shrinks opportunistically). Reverts the 172 lines added there in the original PR version; interaction.test.ts is back to its pre-#1542 baseline (81 tests, unchanged). The same assertions now live in their proper homes (see the prior three commits for the sources they cover): - pure decision pin: src/utils/__tests__/mobile-snapshot-semantics.test.ts (isConfirmedOnScreenProbe, with the two counterfactuals) - direct-guard pin: src/commands/interaction/runtime/resolution.test.ts (throwIfOffscreenInteractionTarget, mirroring tryResolveRefNode) - probe unit tests: src/daemon/__tests__/selector-runtime.test.ts (queryDirectIosSelector) and src/daemon/__tests__/direct-ios-selector.test.ts (isLocalIosRunnerSession, deriveDirectIosNodeSelector) - probe integration: src/daemon/__tests__/offscreen-target-probe.test.ts (confirmIosOffscreenTargetVisible, mocked runner) - end-to-end rescue/refuse, including the frozen-tree live-geometry regression + its counterfactual: new sibling src/commands/interaction/runtime/offscreen-double-check.test.ts (next to resolution.ts, using the same createInteractionDevice harness resolution.test.ts already uses) * style: oxfmt formatting for resolution.test.ts
1 parent f8617a2 commit 1235216

15 files changed

Lines changed: 903 additions & 47 deletions

packages/contracts/src/interaction-guarantees.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,18 @@ const RUNTIME_TREE_SHARED_GUARANTEES = {
147147
kind: 'runtime',
148148
via: 'src/snapshot/snapshot-occlusion.ts#isSnapshotNodeInteractionBlocked',
149149
},
150+
// #1542: the base decision is isNodeVisibleOnScreen (bulk accessibility
151+
// tree), but throwIfOffscreenInteractionTarget is the actual end-to-end
152+
// enforcement point — on iOS (local, non-provider sessions only) a would-be
153+
// refusal is re-checked against a live, tree-independent read via the
154+
// optional AgentDeviceBackend.confirmOffscreenTargetVisible hook before
155+
// erroring, and a confirmed rescue re-targets the action at the LIVE rect,
156+
// not the bulk one. Every other platform, and any backend that omits the
157+
// hook, refuses on isNodeVisibleOnScreen's verdict unchanged — this is a
158+
// rescue-only override, never a way to relax a genuine refusal.
150159
offscreen: {
151160
kind: 'runtime',
152-
via: 'src/snapshot/mobile-snapshot-semantics.ts#isNodeVisibleOnScreen',
161+
via: 'src/commands/interaction/runtime/resolution.ts#throwIfOffscreenInteractionTarget',
153162
},
154163
nonHittable: {
155164
kind: 'runtime',
@@ -291,9 +300,16 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
291300
kind: 'runtime',
292301
via: 'src/snapshot/snapshot-occlusion.ts#isSnapshotNodeInteractionBlocked',
293302
},
303+
// Same enforcement point as the runtime-tree paths (#1542): the
304+
// preflight guard IS throwIfOffscreenInteractionTarget, which can
305+
// rescue via the optional iOS confirmOffscreenTargetVisible hook — see
306+
// the comment on RUNTIME_TREE_SHARED_GUARANTEES.offscreen above. This
307+
// path is web-only in production (no mobile backend implements
308+
// tapTarget/fillTarget), so the rescue hook never fires here in
309+
// practice, but the code path is identical.
294310
offscreen: {
295311
kind: 'runtime',
296-
via: 'src/snapshot/mobile-snapshot-semantics.ts#isNodeVisibleOnScreen',
312+
via: 'src/commands/interaction/runtime/resolution.ts#throwIfOffscreenInteractionTarget',
297313
},
298314
// Annotation only (targetHittable/hint on the result): promotion to a
299315
// hittable ancestor stays a runtime-path behavior — the preflight never

src/backend.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,37 @@ export type AgentDeviceBackend = {
431431
): Promise<BackendScreenshotResult | void>;
432432
readText?(context: BackendCommandContext, node: SnapshotNode): Promise<BackendReadTextResult>;
433433
findText?(context: BackendCommandContext, text: string): Promise<BackendFindTextResult>;
434+
/**
435+
* #1542 off-screen refusal double-check: called ONLY at the moment the
436+
* shared off-screen interaction guard is about to REFUSE a click/tap/
437+
* gesture-target resolution, to re-confirm the target directly — bypassing
438+
* whatever bulk accessibility tree the guard's verdict came from (observed
439+
* on iOS: a keyboard-dismiss content-offset correction can leave a
440+
* ScrollView's bulk AX frame squeezed to a stale value, or the whole bulk
441+
* tree pinned at pre-gesture values, while the target is genuinely fine).
442+
*
443+
* Conceptually a boolean ("is this actually visible?"), but returns the
444+
* confirmed LIVE rect rather than a bare `true`/`false`: a rescue must tap
445+
* at the live coordinate, never the stale bulk-tree one the guard was
446+
* about to refuse — a caller that used the original rect after a rescue
447+
* would silently tap the wrong place when the bulk tree is stale, not just
448+
* stale-looking. `rootViewport` is the guard's own already-resolved root
449+
* viewport (Application/Window frame), passed in so an implementation can
450+
* validate the live rect's tap point against it without recomputing it.
451+
*
452+
* Returns `null` when the target cannot be positively confirmed on-screen
453+
* (no stable id/label, not found, ambiguous match, not hittable, outside
454+
* `rootViewport`, or any transport failure) — the guard MUST fail closed
455+
* (refuse) on `null`. This is a rescue path only, never a way to relax a
456+
* genuine refusal. Backends that do not support a direct, tree-independent
457+
* read simply omit this method, which leaves today's refuse-on-off-screen
458+
* behavior byte-for-byte unchanged.
459+
*/
460+
confirmOffscreenTargetVisible?(
461+
context: BackendCommandContext,
462+
node: Pick<SnapshotNode, 'identifier' | 'label'>,
463+
rootViewport: Rect | null,
464+
): Promise<Rect | null>;
434465
tap?(
435466
context: BackendCommandContext,
436467
point: Point,

src/commands/interaction/runtime/__tests__/test-utils/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ export function createInteractionDevice(
298298
| 'longPress'
299299
| 'scroll'
300300
| 'performGesture'
301+
| 'confirmOffscreenTargetVisible'
301302
>
302303
> & {
303304
platform?: AgentDeviceBackend['platform'];
@@ -327,6 +328,12 @@ export function createInteractionDevice(
327328
: undefined,
328329
scroll: overrides.scroll ? async (...args) => await overrides.scroll?.(...args) : undefined,
329330
performGesture: overrides.performGesture,
331+
// #1542: undefined by default, so every existing test keeps proving the
332+
// fail-closed refusal unchanged; only tests that opt in exercise the
333+
// rescue/agreement paths.
334+
confirmOffscreenTargetVisible: overrides.confirmOffscreenTargetVisible
335+
? async (...args) => (await overrides.confirmOffscreenTargetVisible?.(...args)) ?? null
336+
: undefined,
330337
} satisfies AgentDeviceBackend,
331338
artifacts: createLocalArtifactAdapter(),
332339
sessions: createMemorySessionStore([
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type { Rect } from '@agent-device/kernel/snapshot';
4+
import { ref, selector } from './selector-read-utils.ts';
5+
import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts';
6+
import { createInteractionDevice } from './__tests__/test-utils/index.ts';
7+
8+
// #1542: end-to-end coverage for the off-screen refusal double-check, next to
9+
// resolution.ts (AGENTS.md forbids adding to daemon/handlers/__tests__/
10+
// interaction.test.ts). Uses the same low-level runtime harness
11+
// resolution.test.ts uses (createInteractionDevice), mocking
12+
// `confirmOffscreenTargetVisible` directly rather than the local XCTest
13+
// runner — the runner-level probe I/O is covered separately in
14+
// src/daemon/__tests__/offscreen-target-probe.test.ts.
15+
16+
// The bulk tree's ScrollView ancestor is corrupted (squeezed to a sliver),
17+
// so the target's rect doesn't overlap it — the guard rejects even though
18+
// the target's OWN bulk rect ({x:126,y:136}) happens to already be correct.
19+
function keyboardSqueezedAncestorSnapshot() {
20+
return makeSnapshotState([
21+
{ index: 0, depth: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } },
22+
{
23+
index: 1,
24+
depth: 1,
25+
parentIndex: 0,
26+
type: 'ScrollView',
27+
label: 'Checkout form',
28+
rect: { x: 18, y: 381, width: 366, height: 109 },
29+
},
30+
{
31+
index: 2,
32+
depth: 2,
33+
parentIndex: 1,
34+
type: 'Button',
35+
label: 'Pickup',
36+
identifier: 'shipping-pickup',
37+
rect: { x: 126, y: 136, width: 75, height: 38 },
38+
hittable: true,
39+
},
40+
]);
41+
}
42+
43+
// The FROZEN-TREE manifestation: the whole bulk tree is pinned at
44+
// pre-gesture values, so the target's OWN bulk rect is stale — a DIFFERENT
45+
// place than where it actually is now. This is the shape #1542's live
46+
// review flagged: a naive rescue that only checks "is it confirmed
47+
// on-screen?" without also swapping in the live rect would tap the STALE
48+
// bulk coordinate.
49+
function frozenTreeStaleTargetSnapshot() {
50+
return makeSnapshotState([
51+
{ index: 0, depth: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } },
52+
{
53+
index: 1,
54+
depth: 1,
55+
parentIndex: 0,
56+
type: 'Button',
57+
label: 'Pickup',
58+
identifier: 'shipping-pickup',
59+
// Stale pre-gesture position, off the bottom of the (frozen) viewport.
60+
rect: { x: 20, y: 2000, width: 100, height: 40 },
61+
hittable: true,
62+
},
63+
]);
64+
}
65+
66+
const STALE_TARGET_REF = '@e2';
67+
// Where the runner's live probe says the element ACTUALLY is right now.
68+
const LIVE_RECT: Rect = { x: 150, y: 300, width: 100, height: 40 };
69+
const LIVE_CENTER = { x: LIVE_RECT.x + LIVE_RECT.width / 2, y: LIVE_RECT.y + LIVE_RECT.height / 2 };
70+
const STALE_CENTER = { x: 70, y: 2020 }; // center of the stale bulk rect above
71+
72+
test('rescue: click succeeds when confirmOffscreenTargetVisible confirms the target on-screen', async () => {
73+
const calls: unknown[] = [];
74+
const device = createInteractionDevice(keyboardSqueezedAncestorSnapshot(), {
75+
tap: async (_context, point) => {
76+
calls.push(point);
77+
},
78+
confirmOffscreenTargetVisible: async (_context, node) =>
79+
node.identifier === 'shipping-pickup' ? { x: 126, y: 136, width: 75, height: 38 } : null,
80+
});
81+
82+
const result = await device.interactions.click(selector('id="shipping-pickup"'), {
83+
session: 'default',
84+
});
85+
86+
assert.equal(result.kind, 'selector');
87+
// centerOfRect rounds: (126 + 75/2, 136 + 38/2) = (163.5, 155) -> (164, 155).
88+
assert.deepEqual(calls, [{ x: 164, y: 155 }]);
89+
});
90+
91+
test('FROZEN-TREE regression (#1542): a rescued tap lands at the LIVE rect, never the stale bulk one', async () => {
92+
// Counterfactual: revert resolution.ts's resolveRefInteractionTarget /
93+
// resolveSelectorInteractionTarget to compute the point from the
94+
// pre-guard `node` instead of the guard's returned (possibly
95+
// rescue-patched) node. This test goes red — `calls` records the STALE
96+
// center ({x:70,y:2020}) instead of the LIVE one asserted below.
97+
const calls: unknown[] = [];
98+
const device = createInteractionDevice(frozenTreeStaleTargetSnapshot(), {
99+
tap: async (_context, point) => {
100+
calls.push(point);
101+
},
102+
confirmOffscreenTargetVisible: async (_context, node) =>
103+
node.identifier === 'shipping-pickup' ? LIVE_RECT : null,
104+
});
105+
106+
const result = await device.interactions.click(ref(STALE_TARGET_REF), { session: 'default' });
107+
108+
assert.equal(result.kind, 'ref');
109+
assert.deepEqual(calls, [LIVE_CENTER]);
110+
assert.notDeepEqual(calls, [STALE_CENTER]);
111+
// The response's own point must agree with what was actually dispatched.
112+
assert.deepEqual(result.point, LIVE_CENTER);
113+
});
114+
115+
test('genuine refusal: confirmOffscreenTargetVisible returns null -> still refuses, no tap', async () => {
116+
const calls: unknown[] = [];
117+
const device = createInteractionDevice(keyboardSqueezedAncestorSnapshot(), {
118+
tap: async (_context, point) => {
119+
calls.push(point);
120+
},
121+
confirmOffscreenTargetVisible: async () => null,
122+
});
123+
124+
await assert.rejects(
125+
() => device.interactions.click(selector('id="shipping-pickup"'), { session: 'default' }),
126+
(error: unknown) => {
127+
assert.ok(error instanceof Error);
128+
assert.match(error.message, /off-screen element/);
129+
return true;
130+
},
131+
);
132+
assert.equal(calls.length, 0);
133+
});
134+
135+
test('no rescue hook wired (e.g. non-iOS backend) -> refuses exactly as before', async () => {
136+
const calls: unknown[] = [];
137+
const device = createInteractionDevice(keyboardSqueezedAncestorSnapshot(), {
138+
tap: async (_context, point) => {
139+
calls.push(point);
140+
},
141+
// confirmOffscreenTargetVisible intentionally omitted.
142+
});
143+
144+
await assert.rejects(
145+
() => device.interactions.click(selector('id="shipping-pickup"'), { session: 'default' }),
146+
/off-screen element/,
147+
);
148+
assert.equal(calls.length, 0);
149+
});

src/commands/interaction/runtime/resolution.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { test } from 'vitest';
33
import type { BackendSnapshotOptions } from '../../../backend.ts';
44
import { ref, selector } from './selector-read-utils.ts';
55
import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts';
6-
import { tryResolveRefNode } from './resolution.ts';
6+
import { throwIfOffscreenInteractionTarget, tryResolveRefNode } from './resolution.ts';
77
import { parseSelectorChain, resolveSelectorChain } from '../../../selectors/index.ts';
88
import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts';
99
import type { Point } from '@agent-device/kernel/snapshot';
@@ -614,3 +614,93 @@ test('tryResolveRefNode discloses exact for a resolved ref and label-fallback fo
614614

615615
assert.equal(tryResolveRefNode(nodes, '@e9', { fallbackLabel: '' }), null);
616616
});
617+
618+
// #1542: throwIfOffscreenInteractionTarget is exported for ADR 0011 registry
619+
// honesty (interaction-guarantees.ts's `offscreen` cells point their `via`
620+
// here); this direct-import test is its real consumer, mirroring
621+
// tryResolveRefNode above. End-to-end rescue/refuse coverage through the
622+
// public click/press surface lives in offscreen-double-check.test.ts.
623+
function fakeOffscreenFailure() {
624+
return {
625+
message: 'off-screen',
626+
details: { reason: 'test' },
627+
hint: () => 'scroll toward it',
628+
};
629+
}
630+
631+
test('throwIfOffscreenInteractionTarget: an on-screen node passes through unchanged', async () => {
632+
const device = createInteractionDevice(makeSnapshotState([]));
633+
const nodes = makeSnapshotState([
634+
{ index: 0, depth: 0, type: 'Application', rect: { x: 0, y: 0, width: 400, height: 800 } },
635+
{
636+
index: 1,
637+
depth: 1,
638+
parentIndex: 0,
639+
type: 'Button',
640+
rect: { x: 20, y: 20, width: 40, height: 40 },
641+
},
642+
]).nodes;
643+
644+
const result = await throwIfOffscreenInteractionTarget(
645+
device,
646+
{ session: 'default' },
647+
nodes[1]!,
648+
nodes,
649+
fakeOffscreenFailure(),
650+
);
651+
652+
assert.equal(result, nodes[1]);
653+
});
654+
655+
test('throwIfOffscreenInteractionTarget: off-screen + backend confirms -> returns the node patched with the LIVE rect', async () => {
656+
const device = createInteractionDevice(makeSnapshotState([]), {
657+
confirmOffscreenTargetVisible: async () => ({ x: 30, y: 30, width: 40, height: 40 }),
658+
});
659+
const nodes = makeSnapshotState([
660+
{ index: 0, depth: 0, type: 'Application', rect: { x: 0, y: 0, width: 400, height: 800 } },
661+
{
662+
index: 1,
663+
depth: 1,
664+
parentIndex: 0,
665+
type: 'Button',
666+
rect: { x: 20, y: 2000, width: 40, height: 40 },
667+
},
668+
]).nodes;
669+
670+
const result = await throwIfOffscreenInteractionTarget(
671+
device,
672+
{ session: 'default' },
673+
nodes[1]!,
674+
nodes,
675+
fakeOffscreenFailure(),
676+
);
677+
678+
assert.deepEqual(result.rect, { x: 30, y: 30, width: 40, height: 40 });
679+
assert.equal(result.index, nodes[1]!.index);
680+
});
681+
682+
test('throwIfOffscreenInteractionTarget: off-screen + no rescue -> throws with the supplied failure shape', async () => {
683+
const device = createInteractionDevice(makeSnapshotState([]));
684+
const nodes = makeSnapshotState([
685+
{ index: 0, depth: 0, type: 'Application', rect: { x: 0, y: 0, width: 400, height: 800 } },
686+
{
687+
index: 1,
688+
depth: 1,
689+
parentIndex: 0,
690+
type: 'Button',
691+
rect: { x: 20, y: 2000, width: 40, height: 40 },
692+
},
693+
]).nodes;
694+
695+
await assert.rejects(
696+
() =>
697+
throwIfOffscreenInteractionTarget(
698+
device,
699+
{ session: 'default' },
700+
nodes[1]!,
701+
nodes,
702+
fakeOffscreenFailure(),
703+
),
704+
/off-screen/,
705+
);
706+
});

0 commit comments

Comments
 (0)