Skip to content

Commit 6151848

Browse files
committed
fix: preserve scoped Android snapshot refs
1 parent 7c5b767 commit 6151848

13 files changed

Lines changed: 274 additions & 17 deletions

src/daemon/handlers/__tests__/snapshot-handler.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,145 @@ test('snapshot surfaces filtered-to-zero Android guidance for interactive snapsh
153153
}
154154
});
155155

156+
test('snapshot resolves @ref scope with the stored source after scoped output replaces refs', async () => {
157+
const sessionStore = makeSessionStore();
158+
const sessionName = 'android-ref-scope-repeat';
159+
const session = makeSession(sessionName, androidDevice);
160+
session.snapshot = {
161+
nodes: [
162+
{
163+
ref: 'e1',
164+
index: 0,
165+
depth: 0,
166+
type: 'android.widget.FrameLayout',
167+
rect: { x: 0, y: 0, width: 390, height: 844 },
168+
},
169+
{
170+
ref: 'e2',
171+
index: 1,
172+
depth: 1,
173+
parentIndex: 0,
174+
type: 'androidx.recyclerview.widget.RecyclerView',
175+
rect: { x: 0, y: 80, width: 390, height: 600 },
176+
},
177+
{
178+
ref: 'e3',
179+
index: 2,
180+
depth: 2,
181+
parentIndex: 1,
182+
type: 'android.widget.TextView',
183+
label: 'Unable to load script. Make sure you are running Metro.',
184+
value: 'Unable to load script. Make sure you are running Metro.',
185+
rect: { x: 16, y: 120, width: 358, height: 200 },
186+
},
187+
{
188+
ref: 'e4',
189+
index: 3,
190+
depth: 3,
191+
parentIndex: 2,
192+
type: 'android.widget.TextView',
193+
label: 'loadJSBundleFromAssets',
194+
rect: { x: 16, y: 140, width: 358, height: 40 },
195+
},
196+
],
197+
createdAt: Date.now(),
198+
backend: 'android',
199+
};
200+
sessionStore.set(sessionName, session);
201+
202+
mockDispatch.mockResolvedValue({
203+
nodes: [
204+
{
205+
index: 0,
206+
depth: 0,
207+
type: 'android.widget.TextView',
208+
label: 'Unable to load script. Make sure you are running Metro.',
209+
value: 'Unable to load script. Make sure you are running Metro.',
210+
rect: { x: 16, y: 120, width: 358, height: 200 },
211+
},
212+
{
213+
index: 1,
214+
depth: 1,
215+
parentIndex: 0,
216+
type: 'android.widget.TextView',
217+
label: 'loadJSBundleFromAssets',
218+
rect: { x: 16, y: 140, width: 358, height: 40 },
219+
},
220+
],
221+
truncated: false,
222+
backend: 'android',
223+
});
224+
225+
for (let attempt = 0; attempt < 2; attempt += 1) {
226+
const response = await handleSnapshotCommands({
227+
req: {
228+
token: 't',
229+
session: sessionName,
230+
command: 'snapshot',
231+
positionals: [],
232+
flags: { snapshotScope: '@e3' },
233+
},
234+
sessionName,
235+
logPath: '/tmp/daemon.log',
236+
sessionStore,
237+
});
238+
239+
expect(response?.ok).toBe(true);
240+
if (response?.ok) {
241+
expect(response.data?.nodes).toHaveLength(2);
242+
}
243+
}
244+
245+
expect(mockDispatch).toHaveBeenCalledTimes(2);
246+
for (const call of mockDispatch.mock.calls) {
247+
expect((call[4] as { snapshotScope?: string })?.snapshotScope).toBe(
248+
'Unable to load script. Make sure you are running Metro.',
249+
);
250+
}
251+
const updated = sessionStore.get(sessionName);
252+
expect(updated?.snapshot?.nodes).toHaveLength(2);
253+
expect(updated?.snapshotScopeSource?.nodes[2]?.ref).toBe('e3');
254+
});
255+
256+
test('empty @ref-scoped snapshot output does not replace the stored session snapshot', async () => {
257+
const sessionStore = makeSessionStore();
258+
const sessionName = 'android-empty-scope-preserve';
259+
const session = makeSession(sessionName, androidDevice);
260+
session.snapshot = {
261+
nodes: [
262+
{ ref: 'e1', index: 0, depth: 0, type: 'android.widget.TextView', label: 'Current screen' },
263+
],
264+
createdAt: Date.now(),
265+
backend: 'android',
266+
};
267+
sessionStore.set(sessionName, session);
268+
269+
mockDispatch.mockResolvedValue({
270+
nodes: [],
271+
truncated: false,
272+
backend: 'android',
273+
});
274+
275+
const response = await handleSnapshotCommands({
276+
req: {
277+
token: 't',
278+
session: sessionName,
279+
command: 'snapshot',
280+
positionals: [],
281+
flags: { snapshotScope: '@e1' },
282+
},
283+
sessionName,
284+
logPath: '/tmp/daemon.log',
285+
sessionStore,
286+
});
287+
288+
expect(response?.ok).toBe(true);
289+
if (response?.ok) {
290+
expect(response.data?.nodes).toEqual([]);
291+
}
292+
expect(sessionStore.get(sessionName)?.snapshot?.nodes[0]?.label).toBe('Current screen');
293+
});
294+
156295
test('snapshot warns when recent snapshot node count collapses sharply', async () => {
157296
const sessionStore = makeSessionStore();
158297
const sessionName = 'android-stale-collapse';

src/daemon/handlers/find.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ensureDeviceReady } from '../device-ready.ts';
99
import { extractNodeText, findNearestHittableAncestor } from '../snapshot-processing.ts';
1010
import { readTextForNode } from './interaction-read.ts';
1111
import { captureSnapshot } from './snapshot-capture.ts';
12+
import { setSessionSnapshot } from '../session-snapshot.ts';
1213
import { errorResponse } from './response.ts';
1314
import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts';
1415
import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts';
@@ -109,7 +110,7 @@ export async function handleFindCommands(params: {
109110
lastSnapshotAt = now;
110111
lastNodes = nodes;
111112
if (session) {
112-
session.snapshot = snapshot;
113+
setSessionSnapshot(session, snapshot);
113114
sessionStore.set(sessionName, session);
114115
}
115116
return { nodes, truncated: snapshot.truncated, backend: snapshot.backend };

src/daemon/handlers/interaction-runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createAgentDevice, localCommandPolicy } from '../../runtime.ts';
88
import { AppError } from '../../utils/errors.ts';
99
import type { SessionState } from '../types.ts';
1010
import { createUnsupportedArtifactAdapter } from '../runtime-artifacts.ts';
11+
import { setSessionSnapshot } from '../session-snapshot.ts';
1112
import type { InteractionHandlerParams } from './interaction-common.ts';
1213
import type { CaptureSnapshotForSession } from './interaction-snapshot.ts';
1314

@@ -34,7 +35,7 @@ export function createInteractionRuntime(
3435
: undefined,
3536
set: (record) => {
3637
if (!record.snapshot) return;
37-
session.snapshot = record.snapshot;
38+
setSessionSnapshot(session, record.snapshot);
3839
params.sessionStore.set(params.sessionName, session);
3940
},
4041
},

src/daemon/handlers/interaction-snapshot.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { SessionState } from '../types.ts';
44
import type { SnapshotState } from '../../utils/snapshot.ts';
55
import type { ContextFromFlags } from './interaction-common.ts';
66
import { captureSnapshot } from './snapshot-capture.ts';
7+
import { setSessionSnapshot } from '../session-snapshot.ts';
78

89
export type CaptureSnapshotForSession = (
910
session: SessionState,
@@ -38,7 +39,7 @@ export async function captureSnapshotForSession(
3839
logPath: dispatchContext.logPath ?? '',
3940
androidFreshnessMode: options.androidFreshnessMode,
4041
});
41-
session.snapshot = snapshot;
42+
setSessionSnapshot(session, snapshot);
4243
sessionStore.set(session.name, session);
43-
return session.snapshot;
44+
return snapshot;
4445
}

src/daemon/handlers/session-replay-heal.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { dispatchCommand } from '../../core/dispatch.ts';
2+
import { setSessionSnapshot } from '../session-snapshot.ts';
23
import {
34
attachRefs,
45
type RawSnapshotNode,
@@ -198,7 +199,7 @@ async function captureSnapshotForReplay(
198199
createdAt: Date.now(),
199200
backend: data?.backend,
200201
};
201-
session.snapshot = snapshot;
202+
setSessionSnapshot(session, snapshot);
202203
sessionStore.set(session.name, session);
203204
return snapshot;
204205
}

src/daemon/handlers/snapshot-capture.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{
6060
const data = await captureSnapshotData(params);
6161
clearAndroidSnapshotFreshness(params.session);
6262
return {
63-
snapshot: buildSnapshotState(data, params.flags),
63+
snapshot: buildSnapshotState(data, resolveSnapshotStateFlags(params)),
6464
analysis: data.analysis,
6565
};
6666
}
@@ -146,7 +146,19 @@ async function captureSnapshotAttempt(
146146
const data = await captureSnapshotData(params);
147147
return {
148148
data,
149-
snapshot: buildSnapshotState(data, params.flags),
149+
snapshot: buildSnapshotState(data, resolveSnapshotStateFlags(params)),
150+
};
151+
}
152+
153+
function resolveSnapshotStateFlags(
154+
params: Pick<CaptureSnapshotParams, 'flags' | 'snapshotScope'>,
155+
): CaptureSnapshotParams['flags'] {
156+
if (params.snapshotScope === undefined) {
157+
return params.flags;
158+
}
159+
return {
160+
...params.flags,
161+
snapshotScope: params.snapshotScope,
150162
};
151163
}
152164

@@ -350,8 +362,16 @@ export function resolveSnapshotScope(
350362
if (!ref) {
351363
return errorResponse('INVALID_ARGS', `Invalid ref scope: ${snapshotScope}`);
352364
}
353-
const node = findNodeByRef(session.snapshot.nodes, ref);
354-
const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
365+
const candidates = [
366+
session.snapshot,
367+
...(session.snapshotScopeSource ? [session.snapshotScopeSource] : []),
368+
];
369+
let resolved: string | undefined;
370+
for (const snapshot of candidates) {
371+
const node = findNodeByRef(snapshot.nodes, ref);
372+
resolved = node ? resolveRefLabel(node, snapshot.nodes) : undefined;
373+
if (resolved) break;
374+
}
355375
if (!resolved) {
356376
return errorResponse('COMMAND_FAILED', `Ref ${snapshotScope} not found or has no label`);
357377
}

src/daemon/request-router.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { handleInteractionCommands } from './handlers/interaction.ts';
2323
import { handleLeaseCommands } from './handlers/lease.ts';
2424
import { buildSnapshotState, captureSnapshotData } from './handlers/snapshot-capture.ts';
2525
import { assertSessionSelectorMatches } from './session-selector.ts';
26+
import { setSessionSnapshot } from './session-snapshot.ts';
2627
import { applyRequestLockPolicy } from './request-lock-policy.ts';
2728
import { resolveEffectiveSessionName } from './session-routing.ts';
2829
import { normalizeTenantId, resolveSessionIsolationMode } from './config.ts';
@@ -473,7 +474,7 @@ async function applyScreenshotOverlay(
473474
snapshotScope: undefined,
474475
});
475476
const overlaySnapshot = buildSnapshotState(overlaySnapshotData, undefined);
476-
session.snapshot = overlaySnapshot;
477+
setSessionSnapshot(session, overlaySnapshot);
477478
const overlayRefs = await annotateScreenshotWithRefs({
478479
screenshotPath: data.path as string,
479480
snapshot: overlaySnapshot,

src/daemon/selector-runtime.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type { IsCommandOptions } from '../commands/selector-read.ts';
3131
import { isSupportedPredicate } from './is-predicates.ts';
3232
import type { ContextFromFlags } from './handlers/interaction-common.ts';
3333
import { createUnsupportedArtifactAdapter } from './runtime-artifacts.ts';
34+
import { setSessionSnapshot } from './session-snapshot.ts';
3435
import { getActiveAndroidSnapshotFreshness } from './android-snapshot-freshness.ts';
3536
import {
3637
describeAndroidEscapeSurface,
@@ -232,7 +233,7 @@ function createSelectorRuntimeForDevice(params: {
232233
get: (name) => (name === params.sessionName ? toCommandSession(params.session) : undefined),
233234
set: (record) => {
234235
if (!params.session || !record.snapshot) return;
235-
params.session.snapshot = record.snapshot;
236+
setSessionSnapshot(params.session, record.snapshot);
236237
params.sessionStore.set(params.sessionName, params.session);
237238
},
238239
},
@@ -312,7 +313,7 @@ function createSelectorBackend(params: {
312313
snapshotScope,
313314
});
314315
if (session) {
315-
session.snapshot = capture.snapshot;
316+
setSessionSnapshot(session, capture.snapshot);
316317
sessionStore.set(sessionName, session);
317318
}
318319
lastSnapshotAt = timestamp;
@@ -405,7 +406,7 @@ async function captureWaitSnapshot(params: {
405406
logPath: params.logPath ?? '',
406407
});
407408
if (params.session) {
408-
params.session.snapshot = capture.snapshot;
409+
setSessionSnapshot(params.session, capture.snapshot);
409410
params.sessionStore.set(params.sessionName, params.session);
410411
}
411412
return capture.snapshot;

src/daemon/session-snapshot.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { SnapshotState } from '../utils/snapshot.ts';
2+
import type { SessionState } from './types.ts';
3+
4+
export function setSessionSnapshot(session: SessionState, snapshot: SnapshotState): void {
5+
session.snapshot = snapshot;
6+
session.snapshotScopeSource = undefined;
7+
}

src/daemon/snapshot-runtime.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,24 @@ function createSnapshotRuntime(params: {
138138
throw new AppError('UNKNOWN', 'snapshot runtime did not produce session state');
139139
}
140140
const current = sessionStore.get(sessionName);
141+
const refScopedSnapshot = req.flags?.snapshotScope?.trim().startsWith('@') === true;
142+
const keepCurrentSnapshot =
143+
refScopedSnapshot && record.snapshot.nodes.length === 0 && current?.snapshot;
144+
const snapshot = keepCurrentSnapshot ? current.snapshot : record.snapshot;
141145
const nextSession = buildSnapshotSession({
142146
session: current,
143147
sessionName,
144148
device,
145-
snapshot: record.snapshot,
149+
snapshot,
146150
appBundleId: record.appBundleId,
147151
});
152+
if (!refScopedSnapshot) {
153+
nextSession.snapshotScopeSource = undefined;
154+
} else if (keepCurrentSnapshot) {
155+
nextSession.snapshotScopeSource = current?.snapshotScopeSource;
156+
} else {
157+
nextSession.snapshotScopeSource = current?.snapshotScopeSource ?? current?.snapshot;
158+
}
148159
if (record.appName) nextSession.appName = record.appName;
149160
sessionStore.set(sessionName, nextSession);
150161
},

0 commit comments

Comments
 (0)