Skip to content

Commit fd3c6ac

Browse files
committed
fix(ios): preserve snapshot fallback lineage
1 parent 1600d40 commit fd3c6ac

5 files changed

Lines changed: 139 additions & 15 deletions

File tree

packages/capture-kit/src/ios-snapshot-planning.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ function residueIdentity(residue: IosAcquisitionResidue): string {
190190
expected: residue.expected,
191191
observed: residue.observed,
192192
});
193+
case 'unknown-generation':
194+
return JSON.stringify({ kind: residue.kind, captureId: residue.captureId });
193195
case 'unavailable-fact':
194196
return JSON.stringify({ kind: residue.kind, fact: residue.fact });
195197
case 'fallback-source':

packages/contracts/src/ios-snapshot.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ export type IosAcquisitionResidue =
142142
expected?: IosSnapshotGeneration;
143143
observed?: IosSnapshotGeneration;
144144
}>
145+
| Readonly<{
146+
kind: 'unknown-generation';
147+
captureId: string;
148+
}>
145149
| Readonly<{
146150
kind: 'unavailable-fact';
147151
fact: IosSnapshotFact;

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

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test, vi } from 'vitest';
22
import type { DeviceInfo } from '@agent-device/kernel/device';
3+
import { areIosSnapshotComparisonIdentitiesEqual } from '@agent-device/capture-kit/ios-snapshot-planning';
34
import { platformRuntimeHostFixture } from './runtime.fixtures.ts';
45
import { createAppleSnapshotRoute } from './snapshot-route.ts';
56
import type { SimulatorSnapshotSource, SnapshotSourceOutcome } from './snapshot-source-facade.ts';
@@ -81,7 +82,7 @@ test('typed bridge failure falls back once and disables retries for that app gen
8182
test('a new app generation re-enables the bridge', async () => {
8283
const source = sourceReturning({
8384
stage: 'failed',
84-
failure: { kind: 'stale-target', code: 'target-generation-changed' },
85+
failure: { kind: 'transport-failure', code: 'bridge-disconnected' },
8586
});
8687
const resolveTarget = vi
8788
.fn()
@@ -101,6 +102,27 @@ test('a new app generation re-enables the bridge', async () => {
101102
expect(source.acquire).toHaveBeenCalledTimes(2);
102103
});
103104

105+
test('stale bridge acquisition resolves the current generation before XCTest fallback', async () => {
106+
const currentTarget = { ...target, pid: 84, generation: '84:launch-b' };
107+
const source = sourceReturning({
108+
stage: 'failed',
109+
failure: { kind: 'stale-target', code: 'target-generation-changed' },
110+
});
111+
const resolveTarget = vi.fn().mockResolvedValueOnce(target).mockResolvedValueOnce(currentTarget);
112+
const route = createAppleSnapshotRoute(platformRuntimeHostFixture(), {
113+
source,
114+
resolveTarget,
115+
});
116+
117+
const result = await route.capture(ios, input, signal(), async () => runnerResult());
118+
119+
expect(resolveTarget).toHaveBeenCalledTimes(2);
120+
expect(result.comparisonIdentity?.lineage).toEqual({
121+
targetId: currentTarget.targetId,
122+
generation: currentTarget.generation,
123+
});
124+
});
125+
104126
test('target-resolution fallback remains incomparable with a bridge publication', async () => {
105127
const source = sourceReturning(bridgeAcquisition());
106128
const fallback = vi.fn(async () => runnerResult());
@@ -117,8 +139,29 @@ test('target-resolution fallback remains incomparable with a bridge publication'
117139
expect(result.comparisonIdentity).toMatchObject({
118140
producer: 'apple-runner',
119141
lineage: { targetId: target.targetId },
120-
residue: [{ kind: 'fallback-source', producer: 'apple-runner' }],
142+
residue: [
143+
{ kind: 'unknown-generation', captureId: expect.any(String) },
144+
{ kind: 'fallback-source', producer: 'apple-runner' },
145+
],
146+
});
147+
});
148+
149+
test('two target-resolution fallbacks cannot share comparison identity', async () => {
150+
const route = createAppleSnapshotRoute(platformRuntimeHostFixture(), {
151+
source: sourceReturning(bridgeAcquisition()),
152+
resolveTarget: vi.fn(async () => {
153+
throw new Error('launch job unavailable');
154+
}),
121155
});
156+
157+
const first = await route.capture(ios, input, signal(), async () => runnerResult());
158+
const second = await route.capture(ios, input, signal(), async () => runnerResult());
159+
160+
expect(first.comparisonIdentity).toBeDefined();
161+
expect(second.comparisonIdentity).toBeDefined();
162+
expect(
163+
areIosSnapshotComparisonIdentitiesEqual(first.comparisonIdentity!, second.comparisonIdentity!),
164+
).toBe(false);
122165
});
123166

124167
test('runtime shutdown closes the process-owned bridge source', async () => {

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

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { randomUUID } from 'node:crypto';
12
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
23
import type {
34
CaptureSnapshotInput,
45
SnapshotResult,
56
SnapshotRuntimeAcquiredResult,
67
} from '@agent-device/contracts/snapshot-runtime';
78
import type {
9+
IosAcquisitionResidue,
810
IosSnapshotComparisonIdentity,
911
IosSnapshotLineage,
1012
} from '@agent-device/contracts/ios-snapshot';
@@ -55,13 +57,15 @@ export function createAppleSnapshotRoute(
5557
try {
5658
target = await resolveTarget(device, input.options!.appBundleId!, signal);
5759
} catch (error) {
60+
signal.throwIfAborted();
5861
emitRouteDiagnostic('target-resolution-failed', device, undefined, error);
5962
return await runFallback(
6063
input,
6164
fallback,
6265
{ targetId: `${device.id}:${input.options!.appBundleId!}` },
6366
requestFor(input),
6467
'target-resolution-failed',
68+
[unknownGenerationResidue()],
6569
);
6670
}
6771
rebaselineGeneration(target, latestGeneration, disabledGenerations);
@@ -84,10 +88,19 @@ export function createAppleSnapshotRoute(
8488
...outcome.failure.details,
8589
});
8690
}
91+
const fallbackIdentity = await resolveFailureFallbackIdentity(
92+
outcome.failure,
93+
target,
94+
device,
95+
input.options!.appBundleId!,
96+
signal,
97+
resolveTarget,
98+
);
8799
return await fallbackAfterFailure(
88100
input,
89101
fallback,
90102
target,
103+
fallbackIdentity,
91104
request,
92105
outcome.failure,
93106
disabledGenerations,
@@ -108,6 +121,7 @@ export function createAppleSnapshotRoute(
108121
input,
109122
fallback,
110123
target,
124+
{ lineage: target, residue: [] },
111125
request,
112126
{ kind: 'malformed-tree', code: 'presentation-invariant' },
113127
disabledGenerations,
@@ -132,15 +146,29 @@ function isEligible(device: DeviceInfo, input: CaptureSnapshotInput): boolean {
132146
async function fallbackAfterFailure(
133147
input: CaptureSnapshotInput,
134148
fallback: SnapshotFallback,
135-
target: SimulatorSnapshotTarget,
149+
failedTarget: SimulatorSnapshotTarget,
150+
identity: FallbackIdentity,
136151
request: ReturnType<typeof createIosSnapshotRequest>,
137152
failure: SnapshotSourceFailure,
138153
disabledGenerations: Set<string>,
139154
cause?: unknown,
140155
): Promise<SnapshotResult> {
141-
disabledGenerations.add(generationKey(target));
142-
emitRouteDiagnostic(failure.code, { id: target.udid }, target.generation, cause, failure.details);
143-
return await runFallback(input, fallback, target, request, failure.code);
156+
disabledGenerations.add(generationKey(failedTarget));
157+
emitRouteDiagnostic(
158+
failure.code,
159+
{ id: failedTarget.udid },
160+
failedTarget.generation,
161+
cause,
162+
failure.details,
163+
);
164+
return await runFallback(
165+
input,
166+
fallback,
167+
identity.lineage,
168+
request,
169+
failure.code,
170+
identity.residue,
171+
);
144172
}
145173

146174
async function runFallback(
@@ -149,23 +177,64 @@ async function runFallback(
149177
lineage: IosSnapshotLineage,
150178
request: ReturnType<typeof createIosSnapshotRequest>,
151179
reason: string,
180+
residue: readonly IosAcquisitionResidue[] = [],
152181
): Promise<SnapshotResult> {
153182
const result = await fallback(input);
154183
const comparisonIdentity: IosSnapshotComparisonIdentity = Object.freeze({
155184
producer: 'apple-runner',
156185
intent: request.acquisitionIntent,
157-
lineage: Object.freeze({ ...lineage }),
186+
lineage: Object.freeze({
187+
...(lineage.targetId ? { targetId: lineage.targetId } : {}),
188+
...(lineage.generation ? { generation: lineage.generation } : {}),
189+
}),
158190
presentationKey: buildIosSnapshotPresentationKey(request),
159-
residue: Object.freeze([{ kind: 'fallback-source', producer: 'apple-runner' } as const]),
191+
residue: Object.freeze([
192+
...residue,
193+
{ kind: 'fallback-source', producer: 'apple-runner' } as const,
194+
]),
160195
});
161-
const warning = `Simulator AX snapshot unavailable (${reason}); used XCTest for this app generation.`;
196+
const generation = lineage.generation ? 'this app generation' : 'an unverified app generation';
197+
const warning = `Simulator AX snapshot unavailable (${reason}); used XCTest for ${generation}.`;
162198
return {
163199
...result,
164200
comparisonIdentity,
165201
warnings: [...(result.warnings ?? []), warning],
166202
};
167203
}
168204

205+
type FallbackIdentity = Readonly<{
206+
lineage: IosSnapshotLineage;
207+
residue: readonly IosAcquisitionResidue[];
208+
}>;
209+
210+
async function resolveFailureFallbackIdentity(
211+
failure: SnapshotSourceFailure,
212+
target: SimulatorSnapshotTarget,
213+
device: DeviceInfo,
214+
appBundleId: string,
215+
signal: AbortSignal,
216+
resolveTarget: typeof resolveSimulatorSnapshotTarget,
217+
): Promise<FallbackIdentity> {
218+
if (failure.kind !== 'stale-target') return { lineage: target, residue: [] };
219+
try {
220+
return {
221+
lineage: await resolveTarget(device, appBundleId, signal),
222+
residue: [],
223+
};
224+
} catch (error) {
225+
signal.throwIfAborted();
226+
emitRouteDiagnostic('fallback-target-resolution-failed', device, undefined, error);
227+
return {
228+
lineage: { targetId: target.targetId },
229+
residue: [unknownGenerationResidue()],
230+
};
231+
}
232+
}
233+
234+
function unknownGenerationResidue(): IosAcquisitionResidue {
235+
return { kind: 'unknown-generation', captureId: randomUUID() };
236+
}
237+
169238
function requestFor(input: CaptureSnapshotInput) {
170239
return createIosSnapshotRequest({
171240
raw: input.options?.raw,

test/integration/ios-simulator-e2e/live-snapshot-depth-frontier.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { type LiveContext, runStep, verifyBehavior } from './live-harness.ts';
66

77
const VISIBLE_DEPTH_DEEP_LINK = 'agent-device-test-app:///snapshot-depth';
88
const CHILD_ID = 'visible-depth-projected-child';
9+
const MISSING_HITTABILITY_WARNING =
10+
'iOS snapshot acquisition does not provide hittability evidence; regular snapshots omit unverified hittability while raw snapshots preserve supplied facts.';
911

1012
type SnapshotNode = {
1113
depth?: unknown;
@@ -32,7 +34,7 @@ export async function assertRegularVisibleDepthFrontier(context: LiveContext): P
3234
'--depth',
3335
'1',
3436
]);
35-
assertSnapshotBackend(regular, 'regular depth-1 snapshot');
37+
assertSimulatorBridgeSnapshot(regular, 'regular depth-1 snapshot');
3638
const regularNodes = snapshotNodes(regular);
3739
const regularRoot = requireRoot(regularNodes, 'regular depth-1 snapshot');
3840
const projectedChild = requireIdentifier(regularNodes, CHILD_ID, 'regular depth-1 snapshot');
@@ -55,7 +57,7 @@ export async function assertRegularVisibleDepthFrontier(context: LiveContext): P
5557
'snapshot',
5658
'--raw',
5759
]);
58-
assertSnapshotBackend(rawFull, 'full raw visible-depth snapshot');
60+
assertSimulatorBridgeSnapshot(rawFull, 'full raw visible-depth snapshot');
5961
const rawFullNodes = snapshotNodes(rawFull);
6062
const rawChild = requireIdentifier(rawFullNodes, CHILD_ID, 'full raw visible-depth snapshot');
6163
assert.ok(
@@ -69,7 +71,7 @@ export async function assertRegularVisibleDepthFrontier(context: LiveContext): P
6971
'--depth',
7072
'1',
7173
]);
72-
assertSnapshotBackend(rawDepthOne, 'raw depth-1 visible-depth snapshot');
74+
assertSimulatorBridgeSnapshot(rawDepthOne, 'raw depth-1 visible-depth snapshot');
7375
const rawDepthOneNodes = snapshotNodes(rawDepthOne);
7476
assert.equal(
7577
rawDepthOneNodes.some((node) => node.identifier === CHILD_ID),
@@ -127,10 +129,14 @@ function numericDepth(node: SnapshotNode): number {
127129
return node.depth as number;
128130
}
129131

130-
function assertSnapshotBackend(result: { json?: any }, description: string): void {
132+
function assertSimulatorBridgeSnapshot(result: { json?: any }, description: string): void {
131133
assert.equal(
132134
result.json?.data?.snapshotQuality?.backend,
133-
'tree',
134-
`${description} must exercise the recursive tree backend: ${JSON.stringify(result)}`,
135+
undefined,
136+
`${description} must not carry XCTest tree quality metadata: ${JSON.stringify(result)}`,
137+
);
138+
assert.ok(
139+
result.json?.data?.warnings?.includes(MISSING_HITTABILITY_WARNING),
140+
`${description} must disclose the Simulator AX bridge evidence gap: ${JSON.stringify(result)}`,
135141
);
136142
}

0 commit comments

Comments
 (0)