diff --git a/AGENTS.md b/AGENTS.md index 34729125e..f17a22112 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,17 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o - On macOS, snapshot rects are absolute in window space. Point-based runner interactions must translate through the interaction root frame; do not assume app-origin `(0,0)` coordinates. - Prefer selector or `@ref` interactions over raw x/y commands in tests and docs, especially on macOS where window position can vary across runs. +## Shared Test Utilities +- Before writing a new test, check `src/__tests__/test-utils/` for existing helpers: + - `device-fixtures.ts`: canonical `DeviceInfo` constants (`ANDROID_EMULATOR`, `IOS_SIMULATOR`, `IOS_DEVICE`, `MACOS_DEVICE`, `LINUX_DEVICE`, etc.) + - `session-factories.ts`: `makeSession`, `makeIosSession`, `makeAndroidSession`, `makeMacOsSession` + - `store-factory.ts`: `makeSessionStore` (creates temp `SessionStore` instances) + - `snapshot-builders.ts`: `buildNodes`, `makeSnapshotState` + - `mocked-binaries.ts`: `withMockedAdb`, `withMockedXcrun` (stub CLI binaries for dispatch tests) +- Use `import { ... } from '/__tests__/test-utils/index.ts'` for convenient barrel imports. +- Prefer shared fixtures over inlining new `DeviceInfo` or `SessionState` objects in tests. +- Do not duplicate `makeSessionStore`, `makeSession`, or device constants when a shared helper already exists. + ## Testing Matrix - Docs/skills only: no tests required. - Non-TS, no behavior impact: no tests unless requested. diff --git a/src/__tests__/metro-protocol-public.test.ts b/src/__tests__/metro-protocol-public.test.ts index 88072ba6b..a57105cc4 100644 --- a/src/__tests__/metro-protocol-public.test.ts +++ b/src/__tests__/metro-protocol-public.test.ts @@ -8,57 +8,44 @@ import { type MetroTunnelResponseMessage, } from '../metro.ts'; -test('public metro exports expose stable bridge payload types and url helpers', () => { - const descriptor: MetroBridgeDescriptor = { - enabled: true, - base_url: 'https://bridge.example.test', - ios_runtime: { - metro_host: '127.0.0.1', - metro_port: 8081, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios', - }, - android_runtime: { - metro_host: '10.0.2.2', - metro_port: 8081, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android', - }, - upstream: { - bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios', - }, - probe: { - reachable: true, - status_code: 200, - latency_ms: 4, - detail: 'ok', - }, - }; +// Type-only contract fixtures — these verify that the public subpath types +// remain structurally stable. A rename or breaking shape change will fail +// the compile, not a runtime assertion. +({ + enabled: true, + base_url: 'https://bridge.example.test', + ios_runtime: { + metro_host: '127.0.0.1', + metro_port: 8081, + metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios', + }, + android_runtime: { + metro_host: '10.0.2.2', + metro_port: 8081, + metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android', + }, + upstream: { bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios' }, + probe: { reachable: true, status_code: 200, latency_ms: 4, detail: 'ok' }, +}) satisfies MetroBridgeDescriptor; - assert.equal(descriptor.upstream.port, undefined); - assert.equal(descriptor.status_url, undefined); +({ + type: 'ws-frame', + streamId: 'stream-1', + dataBase64: 'aGVsbG8=', + binary: false, +}) satisfies MetroTunnelRequestMessage; + +({ + type: 'http-response', + requestId: 'req-1', + status: 200, + headers: { 'content-type': 'application/json' }, +}) satisfies MetroTunnelResponseMessage; + +test('public metro exports expose stable url helpers', () => { assert.equal(normalizeBaseUrl('https://bridge.example.test///'), 'https://bridge.example.test'); assert.equal( buildBundleUrl('https://bridge.example.test/', 'ios'), 'https://bridge.example.test/index.bundle?platform=ios&dev=true&minify=false', ); }); - -test('public metro exports compile for representative tunnel request and response payloads', () => { - const request: MetroTunnelRequestMessage = { - type: 'ws-frame', - streamId: 'stream-1', - dataBase64: 'aGVsbG8=', - binary: false, - }; - const response: MetroTunnelResponseMessage = { - type: 'http-response', - requestId: 'req-1', - status: 200, - headers: { 'content-type': 'application/json' }, - }; - - const messages = [request, response]; - - assert.equal(request.type, 'ws-frame'); - assert.equal(response.type, 'http-response'); - assert.equal(messages.length, 2); -}); diff --git a/src/__tests__/test-utils/device-fixtures.ts b/src/__tests__/test-utils/device-fixtures.ts new file mode 100644 index 000000000..87c0ea7d9 --- /dev/null +++ b/src/__tests__/test-utils/device-fixtures.ts @@ -0,0 +1,58 @@ +import type { DeviceInfo } from '../../utils/device.ts'; + +export const ANDROID_EMULATOR: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +export const IOS_SIMULATOR: DeviceInfo = { + platform: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, +}; + +export const IOS_DEVICE: DeviceInfo = { + platform: 'ios', + id: 'ios-device-1', + name: 'iPhone', + kind: 'device', + booted: true, +}; + +export const MACOS_DEVICE: DeviceInfo = { + platform: 'macos', + id: 'host-macos-local', + name: 'Mac', + kind: 'device', + target: 'desktop', + booted: true, +}; + +export const LINUX_DEVICE: DeviceInfo = { + platform: 'linux', + id: 'local', + name: 'Linux Desktop', + kind: 'device', + target: 'desktop', +}; + +export const ANDROID_TV_DEVICE: DeviceInfo = { + platform: 'android', + id: 'and-tv-1', + name: 'Android TV', + kind: 'device', + target: 'tv', +}; + +export const TVOS_SIMULATOR: DeviceInfo = { + platform: 'ios', + id: 'tv-sim-1', + name: 'Apple TV', + kind: 'simulator', + target: 'tv', +}; diff --git a/src/__tests__/test-utils/index.ts b/src/__tests__/test-utils/index.ts new file mode 100644 index 000000000..bc6fbc000 --- /dev/null +++ b/src/__tests__/test-utils/index.ts @@ -0,0 +1,22 @@ +export { + ANDROID_EMULATOR, + ANDROID_TV_DEVICE, + IOS_DEVICE, + IOS_SIMULATOR, + LINUX_DEVICE, + MACOS_DEVICE, + TVOS_SIMULATOR, +} from './device-fixtures.ts'; + +export { + makeAndroidSession, + makeIosSession, + makeMacOsSession, + makeSession, +} from './session-factories.ts'; + +export { makeSessionStore } from './store-factory.ts'; + +export { buildNodes, makeSnapshotState } from './snapshot-builders.ts'; + +export { withMockedAdb, withMockedXcrun } from './mocked-binaries.ts'; diff --git a/src/__tests__/test-utils/mocked-binaries.ts b/src/__tests__/test-utils/mocked-binaries.ts new file mode 100644 index 000000000..5f2a47804 --- /dev/null +++ b/src/__tests__/test-utils/mocked-binaries.ts @@ -0,0 +1,69 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Creates a temporary stub `adb` binary that logs all args to a file, + * prepends it to PATH, and cleans up after the callback finishes. + */ +export async function withMockedAdb( + tempPrefix: string, + run: (argsLogPath: string) => Promise, +): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix)); + const adbPath = path.join(tmpDir, 'adb'); + const argsLogPath = path.join(tmpDir, 'args.log'); + await fs.writeFile( + adbPath, + '#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', + 'utf8', + ); + await fs.chmod(adbPath, 0o755); + + const previousPath = process.env.PATH; + const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; + + try { + await run(argsLogPath); + } finally { + process.env.PATH = previousPath; + if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; + else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +/** + * Creates a temporary stub `xcrun` binary that logs all args to a file, + * prepends it to PATH, and cleans up after the callback finishes. + */ +export async function withMockedXcrun( + tempPrefix: string, + run: (argsLogPath: string) => Promise, +): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix)); + const xcrunPath = path.join(tmpDir, 'xcrun'); + const argsLogPath = path.join(tmpDir, 'args.log'); + await fs.writeFile( + xcrunPath, + '#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', + 'utf8', + ); + await fs.chmod(xcrunPath, 0o755); + + const previousPath = process.env.PATH; + const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; + + try { + await run(argsLogPath); + } finally { + process.env.PATH = previousPath; + if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; + else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} diff --git a/src/__tests__/test-utils/session-factories.ts b/src/__tests__/test-utils/session-factories.ts new file mode 100644 index 000000000..997c9eca5 --- /dev/null +++ b/src/__tests__/test-utils/session-factories.ts @@ -0,0 +1,36 @@ +import type { SessionState } from '../../daemon/types.ts'; +import { IOS_SIMULATOR, ANDROID_EMULATOR, MACOS_DEVICE } from './device-fixtures.ts'; + +export function makeSession( + name: string, + overrides?: Partial, +): SessionState { + return { + name, + device: IOS_SIMULATOR, + createdAt: Date.now(), + actions: [], + ...overrides, + }; +} + +export function makeIosSession( + name: string, + overrides?: Partial, +): SessionState { + return makeSession(name, { device: IOS_SIMULATOR, ...overrides }); +} + +export function makeAndroidSession( + name: string, + overrides?: Partial, +): SessionState { + return makeSession(name, { device: ANDROID_EMULATOR, ...overrides }); +} + +export function makeMacOsSession( + name: string, + overrides?: Partial, +): SessionState { + return makeSession(name, { device: MACOS_DEVICE, ...overrides }); +} diff --git a/src/__tests__/test-utils/snapshot-builders.ts b/src/__tests__/test-utils/snapshot-builders.ts new file mode 100644 index 000000000..91b20debc --- /dev/null +++ b/src/__tests__/test-utils/snapshot-builders.ts @@ -0,0 +1,16 @@ +import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts'; + +export function buildNodes(raw: RawSnapshotNode[]) { + return attachRefs(raw); +} + +export function makeSnapshotState( + raw: RawSnapshotNode[], + overrides?: Partial, +): SnapshotState { + return { + nodes: attachRefs(raw), + createdAt: Date.now(), + ...overrides, + }; +} diff --git a/src/daemon/handlers/__tests__/session-test-store.ts b/src/__tests__/test-utils/store-factory.ts similarity index 59% rename from src/daemon/handlers/__tests__/session-test-store.ts rename to src/__tests__/test-utils/store-factory.ts index 2ba1bfecd..b470c7cdf 100644 --- a/src/daemon/handlers/__tests__/session-test-store.ts +++ b/src/__tests__/test-utils/store-factory.ts @@ -1,9 +1,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { SessionStore } from '../../session-store.ts'; +import { SessionStore } from '../../daemon/session-store.ts'; -export function makeSessionStore(prefix: string): SessionStore { +export function makeSessionStore(prefix = 'agent-device-test-'): SessionStore { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); return new SessionStore(path.join(tempRoot, 'sessions')); } diff --git a/src/core/__tests__/dispatch-back.test.ts b/src/core/__tests__/dispatch-back.test.ts index fa1573815..3cc10e7df 100644 --- a/src/core/__tests__/dispatch-back.test.ts +++ b/src/core/__tests__/dispatch-back.test.ts @@ -1,52 +1,14 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import { dispatchCommand } from '../dispatch.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; - -const ANDROID_DEVICE: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -}; - -async function withMockedAdb( - tempPrefix: string, - run: (argsLogPath: string) => Promise, -): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix)); - const adbPath = path.join(tmpDir, 'adb'); - const argsLogPath = path.join(tmpDir, 'args.log'); - await fs.writeFile( - adbPath, - '#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', - 'utf8', - ); - await fs.chmod(adbPath, 0o755); - - const previousPath = process.env.PATH; - const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; - process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; - process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; - - try { - await run(argsLogPath); - } finally { - process.env.PATH = previousPath; - if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; - else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; - await fs.rm(tmpDir, { recursive: true, force: true }); - } -} +import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; test('dispatch back defaults to in-app mode and keeps Android back on keyevent 4', async () => { await withMockedAdb('agent-device-dispatch-back-modes-', async (argsLogPath) => { for (const backMode of [undefined, 'in-app', 'system'] as const) { - const result = await dispatchCommand(ANDROID_DEVICE, 'back', [], undefined, { + const result = await dispatchCommand(ANDROID_EMULATOR, 'back', [], undefined, { backMode, }); diff --git a/src/core/__tests__/dispatch-pinch.test.ts b/src/core/__tests__/dispatch-pinch.test.ts index 8c580a856..452c42ae0 100644 --- a/src/core/__tests__/dispatch-pinch.test.ts +++ b/src/core/__tests__/dispatch-pinch.test.ts @@ -2,16 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { dispatchCommand } from '../dispatch.ts'; import { AppError } from '../../utils/errors.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; - -const MACOS_DEVICE: DeviceInfo = { - platform: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, -}; +import { MACOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; test('dispatch pinch rejects helper-backed macOS surfaces', async () => { await assert.rejects( diff --git a/src/core/__tests__/dispatch-press.test.ts b/src/core/__tests__/dispatch-press.test.ts index 65c39f08f..c1dcfc32e 100644 --- a/src/core/__tests__/dispatch-press.test.ts +++ b/src/core/__tests__/dispatch-press.test.ts @@ -1,7 +1,11 @@ import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { dispatchCommand, shouldUseIosDragSeries, shouldUseIosTapSeries } from '../dispatch.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; +import { + IOS_SIMULATOR, + ANDROID_EMULATOR, + MACOS_DEVICE, +} from '../../__tests__/test-utils/device-fixtures.ts'; vi.mock('../../platforms/ios/macos-helper.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -13,59 +17,34 @@ vi.mock('../../platforms/ios/macos-helper.ts', async (importOriginal) => { import { runMacOsPressAction } from '../../platforms/ios/macos-helper.ts'; -const iosDevice: DeviceInfo = { - platform: 'ios', - id: 'ios-1', - name: 'iPhone 15', - kind: 'simulator', - booted: true, -}; - -const androidDevice: DeviceInfo = { - platform: 'android', - id: 'android-1', - name: 'Pixel', - kind: 'emulator', - booted: true, -}; - -const macosDevice: DeviceInfo = { - platform: 'macos', - id: 'macos-1', - name: 'Mac', - kind: 'device', - target: 'desktop', - booted: true, -}; - test('shouldUseIosTapSeries enables fast path for repeated plain iOS taps', () => { - assert.equal(shouldUseIosTapSeries(iosDevice, 5, 0, 0), true); + assert.equal(shouldUseIosTapSeries(IOS_SIMULATOR, 5, 0, 0), true); }); test('shouldUseIosTapSeries disables fast path for single press or modified gestures', () => { - assert.equal(shouldUseIosTapSeries(iosDevice, 1, 0, 0), false); - assert.equal(shouldUseIosTapSeries(iosDevice, 5, 100, 0), false); - assert.equal(shouldUseIosTapSeries(iosDevice, 5, 0, 1), false); + assert.equal(shouldUseIosTapSeries(IOS_SIMULATOR, 1, 0, 0), false); + assert.equal(shouldUseIosTapSeries(IOS_SIMULATOR, 5, 100, 0), false); + assert.equal(shouldUseIosTapSeries(IOS_SIMULATOR, 5, 0, 1), false); }); test('shouldUseIosTapSeries disables fast path for non-iOS devices', () => { - assert.equal(shouldUseIosTapSeries(androidDevice, 5, 0, 0), false); + assert.equal(shouldUseIosTapSeries(ANDROID_EMULATOR, 5, 0, 0), false); }); test('shouldUseIosDragSeries enables fast path for repeated iOS swipes', () => { - assert.equal(shouldUseIosDragSeries(iosDevice, 3), true); + assert.equal(shouldUseIosDragSeries(IOS_SIMULATOR, 3), true); }); test('shouldUseIosDragSeries disables fast path for single swipe and non-iOS', () => { - assert.equal(shouldUseIosDragSeries(iosDevice, 1), false); - assert.equal(shouldUseIosDragSeries(androidDevice, 3), false); + assert.equal(shouldUseIosDragSeries(IOS_SIMULATOR, 1), false); + assert.equal(shouldUseIosDragSeries(ANDROID_EMULATOR, 3), false); }); test('dispatchCommand routes macOS menubar press through the helper', async () => { const mockRunMacOsPressAction = vi.mocked(runMacOsPressAction); mockRunMacOsPressAction.mockClear(); - const result = await dispatchCommand(macosDevice, 'press', ['100', '200'], undefined, { + const result = await dispatchCommand(MACOS_DEVICE, 'press', ['100', '200'], undefined, { surface: 'menubar', appBundleId: 'com.example.menubarapp', }); diff --git a/src/core/__tests__/dispatch-push.test.ts b/src/core/__tests__/dispatch-push.test.ts index 094412a19..448a0f6b8 100644 --- a/src/core/__tests__/dispatch-push.test.ts +++ b/src/core/__tests__/dispatch-push.test.ts @@ -5,19 +5,11 @@ import os from 'node:os'; import path from 'node:path'; import { dispatchCommand } from '../dispatch.ts'; import { AppError } from '../../utils/errors.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; - -const ANDROID_DEVICE: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -}; +import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; test('dispatch push reports missing payload file as INVALID_ARGS', async () => { await assert.rejects( - () => dispatchCommand(ANDROID_DEVICE, 'push', ['com.example.app', './missing-payload.json']), + () => dispatchCommand(ANDROID_EMULATOR, 'push', ['com.example.app', './missing-payload.json']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -31,7 +23,7 @@ test('dispatch push reports directory payload path as INVALID_ARGS', async () => const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-dispatch-push-dir-')); try { await assert.rejects( - () => dispatchCommand(ANDROID_DEVICE, 'push', ['com.example.app', tempDir]), + () => dispatchCommand(ANDROID_EMULATOR, 'push', ['com.example.app', tempDir]), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -67,7 +59,7 @@ test('dispatch push prefers existing brace-prefixed payload file over inline par process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; try { - const result = await dispatchCommand(ANDROID_DEVICE, 'push', ['com.example.app', payloadPath]); + const result = await dispatchCommand(ANDROID_EMULATOR, 'push', ['com.example.app', payloadPath]); assert.deepEqual(result, { platform: 'android', package: 'com.example.app', diff --git a/src/core/__tests__/dispatch-rotate.test.ts b/src/core/__tests__/dispatch-rotate.test.ts index 3b96fcb03..bd78b7469 100644 --- a/src/core/__tests__/dispatch-rotate.test.ts +++ b/src/core/__tests__/dispatch-rotate.test.ts @@ -1,8 +1,6 @@ import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -11,55 +9,11 @@ vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => { import { dispatchCommand } from '../dispatch.ts'; import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; +import { ANDROID_EMULATOR, IOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; +import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand); -const ANDROID_DEVICE: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -}; - -const IOS_DEVICE: DeviceInfo = { - platform: 'ios', - id: 'ios-device-1', - name: 'iPhone', - kind: 'device', - booted: true, -}; - -async function withMockedAdb( - tempPrefix: string, - run: (argsLogPath: string) => Promise, -): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix)); - const adbPath = path.join(tmpDir, 'adb'); - const argsLogPath = path.join(tmpDir, 'args.log'); - await fs.writeFile( - adbPath, - '#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', - 'utf8', - ); - await fs.chmod(adbPath, 0o755); - - const previousPath = process.env.PATH; - const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; - process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; - process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; - - try { - await run(argsLogPath); - } finally { - process.env.PATH = previousPath; - if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; - else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; - await fs.rm(tmpDir, { recursive: true, force: true }); - } -} - beforeEach(() => { vi.resetAllMocks(); mockRunIosRunnerCommand.mockResolvedValue({ message: 'rotate', orientation: 'landscape-left' }); @@ -67,7 +21,7 @@ beforeEach(() => { test('dispatch rotate normalizes aliases before Android execution', async () => { await withMockedAdb('agent-device-dispatch-rotate-android-', async (argsLogPath) => { - const result = await dispatchCommand(ANDROID_DEVICE, 'rotate', ['left']); + const result = await dispatchCommand(ANDROID_EMULATOR, 'rotate', ['left']); assert.equal(result?.action, 'rotate'); assert.equal(result?.orientation, 'landscape-left'); diff --git a/src/core/__tests__/dispatch-screenshot.test.ts b/src/core/__tests__/dispatch-screenshot.test.ts index 259370392..93586cc6d 100644 --- a/src/core/__tests__/dispatch-screenshot.test.ts +++ b/src/core/__tests__/dispatch-screenshot.test.ts @@ -1,7 +1,7 @@ import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { dispatchCommand } from '../dispatch.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; +import { MACOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; vi.mock('../../platforms/ios/macos-helper.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -13,15 +13,6 @@ vi.mock('../../platforms/ios/macos-helper.ts', async (importOriginal) => { import { runMacOsScreenshotAction } from '../../platforms/ios/macos-helper.ts'; -const MACOS_DEVICE: DeviceInfo = { - platform: 'macos', - id: 'host-macos-local', - name: 'Mac', - kind: 'device', - target: 'desktop', - booted: true, -}; - test('dispatchCommand routes macOS menubar screenshots through the helper', async () => { const mockRunMacOsScreenshotAction = vi.mocked(runMacOsScreenshotAction); mockRunMacOsScreenshotAction.mockClear(); diff --git a/src/core/__tests__/dispatch-scroll.test.ts b/src/core/__tests__/dispatch-scroll.test.ts index 80160c6d2..87bd2f12d 100644 --- a/src/core/__tests__/dispatch-scroll.test.ts +++ b/src/core/__tests__/dispatch-scroll.test.ts @@ -2,19 +2,11 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { dispatchCommand } from '../dispatch.ts'; import { AppError } from '../../utils/errors.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; - -const IOS_DEVICE: DeviceInfo = { - platform: 'ios', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, -}; +import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; test('dispatch scroll rejects mixing amount and --pixels', async () => { await assert.rejects( - () => dispatchCommand(IOS_DEVICE, 'scroll', ['down', '0.4'], undefined, { pixels: 240 }), + () => dispatchCommand(IOS_SIMULATOR, 'scroll', ['down', '0.4'], undefined, { pixels: 240 }), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && diff --git a/src/core/__tests__/dispatch-trigger-app-event.test.ts b/src/core/__tests__/dispatch-trigger-app-event.test.ts index eb6fea897..01ae4493e 100644 --- a/src/core/__tests__/dispatch-trigger-app-event.test.ts +++ b/src/core/__tests__/dispatch-trigger-app-event.test.ts @@ -5,32 +5,11 @@ import os from 'node:os'; import path from 'node:path'; import { dispatchCommand } from '../dispatch.ts'; import { AppError } from '../../utils/errors.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; - -const ANDROID_DEVICE: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -}; - -const IOS_DEVICE: DeviceInfo = { - platform: 'ios', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, -}; - -const MACOS_DEVICE: DeviceInfo = { - platform: 'macos', - id: 'host-macos-local', - name: 'Mac', - kind: 'device', - target: 'desktop', - booted: true, -}; +import { + ANDROID_EMULATOR, + IOS_DEVICE, + MACOS_DEVICE, +} from '../../__tests__/test-utils/device-fixtures.ts'; test('trigger-app-event reports missing URL template as UNSUPPORTED_OPERATION', async () => { const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; @@ -40,7 +19,7 @@ test('trigger-app-event reports missing URL template as UNSUPPORTED_OPERATION', try { await assert.rejects( - () => dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['screenshot_taken']), + () => dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['screenshot_taken']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION'); @@ -65,7 +44,7 @@ test('trigger-app-event validates payload JSON', async () => { try { await assert.rejects( () => - dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['screenshot_taken', '{invalid-json']), + dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['screenshot_taken', '{invalid-json']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -100,7 +79,7 @@ test('trigger-app-event opens deep link with encoded event payload', async () => 'myapp://agent-device/event?name={event}&payload={payload}&platform={platform}'; try { - const result = await dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', [ + const result = await dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ 'screenshot_taken', '{"source":"qa","count":2}', ]); @@ -147,7 +126,7 @@ test('trigger-app-event prefers platform-specific template over global template' process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = 'myapp://android?name={event}'; try { - const result = await dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['screenshot_taken']); + const result = await dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['screenshot_taken']); assert.equal(result?.eventUrl, 'myapp://android?name=screenshot_taken'); } finally { process.env.PATH = previousPath; @@ -271,7 +250,7 @@ test('trigger-app-event rejects invalid event names', async () => { 'myapp://agent-device/event?name={event}'; try { await assert.rejects( - () => dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['bad event']), + () => dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['bad event']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -294,7 +273,7 @@ test('trigger-app-event rejects payloads that exceed size limits', async () => { try { await assert.rejects( () => - dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', [ + dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ 'screenshot_taken', oversizedPayload, ]), @@ -317,7 +296,7 @@ test('trigger-app-event rejects event URLs that exceed length limits', async () process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = `myapp://${'a'.repeat(5000)}?name={event}`; try { await assert.rejects( - () => dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['screenshot_taken']), + () => dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['screenshot_taken']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); diff --git a/src/core/__tests__/dispatch-type.test.ts b/src/core/__tests__/dispatch-type.test.ts index c2cd8def4..b6e22ba9a 100644 --- a/src/core/__tests__/dispatch-type.test.ts +++ b/src/core/__tests__/dispatch-type.test.ts @@ -2,19 +2,11 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { dispatchCommand } from '../dispatch.ts'; import { AppError } from '../../utils/errors.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; - -const ANDROID_DEVICE: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -}; +import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; test('dispatch type rejects ref-shaped first positional with a repair hint', async () => { await assert.rejects( - () => dispatchCommand(ANDROID_DEVICE, 'type', ['@ref42', 'filed', 'the', 'expense']), + () => dispatchCommand(ANDROID_EMULATOR, 'type', ['@ref42', 'filed', 'the', 'expense']), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && diff --git a/src/daemon/__tests__/recording-gestures.test.ts b/src/daemon/__tests__/recording-gestures.test.ts index 8592c86eb..5aee724a5 100644 --- a/src/daemon/__tests__/recording-gestures.test.ts +++ b/src/daemon/__tests__/recording-gestures.test.ts @@ -1,35 +1,24 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import type { SessionState } from '../types.ts'; import { augmentScrollVisualizationResult, recordTouchVisualizationEvent, } from '../recording-gestures.ts'; -import { attachRefs } from '../../utils/snapshot.ts'; +import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts'; -function makeSession(): SessionState { - return { - name: 'default', - device: { - platform: 'ios', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - createdAt: Date.now(), - actions: [], - snapshot: { - nodes: attachRefs([ +function makeSession() { + return makeIosSession('default', { + snapshot: makeSnapshotState( + [ { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 }, }, - ]), - createdAt: Date.now(), - backend: 'xctest', - }, + ], + { backend: 'xctest' }, + ), recording: { platform: 'ios', outPath: '/tmp/demo.mp4', @@ -39,7 +28,7 @@ function makeSession(): SessionState { child: { kill: () => {} } as any, wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), }, - }; + }); } test('scroll records a semantic scroll gesture for visualization telemetry', () => { diff --git a/src/daemon/__tests__/request-router-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index 3ee0937ae..cdfa3d856 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -1,5 +1,4 @@ import { test, expect, vi } from 'vitest'; -import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -18,9 +17,9 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { }); import { createRequestHandler } from '../request-router.ts'; -import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; vi.mock('../../platforms/android/index.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -62,10 +61,6 @@ vi.mock('../../utils/exec.ts', () => ({ }), })); -function makeStore(): SessionStore { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-router-android-modal-')); - return new SessionStore(path.join(tempRoot, 'sessions')); -} function makeAndroidSession(name: string): SessionState { return { @@ -98,7 +93,7 @@ test('generic Android gesture commands dismiss blocking system dialogs during re execCalls.length = 0; dispatchCalls.length = 0; - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-android-modal-'); sessionStore.set('default', makeAndroidSession('default')); const { openAndroidApp } = await import('../../platforms/android/index.ts'); diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index 08af0ac65..36f676b60 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -1,5 +1,4 @@ import { test, expect, vi, beforeEach } from 'vitest'; -import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -10,17 +9,12 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler } from '../request-router.ts'; -import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; const mockDispatch = vi.mocked(dispatchCommand); -function makeStore(): SessionStore { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-router-lock-')); - return new SessionStore(path.join(tempRoot, 'sessions')); -} - function makeIosSession(name: string): SessionState { return { name, @@ -44,7 +38,7 @@ beforeEach(() => { }); test('direct daemon requests cannot bypass reject lock policy for existing sessions', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-lock-'); sessionStore.set('qa-ios', makeIosSession('qa-ios')); const handler = createRequestHandler({ @@ -77,7 +71,7 @@ test('direct daemon requests cannot bypass reject lock policy for existing sessi }); test('batch steps cannot bypass reject lock policy on nested direct requests', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-lock-'); sessionStore.set('qa-ios', makeIosSession('qa-ios')); const handler = createRequestHandler({ @@ -118,7 +112,7 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a }); test('direct daemon requests apply strip lock policy for existing sessions before dispatch', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-lock-'); sessionStore.set('qa-ios', makeIosSession('qa-ios')); let dispatchCalls = 0; mockDispatch.mockImplementation(async () => { @@ -159,7 +153,7 @@ test('direct daemon requests apply strip lock policy for existing sessions befor }); test('batch preserves tenant-scoped session names across nested requests', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-lock-'); sessionStore.set('tenant-a:default', makeIosSession('tenant-a:default')); const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index aa614f8b0..031171d53 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -1,5 +1,4 @@ import { test, expect, vi, beforeEach } from 'vitest'; -import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -15,21 +14,16 @@ vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts'; import { createRequestHandler } from '../request-router.ts'; -import { SessionStore } from '../session-store.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { AppError } from '../../utils/errors.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -function makeStore(): SessionStore { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-router-open-')); - return new SessionStore(path.join(tempRoot, 'sessions')); -} - function makeIosDevice(id: string): DeviceInfo { return { platform: 'ios', @@ -50,7 +44,7 @@ beforeEach(() => { }); test('router serializes same-device open requests before first session creation finishes', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-open-'); const sameDevice = makeIosDevice('SIM-001'); const resolutionPlan: Array = [ new AppError('DEVICE_NOT_FOUND', 'device discovery is still warming up'), @@ -136,7 +130,7 @@ test('router serializes same-device open requests before first session creation }); test('router allows pre-open requests for different devices to proceed concurrently', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-open-'); const deviceA = makeIosDevice('SIM-001'); const deviceB = makeIosDevice('SIM-002'); mockResolveTargetDevice.mockImplementation(async (flags) => { diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index 7b4061f5e..c6c601dd7 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -1,5 +1,4 @@ import { test, expect, vi, beforeEach } from 'vitest'; -import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -10,24 +9,19 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler } from '../request-router.ts'; -import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; const mockDispatch = vi.mocked(dispatchCommand); -function makeStore(): SessionStore { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-router-recording-health-')); - return new SessionStore(path.join(tempRoot, 'sessions')); -} - beforeEach(() => { mockDispatch.mockReset(); mockDispatch.mockResolvedValue({}); }); test('router blocks non-record commands when recording was invalidated', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-recording-health-'); const session: SessionState = { name: 'default', createdAt: Date.now(), diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index bbb869fee..553e51ad9 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -10,35 +10,18 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler } from '../request-router.ts'; -import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { attachRefs } from '../../utils/snapshot.ts'; import { PNG } from 'pngjs'; +import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeSession as makeBaseSession } from '../../__tests__/test-utils/session-factories.ts'; const mockDispatch = vi.mocked(dispatchCommand); -const ANDROID_DEVICE: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -}; - -function makeStore(): SessionStore { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-router-screenshot-')); - return new SessionStore(path.join(tempRoot, 'sessions')); -} - function makeSession(name: string): SessionState { - return { - name, - device: ANDROID_DEVICE, - createdAt: Date.now(), - actions: [], - }; + return makeBaseSession(name, { device: ANDROID_EMULATOR }); } function makeMacOsMenubarSession(name: string): SessionState { @@ -77,7 +60,7 @@ function writeSolidPng(filePath: string, width = 100, height = 50): void { test('screenshot resolves relative positional path against request cwd', async () => { const callerCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-screenshot-cwd-caller-')); - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.set('default', makeSession('default')); let capturedPath: string | undefined; @@ -112,7 +95,7 @@ test('screenshot resolves relative positional path against request cwd', async ( }); test('router serializes concurrent commands for the same device across sessions', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.set('session-a', makeSession('session-a')); sessionStore.set('session-b', makeSession('session-b')); @@ -186,7 +169,7 @@ test('router serializes concurrent commands for the same device across sessions' }); test('screenshot forwards macOS session surface to dispatch', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.set('default', makeMacOsMenubarSession('default')); mockDispatch.mockImplementation(async () => ({})); @@ -214,7 +197,7 @@ test('screenshot forwards macOS session surface to dispatch', async () => { }); test('click forwards macOS menubar session surface to dispatch', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.set('default', makeMacOsMenubarSession('default')); mockDispatch.mockImplementation(async () => ({})); @@ -243,7 +226,7 @@ test('click forwards macOS menubar session surface to dispatch', async () => { }); test('screenshot keeps absolute positional path unchanged', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.set('default', makeSession('default')); const absolutePath = path.join(os.tmpdir(), 'evidence/test.png'); @@ -279,7 +262,7 @@ test('screenshot keeps absolute positional path unchanged', async () => { test('screenshot resolves --out flag path against request cwd', async () => { const callerCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-screenshot-out-cwd-')); - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.set('default', makeSession('default')); let capturedOut: string | undefined; @@ -316,7 +299,7 @@ test('screenshot resolves --out flag path against request cwd', async () => { }); test('screenshot --overlay-refs captures a fresh snapshot when the session has none', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.set('default', makeSession('default')); const screenshotPath = path.join(os.tmpdir(), `agent-device-overlay-${Date.now()}.png`); @@ -374,7 +357,7 @@ test('screenshot --overlay-refs captures a fresh snapshot when the session has n }); test('screenshot --overlay-refs uses a fresh snapshot instead of stale session snapshot', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-router-screenshot-'); const session = makeSession('default'); session.snapshot = { nodes: attachRefs([ diff --git a/src/daemon/__tests__/screenshot-overlay.test.ts b/src/daemon/__tests__/screenshot-overlay.test.ts index 78f0ada60..1e14f5bb8 100644 --- a/src/daemon/__tests__/screenshot-overlay.test.ts +++ b/src/daemon/__tests__/screenshot-overlay.test.ts @@ -4,15 +4,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { PNG } from 'pngjs'; -import { attachRefs, type SnapshotState } from '../../utils/snapshot.ts'; import { annotateScreenshotWithRefs, buildScreenshotOverlayRefs } from '../screenshot-overlay.ts'; - -function makeSnapshotState(nodes: Parameters[0]): SnapshotState { - return { - nodes: attachRefs(nodes), - createdAt: Date.now(), - }; -} +import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts'; function writeSolidPng(filePath: string, width: number, height: number): void { const png = new PNG({ width, height }); diff --git a/src/daemon/__tests__/snapshot-diff.test.ts b/src/daemon/__tests__/snapshot-diff.test.ts index ff10d00ea..75cee75ba 100644 --- a/src/daemon/__tests__/snapshot-diff.test.ts +++ b/src/daemon/__tests__/snapshot-diff.test.ts @@ -1,11 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { attachRefs, type RawSnapshotNode } from '../../utils/snapshot.ts'; import { buildSnapshotDiff } from '../snapshot-diff.ts'; - -function nodes(raw: RawSnapshotNode[]) { - return attachRefs(raw); -} +import { buildNodes as nodes } from '../../__tests__/test-utils/snapshot-builders.ts'; test('buildSnapshotDiff reports unchanged lines when snapshots are equal', () => { const previous = nodes([ diff --git a/src/daemon/handlers/__tests__/find.test.ts b/src/daemon/handlers/__tests__/find.test.ts index 9891d7dd1..29d81f537 100644 --- a/src/daemon/handlers/__tests__/find.test.ts +++ b/src/daemon/handlers/__tests__/find.test.ts @@ -1,12 +1,15 @@ import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { parseFindArgs, handleFindCommands } from '../find.ts'; -import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; import { withMockedMacOsHelper } from '../../../platforms/ios/__tests__/macos-helper-test-utils.ts'; import { buildSnapshotSignatures } from '../../android-snapshot-freshness.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { + makeIosSession as makeSession, + makeMacOsSession as makeBaseMacOsSession, +} from '../../../__tests__/test-utils/session-factories.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -30,40 +33,8 @@ beforeEach(() => { }); }); -function makeSessionStore(): SessionStore { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-find-handler-')); - return new SessionStore(path.join(root, 'sessions')); -} - -function makeSession(name: string): SessionState { - return { - name, - device: { - platform: 'ios', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - createdAt: Date.now(), - actions: [], - }; -} - -function makeMacOsSession(name: string): SessionState { - return { - name, - device: { - platform: 'macos', - id: 'macos-host', - name: 'Mac', - kind: 'device', - booted: true, - }, - createdAt: Date.now(), - actions: [], - surface: 'desktop', - }; +function makeMacOsSession(name: string) { + return makeBaseMacOsSession(name, { surface: 'desktop' }); } const INCREMENT_NODE = { diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 278dcd1e3..7e55a7de0 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -1,13 +1,17 @@ import { test, expect, vi, beforeEach } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import { handleInteractionCommands, unsupportedRefSnapshotFlags } from '../interaction.ts'; -import { SessionStore } from '../../session-store.ts'; +import type { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; import type { CommandFlags } from '../../../core/dispatch.ts'; import { attachRefs, type SnapshotBackend } from '../../../utils/snapshot.ts'; import { buildSnapshotState } from '../snapshot-capture.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { + makeIosSession, + makeAndroidSession as makeBaseAndroidSession, + makeMacOsSession as makeBaseMacOsSession, +} from '../../../__tests__/test-utils/session-factories.ts'; +import { makeSnapshotState } from '../../../__tests__/test-utils/snapshot-builders.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -75,49 +79,16 @@ async function emulateCaptureSnapshotForSession( return snapshot; } -function makeSessionStore(): SessionStore { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-interaction-handler-')); - return new SessionStore(path.join(root, 'sessions')); -} - function makeSession(name: string): SessionState { - return { - name, - device: { - platform: 'ios', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - createdAt: Date.now(), - actions: [], - }; + return makeIosSession(name); } function makeAndroidSession(name: string): SessionState { - return { - name, - device: { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel 9 Pro XL', - kind: 'emulator', - target: 'mobile', - booted: true, - }, - createdAt: Date.now(), - appBundleId: 'com.android.settings', - actions: [], - }; + return makeBaseAndroidSession(name, { appBundleId: 'com.android.settings' }); } function makeScrollSnapshot(nodes: Parameters[0]) { - return { - nodes: attachRefs(nodes), - createdAt: Date.now(), - backend: 'xctest' as const, - }; + return makeSnapshotState(nodes, { backend: 'xctest' }); } function makeScrollSession( @@ -132,26 +103,11 @@ function makeScrollSession( } function makeMacOsDesktopSession(name: string): SessionState { - return { - name, - device: { - platform: 'macos', - id: 'macos-host', - name: 'Mac', - kind: 'device', - booted: true, - }, - createdAt: Date.now(), - actions: [], - surface: 'desktop', - }; + return makeBaseMacOsSession(name, { surface: 'desktop' }); } function makeMacOsMenubarSession(name: string): SessionState { - return { - ...makeMacOsDesktopSession(name), - surface: 'menubar', - }; + return makeBaseMacOsSession(name, { surface: 'menubar' }); } const contextFromFlags = (flags: CommandFlags | undefined) => ({ diff --git a/src/daemon/handlers/__tests__/replay-heal.test.ts b/src/daemon/handlers/__tests__/replay-heal.test.ts index c63409923..9a8f97dee 100644 --- a/src/daemon/handlers/__tests__/replay-heal.test.ts +++ b/src/daemon/handlers/__tests__/replay-heal.test.ts @@ -12,10 +12,11 @@ import path from 'node:path'; import type { CommandFlags } from '../../../core/dispatch.ts'; import { handleSessionCommands } from '../session.ts'; import { SessionStore } from '../../session-store.ts'; -import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../../types.ts'; +import type { DaemonRequest, DaemonResponse, SessionAction } from '../../types.ts'; import type { DeviceInfo } from '../../../utils/device.ts'; import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; import { ensureDeviceReady } from '../../device-ready.ts'; +import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); @@ -29,24 +30,8 @@ beforeEach(() => { mockEnsureDeviceReady.mockResolvedValue(undefined); }); -function makeDevice(): DeviceInfo { - return { - platform: 'ios', - id: 'sim-1', - name: 'iPhone Test', - kind: 'simulator', - booted: true, - }; -} - -function makeSession(name: string): SessionState { - return { - name, - device: makeDevice(), - createdAt: Date.now(), - appBundleId: 'com.example.app', - actions: [], - }; +function makeSession(name: string) { + return makeIosSession(name, { appBundleId: 'com.example.app' }); } function writeReplayFile(filePath: string, action: SessionAction) { diff --git a/src/daemon/handlers/__tests__/session-inventory.test.ts b/src/daemon/handlers/__tests__/session-inventory.test.ts index c1961368c..6add8cc02 100644 --- a/src/daemon/handlers/__tests__/session-inventory.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { handleSessionInventoryCommands } from '../session-inventory.ts'; -import { makeSessionStore } from './session-test-store.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; test('session inventory lists iOS session metadata directly', async () => { const sessionStore = makeSessionStore('agent-device-session-inventory-'); diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index 4851925bb..50dcb50d9 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { handleSessionObservabilityCommands } from '../session-observability.ts'; -import { makeSessionStore } from './session-test-store.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; test('logs path reports backend for macOS desktop sessions directly', async () => { const sessionStore = makeSessionStore('agent-device-session-observability-'); diff --git a/src/daemon/handlers/__tests__/session-push.test.ts b/src/daemon/handlers/__tests__/session-push.test.ts index 11dc52e22..d1dc49696 100644 --- a/src/daemon/handlers/__tests__/session-push.test.ts +++ b/src/daemon/handlers/__tests__/session-push.test.ts @@ -10,10 +10,11 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { vi.mock('../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); import { handleSessionCommands } from '../session.ts'; -import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; import type { CommandFlags } from '../../../core/dispatch.ts'; import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { makeSession as makeBaseSession } from '../../../__tests__/test-utils/session-factories.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); @@ -24,19 +25,8 @@ beforeEach(() => { mockResolveTargetDevice.mockReset(); }); -function makeStore(): SessionStore { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-push-')); - return new SessionStore(path.join(tempRoot, 'sessions')); -} - function makeSession(name: string, device: SessionState['device']): SessionState { - return { - name, - device, - createdAt: Date.now(), - actions: [], - appBundleId: 'com.example.active', - }; + return makeBaseSession(name, { device, appBundleId: 'com.example.active' }); } const invoke = async (_req: DaemonRequest): Promise => { @@ -47,7 +37,7 @@ const invoke = async (_req: DaemonRequest): Promise => { }; test('push requires active session or explicit device selector', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-session-push-'); const response = await handleSessionCommands({ req: { token: 't', @@ -70,7 +60,7 @@ test('push requires active session or explicit device selector', async () => { }); test('push uses session device and records action', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-session-push-'); const session = makeSession('default', { platform: 'android', id: 'emulator-5554', @@ -112,7 +102,7 @@ test('push uses session device and records action', async () => { }); test('push expands payload file path from request cwd', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-session-push-'); const session = makeSession('default', { platform: 'android', id: 'emulator-5554', @@ -152,7 +142,7 @@ test('push expands payload file path from request cwd', async () => { }); test('push treats brace-prefixed existing payload path as file', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-session-push-'); const session = makeSession('default', { platform: 'android', id: 'emulator-5554', diff --git a/src/daemon/handlers/__tests__/session-runtime-command.test.ts b/src/daemon/handlers/__tests__/session-runtime-command.test.ts index 17b1c8a66..886197c15 100644 --- a/src/daemon/handlers/__tests__/session-runtime-command.test.ts +++ b/src/daemon/handlers/__tests__/session-runtime-command.test.ts @@ -1,9 +1,6 @@ import { test, expect, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; vi.mock('../../runtime-hints.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -16,11 +13,6 @@ vi.mock('../../runtime-hints.ts', async (importOriginal) => { import { handleRuntimeCommand } from '../session-runtime-command.ts'; import { clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; -function makeSessionStore(): SessionStore { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runtime-cmd-')); - return new SessionStore(path.join(root, 'sessions')); -} - test('runtime clear removes applied transport hints for the active app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'runtime-clear-active'; diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index aef68df03..3bc89d99f 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -6,7 +6,7 @@ vi.mock('../../../platforms/android/devices.ts', async (importOriginal) => { }); import { handleSessionStateCommands } from '../session-state.ts'; -import { makeSessionStore } from './session-test-store.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { ensureAndroidEmulatorBooted } from '../../../platforms/android/devices.ts'; const mockEnsureAndroidEmulatorBooted = vi.mocked(ensureAndroidEmulatorBooted); diff --git a/src/daemon/handlers/__tests__/session-trigger.test.ts b/src/daemon/handlers/__tests__/session-trigger.test.ts index b00f3fc44..70a326c62 100644 --- a/src/daemon/handlers/__tests__/session-trigger.test.ts +++ b/src/daemon/handlers/__tests__/session-trigger.test.ts @@ -1,7 +1,4 @@ import { test, expect, vi, beforeEach } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -14,10 +11,11 @@ vi.mock('../session-open-target.ts', async (importOriginal) => { }); import { handleSessionCommands } from '../session.ts'; -import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; import { resolveAndroidPackageForOpen } from '../session-open-target.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { makeSession as makeBaseSession } from '../../../__tests__/test-utils/session-factories.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); @@ -31,20 +29,12 @@ beforeEach(() => { mockResolveAndroidPackage.mockResolvedValue(undefined); }); -function makeStore(): SessionStore { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-trigger-')); - return new SessionStore(path.join(tempRoot, 'sessions')); -} - function makeSession(name: string, device: SessionState['device']): SessionState { - return { - name, + return makeBaseSession(name, { device, - createdAt: Date.now(), - actions: [], appName: 'ExampleApp', appBundleId: 'com.example.app', - }; + }); } const invoke = async (_req: DaemonRequest): Promise => { @@ -55,7 +45,7 @@ const invoke = async (_req: DaemonRequest): Promise => { }; test('trigger-app-event requires active session or explicit device selector', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-session-trigger-'); const response = await handleSessionCommands({ req: { token: 't', @@ -78,7 +68,7 @@ test('trigger-app-event requires active session or explicit device selector', as }); test('trigger-app-event supports explicit selector without active session', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-session-trigger-'); mockResolveTargetDevice.mockResolvedValue({ platform: 'android', id: 'emulator-5554', @@ -117,7 +107,7 @@ test('trigger-app-event supports explicit selector without active session', asyn }); test('trigger-app-event records action and refreshes session app bundle context', async () => { - const sessionStore = makeStore(); + const sessionStore = makeSessionStore('agent-device-session-trigger-'); const session = makeSession('default', { platform: 'android', id: 'emulator-5554',