Skip to content

Commit fb85e40

Browse files
committed
fix: compact unchanged snapshot output
1 parent 7e14dec commit fb85e40

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
@@ -268,6 +268,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
268268
snapshotDepth: options.depth,
269269
snapshotScope: options.scope,
270270
snapshotRaw: options.raw,
271+
snapshotForceFull: options.forceFull,
271272
screenshotFullscreen: options.screenshotFullscreen,
272273
screenshotMaxSize: options.screenshotMaxSize,
273274
overlayRefs: options.overlayRefs,

src/client-shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,5 +188,6 @@ export function serializeSnapshotResult(result: CaptureSnapshotResult): Record<s
188188
...(result.visibility ? { visibility: result.visibility } : {}),
189189
...(result.androidSnapshot ? { androidSnapshot: result.androidSnapshot } : {}),
190190
...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
191+
...(result.unchanged ? { unchanged: result.unchanged } : {}),
191192
};
192193
}

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,
@@ -313,6 +318,7 @@ export type CaptureSnapshotOptions = AgentDeviceRequestOverrides &
313318
depth?: number;
314319
scope?: string;
315320
raw?: boolean;
321+
forceFull?: boolean;
316322
};
317323

318324
export type CaptureSnapshotResult = {
@@ -323,6 +329,7 @@ export type CaptureSnapshotResult = {
323329
visibility?: SnapshotVisibility;
324330
androidSnapshot?: AndroidSnapshotBackendMetadata;
325331
warnings?: string[];
332+
unchanged?: SnapshotUnchanged;
326333
identifiers: AgentDeviceIdentifiers;
327334
};
328335

@@ -751,6 +758,7 @@ type CommandExecutionOptions = {
751758
depth?: number;
752759
scope?: string;
753760
raw?: boolean;
761+
forceFull?: boolean;
754762
screenshotFullscreen?: boolean;
755763
screenshotMaxSize?: number;
756764
count?: number;

src/client.ts

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -301,31 +301,7 @@ export function createAgentDeviceClient(
301301
snapshot: async (options: CaptureSnapshotOptions = {}) => {
302302
const session = resolveRequestSession(options);
303303
const data = await execute(PUBLIC_COMMANDS.snapshot, [], options);
304-
const appBundleId = readOptionalString(data, 'appBundleId');
305-
const visibility =
306-
typeof data.visibility === 'object' && data.visibility !== null
307-
? (data.visibility as CaptureSnapshotResult['visibility'])
308-
: undefined;
309-
const androidSnapshot =
310-
typeof data.androidSnapshot === 'object' && data.androidSnapshot !== null
311-
? (data.androidSnapshot as CaptureSnapshotResult['androidSnapshot'])
312-
: undefined;
313-
return {
314-
nodes: readSnapshotNodes(data.nodes),
315-
truncated: data.truncated === true,
316-
appName: readOptionalString(data, 'appName'),
317-
appBundleId,
318-
...(visibility ? { visibility } : {}),
319-
...(androidSnapshot ? { androidSnapshot } : {}),
320-
warnings: Array.isArray(data.warnings)
321-
? data.warnings.filter((entry): entry is string => typeof entry === 'string')
322-
: undefined,
323-
identifiers: {
324-
session,
325-
appId: appBundleId,
326-
appBundleId,
327-
},
328-
};
304+
return normalizeSnapshotResult(data, session);
329305
},
330306
screenshot: async (options: CaptureScreenshotOptions = {}) => {
331307
const session = resolveRequestSession(options);
@@ -485,6 +461,52 @@ export function createAgentDeviceClient(
485461
};
486462
}
487463

464+
function normalizeSnapshotResult(
465+
data: Record<string, unknown>,
466+
session: string | undefined,
467+
): CaptureSnapshotResult {
468+
const appBundleId = readOptionalString(data, 'appBundleId');
469+
return {
470+
nodes: readSnapshotNodes(data.nodes),
471+
truncated: data.truncated === true,
472+
appName: readOptionalString(data, 'appName'),
473+
appBundleId,
474+
...optionalSnapshotResponseFields(data),
475+
identifiers: {
476+
session,
477+
appId: appBundleId,
478+
appBundleId,
479+
},
480+
};
481+
}
482+
483+
function optionalSnapshotResponseFields(
484+
data: Record<string, unknown>,
485+
): Partial<
486+
Pick<CaptureSnapshotResult, 'androidSnapshot' | 'unchanged' | 'visibility' | 'warnings'>
487+
> {
488+
const visibility = readObject(data.visibility);
489+
const androidSnapshot = readObject(data.androidSnapshot);
490+
const unchanged = readObject(data.unchanged);
491+
const warnings = Array.isArray(data.warnings)
492+
? data.warnings.filter((entry): entry is string => typeof entry === 'string')
493+
: undefined;
494+
return {
495+
...(visibility ? { visibility: visibility as CaptureSnapshotResult['visibility'] } : {}),
496+
...(androidSnapshot
497+
? { androidSnapshot: androidSnapshot as CaptureSnapshotResult['androidSnapshot'] }
498+
: {}),
499+
...(unchanged ? { unchanged: unchanged as CaptureSnapshotResult['unchanged'] } : {}),
500+
...(warnings ? { warnings } : {}),
501+
};
502+
}
503+
504+
function readObject(value: unknown): Record<string, unknown> | undefined {
505+
return typeof value === 'object' && value !== null
506+
? (value as Record<string, unknown>)
507+
: undefined;
508+
}
509+
488510
function stringifyPayload(payload: AppPushOptions['payload']): string {
489511
return typeof payload === 'string' ? payload : JSON.stringify(payload);
490512
}
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)