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", 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', ),