Skip to content

Commit 28c6e86

Browse files
committed
fix(ios): identify a surface capture by what the runner served
The `present` path stamped the capture's comparison lineage from the host-side presence probe. That probe answers about a host PROCESS and deliberately stays positive while a dismissed host lingers, so during that window the runner truthfully returned APP content while the route lineaged it to the HOST: the sheet capture before the dismissal and the app capture after it compared equal, and a post-gesture poll could read the transition as a stable surface. Derive the identity from the returned capture's `systemSurface` instead - the runner stamps the surface it actually served - and say which of the two the capture holds in the warning. The probe's host is now evidence only: it names the matched host in a route diagnostic so a lingering window is legible in the daemon log. Other reasons keep their lineage and wording byte for byte. Captures that bypass the route's planning (a pinned backend, a custom-actions read) also reach the runner, and the runner serves the sheet there too. They carried no comparison key at all, so a sheet and app content fell through to legacy presentation matching as one presentation and could corroborate a tap across the two. The capture owner now gives those a surface-scoped identity as well, with no fallback-source residue: nothing fell back. An app capture off the route is untouched.
1 parent 6ba5b3d commit 28c6e86

2 files changed

Lines changed: 172 additions & 24 deletions

File tree

packages/platform-apple/src/snapshot-route.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ test('a presented system surface captures under the host lineage, never the app
9494
systemSurfacePresent: async () => presentSurface,
9595
});
9696

97-
const first = await route.capture(ios, input, signal(), async () => runnerResult());
98-
const second = await route.capture(ios, input, signal(), async () => runnerResult());
97+
const first = await route.capture(ios, input, signal(), async () => surfaceRunnerResult());
98+
const second = await route.capture(ios, input, signal(), async () => surfaceRunnerResult());
9999

100100
expect(first.comparisonIdentity).toMatchObject({
101101
producer: 'apple-runner',
@@ -113,6 +113,71 @@ test('a presented system surface captures under the host lineage, never the app
113113
]);
114114
});
115115

116+
// The host-side probe answers about a host PROCESS, which stays positive while a dismissed host
117+
// lingers — a documented false positive. Only the runner answers about the screen, and it stamps the
118+
// surface it served on the capture. Reading the probe for identity instead would lineage the app
119+
// capture to the host, make it compare EQUAL to the preceding sheet capture, and let a post-gesture
120+
// poll read the dismissal as a stable surface (#2438).
121+
test('a lingering probe cannot make a sheet capture and an app capture compare equal', async () => {
122+
const route = createAppleSnapshotRoute(platformRuntimeHostFixture(), {
123+
source: sourceReturning(bridgeAcquisition()),
124+
resolveTarget: vi.fn(async () => target),
125+
systemSurfacePresent: async () => presentSurface,
126+
});
127+
128+
const sheet = await route.capture(ios, input, signal(), async () => surfaceRunnerResult());
129+
const app = await route.capture(ios, input, signal(), async () => runnerResult());
130+
const stillApp = await route.capture(ios, input, signal(), async () => runnerResult());
131+
132+
expect(sheet.comparisonIdentity?.lineage).toEqual({
133+
targetId: `${ios.id}:${presentSurface.host.bundleId}`,
134+
});
135+
expect(app.comparisonIdentity?.lineage).toEqual({ targetId: target.targetId });
136+
expect(
137+
areIosSnapshotComparisonIdentitiesEqual(sheet.comparisonIdentity!, app.comparisonIdentity!),
138+
).toBe(false);
139+
// Two app captures taken in the same lingering window still compare equal, so a poll can settle on
140+
// app content: the capture decides the lineage, and nothing here carries a per-capture residue.
141+
expect(
142+
areIosSnapshotComparisonIdentitiesEqual(app.comparisonIdentity!, stillApp.comparisonIdentity!),
143+
).toBe(true);
144+
expect(app.warnings).toEqual([
145+
'Simulator AX snapshot inapplicable (system-surface-host-lingering); used XCTest, which read app content: the system surface host process was still running but no longer presenting.',
146+
]);
147+
});
148+
149+
// A pinned backend and a custom-actions read bypass the route's planning, but they still reach the
150+
// runner, and the runner serves the sheet there too. Without an identity that pair falls back to
151+
// legacy presentation matching, where a sheet and app content read as one presentation and could
152+
// corroborate a tap across the two (#2438).
153+
test.each([
154+
['a pinned backend', { preferredBackend: 'private-ax' }],
155+
['a custom-actions read', { customActions: true }],
156+
] as const)(
157+
'a route-bypassing capture of a system surface is incomparable (%s)',
158+
async (_label, bypass) => {
159+
const route = createAppleSnapshotRoute(platformRuntimeHostFixture(), {
160+
source: sourceReturning(bridgeAcquisition()),
161+
resolveTarget: vi.fn(async () => target),
162+
});
163+
const bypassInput = { options: { ...input.options, ...bypass } };
164+
165+
const sheet = await route.capture(ios, bypassInput, signal(), async () =>
166+
surfaceRunnerResult(),
167+
);
168+
const app = await route.capture(ios, bypassInput, signal(), async () => runnerResult());
169+
170+
expect(sheet.comparisonIdentity).toMatchObject({
171+
producer: 'apple-runner',
172+
lineage: { targetId: `${ios.id}:${presentSurface.host.bundleId}` },
173+
// Nothing fell back here: the runner is the requested producer, not a replacement for the bridge.
174+
residue: [],
175+
});
176+
// An app capture off the route is untouched — identity included, as before.
177+
expect(app).toEqual(runnerResult());
178+
},
179+
);
180+
116181
// Losing the bridge fast path must never be silent: an unprovable probe still owes the caller a
117182
// warning and an identity that cannot be compared against a bridge publication.
118183
test('a probe that cannot answer discloses the skipped bridge and stays incomparable', async () => {
@@ -448,6 +513,14 @@ function runnerResult() {
448513
return { backend: 'xctest' as const, producer: 'apple-runner' as const, nodes: [] };
449514
}
450515

516+
/** The runner's capture OF the sheet: it stamps the surface it actually served onto the result. */
517+
function surfaceRunnerResult() {
518+
return {
519+
...runnerResult(),
520+
systemSurface: { bundleId: presentSurface.host.bundleId, kind: presentSurface.host.kind },
521+
};
522+
}
523+
451524
function signal(): AbortSignal {
452525
return new AbortController().signal;
453526
}

packages/platform-apple/src/snapshot-route.ts

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ type SnapshotFallback = (input: CaptureSnapshotInput) => Promise<SnapshotResult>
4242
/** Why this capture left the bridge: a system surface the bridge cannot see was on screen. */
4343
const SYSTEM_SURFACE_PRESENTED = 'system-surface-presented';
4444

45+
/**
46+
* The same decision, against the host-side probe's one documented false positive: the surface host
47+
* process outlives the dismissal of its sheet (see `system-surface-presence.ts`), so the bridge was
48+
* skipped for a surface the runner then did not serve.
49+
*/
50+
const SYSTEM_SURFACE_HOST_LINGERING = 'system-surface-host-lingering';
51+
4552
export type AppleSnapshotRoute = LaunchObservationPort &
4653
Readonly<{
4754
capture(
@@ -87,7 +94,7 @@ export function createAppleSnapshotRoute(
8794
awaitObservable: observation.awaitObservable,
8895
shutdown: async () => await source.close(),
8996
capture: async (device, input, signal, fallback) => {
90-
if (!isEligible(device, input)) return await fallback(input);
97+
if (!isEligible(device, input)) return await captureOffRoute(device, input, fallback);
9198
// A system surface (e.g. the web sign-in sheet) presented over the app is invisible to the
9299
// host AX bridge — the app is still the AX primaryApp, so the bridge would serve the occluded
93100
// app tree as if healthy (#2438). The XCTest runner can see and drive the surface, so route
@@ -110,16 +117,7 @@ export function createAppleSnapshotRoute(
110117
);
111118
}
112119
if (surfacePresence !== 'absent') {
113-
// The capture describes the host's surface, not the app, so it is lineaged to that host: its
114-
// comparison identity differs from an app capture's by construction, while two captures of
115-
// the same surface share one and stay comparable with each other.
116-
return await runFallback(
117-
input,
118-
fallback,
119-
{ targetId: `${device.id}:${surfacePresence.host.bundleId}` },
120-
requestFor(input),
121-
SYSTEM_SURFACE_PRESENTED,
122-
);
120+
return await runSurfaceFallback(device, input, fallback, surfacePresence.host.bundleId);
123121
}
124122
let target: SimulatorSnapshotTarget;
125123
try {
@@ -276,37 +274,114 @@ async function runFallback(
276274
request: ReturnType<typeof createIosSnapshotRequest>,
277275
reason: string,
278276
residue: readonly IosAcquisitionResidue[] = [],
277+
): Promise<SnapshotResult> {
278+
return stampFallback(await fallback(input), lineage, request, reason, residue);
279+
}
280+
281+
/**
282+
* The `present` path's capture, identified by what the runner actually served rather than by what
283+
* the probe predicted. The probe answers about a host PROCESS and stays positive while a dismissed
284+
* host lingers, while the runner answers about the screen and stamps the surface it served onto the
285+
* capture — so the capture is the authority. Reading the probe here instead would lineage an app
286+
* capture to the host, and the sheet capture before a dismissal would compare EQUAL to the app
287+
* capture after it, which is exactly the transition a post-gesture poll must not miss (#2438).
288+
*
289+
* `detectedHost` is therefore evidence, not identity: it names the host the probe matched so a
290+
* lingering window is legible in the daemon log instead of looking like a missing bridge capture.
291+
*/
292+
async function runSurfaceFallback(
293+
device: DeviceInfo,
294+
input: CaptureSnapshotInput,
295+
fallback: SnapshotFallback,
296+
detectedHost: string,
279297
): Promise<SnapshotResult> {
280298
const result = await fallback(input);
281-
const comparisonIdentity: IosSnapshotComparisonIdentity = Object.freeze({
299+
const served = result.systemSurface;
300+
const reason = served ? SYSTEM_SURFACE_PRESENTED : SYSTEM_SURFACE_HOST_LINGERING;
301+
if (!served) emitRouteDiagnostic(reason, device, undefined, undefined, { detectedHost });
302+
return stampFallback(
303+
result,
304+
{ targetId: `${device.id}:${served?.bundleId ?? input.options!.appBundleId!}` },
305+
requestFor(input),
306+
reason,
307+
);
308+
}
309+
310+
/**
311+
* A capture the route cannot plan — a pinned backend or a custom-actions read, see
312+
* {@link isEligible} — still reaches the XCTest runner, and the runner serves a presented system
313+
* surface on those paths too. Such a capture describes the surface rather than the app, so it is
314+
* identified like any other surface capture: without an identity it would fall back to legacy
315+
* presentation matching, where a sheet and the app read as the same presentation and could
316+
* corroborate a tap across the two (#2438). An app capture off the route carries no identity, as
317+
* before: the route planned nothing about it.
318+
*/
319+
async function captureOffRoute(
320+
device: DeviceInfo,
321+
input: CaptureSnapshotInput,
322+
fallback: SnapshotFallback,
323+
): Promise<SnapshotResult> {
324+
const result = await fallback(input);
325+
const served = result.systemSurface;
326+
if (!served) return result;
327+
return {
328+
...result,
329+
comparisonIdentity: runnerComparisonIdentity(
330+
{ targetId: `${device.id}:${served.bundleId}` },
331+
requestFor(input),
332+
[],
333+
),
334+
};
335+
}
336+
337+
function stampFallback(
338+
result: SnapshotResult,
339+
lineage: IosSnapshotLineage,
340+
request: ReturnType<typeof createIosSnapshotRequest>,
341+
reason: string,
342+
residue: readonly IosAcquisitionResidue[] = [],
343+
): SnapshotResult {
344+
return {
345+
...result,
346+
comparisonIdentity: runnerComparisonIdentity(lineage, request, [
347+
...residue,
348+
{ kind: 'fallback-source', producer: 'apple-runner' },
349+
]),
350+
warnings: [...(result.warnings ?? []), fallbackWarning(reason, lineage)],
351+
};
352+
}
353+
354+
function runnerComparisonIdentity(
355+
lineage: IosSnapshotLineage,
356+
request: ReturnType<typeof createIosSnapshotRequest>,
357+
residue: readonly IosAcquisitionResidue[],
358+
): IosSnapshotComparisonIdentity {
359+
return Object.freeze({
282360
producer: 'apple-runner',
283361
intent: request.acquisitionIntent,
284362
lineage: Object.freeze({
285363
...(lineage.targetId ? { targetId: lineage.targetId } : {}),
286364
...(lineage.generation ? { generation: lineage.generation } : {}),
287365
}),
288366
presentationKey: buildIosSnapshotPresentationKey(request),
289-
residue: Object.freeze([
290-
...residue,
291-
{ kind: 'fallback-source', producer: 'apple-runner' } as const,
292-
]),
367+
residue: Object.freeze([...residue]),
293368
});
294-
return {
295-
...result,
296-
comparisonIdentity,
297-
warnings: [...(result.warnings ?? []), fallbackWarning(reason, lineage)],
298-
};
299369
}
300370

301371
/**
302372
* A presented system surface is not a bridge failure: the bridge is healthy and simply cannot see
303373
* the surface, so it is inapplicable here rather than unavailable — and the capture belongs to that
304-
* surface, not to an app generation. Every other reason keeps the unavailable sentence.
374+
* surface, not to an app generation. A lingering host is the same decision over a surface the runner
375+
* did not serve, so that sentence says what the capture holds instead. Every other reason keeps the
376+
* unavailable sentence.
305377
*/
306378
function fallbackWarning(reason: string, lineage: IosSnapshotLineage): string {
307379
if (reason === SYSTEM_SURFACE_PRESENTED) {
308380
return `Simulator AX snapshot inapplicable (${reason}); used XCTest to read the system surface presented over the app.`;
309381
}
382+
if (reason === SYSTEM_SURFACE_HOST_LINGERING) {
383+
return `Simulator AX snapshot inapplicable (${reason}); used XCTest, which read app content: the system surface host process was still running but no longer presenting.`;
384+
}
310385
const generation = lineage.generation ? 'this app generation' : 'an unverified app generation';
311386
return `Simulator AX snapshot unavailable (${reason}); used XCTest for ${generation}.`;
312387
}

0 commit comments

Comments
 (0)