diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index b4a93e297..a94f79d52 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -1,18 +1,10 @@ { "finding_counts": { - "scripts/integration-progress.mjs": { - "crap_critical": { - "count": 1 - }, + "src/__tests__/cli-config.test.ts": { "crap_high": { "count": 1 }, "crap_moderate": { - "count": 3 - } - }, - "src/__tests__/cli-config.test.ts": { - "crap_high": { "count": 1 } }, @@ -21,11 +13,6 @@ "count": 1 } }, - "src/__tests__/runtime-diagnostics-router.test.ts": { - "crap_moderate": { - "count": 1 - } - }, "src/__tests__/runtime-public.test.ts": { "crap_moderate": { "count": 1 @@ -48,23 +35,11 @@ } }, "src/cli/commands/connection-runtime.ts": { - "complexity_high": { - "count": 1 - }, - "crap_high": { - "count": 1 - }, "crap_moderate": { - "count": 1 + "count": 2 } }, "src/cli/commands/connection.ts": { - "complexity_high": { - "count": 1 - }, - "crap_high": { - "count": 2 - }, "crap_moderate": { "count": 1 } @@ -74,6 +49,17 @@ "count": 1 } }, + "src/cli/parser/args.ts": { + "complexity_critical": { + "count": 2 + }, + "complexity_moderate": { + "count": 1 + }, + "crap_critical": { + "count": 1 + } + }, "src/client/client-companion-tunnel-worker.ts": { "complexity_high": { "count": 1 @@ -90,32 +76,17 @@ "count": 1 } }, - "src/metro/client-metro.ts": { - "crap_moderate": { - "count": 3 - } - }, "src/client/client-shared.ts": { "crap_moderate": { "count": 1 } }, - "src/commands/admin.ts": { - "crap_high": { - "count": 1 - } - }, - "src/commands/capture-diff-screenshot.ts": { - "crap_moderate": { - "count": 1 - } - }, - "src/commands/capture-snapshot.ts": { - "crap_moderate": { + "src/core/batch.ts": { + "complexity_moderate": { "count": 1 } }, - "src/commands/diagnostics-format.ts": { + "src/core/dispatch.ts": { "complexity_critical": { "count": 1 }, @@ -123,39 +94,21 @@ "count": 1 } }, - "src/commands/diagnostics.ts": { - "crap_moderate": { - "count": 1 - } - }, - "src/commands/interaction-resolution.ts": { + "src/daemon/__tests__/network-log.test.ts": { "crap_moderate": { "count": 1 } }, - "src/kernel/contracts.ts": { + "src/daemon/android-snapshot-freshness.ts": { "crap_moderate": { "count": 1 } }, - "src/core/batch.ts": { + "src/daemon/app-log.ts": { "complexity_high": { "count": 1 } }, - "src/core/dispatch.ts": { - "complexity_critical": { - "count": 1 - }, - "crap_critical": { - "count": 1 - } - }, - "src/remote/daemon-artifacts.ts": { - "crap_moderate": { - "count": 2 - } - }, "src/daemon/client/daemon-client-lifecycle.ts": { "complexity_high": { "count": 1 @@ -170,29 +123,11 @@ } }, "src/daemon/client/daemon-client-rpc.ts": { - "crap_moderate": { - "count": 2 - } - }, - "src/daemon/client/daemon-client.ts": { - "complexity_moderate": { - "count": 2 - } - }, - "src/daemon/__tests__/network-log.test.ts": { "crap_moderate": { "count": 1 } }, - "src/daemon/android-snapshot-freshness.ts": { - "crap_moderate": { - "count": 1 - } - }, - "src/daemon/app-log.ts": { - "complexity_high": { - "count": 1 - }, + "src/daemon/client/daemon-client.ts": { "complexity_moderate": { "count": 1 } @@ -207,7 +142,7 @@ }, "src/daemon/handlers/__tests__/interaction.test.ts": { "crap_moderate": { - "count": 5 + "count": 6 } }, "src/daemon/handlers/__tests__/record-trace.test.ts": { @@ -223,22 +158,40 @@ "count": 1 } }, + "src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts": { + "crap_high": { + "count": 3 + }, + "crap_moderate": { + "count": 5 + } + }, "src/daemon/handlers/__tests__/session-close-shutdown.test.ts": { "crap_moderate": { "count": 2 } }, + "src/daemon/handlers/__tests__/session-devices-batch-runtime.test.ts": { + "crap_moderate": { + "count": 1 + } + }, + "src/daemon/handlers/__tests__/session-logs.test.ts": { + "crap_moderate": { + "count": 1 + } + }, "src/daemon/handlers/__tests__/session-open-runtime.test.ts": { "crap_moderate": { "count": 1 } }, - "src/daemon/handlers/__tests__/session.test.ts": { + "src/daemon/handlers/__tests__/session-test-runner.test.ts": { "crap_high": { - "count": 4 + "count": 1 }, "crap_moderate": { - "count": 7 + "count": 1 } }, "src/daemon/handlers/find.ts": { @@ -246,11 +199,11 @@ "count": 1 }, "crap_moderate": { - "count": 3 + "count": 1 } }, "src/daemon/handlers/interaction-common.ts": { - "crap_moderate": { + "crap_high": { "count": 1 } }, @@ -264,13 +217,8 @@ "count": 3 } }, - "src/daemon/handlers/record-trace-recording.ts": { - "crap_moderate": { - "count": 1 - } - }, "src/daemon/handlers/record-trace.ts": { - "complexity_high": { + "complexity_moderate": { "count": 1 } }, @@ -290,9 +238,6 @@ } }, "src/daemon/handlers/session-open.ts": { - "complexity_high": { - "count": 1 - }, "crap_moderate": { "count": 1 } @@ -330,23 +275,6 @@ "count": 1 } }, - "src/daemon/server/http-server.ts": { - "complexity_critical": { - "count": 1 - }, - "complexity_high": { - "count": 1 - }, - "crap_critical": { - "count": 1 - }, - "crap_high": { - "count": 1 - }, - "crap_moderate": { - "count": 2 - } - }, "src/daemon/install-source-resolution.ts": { "crap_moderate": { "count": 1 @@ -373,34 +301,53 @@ "count": 1 } }, - "src/daemon/selector-runtime.ts": { - "crap_moderate": { - "count": 2 - } - }, "src/daemon/selectors-match.ts": { "crap_high": { "count": 1 } }, - "src/utils/selectors-parse.ts": { - "complexity_moderate": { + "src/daemon/selectors-resolve.ts": { + "crap_moderate": { + "count": 1 + } + }, + "src/daemon/server/http-server.ts": { + "complexity_critical": { + "count": 1 + }, + "complexity_high": { + "count": 1 + }, + "crap_critical": { + "count": 1 + }, + "crap_high": { "count": 1 }, "crap_moderate": { "count": 2 } }, - "src/daemon/selectors-resolve.ts": { + "src/daemon/session-selector.ts": { + "complexity_moderate": { + "count": 1 + } + }, + "src/kernel/contracts.ts": { "crap_moderate": { "count": 1 } }, - "src/daemon/session-selector.ts": { + "src/kernel/device.ts": { "complexity_moderate": { "count": 1 } }, + "src/metro/client-metro.ts": { + "crap_moderate": { + "count": 3 + } + }, "src/platforms/android/app-parsers.ts": { "crap_moderate": { "count": 1 @@ -421,25 +368,14 @@ "count": 2 } }, - "src/platforms/boot-diagnostics.ts": { - "complexity_critical": { - "count": 1 - }, - "crap_critical": { - "count": 1 - } - }, - "src/platforms/ios/__tests__/index.test.ts": { + "src/platforms/apple/core/app-resolution.ts": { "crap_moderate": { "count": 1 } }, - "src/platforms/apple/core/apps.ts": { - "complexity_high": { - "count": 1 - }, + "src/platforms/apple/core/app-settings.ts": { "crap_moderate": { - "count": 2 + "count": 1 } }, "src/platforms/apple/core/devices.ts": { @@ -447,19 +383,6 @@ "count": 1 } }, - "src/platforms/ios/interactions.ts": { - "complexity_critical": { - "count": 1 - }, - "crap_critical": { - "count": 1 - } - }, - "src/platforms/apple/os/macos/helper.ts": { - "crap_high": { - "count": 1 - } - }, "src/platforms/apple/core/perf.ts": { "complexity_high": { "count": 1 @@ -496,16 +419,34 @@ "count": 1 } }, + "src/platforms/apple/os/macos/helper.ts": { + "crap_high": { + "count": 1 + } + }, + "src/platforms/boot-diagnostics.ts": { + "complexity_critical": { + "count": 1 + }, + "crap_critical": { + "count": 1 + } + }, "src/platforms/linux/atspi-bridge.ts": { "crap_moderate": { "count": 1 } }, "src/platforms/linux/input-actions.ts": { - "complexity_high": { + "complexity_moderate": { "count": 1 } }, + "src/remote/daemon-artifacts.ts": { + "crap_moderate": { + "count": 2 + } + }, "src/remote/remote-config-core.ts": { "complexity_high": { "count": 1 @@ -514,11 +455,6 @@ "count": 1 } }, - "src/remote/remote-connection-state.ts": { - "crap_high": { - "count": 1 - } - }, "src/remote/upload-client.ts": { "crap_moderate": { "count": 1 @@ -529,14 +465,31 @@ "count": 1 } }, - "src/cli/parser/args.ts": { - "complexity_critical": { + "src/screenshot-diff/screenshot-diff-non-text.ts": { + "crap_high": { + "count": 1 + } + }, + "src/screenshot-diff/screenshot-diff.ts": { + "complexity_moderate": { + "count": 1 + } + }, + "src/snapshot/mobile-snapshot-semantics.ts": { + "crap_moderate": { "count": 2 - }, + } + }, + "src/snapshot/snapshot-diff.ts": { "complexity_moderate": { "count": 1 }, - "crap_critical": { + "crap_moderate": { + "count": 1 + } + }, + "src/snapshot/snapshot-lines.ts": { + "crap_moderate": { "count": 1 } }, @@ -553,21 +506,11 @@ "count": 1 } }, - "src/kernel/device.ts": { - "complexity_moderate": { - "count": 1 - } - }, "src/utils/exec.ts": { "complexity_moderate": { "count": 1 } }, - "src/snapshot/mobile-snapshot-semantics.ts": { - "crap_moderate": { - "count": 2 - } - }, "src/utils/output.ts": { "crap_moderate": { "count": 2 @@ -578,16 +521,6 @@ "count": 1 } }, - "src/screenshot-diff/screenshot-diff-non-text.ts": { - "crap_high": { - "count": 1 - } - }, - "src/screenshot-diff/screenshot-diff.ts": { - "complexity_high": { - "count": 1 - } - }, "src/utils/scroll-edge-state.ts": { "crap_moderate": { "count": 1 @@ -603,17 +536,12 @@ "count": 1 } }, - "src/snapshot/snapshot-diff.ts": { + "src/utils/selectors-parse.ts": { "complexity_moderate": { "count": 1 }, "crap_moderate": { - "count": 1 - } - }, - "src/snapshot/snapshot-lines.ts": { - "crap_moderate": { - "count": 1 + "count": 2 } }, "src/utils/source-value.ts": { @@ -640,6 +568,9 @@ }, "crap_critical": { "count": 1 + }, + "crap_moderate": { + "count": 1 } }, "test/integration/provider-scenarios/daemon-http-server.test.ts": { @@ -660,11 +591,6 @@ "count": 1 } }, - "test/integration/provider-scenarios/remote-daemon-client.test.ts": { - "crap_moderate": { - "count": 1 - } - }, "test/integration/smoke-open-remote-config.test.ts": { "crap_high": { "count": 2 @@ -676,66 +602,72 @@ } }, "test/skillgym/suites/agent-device-smoke-suite.ts": { - "crap_critical": { + "crap_moderate": { "count": 1 } } }, "runtime_coverage_findings": [], "target_keys": [ - "src/daemon/handlers/session.ts:complexity", - "src/daemon/handlers/session-replay-runtime.ts:high impact", - "src/snapshot/snapshot-processing.ts:high impact", - "src/daemon/handlers/session-device-utils.ts:high impact", - "src/commands/cli-grammar/common.ts:high impact", - "src/client/client-shared.ts:high impact", + "src/daemon/client/daemon-client.ts:high impact", "src/replay/script.ts:complexity", - "src/commands/selector-read-utils.ts:high impact", - "src/core/interaction-targeting.ts:high impact", + "src/daemon/handlers/session-replay-runtime.ts:high impact", + "src/daemon/lease-context.ts:high impact", + "src/daemon/context.ts:high impact", + "src/utils/output.ts:high impact", "src/daemon/android-snapshot-freshness.ts:high impact", - "src/compat/maestro/support.ts:high impact", "src/daemon/handlers/session-replay-heal.ts:complexity", + "src/platforms/web/agent-browser-provider.ts:high impact", + "src/compat/maestro/support.ts:high impact", + "src/daemon/handlers/session.ts:complexity", "src/platforms/boot-diagnostics.ts:complexity", - "src/utils/output.ts:high impact", + "src/utils/selector-is-predicates.ts:high impact", + "src/snapshot/snapshot-processing.ts:high impact", "src/daemon/session-routing.ts:high impact", - "src/cli/commands/connection-runtime.ts:complexity", - "src/daemon/context.ts:high impact", + "src/daemon/handlers/session-state.ts:complexity", + "src/commands/cli-grammar/common.ts:high impact", "src/utils/success-text.ts:high impact", - "src/platforms/apple/core/apps.ts:complexity", - "src/compat/maestro/runtime-targets.ts:high impact", + "src/daemon/network-log.ts:high impact", "src/utils/timeouts.ts:high impact", + "src/client/client-shared.ts:high impact", "src/replay/script-utils.ts:high impact", - "src/daemon/handlers/session-open-target.ts:high impact", - "src/utils/selector-is-predicates.ts:high impact", + "src/platforms/apple/core/app-launch.ts:complexity", + "src/utils/parsing.ts:high impact", "src/cli/parser/args.ts:high impact", "src/daemon/snapshot-presentation/tree.ts:high impact", - "src/utils/rect-center.ts:high impact", + "src/platforms/apple/core/perf-xml.ts:high impact", "src/utils/process-identity.ts:high impact", + "src/platforms/web/json-utils.ts:high impact", + "src/utils/rect-center.ts:high impact", + "src/daemon/handlers/session-doctor-output.ts:high impact", "src/daemon/app-log-process.ts:high impact", - "src/snapshot/snapshot-lines.ts:high impact", "src/daemon/handlers/session-test-sharding.ts:high impact", - "src/platforms/android/ui-hierarchy.ts:high impact", - "src/platforms/linux/snapshot.ts:high impact", - "src/utils/parsing.ts:high impact", - "src/daemon/handlers/session-state.ts:complexity", + "src/platforms/apple/core/debug-symbols/utils.ts:high impact", "src/daemon/request-cancel.ts:high impact", - "src/platforms/android/settings.ts:complexity", - "src/utils/text-surface.ts:high impact", + "src/core/interaction-targeting.ts:high impact", + "src/platforms/linux/snapshot.ts:high impact", + "src/snapshot/snapshot-lines.ts:high impact", + "src/cli.ts:complexity", "src/utils/selector-build.ts:high impact", - "src/core/batch.ts:complexity", - "src/kernel/device.ts:high impact", - "src/daemon/network-log.ts:high impact", - "src/commands/cli-grammar/selectors.ts:untested risk", - "src/platforms/ios/xml.ts:high impact", + "src/utils/source-value.ts:high impact", + "src/utils/text-surface.ts:high impact", + "src/platforms/android/settings.ts:complexity", + "src/compat/maestro/runtime-targets.ts:high impact", + "src/commands/interaction/selectors.ts:untested risk", + "src/cloud-webdriver/webdriver-utils.ts:high impact", "src/utils/keyed-lock.ts:high impact", + "src/utils/screenshot-result.ts:high impact", + "src/cloud-webdriver/webdriver-source.ts:high impact", + "src/replay/test/reporters/format.ts:high impact", "src/daemon/handlers/session-test-infrastructure.ts:high impact", - "src/daemon/app-log-stream.ts:high impact", + "src/daemon/request-progress-protocol.ts:high impact", + "src/platforms/android/ui-hierarchy.ts:high impact", "src/utils/rect-visibility.ts:high impact", - "src/cli.ts:complexity", "src/daemon/handlers/session-test-artifacts.ts:high impact", "src/platforms/android/app-parsers.ts:high impact", - "src/utils/source-value.ts:high impact", + "src/daemon/server/http-server.ts:complexity", "src/platforms/android/sdk.ts:high impact", + "src/daemon/client/daemon-client-lifecycle.ts:complexity", "src/client/client-companion-tunnel-worker.ts:complexity" ] -} +} \ No newline at end of file diff --git a/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts b/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts new file mode 100644 index 000000000..767183b9c --- /dev/null +++ b/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts @@ -0,0 +1,733 @@ +import { test, expect, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { AppError } from '../../../kernel/errors.ts'; +import { + mockDispatch, + mockResolveTargetDevice, + mockRunCmd, + makeSessionStore, + makeSession, + noopInvoke, +} from './session-test-harness.ts'; +import type { SessionState } from '../../types.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('appstate on iOS requires active session on selected device', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.apple.Preferences', + appName: 'Settings', + }); + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-2', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + mockDispatch.mockRejectedValue(new Error('snapshot dispatch should not run')); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'appstate', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('SESSION_NOT_FOUND'); + expect(response.error.message).toMatch(/requires an active session/i); + } +}); + +test('appstate returns session appName when bundle id is unavailable', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'sim'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'Maps', + }); + + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + mockDispatch.mockRejectedValue(new Error('snapshot dispatch should not run')); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'appstate', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.platform).toBe('ios'); + expect(response.data?.appName).toBe('Maps'); + expect(response.data?.appBundleId).toBe(undefined); + expect(response.data?.source).toBe('session'); + expect(response.data?.device_udid).toBe('sim-1'); + expect(response.data?.ios_simulator_device_set).toBe(null); + } +}); + +test('appstate fails when iOS session has no tracked app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'sim'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + ); + + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'appstate', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toMatch(/no foreground app is tracked/i); + } +}); + +test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async () => { + const sessionStore = makeSessionStore(); + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-2', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'appstate', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('SESSION_NOT_FOUND'); + } +}); + +test('appstate with explicit missing session returns SESSION_NOT_FOUND', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'sim', + command: 'appstate', + positionals: [], + flags: { session: 'sim', platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName: 'sim', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('SESSION_NOT_FOUND'); + expect(response.error.message).toMatch(/no active session "sim"/i); + expect(response.error.message).not.toMatch(/omit --session/i); + } +}); + +test('clipboard requires an active session or explicit device selector', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'clipboard', + positionals: ['read'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch( + /clipboard requires an active session or an explicit device selector/i, + ); + } +}); + +test('keyboard requires an active session or explicit device selector', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'keyboard', + positionals: ['status'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch( + /keyboard requires an active session or an explicit device selector/i, + ); + } +}); + +test('keyboard dismiss requires active iOS session for explicit selectors', async () => { + const sessionStore = makeSessionStore(); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'keyboard', + positionals: ['dismiss'], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('SESSION_NOT_FOUND'); + expect(response.error.message).toMatch(/requires an active session/i); + } +}); + +test('clipboard rejects unsupported iOS physical devices', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-session'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + ); + + mockDispatch.mockRejectedValue(new Error('dispatch should not run for unsupported targets')); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'clipboard', + positionals: ['read'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toMatch(/clipboard is not supported on this device/i); + } +}); + +test('perf requires an active session', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'perf', + positionals: [], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('SESSION_NOT_FOUND'); + } +}); + +test('perf reports startup metric as unavailable when no sample exists', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'perf-session-empty'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + ); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'perf', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + if (response && response.ok) { + const startup = (response.data?.metrics as any)?.startup; + const memory = (response.data?.metrics as any)?.memory; + const cpu = (response.data?.metrics as any)?.cpu; + expect(startup?.available).toBe(false); + expect(String(startup?.reason ?? '')).toMatch(/no startup sample captured yet/i); + expect(memory?.available).toBe(false); + expect(String(memory?.reason ?? '')).toMatch(/run open first/i); + expect(cpu?.available).toBe(false); + expect(String(cpu?.reason ?? '')).toMatch(/run open first/i); + } +}); + +test('perf preserves successful metrics and normalizes per-metric Android sampling failures', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'perf-session-android-error'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + }); + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (args.includes('meminfo')) { + throw new AppError('COMMAND_FAILED', 'adb exited with code 1', { + stderr: 'error: device offline', + exitCode: 1, + processExitError: true, + }); + } + return { + stdout: '0.0% 1234/com.example.app: 0% user + 0% kernel', + stderr: '', + exitCode: 0, + }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'perf', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + if (response && response.ok) { + const startup = (response.data?.metrics as any)?.startup; + const memory = (response.data?.metrics as any)?.memory; + const cpu = (response.data?.metrics as any)?.cpu; + expect(startup?.available).toBe(false); + expect(memory?.available).toBe(false); + expect(memory?.reason).toBe('error: device offline'); + expect(memory?.error?.code).toBe('COMMAND_FAILED'); + expect(memory?.error?.hint).toMatch(/retry with --debug/i); + expect(memory?.error?.details?.metric).toBe('memory'); + expect(memory?.error?.details?.package).toBe('com.example.app'); + expect(cpu?.available).toBe(true); + expect(cpu?.usagePercent).toBe(0); + } +}); + +test('perf samples Apple cpu and memory metrics on macOS app sessions', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'perf-session-macos'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-mac', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + appBundleId: 'com.example.mac', + }); + mockRunCmd.mockImplementation(async (cmd, _args) => { + if (cmd === 'mdfind') { + return { stdout: '/Applications/Example.app\n', stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') { + return { stdout: 'ExampleExec\n', stderr: '', exitCode: 0 }; + } + if (cmd === 'ps') { + return { + stdout: [ + '111 7.5 4096 /Applications/Example.app/Contents/MacOS/ExampleExec', + '222 0.5 1024 /Applications/Example.app/Contents/MacOS/ExampleExec --flag', + '333 5.0 2048 /Applications/Other.app/Contents/MacOS/OtherExec', + ].join('\n'), + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'perf', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('Expected perf response to succeed for macOS session'); + const memory = (response.data?.metrics as any)?.memory; + const cpu = (response.data?.metrics as any)?.cpu; + expect(memory?.available).toBe(true); + expect(memory?.residentMemoryKb).toBe(5120); + expect(cpu?.available).toBe(true); + expect(cpu?.usagePercent).toBe(8); + expect(cpu?.matchedProcesses).toEqual(['ExampleExec']); +}); + +test('perf samples Apple cpu and memory metrics on iOS simulator app sessions', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'perf-session-ios-sim'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.sim', + }); + mockRunCmd.mockImplementation(async (cmd, args) => { + if (cmd === 'xcrun' && args.includes('get_app_container')) { + return { stdout: '/tmp/Example.app\n', stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') { + return { stdout: 'ExampleSimExec\n', stderr: '', exitCode: 0 }; + } + if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) { + return { + stdout: ['111 11.0 6144 ExampleSimExec', '222 2.0 2048 SpringBoard'].join('\n'), + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'perf', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('Expected perf response to succeed for iOS simulator session'); + const memory = (response.data?.metrics as any)?.memory; + const cpu = (response.data?.metrics as any)?.cpu; + expect(memory?.available).toBe(true); + expect(memory?.residentMemoryKb).toBe(6144); + expect(cpu?.available).toBe(true); + expect(cpu?.usagePercent).toBe(11); + expect(cpu?.matchedProcesses).toEqual(['ExampleSimExec']); +}); + +test('perf samples Apple cpu and memory metrics on physical iOS devices', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z')); + const sessionStore = makeSessionStore(); + const sessionName = 'perf-session-ios-device'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.device', + }); + let exportCount = 0; + mockRunCmd.mockImplementation(async (_cmd, args) => { + if ( + args[0] === 'devicectl' && + args[1] === 'device' && + args[2] === 'info' && + args[3] === 'apps' + ) { + const outputIndex = args.indexOf('--json-output'); + fs.writeFileSync( + args[outputIndex + 1]!, + JSON.stringify({ + result: { + apps: [ + { + bundleIdentifier: 'com.example.device', + name: 'Example Device App', + url: 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/', + }, + ], + }, + }), + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if ( + args[0] === 'devicectl' && + args[1] === 'device' && + args[2] === 'info' && + args[3] === 'processes' + ) { + const outputIndex = args.indexOf('--json-output'); + fs.writeFileSync( + args[outputIndex + 1]!, + JSON.stringify({ + result: { + runningProcesses: [ + { + executable: + 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/ExampleDeviceApp', + processIdentifier: 4001, + }, + ], + }, + }), + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args[0] === 'xctrace' && args[1] === 'record') { + vi.setSystemTime(new Date(Date.now() + 1000)); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (args[0] === 'xctrace' && args[1] === 'export') { + const outputIndex = args.indexOf('--output'); + exportCount += 1; + await fs.promises.writeFile( + args[outputIndex + 1]!, + [ + '', + '', + '', + '', + 'start', + 'process', + 'cpu-total', + 'memory-real', + 'pid', + '', + '', + '123', + '4001', + exportCount === 1 + ? '100000000' + : '350000000', + '8388608', + '4001', + '', + '', + '', + ].join(''), + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'perf', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('Expected perf response to succeed for physical iOS session'); + const memory = (response.data?.metrics as any)?.memory; + const cpu = (response.data?.metrics as any)?.cpu; + expect(memory?.available).toBe(true); + expect(memory?.residentMemoryKb).toBe(8192); + expect(cpu?.available).toBe(true); + expect(cpu?.usagePercent).toBe(25); + expect(cpu?.matchedProcesses).toEqual(['ExampleDeviceApp']); +}); + +test('perf reports physical iOS cpu and memory as unavailable without an app bundle id', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'perf-session-ios-device-no-bundle'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-2', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'perf', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) { + throw new Error('Expected perf response to succeed for physical iOS session without bundle id'); + } + const memory = (response.data?.metrics as any)?.memory; + const cpu = (response.data?.metrics as any)?.cpu; + expect(memory?.available).toBe(false); + expect(memory?.reason).toMatch(/no apple app bundle id is associated with this session/i); + expect(cpu?.available).toBe(false); + expect(cpu?.reason).toMatch(/no apple app bundle id is associated with this session/i); +}); diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts new file mode 100644 index 000000000..63ba4e696 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts @@ -0,0 +1,526 @@ +import { test, expect } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { AppError } from '../../../kernel/errors.ts'; +import { + mockResolveTargetDevice, + mockEnsureDeviceReady, + mockPrewarmAppleRunnerCache, + mockRunCmd, + mockShutdownSimulator, + mockEnsureAndroidEmulatorBooted, + makeSessionStore, + makeSession, + noopInvoke, +} from './session-test-harness.ts'; +import type { SessionState } from '../../types.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('boot requires session or explicit selector', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + } +}); + +test('boot prefers explicit device selector over active session device', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + ); + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-2', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'boot', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockEnsureDeviceReady).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sim-2' }), + expect.any(Object), + ); + const onColdBootStart = mockEnsureDeviceReady.mock.calls[0]?.[1]?.onIosSimulatorColdBootStart; + expect(onColdBootStart).toBeTypeOf('function'); + onColdBootStart?.(selectedDevice); + expect(mockPrewarmAppleRunnerCache).toHaveBeenCalledWith( + selectedDevice, + expect.objectContaining({ + logPath: expect.stringMatching(/daemon\.log$/), + }), + ); + if (response && response.ok) { + expect(response.data?.platform).toBe('ios'); + expect(response.data?.id).toBe('sim-2'); + } +}); + +test('boot --headless launches Android emulator when no running device matches', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); + const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; + mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { + launchCalls.push({ avdName, serial, headless }); + return { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + }); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android', device: 'Pixel_9_Pro_XL', headless: true }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: true }]); + expect(mockEnsureDeviceReady).toHaveBeenCalledWith( + expect.objectContaining({ id: 'emulator-5554' }), + ); + if (response && response.ok) { + expect(response.data?.platform).toBe('android'); + expect(response.data?.id).toBe('emulator-5554'); + expect(response.data?.device).toBe('Pixel_9_Pro_XL'); + } +}); + +test('boot launches Android emulator with GUI when no running device matches', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); + const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; + mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { + launchCalls.push({ avdName, serial, headless }); + return { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + }); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android', device: 'Pixel_9_Pro_XL' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); + if (response && response.ok) { + expect(response.data?.platform).toBe('android'); + expect(response.data?.id).toBe('emulator-5554'); + expect(response.data?.device).toBe('Pixel_9_Pro_XL'); + } +}); + +test('boot launches stopped Android emulator selected from inventory', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'android', + id: 'Pixel_9_Pro_XL', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: false, + }); + const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; + mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { + launchCalls.push({ avdName, serial, headless }); + return { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); + expect(mockEnsureDeviceReady).toHaveBeenCalledWith( + expect.objectContaining({ id: 'emulator-5554', booted: true }), + ); + if (response && response.ok) { + expect(response.data?.platform).toBe('android'); + expect(response.data?.id).toBe('emulator-5554'); + expect(response.data?.device).toBe('Pixel_9_Pro_XL'); + } +}); + +test('boot --headless requires avd selector when device cannot be resolved', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); + mockEnsureAndroidEmulatorBooted.mockRejectedValue(new Error('unexpected')); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android', serial: 'emulator-5554', headless: true }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + expect(mockEnsureAndroidEmulatorBooted).not.toHaveBeenCalled(); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/boot --headless requires --device /); + } +}); + +test('boot --headless rejects non-Android selectors', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro', headless: true }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + expect(mockEnsureAndroidEmulatorBooted).not.toHaveBeenCalled(); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/headless is supported only for Android emulators/i); + } +}); + +test('boot keeps --target validation when emulator is fallback-launched', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); + const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; + mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { + launchCalls.push({ avdName, serial, headless }); + return { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + }); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android', target: 'tv', device: 'Pixel_9_Pro_XL' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); + expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); + if (response && !response.ok) { + expect(response.error.code).toBe('DEVICE_NOT_FOUND'); + expect(response.error.message).toMatch(/matching --target tv/i); + } +}); + +test('shutdown turns off selected iOS simulator', async () => { + const sessionStore = makeSessionStore(); + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-2', + name: 'iPhone 17 Pro', + kind: 'simulator', + target: 'mobile', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'shutdown', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); + expect(mockShutdownSimulator).toHaveBeenCalledWith(selectedDevice); + if (response && response.ok) { + expect(response.data?.platform).toBe('ios'); + expect(response.data?.id).toBe('sim-2'); + expect(response.data?.shutdown).toEqual({ + success: true, + exitCode: 0, + stdout: '', + stderr: '', + }); + } +}); + +test('shutdown rejects active session device and points to close --shutdown', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-2', + name: 'iPhone 17 Pro', + kind: 'simulator', + target: 'mobile', + booted: true, + }; + sessionStore.set(sessionName, makeSession(sessionName, selectedDevice)); + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'shutdown', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + expect(mockShutdownSimulator).not.toHaveBeenCalled(); + if (response && !response.ok) { + expect(response.error.code).toBe('DEVICE_IN_USE'); + expect(response.error.message).toMatch(/close --shutdown/i); + expect(response.error.details?.hint).toBe( + 'Run agent-device close --shutdown --session default', + ); + } +}); + +test('shutdown turns off selected Android emulator', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'shutdown', + positionals: [], + flags: { platform: 'android', device: 'Pixel_9_Pro_XL' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); + expect(mockRunCmd).toHaveBeenCalledWith( + 'adb', + ['-s', 'emulator-5554', 'emu', 'kill'], + expect.objectContaining({ allowFailure: true, timeoutMs: 15_000 }), + ); + if (response && response.ok) { + expect(response.data?.platform).toBe('android'); + expect(response.data?.id).toBe('emulator-5554'); + expect(response.data?.shutdown).toEqual({ + success: true, + exitCode: 0, + stdout: '', + stderr: '', + }); + } +}); + +test('shutdown rejects unsupported physical devices', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'device-1', + name: 'iPhone', + kind: 'device', + target: 'mobile', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'shutdown', + positionals: [], + flags: { platform: 'ios', udid: 'device-1' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + expect(mockShutdownSimulator).not.toHaveBeenCalled(); + expect(mockRunCmd).not.toHaveBeenCalled(); + if (response && !response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toMatch(/Apple simulators and Android emulators/i); + } +}); + +test('shutdown returns an error response when selected target shutdown fails', async () => { + const sessionStore = makeSessionStore(); + const selectedDevice: SessionState['device'] = { + platform: 'apple', + id: 'sim-2', + name: 'iPhone 17 Pro', + kind: 'simulator', + target: 'mobile', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + mockShutdownSimulator.mockResolvedValue({ + success: false, + exitCode: 149, + stdout: '', + stderr: 'simctl shutdown failed', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'shutdown', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toBe('simctl shutdown failed'); + expect(response.error.details?.shutdown).toEqual({ + success: false, + exitCode: 149, + stdout: '', + stderr: 'simctl shutdown failed', + }); + } +}); diff --git a/src/daemon/handlers/__tests__/session-command-replay.test.ts b/src/daemon/handlers/__tests__/session-command-replay.test.ts new file mode 100644 index 000000000..15c1c8158 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-command-replay.test.ts @@ -0,0 +1,192 @@ +import { test, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { makeSessionStore } from './session-test-harness.ts'; +import { handleSessionCommands } from '../session.ts'; +import type { DaemonRequest } from '../../types.ts'; + +test('replay parses open --relaunch flag and replays open with relaunch semantics', async () => { + const sessionStore = makeSessionStore(); + const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-relaunch-')); + const replayPath = path.join(replayRoot, 'relaunch.ad'); + fs.writeFileSync(replayPath, 'open "Settings" --relaunch\n'); + + const invoked: DaemonRequest[] = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'replay', + positionals: [replayPath], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.replayed).toBe(1); + } + expect(invoked.length).toBe(1); + expect(invoked[0]?.command).toBe('open'); + expect(invoked[0]?.positionals).toEqual(['Settings']); + expect(invoked[0]?.flags?.relaunch).toBe(true); +}); + +test('replay parses runtime set flags and replays runtime command', async () => { + const sessionStore = makeSessionStore(); + const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-runtime-')); + const replayPath = path.join(replayRoot, 'runtime.ad'); + fs.writeFileSync( + replayPath, + 'runtime set --platform android --metro-host 10.0.0.10 --metro-port 8081 --launch-url "myapp://dev"\n', + ); + const invoked: DaemonRequest[] = []; + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'replay', + positionals: [replayPath], + flags: {}, + meta: { cwd: replayRoot }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (request) => { + invoked.push(request); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBe(true); + expect(invoked[0]?.command).toBe('runtime'); + expect(invoked[0]?.positionals).toEqual(['set']); + expect(invoked[0]?.flags).toEqual({ + platform: 'android', + metroHost: '10.0.0.10', + metroPort: 8081, + launchUrl: 'myapp://dev', + }); +}); + +test('replay parses inline open runtime flags and replays open with runtime payload', async () => { + const sessionStore = makeSessionStore(); + const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-open-runtime-')); + const replayPath = path.join(replayRoot, 'runtime-open.ad'); + fs.writeFileSync( + replayPath, + 'open "Demo" --relaunch --platform android --metro-host 10.0.0.10 --metro-port 8081 --launch-url "myapp://dev"\n', + ); + const invoked: DaemonRequest[] = []; + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'replay', + positionals: [replayPath], + flags: {}, + meta: { cwd: replayRoot }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (request) => { + invoked.push(request); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBe(true); + expect(invoked[0]?.command).toBe('open'); + expect(invoked[0]?.positionals).toEqual(['Demo']); + expect(invoked[0]?.flags).toEqual({ relaunch: true }); + expect(invoked[0]?.runtime).toEqual({ + platform: 'android', + metroHost: '10.0.0.10', + metroPort: 8081, + launchUrl: 'myapp://dev', + }); +}); + +test('replay resolves relative script path against request cwd', async () => { + const sessionStore = makeSessionStore(); + const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-cwd-')); + const replayDir = path.join(replayRoot, 'workflows'); + fs.mkdirSync(replayDir, { recursive: true }); + fs.writeFileSync(path.join(replayDir, 'flow.ad'), 'open "Settings"\n'); + + const invoked: DaemonRequest[] = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'replay', + positionals: ['workflows/flow.ad'], + flags: {}, + meta: { cwd: replayRoot }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(invoked.length).toBe(1); + expect(invoked[0]?.command).toBe('open'); + expect(invoked[0]?.positionals).toEqual(['Settings']); +}); + +test('replay inherits parent device selectors for each invoked step', async () => { + const sessionStore = makeSessionStore(); + const replayRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'agent-device-replay-parent-selectors-'), + ); + const replayPath = path.join(replayRoot, 'selectors.ad'); + fs.writeFileSync(replayPath, 'open "com.whoop.iphone"\n'); + + const invoked: DaemonRequest[] = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'replay', + positionals: [replayPath], + flags: { + platform: 'ios', + device: 'thymikee-iphone', + udid: '00008150-001849640CF8401C', + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(invoked.length).toBe(1); + expect(invoked[0]?.flags?.platform).toBe('ios'); + expect(invoked[0]?.flags?.device).toBe('thymikee-iphone'); + expect(invoked[0]?.flags?.udid).toBe('00008150-001849640CF8401C'); +}); diff --git a/src/daemon/handlers/__tests__/session-devices-batch-runtime.test.ts b/src/daemon/handlers/__tests__/session-devices-batch-runtime.test.ts new file mode 100644 index 000000000..8f616dd06 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-devices-batch-runtime.test.ts @@ -0,0 +1,611 @@ +import { test, expect, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { retainMaterializedPaths } from '../../materialized-path-registry.ts'; +import { + mockClearRuntimeHints, + mockCleanupRetainedMaterializedPaths, + mockListAndroidDevices, + mockListAppleDevices, + makeSessionStore, + makeSession, + noopInvoke, +} from './session-test-harness.ts'; +import type { DaemonRequest } from '../../types.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('devices filters Apple-family platform selectors', async () => { + const sessionStore = makeSessionStore(); + mockListAndroidDevices.mockResolvedValue([ + { + platform: 'android' as const, + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, + }, + ]); + mockListAppleDevices.mockResolvedValue([ + { + platform: 'apple' as const, + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator' as const, + target: 'mobile' as const, + booted: true, + }, + { + platform: 'apple', + appleOs: 'macos' as const, + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device' as const, + target: 'desktop' as const, + booted: true, + }, + ]); + const runDevices = async (flags: DaemonRequest['flags']) => + handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'devices', + positionals: [], + flags, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + const macosResponse = await runDevices({ platform: 'macos' }); + expect(macosResponse?.ok).toBeTruthy(); + if (macosResponse?.ok) { + const devices = macosResponse.data?.devices as Array<{ platform: string }> | undefined; + expect(devices?.map((device) => device.platform)).toEqual(['macos']); + } + + const iosResponse = await runDevices({ platform: 'ios' }); + expect(iosResponse?.ok).toBeTruthy(); + if (iosResponse?.ok) { + const devices = iosResponse.data?.devices as Array<{ platform: string }> | undefined; + expect(devices?.map((device) => device.platform)).toEqual(['ios']); + } + + const appleDesktopResponse = await runDevices({ platform: 'apple', target: 'desktop' }); + expect(appleDesktopResponse?.ok).toBeTruthy(); + if (appleDesktopResponse?.ok) { + const devices = appleDesktopResponse.data?.devices as Array<{ platform: string }> | undefined; + expect(devices?.map((device) => device.platform)).toEqual(['macos']); + } +}); + +test('devices surfaces appleOs additively while keeping platform the public leaf', async () => { + const sessionStore = makeSessionStore(); + mockListAndroidDevices.mockResolvedValue([]); + mockListAppleDevices.mockResolvedValue([ + { + platform: 'apple' as const, + id: 'sim-1', + name: 'iPad Pro 11-inch (M4)', + kind: 'simulator' as const, + target: 'mobile' as const, + appleOs: 'ipados' as const, + booted: true, + simulatorSetPath: '/tmp/agent-device-sim-set', + }, + ]); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'devices', + positionals: [], + flags: { platform: 'ios' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBeTruthy(); + if (response?.ok) { + const devices = response.data?.devices as Array> | undefined; + expect(devices).toHaveLength(1); + // appleOs is now surfaced additively (iPad -> ipados) ... + expect(devices?.[0]?.appleOs).toBe('ipados'); + // ... while `platform` stays the PUBLIC leaf (never the internal `apple`). + expect(devices?.[0]?.platform).toBe('ios'); + // The internal-only simulator set path is still stripped from the public shape. + expect(devices?.[0]).not.toHaveProperty('simulatorSetPath'); + expect(devices?.[0]?.id).toBe('sim-1'); + } +}); + +test('batch stops on first failing step with partial results', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'batch', + positionals: [], + flags: { + batchSteps: [ + { command: 'open', positionals: ['settings'] }, + { command: 'click', positionals: ['@e1'] }, + ], + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (stepReq) => { + if (stepReq.command === 'click') { + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'missing target', + hint: 'refresh selector', + diagnosticId: 'diag-step-2', + logPath: '/tmp/diag-step-2.ndjson', + }, + }; + } + return { ok: true, data: {} }; + }, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toMatch(/Batch failed at step 2/); + expect(response.error.details?.step).toBe(2); + expect(response.error.details?.executed).toBe(1); + expect(response.error.hint).toBe('refresh selector'); + expect(response.error.diagnosticId).toBe('diag-step-2'); + expect(response.error.logPath).toBe('/tmp/diag-step-2.ndjson'); + const partial = response.error.details?.partialResults; + expect(Array.isArray(partial)).toBeTruthy(); + expect((partial as unknown[]).length).toBe(1); + } +}); + +test('batch rejects nested replay and batch commands', async () => { + const sessionStore = makeSessionStore(); + const nestedReplay = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'batch', + positionals: [], + flags: { + batchSteps: [{ command: 'replay', positionals: ['./flow.ad'] }], + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(nestedReplay).toBeTruthy(); + expect(nestedReplay?.ok).toBe(false); + if (nestedReplay && !nestedReplay.ok) { + expect(nestedReplay.error.code).toBe('INVALID_ARGS'); + } + + const nestedBatch = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'batch', + positionals: [], + flags: { + batchSteps: [{ command: 'batch', positionals: [] }], + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(nestedBatch).toBeTruthy(); + expect(nestedBatch?.ok).toBe(false); + if (nestedBatch && !nestedBatch.ok) { + expect(nestedBatch.error.code).toBe('INVALID_ARGS'); + } +}); + +test('batch step flags override parent selector flags', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'batch', + positionals: [], + flags: { + platform: 'ios', + batchSteps: [ + { + command: 'open', + positionals: ['settings'], + flags: { platform: 'android' }, + }, + ], + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (stepReq) => { + expect(stepReq.flags?.platform).toBe('android'); + return { ok: true, data: {} }; + }, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); +}); + +test('batch step forwards typed runtime payload', async () => { + const sessionStore = makeSessionStore(); + const seenRuntimes: Array = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'batch', + positionals: [], + flags: { + batchSteps: [ + { + command: 'open', + positionals: ['Demo'], + flags: { platform: 'android' }, + runtime: { + metroHost: '10.0.0.10', + metroPort: 8081, + }, + }, + ], + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (stepReq) => { + seenRuntimes.push(stepReq.runtime); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBe(true); + expect(seenRuntimes).toEqual([ + { + metroHost: '10.0.0.10', + metroPort: 8081, + }, + ]); +}); + +test('batch step inherits parent runtime unless the step overrides it', async () => { + const sessionStore = makeSessionStore(); + const seenRuntimes: Array = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'batch', + positionals: [], + runtime: { + platform: 'android', + bundleUrl: 'https://bundle.example.test', + }, + flags: { + batchSteps: [ + { + command: 'open', + positionals: ['Demo'], + }, + { + command: 'open', + positionals: ['Demo'], + runtime: { + metroHost: '10.0.0.10', + metroPort: 8081, + }, + }, + ], + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (stepReq) => { + seenRuntimes.push(stepReq.runtime); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBe(true); + expect(seenRuntimes).toEqual([ + { + platform: 'android', + bundleUrl: 'https://bundle.example.test', + }, + { + metroHost: '10.0.0.10', + metroPort: 8081, + }, + ]); +}); + +test('batch step pins nested requests to the resolved session', async () => { + const sessionStore = makeSessionStore(); + const seenSessions: Array<{ session: string; flagSession: string | undefined }> = []; + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'batch', + positionals: [], + flags: { + batchSteps: [{ command: 'wait', positionals: ['100'] }], + }, + }, + sessionName: 'resolved-session', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (stepReq) => { + seenSessions.push({ + session: stepReq.session, + flagSession: stepReq.flags?.session, + }); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBe(true); + expect(seenSessions).toEqual([ + { + session: 'resolved-session', + flagSession: 'resolved-session', + }, + ]); +}); + +test('runtime set/show/clear manages session-scoped runtime hints before open', async () => { + const sessionStore = makeSessionStore(); + const baseRequest = { + token: 't', + session: 'remote-runtime', + } satisfies Pick; + + const setResponse = await handleSessionCommands({ + req: { + ...baseRequest, + command: 'runtime', + positionals: ['set'], + flags: { + platform: 'android', + metroHost: '10.0.0.10', + metroPort: 8081, + launchUrl: 'myapp://dev-client', + }, + }, + sessionName: 'remote-runtime', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(setResponse?.ok).toBe(true); + + const showResponse = await handleSessionCommands({ + req: { + ...baseRequest, + command: 'runtime', + positionals: ['show'], + flags: {}, + }, + sessionName: 'remote-runtime', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(showResponse?.ok).toBe(true); + if (showResponse && showResponse.ok) { + expect(showResponse.data?.configured).toBe(true); + expect(showResponse.data?.runtime).toEqual({ + platform: 'android', + metroHost: '10.0.0.10', + metroPort: 8081, + bundleUrl: undefined, + launchUrl: 'myapp://dev-client', + }); + } + + const clearResponse = await handleSessionCommands({ + req: { + ...baseRequest, + command: 'runtime', + positionals: ['clear'], + flags: {}, + }, + sessionName: 'remote-runtime', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(clearResponse?.ok).toBe(true); + expect(sessionStore.getRuntimeHints('remote-runtime')).toBe(undefined); +}); + +test('runtime clear removes applied transport hints for the active app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'runtime-clear-active'; + sessionStore.setRuntimeHints(sessionName, { + platform: 'android', + metroHost: '10.0.0.10', + metroPort: 8081, + }); + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.demo', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'runtime', + positionals: ['clear'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(mockClearRuntimeHints).toHaveBeenCalledWith( + expect.objectContaining({ + device: expect.objectContaining({ id: 'emulator-5554' }), + appId: 'com.example.demo', + }), + ); + expect(sessionStore.getRuntimeHints(sessionName)).toBe(undefined); +}); + +test('close clears applied runtime transport hints before deleting the session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'runtime-close-active'; + sessionStore.setRuntimeHints(sessionName, { + platform: 'ios', + metroHost: '127.0.0.1', + metroPort: 8081, + }); + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.demo', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(mockClearRuntimeHints).toHaveBeenCalled(); + expect(sessionStore.get(sessionName)).toBe(undefined); + expect(sessionStore.getRuntimeHints(sessionName)).toBe(undefined); +}); + +test('close clears retained materialized install paths bound to the session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'materialized-close-active'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + }); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-materialized-')); + const appPath = path.join(tempRoot, 'Sample.app'); + fs.mkdirSync(appPath, { recursive: true }); + fs.writeFileSync(path.join(appPath, 'Info.plist'), 'plist'); + const retained = await retainMaterializedPaths({ + installablePath: appPath, + sessionName, + ttlMs: 60_000, + }); + + // Use real cleanup implementation so retained paths are actually removed + const { cleanupRetainedMaterializedPathsForSession: realCleanup } = await vi.importActual< + typeof import('../../materialized-path-registry.ts') + >('../../materialized-path-registry.ts'); + mockCleanupRetainedMaterializedPaths.mockImplementation(realCleanup); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(sessionStore.get(sessionName)).toBe(undefined); + expect(fs.existsSync(retained.installablePath)).toBe(false); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('release_materialized_paths removes retained install artifacts', async () => { + const sessionStore = makeSessionStore(); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-release-materialized-')); + const appPath = path.join(tempRoot, 'Sample.app'); + fs.mkdirSync(appPath, { recursive: true }); + fs.writeFileSync(path.join(appPath, 'Info.plist'), 'plist'); + const retained = await retainMaterializedPaths({ + installablePath: appPath, + ttlMs: 60_000, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'release_materialized_paths', + positionals: [], + flags: {}, + meta: { + materializationId: retained.materializationId, + }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(fs.existsSync(retained.installablePath)).toBe(false); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); diff --git a/src/daemon/handlers/__tests__/session-logs.test.ts b/src/daemon/handlers/__tests__/session-logs.test.ts new file mode 100644 index 000000000..37b79e279 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-logs.test.ts @@ -0,0 +1,425 @@ +import { test, expect } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { AppError } from '../../../kernel/errors.ts'; +import { + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, +} from '../../app-log-ios.ts'; +import { + mockStartAppLog, + mockRunAppLogDoctor, + makeSessionStore, + makeSession, + noopInvoke, +} from './session-test-harness.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('logs requires an active session', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'logs', + positionals: ['path'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('SESSION_NOT_FOUND'); + } +}); + +test('logs rejects invalid action', async () => { + const sessionStore = makeSessionStore(); + sessionStore.set( + 'default', + makeSession('default', { + platform: 'apple', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }), + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'logs', + positionals: ['invalid'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/path, start, stop, doctor, mark, or clear/); + } +}); + +test('logs start requires app session (appBundleId)', async () => { + const sessionStore = makeSessionStore(); + sessionStore.set( + 'default', + makeSession('default', { + platform: 'apple', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }), + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'logs', + positionals: ['start'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/app session|open first/i); + } +}); + +test('logs stop requires active app log stream', async () => { + const sessionStore = makeSessionStore(); + sessionStore.set( + 'default', + makeSession('default', { + platform: 'apple', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }), + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'logs', + positionals: ['stop'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/no app log stream/i); + } +}); + +test('logs clear requires stream to be stopped first', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + appLog: { + platform: 'android', + backend: 'android', + outPath: '/tmp/app.log', + startedAt: Date.now(), + getState: () => 'active', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['clear'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/logs stop/i); + } +}); + +test('logs --restart is only supported with logs clear', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.app', + }); + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['path'], + flags: { restart: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/only supported with logs clear/i); + } +}); + +test('logs clear --restart requires app session bundle id', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, + }), + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['clear'], + flags: { restart: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/app session|open /i); + } +}); + +function makeIosDeviceLogSession(): { + sessionStore: ReturnType; + sessionName: string; +} { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-console-logs'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'ios', + id: '00008150-0000AAAA', + name: 'iPhone', + kind: 'device', + }), + appBundleId: 'com.example.app', + }); + return { sessionStore, sessionName }; +} + +function mockIosDeviceLogBackend(): void { + mockStartAppLog.mockResolvedValue({ + backend: 'ios-device', + startedAt: 1_712_040_000_000, + getState: () => 'active', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }); + mockRunAppLogDoctor.mockResolvedValue({ + checks: { devicectlAvailable: true, devicectlConsoleCapture: true }, + notes: [], + }); +} + +function mockUnsupportedIosDeviceLogBackend(): void { + mockStartAppLog.mockRejectedValue( + new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.message, { + backend: 'ios-device', + hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.hint, + }), + ); + mockRunAppLogDoctor.mockResolvedValue({ + checks: { devicectlAvailable: true, devicectlConsoleCapture: false }, + notes: [IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE], + }); +} + +async function runLogsCommandForSession( + sessionStore: ReturnType, + sessionName: string, + action: 'clear' | 'path' | 'doctor', + flags: Record = {}, +) { + return await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: [action], + flags, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); +} + +function expectActiveIosDeviceLogsPath( + response: Awaited>, +) { + expect(response?.ok).toBe(true); + if (!response || !response.ok) return; + expect(response.data?.active).toBe(true); + expect(response.data?.state).toBe('active'); + expect(response.data?.backend).toBe('ios-device'); + expect(response.data?.failureCode).toBeUndefined(); + expect(response.data?.failureMessage).toBeUndefined(); + expect(response.data?.startedAt).toBe('2024-04-02T06:40:00.000Z'); +} + +function expectEndedIosDeviceLogsPath(response: Awaited>) { + expect(response?.ok).toBe(true); + if (!response || !response.ok) return; + expect(response.data?.active).toBe(false); + expect(response.data?.state).toBe('ended'); + expect(response.data?.backend).toBe('ios-device'); + expect(response.data?.notes).toContain( + 'The app log stream process ended. Run logs clear --restart before the next capture window.', + ); +} + +function expectActiveIosDeviceLogsDoctor( + response: Awaited>, +) { + expect(response?.ok).toBe(true); + if (!response || !response.ok) return; + expect(response.data?.active).toBe(true); + expect(response.data?.state).toBe('active'); + expect(response.data?.backend).toBe('ios-device'); + expect(response.data?.checks).toEqual({ + devicectlAvailable: true, + devicectlConsoleCapture: true, + }); + expect(response.data?.notes).toEqual([]); +} + +function expectUnsupportedIosDeviceLogsDoctor( + response: Awaited>, +) { + expect(response?.ok).toBe(true); + if (!response || !response.ok) return; + expect(response.data?.active).toBe(false); + expect(response.data?.state).toBe('failed'); + expect(response.data?.backend).toBe('ios-device'); + expect(response.data?.failureCode).toBe('UNSUPPORTED_OPERATION'); + expect(response.data?.notes).toEqual([IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE]); +} + +test('logs clear --restart starts active iOS physical-device console capture', async () => { + const { sessionStore, sessionName } = makeIosDeviceLogSession(); + mockIosDeviceLogBackend(); + + const restartResponse = await runLogsCommandForSession(sessionStore, sessionName, 'clear', { + restart: true, + }); + expect(restartResponse?.ok).toBe(true); + if (restartResponse && restartResponse.ok) { + expect(restartResponse.data?.restarted).toBe(true); + } + expect(mockStartAppLog).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'apple', id: '00008150-0000AAAA' }), + 'com.example.app', + expect.stringContaining('app.log'), + expect.stringContaining('app-log.pid'), + ); + + expectActiveIosDeviceLogsPath(await runLogsCommandForSession(sessionStore, sessionName, 'path')); + expectActiveIosDeviceLogsDoctor( + await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), + ); +}); + +test('logs path reports cleanly ended iOS physical-device console capture as inactive', async () => { + const { sessionStore, sessionName } = makeIosDeviceLogSession(); + const session = sessionStore.get(sessionName); + if (!session) throw new Error('Expected test session'); + sessionStore.set(sessionName, { + ...session, + appLog: { + platform: 'apple', + backend: 'ios-device', + outPath: '/tmp/app.log', + startedAt: 1_712_040_000_000, + getState: () => 'ended', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + expectEndedIosDeviceLogsPath(await runLogsCommandForSession(sessionStore, sessionName, 'path')); +}); + +test('logs doctor deduplicates unsupported iOS physical-device console capture notes', async () => { + const { sessionStore, sessionName } = makeIosDeviceLogSession(); + mockUnsupportedIosDeviceLogBackend(); + + const restartResponse = await runLogsCommandForSession(sessionStore, sessionName, 'clear', { + restart: true, + }); + expect(restartResponse?.ok).toBe(false); + expectUnsupportedIosDeviceLogsDoctor( + await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), + ); +}); diff --git a/src/daemon/handlers/__tests__/session-network.test.ts b/src/daemon/handlers/__tests__/session-network.test.ts new file mode 100644 index 000000000..4320df564 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-network.test.ts @@ -0,0 +1,613 @@ +import { test, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { mockRunCmd, makeSessionStore, makeSession, noopInvoke } from './session-test-harness.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('network requires an active session', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'network', + positionals: ['dump'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('SESSION_NOT_FOUND'); + } +}); + +test('network dump adds a targeted note when the session app log stream is inactive', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-network-inactive'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + appLog: { + platform: 'android', + backend: 'android', + outPath: '/tmp/app.log', + startedAt: Date.now(), + getState: () => 'failed', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '10', 'summary'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.active).toBe(true); + expect(response.data?.state).toBe('failed'); + expect(response.data?.notes).toContain( + 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', + ); + } +}); + +test('network dump recovers Android entries from adb logcat when the session stream is inactive', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-network-recovery'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + appLog: { + platform: 'android', + backend: 'android', + outPath: '/tmp/app.log', + startedAt: Date.now(), + getState: () => 'failed', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { + return { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { + return { + stdout: + '04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + + '04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/documents status=200 duration=15032\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '10', 'summary'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.path).toContain('adb logcat recovery'); + expect(response.data?.state).toBe('failed'); + const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; + expect(entries.length).toBe(1); + const latest = entries[0] as Record; + expect(latest.method).toBe('POST'); + expect(latest.url).toBe('https://api.example.com/v1/documents'); + expect(latest.status).toBe(200); + expect(response.data?.notes).toContain( + 'Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set 4321.', + ); + } +}); + +test('network dump merges Android recovery entries ahead of stale session log traffic', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-network-merge'; + const appLogPath = sessionStore.resolveAppLogPath(sessionName); + fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); + fs.writeFileSync( + appLogPath, + '2026-04-01T09:59:00Z GET https://api.example.com/v1/stale status=200\n', + 'utf8', + ); + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + appLog: { + platform: 'android', + backend: 'android', + outPath: appLogPath, + startedAt: Date.now(), + getState: () => 'failed', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { + return { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { + return { + stdout: + '04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + + '04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/fresh status=201 duration=15032\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '10', 'summary'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; + expect(entries.length).toBe(2); + expect((entries[0] as Record).url).toBe('https://api.example.com/v1/fresh'); + expect((entries[1] as Record).url).toBe('https://api.example.com/v1/stale'); + } +}); + +test('network dump recovers Android entries from previous package pid in bounded logcat window', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-network-previous-pid'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + appLog: { + platform: 'android', + backend: 'android', + outPath: '/tmp/app.log', + startedAt: Date.now(), + getState: () => 'failed', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { + return { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { + return { + stdout: + '04-01 10:00:00.000 I/ActivityManager( 9999): Process com.example.app (pid 1234) has died\n' + + '04-01 10:00:00.500 D/GIBSDK (1234): POST https://api.example.com/v1/submit status=504 duration=15000\n' + + '04-01 10:00:01.000 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '10', 'summary'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; + expect(entries.length).toBe(1); + expect((entries[0] as Record).url).toBe('https://api.example.com/v1/submit'); + expect(response.data?.notes).toContain( + 'Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set 4321, 1234.', + ); + } +}); + +test('network dump recovers Android entries when an active stream is still bound to a prior pid', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-network-stale-active-pid'; + const appLogPath = sessionStore.resolveAppLogPath(sessionName); + const appLogPidPath = sessionStore.resolveAppLogPidPath(sessionName); + fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); + fs.writeFileSync( + appLogPath, + '2026-04-01T09:59:00Z GET https://api.example.com/v1/stale status=200\n', + 'utf8', + ); + fs.writeFileSync( + appLogPidPath, + `${JSON.stringify({ + pid: 9999, + startTime: 'Tue Apr 1 09:59:00 2026', + command: 'adb -s emulator-5554 logcat -v time --pid 1234', + })}\n`, + 'utf8', + ); + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + appLog: { + platform: 'android', + backend: 'android', + outPath: appLogPath, + startedAt: Date.now(), + getState: () => 'active', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { + return { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { + return { + stdout: + '04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + + '04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/fresh status=201 duration=15032\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '10', 'summary'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.path).toContain('adb logcat recovery'); + expect(response.data?.state).toBe('active'); + const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; + expect(entries.length).toBe(2); + expect((entries[0] as Record).url).toBe('https://api.example.com/v1/fresh'); + expect((entries[1] as Record).url).toBe('https://api.example.com/v1/stale'); + expect(response.data?.notes).toContain( + 'Session app log stream was still bound to prior Android PID 1234. Recovered recent Android HTTP entries from adb logcat for PID set 4321.', + ); + } +}); + +test('network dump recovers iOS simulator entries from simctl log show when the live stream is empty', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-network-recovery'; + const appLogPath = sessionStore.resolveAppLogPath(sessionName); + fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); + fs.writeFileSync( + appLogPath, + 'Filtering the log data using "subsystem == \\"com.agentdevice.tester\\""\n', + 'utf8', + ); + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.agentdevice.tester', + appLog: { + platform: 'apple', + backend: 'ios-simulator', + outPath: appLogPath, + startedAt: 1_712_040_000_000, + getState: () => 'active', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + mockRunCmd.mockImplementation(async (_cmd, args) => { + if ( + args[0] === 'simctl' && + args[1] === 'spawn' && + args[2] === 'sim-1' && + args[3] === 'log' && + args[4] === 'show' + ) { + return { + stdout: + 'Timestamp Ty Process[PID:TID]\n' + + '2026-04-02 08:08:50.665 I Agent Device Tester[32193:8c7411e] POST https://api.example.com/v1/search statusCode=200 duration=42\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '10', 'summary'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.path).toContain('simctl log show recovery'); + const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; + expect(entries.length).toBe(1); + expect((entries[0] as Record).url).toBe('https://api.example.com/v1/search'); + expect((entries[0] as Record).status).toBe(200); + expect((entries[0] as Record).durationMs).toBe(42); + expect(response.data?.notes).toContain( + 'Recovered 1 iOS simulator HTTP entry from simctl log show (1 app log lines scanned).', + ); + } +}); + +test('network dump explains when iOS simulator recovery found app logs but no HTTP-shaped entries', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-network-no-http'; + const appLogPath = sessionStore.resolveAppLogPath(sessionName); + fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); + fs.writeFileSync( + appLogPath, + 'Filtering the log data using "subsystem == \\"com.agentdevice.tester\\""\n', + 'utf8', + ); + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.agentdevice.tester', + appLog: { + platform: 'apple', + backend: 'ios-simulator', + outPath: appLogPath, + startedAt: 1_712_040_000_000, + getState: () => 'active', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }); + + mockRunCmd.mockImplementation(async (_cmd, args) => { + if ( + args[0] === 'simctl' && + args[1] === 'spawn' && + args[2] === 'sim-1' && + args[3] === 'log' && + args[4] === 'show' + ) { + return { + stdout: + 'Timestamp Ty Process[PID:TID]\n' + + '2026-04-02 08:08:50.665 E Agent Device Tester[32193:8c7411e] Airship config warning\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '10', 'summary'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(Array.isArray(response.data?.entries) ? response.data.entries : []).toHaveLength(0); + expect(response.data?.notes).toContain( + 'Recovered 1 recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.', + ); + expect(response.data?.notes).toContain( + 'No HTTP(s) entries were found in recent iOS simulator app logs. If the app only emits non-HTTP diagnostics, inspect logs path or add app-side URLSession/network logging for per-request timing and payload details.', + ); + } +}); + +test('network dump supports macOS desktop sessions', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-network'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + appBundleId: 'com.apple.systempreferences', + }); + const appLogPath = sessionStore.resolveAppLogPath(sessionName); + fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); + fs.writeFileSync( + appLogPath, + '2026-02-24T10:00:00Z GET https://example.com/mac status=204', + 'utf8', + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '10', 'summary'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.backend).toBe('macos'); + const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; + expect(entries.length).toBe(1); + expect((entries[0] as Record).url).toBe('https://example.com/mac'); + } +}); + +test('network dump validates include mode and limit', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, + }), + ); + + const invalidLimit = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '0'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(invalidLimit).toBeTruthy(); + expect(invalidLimit?.ok).toBe(false); + if (invalidLimit && !invalidLimit.ok) { + expect(invalidLimit.error.code).toBe('INVALID_ARGS'); + expect(invalidLimit.error.message).toMatch(/1\.\.200/); + } + + const invalidMode = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump', '10', 'verbose'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(invalidMode).toBeTruthy(); + expect(invalidMode?.ok).toBe(false); + if (invalidMode && !invalidMode.ok) { + expect(invalidMode.error.code).toBe('INVALID_ARGS'); + expect(invalidMode.error.message).toMatch(/summary, headers, body, all/); + } +}); diff --git a/src/daemon/handlers/__tests__/session-open-existing.test.ts b/src/daemon/handlers/__tests__/session-open-existing.test.ts new file mode 100644 index 000000000..03cccab2f --- /dev/null +++ b/src/daemon/handlers/__tests__/session-open-existing.test.ts @@ -0,0 +1,452 @@ +import { test, expect } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { buildSnapshotSignatures } from '../../android-snapshot-freshness.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { + mockDispatch, + mockResolveTargetDevice, + mockResolveAndroidPackage, + mockRunCmd, + makeSessionStore, + makeSession, + noopInvoke, + assertInvalidArgsMessage, + withMockedPlatform, +} from './session-test-harness.ts'; +import type { SessionState } from '../../types.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('open web URL on iOS device session without active app falls back to Safari', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-session'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + ); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['https://example.com/path'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.apple.mobilesafari'); + expect(updated?.appName).toBe('https://example.com/path'); + expect(dispatchedContext?.appBundleId).toBe('com.apple.mobilesafari'); +}); + +test('open app and URL on existing iOS device session keeps app context', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.previous', + appName: 'Previous App', + }); + + let dispatchedPositionals: string[] | undefined; + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, positionals, _out, context) => { + dispatchedPositionals = positionals; + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['Settings', 'myapp://screen/to'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.apple.Preferences'); + expect(updated?.appName).toBe('Settings'); + expect(dispatchedPositionals).toEqual(['Settings', 'myapp://screen/to']); + expect(dispatchedContext?.appBundleId).toBe('com.apple.Preferences'); +}); + +test('open app on existing macOS session resolves and stores bundle id', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-mac', + name: 'Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + appBundleId: 'com.example.old', + appName: 'Old App', + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['settings'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.apple.systempreferences'); + expect(updated?.appName).toBe('settings'); + expect(dispatchedContext?.appBundleId).toBe('com.apple.systempreferences'); +}); + +test('open rejects --surface on non-macOS devices', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'ios-surface', + command: 'open', + positionals: ['Notes'], + flags: { + platform: 'ios', + surface: 'frontmost-app', + }, + }, + sessionName: 'ios-surface', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage(response, 'surface is only supported on macOS and Linux'); +}); + +test('open on existing macOS frontmost-app session preserves surface without --surface flag', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-frontmost-existing'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + surface: 'frontmost-app', + appBundleId: 'com.apple.TextEdit', + appName: 'TextEdit', + }); + + const prevHelper = process.env.AGENT_DEVICE_MACOS_HELPER_BIN; + process.env.AGENT_DEVICE_MACOS_HELPER_BIN = '/usr/bin/true'; + mockRunCmd.mockResolvedValue({ + stdout: '{"ok":true,"data":{"bundleId":"com.apple.TextEdit","appName":"TextEdit","pid":123}}', + stderr: '', + exitCode: 0, + }); + mockDispatch.mockImplementation(async (_device, _command, positionals) => { + expect(positionals).toEqual([]); + return {}; + }); + + try { + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { + platform: 'macos', + }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + const session = sessionStore.get(sessionName); + expect(session?.surface).toBe('frontmost-app'); + expect(session?.appBundleId).toBe('com.apple.TextEdit'); + expect(session?.appName).toBe('TextEdit'); + if (response && response.ok) { + expect(response.data?.surface).toBe('frontmost-app'); + } + } finally { + if (prevHelper === undefined) delete process.env.AGENT_DEVICE_MACOS_HELPER_BIN; + else process.env.AGENT_DEVICE_MACOS_HELPER_BIN = prevHelper; + } +}); + +test('open on existing iOS session refreshes unavailable simulator by name', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'stale-sim', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: false, + }), + appBundleId: 'com.example.old', + appName: 'Old App', + }); + + const resolvedDevice: SessionState['device'] = { + platform: 'apple', + id: 'fresh-sim', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + const selectors: Array> = []; + let dispatchedDeviceId: string | undefined; + + mockResolveTargetDevice.mockImplementation(async (selector) => { + selectors.push({ ...selector }); + if ((selector as any).udid === 'stale-sim') { + throw new AppError('DEVICE_NOT_FOUND', 'not found'); + } + return resolvedDevice; + }); + mockDispatch.mockImplementation(async (device) => { + dispatchedDeviceId = device.id; + return {}; + }); + + const response = await withMockedPlatform('darwin', async () => + handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['settings'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(selectors.length).toBe(2); + expect(selectors[0]).toEqual({ platform: 'ios', target: undefined, udid: 'stale-sim' }); + expect(selectors[1]).toEqual({ platform: 'ios', target: undefined, device: 'iPhone 17 Pro' }); + expect(dispatchedDeviceId).toBe('fresh-sim'); + const updated = sessionStore.get(sessionName); + expect(updated?.device.id).toBe('fresh-sim'); + if (response && response.ok) { + expect(response.data?.device_udid).toBe('fresh-sim'); + } +}); + +test('open app on existing Android session resolves and stores package id', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appName: 'Old App', + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + mockResolveAndroidPackage.mockResolvedValue('org.reactjs.native.example.RNCLI83'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['RNCLI83'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('org.reactjs.native.example.RNCLI83'); + expect(updated?.appName).toBe('RNCLI83'); + expect(dispatchedContext?.appBundleId).toBe('org.reactjs.native.example.RNCLI83'); +}); + +test('open intent target on existing Android session clears stale package context', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.old', + appName: 'Old App', + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + mockResolveAndroidPackage.mockResolvedValue(undefined); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['settings'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe(undefined); + expect(updated?.appName).toBe('settings'); + expect(dispatchedContext?.appBundleId).toBe(undefined); +}); + +test('open on existing Android session preserves a comparable freshness baseline', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-open-freshness'; + const baselineNodes = Array.from({ length: 14 }, (_, index) => ({ + ref: `e${index + 1}`, + index, + depth: 0, + type: 'android.widget.TextView', + label: `Inbox row ${index + 1}`, + })); + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.old', + appName: 'Old App', + snapshot: { + nodes: baselineNodes, + createdAt: Date.now(), + backend: 'android', + comparisonSafe: true, + }, + }); + + mockDispatch.mockResolvedValue({}); + mockResolveAndroidPackage.mockResolvedValue('com.android.settings'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['settings'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.snapshot).toBeUndefined(); + expect(updated?.androidSnapshotFreshness).toEqual({ + action: 'open', + markedAt: expect.any(Number), + baselineCount: baselineNodes.length, + baselineSignatures: buildSnapshotSignatures(baselineNodes), + routeComparable: true, + }); +}); diff --git a/src/daemon/handlers/__tests__/session-open-url-prewarm.test.ts b/src/daemon/handlers/__tests__/session-open-url-prewarm.test.ts new file mode 100644 index 000000000..ca35fa05f --- /dev/null +++ b/src/daemon/handlers/__tests__/session-open-url-prewarm.test.ts @@ -0,0 +1,724 @@ +import { test, expect, vi } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { AppError } from '../../../kernel/errors.ts'; +import { + mockDispatch, + mockResolveTargetDevice, + mockEnsureDeviceReady, + mockPrewarmIosRunnerSession, + mockPrewarmAppleRunnerCache, + mockPrepareIosRunner, + mockResolveIosApp, + mockResolveIosSimulatorDeepLinkBundleId, + makeSessionStore, + makeSession, + noopInvoke, +} from './session-test-harness.ts'; +import type { SessionState } from '../../types.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('open URL on existing iOS session clears stale app bundle id', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.old', + appName: 'Old App', + }); + + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }); + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['https://example.com/path'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe(undefined); + expect(updated?.appName).toBe('https://example.com/path'); + expect(dispatchedContext?.appBundleId).toBe(undefined); +}); + +test('open URL on existing macOS session clears stale app bundle id', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-mac', + name: 'Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + appBundleId: 'com.example.old', + appName: 'Old App', + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['https://example.com/path'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe(undefined); + expect(updated?.appName).toBe('https://example.com/path'); + expect(dispatchedContext?.appBundleId).toBe(undefined); +}); + +test('open URL on existing iOS device session preserves app bundle id context', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.app', + appName: 'Example App', + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['myapp://item/42'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.example.app'); + expect(updated?.appName).toBe('myapp://item/42'); + expect(dispatchedContext?.appBundleId).toBe('com.example.app'); +}); + +test('open custom URL on existing iOS simulator session preserves app bundle id context', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.app', + appName: 'Example App', + }); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['myapp://item/42'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockEnsureDeviceReady.mock.calls[0]?.[1]).toEqual({ + deviceHub: false, + onIosSimulatorColdBootStart: undefined, + }); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.example.app'); + expect(updated?.appName).toBe('myapp://item/42'); + expect(dispatchedContext?.appBundleId).toBe('com.example.app'); +}); + +test('open custom URL on fresh iOS simulator session infers app bundle id from URL scheme', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-url-session'; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue('org.reactnavigation.playground'); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['rne://navigator-layout'], + flags: { platform: 'ios', udid: 'sim-1' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockResolveIosSimulatorDeepLinkBundleId).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sim-1', kind: 'simulator' }), + 'rne://navigator-layout', + ); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('org.reactnavigation.playground'); + expect(updated?.appName).toBe('rne://navigator-layout'); + expect(dispatchedContext?.appBundleId).toBe('org.reactnavigation.playground'); + expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); +}); + +test('open iOS simulator app prewarms runner cache during cold boot', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-cold-boot-cache-prewarm'; + const device: SessionState['device'] = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: false, + }; + mockResolveTargetDevice.mockResolvedValue(device); + mockResolveIosApp.mockResolvedValueOnce('com.example.app'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['Demo'], + flags: { platform: 'ios', udid: 'sim-1' }, + meta: { requestId: 'open-request' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const onColdBootStart = mockEnsureDeviceReady.mock.calls[0]?.[1]?.onIosSimulatorColdBootStart; + expect(onColdBootStart).toBeTypeOf('function'); + onColdBootStart?.(device); + expect(mockPrewarmAppleRunnerCache).toHaveBeenCalledWith( + device, + expect.objectContaining({ + logPath: expect.stringMatching(/daemon\.log$/), + requestId: 'open-request', + }), + ); + expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); +}); + +test('open iOS app session prewarms runner session when app bundle id is known', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.previous', + appName: 'Previous App', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['Settings', 'myapp://screen/to'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); + expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'apple', id: 'ios-device-1' }), + expect.objectContaining({ logPath: expect.stringMatching(/daemon\.log$/) }), + ); +}); + +test('open iOS Maestro app link waits for runner prewarm before launching app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-maestro-open-link'; + const events: string[] = []; + let finishPrewarm: (() => void) | undefined; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.previous', + appName: 'Previous App', + }); + + mockPrewarmIosRunnerSession.mockImplementation( + () => + new Promise((resolve) => { + events.push('prewarm-start'); + finishPrewarm = () => { + events.push('prewarm-finish'); + resolve(); + }; + }), + ); + mockDispatch.mockImplementation(async (_device, command) => { + events.push(`dispatch:${command}`); + return {}; + }); + + const responsePromise = handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['com.example.app', 'rne://screen-layout'], + flags: { + maestro: { prewarmRunnerBeforeOpen: true }, + }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + await vi.waitFor(() => expect(events).toEqual(['prewarm-start'])); + + finishPrewarm?.(); + const response = await responsePromise; + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(events).toEqual(['prewarm-start', 'prewarm-finish', 'dispatch:open']); + expect((response as any).data?.timing).toMatchObject({ + runnerPrewarmKind: 'session', + runnerPrewarmScheduled: true, + runnerPrewarmWaited: true, + }); +}); + +test('open iOS Maestro app link reports blocking runner prewarm failures before launching app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-maestro-open-link-prewarm-failed'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.previous', + appName: 'Previous App', + }); + mockPrewarmIosRunnerSession.mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'Developer mode is disabled for Apple development tools', { + hint: 'Run `sudo DevToolsSecurity -enable`.', + }), + ); + + await expect( + handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['com.example.app', 'rne://screen-layout'], + flags: { + maestro: { prewarmRunnerBeforeOpen: true }, + }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: 'Developer mode is disabled for Apple development tools', + details: { + hint: expect.stringContaining('DevToolsSecurity -enable'), + }, + }); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'apple', id: 'ios-device-1' }), + expect.objectContaining({ propagateError: true }), + ); +}); + +test('open iOS URL without app bundle id skips runner prewarm', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-session'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + ); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['myapp://screen/to'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled(); +}); + +test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'prepare-ios-runner'; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'ios', udid: 'sim-1', timeoutMs: 240000 }, + meta: { requestId: 'prepare-request' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockEnsureDeviceReady).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'apple', id: 'sim-1' }), + ); + expect(mockPrepareIosRunner).toHaveBeenCalledTimes(1); + expect(mockPrepareIosRunner).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'apple', id: 'sim-1' }), + expect.objectContaining({ + cleanStaleBundles: true, + buildTimeoutMs: 240000, + healthTimeoutMs: 240000, + logPath: expect.stringMatching(/daemon\.log$/), + prepareDeadline: expect.objectContaining({ + elapsedMs: expect.any(Function), + isExpired: expect.any(Function), + remainingMs: expect.any(Function), + }), + requestId: 'prepare-request', + startupTimeoutMs: 240000, + }), + ); + expect((response as any).data).toMatchObject({ + action: 'ios-runner', + platform: 'ios', + deviceId: 'sim-1', + deviceName: 'iPhone 17 Pro', + kind: 'simulator', + connectMs: 3, + healthCheckMs: 3, + runner: { currentUptimeMs: 42 }, + message: 'Prepared Apple runner: iPhone 17 Pro', + }); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('prepare ios-runner explains overlapping timing fields with additive parts', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'prepare-ios-runner-timing'; + const dateNow = vi.spyOn(Date, 'now'); + try { + dateNow.mockReturnValueOnce(1_000).mockReturnValueOnce(28_337); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + mockPrepareIosRunner.mockResolvedValueOnce({ + runner: { currentUptimeMs: 42 }, + buildMs: 10_642, + connectMs: 12_635, + healthCheckMs: 14_702, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'ios', udid: 'sim-1' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + const data = (response as any).data; + expect(data).toMatchObject({ + durationMs: 27_337, + buildMs: 10_642, + connectMs: 12_635, + healthCheckMs: 14_702, + timing: { + totalMs: 27_337, + additiveParts: { + buildMs: 10_642, + connectAfterBuildMs: 1_993, + healthCheckMs: 14_702, + }, + containment: { + connectMs: ['buildMs'], + healthCheckMs: [], + }, + }, + }); + expect(String(data.timing.note)).toMatch(/top-level prepare timing fields.*may overlap/i); + const additiveParts = data.timing.additiveParts as Record; + const additiveTotalMs = Object.values(additiveParts).reduce((sum, value) => sum + value, 0); + expect(additiveTotalMs).toBeLessThanOrEqual(data.timing.totalMs); + expect(data.buildMs + data.connectMs + data.healthCheckMs).toBeGreaterThan(data.durationMs); + } finally { + dateNow.mockRestore(); + } +}); + +test('prepare ios-runner starts the XCTest runner on an explicit macOS selector', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'prepare-macos-runner'; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'macos', timeoutMs: 240000 }, + meta: { requestId: 'prepare-macos-request' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockPrepareIosRunner).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'apple', id: 'host-macos-local' }), + expect.objectContaining({ + buildTimeoutMs: 240000, + healthTimeoutMs: 240000, + prepareDeadline: expect.objectContaining({ + elapsedMs: expect.any(Function), + isExpired: expect.any(Function), + remainingMs: expect.any(Function), + }), + requestId: 'prepare-macos-request', + }), + ); + expect((response as any).data).toMatchObject({ + action: 'ios-runner', + platform: 'macos', + deviceId: 'host-macos-local', + deviceName: 'Host Mac', + kind: 'device', + message: 'Prepared Apple runner: Host Mac', + }); +}); + +test('prepare ios-runner rejects non-Apple runner devices', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'android', + id: 'emulator-5554', + name: 'Pixel 9 Pro XL', + kind: 'emulator', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'prepare-android', + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'android', serial: 'emulator-5554' }, + }, + sessionName: 'prepare-android', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toBe( + 'prepare ios-runner is only supported on Apple runner platforms', + ); + } + expect(mockPrepareIosRunner).not.toHaveBeenCalled(); +}); + +test('prepare requires the ios-runner subcommand', async () => { + const sessionStore = makeSessionStore(); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'prepare-invalid', + command: 'prepare', + positionals: [], + flags: { platform: 'ios' }, + }, + sessionName: 'prepare-invalid', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toBe('prepare requires a subcommand: ios-runner'); + } + expect(mockResolveTargetDevice).not.toHaveBeenCalled(); + expect(mockPrepareIosRunner).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts new file mode 100644 index 000000000..11f37a6d2 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -0,0 +1,761 @@ +import { test, expect, vi } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { LeaseRegistry } from '../../lease-registry.ts'; +import { + mockDispatch, + mockResolveTargetDevice, + mockPrewarmIosRunnerSession, + mockStopIosRunner, + mockScheduleIosRunnerIdleStop, + mockDismissMacOsAlert, + mockSettleSimulator, + makeSessionStore, + makeSession, + noopInvoke, +} from './session-test-harness.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('open --relaunch closes and reopens active session app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: Array<{ command: string; positionals: string[] }> = []; + mockDispatch.mockImplementation(async (_device, command, positionals) => { + calls.push({ command, positionals }); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls.length).toBe(2); + expect(calls[0]).toEqual({ command: 'close', positionals: ['com.example.app'] }); + expect(calls[1]).toEqual({ command: 'open', positionals: ['com.example.app'] }); +}); + +test('open --relaunch on iOS stops runner before close/open', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'My iPhone', + kind: 'device', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'ios-device-1', + name: 'My iPhone', + kind: 'device', + booted: true, + }); + mockStopIosRunner.mockImplementation(async () => { + calls.push('stop-runner'); + }); + mockDispatch.mockImplementation(async (_device, command, positionals) => { + calls.push(`${command}:${positionals.join(' ')}`); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']); +}); + +test('open --relaunch on iOS simulator collapses into one terminate-running open dispatch', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + mockStopIosRunner.mockImplementation(async () => { + calls.push('stop-runner'); + }); + let openContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { + calls.push(`${command}:${positionals.join(' ')}`); + if (command === 'open') openContext = context as Record; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual(['open:com.example.app']); + expect(openContext?.terminateRunningApp).toBe(true); +}); + +test('open --relaunch on iOS simulator keeps close-first ordering', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-url-relaunch-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + let openContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { + calls.push(`${command}:${positionals.join(' ')}`); + if (command === 'open') openContext = context as Record; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['com.example.app', 'https://example.com/deal'], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + // The URL dispatch path cannot carry the terminate, so the relaunch keeps + // the explicit close-then-open sequence. + expect(calls).toEqual(['close:com.example.app', 'open:com.example.app https://example.com/deal']); + expect(openContext?.terminateRunningApp).toBeUndefined(); +}); + +test('open --relaunch --clear-app-state on iOS simulator keeps close-first ordering', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-clear-state-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + let openContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { + calls.push(`${command}:${positionals.join(' ')}`); + if (command === 'open') openContext = context as Record; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true, clearAppState: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual(['close:com.example.app', 'open:com.example.app']); + expect(openContext?.terminateRunningApp).toBeUndefined(); +}); + +test('open --relaunch includes timing and waits for iOS runner prewarm after opening app', async () => { + vi.useFakeTimers({ now: 1_000 }); + const sessionStore = makeSessionStore(); + const sessionName = 'ios-timing-session'; + const events: string[] = []; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'My iPhone', + kind: 'device', + booted: true, + }), + appName: 'Example', + appBundleId: 'com.example.app', + }); + + mockPrewarmIosRunnerSession.mockImplementation( + () => + new Promise((resolve) => { + events.push('prewarm-start'); + setTimeout(() => { + events.push('prewarm-finish'); + resolve(); + }, 250); + }), + ); + mockStopIosRunner.mockImplementation(async () => { + events.push('stop-runner'); + }); + mockDispatch.mockImplementation(async (_device, command) => { + events.push(`dispatch:${command}`); + return {}; + }); + + const responsePromise = handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + await vi.advanceTimersByTimeAsync(250); + const response = await responsePromise; + + expect(response?.ok).toBe(true); + expect(events).toEqual([ + 'stop-runner', + 'dispatch:close', + 'dispatch:open', + 'prewarm-start', + 'prewarm-finish', + ]); + expect((response as any).data?.timing).toMatchObject({ + runnerPrewarmKind: 'session', + runnerPrewarmScheduled: true, + runnerPrewarmWaited: true, + runnerPrewarmDurationMs: 250, + }); + expect((response as any).data?.timing?.totalDurationMs).toBeGreaterThanOrEqual(250); +}); + +test('open --relaunch on iOS without existing session closes then opens target app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-new-session'; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'ios-device-1', + name: 'My iPhone', + kind: 'device', + booted: true, + }); + + const calls: string[] = []; + mockStopIosRunner.mockImplementation(async () => { + calls.push('stop-runner'); + }); + mockDispatch.mockImplementation(async (_device, command, positionals) => { + calls.push(`${command}:${positionals.join(' ')}`); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['com.example.app'], + flags: { relaunch: true, platform: 'ios' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']); +}); + +test('open --relaunch on iOS simulator settles once after the collapsed open', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-sim-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 16', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 16', + kind: 'simulator', + booted: true, + }); + const settleCalls: Array<{ deviceId: string; delayMs: number }> = []; + mockSettleSimulator.mockImplementation(async (device, delayMs) => { + settleCalls.push({ deviceId: device.id, delayMs }); + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + // Collapsed simulator relaunch skips the post-close settle: one settle after open. + expect(settleCalls).toEqual([{ deviceId: 'sim-1', delayMs: 300 }]); +}); + +test('close on macOS session stops runner and dismisses automation alert before delete', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + appBundleId: 'com.apple.systempreferences', + appName: 'System Settings', + }); + + const calls: string[] = []; + mockStopIosRunner.mockImplementation(async (deviceId) => { + calls.push(`stop-runner:${deviceId}`); + }); + mockDismissMacOsAlert.mockImplementation(async (action, options) => { + calls.push( + `dismiss-alert:${action}:${(options as any)?.bundleId ?? (options as any)?.surface ?? 'frontmost'}`, + ); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual([ + 'stop-runner:host-macos-local', + 'dismiss-alert:dismiss:com.apple.systempreferences', + ]); + expect(sessionStore.get(sessionName)).toBe(undefined); +}); + +test('close on iOS simulator session retains runner and deletes the session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockStopIosRunner).not.toHaveBeenCalled(); + expect(mockScheduleIosRunnerIdleStop).toHaveBeenCalledWith('sim-1'); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('close on iOS simulator with scoped simulator set stops runner before deleting session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-scoped-simulator-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + simulatorSetPath: '/tmp/tenant-a/simulator-set', + }), + appName: 'com.example.app', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockStopIosRunner).toHaveBeenCalledWith('sim-1'); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('close on leased iOS simulator session stops runner before deleting session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-leased-simulator-session'; + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-simulator', + deviceKey: 'ios:sim-1', + clientId: 'client-a', + }); + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + expiresAt: lease.expiresAt, + }, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + leaseRegistry, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockStopIosRunner).toHaveBeenCalledWith('sim-1'); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('close --shutdown on iOS simulator stops runner before deleting session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-shutdown-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: { shutdown: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockStopIosRunner).toHaveBeenCalledWith('sim-1'); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('close on iOS stops runner before app close dispatch and performs final idempotent stop', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-close-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'My iPhone', + kind: 'device', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockStopIosRunner.mockImplementation(async () => { + calls.push('stop-runner'); + }); + mockDispatch.mockImplementation(async (_device, command, positionals) => { + calls.push(`${command}:${positionals.join(' ')}`); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: ['com.example.app'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'stop-runner']); +}); + +test('close on iOS simulator retains runner while terminating app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-close-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockStopIosRunner.mockImplementation(async () => { + calls.push('stop-runner'); + }); + mockDispatch.mockImplementation(async (_device, command, positionals) => { + calls.push(`${command}:${positionals.join(' ')}`); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: ['com.example.app'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual(['close:com.example.app']); +}); + +test('close on macOS stops runner before app close dispatch and dismisses automation alert', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-close-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + appBundleId: 'com.apple.systempreferences', + appName: 'System Settings', + }); + + const calls: string[] = []; + mockStopIosRunner.mockImplementation(async (deviceId) => { + calls.push(`stop-runner:${deviceId}`); + }); + mockDismissMacOsAlert.mockImplementation(async (action, options) => { + calls.push( + `dismiss-alert:${action}:${(options as any)?.bundleId ?? (options as any)?.surface ?? 'frontmost'}`, + ); + return {}; + }); + mockDispatch.mockImplementation(async (_device, command, positionals) => { + calls.push(`${command}:${positionals.join(' ')}`); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: ['System Settings'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual([ + 'stop-runner:host-macos-local', + 'dismiss-alert:dismiss:com.apple.systempreferences', + 'close:System Settings', + 'stop-runner:host-macos-local', + 'dismiss-alert:dismiss:com.apple.systempreferences', + ]); +}); diff --git a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts new file mode 100644 index 000000000..697d49ca7 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts @@ -0,0 +1,293 @@ +import { test, expect, vi } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + mockResolveTargetDevice, + mockEnsureDeviceReady, + makeSessionStore, + makeSession, + noopInvoke, + assertInvalidArgsMessage, +} from './session-test-harness.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('open --relaunch rejects URL targets', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['https://example.com/path'], + flags: { relaunch: true }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/does not support URL targets/i); + } +}); + +test('open --relaunch fails without app when no session exists', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'open', + positionals: [], + flags: { relaunch: true }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/requires an app argument/i); + } +}); + +test('open --relaunch rejects Android app binary paths', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['/tmp/app-debug.apk'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', + ); +}); + +test('open --relaunch rejects bare Android app binary filenames', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['app-debug.apk'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Android runtime hints require an installed package name, not "app-debug.apk". Install or reinstall the app first, then relaunch by package.', + ); +}); + +test('open --relaunch rejects Android app binary paths for active sessions', async () => { + const sessionStore = makeSessionStore(); + const session = makeSession('default', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }); + session.appName = 'com.example.app'; + session.appBundleId = 'com.example.app'; + sessionStore.set('default', session); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['/tmp/app-debug.apk'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', + ); +}); + +test('open --relaunch rejects Android app binary paths for active sessions before device refresh', async () => { + const sessionStore = makeSessionStore(); + const session = makeSession('default', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }); + session.appName = 'com.example.app'; + session.appBundleId = 'com.example.app'; + sessionStore.set('default', session); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['/tmp/app-debug.apk'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', + ); +}); + +test('open --relaunch rejects Android app binary paths before resolving a new device', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['/tmp/app-debug.apk'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', + ); +}); + +test('open on in-use device returns DEVICE_IN_USE before readiness checks', async () => { + const sessionStore = makeSessionStore(); + sessionStore.set( + 'busy-session', + makeSession('busy-session', { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + ); + + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['settings'], + flags: { platform: 'ios' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('DEVICE_IN_USE'); + expect(response.error.details?.hint).toContain('agent-device session list'); + expect(response.error.details?.hint).toContain('--session busy-session'); + expect(response.error.details?.hint).toContain('agent-device close --session busy-session'); + } + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); +}); + +test('open on device owned by recording session returns recording recovery hint', async () => { + const sessionStore = makeSessionStore(); + const recordingSession = makeSession('default', { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }); + recordingSession.recordOnlySession = true; + recordingSession.recording = { + platform: 'ios', + child: { kill: vi.fn(), pid: 123 }, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + outPath: '/tmp/recording.mp4', + startedAt: Date.now(), + showTouches: false, + gestureEvents: [], + }; + sessionStore.set('default', recordingSession); + + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'test-attempt', + command: 'open', + positionals: ['settings'], + flags: { platform: 'ios' }, + }, + sessionName: 'test-attempt', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('DEVICE_IN_USE'); + expect(response.error.details?.hint).toContain('Recording session "default" owns this device'); + expect(response.error.details?.hint).toContain('agent-device record stop --session default'); + expect(response.error.details?.hint).toContain('agent-device close --session default'); + expect(response.error.details?.hint).toContain('agent-device session list'); + } + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/handlers/__tests__/session-test-harness.ts b/src/daemon/handlers/__tests__/session-test-harness.ts new file mode 100644 index 000000000..86b303c70 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-test-harness.ts @@ -0,0 +1,294 @@ +import { isMacOs } from '../../../kernel/device.ts'; +import { expect, vi, beforeEach } from 'vitest'; +export { test } from 'vitest'; +export { expect, vi }; + +vi.mock('../../../core/dispatch.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; +}); +vi.mock('../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../../runtime-hints.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + applyRuntimeHintsToApp: vi.fn(async () => {}), + clearRuntimeHintsFromApp: vi.fn(async () => {}), + }; +}); +vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + prepareIosRunner: vi.fn(async () => ({ + runner: { currentUptimeMs: 42 }, + connectMs: 3, + healthCheckMs: 3, + })), + prewarmAppleRunnerCache: vi.fn(), + prewarmIosRunnerSession: vi.fn(), + scheduleIosRunnerIdleStop: vi.fn(), + stopIosRunnerSession: vi.fn(async () => {}), + }; +}); +vi.mock('../../../platforms/apple/os/macos/helper.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) }; +}); +vi.mock('../session-device-utils.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, settleIosSimulator: vi.fn(async () => {}) }; +}); +vi.mock('../session-open-target.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; +}); +vi.mock('../../../platforms/apple/core/simulator.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, getSimulatorState: vi.fn(async () => null), shutdownSimulator: vi.fn() }; +}); +vi.mock('../../../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) }; +}); +vi.mock('../../materialized-path-registry.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, cleanupRetainedMaterializedPathsForSession: vi.fn(async () => {}) }; +}); +vi.mock('../../../platforms/android/devices.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listAndroidDevices: vi.fn(async () => []), + ensureAndroidEmulatorBooted: vi.fn(), + }; +}); +vi.mock('../../../platforms/apple/core/devices.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, listAppleDevices: vi.fn(async () => []) }; +}); +vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listIosApps: vi.fn(async () => []), + resolveIosApp: vi.fn(async () => undefined), + resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), + }; +}); +vi.mock('../../app-log.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runAppLogDoctor: vi.fn(async () => ({ checks: {}, notes: [] })), + startAppLog: vi.fn(), + stopAppLog: vi.fn(async () => {}), + }; +}); +vi.mock('../session-deploy.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + defaultInstallOps: { ios: vi.fn(), android: vi.fn() }, + defaultReinstallOps: { ios: vi.fn(), android: vi.fn() }, + }; +}); + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { buildSnapshotSignatures } from '../../android-snapshot-freshness.ts'; +import { + retainMaterializedPaths, + cleanupRetainedMaterializedPathsForSession, +} from '../../materialized-path-registry.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; +import { SessionStore } from '../../session-store.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, +} from '../../app-log-ios.ts'; +import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; +import { ensureDeviceReady } from '../../device-ready.ts'; +import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; +import { + prepareIosRunner, + prewarmAppleRunnerCache, + prewarmIosRunnerSession, + scheduleIosRunnerIdleStop, + stopIosRunnerSession, +} from '../../../platforms/apple/core/runner/runner-client.ts'; +import { runMacOsAlertAction } from '../../../platforms/apple/os/macos/helper.ts'; +import { settleIosSimulator } from '../session-device-utils.ts'; +import { resolveAndroidPackageForOpen } from '../session-open-target.ts'; +import { runCmd } from '../../../utils/exec.ts'; +import { shutdownSimulator } from '../../../platforms/apple/core/simulator.ts'; +import { + listAndroidDevices, + ensureAndroidEmulatorBooted, +} from '../../../platforms/android/devices.ts'; +import { listAppleDevices } from '../../../platforms/apple/core/devices.ts'; +import { + resolveIosApp, + resolveIosSimulatorDeepLinkBundleId, +} from '../../../platforms/apple/core/apps.ts'; +import { runAppLogDoctor, startAppLog, stopAppLog } from '../../app-log.ts'; +import { defaultInstallOps, defaultReinstallOps } from '../session-deploy.ts'; +import { clearRequestCanceled, markRequestCanceled } from '../../request-cancel.ts'; + +export { + fs, + os, + path, + buildSnapshotSignatures, + retainMaterializedPaths, + LeaseRegistry, + SessionStore, + AppError, + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, + IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, + clearRequestCanceled, + markRequestCanceled, +}; + +export const mockDispatch = vi.mocked(dispatchCommand); +export const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); +export const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); +export const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp); +export const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp); +export const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); +export const mockPrewarmAppleRunnerCache = vi.mocked(prewarmAppleRunnerCache); +export const mockPrepareIosRunner = vi.mocked(prepareIosRunner); +export const mockStopIosRunner = vi.mocked(stopIosRunnerSession); +export const mockScheduleIosRunnerIdleStop = vi.mocked(scheduleIosRunnerIdleStop); +export const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); +export const mockSettleSimulator = vi.mocked(settleIosSimulator); +export const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); +export const mockCleanupRetainedMaterializedPaths = vi.mocked( + cleanupRetainedMaterializedPathsForSession, +); +export const mockRunCmd = vi.mocked(runCmd); +export const mockShutdownSimulator = vi.mocked(shutdownSimulator); +export const mockListAndroidDevices = vi.mocked(listAndroidDevices); +export const mockListAppleDevices = vi.mocked(listAppleDevices); +export const mockResolveIosApp = vi.mocked(resolveIosApp); +export const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked( + resolveIosSimulatorDeepLinkBundleId, +); +export const mockEnsureAndroidEmulatorBooted = vi.mocked(ensureAndroidEmulatorBooted); +export const mockStartAppLog = vi.mocked(startAppLog); +export const mockStopAppLog = vi.mocked(stopAppLog); +export const mockRunAppLogDoctor = vi.mocked(runAppLogDoctor); +export const mockDefaultInstallOpsIos = vi.mocked(defaultInstallOps.ios); +export const mockDefaultInstallOpsAndroid = vi.mocked(defaultInstallOps.android); +export const mockDefaultReinstallOpsIos = vi.mocked(defaultReinstallOps.ios); +export const mockDefaultReinstallOpsAndroid = vi.mocked(defaultReinstallOps.android); + +beforeEach(() => { + vi.useRealTimers(); + mockDispatch.mockReset(); + mockDispatch.mockResolvedValue({}); + mockResolveTargetDevice.mockReset(); + mockEnsureDeviceReady.mockReset(); + mockEnsureDeviceReady.mockResolvedValue(undefined); + mockApplyRuntimeHints.mockReset(); + mockApplyRuntimeHints.mockResolvedValue(undefined); + mockClearRuntimeHints.mockReset(); + mockClearRuntimeHints.mockResolvedValue(undefined); + mockPrewarmIosRunnerSession.mockReset(); + mockPrewarmAppleRunnerCache.mockReset(); + mockPrepareIosRunner.mockReset(); + mockPrepareIosRunner.mockResolvedValue({ + runner: { currentUptimeMs: 42 }, + connectMs: 3, + healthCheckMs: 3, + }); + mockStopIosRunner.mockReset(); + mockScheduleIosRunnerIdleStop.mockReset(); + mockStopIosRunner.mockResolvedValue(undefined); + mockDismissMacOsAlert.mockReset(); + mockDismissMacOsAlert.mockResolvedValue({} as any); + mockSettleSimulator.mockReset(); + mockSettleSimulator.mockResolvedValue(undefined); + mockResolveAndroidPackage.mockReset(); + mockResolveAndroidPackage.mockResolvedValue(undefined); + mockCleanupRetainedMaterializedPaths.mockReset(); + mockCleanupRetainedMaterializedPaths.mockResolvedValue(undefined); + mockRunCmd.mockReset(); + mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + mockShutdownSimulator.mockReset(); + mockShutdownSimulator.mockResolvedValue({ success: true, exitCode: 0, stdout: '', stderr: '' }); + mockListAndroidDevices.mockReset(); + mockListAndroidDevices.mockResolvedValue([]); + mockListAppleDevices.mockReset(); + mockListAppleDevices.mockResolvedValue([]); + mockResolveIosApp.mockReset(); + mockResolveIosApp.mockImplementation(async (device, app) => { + const normalizedApp = app.toLowerCase(); + if (normalizedApp === 'settings') { + return isMacOs(device) ? 'com.apple.systempreferences' : 'com.apple.Preferences'; + } + if (normalizedApp === 'menubarapp') { + return 'com.example.menubarapp'; + } + return app.includes('.') ? app : `com.example.${normalizedApp}`; + }); + mockResolveIosSimulatorDeepLinkBundleId.mockReset(); + mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue(undefined); + mockEnsureAndroidEmulatorBooted.mockReset(); + mockStartAppLog.mockReset(); + mockStopAppLog.mockReset(); + mockStopAppLog.mockResolvedValue(undefined); + mockRunAppLogDoctor.mockReset(); + mockRunAppLogDoctor.mockResolvedValue({ checks: {}, notes: [] }); + mockDefaultInstallOpsIos.mockReset(); + mockDefaultInstallOpsAndroid.mockReset(); + mockDefaultReinstallOpsIos.mockReset(); + mockDefaultReinstallOpsAndroid.mockReset(); +}); + +export function makeSessionStore(): SessionStore { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-handler-')); + return new SessionStore(path.join(root, 'sessions')); +} + +export function makeSession(name: string, device: SessionState['device']): SessionState { + return { + name, + device, + createdAt: Date.now(), + actions: [], + }; +} + +export const noopInvoke = async (_req: DaemonRequest): Promise => ({ + ok: true, + data: {}, +}); + +export function assertInvalidArgsMessage(response: DaemonResponse | null, message: string): void { + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toBe(message); + } +} + +export async function withMockedPlatform( + platform: NodeJS.Platform, + fn: () => Promise, +): Promise { + const original = process.platform; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + try { + return await fn(); + } finally { + Object.defineProperty(process, 'platform', { value: original, configurable: true }); + } +} diff --git a/src/daemon/handlers/__tests__/session-test-runner.test.ts b/src/daemon/handlers/__tests__/session-test-runner.test.ts new file mode 100644 index 000000000..caf6865ac --- /dev/null +++ b/src/daemon/handlers/__tests__/session-test-runner.test.ts @@ -0,0 +1,397 @@ +import { test, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { clearRequestCanceled, markRequestCanceled } from '../../request-cancel.ts'; +import { + mockResolveTargetDevice, + makeSessionStore, + makeSession, + noopInvoke, + assertInvalidArgsMessage, +} from './session-test-harness.ts'; +import type { DaemonRequest } from '../../types.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('session_list includes device_udid and ios_simulator_device_set for iOS sessions', async () => { + const sessionStore = makeSessionStore(); + sessionStore.set( + 'ios-scoped', + makeSession('ios-scoped', { + platform: 'apple', + id: 'DEF-456', + name: 'iPhone 16', + kind: 'simulator', + booted: true, + simulatorSetPath: '/tmp/tenant-a/simulators', + }), + ); + sessionStore.set( + 'android-1', + makeSession('android-1', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + ); + sessionStore.set( + 'macos-1', + makeSession('macos-1', { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + ); + + const response = await handleSessionCommands({ + req: { token: 't', session: 'default', command: 'session_list', positionals: [] }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + if (response && response.ok) { + const sessions = response.data?.sessions as Array>; + expect(Array.isArray(sessions)).toBeTruthy(); + const iosScoped = sessions.find((s) => s.name === 'ios-scoped'); + expect(iosScoped?.device_udid).toBe('DEF-456'); + expect(iosScoped?.ios_simulator_device_set).toBe('/tmp/tenant-a/simulators'); + const android = sessions.find((s) => s.name === 'android-1'); + const macos = sessions.find((s) => s.name === 'macos-1'); + expect(android?.device_udid).toBe(undefined); + expect(android?.ios_simulator_device_set).toBe(undefined); + expect(android?.device_id).toBe('emulator-5554'); + expect(macos?.device_id).toBe('host-macos-local'); + expect(macos?.device_udid).toBe(undefined); + expect(macos?.ios_simulator_device_set).toBe(undefined); + } +}); + +test('test filters replay scripts by context platform and skips untyped files', async () => { + const sessionStore = makeSessionStore(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-filter-')); + fs.writeFileSync(path.join(root, '01-android.ad'), 'context platform=android\nopen "Demo"\n'); + fs.writeFileSync(path.join(root, '02-ios.ad'), 'context platform=ios\nopen "Settings"\n'); + fs.writeFileSync(path.join(root, '03-untyped.ad'), 'open "Calculator"\n'); + + const invoked: DaemonRequest[] = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'test', + positionals: [root], + flags: { platform: 'android' }, + meta: { cwd: root, requestId: 'suite-filter' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBeTruthy(); + expect(invoked.length).toBe(1); + expect(invoked[0]?.flags?.platform).toBe('android'); + expect(invoked[0]?.session).toBe('default:test:suite-filter:1-01-android:attempt-1'); + if (response?.ok) { + expect(response.data?.passed).toBe(1); + expect(response.data?.failed).toBe(0); + expect(response.data?.skipped).toBe(1); + const tests = response.data?.tests as Array> | undefined; + expect(tests?.length).toBe(2); + expect(tests?.[0]?.status).toBe('passed'); + expect(tests?.[1]?.status).toBe('skipped'); + expect(tests?.[1]?.reason).toBe('skipped-by-filter'); + } +}); + +test('test binds each replay script to its declared platform metadata', async () => { + const sessionStore = makeSessionStore(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-platforms-')); + fs.writeFileSync(path.join(root, '01-android.ad'), 'context platform=android\nopen "Demo"\n'); + fs.writeFileSync(path.join(root, '02-ios.ad'), 'context platform=ios\nopen "Settings"\n'); + + const invoked: DaemonRequest[] = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'test', + positionals: [root], + meta: { cwd: root, requestId: 'suite-platforms' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBeTruthy(); + expect(invoked.map((req) => req.flags?.platform)).toEqual(['android', 'ios']); + expect(invoked.map((req) => req.session)).toEqual([ + 'default:test:suite-platforms:1-01-android:attempt-1', + 'default:test:suite-platforms:2-02-ios:attempt-1', + ]); + if (response?.ok) { + expect(response.data?.passed).toBe(2); + expect(response.data?.failed).toBe(0); + expect(response.data?.skipped).toBe(0); + } +}); + +test('test cleans up suite-owned sessions after each executed script', async () => { + const sessionStore = makeSessionStore(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-cleanup-')); + fs.writeFileSync(path.join(root, '01-android.ad'), 'context platform=android\nopen "Demo"\n'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'test', + positionals: [root], + meta: { cwd: root, requestId: 'suite-cleanup' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (req) => { + sessionStore.set( + req.session, + makeSession(req.session, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + ); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBeTruthy(); + expect(sessionStore.get('default:test:suite-cleanup:1-01-android:attempt-1')).toBe(undefined); +}); + +test('test retries failed scripts with fresh suite-owned sessions', async () => { + const sessionStore = makeSessionStore(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-retries-')); + fs.writeFileSync( + path.join(root, '01-retry.ad'), + 'context platform=android retries=9\nopen "Demo"\n', + ); + + const invoked: DaemonRequest[] = []; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'test', + positionals: [root], + meta: { cwd: root, requestId: 'suite-retries' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (req) => { + invoked.push(req); + if (invoked.length < 4) { + return { + ok: false, + error: { + code: 'ASSERTION_FAILED', + message: 'expected selector to exist', + }, + }; + } + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBeTruthy(); + expect(invoked.map((req) => req.session)).toEqual([ + 'default:test:suite-retries:1-01-retry:attempt-1', + 'default:test:suite-retries:1-01-retry:attempt-2', + 'default:test:suite-retries:1-01-retry:attempt-3', + 'default:test:suite-retries:1-01-retry:attempt-4', + ]); + if (response?.ok) { + expect(response.data?.passed).toBe(1); + expect(response.data?.failed).toBe(0); + const tests = response.data?.tests as Array> | undefined; + expect(tests?.[0]?.status).toBe('passed'); + expect(tests?.[0]?.attempts).toBe(4); + } +}); + +test('test applies per-script timeout and writes attempt artifacts', async () => { + const sessionStore = makeSessionStore(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-timeout-')); + const screenshotPath = path.join(root, 'capture.png'); + fs.writeFileSync(screenshotPath, 'screenshot'); + fs.writeFileSync( + path.join(root, '01-timeout.ad'), + 'context platform=android timeout=10\nscreenshot "./capture.png"\nopen "Demo"\n', + ); + + let invocationCount = 0; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'test', + positionals: [root], + meta: { cwd: root, requestId: 'suite-timeout' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (_req) => { + invocationCount += 1; + if (invocationCount === 1) { + return { ok: true, data: { path: screenshotPath } }; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + return { ok: true, data: {} }; + }, + }); + + expect(response?.ok).toBeTruthy(); + if (response?.ok) { + expect(response.data?.failed).toBe(1); + const tests = response.data?.tests as Array> | undefined; + expect(tests?.[0]?.status).toBe('failed'); + expect(tests?.[0]?.attempts).toBe(1); + const artifactsDir = tests?.[0]?.artifactsDir; + expect(typeof artifactsDir).toBe('string'); + const attemptDir = path.join(artifactsDir as string, 'attempt-1'); + expect(fs.existsSync(path.join(attemptDir, 'replay.ad'))).toBe(true); + expect(fs.existsSync(path.join(attemptDir, 'capture.png'))).toBe(true); + expect(fs.existsSync(path.join(attemptDir, 'replay-timing.ndjson'))).toBe(true); + expect(fs.existsSync(path.join(attemptDir, 'result.txt'))).toBe(true); + expect(fs.existsSync(path.join(attemptDir, 'failure.txt'))).toBe(true); + const timingLines = fs + .readFileSync(path.join(attemptDir, 'replay-timing.ndjson'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect(timingLines.some((line) => line.type === 'replay_test_attempt_start')).toBe(true); + expect(timingLines.some((line) => line.type === 'replay_action_start')).toBe(true); + expect( + timingLines.some( + (line) => line.type === 'replay_test_attempt_stop' && line.timedOut === true, + ), + ).toBe(true); + const resultText = fs.readFileSync(path.join(attemptDir, 'result.txt'), 'utf8'); + expect(resultText).toMatch(/status: failed/); + expect(resultText).toMatch(/timeoutMode: cooperative/); + } +}); + +test('open does not retain a session when the request was canceled before completion', async () => { + const sessionStore = makeSessionStore(); + const requestId = 'open-canceled-before-store'; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + target: 'mobile', + booted: true, + } as any); + + markRequestCanceled(requestId); + try { + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['com.apple.Preferences'], + flags: { platform: 'ios' }, + meta: { requestId }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toBe('request canceled'); + } + expect(sessionStore.get('default')).toBeUndefined(); + } finally { + clearRequestCanceled(requestId); + } +}); + +test('test returns invalid args when no replay scripts match the platform filter', async () => { + const sessionStore = makeSessionStore(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-empty-filter-')); + fs.writeFileSync(path.join(root, '01-ios.ad'), 'context platform=ios\nopen "Settings"\n'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'test', + positionals: [root], + flags: { platform: 'android' }, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage(response, 'No replay tests matched for --platform android.'); +}); + +test('test rejects duplicate replay test metadata in the context header', async () => { + const sessionStore = makeSessionStore(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-metadata-')); + fs.writeFileSync( + path.join(root, '01-invalid.ad'), + 'context platform=ios timeout=1000\ncontext timeout=2000\nopen "Demo"\n', + ); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'test', + positionals: [root], + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Conflicting replay test metadata "timeoutMs" in context header: 1000 vs 2000.', + ); +}); diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts deleted file mode 100644 index a76a1e035..000000000 --- a/src/daemon/handlers/__tests__/session.test.ts +++ /dev/null @@ -1,5838 +0,0 @@ -import { isMacOs } from '../../../kernel/device.ts'; -import { test, expect, vi, beforeEach } from 'vitest'; - -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; -}); -vi.mock('../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -vi.mock('../../runtime-hints.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - applyRuntimeHintsToApp: vi.fn(async () => {}), - clearRuntimeHintsFromApp: vi.fn(async () => {}), - }; -}); -vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - prepareIosRunner: vi.fn(async () => ({ - runner: { currentUptimeMs: 42 }, - connectMs: 3, - healthCheckMs: 3, - })), - prewarmAppleRunnerCache: vi.fn(), - prewarmIosRunnerSession: vi.fn(), - scheduleIosRunnerIdleStop: vi.fn(), - stopIosRunnerSession: vi.fn(async () => {}), - }; -}); -vi.mock('../../../platforms/apple/os/macos/helper.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) }; -}); -vi.mock('../session-device-utils.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, settleIosSimulator: vi.fn(async () => {}) }; -}); -vi.mock('../session-open-target.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); -vi.mock('../../../platforms/apple/core/simulator.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, getSimulatorState: vi.fn(async () => null), shutdownSimulator: vi.fn() }; -}); -vi.mock('../../../utils/exec.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) }; -}); -vi.mock('../../materialized-path-registry.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, cleanupRetainedMaterializedPathsForSession: vi.fn(async () => {}) }; -}); -vi.mock('../../../platforms/android/devices.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - listAndroidDevices: vi.fn(async () => []), - ensureAndroidEmulatorBooted: vi.fn(), - }; -}); -vi.mock('../../../platforms/apple/core/devices.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, listAppleDevices: vi.fn(async () => []) }; -}); -vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - listIosApps: vi.fn(async () => []), - resolveIosApp: vi.fn(async () => undefined), - resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), - }; -}); -vi.mock('../../app-log.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - runAppLogDoctor: vi.fn(async () => ({ checks: {}, notes: [] })), - startAppLog: vi.fn(), - stopAppLog: vi.fn(async () => {}), - }; -}); -vi.mock('../session-deploy.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - defaultInstallOps: { ios: vi.fn(), android: vi.fn() }, - defaultReinstallOps: { ios: vi.fn(), android: vi.fn() }, - }; -}); - -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { handleSessionCommands } from '../session.ts'; -import { buildSnapshotSignatures } from '../../android-snapshot-freshness.ts'; -import { - retainMaterializedPaths, - cleanupRetainedMaterializedPathsForSession, -} from '../../materialized-path-registry.ts'; -import { LeaseRegistry } from '../../lease-registry.ts'; -import { SessionStore } from '../../session-store.ts'; -import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; -import { AppError } from '../../../kernel/errors.ts'; -import { - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, -} from '../../app-log-ios.ts'; -import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; -import { ensureDeviceReady } from '../../device-ready.ts'; -import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; -import { - prepareIosRunner, - prewarmAppleRunnerCache, - prewarmIosRunnerSession, - scheduleIosRunnerIdleStop, - stopIosRunnerSession, -} from '../../../platforms/apple/core/runner/runner-client.ts'; -import { runMacOsAlertAction } from '../../../platforms/apple/os/macos/helper.ts'; -import { settleIosSimulator } from '../session-device-utils.ts'; -import { resolveAndroidPackageForOpen } from '../session-open-target.ts'; -import { runCmd } from '../../../utils/exec.ts'; -import { shutdownSimulator } from '../../../platforms/apple/core/simulator.ts'; -import { - listAndroidDevices, - ensureAndroidEmulatorBooted, -} from '../../../platforms/android/devices.ts'; -import { listAppleDevices } from '../../../platforms/apple/core/devices.ts'; -import { - resolveIosApp, - resolveIosSimulatorDeepLinkBundleId, -} from '../../../platforms/apple/core/apps.ts'; -import { runAppLogDoctor, startAppLog, stopAppLog } from '../../app-log.ts'; -import { defaultInstallOps, defaultReinstallOps } from '../session-deploy.ts'; -import { clearRequestCanceled, markRequestCanceled } from '../../request-cancel.ts'; - -const mockDispatch = vi.mocked(dispatchCommand); -const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); -const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp); -const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp); -const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); -const mockPrewarmAppleRunnerCache = vi.mocked(prewarmAppleRunnerCache); -const mockPrepareIosRunner = vi.mocked(prepareIosRunner); -const mockStopIosRunner = vi.mocked(stopIosRunnerSession); -const mockScheduleIosRunnerIdleStop = vi.mocked(scheduleIosRunnerIdleStop); -const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); -const mockSettleSimulator = vi.mocked(settleIosSimulator); -const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); -const mockCleanupRetainedMaterializedPaths = vi.mocked(cleanupRetainedMaterializedPathsForSession); -const mockRunCmd = vi.mocked(runCmd); -const mockShutdownSimulator = vi.mocked(shutdownSimulator); -const mockListAndroidDevices = vi.mocked(listAndroidDevices); -const mockListAppleDevices = vi.mocked(listAppleDevices); -const mockResolveIosApp = vi.mocked(resolveIosApp); -const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked(resolveIosSimulatorDeepLinkBundleId); -const mockEnsureAndroidEmulatorBooted = vi.mocked(ensureAndroidEmulatorBooted); -const mockStartAppLog = vi.mocked(startAppLog); -const mockStopAppLog = vi.mocked(stopAppLog); -const mockRunAppLogDoctor = vi.mocked(runAppLogDoctor); -const mockDefaultInstallOpsIos = vi.mocked(defaultInstallOps.ios); -const mockDefaultInstallOpsAndroid = vi.mocked(defaultInstallOps.android); -const mockDefaultReinstallOpsIos = vi.mocked(defaultReinstallOps.ios); -const mockDefaultReinstallOpsAndroid = vi.mocked(defaultReinstallOps.android); - -beforeEach(() => { - vi.useRealTimers(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); - mockResolveTargetDevice.mockReset(); - mockEnsureDeviceReady.mockReset(); - mockEnsureDeviceReady.mockResolvedValue(undefined); - mockApplyRuntimeHints.mockReset(); - mockApplyRuntimeHints.mockResolvedValue(undefined); - mockClearRuntimeHints.mockReset(); - mockClearRuntimeHints.mockResolvedValue(undefined); - mockPrewarmIosRunnerSession.mockReset(); - mockPrewarmAppleRunnerCache.mockReset(); - mockPrepareIosRunner.mockReset(); - mockPrepareIosRunner.mockResolvedValue({ - runner: { currentUptimeMs: 42 }, - connectMs: 3, - healthCheckMs: 3, - }); - mockStopIosRunner.mockReset(); - mockScheduleIosRunnerIdleStop.mockReset(); - mockStopIosRunner.mockResolvedValue(undefined); - mockDismissMacOsAlert.mockReset(); - mockDismissMacOsAlert.mockResolvedValue({} as any); - mockSettleSimulator.mockReset(); - mockSettleSimulator.mockResolvedValue(undefined); - mockResolveAndroidPackage.mockReset(); - mockResolveAndroidPackage.mockResolvedValue(undefined); - mockCleanupRetainedMaterializedPaths.mockReset(); - mockCleanupRetainedMaterializedPaths.mockResolvedValue(undefined); - mockRunCmd.mockReset(); - mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); - mockShutdownSimulator.mockReset(); - mockShutdownSimulator.mockResolvedValue({ success: true, exitCode: 0, stdout: '', stderr: '' }); - mockListAndroidDevices.mockReset(); - mockListAndroidDevices.mockResolvedValue([]); - mockListAppleDevices.mockReset(); - mockListAppleDevices.mockResolvedValue([]); - mockResolveIosApp.mockReset(); - mockResolveIosApp.mockImplementation(async (device, app) => { - const normalizedApp = app.toLowerCase(); - if (normalizedApp === 'settings') { - return isMacOs(device) ? 'com.apple.systempreferences' : 'com.apple.Preferences'; - } - if (normalizedApp === 'menubarapp') { - return 'com.example.menubarapp'; - } - return app.includes('.') ? app : `com.example.${normalizedApp}`; - }); - mockResolveIosSimulatorDeepLinkBundleId.mockReset(); - mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue(undefined); - mockEnsureAndroidEmulatorBooted.mockReset(); - mockStartAppLog.mockReset(); - mockStopAppLog.mockReset(); - mockStopAppLog.mockResolvedValue(undefined); - mockRunAppLogDoctor.mockReset(); - mockRunAppLogDoctor.mockResolvedValue({ checks: {}, notes: [] }); - mockDefaultInstallOpsIos.mockReset(); - mockDefaultInstallOpsAndroid.mockReset(); - mockDefaultReinstallOpsIos.mockReset(); - mockDefaultReinstallOpsAndroid.mockReset(); -}); - -function makeSessionStore(): SessionStore { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-handler-')); - return new SessionStore(path.join(root, 'sessions')); -} - -function makeSession(name: string, device: SessionState['device']): SessionState { - return { - name, - device, - createdAt: Date.now(), - actions: [], - }; -} - -const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); - -function assertInvalidArgsMessage(response: DaemonResponse | null, message: string): void { - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toBe(message); - } -} - -async function withMockedPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { - const original = process.platform; - Object.defineProperty(process, 'platform', { value: platform, configurable: true }); - try { - return await fn(); - } finally { - Object.defineProperty(process, 'platform', { value: original, configurable: true }); - } -} - -test('devices filters Apple-family platform selectors', async () => { - const sessionStore = makeSessionStore(); - mockListAndroidDevices.mockResolvedValue([ - { - platform: 'android' as const, - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator' as const, - target: 'mobile' as const, - booted: true, - }, - ]); - mockListAppleDevices.mockResolvedValue([ - { - platform: 'apple' as const, - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator' as const, - target: 'mobile' as const, - booted: true, - }, - { - platform: 'apple', - appleOs: 'macos' as const, - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device' as const, - target: 'desktop' as const, - booted: true, - }, - ]); - const runDevices = async (flags: DaemonRequest['flags']) => - handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'devices', - positionals: [], - flags, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - const macosResponse = await runDevices({ platform: 'macos' }); - expect(macosResponse?.ok).toBeTruthy(); - if (macosResponse?.ok) { - const devices = macosResponse.data?.devices as Array<{ platform: string }> | undefined; - expect(devices?.map((device) => device.platform)).toEqual(['macos']); - } - - const iosResponse = await runDevices({ platform: 'ios' }); - expect(iosResponse?.ok).toBeTruthy(); - if (iosResponse?.ok) { - const devices = iosResponse.data?.devices as Array<{ platform: string }> | undefined; - expect(devices?.map((device) => device.platform)).toEqual(['ios']); - } - - const appleDesktopResponse = await runDevices({ platform: 'apple', target: 'desktop' }); - expect(appleDesktopResponse?.ok).toBeTruthy(); - if (appleDesktopResponse?.ok) { - const devices = appleDesktopResponse.data?.devices as Array<{ platform: string }> | undefined; - expect(devices?.map((device) => device.platform)).toEqual(['macos']); - } -}); - -test('devices surfaces appleOs additively while keeping platform the public leaf', async () => { - const sessionStore = makeSessionStore(); - mockListAndroidDevices.mockResolvedValue([]); - mockListAppleDevices.mockResolvedValue([ - { - platform: 'apple' as const, - id: 'sim-1', - name: 'iPad Pro 11-inch (M4)', - kind: 'simulator' as const, - target: 'mobile' as const, - appleOs: 'ipados' as const, - booted: true, - simulatorSetPath: '/tmp/agent-device-sim-set', - }, - ]); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'devices', - positionals: [], - flags: { platform: 'ios' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBeTruthy(); - if (response?.ok) { - const devices = response.data?.devices as Array> | undefined; - expect(devices).toHaveLength(1); - // appleOs is now surfaced additively (iPad -> ipados) ... - expect(devices?.[0]?.appleOs).toBe('ipados'); - // ... while `platform` stays the PUBLIC leaf (never the internal `apple`). - expect(devices?.[0]?.platform).toBe('ios'); - // The internal-only simulator set path is still stripped from the public shape. - expect(devices?.[0]).not.toHaveProperty('simulatorSetPath'); - expect(devices?.[0]?.id).toBe('sim-1'); - } -}); - -test('batch stops on first failing step with partial results', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'batch', - positionals: [], - flags: { - batchSteps: [ - { command: 'open', positionals: ['settings'] }, - { command: 'click', positionals: ['@e1'] }, - ], - }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (stepReq) => { - if (stepReq.command === 'click') { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: 'missing target', - hint: 'refresh selector', - diagnosticId: 'diag-step-2', - logPath: '/tmp/diag-step-2.ndjson', - }, - }; - } - return { ok: true, data: {} }; - }, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toMatch(/Batch failed at step 2/); - expect(response.error.details?.step).toBe(2); - expect(response.error.details?.executed).toBe(1); - expect(response.error.hint).toBe('refresh selector'); - expect(response.error.diagnosticId).toBe('diag-step-2'); - expect(response.error.logPath).toBe('/tmp/diag-step-2.ndjson'); - const partial = response.error.details?.partialResults; - expect(Array.isArray(partial)).toBeTruthy(); - expect((partial as unknown[]).length).toBe(1); - } -}); - -test('batch rejects nested replay and batch commands', async () => { - const sessionStore = makeSessionStore(); - const nestedReplay = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'batch', - positionals: [], - flags: { - batchSteps: [{ command: 'replay', positionals: ['./flow.ad'] }], - }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(nestedReplay).toBeTruthy(); - expect(nestedReplay?.ok).toBe(false); - if (nestedReplay && !nestedReplay.ok) { - expect(nestedReplay.error.code).toBe('INVALID_ARGS'); - } - - const nestedBatch = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'batch', - positionals: [], - flags: { - batchSteps: [{ command: 'batch', positionals: [] }], - }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(nestedBatch).toBeTruthy(); - expect(nestedBatch?.ok).toBe(false); - if (nestedBatch && !nestedBatch.ok) { - expect(nestedBatch.error.code).toBe('INVALID_ARGS'); - } -}); - -test('batch step flags override parent selector flags', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'batch', - positionals: [], - flags: { - platform: 'ios', - batchSteps: [ - { - command: 'open', - positionals: ['settings'], - flags: { platform: 'android' }, - }, - ], - }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (stepReq) => { - expect(stepReq.flags?.platform).toBe('android'); - return { ok: true, data: {} }; - }, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); -}); - -test('batch step forwards typed runtime payload', async () => { - const sessionStore = makeSessionStore(); - const seenRuntimes: Array = []; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'batch', - positionals: [], - flags: { - batchSteps: [ - { - command: 'open', - positionals: ['Demo'], - flags: { platform: 'android' }, - runtime: { - metroHost: '10.0.0.10', - metroPort: 8081, - }, - }, - ], - }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (stepReq) => { - seenRuntimes.push(stepReq.runtime); - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBe(true); - expect(seenRuntimes).toEqual([ - { - metroHost: '10.0.0.10', - metroPort: 8081, - }, - ]); -}); - -test('batch step inherits parent runtime unless the step overrides it', async () => { - const sessionStore = makeSessionStore(); - const seenRuntimes: Array = []; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'batch', - positionals: [], - runtime: { - platform: 'android', - bundleUrl: 'https://bundle.example.test', - }, - flags: { - batchSteps: [ - { - command: 'open', - positionals: ['Demo'], - }, - { - command: 'open', - positionals: ['Demo'], - runtime: { - metroHost: '10.0.0.10', - metroPort: 8081, - }, - }, - ], - }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (stepReq) => { - seenRuntimes.push(stepReq.runtime); - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBe(true); - expect(seenRuntimes).toEqual([ - { - platform: 'android', - bundleUrl: 'https://bundle.example.test', - }, - { - metroHost: '10.0.0.10', - metroPort: 8081, - }, - ]); -}); - -test('batch step pins nested requests to the resolved session', async () => { - const sessionStore = makeSessionStore(); - const seenSessions: Array<{ session: string; flagSession: string | undefined }> = []; - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'batch', - positionals: [], - flags: { - batchSteps: [{ command: 'wait', positionals: ['100'] }], - }, - }, - sessionName: 'resolved-session', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (stepReq) => { - seenSessions.push({ - session: stepReq.session, - flagSession: stepReq.flags?.session, - }); - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBe(true); - expect(seenSessions).toEqual([ - { - session: 'resolved-session', - flagSession: 'resolved-session', - }, - ]); -}); - -test('runtime set/show/clear manages session-scoped runtime hints before open', async () => { - const sessionStore = makeSessionStore(); - const baseRequest = { - token: 't', - session: 'remote-runtime', - } satisfies Pick; - - const setResponse = await handleSessionCommands({ - req: { - ...baseRequest, - command: 'runtime', - positionals: ['set'], - flags: { - platform: 'android', - metroHost: '10.0.0.10', - metroPort: 8081, - launchUrl: 'myapp://dev-client', - }, - }, - sessionName: 'remote-runtime', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(setResponse?.ok).toBe(true); - - const showResponse = await handleSessionCommands({ - req: { - ...baseRequest, - command: 'runtime', - positionals: ['show'], - flags: {}, - }, - sessionName: 'remote-runtime', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(showResponse?.ok).toBe(true); - if (showResponse && showResponse.ok) { - expect(showResponse.data?.configured).toBe(true); - expect(showResponse.data?.runtime).toEqual({ - platform: 'android', - metroHost: '10.0.0.10', - metroPort: 8081, - bundleUrl: undefined, - launchUrl: 'myapp://dev-client', - }); - } - - const clearResponse = await handleSessionCommands({ - req: { - ...baseRequest, - command: 'runtime', - positionals: ['clear'], - flags: {}, - }, - sessionName: 'remote-runtime', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(clearResponse?.ok).toBe(true); - expect(sessionStore.getRuntimeHints('remote-runtime')).toBe(undefined); -}); - -test('runtime clear removes applied transport hints for the active app', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'runtime-clear-active'; - sessionStore.setRuntimeHints(sessionName, { - platform: 'android', - metroHost: '10.0.0.10', - metroPort: 8081, - }); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.demo', - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'runtime', - positionals: ['clear'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - expect(mockClearRuntimeHints).toHaveBeenCalledWith( - expect.objectContaining({ - device: expect.objectContaining({ id: 'emulator-5554' }), - appId: 'com.example.demo', - }), - ); - expect(sessionStore.getRuntimeHints(sessionName)).toBe(undefined); -}); - -test('close clears applied runtime transport hints before deleting the session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'runtime-close-active'; - sessionStore.setRuntimeHints(sessionName, { - platform: 'ios', - metroHost: '127.0.0.1', - metroPort: 8081, - }); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.example.demo', - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - expect(mockClearRuntimeHints).toHaveBeenCalled(); - expect(sessionStore.get(sessionName)).toBe(undefined); - expect(sessionStore.getRuntimeHints(sessionName)).toBe(undefined); -}); - -test('close clears retained materialized install paths bound to the session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'materialized-close-active'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - }); - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-materialized-')); - const appPath = path.join(tempRoot, 'Sample.app'); - fs.mkdirSync(appPath, { recursive: true }); - fs.writeFileSync(path.join(appPath, 'Info.plist'), 'plist'); - const retained = await retainMaterializedPaths({ - installablePath: appPath, - sessionName, - ttlMs: 60_000, - }); - - // Use real cleanup implementation so retained paths are actually removed - const { cleanupRetainedMaterializedPathsForSession: realCleanup } = await vi.importActual< - typeof import('../../materialized-path-registry.ts') - >('../../materialized-path-registry.ts'); - mockCleanupRetainedMaterializedPaths.mockImplementation(realCleanup); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - expect(sessionStore.get(sessionName)).toBe(undefined); - expect(fs.existsSync(retained.installablePath)).toBe(false); - fs.rmSync(tempRoot, { recursive: true, force: true }); -}); - -test('release_materialized_paths removes retained install artifacts', async () => { - const sessionStore = makeSessionStore(); - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-release-materialized-')); - const appPath = path.join(tempRoot, 'Sample.app'); - fs.mkdirSync(appPath, { recursive: true }); - fs.writeFileSync(path.join(appPath, 'Info.plist'), 'plist'); - const retained = await retainMaterializedPaths({ - installablePath: appPath, - ttlMs: 60_000, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'release_materialized_paths', - positionals: [], - flags: {}, - meta: { - materializationId: retained.materializationId, - }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - expect(fs.existsSync(retained.installablePath)).toBe(false); - fs.rmSync(tempRoot, { recursive: true, force: true }); -}); - -test('boot requires session or explicit selector', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'boot', - positionals: [], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - } -}); - -test('boot prefers explicit device selector over active session device', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - ); - const selectedDevice: SessionState['device'] = { - platform: 'apple', - id: 'sim-2', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }; - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'boot', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockEnsureDeviceReady).toHaveBeenCalledWith( - expect.objectContaining({ id: 'sim-2' }), - expect.any(Object), - ); - const onColdBootStart = mockEnsureDeviceReady.mock.calls[0]?.[1]?.onIosSimulatorColdBootStart; - expect(onColdBootStart).toBeTypeOf('function'); - onColdBootStart?.(selectedDevice); - expect(mockPrewarmAppleRunnerCache).toHaveBeenCalledWith( - selectedDevice, - expect.objectContaining({ - logPath: expect.stringMatching(/daemon\.log$/), - }), - ); - if (response && response.ok) { - expect(response.data?.platform).toBe('ios'); - expect(response.data?.id).toBe('sim-2'); - } -}); - -test('boot --headless launches Android emulator when no running device matches', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); - const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; - mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { - launchCalls.push({ avdName, serial, headless }); - return { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel_9_Pro_XL', - kind: 'emulator', - target: 'mobile', - booted: true, - }; - }); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'boot', - positionals: [], - flags: { platform: 'android', device: 'Pixel_9_Pro_XL', headless: true }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: true }]); - expect(mockEnsureDeviceReady).toHaveBeenCalledWith( - expect.objectContaining({ id: 'emulator-5554' }), - ); - if (response && response.ok) { - expect(response.data?.platform).toBe('android'); - expect(response.data?.id).toBe('emulator-5554'); - expect(response.data?.device).toBe('Pixel_9_Pro_XL'); - } -}); - -test('boot launches Android emulator with GUI when no running device matches', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); - const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; - mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { - launchCalls.push({ avdName, serial, headless }); - return { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel_9_Pro_XL', - kind: 'emulator', - target: 'mobile', - booted: true, - }; - }); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'boot', - positionals: [], - flags: { platform: 'android', device: 'Pixel_9_Pro_XL' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); - if (response && response.ok) { - expect(response.data?.platform).toBe('android'); - expect(response.data?.id).toBe('emulator-5554'); - expect(response.data?.device).toBe('Pixel_9_Pro_XL'); - } -}); - -test('boot launches stopped Android emulator selected from inventory', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'android', - id: 'Pixel_9_Pro_XL', - name: 'Pixel_9_Pro_XL', - kind: 'emulator', - target: 'mobile', - booted: false, - }); - const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; - mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { - launchCalls.push({ avdName, serial, headless }); - return { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel_9_Pro_XL', - kind: 'emulator', - target: 'mobile', - booted: true, - }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'boot', - positionals: [], - flags: { platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); - expect(mockEnsureDeviceReady).toHaveBeenCalledWith( - expect.objectContaining({ id: 'emulator-5554', booted: true }), - ); - if (response && response.ok) { - expect(response.data?.platform).toBe('android'); - expect(response.data?.id).toBe('emulator-5554'); - expect(response.data?.device).toBe('Pixel_9_Pro_XL'); - } -}); - -test('boot --headless requires avd selector when device cannot be resolved', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); - mockEnsureAndroidEmulatorBooted.mockRejectedValue(new Error('unexpected')); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'boot', - positionals: [], - flags: { platform: 'android', serial: 'emulator-5554', headless: true }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(mockEnsureAndroidEmulatorBooted).not.toHaveBeenCalled(); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/boot --headless requires --device /); - } -}); - -test('boot --headless rejects non-Android selectors', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'boot', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro', headless: true }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(mockEnsureAndroidEmulatorBooted).not.toHaveBeenCalled(); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/headless is supported only for Android emulators/i); - } -}); - -test('boot keeps --target validation when emulator is fallback-launched', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); - const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; - mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { - launchCalls.push({ avdName, serial, headless }); - return { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel_9_Pro_XL', - kind: 'emulator', - target: 'mobile', - booted: true, - }; - }); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'boot', - positionals: [], - flags: { platform: 'android', target: 'tv', device: 'Pixel_9_Pro_XL' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); - expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); - if (response && !response.ok) { - expect(response.error.code).toBe('DEVICE_NOT_FOUND'); - expect(response.error.message).toMatch(/matching --target tv/i); - } -}); - -test('shutdown turns off selected iOS simulator', async () => { - const sessionStore = makeSessionStore(); - const selectedDevice: SessionState['device'] = { - platform: 'apple', - id: 'sim-2', - name: 'iPhone 17 Pro', - kind: 'simulator', - target: 'mobile', - booted: true, - }; - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'shutdown', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); - expect(mockShutdownSimulator).toHaveBeenCalledWith(selectedDevice); - if (response && response.ok) { - expect(response.data?.platform).toBe('ios'); - expect(response.data?.id).toBe('sim-2'); - expect(response.data?.shutdown).toEqual({ - success: true, - exitCode: 0, - stdout: '', - stderr: '', - }); - } -}); - -test('shutdown rejects active session device and points to close --shutdown', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - const selectedDevice: SessionState['device'] = { - platform: 'apple', - id: 'sim-2', - name: 'iPhone 17 Pro', - kind: 'simulator', - target: 'mobile', - booted: true, - }; - sessionStore.set(sessionName, makeSession(sessionName, selectedDevice)); - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'shutdown', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(mockShutdownSimulator).not.toHaveBeenCalled(); - if (response && !response.ok) { - expect(response.error.code).toBe('DEVICE_IN_USE'); - expect(response.error.message).toMatch(/close --shutdown/i); - expect(response.error.details?.hint).toBe( - 'Run agent-device close --shutdown --session default', - ); - } -}); - -test('shutdown turns off selected Android emulator', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'android', - id: 'emulator-5554', - name: 'Pixel_9_Pro_XL', - kind: 'emulator', - target: 'mobile', - booted: true, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'shutdown', - positionals: [], - flags: { platform: 'android', device: 'Pixel_9_Pro_XL' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); - expect(mockRunCmd).toHaveBeenCalledWith( - 'adb', - ['-s', 'emulator-5554', 'emu', 'kill'], - expect.objectContaining({ allowFailure: true, timeoutMs: 15_000 }), - ); - if (response && response.ok) { - expect(response.data?.platform).toBe('android'); - expect(response.data?.id).toBe('emulator-5554'); - expect(response.data?.shutdown).toEqual({ - success: true, - exitCode: 0, - stdout: '', - stderr: '', - }); - } -}); - -test('shutdown rejects unsupported physical devices', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'device-1', - name: 'iPhone', - kind: 'device', - target: 'mobile', - booted: true, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'shutdown', - positionals: [], - flags: { platform: 'ios', udid: 'device-1' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(mockShutdownSimulator).not.toHaveBeenCalled(); - expect(mockRunCmd).not.toHaveBeenCalled(); - if (response && !response.ok) { - expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toMatch(/Apple simulators and Android emulators/i); - } -}); - -test('shutdown returns an error response when selected target shutdown fails', async () => { - const sessionStore = makeSessionStore(); - const selectedDevice: SessionState['device'] = { - platform: 'apple', - id: 'sim-2', - name: 'iPhone 17 Pro', - kind: 'simulator', - target: 'mobile', - booted: true, - }; - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - mockShutdownSimulator.mockResolvedValue({ - success: false, - exitCode: 149, - stdout: '', - stderr: 'simctl shutdown failed', - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'shutdown', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toBe('simctl shutdown failed'); - expect(response.error.details?.shutdown).toEqual({ - success: false, - exitCode: 149, - stdout: '', - stderr: 'simctl shutdown failed', - }); - } -}); - -test('appstate on iOS requires active session on selected device', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 15', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.apple.Preferences', - appName: 'Settings', - }); - const selectedDevice: SessionState['device'] = { - platform: 'apple', - id: 'sim-2', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }; - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - mockDispatch.mockRejectedValue(new Error('snapshot dispatch should not run')); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'appstate', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - expect(response.error.message).toMatch(/requires an active session/i); - } -}); - -test('appstate returns session appName when bundle id is unavailable', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'sim'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appName: 'Maps', - }); - - const selectedDevice: SessionState['device'] = { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }; - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - mockDispatch.mockRejectedValue(new Error('snapshot dispatch should not run')); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'appstate', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.platform).toBe('ios'); - expect(response.data?.appName).toBe('Maps'); - expect(response.data?.appBundleId).toBe(undefined); - expect(response.data?.source).toBe('session'); - expect(response.data?.device_udid).toBe('sim-1'); - expect(response.data?.ios_simulator_device_set).toBe(null); - } -}); - -test('appstate fails when iOS session has no tracked app', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'sim'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - ); - - const selectedDevice: SessionState['device'] = { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }; - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'appstate', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toMatch(/no foreground app is tracked/i); - } -}); - -test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async () => { - const sessionStore = makeSessionStore(); - const selectedDevice: SessionState['device'] = { - platform: 'apple', - id: 'sim-2', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }; - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'appstate', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - } -}); - -test('appstate with explicit missing session returns SESSION_NOT_FOUND', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'sim', - command: 'appstate', - positionals: [], - flags: { session: 'sim', platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName: 'sim', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - expect(response.error.message).toMatch(/no active session "sim"/i); - expect(response.error.message).not.toMatch(/omit --session/i); - } -}); - -test('clipboard requires an active session or explicit device selector', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'clipboard', - positionals: ['read'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch( - /clipboard requires an active session or an explicit device selector/i, - ); - } -}); - -test('keyboard requires an active session or explicit device selector', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'keyboard', - positionals: ['status'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch( - /keyboard requires an active session or an explicit device selector/i, - ); - } -}); - -test('keyboard dismiss requires active iOS session for explicit selectors', async () => { - const sessionStore = makeSessionStore(); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'keyboard', - positionals: ['dismiss'], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - expect(response.error.message).toMatch(/requires an active session/i); - } -}); - -test('clipboard rejects unsupported iOS physical devices', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-session'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - ); - - mockDispatch.mockRejectedValue(new Error('dispatch should not run for unsupported targets')); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'clipboard', - positionals: ['read'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toMatch(/clipboard is not supported on this device/i); - } -}); - -test('perf requires an active session', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'perf', - positionals: [], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - } -}); - -test('perf reports startup metric as unavailable when no sample exists', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'perf-session-empty'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - ); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'perf', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - if (response && response.ok) { - const startup = (response.data?.metrics as any)?.startup; - const memory = (response.data?.metrics as any)?.memory; - const cpu = (response.data?.metrics as any)?.cpu; - expect(startup?.available).toBe(false); - expect(String(startup?.reason ?? '')).toMatch(/no startup sample captured yet/i); - expect(memory?.available).toBe(false); - expect(String(memory?.reason ?? '')).toMatch(/run open first/i); - expect(cpu?.available).toBe(false); - expect(String(cpu?.reason ?? '')).toMatch(/run open first/i); - } -}); - -test('perf preserves successful metrics and normalizes per-metric Android sampling failures', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'perf-session-android-error'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - }); - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.includes('meminfo')) { - throw new AppError('COMMAND_FAILED', 'adb exited with code 1', { - stderr: 'error: device offline', - exitCode: 1, - processExitError: true, - }); - } - return { - stdout: '0.0% 1234/com.example.app: 0% user + 0% kernel', - stderr: '', - exitCode: 0, - }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'perf', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - if (response && response.ok) { - const startup = (response.data?.metrics as any)?.startup; - const memory = (response.data?.metrics as any)?.memory; - const cpu = (response.data?.metrics as any)?.cpu; - expect(startup?.available).toBe(false); - expect(memory?.available).toBe(false); - expect(memory?.reason).toBe('error: device offline'); - expect(memory?.error?.code).toBe('COMMAND_FAILED'); - expect(memory?.error?.hint).toMatch(/retry with --debug/i); - expect(memory?.error?.details?.metric).toBe('memory'); - expect(memory?.error?.details?.package).toBe('com.example.app'); - expect(cpu?.available).toBe(true); - expect(cpu?.usagePercent).toBe(0); - } -}); - -test('perf samples Apple cpu and memory metrics on macOS app sessions', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'perf-session-macos'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-mac', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - appBundleId: 'com.example.mac', - }); - mockRunCmd.mockImplementation(async (cmd, _args) => { - if (cmd === 'mdfind') { - return { stdout: '/Applications/Example.app\n', stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: 'ExampleExec\n', stderr: '', exitCode: 0 }; - } - if (cmd === 'ps') { - return { - stdout: [ - '111 7.5 4096 /Applications/Example.app/Contents/MacOS/ExampleExec', - '222 0.5 1024 /Applications/Example.app/Contents/MacOS/ExampleExec --flag', - '333 5.0 2048 /Applications/Other.app/Contents/MacOS/OtherExec', - ].join('\n'), - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'perf', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (!response?.ok) throw new Error('Expected perf response to succeed for macOS session'); - const memory = (response.data?.metrics as any)?.memory; - const cpu = (response.data?.metrics as any)?.cpu; - expect(memory?.available).toBe(true); - expect(memory?.residentMemoryKb).toBe(5120); - expect(cpu?.available).toBe(true); - expect(cpu?.usagePercent).toBe(8); - expect(cpu?.matchedProcesses).toEqual(['ExampleExec']); -}); - -test('perf samples Apple cpu and memory metrics on iOS simulator app sessions', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'perf-session-ios-sim'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.example.sim', - }); - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'xcrun' && args.includes('get_app_container')) { - return { stdout: '/tmp/Example.app\n', stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: 'ExampleSimExec\n', stderr: '', exitCode: 0 }; - } - if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) { - return { - stdout: ['111 11.0 6144 ExampleSimExec', '222 2.0 2048 SpringBoard'].join('\n'), - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'perf', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (!response?.ok) throw new Error('Expected perf response to succeed for iOS simulator session'); - const memory = (response.data?.metrics as any)?.memory; - const cpu = (response.data?.metrics as any)?.cpu; - expect(memory?.available).toBe(true); - expect(memory?.residentMemoryKb).toBe(6144); - expect(cpu?.available).toBe(true); - expect(cpu?.usagePercent).toBe(11); - expect(cpu?.matchedProcesses).toEqual(['ExampleSimExec']); -}); - -test('perf samples Apple cpu and memory metrics on physical iOS devices', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z')); - const sessionStore = makeSessionStore(); - const sessionName = 'perf-session-ios-device'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - appBundleId: 'com.example.device', - }); - let exportCount = 0; - mockRunCmd.mockImplementation(async (_cmd, args) => { - if ( - args[0] === 'devicectl' && - args[1] === 'device' && - args[2] === 'info' && - args[3] === 'apps' - ) { - const outputIndex = args.indexOf('--json-output'); - fs.writeFileSync( - args[outputIndex + 1]!, - JSON.stringify({ - result: { - apps: [ - { - bundleIdentifier: 'com.example.device', - name: 'Example Device App', - url: 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/', - }, - ], - }, - }), - ); - return { stdout: '', stderr: '', exitCode: 0 }; - } - if ( - args[0] === 'devicectl' && - args[1] === 'device' && - args[2] === 'info' && - args[3] === 'processes' - ) { - const outputIndex = args.indexOf('--json-output'); - fs.writeFileSync( - args[outputIndex + 1]!, - JSON.stringify({ - result: { - runningProcesses: [ - { - executable: - 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/ExampleDeviceApp', - processIdentifier: 4001, - }, - ], - }, - }), - ); - return { stdout: '', stderr: '', exitCode: 0 }; - } - if (args[0] === 'xctrace' && args[1] === 'record') { - vi.setSystemTime(new Date(Date.now() + 1000)); - return { stdout: '', stderr: '', exitCode: 0 }; - } - if (args[0] === 'xctrace' && args[1] === 'export') { - const outputIndex = args.indexOf('--output'); - exportCount += 1; - await fs.promises.writeFile( - args[outputIndex + 1]!, - [ - '', - '', - '', - '', - 'start', - 'process', - 'cpu-total', - 'memory-real', - 'pid', - '', - '', - '123', - '4001', - exportCount === 1 - ? '100000000' - : '350000000', - '8388608', - '4001', - '', - '', - '', - ].join(''), - ); - return { stdout: '', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'perf', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (!response?.ok) throw new Error('Expected perf response to succeed for physical iOS session'); - const memory = (response.data?.metrics as any)?.memory; - const cpu = (response.data?.metrics as any)?.cpu; - expect(memory?.available).toBe(true); - expect(memory?.residentMemoryKb).toBe(8192); - expect(cpu?.available).toBe(true); - expect(cpu?.usagePercent).toBe(25); - expect(cpu?.matchedProcesses).toEqual(['ExampleDeviceApp']); -}); - -test('perf reports physical iOS cpu and memory as unavailable without an app bundle id', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'perf-session-ios-device-no-bundle'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-2', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'perf', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (!response?.ok) { - throw new Error('Expected perf response to succeed for physical iOS session without bundle id'); - } - const memory = (response.data?.metrics as any)?.memory; - const cpu = (response.data?.metrics as any)?.cpu; - expect(memory?.available).toBe(false); - expect(memory?.reason).toMatch(/no apple app bundle id is associated with this session/i); - expect(cpu?.available).toBe(false); - expect(cpu?.reason).toMatch(/no apple app bundle id is associated with this session/i); -}); - -test('open URL on existing iOS session clears stale app bundle id', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 15', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.example.old', - appName: 'Old App', - }); - - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 15', - kind: 'simulator', - booted: true, - }); - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['https://example.com/path'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe(undefined); - expect(updated?.appName).toBe('https://example.com/path'); - expect(dispatchedContext?.appBundleId).toBe(undefined); -}); - -test('open URL on existing macOS session clears stale app bundle id', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-mac', - name: 'Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - appBundleId: 'com.example.old', - appName: 'Old App', - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['https://example.com/path'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe(undefined); - expect(updated?.appName).toBe('https://example.com/path'); - expect(dispatchedContext?.appBundleId).toBe(undefined); -}); - -test('open URL on existing iOS device session preserves app bundle id context', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - appBundleId: 'com.example.app', - appName: 'Example App', - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['myapp://item/42'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.example.app'); - expect(updated?.appName).toBe('myapp://item/42'); - expect(dispatchedContext?.appBundleId).toBe('com.example.app'); -}); - -test('open custom URL on existing iOS simulator session preserves app bundle id context', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.example.app', - appName: 'Example App', - }); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['myapp://item/42'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockEnsureDeviceReady.mock.calls[0]?.[1]).toEqual({ - deviceHub: false, - onIosSimulatorColdBootStart: undefined, - }); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.example.app'); - expect(updated?.appName).toBe('myapp://item/42'); - expect(dispatchedContext?.appBundleId).toBe('com.example.app'); -}); - -test('open custom URL on fresh iOS simulator session infers app bundle id from URL scheme', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-url-session'; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue('org.reactnavigation.playground'); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['rne://navigator-layout'], - flags: { platform: 'ios', udid: 'sim-1' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockResolveIosSimulatorDeepLinkBundleId).toHaveBeenCalledWith( - expect.objectContaining({ id: 'sim-1', kind: 'simulator' }), - 'rne://navigator-layout', - ); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('org.reactnavigation.playground'); - expect(updated?.appName).toBe('rne://navigator-layout'); - expect(dispatchedContext?.appBundleId).toBe('org.reactnavigation.playground'); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); -}); - -test('open iOS simulator app prewarms runner cache during cold boot', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-cold-boot-cache-prewarm'; - const device: SessionState['device'] = { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: false, - }; - mockResolveTargetDevice.mockResolvedValue(device); - mockResolveIosApp.mockResolvedValueOnce('com.example.app'); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['Demo'], - flags: { platform: 'ios', udid: 'sim-1' }, - meta: { requestId: 'open-request' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const onColdBootStart = mockEnsureDeviceReady.mock.calls[0]?.[1]?.onIosSimulatorColdBootStart; - expect(onColdBootStart).toBeTypeOf('function'); - onColdBootStart?.(device); - expect(mockPrewarmAppleRunnerCache).toHaveBeenCalledWith( - device, - expect.objectContaining({ - logPath: expect.stringMatching(/daemon\.log$/), - requestId: 'open-request', - }), - ); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); -}); - -test('open iOS app session prewarms runner session when app bundle id is known', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - appBundleId: 'com.example.previous', - appName: 'Previous App', - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['Settings', 'myapp://screen/to'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: 'ios-device-1' }), - expect.objectContaining({ logPath: expect.stringMatching(/daemon\.log$/) }), - ); -}); - -test('open iOS Maestro app link waits for runner prewarm before launching app', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-maestro-open-link'; - const events: string[] = []; - let finishPrewarm: (() => void) | undefined; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - appBundleId: 'com.example.previous', - appName: 'Previous App', - }); - - mockPrewarmIosRunnerSession.mockImplementation( - () => - new Promise((resolve) => { - events.push('prewarm-start'); - finishPrewarm = () => { - events.push('prewarm-finish'); - resolve(); - }; - }), - ); - mockDispatch.mockImplementation(async (_device, command) => { - events.push(`dispatch:${command}`); - return {}; - }); - - const responsePromise = handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['com.example.app', 'rne://screen-layout'], - flags: { - maestro: { prewarmRunnerBeforeOpen: true }, - }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - await vi.waitFor(() => expect(events).toEqual(['prewarm-start'])); - - finishPrewarm?.(); - const response = await responsePromise; - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(events).toEqual(['prewarm-start', 'prewarm-finish', 'dispatch:open']); - expect((response as any).data?.timing).toMatchObject({ - runnerPrewarmKind: 'session', - runnerPrewarmScheduled: true, - runnerPrewarmWaited: true, - }); -}); - -test('open iOS Maestro app link reports blocking runner prewarm failures before launching app', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-maestro-open-link-prewarm-failed'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - appBundleId: 'com.example.previous', - appName: 'Previous App', - }); - mockPrewarmIosRunnerSession.mockRejectedValueOnce( - new AppError('COMMAND_FAILED', 'Developer mode is disabled for Apple development tools', { - hint: 'Run `sudo DevToolsSecurity -enable`.', - }), - ); - - await expect( - handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['com.example.app', 'rne://screen-layout'], - flags: { - maestro: { prewarmRunnerBeforeOpen: true }, - }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }), - ).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - message: 'Developer mode is disabled for Apple development tools', - details: { - hint: expect.stringContaining('DevToolsSecurity -enable'), - }, - }); - expect(mockDispatch).not.toHaveBeenCalled(); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: 'ios-device-1' }), - expect.objectContaining({ propagateError: true }), - ); -}); - -test('open iOS URL without app bundle id skips runner prewarm', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-session'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - ); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['myapp://screen/to'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled(); -}); - -test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'prepare-ios-runner'; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'prepare', - positionals: ['ios-runner'], - flags: { platform: 'ios', udid: 'sim-1', timeoutMs: 240000 }, - meta: { requestId: 'prepare-request' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockEnsureDeviceReady).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: 'sim-1' }), - ); - expect(mockPrepareIosRunner).toHaveBeenCalledTimes(1); - expect(mockPrepareIosRunner).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: 'sim-1' }), - expect.objectContaining({ - cleanStaleBundles: true, - buildTimeoutMs: 240000, - healthTimeoutMs: 240000, - logPath: expect.stringMatching(/daemon\.log$/), - prepareDeadline: expect.objectContaining({ - elapsedMs: expect.any(Function), - isExpired: expect.any(Function), - remainingMs: expect.any(Function), - }), - requestId: 'prepare-request', - startupTimeoutMs: 240000, - }), - ); - expect((response as any).data).toMatchObject({ - action: 'ios-runner', - platform: 'ios', - deviceId: 'sim-1', - deviceName: 'iPhone 17 Pro', - kind: 'simulator', - connectMs: 3, - healthCheckMs: 3, - runner: { currentUptimeMs: 42 }, - message: 'Prepared Apple runner: iPhone 17 Pro', - }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('prepare ios-runner explains overlapping timing fields with additive parts', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'prepare-ios-runner-timing'; - const dateNow = vi.spyOn(Date, 'now'); - try { - dateNow.mockReturnValueOnce(1_000).mockReturnValueOnce(28_337); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - mockPrepareIosRunner.mockResolvedValueOnce({ - runner: { currentUptimeMs: 42 }, - buildMs: 10_642, - connectMs: 12_635, - healthCheckMs: 14_702, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'prepare', - positionals: ['ios-runner'], - flags: { platform: 'ios', udid: 'sim-1' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - const data = (response as any).data; - expect(data).toMatchObject({ - durationMs: 27_337, - buildMs: 10_642, - connectMs: 12_635, - healthCheckMs: 14_702, - timing: { - totalMs: 27_337, - additiveParts: { - buildMs: 10_642, - connectAfterBuildMs: 1_993, - healthCheckMs: 14_702, - }, - containment: { - connectMs: ['buildMs'], - healthCheckMs: [], - }, - }, - }); - expect(String(data.timing.note)).toMatch(/top-level prepare timing fields.*may overlap/i); - const additiveParts = data.timing.additiveParts as Record; - const additiveTotalMs = Object.values(additiveParts).reduce((sum, value) => sum + value, 0); - expect(additiveTotalMs).toBeLessThanOrEqual(data.timing.totalMs); - expect(data.buildMs + data.connectMs + data.healthCheckMs).toBeGreaterThan(data.durationMs); - } finally { - dateNow.mockRestore(); - } -}); - -test('prepare ios-runner starts the XCTest runner on an explicit macOS selector', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'prepare-macos-runner'; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'prepare', - positionals: ['ios-runner'], - flags: { platform: 'macos', timeoutMs: 240000 }, - meta: { requestId: 'prepare-macos-request' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockPrepareIosRunner).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: 'host-macos-local' }), - expect.objectContaining({ - buildTimeoutMs: 240000, - healthTimeoutMs: 240000, - prepareDeadline: expect.objectContaining({ - elapsedMs: expect.any(Function), - isExpired: expect.any(Function), - remainingMs: expect.any(Function), - }), - requestId: 'prepare-macos-request', - }), - ); - expect((response as any).data).toMatchObject({ - action: 'ios-runner', - platform: 'macos', - deviceId: 'host-macos-local', - deviceName: 'Host Mac', - kind: 'device', - message: 'Prepared Apple runner: Host Mac', - }); -}); - -test('prepare ios-runner rejects non-Apple runner devices', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'android', - id: 'emulator-5554', - name: 'Pixel 9 Pro XL', - kind: 'emulator', - booted: true, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'prepare-android', - command: 'prepare', - positionals: ['ios-runner'], - flags: { platform: 'android', serial: 'emulator-5554' }, - }, - sessionName: 'prepare-android', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toBe( - 'prepare ios-runner is only supported on Apple runner platforms', - ); - } - expect(mockPrepareIosRunner).not.toHaveBeenCalled(); -}); - -test('prepare requires the ios-runner subcommand', async () => { - const sessionStore = makeSessionStore(); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'prepare-invalid', - command: 'prepare', - positionals: [], - flags: { platform: 'ios' }, - }, - sessionName: 'prepare-invalid', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toBe('prepare requires a subcommand: ios-runner'); - } - expect(mockResolveTargetDevice).not.toHaveBeenCalled(); - expect(mockPrepareIosRunner).not.toHaveBeenCalled(); -}); - -test('open web URL on iOS device session without active app falls back to Safari', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-session'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - ); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['https://example.com/path'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.apple.mobilesafari'); - expect(updated?.appName).toBe('https://example.com/path'); - expect(dispatchedContext?.appBundleId).toBe('com.apple.mobilesafari'); -}); - -test('open app and URL on existing iOS device session keeps app context', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - appBundleId: 'com.example.previous', - appName: 'Previous App', - }); - - let dispatchedPositionals: string[] | undefined; - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, positionals, _out, context) => { - dispatchedPositionals = positionals; - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['Settings', 'myapp://screen/to'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.apple.Preferences'); - expect(updated?.appName).toBe('Settings'); - expect(dispatchedPositionals).toEqual(['Settings', 'myapp://screen/to']); - expect(dispatchedContext?.appBundleId).toBe('com.apple.Preferences'); -}); - -test('open app on existing macOS session resolves and stores bundle id', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-mac', - name: 'Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - appBundleId: 'com.example.old', - appName: 'Old App', - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['settings'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.apple.systempreferences'); - expect(updated?.appName).toBe('settings'); - expect(dispatchedContext?.appBundleId).toBe('com.apple.systempreferences'); -}); - -test('open rejects --surface on non-macOS devices', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'ios-surface', - command: 'open', - positionals: ['Notes'], - flags: { - platform: 'ios', - surface: 'frontmost-app', - }, - }, - sessionName: 'ios-surface', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage(response, 'surface is only supported on macOS and Linux'); -}); - -test('open on existing macOS frontmost-app session preserves surface without --surface flag', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-frontmost-existing'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - surface: 'frontmost-app', - appBundleId: 'com.apple.TextEdit', - appName: 'TextEdit', - }); - - const prevHelper = process.env.AGENT_DEVICE_MACOS_HELPER_BIN; - process.env.AGENT_DEVICE_MACOS_HELPER_BIN = '/usr/bin/true'; - mockRunCmd.mockResolvedValue({ - stdout: '{"ok":true,"data":{"bundleId":"com.apple.TextEdit","appName":"TextEdit","pid":123}}', - stderr: '', - exitCode: 0, - }); - mockDispatch.mockImplementation(async (_device, _command, positionals) => { - expect(positionals).toEqual([]); - return {}; - }); - - try { - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: [], - flags: { - platform: 'macos', - }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - const session = sessionStore.get(sessionName); - expect(session?.surface).toBe('frontmost-app'); - expect(session?.appBundleId).toBe('com.apple.TextEdit'); - expect(session?.appName).toBe('TextEdit'); - if (response && response.ok) { - expect(response.data?.surface).toBe('frontmost-app'); - } - } finally { - if (prevHelper === undefined) delete process.env.AGENT_DEVICE_MACOS_HELPER_BIN; - else process.env.AGENT_DEVICE_MACOS_HELPER_BIN = prevHelper; - } -}); - -test('open on existing iOS session refreshes unavailable simulator by name', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'stale-sim', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: false, - }), - appBundleId: 'com.example.old', - appName: 'Old App', - }); - - const resolvedDevice: SessionState['device'] = { - platform: 'apple', - id: 'fresh-sim', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }; - const selectors: Array> = []; - let dispatchedDeviceId: string | undefined; - - mockResolveTargetDevice.mockImplementation(async (selector) => { - selectors.push({ ...selector }); - if ((selector as any).udid === 'stale-sim') { - throw new AppError('DEVICE_NOT_FOUND', 'not found'); - } - return resolvedDevice; - }); - mockDispatch.mockImplementation(async (device) => { - dispatchedDeviceId = device.id; - return {}; - }); - - const response = await withMockedPlatform('darwin', async () => - handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['settings'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }), - ); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(selectors.length).toBe(2); - expect(selectors[0]).toEqual({ platform: 'ios', target: undefined, udid: 'stale-sim' }); - expect(selectors[1]).toEqual({ platform: 'ios', target: undefined, device: 'iPhone 17 Pro' }); - expect(dispatchedDeviceId).toBe('fresh-sim'); - const updated = sessionStore.get(sessionName); - expect(updated?.device.id).toBe('fresh-sim'); - if (response && response.ok) { - expect(response.data?.device_udid).toBe('fresh-sim'); - } -}); - -test('open app on existing Android session resolves and stores package id', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - appName: 'Old App', - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - mockResolveAndroidPackage.mockResolvedValue('org.reactjs.native.example.RNCLI83'); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['RNCLI83'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('org.reactjs.native.example.RNCLI83'); - expect(updated?.appName).toBe('RNCLI83'); - expect(dispatchedContext?.appBundleId).toBe('org.reactjs.native.example.RNCLI83'); -}); - -test('open intent target on existing Android session clears stale package context', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.old', - appName: 'Old App', - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - mockResolveAndroidPackage.mockResolvedValue(undefined); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['settings'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe(undefined); - expect(updated?.appName).toBe('settings'); - expect(dispatchedContext?.appBundleId).toBe(undefined); -}); - -test('open on existing Android session preserves a comparable freshness baseline', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-open-freshness'; - const baselineNodes = Array.from({ length: 14 }, (_, index) => ({ - ref: `e${index + 1}`, - index, - depth: 0, - type: 'android.widget.TextView', - label: `Inbox row ${index + 1}`, - })); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.old', - appName: 'Old App', - snapshot: { - nodes: baselineNodes, - createdAt: Date.now(), - backend: 'android', - comparisonSafe: true, - }, - }); - - mockDispatch.mockResolvedValue({}); - mockResolveAndroidPackage.mockResolvedValue('com.android.settings'); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['settings'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.snapshot).toBeUndefined(); - expect(updated?.androidSnapshotFreshness).toEqual({ - action: 'open', - markedAt: expect.any(Number), - baselineCount: baselineNodes.length, - baselineSignatures: buildSnapshotSignatures(baselineNodes), - routeComparable: true, - }); -}); - -test('open --relaunch closes and reopens active session app', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - appName: 'com.example.app', - }); - - const calls: Array<{ command: string; positionals: string[] }> = []; - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push({ command, positionals }); - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: [], - flags: { relaunch: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(calls.length).toBe(2); - expect(calls[0]).toEqual({ command: 'close', positionals: ['com.example.app'] }); - expect(calls[1]).toEqual({ command: 'open', positionals: ['com.example.app'] }); -}); - -test('open --relaunch on iOS stops runner before close/open', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }), - appName: 'com.example.app', - }); - - const calls: string[] = []; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }); - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${positionals.join(' ')}`); - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: [], - flags: { relaunch: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']); -}); - -test('open --relaunch on iOS simulator collapses into one terminate-running open dispatch', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appName: 'com.example.app', - }); - - const calls: string[] = []; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - let openContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { - calls.push(`${command}:${positionals.join(' ')}`); - if (command === 'open') openContext = context as Record; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: [], - flags: { relaunch: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(calls).toEqual(['open:com.example.app']); - expect(openContext?.terminateRunningApp).toBe(true); -}); - -test('open --relaunch on iOS simulator keeps close-first ordering', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-url-relaunch-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appName: 'com.example.app', - }); - - const calls: string[] = []; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - let openContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { - calls.push(`${command}:${positionals.join(' ')}`); - if (command === 'open') openContext = context as Record; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['com.example.app', 'https://example.com/deal'], - flags: { relaunch: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - // The URL dispatch path cannot carry the terminate, so the relaunch keeps - // the explicit close-then-open sequence. - expect(calls).toEqual(['close:com.example.app', 'open:com.example.app https://example.com/deal']); - expect(openContext?.terminateRunningApp).toBeUndefined(); -}); - -test('open --relaunch --clear-app-state on iOS simulator keeps close-first ordering', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-clear-state-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appName: 'com.example.app', - }); - - const calls: string[] = []; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - let openContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { - calls.push(`${command}:${positionals.join(' ')}`); - if (command === 'open') openContext = context as Record; - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: [], - flags: { relaunch: true, clearAppState: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(calls).toEqual(['close:com.example.app', 'open:com.example.app']); - expect(openContext?.terminateRunningApp).toBeUndefined(); -}); - -test('open --relaunch includes timing and waits for iOS runner prewarm after opening app', async () => { - vi.useFakeTimers({ now: 1_000 }); - const sessionStore = makeSessionStore(); - const sessionName = 'ios-timing-session'; - const events: string[] = []; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }), - appName: 'Example', - appBundleId: 'com.example.app', - }); - - mockPrewarmIosRunnerSession.mockImplementation( - () => - new Promise((resolve) => { - events.push('prewarm-start'); - setTimeout(() => { - events.push('prewarm-finish'); - resolve(); - }, 250); - }), - ); - mockStopIosRunner.mockImplementation(async () => { - events.push('stop-runner'); - }); - mockDispatch.mockImplementation(async (_device, command) => { - events.push(`dispatch:${command}`); - return {}; - }); - - const responsePromise = handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: [], - flags: { relaunch: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - await vi.advanceTimersByTimeAsync(250); - const response = await responsePromise; - - expect(response?.ok).toBe(true); - expect(events).toEqual([ - 'stop-runner', - 'dispatch:close', - 'dispatch:open', - 'prewarm-start', - 'prewarm-finish', - ]); - expect((response as any).data?.timing).toMatchObject({ - runnerPrewarmKind: 'session', - runnerPrewarmScheduled: true, - runnerPrewarmWaited: true, - runnerPrewarmDurationMs: 250, - }); - expect((response as any).data?.timing?.totalDurationMs).toBeGreaterThanOrEqual(250); -}); - -test('open --relaunch on iOS without existing session closes then opens target app', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-new-session'; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${positionals.join(' ')}`); - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['com.example.app'], - flags: { relaunch: true, platform: 'ios' }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']); -}); - -test('open --relaunch on iOS simulator settles once after the collapsed open', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 16', - kind: 'simulator', - booted: true, - }), - appName: 'com.example.app', - }); - - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 16', - kind: 'simulator', - booted: true, - }); - const settleCalls: Array<{ deviceId: string; delayMs: number }> = []; - mockSettleSimulator.mockImplementation(async (device, delayMs) => { - settleCalls.push({ deviceId: device.id, delayMs }); - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: [], - flags: { relaunch: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - // Collapsed simulator relaunch skips the post-close settle: one settle after open. - expect(settleCalls).toEqual([{ deviceId: 'sim-1', delayMs: 300 }]); -}); - -test('close on macOS session stops runner and dismisses automation alert before delete', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - appBundleId: 'com.apple.systempreferences', - appName: 'System Settings', - }); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async (deviceId) => { - calls.push(`stop-runner:${deviceId}`); - }); - mockDismissMacOsAlert.mockImplementation(async (action, options) => { - calls.push( - `dismiss-alert:${action}:${(options as any)?.bundleId ?? (options as any)?.surface ?? 'frontmost'}`, - ); - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(calls).toEqual([ - 'stop-runner:host-macos-local', - 'dismiss-alert:dismiss:com.apple.systempreferences', - ]); - expect(sessionStore.get(sessionName)).toBe(undefined); -}); - -test('close on iOS simulator session retains runner and deletes the session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appName: 'com.example.app', - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockStopIosRunner).not.toHaveBeenCalled(); - expect(mockScheduleIosRunnerIdleStop).toHaveBeenCalledWith('sim-1'); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('close on iOS simulator with scoped simulator set stops runner before deleting session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-scoped-simulator-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - simulatorSetPath: '/tmp/tenant-a/simulator-set', - }), - appName: 'com.example.app', - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockStopIosRunner).toHaveBeenCalledWith('sim-1'); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('close on leased iOS simulator session stops runner before deleting session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-leased-simulator-session'; - const leaseRegistry = new LeaseRegistry(); - const lease = leaseRegistry.allocateLease({ - tenantId: 'tenant-a', - runId: 'run-1', - leaseBackend: 'ios-simulator', - deviceKey: 'ios:sim-1', - clientId: 'client-a', - }); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appName: 'com.example.app', - lease: { - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - leaseBackend: lease.backend, - deviceKey: lease.deviceKey, - clientId: lease.clientId, - expiresAt: lease.expiresAt, - }, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - leaseRegistry, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockStopIosRunner).toHaveBeenCalledWith('sim-1'); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('close --shutdown on iOS simulator stops runner before deleting session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-shutdown-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appName: 'com.example.app', - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: { shutdown: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockStopIosRunner).toHaveBeenCalledWith('sim-1'); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('close on iOS stops runner before app close dispatch and performs final idempotent stop', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-close-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }), - appName: 'com.example.app', - }); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${positionals.join(' ')}`); - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: ['com.example.app'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'stop-runner']); -}); - -test('close on iOS simulator retains runner while terminating app', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-close-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appName: 'com.example.app', - }); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${positionals.join(' ')}`); - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: ['com.example.app'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(calls).toEqual(['close:com.example.app']); -}); - -test('close on macOS stops runner before app close dispatch and dismisses automation alert', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-close-session'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - appBundleId: 'com.apple.systempreferences', - appName: 'System Settings', - }); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async (deviceId) => { - calls.push(`stop-runner:${deviceId}`); - }); - mockDismissMacOsAlert.mockImplementation(async (action, options) => { - calls.push( - `dismiss-alert:${action}:${(options as any)?.bundleId ?? (options as any)?.surface ?? 'frontmost'}`, - ); - return {}; - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${positionals.join(' ')}`); - return {}; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: ['System Settings'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(calls).toEqual([ - 'stop-runner:host-macos-local', - 'dismiss-alert:dismiss:com.apple.systempreferences', - 'close:System Settings', - 'stop-runner:host-macos-local', - 'dismiss-alert:dismiss:com.apple.systempreferences', - ]); -}); - -test('open --relaunch rejects URL targets', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['https://example.com/path'], - flags: { relaunch: true }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/does not support URL targets/i); - } -}); - -test('open --relaunch fails without app when no session exists', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'open', - positionals: [], - flags: { relaunch: true }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/requires an app argument/i); - } -}); - -test('open --relaunch rejects Android app binary paths', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects bare Android app binary filenames', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects Android app binary paths for active sessions', async () => { - const sessionStore = makeSessionStore(); - const session = makeSession('default', { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }); - session.appName = 'com.example.app'; - session.appBundleId = 'com.example.app'; - sessionStore.set('default', session); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects Android app binary paths for active sessions before device refresh', async () => { - const sessionStore = makeSessionStore(); - const session = makeSession('default', { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }); - session.appName = 'com.example.app'; - session.appBundleId = 'com.example.app'; - sessionStore.set('default', session); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects Android app binary paths before resolving a new device', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open on in-use device returns DEVICE_IN_USE before readiness checks', async () => { - const sessionStore = makeSessionStore(); - sessionStore.set( - 'busy-session', - makeSession('busy-session', { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - ); - - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['settings'], - flags: { platform: 'ios' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('DEVICE_IN_USE'); - expect(response.error.details?.hint).toContain('agent-device session list'); - expect(response.error.details?.hint).toContain('--session busy-session'); - expect(response.error.details?.hint).toContain('agent-device close --session busy-session'); - } - expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); -}); - -test('open on device owned by recording session returns recording recovery hint', async () => { - const sessionStore = makeSessionStore(); - const recordingSession = makeSession('default', { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }); - recordingSession.recordOnlySession = true; - recordingSession.recording = { - platform: 'ios', - child: { kill: vi.fn(), pid: 123 }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - outPath: '/tmp/recording.mp4', - startedAt: Date.now(), - showTouches: false, - gestureEvents: [], - }; - sessionStore.set('default', recordingSession); - - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'test-attempt', - command: 'open', - positionals: ['settings'], - flags: { platform: 'ios' }, - }, - sessionName: 'test-attempt', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('DEVICE_IN_USE'); - expect(response.error.details?.hint).toContain('Recording session "default" owns this device'); - expect(response.error.details?.hint).toContain('agent-device record stop --session default'); - expect(response.error.details?.hint).toContain('agent-device close --session default'); - expect(response.error.details?.hint).toContain('agent-device session list'); - } - expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); -}); - -test('replay parses open --relaunch flag and replays open with relaunch semantics', async () => { - const sessionStore = makeSessionStore(); - const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-relaunch-')); - const replayPath = path.join(replayRoot, 'relaunch.ad'); - fs.writeFileSync(replayPath, 'open "Settings" --relaunch\n'); - - const invoked: DaemonRequest[] = []; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'replay', - positionals: [replayPath], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (req) => { - invoked.push(req); - return { ok: true, data: {} }; - }, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.replayed).toBe(1); - } - expect(invoked.length).toBe(1); - expect(invoked[0]?.command).toBe('open'); - expect(invoked[0]?.positionals).toEqual(['Settings']); - expect(invoked[0]?.flags?.relaunch).toBe(true); -}); - -test('replay parses runtime set flags and replays runtime command', async () => { - const sessionStore = makeSessionStore(); - const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-runtime-')); - const replayPath = path.join(replayRoot, 'runtime.ad'); - fs.writeFileSync( - replayPath, - 'runtime set --platform android --metro-host 10.0.0.10 --metro-port 8081 --launch-url "myapp://dev"\n', - ); - const invoked: DaemonRequest[] = []; - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'replay', - positionals: [replayPath], - flags: {}, - meta: { cwd: replayRoot }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (request) => { - invoked.push(request); - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBe(true); - expect(invoked[0]?.command).toBe('runtime'); - expect(invoked[0]?.positionals).toEqual(['set']); - expect(invoked[0]?.flags).toEqual({ - platform: 'android', - metroHost: '10.0.0.10', - metroPort: 8081, - launchUrl: 'myapp://dev', - }); -}); - -test('replay parses inline open runtime flags and replays open with runtime payload', async () => { - const sessionStore = makeSessionStore(); - const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-open-runtime-')); - const replayPath = path.join(replayRoot, 'runtime-open.ad'); - fs.writeFileSync( - replayPath, - 'open "Demo" --relaunch --platform android --metro-host 10.0.0.10 --metro-port 8081 --launch-url "myapp://dev"\n', - ); - const invoked: DaemonRequest[] = []; - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'replay', - positionals: [replayPath], - flags: {}, - meta: { cwd: replayRoot }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (request) => { - invoked.push(request); - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBe(true); - expect(invoked[0]?.command).toBe('open'); - expect(invoked[0]?.positionals).toEqual(['Demo']); - expect(invoked[0]?.flags).toEqual({ relaunch: true }); - expect(invoked[0]?.runtime).toEqual({ - platform: 'android', - metroHost: '10.0.0.10', - metroPort: 8081, - launchUrl: 'myapp://dev', - }); -}); - -test('replay resolves relative script path against request cwd', async () => { - const sessionStore = makeSessionStore(); - const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-cwd-')); - const replayDir = path.join(replayRoot, 'workflows'); - fs.mkdirSync(replayDir, { recursive: true }); - fs.writeFileSync(path.join(replayDir, 'flow.ad'), 'open "Settings"\n'); - - const invoked: DaemonRequest[] = []; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'replay', - positionals: ['workflows/flow.ad'], - flags: {}, - meta: { cwd: replayRoot }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (req) => { - invoked.push(req); - return { ok: true, data: {} }; - }, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(invoked.length).toBe(1); - expect(invoked[0]?.command).toBe('open'); - expect(invoked[0]?.positionals).toEqual(['Settings']); -}); - -test('replay inherits parent device selectors for each invoked step', async () => { - const sessionStore = makeSessionStore(); - const replayRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'agent-device-replay-parent-selectors-'), - ); - const replayPath = path.join(replayRoot, 'selectors.ad'); - fs.writeFileSync(replayPath, 'open "com.whoop.iphone"\n'); - - const invoked: DaemonRequest[] = []; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'replay', - positionals: [replayPath], - flags: { - platform: 'ios', - device: 'thymikee-iphone', - udid: '00008150-001849640CF8401C', - }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (req) => { - invoked.push(req); - return { ok: true, data: {} }; - }, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(invoked.length).toBe(1); - expect(invoked[0]?.flags?.platform).toBe('ios'); - expect(invoked[0]?.flags?.device).toBe('thymikee-iphone'); - expect(invoked[0]?.flags?.udid).toBe('00008150-001849640CF8401C'); -}); - -test('logs requires an active session', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'logs', - positionals: ['path'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - } -}); - -test('logs rejects invalid action', async () => { - const sessionStore = makeSessionStore(); - sessionStore.set( - 'default', - makeSession('default', { - platform: 'apple', - id: 'sim-1', - name: 'iPhone', - kind: 'simulator', - booted: true, - }), - ); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'logs', - positionals: ['invalid'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/path, start, stop, doctor, mark, or clear/); - } -}); - -test('logs start requires app session (appBundleId)', async () => { - const sessionStore = makeSessionStore(); - sessionStore.set( - 'default', - makeSession('default', { - platform: 'apple', - id: 'sim-1', - name: 'iPhone', - kind: 'simulator', - booted: true, - }), - ); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'logs', - positionals: ['start'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/app session|open first/i); - } -}); - -test('logs stop requires active app log stream', async () => { - const sessionStore = makeSessionStore(); - sessionStore.set( - 'default', - makeSession('default', { - platform: 'apple', - id: 'sim-1', - name: 'iPhone', - kind: 'simulator', - booted: true, - }), - ); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'logs', - positionals: ['stop'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/no app log stream/i); - } -}); - -test('logs clear requires stream to be stopped first', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: '/tmp/app.log', - startedAt: Date.now(), - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: ['clear'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/logs stop/i); - } -}); - -test('logs --restart is only supported with logs clear', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.example.app', - }); - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: ['path'], - flags: { restart: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/only supported with logs clear/i); - } -}); - -test('logs clear --restart requires app session bundle id', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, - }), - ); - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: ['clear'], - flags: { restart: true }, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/app session|open /i); - } -}); - -function makeIosDeviceLogSession(): { - sessionStore: ReturnType; - sessionName: string; -} { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-console-logs'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'ios', - id: '00008150-0000AAAA', - name: 'iPhone', - kind: 'device', - }), - appBundleId: 'com.example.app', - }); - return { sessionStore, sessionName }; -} - -function mockIosDeviceLogBackend(): void { - mockStartAppLog.mockResolvedValue({ - backend: 'ios-device', - startedAt: 1_712_040_000_000, - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }); - mockRunAppLogDoctor.mockResolvedValue({ - checks: { devicectlAvailable: true, devicectlConsoleCapture: true }, - notes: [], - }); -} - -function mockUnsupportedIosDeviceLogBackend(): void { - mockStartAppLog.mockRejectedValue( - new AppError('UNSUPPORTED_OPERATION', IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.message, { - backend: 'ios-device', - hint: IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED.hint, - }), - ); - mockRunAppLogDoctor.mockResolvedValue({ - checks: { devicectlAvailable: true, devicectlConsoleCapture: false }, - notes: [IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE], - }); -} - -async function runLogsCommandForSession( - sessionStore: ReturnType, - sessionName: string, - action: 'clear' | 'path' | 'doctor', - flags: Record = {}, -) { - return await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'logs', - positionals: [action], - flags, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); -} - -function expectActiveIosDeviceLogsPath( - response: Awaited>, -) { - expect(response?.ok).toBe(true); - if (!response || !response.ok) return; - expect(response.data?.active).toBe(true); - expect(response.data?.state).toBe('active'); - expect(response.data?.backend).toBe('ios-device'); - expect(response.data?.failureCode).toBeUndefined(); - expect(response.data?.failureMessage).toBeUndefined(); - expect(response.data?.startedAt).toBe('2024-04-02T06:40:00.000Z'); -} - -function expectEndedIosDeviceLogsPath(response: Awaited>) { - expect(response?.ok).toBe(true); - if (!response || !response.ok) return; - expect(response.data?.active).toBe(false); - expect(response.data?.state).toBe('ended'); - expect(response.data?.backend).toBe('ios-device'); - expect(response.data?.notes).toContain( - 'The app log stream process ended. Run logs clear --restart before the next capture window.', - ); -} - -function expectActiveIosDeviceLogsDoctor( - response: Awaited>, -) { - expect(response?.ok).toBe(true); - if (!response || !response.ok) return; - expect(response.data?.active).toBe(true); - expect(response.data?.state).toBe('active'); - expect(response.data?.backend).toBe('ios-device'); - expect(response.data?.checks).toEqual({ - devicectlAvailable: true, - devicectlConsoleCapture: true, - }); - expect(response.data?.notes).toEqual([]); -} - -function expectUnsupportedIosDeviceLogsDoctor( - response: Awaited>, -) { - expect(response?.ok).toBe(true); - if (!response || !response.ok) return; - expect(response.data?.active).toBe(false); - expect(response.data?.state).toBe('failed'); - expect(response.data?.backend).toBe('ios-device'); - expect(response.data?.failureCode).toBe('UNSUPPORTED_OPERATION'); - expect(response.data?.notes).toEqual([IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE]); -} - -test('logs clear --restart starts active iOS physical-device console capture', async () => { - const { sessionStore, sessionName } = makeIosDeviceLogSession(); - mockIosDeviceLogBackend(); - - const restartResponse = await runLogsCommandForSession(sessionStore, sessionName, 'clear', { - restart: true, - }); - expect(restartResponse?.ok).toBe(true); - if (restartResponse && restartResponse.ok) { - expect(restartResponse.data?.restarted).toBe(true); - } - expect(mockStartAppLog).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: '00008150-0000AAAA' }), - 'com.example.app', - expect.stringContaining('app.log'), - expect.stringContaining('app-log.pid'), - ); - - expectActiveIosDeviceLogsPath(await runLogsCommandForSession(sessionStore, sessionName, 'path')); - expectActiveIosDeviceLogsDoctor( - await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), - ); -}); - -test('logs path reports cleanly ended iOS physical-device console capture as inactive', async () => { - const { sessionStore, sessionName } = makeIosDeviceLogSession(); - const session = sessionStore.get(sessionName); - if (!session) throw new Error('Expected test session'); - sessionStore.set(sessionName, { - ...session, - appLog: { - platform: 'apple', - backend: 'ios-device', - outPath: '/tmp/app.log', - startedAt: 1_712_040_000_000, - getState: () => 'ended', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }); - - expectEndedIosDeviceLogsPath(await runLogsCommandForSession(sessionStore, sessionName, 'path')); -}); - -test('logs doctor deduplicates unsupported iOS physical-device console capture notes', async () => { - const { sessionStore, sessionName } = makeIosDeviceLogSession(); - mockUnsupportedIosDeviceLogBackend(); - - const restartResponse = await runLogsCommandForSession(sessionStore, sessionName, 'clear', { - restart: true, - }); - expect(restartResponse?.ok).toBe(false); - expectUnsupportedIosDeviceLogsDoctor( - await runLogsCommandForSession(sessionStore, sessionName, 'doctor'), - ); -}); - -test('network requires an active session', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'network', - positionals: ['dump'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - } -}); - -test('network dump adds a targeted note when the session app log stream is inactive', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-network-inactive'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: '/tmp/app.log', - startedAt: Date.now(), - getState: () => 'failed', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.active).toBe(true); - expect(response.data?.state).toBe('failed'); - expect(response.data?.notes).toContain( - 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', - ); - } -}); - -test('network dump recovers Android entries from adb logcat when the session stream is inactive', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-network-recovery'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: '/tmp/app.log', - startedAt: Date.now(), - getState: () => 'failed', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { - return { - stdout: - '04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + - '04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/documents status=200 duration=15032\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.path).toContain('adb logcat recovery'); - expect(response.data?.state).toBe('failed'); - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(1); - const latest = entries[0] as Record; - expect(latest.method).toBe('POST'); - expect(latest.url).toBe('https://api.example.com/v1/documents'); - expect(latest.status).toBe(200); - expect(response.data?.notes).toContain( - 'Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set 4321.', - ); - } -}); - -test('network dump merges Android recovery entries ahead of stale session log traffic', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-network-merge'; - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - '2026-04-01T09:59:00Z GET https://api.example.com/v1/stale status=200\n', - 'utf8', - ); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: appLogPath, - startedAt: Date.now(), - getState: () => 'failed', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { - return { - stdout: - '04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + - '04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/fresh status=201 duration=15032\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(2); - expect((entries[0] as Record).url).toBe('https://api.example.com/v1/fresh'); - expect((entries[1] as Record).url).toBe('https://api.example.com/v1/stale'); - } -}); - -test('network dump recovers Android entries from previous package pid in bounded logcat window', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-network-previous-pid'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: '/tmp/app.log', - startedAt: Date.now(), - getState: () => 'failed', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { - return { - stdout: - '04-01 10:00:00.000 I/ActivityManager( 9999): Process com.example.app (pid 1234) has died\n' + - '04-01 10:00:00.500 D/GIBSDK (1234): POST https://api.example.com/v1/submit status=504 duration=15000\n' + - '04-01 10:00:01.000 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(1); - expect((entries[0] as Record).url).toBe('https://api.example.com/v1/submit'); - expect(response.data?.notes).toContain( - 'Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set 4321, 1234.', - ); - } -}); - -test('network dump recovers Android entries when an active stream is still bound to a prior pid', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-network-stale-active-pid'; - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - const appLogPidPath = sessionStore.resolveAppLogPidPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - '2026-04-01T09:59:00Z GET https://api.example.com/v1/stale status=200\n', - 'utf8', - ); - fs.writeFileSync( - appLogPidPath, - `${JSON.stringify({ - pid: 9999, - startTime: 'Tue Apr 1 09:59:00 2026', - command: 'adb -s emulator-5554 logcat -v time --pid 1234', - })}\n`, - 'utf8', - ); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: { - platform: 'android', - backend: 'android', - outPath: appLogPath, - startedAt: Date.now(), - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { - return { - stdout: - '04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + - '04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/fresh status=201 duration=15032\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.path).toContain('adb logcat recovery'); - expect(response.data?.state).toBe('active'); - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(2); - expect((entries[0] as Record).url).toBe('https://api.example.com/v1/fresh'); - expect((entries[1] as Record).url).toBe('https://api.example.com/v1/stale'); - expect(response.data?.notes).toContain( - 'Session app log stream was still bound to prior Android PID 1234. Recovered recent Android HTTP entries from adb logcat for PID set 4321.', - ); - } -}); - -test('network dump recovers iOS simulator entries from simctl log show when the live stream is empty', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-network-recovery'; - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - 'Filtering the log data using "subsystem == \\"com.agentdevice.tester\\""\n', - 'utf8', - ); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.agentdevice.tester', - appLog: { - platform: 'apple', - backend: 'ios-simulator', - outPath: appLogPath, - startedAt: 1_712_040_000_000, - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if ( - args[0] === 'simctl' && - args[1] === 'spawn' && - args[2] === 'sim-1' && - args[3] === 'log' && - args[4] === 'show' - ) { - return { - stdout: - 'Timestamp Ty Process[PID:TID]\n' + - '2026-04-02 08:08:50.665 I Agent Device Tester[32193:8c7411e] POST https://api.example.com/v1/search statusCode=200 duration=42\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.path).toContain('simctl log show recovery'); - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(1); - expect((entries[0] as Record).url).toBe('https://api.example.com/v1/search'); - expect((entries[0] as Record).status).toBe(200); - expect((entries[0] as Record).durationMs).toBe(42); - expect(response.data?.notes).toContain( - 'Recovered 1 iOS simulator HTTP entry from simctl log show (1 app log lines scanned).', - ); - } -}); - -test('network dump explains when iOS simulator recovery found app logs but no HTTP-shaped entries', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-network-no-http'; - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - 'Filtering the log data using "subsystem == \\"com.agentdevice.tester\\""\n', - 'utf8', - ); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.agentdevice.tester', - appLog: { - platform: 'apple', - backend: 'ios-simulator', - outPath: appLogPath, - startedAt: 1_712_040_000_000, - getState: () => 'active', - stop: async () => {}, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if ( - args[0] === 'simctl' && - args[1] === 'spawn' && - args[2] === 'sim-1' && - args[3] === 'log' && - args[4] === 'show' - ) { - return { - stdout: - 'Timestamp Ty Process[PID:TID]\n' + - '2026-04-02 08:08:50.665 E Agent Device Tester[32193:8c7411e] Airship config warning\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(Array.isArray(response.data?.entries) ? response.data.entries : []).toHaveLength(0); - expect(response.data?.notes).toContain( - 'Recovered 1 recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.', - ); - expect(response.data?.notes).toContain( - 'No HTTP(s) entries were found in recent iOS simulator app logs. If the app only emits non-HTTP diagnostics, inspect logs path or add app-side URLSession/network logging for per-request timing and payload details.', - ); - } -}); - -test('network dump supports macOS desktop sessions', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-network'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - appBundleId: 'com.apple.systempreferences', - }); - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - '2026-02-24T10:00:00Z GET https://example.com/mac status=204', - 'utf8', - ); - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.backend).toBe('macos'); - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(1); - expect((entries[0] as Record).url).toBe('https://example.com/mac'); - } -}); - -test('network dump validates include mode and limit', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, - }), - ); - - const invalidLimit = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '0'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(invalidLimit).toBeTruthy(); - expect(invalidLimit?.ok).toBe(false); - if (invalidLimit && !invalidLimit.ok) { - expect(invalidLimit.error.code).toBe('INVALID_ARGS'); - expect(invalidLimit.error.message).toMatch(/1\.\.200/); - } - - const invalidMode = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'verbose'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(invalidMode).toBeTruthy(); - expect(invalidMode?.ok).toBe(false); - if (invalidMode && !invalidMode.ok) { - expect(invalidMode.error.code).toBe('INVALID_ARGS'); - expect(invalidMode.error.message).toMatch(/summary, headers, body, all/); - } -}); - -test('session_list includes device_udid and ios_simulator_device_set for iOS sessions', async () => { - const sessionStore = makeSessionStore(); - sessionStore.set( - 'ios-scoped', - makeSession('ios-scoped', { - platform: 'apple', - id: 'DEF-456', - name: 'iPhone 16', - kind: 'simulator', - booted: true, - simulatorSetPath: '/tmp/tenant-a/simulators', - }), - ); - sessionStore.set( - 'android-1', - makeSession('android-1', { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - ); - sessionStore.set( - 'macos-1', - makeSession('macos-1', { - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - ); - - const response = await handleSessionCommands({ - req: { token: 't', session: 'default', command: 'session_list', positionals: [] }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - if (response && response.ok) { - const sessions = response.data?.sessions as Array>; - expect(Array.isArray(sessions)).toBeTruthy(); - const iosScoped = sessions.find((s) => s.name === 'ios-scoped'); - expect(iosScoped?.device_udid).toBe('DEF-456'); - expect(iosScoped?.ios_simulator_device_set).toBe('/tmp/tenant-a/simulators'); - const android = sessions.find((s) => s.name === 'android-1'); - const macos = sessions.find((s) => s.name === 'macos-1'); - expect(android?.device_udid).toBe(undefined); - expect(android?.ios_simulator_device_set).toBe(undefined); - expect(android?.device_id).toBe('emulator-5554'); - expect(macos?.device_id).toBe('host-macos-local'); - expect(macos?.device_udid).toBe(undefined); - expect(macos?.ios_simulator_device_set).toBe(undefined); - } -}); - -test('test filters replay scripts by context platform and skips untyped files', async () => { - const sessionStore = makeSessionStore(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-filter-')); - fs.writeFileSync(path.join(root, '01-android.ad'), 'context platform=android\nopen "Demo"\n'); - fs.writeFileSync(path.join(root, '02-ios.ad'), 'context platform=ios\nopen "Settings"\n'); - fs.writeFileSync(path.join(root, '03-untyped.ad'), 'open "Calculator"\n'); - - const invoked: DaemonRequest[] = []; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'test', - positionals: [root], - flags: { platform: 'android' }, - meta: { cwd: root, requestId: 'suite-filter' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (req) => { - invoked.push(req); - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBeTruthy(); - expect(invoked.length).toBe(1); - expect(invoked[0]?.flags?.platform).toBe('android'); - expect(invoked[0]?.session).toBe('default:test:suite-filter:1-01-android:attempt-1'); - if (response?.ok) { - expect(response.data?.passed).toBe(1); - expect(response.data?.failed).toBe(0); - expect(response.data?.skipped).toBe(1); - const tests = response.data?.tests as Array> | undefined; - expect(tests?.length).toBe(2); - expect(tests?.[0]?.status).toBe('passed'); - expect(tests?.[1]?.status).toBe('skipped'); - expect(tests?.[1]?.reason).toBe('skipped-by-filter'); - } -}); - -test('test binds each replay script to its declared platform metadata', async () => { - const sessionStore = makeSessionStore(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-platforms-')); - fs.writeFileSync(path.join(root, '01-android.ad'), 'context platform=android\nopen "Demo"\n'); - fs.writeFileSync(path.join(root, '02-ios.ad'), 'context platform=ios\nopen "Settings"\n'); - - const invoked: DaemonRequest[] = []; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'test', - positionals: [root], - meta: { cwd: root, requestId: 'suite-platforms' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (req) => { - invoked.push(req); - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBeTruthy(); - expect(invoked.map((req) => req.flags?.platform)).toEqual(['android', 'ios']); - expect(invoked.map((req) => req.session)).toEqual([ - 'default:test:suite-platforms:1-01-android:attempt-1', - 'default:test:suite-platforms:2-02-ios:attempt-1', - ]); - if (response?.ok) { - expect(response.data?.passed).toBe(2); - expect(response.data?.failed).toBe(0); - expect(response.data?.skipped).toBe(0); - } -}); - -test('test cleans up suite-owned sessions after each executed script', async () => { - const sessionStore = makeSessionStore(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-cleanup-')); - fs.writeFileSync(path.join(root, '01-android.ad'), 'context platform=android\nopen "Demo"\n'); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'test', - positionals: [root], - meta: { cwd: root, requestId: 'suite-cleanup' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (req) => { - sessionStore.set( - req.session, - makeSession(req.session, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - ); - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBeTruthy(); - expect(sessionStore.get('default:test:suite-cleanup:1-01-android:attempt-1')).toBe(undefined); -}); - -test('test retries failed scripts with fresh suite-owned sessions', async () => { - const sessionStore = makeSessionStore(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-retries-')); - fs.writeFileSync( - path.join(root, '01-retry.ad'), - 'context platform=android retries=9\nopen "Demo"\n', - ); - - const invoked: DaemonRequest[] = []; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'test', - positionals: [root], - meta: { cwd: root, requestId: 'suite-retries' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (req) => { - invoked.push(req); - if (invoked.length < 4) { - return { - ok: false, - error: { - code: 'ASSERTION_FAILED', - message: 'expected selector to exist', - }, - }; - } - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBeTruthy(); - expect(invoked.map((req) => req.session)).toEqual([ - 'default:test:suite-retries:1-01-retry:attempt-1', - 'default:test:suite-retries:1-01-retry:attempt-2', - 'default:test:suite-retries:1-01-retry:attempt-3', - 'default:test:suite-retries:1-01-retry:attempt-4', - ]); - if (response?.ok) { - expect(response.data?.passed).toBe(1); - expect(response.data?.failed).toBe(0); - const tests = response.data?.tests as Array> | undefined; - expect(tests?.[0]?.status).toBe('passed'); - expect(tests?.[0]?.attempts).toBe(4); - } -}); - -test('test applies per-script timeout and writes attempt artifacts', async () => { - const sessionStore = makeSessionStore(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-timeout-')); - const screenshotPath = path.join(root, 'capture.png'); - fs.writeFileSync(screenshotPath, 'screenshot'); - fs.writeFileSync( - path.join(root, '01-timeout.ad'), - 'context platform=android timeout=10\nscreenshot "./capture.png"\nopen "Demo"\n', - ); - - let invocationCount = 0; - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'test', - positionals: [root], - meta: { cwd: root, requestId: 'suite-timeout' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: async (_req) => { - invocationCount += 1; - if (invocationCount === 1) { - return { ok: true, data: { path: screenshotPath } }; - } - await new Promise((resolve) => setTimeout(resolve, 25)); - return { ok: true, data: {} }; - }, - }); - - expect(response?.ok).toBeTruthy(); - if (response?.ok) { - expect(response.data?.failed).toBe(1); - const tests = response.data?.tests as Array> | undefined; - expect(tests?.[0]?.status).toBe('failed'); - expect(tests?.[0]?.attempts).toBe(1); - const artifactsDir = tests?.[0]?.artifactsDir; - expect(typeof artifactsDir).toBe('string'); - const attemptDir = path.join(artifactsDir as string, 'attempt-1'); - expect(fs.existsSync(path.join(attemptDir, 'replay.ad'))).toBe(true); - expect(fs.existsSync(path.join(attemptDir, 'capture.png'))).toBe(true); - expect(fs.existsSync(path.join(attemptDir, 'replay-timing.ndjson'))).toBe(true); - expect(fs.existsSync(path.join(attemptDir, 'result.txt'))).toBe(true); - expect(fs.existsSync(path.join(attemptDir, 'failure.txt'))).toBe(true); - const timingLines = fs - .readFileSync(path.join(attemptDir, 'replay-timing.ndjson'), 'utf8') - .trim() - .split('\n') - .map((line) => JSON.parse(line) as Record); - expect(timingLines.some((line) => line.type === 'replay_test_attempt_start')).toBe(true); - expect(timingLines.some((line) => line.type === 'replay_action_start')).toBe(true); - expect( - timingLines.some( - (line) => line.type === 'replay_test_attempt_stop' && line.timedOut === true, - ), - ).toBe(true); - const resultText = fs.readFileSync(path.join(attemptDir, 'result.txt'), 'utf8'); - expect(resultText).toMatch(/status: failed/); - expect(resultText).toMatch(/timeoutMode: cooperative/); - } -}); - -test('open does not retain a session when the request was canceled before completion', async () => { - const sessionStore = makeSessionStore(); - const requestId = 'open-canceled-before-store'; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - target: 'mobile', - booted: true, - } as any); - - markRequestCanceled(requestId); - try { - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['com.apple.Preferences'], - flags: { platform: 'ios' }, - meta: { requestId }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toBe('request canceled'); - } - expect(sessionStore.get('default')).toBeUndefined(); - } finally { - clearRequestCanceled(requestId); - } -}); - -test('test returns invalid args when no replay scripts match the platform filter', async () => { - const sessionStore = makeSessionStore(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-empty-filter-')); - fs.writeFileSync(path.join(root, '01-ios.ad'), 'context platform=ios\nopen "Settings"\n'); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'test', - positionals: [root], - flags: { platform: 'android' }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage(response, 'No replay tests matched for --platform android.'); -}); - -test('test rejects duplicate replay test metadata in the context header', async () => { - const sessionStore = makeSessionStore(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-metadata-')); - fs.writeFileSync( - path.join(root, '01-invalid.ad'), - 'context platform=ios timeout=1000\ncontext timeout=2000\nopen "Demo"\n', - ); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'test', - positionals: [root], - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Conflicting replay test metadata "timeoutMs" in context header: 1000 vs 2000.', - ); -}); diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts index c1084b935..fd7ca74d9 100644 --- a/src/platforms/android/__tests__/index.test.ts +++ b/src/platforms/android/__tests__/index.test.ts @@ -2012,7 +2012,7 @@ test('fillAndroid uses chunk-safe shell input and retries when verification stil assert.ok(shellInputTextCount > 1); }, ); -}); +}, 15_000); test('fillAndroid keeps delayed typing in typed-input mode', async () => { await withMockedAdb( @@ -2059,7 +2059,7 @@ test('fillAndroid keeps delayed typing in typed-input mode', async () => { assert.doesNotMatch(logged, /shell\ninput\nkeyevent\nKEYCODE_PASTE/); }, ); -}); +}, 15_000); test('fillAndroid tolerates delayed React Native text verification', async () => { await withMockedAdb( diff --git a/src/platforms/apple/core/app-device-io.ts b/src/platforms/apple/core/app-device-io.ts new file mode 100644 index 000000000..6c37053e3 --- /dev/null +++ b/src/platforms/apple/core/app-device-io.ts @@ -0,0 +1,62 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; +import { readMacOsClipboardText, writeMacOsClipboardText } from '../os/macos/apps.ts'; +import { runSimctl } from './apps-simctl.ts'; + +export async function readIosClipboardText(device: DeviceInfo): Promise { + if (isMacOs(device)) { + return await readMacOsClipboardText(); + } + requireSimulatorDevice(device, 'clipboard'); + await ensureBootedSimulator(device); + const result = await runSimctl(device, ['pbpaste', device.id], { allowFailure: true }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to read iOS simulator clipboard', { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + return result.stdout.replace(/\r\n/g, '\n').replace(/\n$/, ''); +} + +export async function writeIosClipboardText(device: DeviceInfo, text: string): Promise { + if (isMacOs(device)) { + await writeMacOsClipboardText(text); + return; + } + requireSimulatorDevice(device, 'clipboard'); + await ensureBootedSimulator(device); + const result = await runSimctl(device, ['pbcopy', device.id], { + allowFailure: true, + stdin: text, + }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to write iOS simulator clipboard', { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } +} + +export async function pushIosNotification( + device: DeviceInfo, + bundleId: string, + payload: Record, +): Promise { + requireSimulatorDevice(device, 'push'); + await ensureBootedSimulator(device); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-push-')); + const payloadPath = path.join(tempDir, 'payload.apns'); + try { + await fs.writeFile(payloadPath, `${JSON.stringify(payload)}\n`, 'utf8'); + await runSimctl(device, ['push', device.id, bundleId, payloadPath]); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} diff --git a/src/platforms/apple/core/app-install.ts b/src/platforms/apple/core/app-install.ts new file mode 100644 index 000000000..4226f8701 --- /dev/null +++ b/src/platforms/apple/core/app-install.ts @@ -0,0 +1,127 @@ +import type { DeviceInfo } from '../../../kernel/device.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { IOS_DEVICE_INSTALL_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts'; +import { runIosDevicectl } from './devicectl.ts'; +import { prepareIosInstallArtifact } from './install-artifact.ts'; +import { ensureBootedSimulator } from './simulator.ts'; +import { runXcrun } from './tool-provider.ts'; +import { + invalidateIosAppResolutionCache, + maybeResolveIosDevicectlHint, + resolveIosApp, +} from './app-resolution.ts'; +import { isMissingAppErrorOutput, runSimctl } from './apps-simctl.ts'; + +type InstallIosAppOptions = { + appIdentifierHint?: string; +}; + +async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundleId: string }> { + return await invalidateIosAppResolutionCache(device, async () => { + const bundleId = await resolveIosApp(device, app); + if (device.kind !== 'simulator') { + const args = ['devicectl', 'device', 'uninstall', 'app', '--device', device.id, bundleId]; + const result = await runXcrun(args, { + allowFailure: true, + timeoutMs: IOS_DEVICECTL_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + const stdout = String(result.stdout ?? ''); + const stderr = String(result.stderr ?? ''); + const output = `${stdout}\n${stderr}`.toLowerCase(); + if (!isMissingAppErrorOutput(output)) { + throw new AppError('COMMAND_FAILED', `Failed to uninstall iOS app ${bundleId}`, { + cmd: 'xcrun', + args, + exitCode: result.exitCode, + stdout, + stderr, + deviceId: device.id, + hint: maybeResolveIosDevicectlHint(stdout, stderr), + }); + } + } + return { bundleId }; + } + + await ensureBootedSimulator(device); + + const result = await runSimctl(device, ['uninstall', device.id, bundleId], { + allowFailure: true, + }); + if (result.exitCode !== 0) { + const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); + if (!isMissingAppErrorOutput(output)) { + throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + } + + return { bundleId }; + }); +} + +export async function installIosApp( + device: DeviceInfo, + appPath: string, + options?: InstallIosAppOptions, +): Promise<{ + archivePath?: string; + installablePath: string; + bundleId?: string; + appName?: string; + launchTarget?: string; +}> { + const prepared = await prepareIosInstallArtifact({ kind: 'path', path: appPath }, options); + try { + await installIosInstallablePath(device, prepared.installablePath); + return { + archivePath: prepared.archivePath, + installablePath: prepared.installablePath, + bundleId: prepared.bundleId, + appName: prepared.appName, + launchTarget: prepared.bundleId, + }; + } finally { + await prepared.cleanup(); + } +} + +export async function reinstallIosApp( + device: DeviceInfo, + app: string, + appPath: string, +): Promise<{ bundleId: string }> { + return await invalidateIosAppResolutionCache(device, async () => { + const { bundleId } = await uninstallIosApp(device, app); + await installIosApp(device, appPath, { appIdentifierHint: app }); + return { bundleId }; + }); +} + +export async function installIosInstallablePath( + device: DeviceInfo, + installablePath: string, +): Promise { + await invalidateIosAppResolutionCache(device, async () => { + if (device.kind !== 'simulator') { + await runIosDevicectl( + ['device', 'install', 'app', '--device', device.id, installablePath], + { + action: 'install iOS app', + deviceId: device.id, + }, + { + timeoutMs: IOS_DEVICE_INSTALL_TIMEOUT_MS, + }, + ); + return; + } + + await ensureBootedSimulator(device); + await runSimctl(device, ['install', device.id, installablePath]); + }); +} diff --git a/src/platforms/apple/core/app-launch.ts b/src/platforms/apple/core/app-launch.ts new file mode 100644 index 000000000..72e4ffc04 --- /dev/null +++ b/src/platforms/apple/core/app-launch.ts @@ -0,0 +1,323 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { isIosFamily, isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { emitDiagnostic } from '../../../utils/diagnostics.ts'; +import { + LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE, + LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE, +} from '../../../core/launch-console.ts'; +import { Deadline, retryWithPolicy } from '../../../utils/retry.ts'; +import { + isDeepLinkTarget, + isWebUrl, + resolveIosDeviceDeepLinkBundleId, +} from '../../../core/open-target.ts'; +import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_SIMULATOR_TERMINATE_TIMEOUT_MS } from './config.ts'; +import { runIosDevicectl } from './devicectl.ts'; +import { + isSimulatorLaunchFBSError, + probeSimulatorLaunchContext, + classifyLaunchFailure, + launchFailureHint, +} from './launch-diagnostics.ts'; +import { ensureBootedSimulator, getSimulatorState } from './simulator.ts'; +import { runXcrun } from './tool-provider.ts'; +import { closeMacOsApp, openMacOsApp } from '../os/macos/apps.ts'; +import { resolveIosApp } from './app-resolution.ts'; +import { runSimctl, simctlArgs } from './apps-simctl.ts'; + +const IOS_SIMULATOR_CONSOLE_CAPTURE_MS = 25_000; +const IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE = + '--launch-args is not supported with iOS simulator URL opens (simctl openurl ignores launch args). Launch the app first with --launch-args, then issue the URL open in a separate call.'; + +// fallow-ignore-next-line complexity +export async function openIosApp( + device: DeviceInfo, + app: string, + options?: { + appBundleId?: string; + launchConsole?: string; + launchArgs?: string[]; + terminateRunningApp?: boolean; + url?: string; + }, +): Promise { + const launchConsole = options?.launchConsole?.trim(); + const launchArgs = options?.launchArgs; + if (launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) { + throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); + } + if (isMacOs(device)) { + if (launchArgs && launchArgs.length > 0) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-args is not supported on macOS; launch arguments are currently iOS-only.', + ); + } + await openMacOsApp(device, app, options); + return; + } + const explicitUrl = options?.url?.trim(); + if (explicitUrl) { + if (launchConsole) { + throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); + } + if (!isDeepLinkTarget(explicitUrl)) { + throw new AppError('INVALID_ARGS', 'open requires a valid URL target'); + } + if (device.kind === 'simulator') { + if (launchArgs || isWebUrl(explicitUrl)) { + const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); + await launchIosSimulatorApp(device, bundleId, { + ...(launchArgs ? { launchArgs } : {}), + }); + } + await openIosSimulatorUrl(device, explicitUrl, undefined); + return; + } + const appBundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); + const bundleId = resolveIosDeviceDeepLinkBundleId(appBundleId, explicitUrl); + if (!bundleId) { + throw new AppError( + 'INVALID_ARGS', + 'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.', + ); + } + await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl, launchArgs }); + return; + } + + const deepLinkTarget = app.trim(); + if (isDeepLinkTarget(deepLinkTarget)) { + if (launchConsole) { + throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); + } + if (device.kind === 'simulator') { + await openIosSimulatorUrl(device, deepLinkTarget, launchArgs); + return; + } + const bundleId = resolveIosDeviceDeepLinkBundleId(options?.appBundleId, deepLinkTarget); + if (!bundleId) { + throw new AppError( + 'INVALID_ARGS', + 'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.', + ); + } + await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget, launchArgs }); + return; + } + + const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); + if (device.kind === 'simulator') { + await launchIosSimulatorApp(device, bundleId, { + ...(launchConsole ? { launchConsole } : {}), + ...(launchArgs ? { launchArgs } : {}), + ...(options?.terminateRunningApp ? { terminateRunningApp: true } : {}), + }); + return; + } + + await launchIosDeviceProcess(device, bundleId, { launchArgs }); +} + +async function openIosSimulatorUrl( + device: DeviceInfo, + url: string, + launchArgs: string[] | undefined, +): Promise { + if (launchArgs && launchArgs.length > 0) { + throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE); + } + await ensureBootedSimulator(device); + await runSimctl(device, ['openurl', device.id, url]); +} + +export async function openIosDevice(device: DeviceInfo): Promise { + if (isMacOs(device)) { + return; + } + if (device.kind !== 'simulator') return; + const state = await getSimulatorState(device); + if (state === 'Booted') return; + + await ensureBootedSimulator(device); +} + +export async function closeIosApp(device: DeviceInfo, app: string): Promise { + if (isMacOs(device)) { + await closeMacOsApp(device, app); + return; + } + const bundleId = await resolveIosApp(device, app); + if (device.kind === 'simulator') { + await ensureBootedSimulator(device); + const terminateArgs = simctlArgs(device, ['terminate', device.id, bundleId]); + const result = await runXcrun(terminateArgs, { + allowFailure: true, + timeoutMs: IOS_SIMULATOR_TERMINATE_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + const stderr = result.stderr.toLowerCase(); + if (stderr.includes('found nothing to terminate')) return; + throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, { + cmd: 'xcrun', + args: terminateArgs, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + return; + } + + await runIosDevicectl(['device', 'process', 'terminate', '--device', device.id, bundleId], { + action: 'terminate iOS app', + deviceId: device.id, + }); +} + +async function launchIosSimulatorApp( + device: DeviceInfo, + bundleId: string, + options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, +): Promise { + await ensureBootedSimulator(device); + + let consecutiveFBSFailures = 0; + const MAX_CONSECUTIVE_FBS_FAILURES = 3; + + const launchDeadline = Deadline.fromTimeoutMs(IOS_APP_LAUNCH_TIMEOUT_MS); + try { + await retryWithPolicy( + async ({ deadline: attemptDeadline }) => { + if (attemptDeadline?.isExpired()) { + throw new AppError('COMMAND_FAILED', 'App launch deadline exceeded', { + timeoutMs: IOS_APP_LAUNCH_TIMEOUT_MS, + }); + } + + const launchArgs = simctlArgs( + device, + buildIosSimulatorLaunchArgs(device.id, bundleId, options), + ); + const result = options?.launchConsole + ? await runIosSimulatorConsoleLaunch(launchArgs, options.launchConsole) + : await runXcrun(launchArgs, { + allowFailure: true, + }); + if (result.exitCode === 0) return; + + throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, { + cmd: 'xcrun', + args: launchArgs, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + }, + { + maxAttempts: 10, + baseDelayMs: 1_000, + maxDelayMs: 5_000, + jitter: 0.2, + shouldRetry(error: unknown) { + if (!isSimulatorLaunchFBSError(error)) return false; + consecutiveFBSFailures += 1; + return consecutiveFBSFailures < MAX_CONSECUTIVE_FBS_FAILURES; + }, + }, + { deadline: launchDeadline }, + ); + } catch (error) { + if (isSimulatorLaunchFBSError(error)) { + const appError = error as AppError; + const probe = await probeSimulatorLaunchContext(device, bundleId); + const reason = classifyLaunchFailure(probe); + appError.details = { ...appError.details, hint: launchFailureHint(reason) }; + } + throw error; + } +} + +function buildIosSimulatorLaunchArgs( + deviceId: string, + bundleId: string, + options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, +): string[] { + const args = ['launch']; + if (options?.launchConsole) args.push('--console-pty'); + if (options?.terminateRunningApp) args.push('--terminate-running-process'); + args.push(deviceId, bundleId); + if (options?.launchArgs && options.launchArgs.length > 0) { + args.push(...options.launchArgs); + } + return args; +} + +async function runIosSimulatorConsoleLaunch( + launchArgs: string[], + logPath: string, +): Promise>> { + await fs.mkdir(path.dirname(logPath), { recursive: true }); + try { + const result = await runXcrun(launchArgs, { + allowFailure: true, + timeoutMs: IOS_SIMULATOR_CONSOLE_CAPTURE_MS, + }); + await writeIosSimulatorConsoleLog(logPath, result.stdout, result.stderr); + return result; + } catch (error) { + const appError = error instanceof AppError ? error : undefined; + const details = appError?.details; + if (details?.timeoutMs === IOS_SIMULATOR_CONSOLE_CAPTURE_MS) { + const stdout = typeof details.stdout === 'string' ? details.stdout : ''; + const stderr = typeof details.stderr === 'string' ? details.stderr : ''; + await writeIosSimulatorConsoleLog(logPath, stdout, stderr); + emitDiagnostic({ + level: 'warn', + phase: 'ios_simulator_launch_console_capture_timeout', + data: { + timeoutMs: IOS_SIMULATOR_CONSOLE_CAPTURE_MS, + logPath, + stdoutBytes: Buffer.byteLength(stdout), + stderrBytes: Buffer.byteLength(stderr), + }, + }); + return { stdout, stderr, exitCode: 0 }; + } + throw error; + } +} + +async function writeIosSimulatorConsoleLog( + logPath: string, + stdout: string, + stderr: string, +): Promise { + await fs.writeFile(logPath, joinProcessOutput(stdout, stderr), 'utf8'); +} + +function joinProcessOutput(stdout: string, stderr: string): string { + if (!stdout || !stderr || stdout.endsWith('\n') || stdout.endsWith('\r')) { + return `${stdout}${stderr}`; + } + return `${stdout}\n${stderr}`; +} + +async function launchIosDeviceProcess( + device: DeviceInfo, + bundleId: string, + options?: { payloadUrl?: string; launchArgs?: string[] }, +): Promise { + const args = ['device', 'process', 'launch', '--device', device.id, bundleId]; + if (options?.payloadUrl) { + args.push('--payload-url', options.payloadUrl); + } + if (options?.launchArgs && options.launchArgs.length > 0) { + // `devicectl` uses Swift ArgumentParser; without `--` an arg starting with + // `-` / `--` could be re-interpreted as one of devicectl's own options. + args.push('--', ...options.launchArgs); + } + await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id }); +} diff --git a/src/platforms/apple/core/app-resolution.ts b/src/platforms/apple/core/app-resolution.ts new file mode 100644 index 000000000..33a21e661 --- /dev/null +++ b/src/platforms/apple/core/app-resolution.ts @@ -0,0 +1,233 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isIosFamily, isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import type { AppsFilter } from '../../../contracts/app-inventory.ts'; +import { + createAppResolutionCache, + type AppResolutionCacheScope, +} from '../../app-resolution-cache.ts'; +import { + IOS_DEVICECTL_DEFAULT_HINT, + listIosDeviceApps, + resolveIosDevicectlHint, +} from './devicectl.ts'; +import type { IosAppInfo } from './app-info.ts'; +import { filterAppleAppsByBundlePrefix } from './app-filter.ts'; +import { listMacApps, resolveMacOsApp } from '../os/macos/apps.ts'; +import { runAppleToolCommand } from './tool-provider.ts'; +import { runSimctl } from './apps-simctl.ts'; + +const ALIASES: Record = { + settings: 'com.apple.Preferences', +}; +const AGENT_DEVICE_RUNNER_BUNDLE_PREFIX = 'com.callstack.agentdevice.runner'; + +const iosAppResolutionCache = createAppResolutionCache(); + +function iosAppResolutionScope(device: DeviceInfo): AppResolutionCacheScope { + return { platform: 'ios', deviceId: device.id, variant: device.kind }; +} + +export async function invalidateIosAppResolutionCache( + device: DeviceInfo, + fn: () => Promise, +): Promise { + return await iosAppResolutionCache.invalidateWhile(iosAppResolutionScope(device), fn); +} + +export async function resolveIosApp(device: DeviceInfo, app: string): Promise { + if (isMacOs(device)) { + return await resolveMacOsApp(app); + } + const trimmed = app.trim(); + if (trimmed.includes('.')) return trimmed; + + const alias = ALIASES[trimmed.toLowerCase()]; + if (alias) return alias; + + const cacheScope = iosAppResolutionScope(device); + const cached = iosAppResolutionCache.get(cacheScope, trimmed); + if (cached) return cached; + + const list = + device.kind === 'simulator' + ? await listSimulatorApps(device) + : await listIosDeviceApps(device, 'all'); + const matches = list.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase()); + const match = matches[0]; + if (match !== undefined && matches.length === 1) { + return iosAppResolutionCache.set(cacheScope, trimmed, match.bundleId); + } + if (matches.length > 1) { + throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches }); + } + + throw new AppError('APP_NOT_INSTALLED', `No app found matching "${app}"`); +} + +type SimulatorAppMetadata = { + bundleId: string; + name: string; + path?: string; + applicationType?: string; +}; + +export async function resolveIosSimulatorDeepLinkBundleId( + device: DeviceInfo, + url: string, +): Promise { + if (!isIosFamily(device) || device.kind !== 'simulator') return undefined; + const scheme = parseUrlScheme(url); + if (!scheme) return undefined; + + const apps = await listSimulatorAppMetadata(device); + const matches: SimulatorAppMetadata[] = []; + for (const app of apps) { + if (app.bundleId.startsWith(AGENT_DEVICE_RUNNER_BUNDLE_PREFIX)) continue; + if (!app.path) continue; + const schemes = await readIosSimulatorAppUrlSchemes(path.join(app.path, 'Info.plist')); + if (schemes.has(scheme)) { + matches.push(app); + } + } + + const userMatches = matches.filter((app) => app.applicationType === 'User'); + if (userMatches.length === 1) return userMatches[0]?.bundleId; + if (userMatches.length > 1) return undefined; + return matches.length === 1 ? matches[0]?.bundleId : undefined; +} + +function parseUrlScheme(url: string): string | undefined { + const match = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(url.trim()); + return match?.[1]?.toLowerCase(); +} + +export async function listIosApps(device: DeviceInfo, filter: AppsFilter): Promise { + if (isMacOs(device)) { + return await listMacApps(filter); + } + if (device.kind === 'simulator') { + const apps = await listSimulatorApps(device); + return filterAppleAppsByBundlePrefix(apps, filter); + } + return await listIosDeviceApps(device, filter); +} + +async function listSimulatorApps(device: DeviceInfo): Promise { + const apps = await listSimulatorAppMetadata(device); + return apps.map((app) => ({ + bundleId: app.bundleId, + name: app.name, + })); +} + +async function listSimulatorAppMetadata(device: DeviceInfo): Promise { + const result = await runSimctl(device, ['listapps', device.id], { allowFailure: true }); + const stdout = result.stdout as string; + const trimmed = stdout.trim(); + if (!trimmed) return []; + + let parsed: Record< + string, + { + ApplicationType?: string; + Bundle?: string; + CFBundleDisplayName?: string; + CFBundleName?: string; + Path?: string; + } + > | null = null; + if (trimmed.startsWith('{')) { + try { + parsed = JSON.parse(trimmed) as Record< + string, + { + ApplicationType?: string; + Bundle?: string; + CFBundleDisplayName?: string; + CFBundleName?: string; + Path?: string; + } + >; + } catch { + parsed = null; + } + } + + if (!parsed && trimmed.startsWith('{')) { + try { + const converted = await runAppleToolCommand('plutil', ['-convert', 'json', '-o', '-', '-'], { + allowFailure: true, + stdin: trimmed, + }); + if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) { + parsed = JSON.parse(converted.stdout) as Record< + string, + { + ApplicationType?: string; + Bundle?: string; + CFBundleDisplayName?: string; + CFBundleName?: string; + Path?: string; + } + >; + } + } catch { + parsed = null; + } + } + + if (!parsed) return []; + return Object.entries(parsed).map(([bundleId, info]) => { + const appPath = resolveSimulatorAppPath(info); + return { + bundleId, + name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId, + ...(appPath ? { path: appPath } : {}), + ...(info.ApplicationType ? { applicationType: info.ApplicationType } : {}), + }; + }); +} + +function resolveSimulatorAppPath(info: { Bundle?: string; Path?: string }): string | undefined { + if (info.Path) return info.Path; + if (!info.Bundle) return undefined; + try { + return fileURLToPath(info.Bundle); + } catch { + return undefined; + } +} + +async function readIosSimulatorAppUrlSchemes(infoPlistPath: string): Promise> { + const result = await runAppleToolCommand( + 'plutil', + ['-convert', 'json', '-o', '-', infoPlistPath], + { + allowFailure: true, + }, + ); + if (result.exitCode !== 0) return new Set(); + try { + const parsed = JSON.parse(result.stdout) as { + CFBundleURLTypes?: Array<{ CFBundleURLSchemes?: unknown }>; + }; + const schemes = new Set(); + for (const urlType of parsed.CFBundleURLTypes ?? []) { + if (!Array.isArray(urlType.CFBundleURLSchemes)) continue; + for (const scheme of urlType.CFBundleURLSchemes) { + if (typeof scheme === 'string' && scheme.trim()) { + schemes.add(scheme.trim().toLowerCase()); + } + } + } + return schemes; + } catch { + return new Set(); + } +} + +export function maybeResolveIosDevicectlHint(stdout: string, stderr: string): string { + return resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT; +} diff --git a/src/platforms/apple/core/app-settings.ts b/src/platforms/apple/core/app-settings.ts new file mode 100644 index 000000000..336874509 --- /dev/null +++ b/src/platforms/apple/core/app-settings.ts @@ -0,0 +1,529 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { isIosFamily, isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { requireLocationCoordinates } from '../../../utils/location-coordinates.ts'; +import { resolveIosSimulatorDeviceSetPath } from '../../../utils/device-isolation.ts'; +import { getUnsupportedMacOsSettingMessage } from '../../../core/settings-contract.ts'; +import { + parsePermissionAction, + parsePermissionTarget, + type SettingOptions, +} from '../../permission-utils.ts'; +import { parseAppearanceAction } from '../../appearance.ts'; +import { parseSettingState } from '../../setting-state.ts'; +import { + summarizeCommandAttemptFailures, + type CommandAttemptFailure, +} from '../../command-attempts.ts'; +import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; +import { runXcrun } from './tool-provider.ts'; +import { setMacOsAppearance } from '../os/macos/apps.ts'; +import { runMacOsPermissionAction, type MacOsPermissionTarget } from '../os/macos/helper.ts'; +import { + invalidateSimulatorStatusBarOverrideCache, + rememberClearedStatusBarOverrides, +} from './screenshot-status-bar.ts'; +import { closeIosApp } from './app-launch.ts'; +import { resolveIosApp } from './app-resolution.ts'; +import { runSimctl, simctlArgs } from './apps-simctl.ts'; + +let cachedSimctlPrivacyServices: Set | null = null; +let cachedSimctlPrivacyServicesCacheKey: string | undefined; + +// fallow-ignore-next-line complexity +export async function setIosSetting( + device: DeviceInfo, + setting: string, + state: string, + appBundleId?: string, + options?: SettingOptions, +): Promise | void> { + if (isMacOs(device)) { + const normalizedSetting = setting.toLowerCase(); + if (normalizedSetting === 'appearance') { + await setMacOsAppearance(state); + return; + } + if (normalizedSetting === 'permission') { + const action = parsePermissionAction(state); + if (action === 'deny') { + throw new AppError('INVALID_ARGS', getUnsupportedMacOsSettingMessage('permission')); + } + const permissionTarget = parseMacOsPermissionTarget(options?.permissionTarget); + return await runMacOsPermissionAction(action, permissionTarget); + } + throw new AppError('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); + } + requireSimulatorDevice(device, 'settings'); + await ensureBootedSimulator(device); + const normalized = setting.toLowerCase(); + + switch (normalized) { + case 'clear-app-state': { + if (state.toLowerCase() !== 'clear') { + throw new AppError('INVALID_ARGS', 'settings clear-app-state only supports clear.'); + } + if (!appBundleId) { + throw new AppError( + 'INVALID_ARGS', + 'settings clear-app-state requires an app id or an active app session.', + ); + } + const result = await clearIosSimulatorAppState(device, appBundleId); + return { bundleId: result.bundleId, containerPath: result.containerPath, cleared: true }; + } + case 'wifi': { + const enabled = parseSettingState(state); + const mode = enabled ? 'active' : 'failed'; + await runSimctl(device, ['status_bar', device.id, 'override', '--wifiMode', mode]); + invalidateSimulatorStatusBarOverrideCache(device); + return; + } + case 'airplane': { + const enabled = parseSettingState(state); + if (enabled) { + await runSimctl(device, [ + 'status_bar', + device.id, + 'override', + '--dataNetwork', + 'hide', + '--wifiMode', + 'failed', + '--wifiBars', + '0', + '--cellularMode', + 'failed', + '--cellularBars', + '0', + '--operatorName', + '', + ]); + invalidateSimulatorStatusBarOverrideCache(device); + } else { + await runSimctl(device, ['status_bar', device.id, 'clear']); + rememberClearedStatusBarOverrides(device); + } + return; + } + case 'location': { + if (state.toLowerCase() === 'set') { + const { latitude, longitude } = requireLocationCoordinates(options); + await runSimctl(device, ['location', device.id, 'set', `${latitude},${longitude}`]); + return { latitude, longitude }; + } + const enabled = parseSettingState(state); + if (!appBundleId) { + throw new AppError('INVALID_ARGS', 'location setting requires an active app in session'); + } + const action = enabled ? 'grant' : 'revoke'; + await runSimctl(device, ['privacy', device.id, action, 'location', appBundleId]); + return; + } + case 'faceid': + case 'touchid': { + const biometricSetting = normalized as IosBiometricSetting; + const biometric = IOS_BIOMETRIC_SETTINGS[biometricSetting]; + const action = parseBiometricAction(state, biometricSetting); + await runIosBiometricSimctlCommand(device, action, { + settingName: biometricSetting, + label: biometric.label, + modalityAliases: biometric.modalityAliases, + }); + return; + } + case 'appearance': { + const target = await resolveIosAppearanceTarget(device, state); + await runSimctl(device, ['ui', device.id, 'appearance', target]); + return; + } + case 'permission': { + if (!appBundleId) { + throw new AppError('INVALID_ARGS', 'permission setting requires an active app in session'); + } + const action = mapIosPermissionAction(parsePermissionAction(state)); + const target = parseIosPermissionTarget(options?.permissionTarget, options?.permissionMode); + await runIosPrivacyCommand(device, action, target, appBundleId); + return; + } + default: + throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`); + } +} + +async function clearIosSimulatorAppState( + device: DeviceInfo, + app: string, +): Promise<{ bundleId: string; containerPath: string }> { + if (!isIosFamily(device) || device.kind !== 'simulator') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Clearing app state is currently supported only on iOS simulators.', + ); + } + + const bundleId = await resolveIosApp(device, app); + await ensureBootedSimulator(device); + await closeIosApp(device, bundleId); + + const result = await runSimctl(device, ['get_app_container', device.id, bundleId, 'data'], { + allowFailure: true, + }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `simctl get_app_container failed for ${bundleId}`, { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + + const containerPath = result.stdout.trim(); + if (!containerPath) { + throw new AppError( + 'COMMAND_FAILED', + `simctl get_app_container returned an empty data container path for ${bundleId}`, + ); + } + + const entries = await fs.readdir(containerPath); + await Promise.all( + entries.map((entry) => + fs.rm(path.join(containerPath, entry), { + recursive: true, + force: true, + }), + ), + ); + + return { bundleId, containerPath }; +} + +function parseMacOsPermissionTarget(value: string | undefined): MacOsPermissionTarget { + const normalized = value?.trim().toLowerCase(); + if ( + normalized === 'accessibility' || + normalized === 'screen-recording' || + normalized === 'input-monitoring' + ) { + return normalized; + } + throw new AppError( + 'INVALID_ARGS', + 'Unsupported macOS permission target. Use accessibility|screen-recording|input-monitoring.', + ); +} + +async function resolveIosAppearanceTarget( + device: DeviceInfo, + state: string, +): Promise<'light' | 'dark'> { + const action = parseAppearanceAction(state); + if (action !== 'toggle') return action; + + const currentResult = await runSimctl(device, ['ui', device.id, 'appearance'], { + allowFailure: true, + }); + if (currentResult.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to read current iOS appearance', { + stdout: currentResult.stdout, + stderr: currentResult.stderr, + exitCode: currentResult.exitCode, + }); + } + const current = parseIosAppearance(currentResult.stdout, currentResult.stderr); + if (!current) { + throw new AppError('COMMAND_FAILED', 'Unable to determine current iOS appearance for toggle', { + stdout: currentResult.stdout, + stderr: currentResult.stderr, + }); + } + return current === 'dark' ? 'light' : 'dark'; +} + +function parseIosAppearance(stdout: string, stderr: string): 'light' | 'dark' | null { + const match = /\b(light|dark|unsupported|unknown)\b/i.exec(`${stdout}\n${stderr}`); + if (!match) return null; + const value = match[1]?.toLowerCase(); + if (value === 'dark') return 'dark'; + if (value === 'light') return 'light'; + return null; +} + +type IosBiometricAction = 'match' | 'nonmatch' | 'enroll' | 'unenroll'; +type IosBiometricSetting = 'faceid' | 'touchid'; + +const IOS_BIOMETRIC_SETTINGS: Record< + IosBiometricSetting, + { label: 'Face ID' | 'Touch ID'; modalityAliases: string[] } +> = { + faceid: { label: 'Face ID', modalityAliases: ['face'] }, + touchid: { label: 'Touch ID', modalityAliases: ['finger', 'touch'] }, +}; + +function mapIosPermissionAction(action: 'grant' | 'deny' | 'reset'): 'grant' | 'revoke' | 'reset' { + if (action === 'deny') return 'revoke'; + return action; +} + +async function runIosPrivacyCommand( + device: DeviceInfo, + action: 'grant' | 'revoke' | 'reset', + target: string, + appBundleId: string, +): Promise { + const supportedServices = await getSimctlPrivacyServices(device); + if (!supportedServices.has(target)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `iOS simctl privacy does not support service "${target}" on this runtime.`, + { + deviceId: device.id, + appBundleId, + hint: `Supported services: ${Array.from(supportedServices).sort().join(', ')}`, + }, + ); + } + + const args = ['privacy', device.id, action, target, appBundleId]; + const isNotificationsTarget = target === 'notifications'; + if (!(action === 'reset' && isNotificationsTarget)) { + try { + await runSimctl(device, args); + return; + } catch (error) { + if (!(isNotificationsTarget && isNotificationsOperationNotPermitted(error))) { + throw error; + } + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'iOS simulator does not support setting notifications permission via simctl privacy on this runtime.', + { + deviceId: device.id, + appBundleId, + hint: 'Use reset notifications for reprompt behavior, or toggle notifications manually in Settings.', + }, + ); + } + } + + try { + await runSimctl(device, args); + return; + } catch (error) { + if (!isNotificationsOperationNotPermitted(error)) { + throw error; + } + } + + try { + await runSimctl(device, ['privacy', device.id, 'reset', 'all', appBundleId]); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'iOS simulator blocked direct notifications reset. Fallback reset-all also failed.', + { + deviceId: device.id, + appBundleId, + hint: 'Use reinstall to force a fresh notifications prompt, or reset simulator content and settings.', + }, + error instanceof Error ? error : undefined, + ); + } +} + +function isNotificationsOperationNotPermitted(error: unknown): boolean { + if (!(error instanceof AppError) || error.code !== 'COMMAND_FAILED') return false; + const stderr = String(error.details?.stderr ?? '').toLowerCase(); + return ( + (stderr.includes('failed to grant access') || + stderr.includes('failed to revoke access') || + stderr.includes('failed to reset access')) && + stderr.includes('operation not permitted') + ); +} + +async function getSimctlPrivacyServices(device: DeviceInfo): Promise> { + const simulatorSetPath = resolveIosSimulatorDeviceSetPath(device.simulatorSetPath); + const currentCacheKey = `${process.env.PATH ?? ''}::${simulatorSetPath ?? ''}`; + if (cachedSimctlPrivacyServices && cachedSimctlPrivacyServicesCacheKey === currentCacheKey) { + return cachedSimctlPrivacyServices; + } + const result = await runSimctl(device, ['privacy', 'help'], { allowFailure: true }); + const services = parseSimctlPrivacyServices(`${result.stdout}\n${result.stderr}`); + if (services.size === 0) { + throw new AppError('COMMAND_FAILED', 'Unable to determine supported simctl privacy services', { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + hint: 'Run `xcrun simctl privacy help` manually to verify available services for this runtime.', + }); + } + cachedSimctlPrivacyServices = services; + cachedSimctlPrivacyServicesCacheKey = currentCacheKey; + return services; +} + +function parseSimctlPrivacyServices(helpText: string): Set { + const services = new Set(); + let inServiceSection = false; + for (const line of helpText.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed === 'service') { + inServiceSection = true; + continue; + } + if (!inServiceSection) continue; + if (trimmed.startsWith('bundle identifier')) break; + const match = /^([a-z-]+)\s+-\s+/.exec(trimmed); + const service = match?.[1]; + if (service !== undefined) { + services.add(service); + } + } + return services; +} + +// fallow-ignore-next-line complexity +function parseIosPermissionTarget( + permissionTarget: string | undefined, + permissionMode: string | undefined, +): string { + const normalized = parsePermissionTarget(permissionTarget); + if (normalized !== 'photos' && permissionMode?.trim()) { + throw new AppError( + 'INVALID_ARGS', + `Permission mode is only supported for photos. Received: ${permissionMode}.`, + ); + } + if (normalized === 'camera') return 'camera'; + if (normalized === 'microphone') return 'microphone'; + if (normalized === 'contacts') return 'contacts'; + if (normalized === 'contacts-limited') return 'contacts-limited'; + if (normalized === 'notifications') return 'notifications'; + if (normalized === 'calendar') return 'calendar'; + if (normalized === 'location') return 'location'; + if (normalized === 'location-always') return 'location-always'; + if (normalized === 'media-library') return 'media-library'; + if (normalized === 'motion') return 'motion'; + if (normalized === 'reminders') return 'reminders'; + if (normalized === 'siri') return 'siri'; + if (normalized === 'photos') { + const mode = permissionMode?.trim().toLowerCase(); + if (!mode || mode === 'full') return 'photos'; + if (mode === 'limited') return 'photos-add'; + throw new AppError('INVALID_ARGS', `Invalid photos mode: ${permissionMode}. Use full|limited.`); + } + throw new AppError( + 'INVALID_ARGS', + `Unsupported permission target: ${permissionTarget}. Use camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri.`, + ); +} + +function parseBiometricAction(state: string, settingName: IosBiometricSetting): IosBiometricAction { + const normalized = state.trim().toLowerCase(); + if (normalized === 'match') return 'match'; + if (normalized === 'nonmatch') return 'nonmatch'; + if (normalized === 'enroll') return 'enroll'; + if (normalized === 'unenroll') return 'unenroll'; + throw new AppError( + 'INVALID_ARGS', + `Invalid ${settingName} state: ${state}. Use match|nonmatch|enroll|unenroll.`, + ); +} + +async function runIosBiometricSimctlCommand( + device: DeviceInfo, + action: IosBiometricAction, + options: { + settingName: IosBiometricSetting; + label: 'Face ID' | 'Touch ID'; + modalityAliases: string[]; + }, +): Promise { + const attempts = biometricCommandAttempts(device.id, action, options.modalityAliases); + const failures: CommandAttemptFailure[] = []; + + for (const args of attempts) { + const commandArgs = simctlArgs(device, args); + const result = await runXcrun(commandArgs, { allowFailure: true }); + if (result.exitCode === 0) return; + failures.push({ + args: commandArgs, + stderr: result.stderr, + stdout: result.stdout, + exitCode: result.exitCode, + }); + } + + const attemptsPayload = summarizeCommandAttemptFailures(failures); + const capabilityMissing = + failures.length > 0 && + failures.every((failure) => isIosBiometricCapabilityMissing(failure.stdout, failure.stderr)); + if (capabilityMissing) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `${options.label} simulation is not supported on this simulator runtime.`, + { + deviceId: device.id, + action, + setting: options.settingName, + attempts: attemptsPayload, + }, + ); + } + throw new AppError('COMMAND_FAILED', `Failed to simulate ${options.settingName}.`, { + deviceId: device.id, + action, + setting: options.settingName, + attempts: attemptsPayload, + }); +} + +function biometricCommandAttempts( + deviceId: string, + action: IosBiometricAction, + modalityAliases: string[], +): string[][] { + const modalities = modalityAliases.length > 0 ? modalityAliases : ['face']; + switch (action) { + case 'match': + return modalities.flatMap((modality) => [ + ['biometric', deviceId, 'match', modality], + ['biometric', 'match', deviceId, modality], + ]); + case 'nonmatch': + return modalities.flatMap((modality) => [ + ['biometric', deviceId, 'nonmatch', modality], + ['biometric', deviceId, 'nomatch', modality], + ['biometric', 'nonmatch', deviceId, modality], + ['biometric', 'nomatch', deviceId, modality], + ]); + case 'enroll': + return [ + ['biometric', deviceId, 'enroll', 'yes'], + ['biometric', deviceId, 'enroll', '1'], + ['biometric', 'enroll', deviceId, 'yes'], + ['biometric', 'enroll', deviceId, '1'], + ]; + case 'unenroll': + return [ + ['biometric', deviceId, 'enroll', 'no'], + ['biometric', deviceId, 'enroll', '0'], + ['biometric', 'enroll', deviceId, 'no'], + ['biometric', 'enroll', deviceId, '0'], + ]; + } +} + +function isIosBiometricCapabilityMissing(stdout: string, stderr: string): boolean { + const text = `${stdout}\n${stderr}`.toLowerCase(); + return ( + text.includes('unrecognized subcommand') || + text.includes('unknown subcommand') || + text.includes('not supported') || + text.includes('unavailable') || + (text.includes('biometric') && text.includes('invalid')) + ); +} diff --git a/src/platforms/apple/core/apps-simctl.ts b/src/platforms/apple/core/apps-simctl.ts new file mode 100644 index 000000000..0ab04784f --- /dev/null +++ b/src/platforms/apple/core/apps-simctl.ts @@ -0,0 +1,23 @@ +import type { DeviceInfo } from '../../../kernel/device.ts'; +import { buildSimctlArgsForDevice } from './simctl.ts'; +import { runXcrun } from './tool-provider.ts'; + +export function simctlArgs(device: DeviceInfo, args: string[]): string[] { + return buildSimctlArgsForDevice(device, args); +} + +export function runSimctl( + device: DeviceInfo, + args: string[], + options?: Parameters[1], +) { + return runXcrun(simctlArgs(device, args), options); +} + +export function isMissingAppErrorOutput(output: string): boolean { + return ( + output.includes('not installed') || + output.includes('not found') || + output.includes('no such file') + ); +} diff --git a/src/platforms/apple/core/apps.ts b/src/platforms/apple/core/apps.ts index 4d3f53e3d..a6718854b 100644 --- a/src/platforms/apple/core/apps.ts +++ b/src/platforms/apple/core/apps.ts @@ -1,1262 +1,18 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { isIosFamily, isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; -import { AppError } from '../../../kernel/errors.ts'; -import { emitDiagnostic } from '../../../utils/diagnostics.ts'; -import type { AppsFilter } from '../../../contracts/app-inventory.ts'; -import { - LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE, - LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE, -} from '../../../core/launch-console.ts'; -import { requireLocationCoordinates } from '../../../utils/location-coordinates.ts'; -import { resolveIosSimulatorDeviceSetPath } from '../../../utils/device-isolation.ts'; -import { Deadline, retryWithPolicy } from '../../../utils/retry.ts'; -import { - isDeepLinkTarget, - isWebUrl, - resolveIosDeviceDeepLinkBundleId, -} from '../../../core/open-target.ts'; -import { getUnsupportedMacOsSettingMessage } from '../../../core/settings-contract.ts'; -import { - parsePermissionAction, - parsePermissionTarget, - type SettingOptions, -} from '../../permission-utils.ts'; -import { parseAppearanceAction } from '../../appearance.ts'; -import { parseSettingState } from '../../setting-state.ts'; -import { - createAppResolutionCache, - type AppResolutionCacheScope, -} from '../../app-resolution-cache.ts'; -import { - summarizeCommandAttemptFailures, - type CommandAttemptFailure, -} from '../../command-attempts.ts'; - -import { - IOS_APP_LAUNCH_TIMEOUT_MS, - IOS_DEVICE_INSTALL_TIMEOUT_MS, - IOS_DEVICECTL_TIMEOUT_MS, - IOS_SIMULATOR_TERMINATE_TIMEOUT_MS, -} from './config.ts'; -import { - IOS_DEVICECTL_DEFAULT_HINT, - listIosDeviceApps, - resolveIosDevicectlHint, - runIosDevicectl, -} from './devicectl.ts'; -import type { IosAppInfo } from './app-info.ts'; -import { - isSimulatorLaunchFBSError, - probeSimulatorLaunchContext, - classifyLaunchFailure, - launchFailureHint, -} from './launch-diagnostics.ts'; -import { ensureBootedSimulator, getSimulatorState, requireSimulatorDevice } from './simulator.ts'; -import { buildSimctlArgsForDevice } from './simctl.ts'; -import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; -import { prepareIosInstallArtifact } from './install-artifact.ts'; -import { filterAppleAppsByBundlePrefix } from './app-filter.ts'; -import { - closeMacOsApp, - listMacApps, - openMacOsApp, - readMacOsClipboardText, - resolveMacOsApp, - setMacOsAppearance, - writeMacOsClipboardText, -} from '../os/macos/apps.ts'; -import { runMacOsPermissionAction, type MacOsPermissionTarget } from '../os/macos/helper.ts'; -import { - invalidateSimulatorStatusBarOverrideCache, - rememberClearedStatusBarOverrides, -} from './screenshot-status-bar.ts'; export { screenshotIos, shouldFallbackToRunnerForIosScreenshot, shouldRetryIosSimulatorScreenshot, } from './screenshot.ts'; - -const ALIASES: Record = { - settings: 'com.apple.Preferences', -}; -const IOS_SIMULATOR_CONSOLE_CAPTURE_MS = 25_000; -const AGENT_DEVICE_RUNNER_BUNDLE_PREFIX = 'com.callstack.agentdevice.runner'; -const IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE = - '--launch-args is not supported with iOS simulator URL opens (simctl openurl ignores launch args). Launch the app first with --launch-args, then issue the URL open in a separate call.'; - -const iosAppResolutionCache = createAppResolutionCache(); -let cachedSimctlPrivacyServices: Set | null = null; -let cachedSimctlPrivacyServicesCacheKey: string | undefined; - -function iosAppResolutionScope(device: DeviceInfo): AppResolutionCacheScope { - return { platform: 'ios', deviceId: device.id, variant: device.kind }; -} - -function simctlArgs(device: DeviceInfo, args: string[]): string[] { - return buildSimctlArgsForDevice(device, args); -} - -function runSimctl(device: DeviceInfo, args: string[], options?: Parameters[1]) { - return runXcrun(simctlArgs(device, args), options); -} - -function isMissingAppErrorOutput(output: string): boolean { - return ( - output.includes('not installed') || - output.includes('not found') || - output.includes('no such file') - ); -} - -type InstallIosAppOptions = { - appIdentifierHint?: string; -}; - -export async function resolveIosApp(device: DeviceInfo, app: string): Promise { - if (isMacOs(device)) { - return await resolveMacOsApp(app); - } - const trimmed = app.trim(); - if (trimmed.includes('.')) return trimmed; - - const alias = ALIASES[trimmed.toLowerCase()]; - if (alias) return alias; - - const cacheScope = iosAppResolutionScope(device); - const cached = iosAppResolutionCache.get(cacheScope, trimmed); - if (cached) return cached; - - const list = - device.kind === 'simulator' - ? await listSimulatorApps(device) - : await listIosDeviceApps(device, 'all'); - const matches = list.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase()); - const match = matches[0]; - if (match !== undefined && matches.length === 1) { - return iosAppResolutionCache.set(cacheScope, trimmed, match.bundleId); - } - if (matches.length > 1) { - throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches }); - } - - throw new AppError('APP_NOT_INSTALLED', `No app found matching "${app}"`); -} - -type SimulatorAppMetadata = { - bundleId: string; - name: string; - path?: string; - applicationType?: string; -}; - -export async function resolveIosSimulatorDeepLinkBundleId( - device: DeviceInfo, - url: string, -): Promise { - if (!isIosFamily(device) || device.kind !== 'simulator') return undefined; - const scheme = parseUrlScheme(url); - if (!scheme) return undefined; - - const apps = await listSimulatorAppMetadata(device); - const matches: SimulatorAppMetadata[] = []; - for (const app of apps) { - if (app.bundleId.startsWith(AGENT_DEVICE_RUNNER_BUNDLE_PREFIX)) continue; - if (!app.path) continue; - const schemes = await readIosSimulatorAppUrlSchemes(path.join(app.path, 'Info.plist')); - if (schemes.has(scheme)) { - matches.push(app); - } - } - - const userMatches = matches.filter((app) => app.applicationType === 'User'); - if (userMatches.length === 1) return userMatches[0]?.bundleId; - if (userMatches.length > 1) return undefined; - return matches.length === 1 ? matches[0]?.bundleId : undefined; -} - -function parseUrlScheme(url: string): string | undefined { - const match = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(url.trim()); - return match?.[1]?.toLowerCase(); -} - -// fallow-ignore-next-line complexity -export async function openIosApp( - device: DeviceInfo, - app: string, - options?: { - appBundleId?: string; - launchConsole?: string; - launchArgs?: string[]; - terminateRunningApp?: boolean; - url?: string; - }, -): Promise { - const launchConsole = options?.launchConsole?.trim(); - const launchArgs = options?.launchArgs; - if (launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) { - throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); - } - if (isMacOs(device)) { - if (launchArgs && launchArgs.length > 0) { - throw new AppError( - 'UNSUPPORTED_OPERATION', - '--launch-args is not supported on macOS; launch arguments are currently iOS-only.', - ); - } - await openMacOsApp(device, app, options); - return; - } - const explicitUrl = options?.url?.trim(); - if (explicitUrl) { - if (launchConsole) { - throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); - } - if (!isDeepLinkTarget(explicitUrl)) { - throw new AppError('INVALID_ARGS', 'open requires a valid URL target'); - } - if (device.kind === 'simulator') { - if (launchArgs || isWebUrl(explicitUrl)) { - const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); - await launchIosSimulatorApp(device, bundleId, { - ...(launchArgs ? { launchArgs } : {}), - }); - } - await openIosSimulatorUrl(device, explicitUrl, undefined); - return; - } - const appBundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); - const bundleId = resolveIosDeviceDeepLinkBundleId(appBundleId, explicitUrl); - if (!bundleId) { - throw new AppError( - 'INVALID_ARGS', - 'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.', - ); - } - await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl, launchArgs }); - return; - } - - const deepLinkTarget = app.trim(); - if (isDeepLinkTarget(deepLinkTarget)) { - if (launchConsole) { - throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); - } - if (device.kind === 'simulator') { - await openIosSimulatorUrl(device, deepLinkTarget, launchArgs); - return; - } - const bundleId = resolveIosDeviceDeepLinkBundleId(options?.appBundleId, deepLinkTarget); - if (!bundleId) { - throw new AppError( - 'INVALID_ARGS', - 'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.', - ); - } - await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget, launchArgs }); - return; - } - - const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); - if (device.kind === 'simulator') { - await launchIosSimulatorApp(device, bundleId, { - ...(launchConsole ? { launchConsole } : {}), - ...(launchArgs ? { launchArgs } : {}), - ...(options?.terminateRunningApp ? { terminateRunningApp: true } : {}), - }); - return; - } - - await launchIosDeviceProcess(device, bundleId, { launchArgs }); -} - -async function openIosSimulatorUrl( - device: DeviceInfo, - url: string, - launchArgs: string[] | undefined, -): Promise { - if (launchArgs && launchArgs.length > 0) { - throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE); - } - await ensureBootedSimulator(device); - await runSimctl(device, ['openurl', device.id, url]); -} - -export async function openIosDevice(device: DeviceInfo): Promise { - if (isMacOs(device)) { - return; - } - if (device.kind !== 'simulator') return; - const state = await getSimulatorState(device); - if (state === 'Booted') return; - - await ensureBootedSimulator(device); -} - -export async function closeIosApp(device: DeviceInfo, app: string): Promise { - if (isMacOs(device)) { - await closeMacOsApp(device, app); - return; - } - const bundleId = await resolveIosApp(device, app); - if (device.kind === 'simulator') { - await ensureBootedSimulator(device); - const terminateArgs = simctlArgs(device, ['terminate', device.id, bundleId]); - const result = await runXcrun(terminateArgs, { - allowFailure: true, - timeoutMs: IOS_SIMULATOR_TERMINATE_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - const stderr = result.stderr.toLowerCase(); - if (stderr.includes('found nothing to terminate')) return; - throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, { - cmd: 'xcrun', - args: terminateArgs, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); - } - return; - } - - await runIosDevicectl(['device', 'process', 'terminate', '--device', device.id, bundleId], { - action: 'terminate iOS app', - deviceId: device.id, - }); -} - -async function clearIosSimulatorAppState( - device: DeviceInfo, - app: string, -): Promise<{ bundleId: string; containerPath: string }> { - if (!isIosFamily(device) || device.kind !== 'simulator') { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'Clearing app state is currently supported only on iOS simulators.', - ); - } - - const bundleId = await resolveIosApp(device, app); - await ensureBootedSimulator(device); - await closeIosApp(device, bundleId); - - const result = await runSimctl(device, ['get_app_container', device.id, bundleId, 'data'], { - allowFailure: true, - }); - if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', `simctl get_app_container failed for ${bundleId}`, { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); - } - - const containerPath = result.stdout.trim(); - if (!containerPath) { - throw new AppError( - 'COMMAND_FAILED', - `simctl get_app_container returned an empty data container path for ${bundleId}`, - ); - } - - const entries = await fs.readdir(containerPath); - await Promise.all( - entries.map((entry) => - fs.rm(path.join(containerPath, entry), { - recursive: true, - force: true, - }), - ), - ); - - return { bundleId, containerPath }; -} - -export async function uninstallIosApp( - device: DeviceInfo, - app: string, -): Promise<{ bundleId: string }> { - return await iosAppResolutionCache.invalidateWhile(iosAppResolutionScope(device), async () => { - const bundleId = await resolveIosApp(device, app); - if (device.kind !== 'simulator') { - const args = ['devicectl', 'device', 'uninstall', 'app', '--device', device.id, bundleId]; - const result = await runXcrun(args, { - allowFailure: true, - timeoutMs: IOS_DEVICECTL_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - const stdout = String(result.stdout ?? ''); - const stderr = String(result.stderr ?? ''); - const output = `${stdout}\n${stderr}`.toLowerCase(); - if (!isMissingAppErrorOutput(output)) { - throw new AppError('COMMAND_FAILED', `Failed to uninstall iOS app ${bundleId}`, { - cmd: 'xcrun', - args, - exitCode: result.exitCode, - stdout, - stderr, - deviceId: device.id, - hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, - }); - } - } - return { bundleId }; - } - - await ensureBootedSimulator(device); - - const result = await runSimctl(device, ['uninstall', device.id, bundleId], { - allowFailure: true, - }); - if (result.exitCode !== 0) { - const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); - if (!isMissingAppErrorOutput(output)) { - throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); - } - } - - return { bundleId }; - }); -} - -export async function installIosApp( - device: DeviceInfo, - appPath: string, - options?: InstallIosAppOptions, -): Promise<{ - archivePath?: string; - installablePath: string; - bundleId?: string; - appName?: string; - launchTarget?: string; -}> { - const prepared = await prepareIosInstallArtifact({ kind: 'path', path: appPath }, options); - try { - await installIosInstallablePath(device, prepared.installablePath); - return { - archivePath: prepared.archivePath, - installablePath: prepared.installablePath, - bundleId: prepared.bundleId, - appName: prepared.appName, - launchTarget: prepared.bundleId, - }; - } finally { - await prepared.cleanup(); - } -} - -export async function reinstallIosApp( - device: DeviceInfo, - app: string, - appPath: string, -): Promise<{ bundleId: string }> { - return await iosAppResolutionCache.invalidateWhile(iosAppResolutionScope(device), async () => { - const { bundleId } = await uninstallIosApp(device, app); - await installIosApp(device, appPath, { appIdentifierHint: app }); - return { bundleId }; - }); -} - -export async function installIosInstallablePath( - device: DeviceInfo, - installablePath: string, -): Promise { - await iosAppResolutionCache.invalidateWhile(iosAppResolutionScope(device), async () => { - if (device.kind !== 'simulator') { - await runIosDevicectl( - ['device', 'install', 'app', '--device', device.id, installablePath], - { - action: 'install iOS app', - deviceId: device.id, - }, - { - timeoutMs: IOS_DEVICE_INSTALL_TIMEOUT_MS, - }, - ); - return; - } - - await ensureBootedSimulator(device); - await runSimctl(device, ['install', device.id, installablePath]); - }); -} - -export async function readIosClipboardText(device: DeviceInfo): Promise { - if (isMacOs(device)) { - return await readMacOsClipboardText(); - } - requireSimulatorDevice(device, 'clipboard'); - await ensureBootedSimulator(device); - const result = await runSimctl(device, ['pbpaste', device.id], { allowFailure: true }); - if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to read iOS simulator clipboard', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); - } - return result.stdout.replace(/\r\n/g, '\n').replace(/\n$/, ''); -} - -export async function writeIosClipboardText(device: DeviceInfo, text: string): Promise { - if (isMacOs(device)) { - await writeMacOsClipboardText(text); - return; - } - requireSimulatorDevice(device, 'clipboard'); - await ensureBootedSimulator(device); - const result = await runSimctl(device, ['pbcopy', device.id], { - allowFailure: true, - stdin: text, - }); - if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to write iOS simulator clipboard', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); - } -} - -export async function pushIosNotification( - device: DeviceInfo, - bundleId: string, - payload: Record, -): Promise { - requireSimulatorDevice(device, 'push'); - await ensureBootedSimulator(device); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-push-')); - const payloadPath = path.join(tempDir, 'payload.apns'); - try { - await fs.writeFile(payloadPath, `${JSON.stringify(payload)}\n`, 'utf8'); - await runSimctl(device, ['push', device.id, bundleId, payloadPath]); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } -} - -export async function setIosSetting( - device: DeviceInfo, - setting: string, - state: string, - appBundleId?: string, - options?: SettingOptions, -): Promise | void> { - if (isMacOs(device)) { - const normalizedSetting = setting.toLowerCase(); - if (normalizedSetting === 'appearance') { - await setMacOsAppearance(state); - return; - } - if (normalizedSetting === 'permission') { - const action = parsePermissionAction(state); - if (action === 'deny') { - throw new AppError('INVALID_ARGS', getUnsupportedMacOsSettingMessage('permission')); - } - const permissionTarget = parseMacOsPermissionTarget(options?.permissionTarget); - return await runMacOsPermissionAction(action, permissionTarget); - } - throw new AppError('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); - } - requireSimulatorDevice(device, 'settings'); - await ensureBootedSimulator(device); - const normalized = setting.toLowerCase(); - - switch (normalized) { - case 'clear-app-state': { - if (state.toLowerCase() !== 'clear') { - throw new AppError('INVALID_ARGS', 'settings clear-app-state only supports clear.'); - } - if (!appBundleId) { - throw new AppError( - 'INVALID_ARGS', - 'settings clear-app-state requires an app id or an active app session.', - ); - } - const result = await clearIosSimulatorAppState(device, appBundleId); - return { bundleId: result.bundleId, containerPath: result.containerPath, cleared: true }; - } - case 'wifi': { - const enabled = parseSettingState(state); - const mode = enabled ? 'active' : 'failed'; - await runSimctl(device, ['status_bar', device.id, 'override', '--wifiMode', mode]); - invalidateSimulatorStatusBarOverrideCache(device); - return; - } - case 'airplane': { - const enabled = parseSettingState(state); - if (enabled) { - await runSimctl(device, [ - 'status_bar', - device.id, - 'override', - '--dataNetwork', - 'hide', - '--wifiMode', - 'failed', - '--wifiBars', - '0', - '--cellularMode', - 'failed', - '--cellularBars', - '0', - '--operatorName', - '', - ]); - invalidateSimulatorStatusBarOverrideCache(device); - } else { - await runSimctl(device, ['status_bar', device.id, 'clear']); - rememberClearedStatusBarOverrides(device); - } - return; - } - case 'location': { - if (state.toLowerCase() === 'set') { - const { latitude, longitude } = requireLocationCoordinates(options); - await runSimctl(device, ['location', device.id, 'set', `${latitude},${longitude}`]); - return { latitude, longitude }; - } - const enabled = parseSettingState(state); - if (!appBundleId) { - throw new AppError('INVALID_ARGS', 'location setting requires an active app in session'); - } - const action = enabled ? 'grant' : 'revoke'; - await runSimctl(device, ['privacy', device.id, action, 'location', appBundleId]); - return; - } - case 'faceid': - case 'touchid': { - const biometricSetting = normalized as IosBiometricSetting; - const biometric = IOS_BIOMETRIC_SETTINGS[biometricSetting]; - const action = parseBiometricAction(state, biometricSetting); - await runIosBiometricSimctlCommand(device, action, { - settingName: biometricSetting, - label: biometric.label, - modalityAliases: biometric.modalityAliases, - }); - return; - } - case 'appearance': { - const target = await resolveIosAppearanceTarget(device, state); - await runSimctl(device, ['ui', device.id, 'appearance', target]); - return; - } - case 'permission': { - if (!appBundleId) { - throw new AppError('INVALID_ARGS', 'permission setting requires an active app in session'); - } - const action = mapIosPermissionAction(parsePermissionAction(state)); - const target = parseIosPermissionTarget(options?.permissionTarget, options?.permissionMode); - await runIosPrivacyCommand(device, action, target, appBundleId); - return; - } - default: - throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`); - } -} - -export async function listIosApps(device: DeviceInfo, filter: AppsFilter): Promise { - if (isMacOs(device)) { - return await listMacApps(filter); - } - if (device.kind === 'simulator') { - const apps = await listSimulatorApps(device); - return filterAppleAppsByBundlePrefix(apps, filter); - } - return await listIosDeviceApps(device, filter); -} - -export async function listSimulatorApps(device: DeviceInfo): Promise { - const apps = await listSimulatorAppMetadata(device); - return apps.map((app) => ({ - bundleId: app.bundleId, - name: app.name, - })); -} - -async function listSimulatorAppMetadata(device: DeviceInfo): Promise { - const result = await runSimctl(device, ['listapps', device.id], { allowFailure: true }); - const stdout = result.stdout as string; - const trimmed = stdout.trim(); - if (!trimmed) return []; - - let parsed: Record< - string, - { - ApplicationType?: string; - Bundle?: string; - CFBundleDisplayName?: string; - CFBundleName?: string; - Path?: string; - } - > | null = null; - if (trimmed.startsWith('{')) { - try { - parsed = JSON.parse(trimmed) as Record< - string, - { - ApplicationType?: string; - Bundle?: string; - CFBundleDisplayName?: string; - CFBundleName?: string; - Path?: string; - } - >; - } catch { - parsed = null; - } - } - - if (!parsed && trimmed.startsWith('{')) { - try { - const converted = await runAppleToolCommand('plutil', ['-convert', 'json', '-o', '-', '-'], { - allowFailure: true, - stdin: trimmed, - }); - if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) { - parsed = JSON.parse(converted.stdout) as Record< - string, - { - ApplicationType?: string; - Bundle?: string; - CFBundleDisplayName?: string; - CFBundleName?: string; - Path?: string; - } - >; - } - } catch { - parsed = null; - } - } - - if (!parsed) return []; - return Object.entries(parsed).map(([bundleId, info]) => { - const appPath = resolveSimulatorAppPath(info); - return { - bundleId, - name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId, - ...(appPath ? { path: appPath } : {}), - ...(info.ApplicationType ? { applicationType: info.ApplicationType } : {}), - }; - }); -} - -function resolveSimulatorAppPath(info: { Bundle?: string; Path?: string }): string | undefined { - if (info.Path) return info.Path; - if (!info.Bundle) return undefined; - try { - return fileURLToPath(info.Bundle); - } catch { - return undefined; - } -} - -async function readIosSimulatorAppUrlSchemes(infoPlistPath: string): Promise> { - const result = await runAppleToolCommand( - 'plutil', - ['-convert', 'json', '-o', '-', infoPlistPath], - { - allowFailure: true, - }, - ); - if (result.exitCode !== 0) return new Set(); - try { - const parsed = JSON.parse(result.stdout) as { - CFBundleURLTypes?: Array<{ CFBundleURLSchemes?: unknown }>; - }; - const schemes = new Set(); - for (const urlType of parsed.CFBundleURLTypes ?? []) { - if (!Array.isArray(urlType.CFBundleURLSchemes)) continue; - for (const scheme of urlType.CFBundleURLSchemes) { - if (typeof scheme === 'string' && scheme.trim()) { - schemes.add(scheme.trim().toLowerCase()); - } - } - } - return schemes; - } catch { - return new Set(); - } -} - -function parseMacOsPermissionTarget(value: string | undefined): MacOsPermissionTarget { - const normalized = value?.trim().toLowerCase(); - if ( - normalized === 'accessibility' || - normalized === 'screen-recording' || - normalized === 'input-monitoring' - ) { - return normalized; - } - throw new AppError( - 'INVALID_ARGS', - 'Unsupported macOS permission target. Use accessibility|screen-recording|input-monitoring.', - ); -} - -async function resolveIosAppearanceTarget( - device: DeviceInfo, - state: string, -): Promise<'light' | 'dark'> { - const action = parseAppearanceAction(state); - if (action !== 'toggle') return action; - - const currentResult = await runSimctl(device, ['ui', device.id, 'appearance'], { - allowFailure: true, - }); - if (currentResult.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to read current iOS appearance', { - stdout: currentResult.stdout, - stderr: currentResult.stderr, - exitCode: currentResult.exitCode, - }); - } - const current = parseIosAppearance(currentResult.stdout, currentResult.stderr); - if (!current) { - throw new AppError('COMMAND_FAILED', 'Unable to determine current iOS appearance for toggle', { - stdout: currentResult.stdout, - stderr: currentResult.stderr, - }); - } - return current === 'dark' ? 'light' : 'dark'; -} - -function parseIosAppearance(stdout: string, stderr: string): 'light' | 'dark' | null { - const match = /\b(light|dark|unsupported|unknown)\b/i.exec(`${stdout}\n${stderr}`); - if (!match) return null; - const value = match[1]?.toLowerCase(); - if (value === 'dark') return 'dark'; - if (value === 'light') return 'light'; - return null; -} - -type IosBiometricAction = 'match' | 'nonmatch' | 'enroll' | 'unenroll'; -type IosBiometricSetting = 'faceid' | 'touchid'; - -const IOS_BIOMETRIC_SETTINGS: Record< - IosBiometricSetting, - { label: 'Face ID' | 'Touch ID'; modalityAliases: string[] } -> = { - faceid: { label: 'Face ID', modalityAliases: ['face'] }, - touchid: { label: 'Touch ID', modalityAliases: ['finger', 'touch'] }, -}; - -function mapIosPermissionAction(action: 'grant' | 'deny' | 'reset'): 'grant' | 'revoke' | 'reset' { - if (action === 'deny') return 'revoke'; - return action; -} - -async function runIosPrivacyCommand( - device: DeviceInfo, - action: 'grant' | 'revoke' | 'reset', - target: string, - appBundleId: string, -): Promise { - const supportedServices = await getSimctlPrivacyServices(device); - if (!supportedServices.has(target)) { - throw new AppError( - 'UNSUPPORTED_OPERATION', - `iOS simctl privacy does not support service "${target}" on this runtime.`, - { - deviceId: device.id, - appBundleId, - hint: `Supported services: ${Array.from(supportedServices).sort().join(', ')}`, - }, - ); - } - - const args = ['privacy', device.id, action, target, appBundleId]; - const isNotificationsTarget = target === 'notifications'; - if (!(action === 'reset' && isNotificationsTarget)) { - try { - await runSimctl(device, args); - return; - } catch (error) { - if (!(isNotificationsTarget && isNotificationsOperationNotPermitted(error))) { - throw error; - } - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'iOS simulator does not support setting notifications permission via simctl privacy on this runtime.', - { - deviceId: device.id, - appBundleId, - hint: 'Use reset notifications for reprompt behavior, or toggle notifications manually in Settings.', - }, - ); - } - } - - try { - await runSimctl(device, args); - return; - } catch (error) { - if (!isNotificationsOperationNotPermitted(error)) { - throw error; - } - } - - try { - await runSimctl(device, ['privacy', device.id, 'reset', 'all', appBundleId]); - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - 'iOS simulator blocked direct notifications reset. Fallback reset-all also failed.', - { - deviceId: device.id, - appBundleId, - hint: 'Use reinstall to force a fresh notifications prompt, or reset simulator content and settings.', - }, - error instanceof Error ? error : undefined, - ); - } -} - -function isNotificationsOperationNotPermitted(error: unknown): boolean { - if (!(error instanceof AppError) || error.code !== 'COMMAND_FAILED') return false; - const stderr = String(error.details?.stderr ?? '').toLowerCase(); - return ( - (stderr.includes('failed to grant access') || - stderr.includes('failed to revoke access') || - stderr.includes('failed to reset access')) && - stderr.includes('operation not permitted') - ); -} - -async function getSimctlPrivacyServices(device: DeviceInfo): Promise> { - const simulatorSetPath = resolveIosSimulatorDeviceSetPath(device.simulatorSetPath); - const currentCacheKey = `${process.env.PATH ?? ''}::${simulatorSetPath ?? ''}`; - if (cachedSimctlPrivacyServices && cachedSimctlPrivacyServicesCacheKey === currentCacheKey) { - return cachedSimctlPrivacyServices; - } - const result = await runSimctl(device, ['privacy', 'help'], { allowFailure: true }); - const services = parseSimctlPrivacyServices(`${result.stdout}\n${result.stderr}`); - if (services.size === 0) { - throw new AppError('COMMAND_FAILED', 'Unable to determine supported simctl privacy services', { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - hint: 'Run `xcrun simctl privacy help` manually to verify available services for this runtime.', - }); - } - cachedSimctlPrivacyServices = services; - cachedSimctlPrivacyServicesCacheKey = currentCacheKey; - return services; -} - -function parseSimctlPrivacyServices(helpText: string): Set { - const services = new Set(); - let inServiceSection = false; - for (const line of helpText.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (trimmed === 'service') { - inServiceSection = true; - continue; - } - if (!inServiceSection) continue; - if (trimmed.startsWith('bundle identifier')) break; - const match = /^([a-z-]+)\s+-\s+/.exec(trimmed); - const service = match?.[1]; - if (service !== undefined) { - services.add(service); - } - } - return services; -} - -// fallow-ignore-next-line complexity -function parseIosPermissionTarget( - permissionTarget: string | undefined, - permissionMode: string | undefined, -): string { - const normalized = parsePermissionTarget(permissionTarget); - if (normalized !== 'photos' && permissionMode?.trim()) { - throw new AppError( - 'INVALID_ARGS', - `Permission mode is only supported for photos. Received: ${permissionMode}.`, - ); - } - if (normalized === 'camera') return 'camera'; - if (normalized === 'microphone') return 'microphone'; - if (normalized === 'contacts') return 'contacts'; - if (normalized === 'contacts-limited') return 'contacts-limited'; - if (normalized === 'notifications') return 'notifications'; - if (normalized === 'calendar') return 'calendar'; - if (normalized === 'location') return 'location'; - if (normalized === 'location-always') return 'location-always'; - if (normalized === 'media-library') return 'media-library'; - if (normalized === 'motion') return 'motion'; - if (normalized === 'reminders') return 'reminders'; - if (normalized === 'siri') return 'siri'; - if (normalized === 'photos') { - const mode = permissionMode?.trim().toLowerCase(); - if (!mode || mode === 'full') return 'photos'; - if (mode === 'limited') return 'photos-add'; - throw new AppError('INVALID_ARGS', `Invalid photos mode: ${permissionMode}. Use full|limited.`); - } - throw new AppError( - 'INVALID_ARGS', - `Unsupported permission target: ${permissionTarget}. Use camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri.`, - ); -} - -function parseBiometricAction(state: string, settingName: IosBiometricSetting): IosBiometricAction { - const normalized = state.trim().toLowerCase(); - if (normalized === 'match') return 'match'; - if (normalized === 'nonmatch') return 'nonmatch'; - if (normalized === 'enroll') return 'enroll'; - if (normalized === 'unenroll') return 'unenroll'; - throw new AppError( - 'INVALID_ARGS', - `Invalid ${settingName} state: ${state}. Use match|nonmatch|enroll|unenroll.`, - ); -} - -async function runIosBiometricSimctlCommand( - device: DeviceInfo, - action: IosBiometricAction, - options: { - settingName: IosBiometricSetting; - label: 'Face ID' | 'Touch ID'; - modalityAliases: string[]; - }, -): Promise { - const attempts = biometricCommandAttempts(device.id, action, options.modalityAliases); - const failures: CommandAttemptFailure[] = []; - - for (const args of attempts) { - const commandArgs = simctlArgs(device, args); - const result = await runXcrun(commandArgs, { allowFailure: true }); - if (result.exitCode === 0) return; - failures.push({ - args: commandArgs, - stderr: result.stderr, - stdout: result.stdout, - exitCode: result.exitCode, - }); - } - - const attemptsPayload = summarizeCommandAttemptFailures(failures); - const capabilityMissing = - failures.length > 0 && - failures.every((failure) => isIosBiometricCapabilityMissing(failure.stdout, failure.stderr)); - if (capabilityMissing) { - throw new AppError( - 'UNSUPPORTED_OPERATION', - `${options.label} simulation is not supported on this simulator runtime.`, - { - deviceId: device.id, - action, - setting: options.settingName, - attempts: attemptsPayload, - }, - ); - } - throw new AppError('COMMAND_FAILED', `Failed to simulate ${options.settingName}.`, { - deviceId: device.id, - action, - setting: options.settingName, - attempts: attemptsPayload, - }); -} - -function biometricCommandAttempts( - deviceId: string, - action: IosBiometricAction, - modalityAliases: string[], -): string[][] { - const modalities = modalityAliases.length > 0 ? modalityAliases : ['face']; - switch (action) { - case 'match': - return modalities.flatMap((modality) => [ - ['biometric', deviceId, 'match', modality], - ['biometric', 'match', deviceId, modality], - ]); - case 'nonmatch': - return modalities.flatMap((modality) => [ - ['biometric', deviceId, 'nonmatch', modality], - ['biometric', deviceId, 'nomatch', modality], - ['biometric', 'nonmatch', deviceId, modality], - ['biometric', 'nomatch', deviceId, modality], - ]); - case 'enroll': - return [ - ['biometric', deviceId, 'enroll', 'yes'], - ['biometric', deviceId, 'enroll', '1'], - ['biometric', 'enroll', deviceId, 'yes'], - ['biometric', 'enroll', deviceId, '1'], - ]; - case 'unenroll': - return [ - ['biometric', deviceId, 'enroll', 'no'], - ['biometric', deviceId, 'enroll', '0'], - ['biometric', 'enroll', deviceId, 'no'], - ['biometric', 'enroll', deviceId, '0'], - ]; - } -} - -function isIosBiometricCapabilityMissing(stdout: string, stderr: string): boolean { - const text = `${stdout}\n${stderr}`.toLowerCase(); - return ( - text.includes('unrecognized subcommand') || - text.includes('unknown subcommand') || - text.includes('not supported') || - text.includes('unavailable') || - (text.includes('biometric') && text.includes('invalid')) - ); -} - -async function launchIosSimulatorApp( - device: DeviceInfo, - bundleId: string, - options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, -): Promise { - await ensureBootedSimulator(device); - - let consecutiveFBSFailures = 0; - const MAX_CONSECUTIVE_FBS_FAILURES = 3; - - const launchDeadline = Deadline.fromTimeoutMs(IOS_APP_LAUNCH_TIMEOUT_MS); - try { - await retryWithPolicy( - async ({ deadline: attemptDeadline }) => { - if (attemptDeadline?.isExpired()) { - throw new AppError('COMMAND_FAILED', 'App launch deadline exceeded', { - timeoutMs: IOS_APP_LAUNCH_TIMEOUT_MS, - }); - } - - const launchArgs = simctlArgs( - device, - buildIosSimulatorLaunchArgs(device.id, bundleId, options), - ); - const result = options?.launchConsole - ? await runIosSimulatorConsoleLaunch(launchArgs, options.launchConsole) - : await runXcrun(launchArgs, { - allowFailure: true, - }); - if (result.exitCode === 0) return; - - throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, { - cmd: 'xcrun', - args: launchArgs, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); - }, - { - maxAttempts: 10, - baseDelayMs: 1_000, - maxDelayMs: 5_000, - jitter: 0.2, - shouldRetry(error: unknown) { - if (!isSimulatorLaunchFBSError(error)) return false; - consecutiveFBSFailures += 1; - return consecutiveFBSFailures < MAX_CONSECUTIVE_FBS_FAILURES; - }, - }, - { deadline: launchDeadline }, - ); - } catch (error) { - if (isSimulatorLaunchFBSError(error)) { - const appError = error as AppError; - const probe = await probeSimulatorLaunchContext(device, bundleId); - const reason = classifyLaunchFailure(probe); - appError.details = { ...appError.details, hint: launchFailureHint(reason) }; - } - throw error; - } -} - -function buildIosSimulatorLaunchArgs( - deviceId: string, - bundleId: string, - options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, -): string[] { - const args = ['launch']; - if (options?.launchConsole) args.push('--console-pty'); - if (options?.terminateRunningApp) args.push('--terminate-running-process'); - args.push(deviceId, bundleId); - if (options?.launchArgs && options.launchArgs.length > 0) { - args.push(...options.launchArgs); - } - return args; -} - -async function runIosSimulatorConsoleLaunch( - launchArgs: string[], - logPath: string, -): Promise>> { - await fs.mkdir(path.dirname(logPath), { recursive: true }); - try { - const result = await runXcrun(launchArgs, { - allowFailure: true, - timeoutMs: IOS_SIMULATOR_CONSOLE_CAPTURE_MS, - }); - await writeIosSimulatorConsoleLog(logPath, result.stdout, result.stderr); - return result; - } catch (error) { - const appError = error instanceof AppError ? error : undefined; - const details = appError?.details; - if (details?.timeoutMs === IOS_SIMULATOR_CONSOLE_CAPTURE_MS) { - const stdout = typeof details.stdout === 'string' ? details.stdout : ''; - const stderr = typeof details.stderr === 'string' ? details.stderr : ''; - await writeIosSimulatorConsoleLog(logPath, stdout, stderr); - emitDiagnostic({ - level: 'warn', - phase: 'ios_simulator_launch_console_capture_timeout', - data: { - timeoutMs: IOS_SIMULATOR_CONSOLE_CAPTURE_MS, - logPath, - stdoutBytes: Buffer.byteLength(stdout), - stderrBytes: Buffer.byteLength(stderr), - }, - }); - return { stdout, stderr, exitCode: 0 }; - } - throw error; - } -} - -async function writeIosSimulatorConsoleLog( - logPath: string, - stdout: string, - stderr: string, -): Promise { - await fs.writeFile(logPath, joinProcessOutput(stdout, stderr), 'utf8'); -} - -function joinProcessOutput(stdout: string, stderr: string): string { - if (!stdout || !stderr || stdout.endsWith('\n') || stdout.endsWith('\r')) { - return `${stdout}${stderr}`; - } - return `${stdout}\n${stderr}`; -} - -async function launchIosDeviceProcess( - device: DeviceInfo, - bundleId: string, - options?: { payloadUrl?: string; launchArgs?: string[] }, -): Promise { - const args = ['device', 'process', 'launch', '--device', device.id, bundleId]; - if (options?.payloadUrl) { - args.push('--payload-url', options.payloadUrl); - } - if (options?.launchArgs && options.launchArgs.length > 0) { - // `devicectl` uses Swift ArgumentParser; without `--` an arg starting with - // `-` / `--` could be re-interpreted as one of devicectl's own options. - args.push('--', ...options.launchArgs); - } - await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id }); -} +export { + listIosApps, + resolveIosApp, + resolveIosSimulatorDeepLinkBundleId, +} from './app-resolution.ts'; +export { closeIosApp, openIosApp, openIosDevice } from './app-launch.ts'; +export { installIosApp, installIosInstallablePath, reinstallIosApp } from './app-install.ts'; +export { + pushIosNotification, + readIosClipboardText, + writeIosClipboardText, +} from './app-device-io.ts'; +export { setIosSetting } from './app-settings.ts';