Skip to content

Commit dea6c3b

Browse files
authored
fix: compact unchanged snapshot output (#547)
1 parent 068d4c5 commit dea6c3b

16 files changed

Lines changed: 483 additions & 36 deletions

src/__tests__/client.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,3 +526,16 @@ test('client capture.snapshot preserves visibility metadata from daemon response
526526
reasons: ['offscreen-nodes'],
527527
});
528528
});
529+
530+
test('client capture.snapshot forwards force-full as snapshotForceFull flag', async () => {
531+
const setup = createTransport(async () => ({
532+
ok: true,
533+
data: { nodes: [], truncated: false },
534+
}));
535+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
536+
537+
await client.capture.snapshot({ forceFull: true });
538+
539+
assert.equal(setup.calls[0]?.command, 'snapshot');
540+
assert.equal(setup.calls[0]?.flags?.snapshotForceFull, true);
541+
});

src/cli/commands/snapshot.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,21 @@ export const snapshotCommand: ClientCommandHandler = async ({ flags, client }) =
1111
depth: flags.snapshotDepth,
1212
scope: flags.snapshotScope,
1313
raw: flags.snapshotRaw,
14+
forceFull: flags.snapshotForceFull,
1415
});
1516
const data = serializeSnapshotResult(result);
16-
writeCommandOutput(flags, data, () =>
17-
formatSnapshotText(data, {
17+
// Programmatic SDK callers can see `unchanged`; CLI --json hides it for schema compatibility.
18+
const outputData = flags.json ? withoutUnchanged(data) : data;
19+
writeCommandOutput(flags, outputData, () =>
20+
formatSnapshotText(outputData, {
1821
raw: flags.snapshotRaw,
1922
flatten: flags.snapshotInteractiveOnly,
2023
}),
2124
);
2225
return true;
2326
};
27+
28+
function withoutUnchanged(data: Record<string, unknown>): Record<string, unknown> {
29+
const { unchanged: _unchanged, ...outputData } = data;
30+
return outputData;
31+
}

src/client-normalizers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
265265
snapshotDepth: options.depth,
266266
snapshotScope: options.scope,
267267
snapshotRaw: options.raw,
268+
snapshotForceFull: options.forceFull,
268269
screenshotFullscreen: options.screenshotFullscreen,
269270
screenshotMaxSize: options.screenshotMaxSize,
270271
screenshotNoStabilize: options.screenshotNoStabilize,

src/client-shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,6 @@ export function serializeSnapshotResult(result: CaptureSnapshotResult): Record<s
169169
...(result.visibility ? { visibility: result.visibility } : {}),
170170
...(result.androidSnapshot ? { androidSnapshot: result.androidSnapshot } : {}),
171171
...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
172+
...(result.unchanged ? { unchanged: result.unchanged } : {}),
172173
};
173174
}

src/client-types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import type {
1010
import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts';
1111
import type { FindLocator } from './utils/finders.ts';
1212
import type { AndroidSnapshotBackendMetadata } from './platforms/android/snapshot-types.ts';
13-
import type { ScreenshotOverlayRef, SnapshotNode, SnapshotVisibility } from './utils/snapshot.ts';
13+
import type {
14+
ScreenshotOverlayRef,
15+
SnapshotNode,
16+
SnapshotUnchanged,
17+
SnapshotVisibility,
18+
} from './utils/snapshot.ts';
1419
import type {
1520
MetroPrepareKind,
1621
PrepareMetroRuntimeResult,
@@ -295,6 +300,7 @@ export type CaptureSnapshotOptions = AgentDeviceRequestOverrides &
295300
depth?: number;
296301
scope?: string;
297302
raw?: boolean;
303+
forceFull?: boolean;
298304
};
299305

300306
export type CaptureSnapshotResult = {
@@ -305,6 +311,7 @@ export type CaptureSnapshotResult = {
305311
visibility?: SnapshotVisibility;
306312
androidSnapshot?: AndroidSnapshotBackendMetadata;
307313
warnings?: string[];
314+
unchanged?: SnapshotUnchanged;
308315
identifiers: AgentDeviceIdentifiers;
309316
};
310317

@@ -734,6 +741,7 @@ type CommandExecutionOptions = {
734741
depth?: number;
735742
scope?: string;
736743
raw?: boolean;
744+
forceFull?: boolean;
737745
screenshotFullscreen?: boolean;
738746
screenshotMaxSize?: number;
739747
screenshotNoStabilize?: boolean;

src/client.ts

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -275,31 +275,7 @@ export function createAgentDeviceClient(
275275
snapshot: async (options: CaptureSnapshotOptions = {}) => {
276276
const session = resolveRequestSession(options);
277277
const data = await execute(PUBLIC_COMMANDS.snapshot, [], options);
278-
const appBundleId = readOptionalString(data, 'appBundleId');
279-
const visibility =
280-
typeof data.visibility === 'object' && data.visibility !== null
281-
? (data.visibility as CaptureSnapshotResult['visibility'])
282-
: undefined;
283-
const androidSnapshot =
284-
typeof data.androidSnapshot === 'object' && data.androidSnapshot !== null
285-
? (data.androidSnapshot as CaptureSnapshotResult['androidSnapshot'])
286-
: undefined;
287-
return {
288-
nodes: readSnapshotNodes(data.nodes),
289-
truncated: data.truncated === true,
290-
appName: readOptionalString(data, 'appName'),
291-
appBundleId,
292-
...(visibility ? { visibility } : {}),
293-
...(androidSnapshot ? { androidSnapshot } : {}),
294-
warnings: Array.isArray(data.warnings)
295-
? data.warnings.filter((entry): entry is string => typeof entry === 'string')
296-
: undefined,
297-
identifiers: {
298-
session,
299-
appId: appBundleId,
300-
appBundleId,
301-
},
302-
};
278+
return normalizeSnapshotResult(data, session);
303279
},
304280
screenshot: async (options: CaptureScreenshotOptions = {}) => {
305281
const session = resolveRequestSession(options);
@@ -460,6 +436,52 @@ export function createAgentDeviceClient(
460436
};
461437
}
462438

439+
function normalizeSnapshotResult(
440+
data: Record<string, unknown>,
441+
session: string | undefined,
442+
): CaptureSnapshotResult {
443+
const appBundleId = readOptionalString(data, 'appBundleId');
444+
return {
445+
nodes: readSnapshotNodes(data.nodes),
446+
truncated: data.truncated === true,
447+
appName: readOptionalString(data, 'appName'),
448+
appBundleId,
449+
...optionalSnapshotResponseFields(data),
450+
identifiers: {
451+
session,
452+
appId: appBundleId,
453+
appBundleId,
454+
},
455+
};
456+
}
457+
458+
function optionalSnapshotResponseFields(
459+
data: Record<string, unknown>,
460+
): Partial<
461+
Pick<CaptureSnapshotResult, 'androidSnapshot' | 'unchanged' | 'visibility' | 'warnings'>
462+
> {
463+
const visibility = readObject(data.visibility);
464+
const androidSnapshot = readObject(data.androidSnapshot);
465+
const unchanged = readObject(data.unchanged);
466+
const warnings = Array.isArray(data.warnings)
467+
? data.warnings.filter((entry): entry is string => typeof entry === 'string')
468+
: undefined;
469+
return {
470+
...(visibility ? { visibility: visibility as CaptureSnapshotResult['visibility'] } : {}),
471+
...(androidSnapshot
472+
? { androidSnapshot: androidSnapshot as CaptureSnapshotResult['androidSnapshot'] }
473+
: {}),
474+
...(unchanged ? { unchanged: unchanged as CaptureSnapshotResult['unchanged'] } : {}),
475+
...(warnings ? { warnings } : {}),
476+
};
477+
}
478+
479+
function readObject(value: unknown): Record<string, unknown> | undefined {
480+
return typeof value === 'object' && value !== null
481+
? (value as Record<string, unknown>)
482+
: undefined;
483+
}
484+
463485
function stringifyPayload(payload: AppPushOptions['payload']): string {
464486
return typeof payload === 'string' ? payload : JSON.stringify(payload);
465487
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { expect, test } from 'vitest';
2+
import {
3+
buildUnchangedSnapshotMetadata,
4+
ensureSnapshotPresentationKey,
5+
} from '../snapshot-unchanged.ts';
6+
import type { SnapshotState } from '../../utils/snapshot.ts';
7+
8+
function snapshot(
9+
label: string,
10+
overrides: Partial<SnapshotState> = {},
11+
options: Parameters<typeof ensureSnapshotPresentationKey>[1] = {},
12+
): SnapshotState {
13+
return ensureSnapshotPresentationKey(
14+
{
15+
nodes: [
16+
{
17+
ref: 'e1',
18+
index: 0,
19+
depth: 0,
20+
type: 'Button',
21+
label,
22+
pid: 1234,
23+
hittable: true,
24+
},
25+
],
26+
createdAt: 1_000,
27+
backend: 'xctest',
28+
...overrides,
29+
},
30+
options,
31+
);
32+
}
33+
34+
test('unchanged metadata ignores refs and volatile process ids', () => {
35+
const previous = snapshot('Create');
36+
const current = snapshot('Create', {
37+
nodes: [{ ...previous.nodes[0]!, ref: 'e99', pid: 5678 }],
38+
});
39+
40+
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toMatchObject({
41+
nodeCount: 1,
42+
});
43+
});
44+
45+
test('unchanged metadata detects visible label changes', () => {
46+
expect(
47+
buildUnchangedSnapshotMetadata({
48+
previous: snapshot('Create'),
49+
current: snapshot('Send'),
50+
options: {},
51+
}),
52+
).toBeUndefined();
53+
});
54+
55+
test('unchanged metadata requires comparison-safe snapshots', () => {
56+
expect(
57+
buildUnchangedSnapshotMetadata({
58+
previous: snapshot('Create', { comparisonSafe: false }),
59+
current: snapshot('Create'),
60+
options: {},
61+
}),
62+
).toBeUndefined();
63+
64+
expect(
65+
buildUnchangedSnapshotMetadata({
66+
previous: snapshot('Create'),
67+
current: snapshot('Create', { comparisonSafe: false }),
68+
options: {},
69+
}),
70+
).toBeUndefined();
71+
});
72+
73+
test('unchanged metadata requires matching presentation key and identity', () => {
74+
const previous = snapshot('Create', { createdAt: 1_000 });
75+
const current = snapshot('Create', { createdAt: 3_500 });
76+
77+
expect(
78+
buildUnchangedSnapshotMetadata({
79+
previous,
80+
current: snapshot('Create', { createdAt: 3_500 }, { scope: 'Composer' }),
81+
options: { scope: 'Composer' },
82+
}),
83+
).toBeUndefined();
84+
85+
expect(
86+
buildUnchangedSnapshotMetadata({
87+
previous,
88+
current,
89+
options: {},
90+
identity: {
91+
previousAppBundleId: 'com.example.before',
92+
currentAppBundleId: 'com.example.after',
93+
},
94+
}),
95+
).toBeUndefined();
96+
97+
expect(
98+
buildUnchangedSnapshotMetadata({
99+
previous: snapshot(
100+
'Create',
101+
{ createdAt: 1_000 },
102+
{ interactiveOnly: true, scope: 'Composer' },
103+
),
104+
current: snapshot(
105+
'Create',
106+
{ createdAt: 3_500 },
107+
{ interactiveOnly: true, scope: 'Composer' },
108+
),
109+
options: { interactiveOnly: true, scope: 'Composer' },
110+
identity: {
111+
previousAppBundleId: 'com.example.app',
112+
currentAppBundleId: 'com.example.app',
113+
},
114+
}),
115+
).toMatchObject({ ageMs: 2_500, nodeCount: 1, interactiveOnly: true, scope: 'Composer' });
116+
});
117+
118+
test('unchanged metadata trims scope in compact output metadata', () => {
119+
expect(
120+
buildUnchangedSnapshotMetadata({
121+
previous: snapshot('Create', { createdAt: 1_000 }, { scope: ' Composer ' }),
122+
current: snapshot('Create', { createdAt: 3_500 }, { scope: ' Composer ' }),
123+
options: { scope: ' Composer ' },
124+
}),
125+
).toMatchObject({ scope: 'Composer' });
126+
});
127+
128+
test('force-full and raw snapshots do not emit unchanged metadata', () => {
129+
const previous = snapshot('Create');
130+
const current = snapshot('Create');
131+
132+
expect(
133+
buildUnchangedSnapshotMetadata({ previous, current, options: { forceFull: true } }),
134+
).toBeUndefined();
135+
expect(
136+
buildUnchangedSnapshotMetadata({ previous, current, options: { raw: true } }),
137+
).toBeUndefined();
138+
});

src/commands/capture-definition.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ const SNAPSHOT_FLAGS = [
1717
const snapshotCommandDefinition = defineCommand({
1818
name: PUBLIC_COMMANDS.snapshot,
1919
schema: {
20-
usageOverride: 'snapshot [--diff] [-i] [-c] [-d <depth>] [-s <scope>] [--raw]',
20+
usageOverride: 'snapshot [--diff] [-i] [-c] [-d <depth>] [-s <scope>] [--raw] [--force-full]',
2121
helpDescription: 'Capture accessibility tree or diff against the previous session baseline',
2222
positionalArgs: [],
23-
allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS],
23+
allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull'],
2424
},
2525
capability: ALL_DEVICE_COMMAND_CAPABILITY,
2626
});

src/commands/capture-snapshot.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,17 @@ import type { AgentDeviceRuntime, CommandSessionRecord } from '../runtime-contra
44
import { AppError } from '../utils/errors.ts';
55
import { buildSnapshotDiff, countSnapshotComparableLines } from '../utils/snapshot-diff.ts';
66
import type { SnapshotDiffLine, SnapshotDiffSummary } from '../utils/snapshot-diff.ts';
7-
import type { SnapshotNode, SnapshotState, SnapshotVisibility } from '../utils/snapshot.ts';
7+
import type {
8+
SnapshotNode,
9+
SnapshotState,
10+
SnapshotUnchanged,
11+
SnapshotVisibility,
12+
} from '../utils/snapshot.ts';
813
import { buildSnapshotVisibility } from '../utils/snapshot-visibility.ts';
14+
import {
15+
buildUnchangedSnapshotMetadata,
16+
ensureSnapshotPresentationKey,
17+
} from './snapshot-unchanged.ts';
918
import type {
1019
DiffSnapshotCommandOptions,
1120
RuntimeCommand,
@@ -23,6 +32,7 @@ export type SnapshotCommandResult = {
2332
visibility?: SnapshotVisibility;
2433
androidSnapshot?: AndroidSnapshotBackendMetadata;
2534
warnings?: string[];
35+
unchanged?: SnapshotUnchanged;
2636
};
2737

2838
export type DiffSnapshotCommandResult = {
@@ -45,6 +55,15 @@ export const snapshotCommand: RuntimeCommand<
4555
SnapshotCommandResult
4656
> = async (runtime, options): Promise<SnapshotCommandResult> => {
4757
const capture = await captureRuntimeSnapshot(runtime, options);
58+
const unchanged = buildUnchangedSnapshotMetadata({
59+
previous: capture.session?.snapshot,
60+
current: capture.snapshot,
61+
options,
62+
identity: {
63+
previousAppBundleId: capture.session?.appBundleId,
64+
currentAppBundleId: capture.result.appBundleId ?? capture.session?.appBundleId,
65+
},
66+
});
4867
await runtime.sessions.set(nextSnapshotSession(options.session, capture));
4968
return {
5069
nodes: capture.snapshot.nodes,
@@ -56,6 +75,7 @@ export const snapshotCommand: RuntimeCommand<
5675
}),
5776
...(capture.result.androidSnapshot ? { androidSnapshot: capture.result.androidSnapshot } : {}),
5877
...(capture.warnings.length > 0 ? { warnings: capture.warnings } : {}),
78+
...(unchanged ? { unchanged } : {}),
5979
...snapshotAppFields(capture),
6080
};
6181
};
@@ -127,7 +147,10 @@ async function captureRuntimeSnapshot(
127147
raw: options.raw,
128148
},
129149
);
130-
const snapshot = normalizeBackendSnapshot(result, runtime);
150+
const snapshot = ensureSnapshotPresentationKey(
151+
normalizeBackendSnapshot(result, runtime),
152+
options,
153+
);
131154
const warningTime = now(runtime);
132155
return {
133156
snapshot,

0 commit comments

Comments
 (0)