Skip to content

Commit 2215a22

Browse files
authored
test(daemon): extract shared fixtures from the snapshot handler suite (#2254)
The snapshot handler suite repeated its setup inline: the device literals, the token-fixed daemon request, the bind-counting runtime, the Android capture reply, the flat text rows, the freshness-window session with its baseline signatures, and the location-required and Battery surfaces. Those move to the sibling fixtures module (renamed from snapshot-handler-fixture.ts to the *.fixtures.ts convention) as named exports; every scenario keeps its title, its inputs, and its own assertions. jscpd (--min-tokens 80 --min-lines 8): 536 -> 58 duplicated lines in snapshot-handler.test.ts (the two remaining clones are assertion blocks). Test count 41 -> 41, expect( calls 162 -> 162.
1 parent bbf0b21 commit 2215a22

3 files changed

Lines changed: 340 additions & 641 deletions

File tree

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

Lines changed: 0 additions & 32 deletions
This file was deleted.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import path from 'node:path';
2+
import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts';
3+
import { buildNodes } from '../../../__tests__/test-utils/snapshot-builders.ts';
4+
import type { ProviderDeviceRuntime } from '@agent-device/contracts/device';
5+
import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot';
6+
import { buildSnapshotSignatures } from '../../../snapshot/snapshot-freshness/index.ts';
7+
import type {
8+
BindDeviceRuntime,
9+
InspectDeviceRuntimeFacts,
10+
} from '../../request-runtime-binding.ts';
11+
import { snapshotRuntimeFixture } from '../../__tests__/snapshot-runtime-fixture.ts';
12+
import { SessionStore } from '../../session-store.ts';
13+
import type { DaemonRequest, SessionState } from '../../types.ts';
14+
15+
export function makeSessionStore(): SessionStore {
16+
const root = mkdtempForTestSync('agent-device-snapshot-handler-');
17+
return new SessionStore(path.join(root, 'sessions'));
18+
}
19+
20+
export function makeSession(
21+
name: string,
22+
device: SessionState['device'],
23+
extra?: Partial<SessionState>,
24+
): SessionState {
25+
return { name, device, createdAt: Date.now(), actions: [], ...extra };
26+
}
27+
28+
export function makeProviderRuntimeOwning(
29+
device: SessionState['device'],
30+
provider = 'browserstack',
31+
): ProviderDeviceRuntime {
32+
return {
33+
provider,
34+
leaseLifecycle: {},
35+
deviceInventoryProvider: async () => [device],
36+
ownsDevice: (candidate) => candidate.id === device.id,
37+
getInteractor: () => undefined,
38+
shutdown: async () => undefined,
39+
};
40+
}
41+
42+
export const iosSimulatorDevice: SessionState['device'] = {
43+
platform: 'apple',
44+
id: 'sim-1',
45+
name: 'My iPhone Simulator',
46+
kind: 'simulator',
47+
booted: true,
48+
};
49+
50+
export const macOsDevice: SessionState['device'] = {
51+
platform: 'apple',
52+
appleOs: 'macos',
53+
id: 'host-macos-local',
54+
name: 'Host Mac',
55+
kind: 'device',
56+
target: 'desktop',
57+
booted: true,
58+
};
59+
60+
export const androidDevice: SessionState['device'] = {
61+
platform: 'android',
62+
id: 'emulator-5554',
63+
name: 'Pixel 9 Pro XL',
64+
kind: 'emulator',
65+
target: 'mobile',
66+
booted: true,
67+
};
68+
69+
export const providerIosDevice: SessionState['device'] = {
70+
platform: 'apple',
71+
id: 'browserstack:ios:lease-a',
72+
name: 'iPhone 16',
73+
kind: 'device',
74+
target: 'mobile',
75+
booted: true,
76+
};
77+
78+
/** A snapshot-route daemon request; the token is fixed for the whole suite. */
79+
export function snapshotRequest(
80+
sessionName: string,
81+
command: DaemonRequest['command'],
82+
options: Partial<Pick<DaemonRequest, 'positionals' | 'flags' | 'internal'>> = {},
83+
): DaemonRequest {
84+
return { token: 't', session: sessionName, command, positionals: [], flags: {}, ...options };
85+
}
86+
87+
/**
88+
* The suite's snapshot runtime with a bind counter, so a test can assert whether the handler
89+
* bound the device at all (ADR 0019 §9: at most one bind per request).
90+
*/
91+
export function countingSnapshotRuntime(): Readonly<{
92+
inspectFacts: InspectDeviceRuntimeFacts;
93+
bindDevice: BindDeviceRuntime;
94+
bindCount: () => number;
95+
}> {
96+
const runtime = snapshotRuntimeFixture();
97+
let bindCount = 0;
98+
const bindDevice: BindDeviceRuntime = async (device, use) => {
99+
bindCount += 1;
100+
return await runtime.bindDevice(device, use);
101+
};
102+
return { inspectFacts: runtime.inspectFacts, bindDevice, bindCount: () => bindCount };
103+
}
104+
105+
export const inboxRow = (row: number): string => `Inbox row ${row}`;
106+
107+
/** `count` flat Android text rows as a capture returns them; `buildNodes` makes them a stored tree. */
108+
export function androidTextRows(count: number, label: (row: number) => string): RawSnapshotNode[] {
109+
return Array.from({ length: count }, (_, index) => ({
110+
index,
111+
depth: 0,
112+
type: 'android.widget.TextView',
113+
label: label(index + 1),
114+
}));
115+
}
116+
117+
export type AndroidCaptureAnalysis = { rawNodeCount: number; maxDepth: number };
118+
119+
/** What the Android capture double returns: an untruncated tree with optional backend analysis. */
120+
export function androidCapture(
121+
nodes: readonly RawSnapshotNode[],
122+
analysis?: AndroidCaptureAnalysis,
123+
): Record<string, unknown> {
124+
return { nodes, truncated: false, backend: 'android', ...(analysis ? { analysis } : {}) };
125+
}
126+
127+
/**
128+
* An Android session whose stored tree is `baselineNodes`, still inside the freshness window a
129+
* recent `action` opened: the next capture is compared against that baseline before it is
130+
* trusted.
131+
*/
132+
export function makeAndroidFreshnessSession(
133+
name: string,
134+
action: 'press' | 'click',
135+
baselineNodes: SnapshotNode[],
136+
): SessionState {
137+
const session = makeSession(name, androidDevice);
138+
session.snapshot = {
139+
nodes: baselineNodes,
140+
createdAt: Date.now(),
141+
backend: 'android',
142+
comparisonSafe: true,
143+
};
144+
session.androidSnapshotFreshness = {
145+
action,
146+
markedAt: Date.now(),
147+
baselineCount: baselineNodes.length,
148+
baselineSignatures: buildSnapshotSignatures(baselineNodes),
149+
routeComparable: true,
150+
};
151+
return session;
152+
}
153+
154+
/** An inbox list, stored with refs, as the baseline a freshness window compares against. */
155+
export function inboxBaselineNodes(count: number): SnapshotNode[] {
156+
return buildNodes(androidTextRows(count, inboxRow));
157+
}
158+
159+
export const locationRequiredNodes: RawSnapshotNode[] = [
160+
{
161+
index: 0,
162+
depth: 0,
163+
type: 'android.widget.TextView',
164+
label: 'Location required',
165+
rect: { x: 24, y: 180, width: 342, height: 40 },
166+
},
167+
{
168+
index: 1,
169+
depth: 0,
170+
type: 'android.widget.Button',
171+
label: 'Dismiss',
172+
rect: { x: 24, y: 260, width: 342, height: 48 },
173+
},
174+
];
175+
176+
/** The location-required surface as one capture. */
177+
export function locationRequiredCapture(): Record<string, unknown> {
178+
return androidCapture(locationRequiredNodes, { rawNodeCount: 2, maxDepth: 0 });
179+
}
180+
181+
/** A surface that shows only a Battery row, the #1270 wait target. */
182+
export function batteryCapture(): Record<string, unknown> {
183+
return androidCapture(
184+
[
185+
{
186+
index: 0,
187+
depth: 0,
188+
type: 'android.widget.TextView',
189+
label: 'Battery',
190+
rect: { x: 252, y: 780, width: 153, height: 65 },
191+
},
192+
],
193+
{ rawNodeCount: 1, maxDepth: 0 },
194+
);
195+
}

0 commit comments

Comments
 (0)