Skip to content

Commit fc60fd5

Browse files
committed
test(replay): add targeted iOS sparse-snapshot retry regressions
Address the reviewer's second finding on #1386: the Android capture-failed retry path had recover/exhaustion regressions, but the iOS sparse-snapshot verdict branch (isSparseSnapshotQualityVerdict, always-retryable, no thrown error involved) had no test of its own - only incidental coverage by way of the Android-shaped tests. Add the mirror pair: a sparse verdict that recovers within the bounded retry and dispatches, and one that never recovers and fails closed as identity-unverifiable once the retry is exhausted.
1 parent 4de8628 commit fc60fd5

1 file changed

Lines changed: 99 additions & 0 deletions

File tree

‎src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,105 @@ test('a content-quality capture failure that never recovers still fails closed a
616616
expect(mockDispatchCommand.mock.calls.length).toBeGreaterThan(1);
617617
});
618618

619+
// #1385: the iOS side of the same launch race — the capture doesn't throw,
620+
// it returns a structurally-valid tree flagged `sparse` (the private-AX
621+
// fallback engaging under load). `isSparseSnapshotQualityVerdict` treats this
622+
// as always-retryable, unlike the Android thrown-error branch, which is
623+
// gated on the narrower content-quality taxonomy — so this needs its own
624+
// regression, not just coverage by the Android capture-failed tests above.
625+
function iosSparseCapture(): {
626+
nodes: never[];
627+
truncated: false;
628+
backend: 'xctest';
629+
quality: object;
630+
} {
631+
return {
632+
nodes: [],
633+
truncated: false,
634+
backend: 'xctest',
635+
quality: { state: 'sparse', backend: 'private-ax', reason: 'AX query rejected mid-transition' },
636+
};
637+
}
638+
639+
test('an iOS sparse-snapshot verdict right after launch recovers within the bounded retry, and the action still dispatches', async () => {
640+
const root = fs.mkdtempSync(
641+
path.join(os.tmpdir(), 'agent-device-replay-target-verify-launch-race-sparse-recover-'),
642+
);
643+
const { sessionStore, sessionName } = setupSession(root);
644+
const filePath = writeReplayFile(root, [SAVE_ANNOTATION, 'click id="save"']);
645+
646+
// First two captures return a structurally-valid but sparse-flagged tree
647+
// (mirrors the private-AX fallback engaging mid-launch); the third lands
648+
// after the app settles and finds the recorded target.
649+
mockDispatchCommand
650+
.mockImplementationOnce(async () => iosSparseCapture())
651+
.mockImplementationOnce(async () => iosSparseCapture())
652+
.mockResolvedValue({
653+
nodes: [
654+
{
655+
index: 0,
656+
depth: 0,
657+
type: 'Button',
658+
identifier: 'save',
659+
label: 'Save',
660+
rect: { x: 10, y: 10, width: 40, height: 20 },
661+
},
662+
],
663+
truncated: false,
664+
backend: 'xctest',
665+
});
666+
667+
const invoked: DaemonRequest[] = [];
668+
const response = await runReplayScriptFile({
669+
req: baseReq({ positionals: [filePath] }),
670+
sessionName,
671+
logPath: path.join(root, 'daemon.log'),
672+
sessionStore,
673+
invoke: async (req) => {
674+
invoked.push(req);
675+
return { ok: true, data: {} };
676+
},
677+
});
678+
679+
expect(response.ok).toBe(true);
680+
expect(invoked.map((req) => req.command)).toEqual(['click']);
681+
expect(mockDispatchCommand).toHaveBeenCalledTimes(3);
682+
});
683+
684+
test('an iOS sparse-snapshot verdict that never recovers still fails closed as identity-unverifiable once the bounded retry is exhausted', async () => {
685+
const root = fs.mkdtempSync(
686+
path.join(os.tmpdir(), 'agent-device-replay-target-verify-launch-race-sparse-exhausted-'),
687+
);
688+
const { sessionStore, sessionName } = setupSession(root);
689+
const filePath = writeReplayFile(root, [SAVE_ANNOTATION, 'click id="save"']);
690+
691+
mockDispatchCommand.mockImplementation(async () => iosSparseCapture());
692+
693+
const invoked: DaemonRequest[] = [];
694+
const response = await runReplayScriptFile({
695+
req: baseReq({ positionals: [filePath] }),
696+
sessionName,
697+
logPath: path.join(root, 'daemon.log'),
698+
sessionStore,
699+
invoke: async (req) => {
700+
invoked.push(req);
701+
return { ok: true, data: {} };
702+
},
703+
});
704+
705+
expect(invoked.length).toBe(0);
706+
expect(response.ok).toBe(false);
707+
if (response.ok) return;
708+
expect(response.error.code).toBe('REPLAY_DIVERGENCE');
709+
const divergence = response.error.details?.divergence as Record<string, unknown>;
710+
expect(divergence.kind).toBe('identity-unverifiable');
711+
const cause = divergence.cause as { code: string; message: string };
712+
expect(cause.code).toBe('IDENTITY_UNVERIFIABLE');
713+
expect(cause.message).toContain('sparse-snapshot');
714+
// Bounded: the retry gives up after its fixed delay list, not forever.
715+
expect(mockDispatchCommand.mock.calls.length).toBeGreaterThan(1);
716+
});
717+
619718
test('a permanent (non-content-quality) capture failure fails fast without retrying', async () => {
620719
const root = fs.mkdtempSync(
621720
path.join(os.tmpdir(), 'agent-device-replay-target-verify-launch-race-permanent-'),

0 commit comments

Comments
 (0)