From 771ac816a8fa54fc76633b6c2a9dcb7137614927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 22:48:06 +0200 Subject: [PATCH 1/2] refactor(daemon): split session-state handlers by device-state domain question handleSessionStateCommands (CC 34) and handleAppStateCommand (CC 30) kept CRAP at or above 30 from complexity alone. The command group now routes through a small dispatcher: boot, shutdown, and appstate each answer their own domain question in a dedicated module, and appstate separates target admission, the Apple session response, and the device-runtime query. No behavior change; tests move one-to-one with their subjects. --- ...criptor-shutdown-runtime-execution.test.ts | 8 +- .../session-appstate-device-runtime.test.ts | 296 ++++++++++++ .../__tests__/session-appstate-input.test.ts | 23 + ...up.test.ts => session-boot-warmup.test.ts} | 0 ...-shutdown.test.ts => session-boot.test.ts} | 271 +++-------- .../__tests__/session-shutdown.test.ts | 244 ++++++++++ .../handlers/__tests__/session-state.test.ts | 407 +---------------- .../session-appstate-apple-session.ts | 134 ++++++ .../session-appstate-device-runtime.ts | 70 +++ src/daemon/handlers/session-appstate.ts | 85 ++++ src/daemon/handlers/session-boot.ts | 151 ++++++ src/daemon/handlers/session-shutdown.ts | 104 +++++ src/daemon/handlers/session-state.ts | 431 +----------------- .../command-coverage/declarations.ts | 2 +- 14 files changed, 1209 insertions(+), 1017 deletions(-) create mode 100644 src/daemon/handlers/__tests__/session-appstate-device-runtime.test.ts rename src/daemon/handlers/__tests__/{session-state-boot-warmup.test.ts => session-boot-warmup.test.ts} (100%) rename src/daemon/handlers/__tests__/{session-boot-shutdown.test.ts => session-boot.test.ts} (68%) create mode 100644 src/daemon/handlers/__tests__/session-shutdown.test.ts create mode 100644 src/daemon/handlers/session-appstate-apple-session.ts create mode 100644 src/daemon/handlers/session-appstate-device-runtime.ts create mode 100644 src/daemon/handlers/session-appstate.ts create mode 100644 src/daemon/handlers/session-boot.ts create mode 100644 src/daemon/handlers/session-shutdown.ts diff --git a/src/__tests__/command-descriptor-shutdown-runtime-execution.test.ts b/src/__tests__/command-descriptor-shutdown-runtime-execution.test.ts index fada2caabf..61f5e4975b 100644 --- a/src/__tests__/command-descriptor-shutdown-runtime-execution.test.ts +++ b/src/__tests__/command-descriptor-shutdown-runtime-execution.test.ts @@ -3,8 +3,8 @@ import { expect, test } from 'vitest'; import { shutdownTargetUse } from '@agent-device/contracts/platform-runtime-operations'; import { commandDescriptors } from '@agent-device/command-registry/registry'; -const sessionStateSource = readFileSync( - new URL('../daemon/handlers/session-state.ts', import.meta.url), +const sessionShutdownSource = readFileSync( + new URL('../daemon/handlers/session-shutdown.ts', import.meta.url), 'utf8', ); const sessionCloseSource = readFileSync( @@ -42,9 +42,7 @@ test('shutdown descriptor declares one canonical runtime operation', () => { }); test('canonical shutdown and close parity share one concrete teardown mechanic', () => { - const shutdownRoute = sessionStateSource.slice( - sessionStateSource.indexOf("if (req.command === 'shutdown')"), - ); + const shutdownRoute = sessionShutdownSource; expect(shutdownRoute.match(/inspectFacts\(device\)/g)).toHaveLength(1); expect(shutdownRoute.match(/bindDevice\(device, shutdownTargetUse\)/g)).toHaveLength(1); expect(shutdownRoute).not.toContain('operations.ensureReady'); diff --git a/src/daemon/handlers/__tests__/session-appstate-device-runtime.test.ts b/src/daemon/handlers/__tests__/session-appstate-device-runtime.test.ts new file mode 100644 index 0000000000..501631c3e2 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-appstate-device-runtime.test.ts @@ -0,0 +1,296 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; +import { test, expect, vi } from 'vitest'; + +import { handleSessionStateCommands } from '../session-state.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { withTestDeviceInventory } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; +import { + type DeviceBinding, + type RuntimeFacts, + localRuntimeOwner, + narrowDeviceBinding, +} from '@agent-device/contracts/platform-runtime'; +import { + type PlatformRuntimeOperations, + appStateUse, +} from '@agent-device/contracts/platform-runtime-operations'; +import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; +import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; + +test('appstate rejects web before Android app-state backend dispatch', async () => { + const device = { + platform: 'web' as const, + id: 'agent-browser-chrome', + name: 'Agent Browser Chrome', + kind: 'device' as const, + target: 'desktop' as const, + booted: true, + }; + let bound = false; + const bindDevice: BindDeviceRuntime = async () => { + bound = true; + throw new Error('unsupported appstate must not bind'); + }; + const response = await withTestDeviceInventory( + { + local: async () => [device], + }, + async () => + await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'appstate', + positionals: [], + flags: { platform: 'web' }, + }, + sessionName: 'default', + sessionStore: makeSessionStore(), + bindDevice, + inspectFacts: async () => + createUnavailablePlatformRuntimeFacts(device, localRuntimeOwner('web'), { + appLog: { available: false, reason: 'unsupported-platform-leaf' }, + appState: { available: false, reason: 'unsupported-platform-leaf' }, + network: { available: false, reason: 'unsupported-platform-leaf' }, + screenshot: { available: false, reason: 'unsupported-platform-leaf' }, + viewport: { available: false, reason: 'unsupported-platform-leaf' }, + focus: { available: false, reason: 'unsupported-platform-leaf' }, + gesture: { available: false, reason: 'unsupported-platform-leaf' }, + scroll: { available: false, reason: 'unsupported-platform-leaf' }, + typeText: { available: false, reason: 'unsupported-platform-leaf' }, + touch: { available: false, reason: 'unsupported-platform-leaf' }, + elementText: { available: false, reason: 'unsupported-platform-leaf' }, + back: { available: false, reason: 'unsupported-platform-leaf' }, + orientation: { available: false, reason: 'unsupported-platform-leaf' }, + tvRemote: { available: false, reason: 'unsupported-platform-leaf' }, + keyboard: { available: false, reason: 'unsupported-platform-leaf' }, + clipboard: { available: false, reason: 'unsupported-platform-leaf' }, + systemButton: { available: false, reason: 'unsupported-platform-leaf' }, + fold: { available: false, reason: 'unsupported-platform-leaf' }, + triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' }, + settings: { available: false, reason: 'unsupported-platform-leaf' }, + readAlert: { available: false, reason: 'unsupported-platform-leaf' }, + awaitAlert: { available: false, reason: 'unsupported-platform-leaf' }, + acceptAlert: { available: false, reason: 'unsupported-platform-leaf' }, + dismissAlert: { available: false, reason: 'unsupported-platform-leaf' }, + audioProbeCapture: { available: false, reason: 'unsupported-platform-leaf' }, + audioProbeQuery: { available: false, reason: 'unsupported-platform-leaf' }, + readiness: { available: false, reason: 'unsupported-platform-leaf' }, + lifecycle: applicationLifecycleOperationFacts({ + resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' }, + prepareApplicationOpen: { available: false, reason: 'unsupported-platform-leaf' }, + openApplication: { available: false, reason: 'unsupported-platform-leaf' }, + applyRuntimeHints: { available: false, reason: 'unsupported-platform-leaf' }, + clearRuntimeHints: { available: false, reason: 'unsupported-platform-leaf' }, + closeApplication: { available: false, reason: 'unsupported-platform-leaf' }, + finalizeApplicationClose: { available: false, reason: 'unsupported-platform-leaf' }, + prepareAppleRunner: { available: false, reason: 'unsupported-platform-leaf' }, + configureProviderPortReverse: { + available: false, + reason: 'unsupported-platform-leaf', + }, + }), + }), + }), + ); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toMatch(/appstate is not supported on web/i); + } + expect(bound).toBe(false); +}); + +test('appstate rejects a missing readiness fact even when appState is available', async () => { + const device: DeviceInfo = { + platform: 'android', + id: 'android-device-1', + name: 'Android Device', + kind: 'device', + target: 'mobile', + booted: true, + }; + const facts: RuntimeFacts = { + device: { ...deviceShape(device), providerMode: 'local' }, + operations: { + appLogInspect: { available: false, reason: 'owner-capability-missing' }, + appLogDoctor: { available: false, reason: 'owner-capability-missing' }, + appLogStart: { available: false, reason: 'owner-capability-missing' }, + appLogReattach: { available: false, reason: 'owner-capability-missing' }, + appLogCleanup: { available: false, reason: 'owner-capability-missing' }, + appState: { available: true }, + listApps: { available: false, reason: 'owner-capability-missing' }, + networkDump: { available: false, reason: 'owner-capability-missing' }, + screenRecordingStart: { available: false, reason: 'owner-capability-missing' }, + screenRecordingReattach: { available: false, reason: 'owner-capability-missing' }, + screenRecordingCleanup: { available: false, reason: 'owner-capability-missing' }, + ensureReady: { + available: false, + reason: 'unsupported-device-kind', + hint: 'readiness is unavailable for this device kind', + }, + bootTarget: { available: false, reason: 'unsupported-device-kind' }, + bootTargetHeadless: { available: false, reason: 'unsupported-device-kind' }, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, + ...applicationLifecycleOperationFacts({ + resolveOpenTarget: { available: false, reason: 'unsupported-device-kind' }, + prepareApplicationOpen: { available: false, reason: 'unsupported-device-kind' }, + openApplication: { available: false, reason: 'unsupported-device-kind' }, + applyRuntimeHints: { available: false, reason: 'unsupported-device-kind' }, + clearRuntimeHints: { available: false, reason: 'unsupported-device-kind' }, + closeApplication: { available: false, reason: 'unsupported-device-kind' }, + finalizeApplicationClose: { available: false, reason: 'unsupported-device-kind' }, + prepareAppleRunner: { available: false, reason: 'unsupported-device-kind' }, + configureProviderPortReverse: { available: false, reason: 'unsupported-device-kind' }, + }), + }, + }; + const inspectFacts = vi.fn(async () => facts); + let bound = false; + const bindDevice: BindDeviceRuntime = async () => { + bound = true; + throw new Error('missing readiness must not bind'); + }; + const response = await withTestDeviceInventory( + { local: async () => [device] }, + async () => + await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'appstate', + positionals: [], + flags: { platform: 'android', device: device.name }, + }, + sessionName: 'default', + sessionStore: makeSessionStore(), + inspectFacts, + bindDevice, + }), + ); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.hint).toMatch(/readiness is unavailable/i); + } + expect(inspectFacts).toHaveBeenCalledOnce(); + expect(bound).toBe(false); +}); +test('sessionless Android appstate inspects once, binds once, and preserves operation order', async () => { + const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + const unavailable = { available: false, reason: 'owner-capability-missing' } as const; + const available = { available: true } as const; + const facts: RuntimeFacts = { + device: { ...deviceShape(device), providerMode: 'local' }, + operations: { + appLogInspect: unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + appState: available, + listApps: unavailable, + networkDump: unavailable, + screenRecordingStart: unavailable, + screenRecordingReattach: unavailable, + screenRecordingCleanup: unavailable, + ensureReady: available, + bootTarget: available, + bootTargetHeadless: unavailable, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, + ...applicationLifecycleOperationFacts({ + resolveOpenTarget: unavailable, + prepareApplicationOpen: unavailable, + openApplication: unavailable, + applyRuntimeHints: unavailable, + clearRuntimeHints: unavailable, + closeApplication: unavailable, + finalizeApplicationClose: unavailable, + prepareAppleRunner: unavailable, + configureProviderPortReverse: unavailable, + }), + }, + }; + const events: string[] = []; + const binding: DeviceBinding = { + device, + owner: localRuntimeOwner('android'), + facts, + operations: { + ensureReady: async (input) => { + events.push(`ensureReady:${JSON.stringify(input)}`); + return { ...device, booted: true }; + }, + appState: async () => { + events.push('appState'); + return { package: 'com.example.app', activity: '.MainActivity' }; + }, + }, + [Symbol.asyncDispose]: async () => undefined, + }; + const inspectFacts = vi.fn(async () => { + events.push('inspectFacts'); + return facts; + }); + const bindDevice: BindDeviceRuntime = async (selected, use) => { + events.push('bindDevice'); + expect(selected).toEqual(device); + expect(use).toBe(appStateUse); + return narrowDeviceBinding(binding, use); + }; + + const response = await withTestDeviceInventory( + { local: async () => [device] }, + async () => + await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'appstate', + positionals: [], + flags: { + platform: 'android', + device: 'Pixel Emulator', + androidDeviceAllowlist: 'emulator-5555,emulator-5554', + }, + }, + sessionName: 'default', + sessionStore: makeSessionStore(), + inspectFacts, + bindDevice, + }), + ); + + expect(response).toEqual({ + ok: true, + data: { + platform: 'android', + package: 'com.example.app', + activity: '.MainActivity', + }, + }); + expect(inspectFacts).toHaveBeenCalledOnce(); + expect(events).toEqual([ + 'inspectFacts', + 'bindDevice', + 'ensureReady:{"androidSerialAllowlist":["emulator-5554","emulator-5555"]}', + 'appState', + ]); +}); diff --git a/src/daemon/handlers/__tests__/session-appstate-input.test.ts b/src/daemon/handlers/__tests__/session-appstate-input.test.ts index c5b36b4629..b2a217fbc9 100644 --- a/src/daemon/handlers/__tests__/session-appstate-input.test.ts +++ b/src/daemon/handlers/__tests__/session-appstate-input.test.ts @@ -12,6 +12,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { SessionState } from '../../session-state.ts'; import type { DaemonRequest } from '../../daemon-request.ts'; import { handleSessionCommands, mockInspectDeviceRuntimeFacts } from './session-command-harness.ts'; +import { handleSessionStateCommands } from '../session-state.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { @@ -452,3 +453,25 @@ test('appstate on iOS keeps the session answer, with no state, when the runner c expect(response.data).not.toHaveProperty('state'); } }); + +test('appstate returns missing-session error for explicit session flag', async () => { + const response = await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'named', + command: 'appstate', + positionals: [], + flags: { platform: 'ios', session: 'named' }, + }, + sessionName: 'named', + sessionStore: makeSessionStore(), + }); + + 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(/Run open with --session named first/i); + } +}); diff --git a/src/daemon/handlers/__tests__/session-state-boot-warmup.test.ts b/src/daemon/handlers/__tests__/session-boot-warmup.test.ts similarity index 100% rename from src/daemon/handlers/__tests__/session-state-boot-warmup.test.ts rename to src/daemon/handlers/__tests__/session-boot-warmup.test.ts diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts b/src/daemon/handlers/__tests__/session-boot.test.ts similarity index 68% rename from src/daemon/handlers/__tests__/session-boot-shutdown.test.ts rename to src/daemon/handlers/__tests__/session-boot.test.ts index a0e9870cff..f45f1297ec 100644 --- a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-boot.test.ts @@ -9,6 +9,10 @@ import { makeSession, noopInvoke, } from './session-test-harness.ts'; +import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; +import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; +import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import { handleSessionStateCommands } from '../session-state.ts'; import type { SessionState } from '../../session-state.ts'; import { handleSessionCommands, @@ -16,7 +20,6 @@ import { mockEnsureReadyHeadlessRuntime, mockEnsureReadyRuntime, mockInspectDeviceRuntimeFacts, - mockShutdownTargetRuntime, } from './session-command-harness.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { testCreateCommandSurface } from '../../__tests__/command-surface-fixture.ts'; @@ -442,227 +445,77 @@ test('boot keeps --target validation before facts inspection', async () => { } }); -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({ - createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'shutdown', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); - expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); - expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); - expect(mockShutdownTargetRuntime).toHaveBeenCalledOnce(); - 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.publish(sessionName, makeSession(sessionName, selectedDevice)); - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - - const response = await handleSessionCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'shutdown', - positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); - expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); - expect(mockShutdownTargetRuntime).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: false, - }); - - const response = await handleSessionCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'shutdown', - positionals: [], - flags: { platform: 'android', device: 'Pixel_9_Pro_XL' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); - expect(mockResolveTargetDevice).toHaveBeenCalledWith( - { platform: 'android', device: 'Pixel_9_Pro_XL' }, - { androidAvdSelection: 'include-stopped' }, - ); - expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); - expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); - expect(mockShutdownTargetRuntime).toHaveBeenCalledOnce(); - 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', +test('boot rejects --headless outside Android directly', async () => { + const device = { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'sim-1', name: 'iPhone', - kind: 'device', - target: 'mobile', - booted: true, - }); - - const response = await handleSessionCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'shutdown', - positionals: [], - flags: { platform: 'ios', udid: 'device-1' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); - expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); - expect(mockShutdownTargetRuntime).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, + kind: 'simulator' as const, + target: 'mobile' as const, + booted: false, }; - mockResolveTargetDevice.mockResolvedValue(selectedDevice); - mockShutdownTargetRuntime.mockResolvedValue({ - success: false, - exitCode: 149, - stdout: '', - stderr: 'simctl shutdown failed', - }); - - const response = await handleSessionCommands({ - createCommandSurface: testCreateCommandSurface, + mockResolveTargetDevice.mockResolvedValue(device); + const response = await handleSessionStateCommands({ platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', - command: 'shutdown', + command: 'boot', positionals: [], - flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + flags: { platform: 'ios', headless: true }, }, sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, + sessionStore: makeSessionStore(), + inspectFacts: async () => + createUnavailablePlatformRuntimeFacts(device, localRuntimeOwner('apple'), { + appLog: { available: false, reason: 'owner-capability-missing' }, + network: { available: false, reason: 'owner-capability-missing' }, + screenshot: { available: false, reason: 'owner-capability-missing' }, + viewport: { available: false, reason: 'owner-capability-missing' }, + focus: { available: false, reason: 'owner-capability-missing' }, + gesture: { available: false, reason: 'owner-capability-missing' }, + scroll: { available: false, reason: 'owner-capability-missing' }, + typeText: { available: false, reason: 'owner-capability-missing' }, + touch: { available: false, reason: 'owner-capability-missing' }, + elementText: { available: false, reason: 'owner-capability-missing' }, + back: { available: false, reason: 'owner-capability-missing' }, + orientation: { available: false, reason: 'owner-capability-missing' }, + tvRemote: { available: false, reason: 'owner-capability-missing' }, + keyboard: { available: false, reason: 'owner-capability-missing' }, + clipboard: { available: false, reason: 'owner-capability-missing' }, + systemButton: { available: false, reason: 'owner-capability-missing' }, + fold: { available: false, reason: 'owner-capability-missing' }, + triggerAppEvent: { available: false, reason: 'owner-capability-missing' }, + settings: { available: false, reason: 'owner-capability-missing' }, + readAlert: { available: false, reason: 'owner-capability-missing' }, + awaitAlert: { available: false, reason: 'owner-capability-missing' }, + acceptAlert: { available: false, reason: 'owner-capability-missing' }, + dismissAlert: { available: false, reason: 'owner-capability-missing' }, + audioProbeCapture: { available: false, reason: 'owner-capability-missing' }, + audioProbeQuery: { available: false, reason: 'owner-capability-missing' }, + readiness: { available: false, reason: 'unsupported-device-kind' }, + lifecycle: applicationLifecycleOperationFacts({ + resolveOpenTarget: { available: false, reason: 'owner-capability-missing' }, + prepareApplicationOpen: { available: false, reason: 'owner-capability-missing' }, + openApplication: { available: false, reason: 'owner-capability-missing' }, + applyRuntimeHints: { available: false, reason: 'owner-capability-missing' }, + clearRuntimeHints: { available: false, reason: 'owner-capability-missing' }, + closeApplication: { available: false, reason: 'owner-capability-missing' }, + finalizeApplicationClose: { available: false, reason: 'owner-capability-missing' }, + prepareAppleRunner: { available: false, reason: 'owner-capability-missing' }, + configureProviderPortReverse: { + available: false, + reason: 'owner-capability-missing', + }, + }), + }), }); 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', - }); + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/supported only for Android emulators/i); } }); diff --git a/src/daemon/handlers/__tests__/session-shutdown.test.ts b/src/daemon/handlers/__tests__/session-shutdown.test.ts new file mode 100644 index 0000000000..53613c1afe --- /dev/null +++ b/src/daemon/handlers/__tests__/session-shutdown.test.ts @@ -0,0 +1,244 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; +import { test, expect } from 'vitest'; +import * as path from 'node:path'; +import { + mockResolveTargetDevice, + mockEnsureDeviceReady, + makeSessionStore, + makeSession, + noopInvoke, +} from './session-test-harness.ts'; +import type { SessionState } from '../../session-state.ts'; +import { + handleSessionCommands, + mockBindDeviceRuntime, + mockInspectDeviceRuntimeFacts, + mockShutdownTargetRuntime, +} from './session-command-harness.ts'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { testCreateCommandSurface } from '../../__tests__/command-surface-fixture.ts'; + +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({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'shutdown', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); + expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); + expect(mockShutdownTargetRuntime).toHaveBeenCalledOnce(); + 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.publish(sessionName, makeSession(sessionName, selectedDevice)); + mockResolveTargetDevice.mockResolvedValue(selectedDevice); + + const response = await handleSessionCommands({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'shutdown', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); + expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); + expect(mockShutdownTargetRuntime).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: false, + }); + + const response = await handleSessionCommands({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'shutdown', + positionals: [], + flags: { platform: 'android', device: 'Pixel_9_Pro_XL' }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); + expect(mockResolveTargetDevice).toHaveBeenCalledWith( + { platform: 'android', device: 'Pixel_9_Pro_XL' }, + { androidAvdSelection: 'include-stopped' }, + ); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); + expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); + expect(mockShutdownTargetRuntime).toHaveBeenCalledOnce(); + 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({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'shutdown', + positionals: [], + flags: { platform: 'ios', udid: 'device-1' }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); + expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); + expect(mockShutdownTargetRuntime).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); + mockShutdownTargetRuntime.mockResolvedValue({ + success: false, + exitCode: 149, + stdout: '', + stderr: 'simctl shutdown failed', + }); + + const response = await handleSessionCommands({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'shutdown', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), '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-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 70cc1ee5ba..804b097451 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -1,397 +1,36 @@ import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; -import { test, expect, vi } from 'vitest'; - +import { expect, test } from 'vitest'; import { handleSessionStateCommands } from '../session-state.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; -import { withTestDeviceInventory } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; -import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; -import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; -import { - type DeviceBinding, - type RuntimeFacts, - localRuntimeOwner, - narrowDeviceBinding, -} from '@agent-device/contracts/platform-runtime'; -import { - type PlatformRuntimeOperations, - appStateUse, -} from '@agent-device/contracts/platform-runtime-operations'; -import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; -import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; -import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; - -test('boot rejects --headless outside Android directly', async () => { - const device = { - platform: 'apple' as const, - appleOs: 'ios' as const, - id: 'sim-1', - name: 'iPhone', - kind: 'simulator' as const, - target: 'mobile' as const, - booted: false, - }; - const response = await withTestDeviceInventory( - { local: async () => [device] }, - async () => - await handleSessionStateCommands({ - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'boot', - positionals: [], - flags: { platform: 'ios', headless: true }, - }, - sessionName: 'default', - sessionStore: makeSessionStore('agent-device-session-state-'), - inspectFacts: async () => - createUnavailablePlatformRuntimeFacts(device, localRuntimeOwner('apple'), { - appLog: { available: false, reason: 'owner-capability-missing' }, - network: { available: false, reason: 'owner-capability-missing' }, - screenshot: { available: false, reason: 'owner-capability-missing' }, - viewport: { available: false, reason: 'owner-capability-missing' }, - focus: { available: false, reason: 'owner-capability-missing' }, - gesture: { available: false, reason: 'owner-capability-missing' }, - scroll: { available: false, reason: 'owner-capability-missing' }, - typeText: { available: false, reason: 'owner-capability-missing' }, - touch: { available: false, reason: 'owner-capability-missing' }, - elementText: { available: false, reason: 'owner-capability-missing' }, - back: { available: false, reason: 'owner-capability-missing' }, - orientation: { available: false, reason: 'owner-capability-missing' }, - tvRemote: { available: false, reason: 'owner-capability-missing' }, - keyboard: { available: false, reason: 'owner-capability-missing' }, - clipboard: { available: false, reason: 'owner-capability-missing' }, - systemButton: { available: false, reason: 'owner-capability-missing' }, - fold: { available: false, reason: 'owner-capability-missing' }, - triggerAppEvent: { available: false, reason: 'owner-capability-missing' }, - settings: { available: false, reason: 'owner-capability-missing' }, - readAlert: { available: false, reason: 'owner-capability-missing' }, - awaitAlert: { available: false, reason: 'owner-capability-missing' }, - acceptAlert: { available: false, reason: 'owner-capability-missing' }, - dismissAlert: { available: false, reason: 'owner-capability-missing' }, - audioProbeCapture: { available: false, reason: 'owner-capability-missing' }, - audioProbeQuery: { available: false, reason: 'owner-capability-missing' }, - readiness: { available: false, reason: 'unsupported-device-kind' }, - lifecycle: applicationLifecycleOperationFacts({ - resolveOpenTarget: { available: false, reason: 'owner-capability-missing' }, - prepareApplicationOpen: { available: false, reason: 'owner-capability-missing' }, - openApplication: { available: false, reason: 'owner-capability-missing' }, - applyRuntimeHints: { available: false, reason: 'owner-capability-missing' }, - clearRuntimeHints: { available: false, reason: 'owner-capability-missing' }, - closeApplication: { available: false, reason: 'owner-capability-missing' }, - finalizeApplicationClose: { available: false, reason: 'owner-capability-missing' }, - prepareAppleRunner: { available: false, reason: 'owner-capability-missing' }, - configureProviderPortReverse: { - available: false, - reason: 'owner-capability-missing', - }, - }), - }), - }), - ); +import type { DaemonRequest } from '../../daemon-request.ts'; - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/supported only for Android emulators/i); - } -}); +function request(command: string): DaemonRequest { + return { token: 't', session: 'default', command, positionals: [], flags: {} }; +} -test('appstate returns missing-session error for explicit session flag', async () => { - const response = await handleSessionStateCommands({ +async function dispatch(command: string) { + return await handleSessionStateCommands({ platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'named', - command: 'appstate', - positionals: [], - flags: { platform: 'ios', session: 'named' }, - }, - sessionName: 'named', + req: request(command), + sessionName: 'default', sessionStore: makeSessionStore('agent-device-session-state-'), }); +} - 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(/Run open with --session named first/i); - } -}); - -test('appstate rejects web before Android app-state backend dispatch', async () => { - const device = { - platform: 'web' as const, - id: 'agent-browser-chrome', - name: 'Agent Browser Chrome', - kind: 'device' as const, - target: 'desktop' as const, - booted: true, - }; - let bound = false; - const bindDevice: BindDeviceRuntime = async () => { - bound = true; - throw new Error('unsupported appstate must not bind'); - }; - const response = await withTestDeviceInventory( - { - local: async () => [device], - }, - async () => - await handleSessionStateCommands({ - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'appstate', - positionals: [], - flags: { platform: 'web' }, - }, - sessionName: 'default', - sessionStore: makeSessionStore('agent-device-session-state-'), - bindDevice, - inspectFacts: async () => - createUnavailablePlatformRuntimeFacts(device, localRuntimeOwner('web'), { - appLog: { available: false, reason: 'unsupported-platform-leaf' }, - appState: { available: false, reason: 'unsupported-platform-leaf' }, - network: { available: false, reason: 'unsupported-platform-leaf' }, - screenshot: { available: false, reason: 'unsupported-platform-leaf' }, - viewport: { available: false, reason: 'unsupported-platform-leaf' }, - focus: { available: false, reason: 'unsupported-platform-leaf' }, - gesture: { available: false, reason: 'unsupported-platform-leaf' }, - scroll: { available: false, reason: 'unsupported-platform-leaf' }, - typeText: { available: false, reason: 'unsupported-platform-leaf' }, - touch: { available: false, reason: 'unsupported-platform-leaf' }, - elementText: { available: false, reason: 'unsupported-platform-leaf' }, - back: { available: false, reason: 'unsupported-platform-leaf' }, - orientation: { available: false, reason: 'unsupported-platform-leaf' }, - tvRemote: { available: false, reason: 'unsupported-platform-leaf' }, - keyboard: { available: false, reason: 'unsupported-platform-leaf' }, - clipboard: { available: false, reason: 'unsupported-platform-leaf' }, - systemButton: { available: false, reason: 'unsupported-platform-leaf' }, - fold: { available: false, reason: 'unsupported-platform-leaf' }, - triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' }, - settings: { available: false, reason: 'unsupported-platform-leaf' }, - readAlert: { available: false, reason: 'unsupported-platform-leaf' }, - awaitAlert: { available: false, reason: 'unsupported-platform-leaf' }, - acceptAlert: { available: false, reason: 'unsupported-platform-leaf' }, - dismissAlert: { available: false, reason: 'unsupported-platform-leaf' }, - audioProbeCapture: { available: false, reason: 'unsupported-platform-leaf' }, - audioProbeQuery: { available: false, reason: 'unsupported-platform-leaf' }, - readiness: { available: false, reason: 'unsupported-platform-leaf' }, - lifecycle: applicationLifecycleOperationFacts({ - resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' }, - prepareApplicationOpen: { available: false, reason: 'unsupported-platform-leaf' }, - openApplication: { available: false, reason: 'unsupported-platform-leaf' }, - applyRuntimeHints: { available: false, reason: 'unsupported-platform-leaf' }, - clearRuntimeHints: { available: false, reason: 'unsupported-platform-leaf' }, - closeApplication: { available: false, reason: 'unsupported-platform-leaf' }, - finalizeApplicationClose: { available: false, reason: 'unsupported-platform-leaf' }, - prepareAppleRunner: { available: false, reason: 'unsupported-platform-leaf' }, - configureProviderPortReverse: { - available: false, - reason: 'unsupported-platform-leaf', - }, - }), - }), - }), - ); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toMatch(/appstate is not supported on web/i); - } - expect(bound).toBe(false); +test('the state group stays silent for commands it does not own', async () => { + expect(await dispatch('open')).toBeNull(); }); -test('appstate rejects a missing readiness fact even when appState is available', async () => { - const device: DeviceInfo = { - platform: 'android', - id: 'android-device-1', - name: 'Android Device', - kind: 'device', - target: 'mobile', - booted: true, - }; - const facts: RuntimeFacts = { - device: { ...deviceShape(device), providerMode: 'local' }, - operations: { - appLogInspect: { available: false, reason: 'owner-capability-missing' }, - appLogDoctor: { available: false, reason: 'owner-capability-missing' }, - appLogStart: { available: false, reason: 'owner-capability-missing' }, - appLogReattach: { available: false, reason: 'owner-capability-missing' }, - appLogCleanup: { available: false, reason: 'owner-capability-missing' }, - appState: { available: true }, - listApps: { available: false, reason: 'owner-capability-missing' }, - networkDump: { available: false, reason: 'owner-capability-missing' }, - screenRecordingStart: { available: false, reason: 'owner-capability-missing' }, - screenRecordingReattach: { available: false, reason: 'owner-capability-missing' }, - screenRecordingCleanup: { available: false, reason: 'owner-capability-missing' }, - ensureReady: { - available: false, - reason: 'unsupported-device-kind', - hint: 'readiness is unavailable for this device kind', - }, - bootTarget: { available: false, reason: 'unsupported-device-kind' }, - bootTargetHeadless: { available: false, reason: 'unsupported-device-kind' }, - ...unavailableDeploymentSnapshotAndShutdownOperationFacts, - ...applicationLifecycleOperationFacts({ - resolveOpenTarget: { available: false, reason: 'unsupported-device-kind' }, - prepareApplicationOpen: { available: false, reason: 'unsupported-device-kind' }, - openApplication: { available: false, reason: 'unsupported-device-kind' }, - applyRuntimeHints: { available: false, reason: 'unsupported-device-kind' }, - clearRuntimeHints: { available: false, reason: 'unsupported-device-kind' }, - closeApplication: { available: false, reason: 'unsupported-device-kind' }, - finalizeApplicationClose: { available: false, reason: 'unsupported-device-kind' }, - prepareAppleRunner: { available: false, reason: 'unsupported-device-kind' }, - configureProviderPortReverse: { available: false, reason: 'unsupported-device-kind' }, - }), - }, - }; - const inspectFacts = vi.fn(async () => facts); - let bound = false; - const bindDevice: BindDeviceRuntime = async () => { - bound = true; - throw new Error('missing readiness must not bind'); - }; - const response = await withTestDeviceInventory( - { local: async () => [device] }, - async () => - await handleSessionStateCommands({ - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'appstate', - positionals: [], - flags: { platform: 'android', device: device.name }, - }, - sessionName: 'default', - sessionStore: makeSessionStore('agent-device-session-state-'), - inspectFacts, - bindDevice, - }), - ); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.hint).toMatch(/readiness is unavailable/i); +test('the state group routes each command it owns to its handler', async () => { + // No session and no selector: each owned command reaches its handler's guard rather than + // the group's null, which is what distinguishes routing from silence. + for (const command of ['boot', 'shutdown', 'appstate']) { + const response = await dispatch(command); + if (!response || response.ok) { + throw new Error( + `${command} should reach its handler's guard as an INVALID_ARGS refusal, got ${JSON.stringify(response)}`, + ); + } + expect(response.error.code, command).toBe('INVALID_ARGS'); } - expect(inspectFacts).toHaveBeenCalledOnce(); - expect(bound).toBe(false); -}); - -test('sessionless Android appstate inspects once, binds once, and preserves operation order', async () => { - const device: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - target: 'mobile', - booted: true, - }; - const unavailable = { available: false, reason: 'owner-capability-missing' } as const; - const available = { available: true } as const; - const facts: RuntimeFacts = { - device: { ...deviceShape(device), providerMode: 'local' }, - operations: { - appLogInspect: unavailable, - appLogDoctor: unavailable, - appLogStart: unavailable, - appLogReattach: unavailable, - appLogCleanup: unavailable, - appState: available, - listApps: unavailable, - networkDump: unavailable, - screenRecordingStart: unavailable, - screenRecordingReattach: unavailable, - screenRecordingCleanup: unavailable, - ensureReady: available, - bootTarget: available, - bootTargetHeadless: unavailable, - ...unavailableDeploymentSnapshotAndShutdownOperationFacts, - ...applicationLifecycleOperationFacts({ - resolveOpenTarget: unavailable, - prepareApplicationOpen: unavailable, - openApplication: unavailable, - applyRuntimeHints: unavailable, - clearRuntimeHints: unavailable, - closeApplication: unavailable, - finalizeApplicationClose: unavailable, - prepareAppleRunner: unavailable, - configureProviderPortReverse: unavailable, - }), - }, - }; - const events: string[] = []; - const binding: DeviceBinding = { - device, - owner: localRuntimeOwner('android'), - facts, - operations: { - ensureReady: async (input) => { - events.push(`ensureReady:${JSON.stringify(input)}`); - return { ...device, booted: true }; - }, - appState: async () => { - events.push('appState'); - return { package: 'com.example.app', activity: '.MainActivity' }; - }, - }, - [Symbol.asyncDispose]: async () => undefined, - }; - const inspectFacts = vi.fn(async () => { - events.push('inspectFacts'); - return facts; - }); - const bindDevice: BindDeviceRuntime = async (selected, use) => { - events.push('bindDevice'); - expect(selected).toEqual(device); - expect(use).toBe(appStateUse); - return narrowDeviceBinding(binding, use); - }; - - const response = await withTestDeviceInventory( - { local: async () => [device] }, - async () => - await handleSessionStateCommands({ - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'appstate', - positionals: [], - flags: { - platform: 'android', - device: 'Pixel Emulator', - androidDeviceAllowlist: 'emulator-5555,emulator-5554', - }, - }, - sessionName: 'default', - sessionStore: makeSessionStore('agent-device-session-state-'), - inspectFacts, - bindDevice, - }), - ); - - expect(response).toEqual({ - ok: true, - data: { - platform: 'android', - package: 'com.example.app', - activity: '.MainActivity', - }, - }); - expect(inspectFacts).toHaveBeenCalledOnce(); - expect(events).toEqual([ - 'inspectFacts', - 'bindDevice', - 'ensureReady:{"androidSerialAllowlist":["emulator-5554","emulator-5555"]}', - 'appState', - ]); }); diff --git a/src/daemon/handlers/session-appstate-apple-session.ts b/src/daemon/handlers/session-appstate-apple-session.ts new file mode 100644 index 0000000000..a82e596bc3 --- /dev/null +++ b/src/daemon/handlers/session-appstate-apple-session.ts @@ -0,0 +1,134 @@ +import type { AppleApplicationState } from '@agent-device/kernel/snapshot'; +import { + isMacOs, + isIosFamily, + publicPlatformString, + type DeviceInfo, +} from '@agent-device/kernel/device'; +import type { SessionSurface } from '@agent-device/contracts/session'; +import { errorResponse } from '@agent-device/kernel/contracts'; +import { asAppError, isRequestCanceledError } from '@agent-device/kernel/errors'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { appStateUse } from '@agent-device/contracts/platform-runtime-operations'; +import { admitRuntimeUse } from '../runtime-admission.ts'; +import type { RuntimeCommandHandlerParams } from '../session-runtime-admission.ts'; +import type { DaemonResponse } from '../daemon-request.ts'; + +/** + * The session facts this answer is built from, as a structural view: the handler reads exactly + * the fields it uses and passes no more of the session shape than this. + */ +type AppStateSessionRecord = Readonly<{ + device: DeviceInfo; + surface?: SessionSurface; + appBundleId?: string; + appName?: string; +}>; + +const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE = + 'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "" ).'; +const MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE = + 'macOS appstate requires an active session on the target device. Run open first (for example: open --session macos --platform macos "System Settings").'; + +/** + * Apple platforms answer appstate from a session and its runner, never from a bare device query: + * this is the refusal for a request whose Apple target has no session to read. + */ +export function appleSessionRequiredRefusal(platform: 'ios' | 'macos'): DaemonResponse { + return errorResponse( + 'SESSION_NOT_FOUND', + platform === 'macos' + ? MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE + : IOS_APPSTATE_SESSION_REQUIRED_MESSAGE, + ); +} + +/** + * The session app's state as a live runner reads it, when this device's owner admits the read; + * nothing otherwise, so the session record alone answers and no state is invented. The owner never + * starts a runner for it. A runner that cannot answer right now (busy, mid-restart) leaves the + * session answer as it was and says so in the log; a cancelled request stays cancelled. + */ +async function readAppleSessionAppState( + params: RuntimeCommandHandlerParams, + session: Readonly<{ device: DeviceInfo; appBundleId?: string }>, +): Promise { + if (!session.appBundleId) return undefined; + const admitted = await admitRuntimeUse({ + command: 'appstate', + device: session.device, + use: appStateUse, + inspectFacts: params.inspectFacts, + platformServices: params.platformServices, + bindDevice: params.bindDevice, + }); + if (admitted.type === 'response') return undefined; + try { + const read = await admitted.runtime.operations.appState({ appBundleId: session.appBundleId }); + return read.applicationState; + } catch (error) { + if (isRequestCanceledError(error)) throw error; + emitDiagnostic({ + level: 'warn', + phase: 'apple_appstate_runner_read_failed', + data: { code: asAppError(error).code, message: asAppError(error).message }, + }); + return undefined; + } +} + +/** A macOS desktop surface (not an app session) is answered from the session record alone. */ +function desktopSurfaceResponse(session: AppStateSessionRecord): DaemonResponse | undefined { + if (!isMacOs(session.device) || !session.surface) return undefined; + if (session.surface === 'app' || session.surface === 'frontmost-app') return undefined; + return { + ok: true, + data: { + platform: publicPlatformString(session.device), + appName: session.surface, + appBundleId: session.appBundleId, + source: 'session', + surface: session.surface, + }, + }; +} + +/** + * What this Apple session currently hosts: the tracked app's live runner state when the owner + * admits a read, otherwise the session record itself. A session with no tracked foreground app + * says so instead of inventing one. + */ +export async function appleSessionAppStateResponse( + params: RuntimeCommandHandlerParams, + session: AppStateSessionRecord, +): Promise { + if (!session.appName && !session.appBundleId) { + const surface = desktopSurfaceResponse(session); + if (surface) return surface; + const sessionPlatform = isMacOs(session.device) ? 'macOS' : 'iOS'; + return errorResponse( + 'COMMAND_FAILED', + `No foreground app is tracked for this ${sessionPlatform} session. Open an app in the session, then retry appstate.`, + ); + } + + const state = await readAppleSessionAppState(params, session); + const appName = session.appName ?? session.appBundleId; + return { + ok: true, + data: { + platform: publicPlatformString(session.device), + appName: appName ?? 'unknown', + appBundleId: session.appBundleId, + source: state ? 'runner' : 'session', + ...(state ? { state } : {}), + surface: session.surface ?? 'app', + ...(isIosFamily(session.device) + ? { + device_udid: session.device.id, + ios_simulator_device_set: session.device.simulatorSetPath ?? null, + } + : {}), + }, + }; +} diff --git a/src/daemon/handlers/session-appstate-device-runtime.ts b/src/daemon/handlers/session-appstate-device-runtime.ts new file mode 100644 index 0000000000..6057d8bc18 --- /dev/null +++ b/src/daemon/handlers/session-appstate-device-runtime.ts @@ -0,0 +1,70 @@ +import { appStateUse } from '@agent-device/contracts/platform-runtime-operations'; +import { + isIosFamily, + isMacOs, + publicPlatformString, + type DeviceInfo, +} from '@agent-device/kernel/device'; +import { errorResponse, type DaemonFailureResponse } from '@agent-device/kernel/contracts'; +import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation'; +import { admitRuntimeUse } from '../runtime-admission.ts'; +import type { RuntimeCommandHandlerParams } from '../session-runtime-admission.ts'; +import type { DaemonResponse } from '../daemon-request.ts'; +import { appleSessionRequiredRefusal } from './session-appstate-apple-session.ts'; + +function sortedAndroidSerialAllowlist(value: string | undefined): string[] | undefined { + const allowlist = resolveAndroidSerialAllowlist(value); + return allowlist ? [...allowlist].sort() : undefined; +} + +function appstateUnsupportedResponse( + device: DeviceInfo, + hint: string | undefined, +): DaemonFailureResponse { + return errorResponse( + 'UNSUPPORTED_OPERATION', + device.platform === 'web' + ? 'appstate is not supported on web.' + : 'appstate is not supported on this device', + undefined, + hint ? { hint } : undefined, + ); +} + +/** + * The foreground app a device runtime reports for a non-session target. Apple devices never + * answer here — they require the session the runner is attached to — so only runtimes whose owner + * admits `appState` (Android today) get their readiness warmed and queried. + */ +export async function queryDeviceRuntimeAppState( + params: RuntimeCommandHandlerParams, + device: DeviceInfo, +): Promise { + if (isIosFamily(device)) return appleSessionRequiredRefusal('ios'); + if (isMacOs(device)) return appleSessionRequiredRefusal('macos'); + const flags = params.req.flags ?? {}; + const admitted = await admitRuntimeUse({ + command: 'appstate', + device, + use: appStateUse, + inspectFacts: params.inspectFacts, + platformServices: params.platformServices, + bindDevice: params.bindDevice, + unavailableResponse: (unavailable) => appstateUnsupportedResponse(device, unavailable.hint), + }); + if (admitted.type === 'response') return admitted.response; + const runtime = admitted.runtime; + await runtime.operations.ensureReady({ + serial: flags.serial, + androidSerialAllowlist: sortedAndroidSerialAllowlist(flags.androidDeviceAllowlist), + }); + const state = await runtime.operations.appState(); + return { + ok: true, + data: { + platform: publicPlatformString(device), + package: state.package, + activity: state.activity, + }, + }; +} diff --git a/src/daemon/handlers/session-appstate.ts b/src/daemon/handlers/session-appstate.ts new file mode 100644 index 0000000000..591f4d742c --- /dev/null +++ b/src/daemon/handlers/session-appstate.ts @@ -0,0 +1,85 @@ +import { isApplePlatform } from '@agent-device/kernel/device'; +import { errorResponse } from '@agent-device/kernel/contracts'; +import { + hasExplicitSessionFlag, + requireSessionOrExplicitSelector, + resolveCommandDevice, + selectorTargetsSessionDevice, +} from '../session-device-resolution.ts'; +import type { RuntimeCommandHandlerParams } from '../session-runtime-admission.ts'; +import type { DaemonResponse } from '../daemon-request.ts'; +import { + appleSessionAppStateResponse, + appleSessionRequiredRefusal, +} from './session-appstate-apple-session.ts'; +import { queryDeviceRuntimeAppState } from './session-appstate-device-runtime.ts'; + +function appStateSessionNotFoundResponse( + sessionName: string, + platform: string | undefined, +): DaemonResponse { + if (platform === 'ios') { + return errorResponse( + 'SESSION_NOT_FOUND', + `No active session "${sessionName}". Run open with --session ${sessionName} first.`, + ); + } + return errorResponse( + 'SESSION_NOT_FOUND', + `No active session "${sessionName}". Run open with --session ${sessionName} first, or omit --session to query by device selector.`, + ); +} + +/** + * Has this appstate request an answerable target, and which authority answers it: the Apple + * session it points at, or the device runtime the selector resolves to. Every way the request can + * be refused without a device answers here. + */ +function admitAppStateTarget( + params: RuntimeCommandHandlerParams, +): { refusal: DaemonResponse } | { answeredBy: 'apple-session' | 'device-runtime' } { + const { req, sessionName, sessionStore } = params; + const session = sessionStore.get(sessionName); + const flags = req.flags ?? {}; + const normalizedPlatform = flags.platform; + + if (!session && hasExplicitSessionFlag(flags)) { + return { refusal: appStateSessionNotFoundResponse(sessionName, normalizedPlatform) }; + } + + const guard = requireSessionOrExplicitSelector('appstate', session, flags); + if (guard) return { refusal: guard }; + + const useSessionStateForApple = + isApplePlatform(session?.device.platform) && selectorTargetsSessionDevice(flags, session); + + if (normalizedPlatform === 'ios' && !useSessionStateForApple) { + return { refusal: appleSessionRequiredRefusal('ios') }; + } + if (normalizedPlatform === 'macos' && !useSessionStateForApple) { + return { refusal: appleSessionRequiredRefusal('macos') }; + } + return { answeredBy: useSessionStateForApple && session ? 'apple-session' : 'device-runtime' }; +} + +/** + * Which app is in the foreground: the Apple session's record and runner answer for their own + * devices; every other target is resolved and answered by its device runtime. + */ +export async function handleAppStateCommand( + params: RuntimeCommandHandlerParams, +): Promise { + const target = admitAppStateTarget(params); + if ('refusal' in target) return target.refusal; + + const session = params.sessionStore.get(params.sessionName); + if (target.answeredBy === 'apple-session' && session) { + return await appleSessionAppStateResponse(params, session); + } + const device = await resolveCommandDevice({ + session, + flags: params.req.flags ?? {}, + platformServices: params.platformServices, + }); + return await queryDeviceRuntimeAppState(params, device); +} diff --git a/src/daemon/handlers/session-boot.ts b/src/daemon/handlers/session-boot.ts new file mode 100644 index 0000000000..c563afbafa --- /dev/null +++ b/src/daemon/handlers/session-boot.ts @@ -0,0 +1,151 @@ +import { resolveDeviceReadinessRuntimePlan } from '@agent-device/contracts/platform-runtime-operations'; +import { + isApplePlatform, + publicPlatformString, + type DeviceInfo, +} from '@agent-device/kernel/device'; +import { asAppError } from '@agent-device/kernel/errors'; +import { errorResponse } from '@agent-device/kernel/contracts'; +import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation'; +import { + requireSessionOrExplicitSelector, + resolveCommandDevice, +} from '../session-device-resolution.ts'; +import { admitRuntimeOperations, type UnavailableRuntimeResponse } from '../runtime-admission.ts'; +import { startupDeadlineAtMs } from '../startup-deadline.ts'; +import type { RuntimeCommandHandlerParams } from '../session-runtime-admission.ts'; +import type { DaemonResponse } from '../daemon-request.ts'; + +/** `boot --headless` reports an unsupported cell as a request error, not a device capability gap. */ +function bootUnavailableResponse(headless: boolean): UnavailableRuntimeResponse { + return (unavailable) => + errorResponse( + headless ? 'INVALID_ARGS' : 'UNSUPPORTED_OPERATION', + headless + ? 'boot --headless is supported only for Android emulators.' + : 'boot is not supported on this device', + undefined, + unavailable.hint ? { hint: unavailable.hint } : undefined, + ); +} + +function hasAndroidAvdIdentity( + selectedName: string | undefined, + sessionDevice: DeviceInfo | undefined, +): boolean { + return Boolean( + selectedName?.trim() || + (sessionDevice?.platform === 'android' && sessionDevice.kind === 'emulator'), + ); +} + +/** + * Which device this boot targets. A headless plan whose selector carries no Android AVD identity + * turns the resolver's `DEVICE_NOT_FOUND` into the argument error that names the missing + * `--device `; every other resolution failure propagates unchanged. + */ +async function resolveBootTargetDevice( + params: RuntimeCommandHandlerParams, + headless: boolean, +): Promise { + const { sessionName, sessionStore, req, platformServices } = params; + const session = sessionStore.get(sessionName); + const flags = req.flags ?? {}; + try { + const device = await resolveCommandDevice({ + session, + flags, + androidAvdSelection: 'include-stopped', + platformServices, + }); + if (flags.target && (device.target ?? 'mobile') !== flags.target) { + return { + refusal: errorResponse( + 'DEVICE_NOT_FOUND', + `No ${device.platform} device found matching --target ${flags.target}.`, + ), + }; + } + return device; + } catch (error) { + if ( + headless && + !hasAndroidAvdIdentity(flags.device, session?.device) && + asAppError(error).code === 'DEVICE_NOT_FOUND' + ) { + return { + refusal: errorResponse( + 'INVALID_ARGS', + 'boot --headless requires --device (or an Android emulator session target).', + ), + }; + } + throw error; + } +} + +/** The device the runtime now reports as booted, in the wire shape `boot` publishes. */ +function bootedDeviceResponse(device: DeviceInfo): DaemonResponse { + return { + ok: true, + data: { + platform: publicPlatformString(device), + target: device.target ?? 'mobile', + device: device.name, + id: device.id, + kind: device.kind, + booted: true, + // Additive Apple-OS discriminant; Apple devices only. Gate on the platform + // (not just field presence) so a non-Apple record with a stray appleOs never + // surfaces it. + ...(isApplePlatform(device.platform) && device.appleOs ? { appleOs: device.appleOs } : {}), + }, + }; +} + +/** Brings the selected device up, headless for an Android AVD plan, through one admitted runtime. */ +export async function handleBootCommand( + params: RuntimeCommandHandlerParams, +): Promise { + const flags = params.req.flags ?? {}; + const guard = requireSessionOrExplicitSelector( + params.req.command, + params.sessionStore.get(params.sessionName), + flags, + ); + if (guard) return guard; + + const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( + flags.androidDeviceAllowlist, + ); + const androidSerialAllowlist = resolvedAndroidSerialAllowlist + ? [...resolvedAndroidSerialAllowlist].sort() + : undefined; + const plan = resolveDeviceReadinessRuntimePlan({ headless: flags.headless === true }); + + const target = await resolveBootTargetDevice(params, plan.kind === 'boot-target-headless'); + if ('refusal' in target) return target.refusal; + + const admitted = await admitRuntimeOperations({ + command: 'boot', + device: target, + required: plan.use.required, + inspectFacts: params.inspectFacts, + platformServices: params.platformServices, + bindDevice: params.bindDevice, + unavailableResponse: bootUnavailableResponse(plan.kind === 'boot-target-headless'), + }); + if (admitted.type === 'response') return admitted.response; + + const input = { + serial: flags.serial, + androidSerialAllowlist, + deadlineAtMs: startupDeadlineAtMs(flags.timeoutMs), + }; + const device = + plan.kind === 'boot-target-headless' + ? await (await admitted.bind(target, plan.use)).operations.bootTargetHeadless(input) + : await (await admitted.bind(target, plan.use)).operations.bootTarget(input); + + return bootedDeviceResponse(device); +} diff --git a/src/daemon/handlers/session-shutdown.ts b/src/daemon/handlers/session-shutdown.ts new file mode 100644 index 0000000000..f9e7a74b7d --- /dev/null +++ b/src/daemon/handlers/session-shutdown.ts @@ -0,0 +1,104 @@ +import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import type { TargetShutdownResult } from '@agent-device/contracts/device'; +import { shutdownTargetUse } from '@agent-device/contracts/platform-runtime-operations'; +import { isApplePlatform, publicPlatformString } from '@agent-device/kernel/device'; +import { errorResponse } from '@agent-device/kernel/contracts'; +import { + requireSessionOrExplicitSelector, + resolveCommandDevice, +} from '../session-device-resolution.ts'; +import { + requireRuntimeBinding, + requireRuntimeFacts, + type RuntimeCommandHandlerParams, +} from '../session-runtime-admission.ts'; +import type { DaemonResponse } from '../daemon-request.ts'; + +function shutdownUnavailableResponse(fact: RuntimeOperationFact) { + if (fact.available) return null; + return errorResponse( + 'UNSUPPORTED_OPERATION', + 'shutdown is supported only for Apple simulators and Android emulators.', + undefined, + fact.hint ? { hint: fact.hint } : undefined, + ); +} + +function shutdownFailureMessage(shutdown: TargetShutdownResult): string { + const message = shutdown.error?.message ?? shutdown.stderr.trim(); + return message.length > 0 ? message : 'Shutdown failed'; +} + +/** Turns off the selected simulator/emulator without ending any session that uses it. */ +export async function handleShutdownCommand( + params: RuntimeCommandHandlerParams, +): Promise { + const activeSession = params.sessionStore.get(params.sessionName); + const flags = params.req.flags ?? {}; + const guard = requireSessionOrExplicitSelector(params.req.command, activeSession, flags); + if (guard) return guard; + + const device = await resolveCommandDevice({ + flags, + session: activeSession, + androidAvdSelection: 'include-stopped', + platformServices: params.platformServices, + }); + const inspectFacts = requireRuntimeFacts(params.inspectFacts); + const facts = await inspectFacts(device); + const unsupported = shutdownUnavailableResponse(facts.operations.shutdownTarget); + if (unsupported) return unsupported; + + if ( + activeSession && + activeSession.device.platform === device.platform && + activeSession.device.id === device.id + ) { + return errorResponse( + 'DEVICE_IN_USE', + 'Cannot shut down an active session device directly. Use close --shutdown to end the session and turn off the simulator/emulator.', + { + hint: `Run agent-device close --shutdown --session ${params.sessionName}`, + session: params.sessionName, + platform: publicPlatformString(device), + target: device.target ?? 'mobile', + device: device.name, + id: device.id, + kind: device.kind, + }, + ); + } + + const bindDevice = requireRuntimeBinding(params.bindDevice); + const shutdown = await (await bindDevice(device, shutdownTargetUse)).operations.shutdownTarget(); + if (!shutdown.success) { + return errorResponse( + shutdown.error?.code ?? 'COMMAND_FAILED', + shutdownFailureMessage(shutdown), + { + platform: publicPlatformString(device), + target: device.target ?? 'mobile', + device: device.name, + id: device.id, + kind: device.kind, + shutdown, + }, + ); + } + + return { + ok: true, + data: { + platform: publicPlatformString(device), + target: device.target ?? 'mobile', + device: device.name, + id: device.id, + kind: device.kind, + shutdown, + // Additive Apple-OS discriminant; Apple devices only. Gate on the platform + // (not just field presence) so a non-Apple record with a stray appleOs never + // surfaces it. + ...(isApplePlatform(device.platform) && device.appleOs ? { appleOs: device.appleOs } : {}), + }, + }; +} diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index e9ed47c444..eca51c67d4 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -1,424 +1,19 @@ -import type { DaemonPlatformServices } from '../platform-services.ts'; -import { asAppError, isRequestCanceledError } from '@agent-device/kernel/errors'; -import type { TargetShutdownResult } from '@agent-device/contracts/device'; -import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; -import { - appStateUse, - resolveDeviceReadinessRuntimePlan, - shutdownTargetUse, -} from '@agent-device/contracts/platform-runtime-operations'; -import { - isApplePlatform, - isIosFamily, - isMacOs, - publicPlatformString, - type DeviceInfo, -} from '@agent-device/kernel/device'; -import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import { SessionStore } from '../session-store.ts'; -import { startupDeadlineAtMs } from '../startup-deadline.ts'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import type { AppleApplicationState } from '@agent-device/kernel/snapshot'; -import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation'; -import { - hasExplicitSessionFlag, - requireSessionOrExplicitSelector, - resolveCommandDevice, - selectorTargetsSessionDevice, -} from '../session-device-resolution.ts'; -import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { - admitRuntimeOperations, - admitRuntimeUse, - type UnavailableRuntimeResponse, -} from '../runtime-admission.ts'; -import { - requireRuntimeBinding, - requireRuntimeFacts, - type RuntimeCommandHandlerParams, -} from '../session-runtime-admission.ts'; -import { errorResponse } from '@agent-device/kernel/contracts'; - -const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE = - 'iOS appstate requires an active session on the target device. Run open first (for example: open --session sim --platform ios --device "" ).'; -const MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE = - 'macOS appstate requires an active session on the target device. Run open first (for example: open --session macos --platform macos "System Settings").'; - -/** `boot --headless` reports an unsupported cell as a request error, not a device capability gap. */ -function bootUnavailableResponse(headless: boolean): UnavailableRuntimeResponse { - return (unavailable) => - errorResponse( - headless ? 'INVALID_ARGS' : 'UNSUPPORTED_OPERATION', - headless - ? 'boot --headless is supported only for Android emulators.' - : 'boot is not supported on this device', - undefined, - unavailable.hint ? { hint: unavailable.hint } : undefined, - ); -} - -function shutdownUnavailableResponse(fact: RuntimeOperationFact) { - if (fact.available) return null; - return errorResponse( - 'UNSUPPORTED_OPERATION', - 'shutdown is supported only for Apple simulators and Android emulators.', - undefined, - fact.hint ? { hint: fact.hint } : undefined, - ); -} - -function hasAndroidAvdIdentity( - selectedName: string | undefined, - sessionDevice: DeviceInfo | undefined, -): boolean { - return Boolean( - selectedName?.trim() || - (sessionDevice?.platform === 'android' && sessionDevice.kind === 'emulator'), - ); -} +import type { DaemonResponse } from '../daemon-request.ts'; +import type { RuntimeCommandHandlerParams } from '../session-runtime-admission.ts'; +import { handleAppStateCommand } from './session-appstate.ts'; +import { handleBootCommand } from './session-boot.ts'; +import { handleShutdownCommand } from './session-shutdown.ts'; /** - * The session app's state as a live runner reads it, when this device's owner admits the read; - * nothing otherwise, so the session record alone answers and no state is invented. The owner never - * starts a runner for it. A runner that cannot answer right now (busy, mid-restart) leaves the - * session answer as it was and says so in the log; a cancelled request stays cancelled. + * The routing card for the device-state command group. Each of `boot`, `shutdown` and `appstate` + * answers its own domain question in a dedicated handler; this entry only decides which handler + * speaks, and returns null for a command the group does not own. */ -async function readAppleSessionAppState( +export async function handleSessionStateCommands( params: RuntimeCommandHandlerParams, - session: Readonly<{ device: DeviceInfo; appBundleId?: string }>, -): Promise { - if (!session.appBundleId) return undefined; - const admitted = await admitRuntimeUse({ - command: 'appstate', - device: session.device, - use: appStateUse, - inspectFacts: params.inspectFacts, - platformServices: params.platformServices, - bindDevice: params.bindDevice, - }); - if (admitted.type === 'response') return undefined; - try { - const read = await admitted.runtime.operations.appState({ appBundleId: session.appBundleId }); - return read.applicationState; - } catch (error) { - if (isRequestCanceledError(error)) throw error; - emitDiagnostic({ - level: 'warn', - phase: 'apple_appstate_runner_read_failed', - data: { code: asAppError(error).code, message: asAppError(error).message }, - }); - return undefined; - } -} - -async function handleAppStateCommand(params: RuntimeCommandHandlerParams): Promise { - const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); - const flags = req.flags ?? {}; - const normalizedPlatform = flags.platform; - - if (!session && hasExplicitSessionFlag(flags)) { - const message = - normalizedPlatform === 'ios' - ? `No active session "${sessionName}". Run open with --session ${sessionName} first.` - : `No active session "${sessionName}". Run open with --session ${sessionName} first, or omit --session to query by device selector.`; - return errorResponse('SESSION_NOT_FOUND', message); - } - - const guard = requireSessionOrExplicitSelector('appstate', session, flags); - if (guard) return guard; - - const shouldUseSessionStateForApple = - isApplePlatform(session?.device.platform) && selectorTargetsSessionDevice(flags, session); - const targetsIos = normalizedPlatform === 'ios'; - const targetsMacOs = normalizedPlatform === 'macos'; - - if (targetsIos && !shouldUseSessionStateForApple) { - return errorResponse('SESSION_NOT_FOUND', IOS_APPSTATE_SESSION_REQUIRED_MESSAGE); - } - if (targetsMacOs && !shouldUseSessionStateForApple) { - return errorResponse('SESSION_NOT_FOUND', MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE); - } - - if (shouldUseSessionStateForApple && session) { - const appName = session.appName ?? session.appBundleId; - if (!session.appName && !session.appBundleId) { - if ( - isMacOs(session.device) && - session.surface && - session.surface !== 'app' && - session.surface !== 'frontmost-app' - ) { - return { - ok: true, - data: { - platform: publicPlatformString(session.device), - appName: session.surface, - appBundleId: session.appBundleId, - source: 'session', - surface: session.surface, - }, - }; - } - - const sessionPlatform = isMacOs(session.device) ? 'macOS' : 'iOS'; - return errorResponse( - 'COMMAND_FAILED', - `No foreground app is tracked for this ${sessionPlatform} session. Open an app in the session, then retry appstate.`, - ); - } - - const state = await readAppleSessionAppState(params, session); - return { - ok: true, - data: { - platform: publicPlatformString(session.device), - appName: appName ?? 'unknown', - appBundleId: session.appBundleId, - source: state ? 'runner' : 'session', - ...(state ? { state } : {}), - surface: session.surface ?? 'app', - ...(isIosFamily(session.device) - ? { - device_udid: session.device.id, - ios_simulator_device_set: session.device.simulatorSetPath ?? null, - } - : {}), - }, - }; - } - - const device = await resolveCommandDevice({ - session, - flags, - platformServices: params.platformServices, - }); - if (isIosFamily(device)) { - return errorResponse('SESSION_NOT_FOUND', IOS_APPSTATE_SESSION_REQUIRED_MESSAGE); - } - if (isMacOs(device)) { - return errorResponse('SESSION_NOT_FOUND', MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE); - } - const admitted = await admitRuntimeUse({ - command: 'appstate', - device, - use: appStateUse, - inspectFacts: params.inspectFacts, - platformServices: params.platformServices, - bindDevice: params.bindDevice, - unavailableResponse: (unavailable) => - errorResponse( - 'UNSUPPORTED_OPERATION', - device.platform === 'web' - ? 'appstate is not supported on web.' - : 'appstate is not supported on this device', - undefined, - unavailable.hint ? { hint: unavailable.hint } : undefined, - ), - }); - if (admitted.type === 'response') return admitted.response; - const runtime = admitted.runtime; - await runtime.operations.ensureReady({ - serial: flags.serial, - androidSerialAllowlist: resolveAndroidSerialAllowlistForAppState(flags.androidDeviceAllowlist), - }); - const state = await runtime.operations.appState(); - return { - ok: true, - data: { - platform: publicPlatformString(device), - package: state.package, - activity: state.activity, - }, - }; -} - -export async function handleSessionStateCommands(params: { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - inspectFacts?: InspectDeviceRuntimeFacts; - platformServices: DaemonPlatformServices; - bindDevice?: BindDeviceRuntime; -}): Promise { - const { req, sessionName, sessionStore } = params; - - if (req.command === 'boot') { - const session = sessionStore.get(sessionName); - const flags = req.flags ?? {}; - const guard = requireSessionOrExplicitSelector(req.command, session, flags); - if (guard) return guard; - - const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( - flags.androidDeviceAllowlist, - ); - const androidSerialAllowlist = resolvedAndroidSerialAllowlist - ? [...resolvedAndroidSerialAllowlist].sort() - : undefined; - const plan = resolveDeviceReadinessRuntimePlan({ headless: flags.headless === true }); - - let device: DeviceInfo; - try { - device = await resolveCommandDevice({ - session, - flags, - androidAvdSelection: 'include-stopped', - platformServices: params.platformServices, - }); - } catch (error) { - const appErr = asAppError(error); - if ( - plan.kind === 'boot-target-headless' && - !hasAndroidAvdIdentity(flags.device, session?.device) && - appErr.code === 'DEVICE_NOT_FOUND' - ) { - return errorResponse( - 'INVALID_ARGS', - 'boot --headless requires --device (or an Android emulator session target).', - ); - } - throw error; - } - - if (flags.target && (device.target ?? 'mobile') !== flags.target) { - return errorResponse( - 'DEVICE_NOT_FOUND', - `No ${device.platform} device found matching --target ${flags.target}.`, - ); - } - - const admitted = await admitRuntimeOperations({ - command: 'boot', - device, - required: plan.use.required, - inspectFacts: params.inspectFacts, - platformServices: params.platformServices, - bindDevice: params.bindDevice, - unavailableResponse: bootUnavailableResponse(plan.kind === 'boot-target-headless'), - }); - if (admitted.type === 'response') return admitted.response; - - const input = { - serial: flags.serial, - androidSerialAllowlist, - deadlineAtMs: startupDeadlineAtMs(flags.timeoutMs), - }; - if (plan.kind === 'boot-target-headless') { - device = await (await admitted.bind(device, plan.use)).operations.bootTargetHeadless(input); - } else { - device = await (await admitted.bind(device, plan.use)).operations.bootTarget(input); - } - - return { - ok: true, - data: { - platform: publicPlatformString(device), - target: device.target ?? 'mobile', - device: device.name, - id: device.id, - kind: device.kind, - booted: true, - // Additive Apple-OS discriminant; Apple devices only. Gate on the platform - // (not just field presence) so a non-Apple record with a stray appleOs never - // surfaces it. - ...(isApplePlatform(device.platform) && device.appleOs ? { appleOs: device.appleOs } : {}), - }, - }; - } - - if (req.command === 'shutdown') { - const activeSession = sessionStore.get(sessionName); - const flags = req.flags ?? {}; - const guard = requireSessionOrExplicitSelector(req.command, activeSession, flags); - if (guard) return guard; - - const device = await resolveCommandDevice({ - flags, - session: activeSession, - androidAvdSelection: 'include-stopped', - platformServices: params.platformServices, - }); - const inspectFacts = requireRuntimeFacts(params.inspectFacts); - const facts = await inspectFacts(device); - const unsupported = shutdownUnavailableResponse(facts.operations.shutdownTarget); - if (unsupported) return unsupported; - - if ( - activeSession && - activeSession.device.platform === device.platform && - activeSession.device.id === device.id - ) { - return errorResponse( - 'DEVICE_IN_USE', - 'Cannot shut down an active session device directly. Use close --shutdown to end the session and turn off the simulator/emulator.', - { - hint: `Run agent-device close --shutdown --session ${sessionName}`, - session: sessionName, - platform: publicPlatformString(device), - target: device.target ?? 'mobile', - device: device.name, - id: device.id, - kind: device.kind, - }, - ); - } - - const bindDevice = requireRuntimeBinding(params.bindDevice); - const shutdown = await ( - await bindDevice(device, shutdownTargetUse) - ).operations.shutdownTarget(); - if (!shutdown.success) { - return errorResponse( - shutdown.error?.code ?? 'COMMAND_FAILED', - shutdownFailureMessage(shutdown), - { - platform: publicPlatformString(device), - target: device.target ?? 'mobile', - device: device.name, - id: device.id, - kind: device.kind, - shutdown, - }, - ); - } - - return { - ok: true, - data: { - platform: publicPlatformString(device), - target: device.target ?? 'mobile', - device: device.name, - id: device.id, - kind: device.kind, - shutdown, - // Additive Apple-OS discriminant; Apple devices only. Gate on the platform - // (not just field presence) so a non-Apple record with a stray appleOs never - // surfaces it. - ...(isApplePlatform(device.platform) && device.appleOs ? { appleOs: device.appleOs } : {}), - }, - }; - } - - if (req.command === 'appstate') { - return await handleAppStateCommand({ - req, - sessionName, - sessionStore, - inspectFacts: params.inspectFacts, - platformServices: params.platformServices, - bindDevice: params.bindDevice, - }); - } - +): Promise { + if (params.req.command === 'boot') return await handleBootCommand(params); + if (params.req.command === 'shutdown') return await handleShutdownCommand(params); + if (params.req.command === 'appstate') return await handleAppStateCommand(params); return null; } - -function resolveAndroidSerialAllowlistForAppState(value: string | undefined): string[] | undefined { - const allowlist = resolveAndroidSerialAllowlist(value); - return allowlist ? [...allowlist].sort() : undefined; -} - -function shutdownFailureMessage(shutdown: TargetShutdownResult): string { - const message = shutdown.error?.message ?? shutdown.stderr.trim(); - return message.length > 0 ? message : 'Shutdown failed'; -} diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index 0646bdd3ea..e9064bcaba 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -179,7 +179,7 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'shutdown simulator boots again and inventory confirms it', ), macos: macos.contract( - 'src/daemon/handlers/__tests__/session-boot-shutdown.test.ts', + 'src/daemon/handlers/__tests__/session-boot.test.ts', 'boot rejects the macOS host boot cell after one facts inspection and before binding', 'macOS boot is refused by the runtime fact before dispatch', ), From ab79e9b8f51fba227eab86d407378e16943c63fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 22:48:06 +0200 Subject: [PATCH 2/2] chore(gates): register split daemon handler modules and retire resolved health baseline entry The five new handler modules join the daemon-sessions layer in the R81 manifest, and the fallow health baseline drops the session-state.ts complexity_critical entry its two split functions no longer trigger. --- fallow-baselines/health.json | 6 ------ scripts/layering/daemon-layer-manifest.json | 5 +++++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index 5ce01a8576..6834f3ba78 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -78,11 +78,6 @@ "count": 1 } }, - "src/daemon/handlers/session-state.ts": { - "complexity_critical": { - "count": 2 - } - }, "src/daemon/handlers/session-test-artifacts.ts": { "complexity_moderate": { "count": 1 @@ -197,7 +192,6 @@ "packages/provision-kit/src/boot-diagnostics.ts:complexity", "src/compat/maestro/support.ts:high impact", "src/daemon/session-routing.ts:high impact", - "src/daemon/handlers/session-state.ts:complexity", "src/commands/cli-grammar/common.ts:high impact", "src/snapshot/snapshot-presentation/tree.ts:high impact", "packages/kernel/src/success-text.ts:high impact", diff --git a/scripts/layering/daemon-layer-manifest.json b/scripts/layering/daemon-layer-manifest.json index 09f744d822..9256d1b932 100644 --- a/scripts/layering/daemon-layer-manifest.json +++ b/scripts/layering/daemon-layer-manifest.json @@ -192,6 +192,10 @@ "src/daemon/handlers/session-app-deployment.ts", "src/daemon/handlers/session-app-source-deployment.ts", "src/daemon/handlers/session-batch.ts", + "src/daemon/handlers/session-appstate-apple-session.ts", + "src/daemon/handlers/session-appstate-device-runtime.ts", + "src/daemon/handlers/session-appstate.ts", + "src/daemon/handlers/session-boot.ts", "src/daemon/handlers/session-command-input.ts", "src/daemon/handlers/session-doctor-app.ts", "src/daemon/handlers/session-doctor-device.ts", @@ -206,6 +210,7 @@ "src/daemon/handlers/session-runtime-command.ts", "src/daemon/handlers/session-runtime-port-reverse.ts", "src/daemon/handlers/session-script-publication.ts", + "src/daemon/handlers/session-shutdown.ts", "src/daemon/handlers/session-state.ts", "src/daemon/handlers/session.ts", "src/daemon/handlers/snapshot.ts",