|
| 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