From 1209d3b4103fc459f2472660c26fed17e50d76e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 14:18:47 +0200 Subject: [PATCH 1/9] test(daemon): add lifecycle-operations spy fixture for close finalization Co-Authored-By: Claude Opus 5.5 --- .../support/lifecycle-operations-spy.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/daemon/src/__tests__/support/lifecycle-operations-spy.ts diff --git a/packages/daemon/src/__tests__/support/lifecycle-operations-spy.ts b/packages/daemon/src/__tests__/support/lifecycle-operations-spy.ts new file mode 100644 index 0000000000..a86cff82c5 --- /dev/null +++ b/packages/daemon/src/__tests__/support/lifecycle-operations-spy.ts @@ -0,0 +1,27 @@ +import { vi } from 'vitest'; +import type { ApplicationLifecycleRuntimeOperations } from '@agent-device/contracts/application-lifecycle-runtime'; +import { + applicationLifecycleRuntimeFixture, + type LifecycleOperationsLoader, +} from './application-lifecycle-runtime-fixture.ts'; + +/** + * The canned lifecycle port with `finalizeApplicationClose` answered by a spy (an empty result by + * default) and any other operation replaceable per case. Daemon close tests read the spy to prove + * the neutral orchestration asked the platform for close finalization, and with which arguments, + * without encoding how a platform performs it. + */ +export function lifecycleOperationsSpy( + overrides: Partial = {}, +) { + const finalizeApplicationClose = vi.fn< + ApplicationLifecycleRuntimeOperations['finalizeApplicationClose'] + >(async () => ({})); + const loader: LifecycleOperationsLoader = async (device, signal, shutdownTarget) => + Object.freeze({ + ...(await applicationLifecycleRuntimeFixture(device, signal, shutdownTarget)), + ...overrides, + finalizeApplicationClose, + }); + return { loader, finalizeApplicationClose }; +} From ccb40dee49b69e38bd7d31b0bfd85a4477032c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 14:18:47 +0200 Subject: [PATCH 2/9] test: redesign session-close composition tests (a/c splits) session-close-error-precedence: (a) daemon error precedence into the in-package twin with finalize spy; (c) apple pre-close runner stop into platform-apple lifecycle.test. session-close-save-script: (a) into the in-package twin. session-close-shutdown-platform: (a) daemon forwarding into the twin; (c) shutdown gating into platform-apple and platform-android lifecycle tests. session-teardown-resources: (a) close-handler recording tests into the twin; (c) retention policy into src/platform-runtime-resource-cleanup.test.ts. session-close-runtime-hints: (a) hint facts and clearing into session-close-lifecycle-runtime twin; (c) lazy runtime-hints load into src/platform-runtime-android-application-tools.test.ts. Co-Authored-By: Claude Opus 5.5 --- .../session-close-error-precedence.test.ts | 106 +++++++ .../session-close-lifecycle-runtime.test.ts | 171 ++++++++++- .../session-close-save-script.test.ts | 73 +++++ .../session-close-shutdown-platform.test.ts | 59 ++++ .../session-teardown-resources.test.ts | 70 +++++ .../platform-android/src/lifecycle.test.ts | 23 +- packages/platform-apple/src/lifecycle.test.ts | 62 ++++ .../session-close-error-precedence.test.ts | 152 ---------- .../session-close-runtime-hints.test.ts | 271 ------------------ .../session-close-save-script.test.ts | 107 ------- .../session-close-shutdown-platform.test.ts | 109 ------- .../session-teardown-resources.test.ts | 109 ------- ...-runtime-android-application-tools.test.ts | 58 ++++ 13 files changed, 620 insertions(+), 750 deletions(-) delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-close-error-precedence.test.ts delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-close-runtime-hints.test.ts delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-close-save-script.test.ts delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-close-shutdown-platform.test.ts delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts create mode 100644 src/platform-runtime-android-application-tools.test.ts diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts index 7614988154..975a8d06ca 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts @@ -5,6 +5,7 @@ import { type SessionState, } from './session-close-shutdown.fixtures.ts'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; +import { lifecycleOperationsSpy } from '../../../../__tests__/support/lifecycle-operations-spy.ts'; const { acquireDeviceClaim, @@ -303,6 +304,111 @@ test('a successful close clears the device claim', async () => { } }); +test('targeted close preserves the platform-close AppError and still runs later cleanup', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'targeted-close-error-session'; + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { + device: { + platform: 'apple', + id: 'sim-udid-close-error', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }, + }); + sessionStore.publish(sessionName, session); + + const platformCloseError = new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { + reason: 'device_disconnected', + hint: 'Reconnect the device and retry close.', + }); + mockDispatchCommand.mockRejectedValueOnce(platformCloseError); + const { loader, finalizeApplicationClose } = lifecycleOperationsSpy(); + + await expect( + handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: ['com.example.app'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toBe(platformCloseError); + + expect(platformCloseError.code).toBe('DEVICE_UNAVAILABLE'); + expect(platformCloseError.details).toMatchObject({ + reason: 'device_disconnected', + hint: 'Reconnect the device and retry close.', + }); + expect(session.actions.some((action) => action.command === 'close')).toBe(false); + expect(finalizeApplicationClose).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('targeted close surfaces a required pre-close failure from the lifecycle port and still finalizes', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'targeted-close-preclose-failure-session'; + const session = { + ...makeSession(sessionName, { + platform: 'apple', + id: 'physical-device-close', + name: 'My iPhone', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.app', + } as unknown as SessionState; + sessionStore.publish(sessionName, session); + + const preCloseError = new AppError('RUNNER_UNAVAILABLE', 'runner stop failed', { + reason: 'runner_stop_failed', + hint: 'Retry once the runner is reachable.', + }); + const { loader, finalizeApplicationClose } = lifecycleOperationsSpy({ + closeApplication: async () => { + throw preCloseError; + }, + }); + + await expect( + handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: ['com.example.app'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toBe(preCloseError); + + expect(mockDispatchCommand).not.toHaveBeenCalled(); + expect(preCloseError.code).toBe('RUNNER_UNAVAILABLE'); + expect(preCloseError.details).toMatchObject({ + reason: 'runner_stop_failed', + hint: 'Retry once the runner is reachable.', + }); + expect(session.actions.some((action) => action.command === 'close')).toBe(false); + expect(finalizeApplicationClose).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + // Live evidence (2026-08-02): a plain `open` followed by `close --save-script` used to fold the // never-armed session into the authoring lifecycle and publish anyway, producing a script with // selector fallback chains but no recording-time `target-v1` evidence. These two tests prove the diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index bab09d947d..d43f78abbd 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -20,7 +20,10 @@ import { mockInspectDeviceRuntimeFacts, } from '../../../../__tests__/support/handlers/session-command-harness.ts'; import { lifecycleRuntimeFacts } from '../../../../__tests__/support/application-lifecycle-runtime-harness.ts'; -import { dispatchApplicationLifecycleEffect } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; +import { + clearRuntimeHintsFixture, + dispatchApplicationLifecycleEffect, +} from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); @@ -255,3 +258,169 @@ test('app-only close expires the rebuilt record after admission, preserving the expect(refFrameState(ref.session)).toBe('active'); expect(mockDispatch).toHaveBeenCalledOnce(); }); + +test('a close-capable runtime with false runtime-hints facts never asks the platform to clear hints', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'harmony-close-without-runtime-hints'; + const device = { + platform: 'harmonyos' as const, + id: 'harmony-device-1', + name: 'Harmony device', + kind: 'device' as const, + target: 'mobile' as const, + booted: true, + }; + const session = makeSession(sessionName, device); + session.appBundleId = 'com.example.harmony'; + sessionStore.publish(sessionName, session); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { + const facts = lifecycleRuntimeFacts(candidate); + return { + ...facts, + operations: { + ...facts.operations, + clearRuntimeHints: { + available: false as const, + reason: 'unsupported-platform-leaf' as const, + }, + }, + }; + }); + + const response = await close({ + sessionName, + sessionStore, + positionals: ['com.example.harmony'], + }); + + expect(response?.ok).toBe(true); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); + expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledOnce(); + expect(clearRuntimeHintsFixture).not.toHaveBeenCalled(); +}); + +test('an app-only close with stored hints selects no runtime-hint cleanup sibling', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-app-only-close-stored-runtime-hints'; + const device = { + platform: 'android' as const, + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, + }; + const session = makeSession(sessionName, device); + session.appBundleId = 'com.example.app'; + sessionStore.publish(sessionName, session); + sessionStore.setRuntimeHints(sessionName, { platform: 'android', metroHost: '10.0.2.2' }); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { + const facts = lifecycleRuntimeFacts(candidate); + return { + ...facts, + operations: { + ...facts.operations, + clearRuntimeHints: { + available: false as const, + reason: 'unsupported-provider-mode' as const, + }, + }, + }; + }); + + const response = await close({ + sessionName, + sessionStore, + positionals: ['com.example.app'], + internal: { closeAppOnly: true }, + }); + + expect(response?.ok).toBe(true); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); + expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledOnce(); + expect(clearRuntimeHintsFixture).not.toHaveBeenCalled(); + expect(sessionStore.getRuntimeHints(sessionName)).toEqual({ + platform: 'android', + metroHost: '10.0.2.2', + }); +}); + +test('a close with persisted native hints fails closed when the distinct cleanup fact is false', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-close-stale-runtime-hints'; + const device = { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'ios-device-1', + name: 'iPhone', + kind: 'device' as const, + target: 'mobile' as const, + booted: true, + }; + const session = makeSession(sessionName, device); + session.appBundleId = 'com.example.app'; + sessionStore.publish(sessionName, session); + sessionStore.setRuntimeHints(sessionName, { platform: 'ios', metroHost: '10.0.2.2' }); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { + const facts = lifecycleRuntimeFacts(candidate); + return { + ...facts, + operations: { + ...facts.operations, + clearRuntimeHints: { + available: false as const, + reason: 'unsupported-device-kind' as const, + }, + }, + }; + }); + + const response = await close({ sessionName, sessionStore }); + + expect(response).toMatchObject({ + ok: false, + error: { code: 'UNSUPPORTED_OPERATION', message: 'close is not supported on this device' }, + }); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); + expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(clearRuntimeHintsFixture).not.toHaveBeenCalled(); + expect(sessionStore.get(sessionName)).toMatchObject({ name: sessionName }); +}); + +test('a supported Android close clears admitted runtime hints exactly once', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-close-runtime-hints'; + const device = { + platform: 'android' as const, + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, + }; + const session = makeSession(sessionName, device); + session.appBundleId = 'com.example.app'; + sessionStore.publish(sessionName, session); + sessionStore.setRuntimeHints(sessionName, { + platform: 'android', + metroHost: '10.0.2.2', + metroPort: 8081, + }); + + const response = await close({ sessionName, sessionStore }); + + expect(response?.ok).toBe(true); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); + expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); + expect(clearRuntimeHintsFixture).toHaveBeenCalledOnce(); + expect(clearRuntimeHintsFixture).toHaveBeenCalledWith( + expect.objectContaining({ + device, + appId: 'com.example.app', + values: expect.objectContaining({ metroHost: '10.0.2.2', metroPort: '8081' }), + }), + ); +}); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-save-script.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-save-script.test.ts index 7ad6fcb12c..2dd42d54c8 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-save-script.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-save-script.test.ts @@ -3,13 +3,17 @@ import { beforeEach, expect, test } from 'vitest'; import { sessionCloseShutdownFixture } from './session-close-shutdown.fixtures.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; +import { lifecycleOperationsSpy } from '../../../../__tests__/support/lifecycle-operations-spy.ts'; const { handleSessionCommands, + fs, + makeIosSimulatorRecordingSession, makeSession, makeSessionStore, noopInvoke, path, + recordingFinishMock, resetSessionCloseShutdownMocks, } = sessionCloseShutdownFixture; @@ -56,3 +60,72 @@ test('close --save-script on a session with an active .ad repair transaction is expect(response?.ok).toBe(true); expect(sessionStore.get(sessionName)).toBeUndefined(); }); + +test('close --save-script on a never-armed session is rejected before teardown, with no script written', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-unarmed-close-save-script-session'; + const scriptPath = path.join( + mkdtempForTestSync('agent-device-unarmed-close'), + `agent-device-unarmed-close-${Date.now()}.ad`, + ); + // The session carries cleanup-bearing state (an active recording) so the assertions below can + // tell a guard that runs before teardown from one that runs after it. + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); + const finish = recordingFinishMock(session); + sessionStore.publish(sessionName, session); + const { loader, finalizeApplicationClose } = lifecycleOperationsSpy(); + + await expect( + handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: { saveScript: scriptPath }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toMatchObject({ + code: 'INVALID_ARGS', + message: expect.stringMatching(/not armed/), + details: expect.objectContaining({ + hint: expect.stringMatching(/open --save-script/), + }), + }); + + expect(sessionStore.get(sessionName)).toBeDefined(); + expect(fs.existsSync(scriptPath)).toBe(false); + expect(finish).not.toHaveBeenCalled(); + expect(session.screenRecording).toBeDefined(); + expect(finalizeApplicationClose).not.toHaveBeenCalled(); + + const plainClose = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(plainClose?.ok).toBe(true); + expect(finish).toHaveBeenCalledOnce(); + expect(finalizeApplicationClose).toHaveBeenCalledWith( + expect.objectContaining({ retainRunner: false }), + ); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts index a30bdffe1d..453fe454a2 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts @@ -7,6 +7,7 @@ import { } from './session-close-shutdown.fixtures.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; +import { lifecycleOperationsSpy } from '../../../../__tests__/support/lifecycle-operations-spy.ts'; const { handleSessionCommands, @@ -297,3 +298,61 @@ test('close --shutdown returns success and failure payload when shutdownSimulato expect(sessionStore.get(sessionName)).toBeUndefined(); expectShutdownFailure(response, sessionName, 'simctl shutdown failed'); }); + +test.each([ + { + label: 'a non-simulator iOS device', + device: { + platform: 'apple' as const, + id: 'physical-device-1', + name: 'My iPhone', + kind: 'device' as const, + booted: true, + }, + }, + { + label: 'an Android device', + device: { + platform: 'android' as const, + id: 'R5CT123456A', + name: 'Pixel 9', + kind: 'device' as const, + booted: true, + }, + }, +])( + 'close --shutdown reaches the platform finalization for $label and reports no shutdown when the platform declines', + async ({ device }) => { + const sessionStore = makeSessionStore(); + const sessionName = `${device.platform}-device-shutdown-session`; + sessionStore.publish(sessionName, makeSession(sessionName, device)); + const { loader, finalizeApplicationClose } = lifecycleOperationsSpy(); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: { shutdown: true }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(finalizeApplicationClose).toHaveBeenCalledWith( + expect.objectContaining({ shutdownTarget: true }), + ); + expect(sessionStore.get(sessionName)).toBeUndefined(); + if (response?.ok) { + expect(response.data?.session).toBe(sessionName); + expect(response.data?.shutdown).toBeUndefined(); + } + }, +); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts index c0e19f311c..36c4a92bce 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts @@ -4,12 +4,19 @@ import { type SessionState, } from './session-close-shutdown.fixtures.ts'; import { platformResourceCleanupFixture } from '../../../../__tests__/support/platform-resource-cleanup-fixture.ts'; +import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; +import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; +import { lifecycleOperationsSpy } from '../../../../__tests__/support/lifecycle-operations-spy.ts'; const { AppError, + handleSessionCommands, makeIosSimulatorRecordingSession, makeSession, makeSessionStore, + mkdtempForTestSync, + noopInvoke, + path, mockStopIosRunnerSession, mockStopIosRunnerSession: stopIosRunnerSession, recordingCleanupMock, @@ -179,6 +186,69 @@ test('daemon session teardown attempts every resource after an earlier cleanup r expect(platformCleanup.stopSnapshotHelper).toHaveBeenCalledWith(session.device); }); +test('close finalizes an active iOS simulator recording before deleting the session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-active-recording-close-session'; + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); + const finish = recordingFinishMock(session); + sessionStore.publish(sessionName, session); + const platformResourceCleanup = platformResourceCleanupFixture(); + const { loader, finalizeApplicationClose } = lifecycleOperationsSpy(); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + platformResourceCleanup, + lifecycleOperationsLoader: loader, + req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(finish).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)).toBeUndefined(); + expect(platformResourceCleanup.retainExecutionHostAfterClose).toHaveBeenCalledWith( + expect.objectContaining({ hasScreenRecording: true }), + ); + expect(finalizeApplicationClose).toHaveBeenCalledOnce(); + expect(finish.mock.invocationCallOrder[0]).toBeLessThan( + finalizeApplicationClose.mock.invocationCallOrder[0]!, + ); +}); + +test('close surfaces a recording finalization failure through the cleanup-failure channel', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-recording-close-failure-session'; + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { + recorderExitCode: 1, + }); + const finish = recordingFinishMock(session); + const forceCleanup = recordingCleanupMock(session); + sessionStore.publish(sessionName, session); + const { loader, finalizeApplicationClose } = lifecycleOperationsSpy(); + + await expect( + handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toThrow(/recording: .*failed to stop recording/); + + expect(finish).toHaveBeenCalledOnce(); + expect(forceCleanup).toHaveBeenCalledOnce(); + expect(finalizeApplicationClose).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + function attachPerfCapture( session: SessionState, finish = vi.fn(async () => ({ diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index 48aee7335f..e2da03c212 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -45,6 +45,7 @@ type LifecycleFixture = Readonly<{ function createLifecycle( params: Readonly<{ + device?: DeviceInfo; snapshot?: (options: SnapshotOptions) => Promise; openedAppBundleId?: string; launchedAppBundleId?: string; @@ -128,7 +129,7 @@ function createLifecycle( >; const lifecycle = bindAndroidApplicationLifecycle({ host, - device, + device: params.device ?? device, signal: params.signal ?? new AbortController().signal, }); return { calls, framePerfResets, launchReports, lifecycle }; @@ -431,6 +432,26 @@ test('close --shutdown restores the keyboard and only then kills the emulator (# expect(result.shutdown).toMatchObject({ success: true }); }); +test('close --shutdown is ignored for a physical Android device', async () => { + const shutdownTarget = vi.fn(async () => ({ + success: true, + exitCode: 0, + stdout: '', + stderr: '', + })); + const { lifecycle } = createLifecycle({ + device: { ...device, id: 'R5CT123456A', kind: 'device' }, + shutdownTarget, + }); + + const result = await lifecycle.finalizeApplicationClose( + finalizationInput({ shutdownTarget: true }), + ); + + expect(shutdownTarget).not.toHaveBeenCalled(); + expect(result).toEqual({}); +}); + test('an ordinary close tells the restore no emulator kill is coming', async () => { const sequence: string[] = []; const { lifecycle } = createLifecycle({ diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index d04dedcd58..749bbac780 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -263,6 +263,42 @@ test('discards a retained physical iOS runner when relaunch fails and preserves expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); }); +test('close stops a physical Apple runner before the platform close and skips the close when the stop fails', async () => { + const signal = new AbortController().signal; + const events: string[] = []; + const preCloseError = new AppError('RUNNER_UNAVAILABLE', 'runner stop failed', { + reason: 'runner_stop_failed', + }); + const interactor = { + close: vi.fn(async () => { + events.push('close'); + }), + } as unknown as Interactor; + const baseHost = platformRuntimeHostFixture(); + const stopRunnerSession = vi.fn(async () => { + events.push('stop'); + throw preCloseError; + }); + const host = { + ...baseHost, + localInteractors: { resolve: async () => interactor }, + appleApplications: { ...baseHost.appleApplications, stopRunnerSession }, + } as unknown as PlatformRuntimeHost; + const lifecycle = bindAppleApplicationLifecycle({ host, device, signal }); + + await expect( + lifecycle.closeApplication({ + positionals: ['com.example.app'], + surface: 'app', + execution: {}, + }), + ).rejects.toBe(preCloseError); + + expect(events).toEqual(['stop']); + expect(stopRunnerSession).toHaveBeenCalledWith(device.id); + expect(interactor.close).not.toHaveBeenCalled(); +}); + test.each([true, false])( 'close finalization delegates the runner release to the runner module with retain=%s (#2552)', async (retainRunner) => { @@ -313,6 +349,32 @@ test('daemon-shutdown finalization dismisses alerts and defers the runner releas expect(dismissCloseAlerts).toHaveBeenCalled(); }); +test('close --shutdown is ignored for a physical Apple device', async () => { + const signal = new AbortController().signal; + const baseHost = platformRuntimeHostFixture(); + const shutdownTarget = vi.fn(async () => ({ + success: true, + exitCode: 0, + stdout: '', + stderr: '', + })); + const host = { + ...baseHost, + deviceShutdown: { ...baseHost.deviceShutdown, apple: { shutdownTarget } }, + } as unknown as PlatformRuntimeHost; + const lifecycle = bindAppleApplicationLifecycle({ host, device, signal }); + + const result = await lifecycle.finalizeApplicationClose({ + surface: 'app', + retainRunner: false, + stateDir: '/tmp', + shutdownTarget: true, + }); + + expect(shutdownTarget).not.toHaveBeenCalled(); + expect(result).toEqual({}); +}); + test('prepare shares one startup budget across the Simulator boot and the runner preparation', async () => { vi.useFakeTimers(); try { diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-close-error-precedence.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-close-error-precedence.test.ts deleted file mode 100644 index 02d54e0695..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-close-error-precedence.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { beforeEach, expect, test } from 'vitest'; -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; -import { - sessionCloseShutdownFixture, - type SessionState, -} from '@agent-device/daemon/testing/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; -import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; - -const { - AppError, - handleSessionCommands: handleHarnessSessionCommands, - makeIosSimulatorRecordingSession, - makeSession, - makeSessionStore, - mkdtempForTestSync, - mockDispatchCommand, - mockReleaseRunnerOnClose, - mockStopIosRunnerSession, - noopInvoke, - path, - resetSessionCloseShutdownMocks, -} = sessionCloseShutdownFixture; - -// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: -// these composition cases pass the REAL composed fixture instead of the daemon's canned default. -const handleSessionCommands = (params: Parameters[0]) => - handleHarnessSessionCommands({ - ...params, - lifecycleOperationsLoader: composedLifecycleRuntimeFixture, - }); - -// These two cases pin the REAL Apple close sequencing around a platform close: the runner stop -// that precedes dispatch on a physical device, and error precedence when the close effect itself -// fails. The canned port fixture answers close without runner staging, so they run in the -// composition lane. -beforeEach(resetSessionCloseShutdownMocks); - -test('targeted close preserves the platform-close AppError and still runs later cleanup', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'targeted-close-error-session'; - const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { - device: { - platform: 'apple', - id: 'sim-udid-close-error', - name: 'iPhone 15', - kind: 'simulator', - booted: true, - }, - }); - sessionStore.publish(sessionName, session); - - const platformCloseError = new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { - reason: 'device_disconnected', - hint: 'Reconnect the device and retry close.', - }); - mockDispatchCommand.mockRejectedValueOnce(platformCloseError); - - await expect( - handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: ['com.example.app'], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }), - ).rejects.toBe(platformCloseError); - - // The original AppError code/details/hint are preserved, not collapsed into a - // generic cleanup aggregate. - expect(platformCloseError.code).toBe('DEVICE_UNAVAILABLE'); - expect(platformCloseError.details).toMatchObject({ - reason: 'device_disconnected', - hint: 'Reconnect the device and retry close.', - }); - // A failed close is not recorded as `Closed`. - expect(session.actions.some((action) => action.command === 'close')).toBe(false); - // Subsequent independent cleanup still ran, and the session was still deleted. - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('targeted close skips platform dispatch and preserves the error when the required pre-close runner stop fails', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'targeted-close-preclose-failure-session'; - // A physical Apple target (non-simulator) must stop its runner before the - // platform close is dispatched — the runner owns the device connection. - const session = { - ...makeSession(sessionName, { - platform: 'apple', - id: 'physical-device-close', - name: 'My iPhone', - kind: 'device', - booted: true, - }), - appBundleId: 'com.example.app', - } as unknown as SessionState; - sessionStore.publish(sessionName, session); - - const preCloseError = new AppError('RUNNER_UNAVAILABLE', 'runner stop failed', { - reason: 'runner_stop_failed', - hint: 'Retry once the runner is reachable.', - }); - // Scoped to this test's pre-close stop: this one-shot rejection is consumed there, - // so it cannot leak into later tests when `vi.clearAllMocks()` clears only call - // history — a persistent `mockRejectedValue` would leak into every later test that - // exercises an Apple-platform runner stop. - mockStopIosRunnerSession.mockRejectedValueOnce(preCloseError); - - await expect( - handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: ['com.example.app'], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }), - ).rejects.toBe(preCloseError); - - // The platform close must not be dispatched after a required pre-close stop fails. - expect(mockDispatchCommand).not.toHaveBeenCalled(); - // The original failure code/details/hint are preserved, not collapsed into a - // generic cleanup aggregate. - expect(preCloseError.code).toBe('RUNNER_UNAVAILABLE'); - expect(preCloseError.details).toMatchObject({ - reason: 'runner_stop_failed', - hint: 'Retry once the runner is reachable.', - }); - // A skipped close is not recorded as `Closed`. - expect(session.actions.some((action) => action.command === 'close')).toBe(false); - // Later independent cleanup still ran (runner release re-attempted), and the - // session was still deleted. - expect(mockStopIosRunnerSession).toHaveBeenCalledOnce(); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-close-runtime-hints.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-close-runtime-hints.test.ts deleted file mode 100644 index f75ec42e6f..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-close-runtime-hints.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { beforeEach, expect, test, vi } from 'vitest'; -import path from 'node:path'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import type { DaemonRequest } from '@agent-device/daemon'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; - -// These close cases pin when the REAL composed lifecycle reaches the root runtime-hints module -// (lazy evaluation: only an admitted hint mutation loads it), so they run in the composition lane -// against the real composed lifecycle. -const runtimeHintsModule = vi.hoisted(() => ({ - evaluated: false, - clearRuntimeHintValues: vi.fn(async () => {}), -})); - -vi.mock('../../../platform-runtime-runtime-hints.ts', () => { - runtimeHintsModule.evaluated = true; - return { clearRuntimeHintValues: runtimeHintsModule.clearRuntimeHintValues }; -}); - -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; -import { - handleSessionCommands as handleHarnessSessionCommands, - mockBindDeviceRuntime, - mockInspectDeviceRuntimeFacts, -} from '@agent-device/daemon/testing/handlers/__tests__/session-command-harness'; -import { lifecycleRuntimeFacts } from '@agent-device/daemon/testing/__tests__/application-lifecycle-runtime-harness'; -import { - composedLifecycleRuntimeFixture, - dispatchApplicationLifecycleEffect, -} from '../application-lifecycle-runtime-fixture.ts'; - -// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: -// these composition cases pass the REAL composed fixture instead of the daemon's canned default. -const handleSessionCommands = (params: Parameters[0]) => - handleHarnessSessionCommands({ - ...params, - lifecycleOperationsLoader: composedLifecycleRuntimeFixture, - }); -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; - -const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); - -function makeSessionStore(): SessionStore { - const root = mkdtempForTestSync('agent-device-session-close-lifecycle-runtime-'); - return new SessionStore(path.join(root, 'sessions')); -} - -function makeSession(name: string, device: SessionState['device']): SessionState { - return { name, device, createdAt: Date.now(), actions: [] }; -} - -function closeRequest( - session: string, - positionals: string[], - internal?: DaemonRequest['internal'], -) { - return { - token: 't', - session, - command: 'close' as const, - positionals, - flags: {}, - ...(internal === undefined ? {} : { internal }), - }; -} - -async function close(params: { - sessionName: string; - sessionStore: SessionStore; - positionals?: string[]; - internal?: DaemonRequest['internal']; -}) { - return await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: closeRequest(params.sessionName, params.positionals ?? [], params.internal), - sessionName: params.sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore: params.sessionStore, - invoke: noopInvoke, - }); -} - -// The load-policy evidence must survive module loading: the mocked factory sets the -// flag when the SUT first imports it, so the FIRST entry into a test may still see a -// false flag only because the SUT imports the runtime-hints operation lazily. If a -// regression made that import eager, the factory would already have run during test -// module loading and this first assertion fails. After that proof, each case resets -// the flag so its own negative assertion measures only its own close. -let eagerEvaluationProofed = false; - -beforeEach(() => { - vi.clearAllMocks(); - if (!eagerEvaluationProofed) { - expect(runtimeHintsModule.evaluated).toBe(false); - eagerEvaluationProofed = true; - } - runtimeHintsModule.evaluated = false; -}); - -test('a close-capable runtime with false runtime-hints facts never loads the runtime-hints operation', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'harmony-close-without-runtime-hints'; - const device = { - platform: 'harmonyos' as const, - id: 'harmony-device-1', - name: 'Harmony device', - kind: 'device' as const, - target: 'mobile' as const, - booted: true, - }; - const session = makeSession(sessionName, device); - session.appBundleId = 'com.example.harmony'; - sessionStore.publish(sessionName, session); - mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { - const facts = lifecycleRuntimeFacts(candidate); - return { - ...facts, - operations: { - ...facts.operations, - clearRuntimeHints: { - available: false as const, - reason: 'unsupported-platform-leaf' as const, - }, - }, - }; - }); - - const response = await close({ - sessionName, - sessionStore, - positionals: ['com.example.harmony'], - }); - - expect(response?.ok).toBe(true); - expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); - expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledOnce(); - expect(runtimeHintsModule.evaluated).toBe(false); - expect(runtimeHintsModule.clearRuntimeHintValues).not.toHaveBeenCalled(); -}); - -test('an app-only close with stored hints selects no runtime-hint cleanup sibling', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-app-only-close-stored-runtime-hints'; - const device = { - platform: 'android' as const, - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator' as const, - target: 'mobile' as const, - booted: true, - }; - const session = makeSession(sessionName, device); - session.appBundleId = 'com.example.app'; - sessionStore.publish(sessionName, session); - sessionStore.setRuntimeHints(sessionName, { platform: 'android', metroHost: '10.0.2.2' }); - mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { - const facts = lifecycleRuntimeFacts(candidate); - return { - ...facts, - operations: { - ...facts.operations, - clearRuntimeHints: { - available: false as const, - reason: 'unsupported-provider-mode' as const, - }, - }, - }; - }); - - const response = await close({ - sessionName, - sessionStore, - positionals: ['com.example.app'], - internal: { closeAppOnly: true }, - }); - - expect(response?.ok).toBe(true); - expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); - expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledOnce(); - expect(runtimeHintsModule.evaluated).toBe(false); - expect(runtimeHintsModule.clearRuntimeHintValues).not.toHaveBeenCalled(); - expect(sessionStore.getRuntimeHints(sessionName)).toEqual({ - platform: 'android', - metroHost: '10.0.2.2', - }); -}); - -test('a close with persisted native hints fails closed when the distinct cleanup fact is false', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-close-stale-runtime-hints'; - const device = { - platform: 'apple' as const, - appleOs: 'ios' as const, - id: 'ios-device-1', - name: 'iPhone', - kind: 'device' as const, - target: 'mobile' as const, - booted: true, - }; - const session = makeSession(sessionName, device); - session.appBundleId = 'com.example.app'; - sessionStore.publish(sessionName, session); - sessionStore.setRuntimeHints(sessionName, { platform: 'ios', metroHost: '10.0.2.2' }); - mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { - const facts = lifecycleRuntimeFacts(candidate); - return { - ...facts, - operations: { - ...facts.operations, - clearRuntimeHints: { - available: false as const, - reason: 'unsupported-device-kind' as const, - }, - }, - }; - }); - - const response = await close({ sessionName, sessionStore }); - - expect(response).toMatchObject({ - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'close is not supported on this device' }, - }); - expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); - expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); - expect(runtimeHintsModule.evaluated).toBe(false); - expect(sessionStore.get(sessionName)).toMatchObject({ name: sessionName }); -}); - -test('a supported Android close clears admitted runtime hints exactly once', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-close-runtime-hints'; - const device = { - platform: 'android' as const, - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator' as const, - target: 'mobile' as const, - booted: true, - }; - const session = makeSession(sessionName, device); - session.appBundleId = 'com.example.app'; - sessionStore.publish(sessionName, session); - sessionStore.setRuntimeHints(sessionName, { - platform: 'android', - metroHost: '10.0.2.2', - metroPort: 8081, - }); - - const response = await close({ sessionName, sessionStore }); - - expect(response?.ok).toBe(true); - expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); - expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); - expect(runtimeHintsModule.evaluated).toBe(true); - expect(runtimeHintsModule.clearRuntimeHintValues).toHaveBeenCalledOnce(); - expect(runtimeHintsModule.clearRuntimeHintValues).toHaveBeenCalledWith( - expect.objectContaining({ - device, - appId: 'com.example.app', - values: expect.objectContaining({ metroHost: '10.0.2.2', metroPort: '8081' }), - }), - ); -}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-close-save-script.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-close-save-script.test.ts deleted file mode 100644 index f98f24d17b..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-close-save-script.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { beforeEach, expect, test } from 'vitest'; -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; -import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; -import { sessionCloseShutdownFixture } from '@agent-device/daemon/testing/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures'; - -const { - handleSessionCommands: handleHarnessSessionCommands, - fs, - makeIosSimulatorRecordingSession, - makeSessionStore, - mockReleaseRunnerOnClose, - mockStopIosRunnerSession, - noopInvoke, - path, - recordingFinishMock, - resetSessionCloseShutdownMocks, -} = sessionCloseShutdownFixture; - -// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: -// these composition cases pass the REAL composed fixture instead of the daemon's canned default. -const handleSessionCommands = (params: Parameters[0]) => - handleHarnessSessionCommands({ - ...params, - lifecycleOperationsLoader: composedLifecycleRuntimeFixture, - }); - -// The second half of this test proves that after the unarmed-authoring rejection, a plain close -// finalizes for real (recorder signaled, runner released with retain:false, session deleted) — -// the REAL Apple close finalization. The canned port fixture answers finalize without runner -// release, so the pair runs in the composition lane. -beforeEach(resetSessionCloseShutdownMocks); - -test('close --save-script on a never-armed session is rejected before teardown, with no script written', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-unarmed-close-save-script-session'; - const scriptPath = path.join( - mkdtempForTestSync('agent-device-unarmed-close'), - `agent-device-unarmed-close-${Date.now()}.ad`, - ); - // The fixture must carry real cleanup-bearing state (here: an active recording, like - // `makeIosSimulatorRecordingSession`'s other consumers) so this test can actually prove the - // guard runs *before* `stopBestEffortSessionResources` — not just that the response rejects. - // Without it, moving the guard after teardown would still pass: there would be nothing for - // teardown to observably touch. - const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); - const finish = recordingFinishMock(session); - sessionStore.publish(sessionName, session); - - await expect( - handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: { saveScript: scriptPath }, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }), - ).rejects.toMatchObject({ - code: 'INVALID_ARGS', - message: expect.stringMatching(/not armed/), - details: expect.objectContaining({ - hint: expect.stringMatching(/open --save-script/), - }), - }); - - // The rejection does not tear down the session — it stays retryable/recoverable. - expect(sessionStore.get(sessionName)).toBeDefined(); - expect(fs.existsSync(scriptPath)).toBe(false); - // No teardown hook ran: the recording is still live (recorder never signaled) and the runner - // was never told to stop. This is the assertion that goes red if the guard moves after - // `stopBestEffortSessionResources` — see the counterfactual in the PR description. - expect(finish).not.toHaveBeenCalled(); - expect(session.screenRecording).toBeDefined(); - expect(mockStopIosRunnerSession).not.toHaveBeenCalled(); - expect(mockReleaseRunnerOnClose).not.toHaveBeenCalled(); - - // A plain close (no --save-script) still closes the same session cleanly afterward, and now - // teardown genuinely does run: the recorder is signaled and the session deleted. - const plainClose = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(plainClose?.ok).toBe(true); - expect(finish).toHaveBeenCalledOnce(); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-close-shutdown-platform.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-close-shutdown-platform.test.ts deleted file mode 100644 index b4f24eca59..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-close-shutdown-platform.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { beforeEach, expect, test } from 'vitest'; -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; -import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; -import { sessionCloseShutdownFixture } from '@agent-device/daemon/testing/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures'; - -const { - handleSessionCommands: handleHarnessSessionCommands, - makeSession, - makeSessionStore, - mockShutdownTargetRuntime, - noopInvoke, - path, - resetSessionCloseShutdownMocks, -} = sessionCloseShutdownFixture; - -// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: -// these composition cases pass the REAL composed fixture instead of the daemon's canned default. -const handleSessionCommands = (params: Parameters[0]) => - handleHarnessSessionCommands({ - ...params, - lifecycleOperationsLoader: composedLifecycleRuntimeFixture, - }); - -// The shutdown-gating rule lives in the real Apple/Android close finalization: a physical device -// (or any Android device) ignores `close --shutdown`. The canned port fixture answers finalize -// without platform gating, so these cases run in the composition lane. -beforeEach(resetSessionCloseShutdownMocks); - -test('close --shutdown is ignored for non-simulator iOS devices', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-shutdown-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'physical-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }), - ); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: { shutdown: true }, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockShutdownTargetRuntime).not.toHaveBeenCalled(); - expect(sessionStore.get(sessionName)).toBeUndefined(); - if (response && response.ok) { - expect(response.data?.session).toBe(sessionName); - expect(response.data?.shutdown).toBeUndefined(); - } -}); - -test('close --shutdown is ignored for Android devices', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-device-shutdown-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'R5CT123456A', - name: 'Pixel 9', - kind: 'device', - booted: true, - }), - ); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: { shutdown: true }, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(mockShutdownTargetRuntime).not.toHaveBeenCalled(); - expect(sessionStore.get(sessionName)).toBeUndefined(); - if (response && response.ok) { - expect(response.data?.session).toBe(sessionName); - expect(response.data?.shutdown).toBeUndefined(); - } -}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts deleted file mode 100644 index 99ce9888c4..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { beforeEach, expect, test } from 'vitest'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; -import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; -import { sessionCloseShutdownFixture } from '@agent-device/daemon/testing/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures'; - -const { - handleSessionCommands: handleHarnessSessionCommands, - makeIosSimulatorRecordingSession, - makeSessionStore, - mockReleaseRunnerOnClose, - noopInvoke, - path, - recordingCleanupMock, - recordingFinishMock, - resetSessionCloseShutdownMocks, -} = sessionCloseShutdownFixture; - -// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: -// these composition cases pass the REAL composed fixture instead of the daemon's canned default. -// The resource-cleanup capability is the production-composed one too: what defeats runner -// retention here is its real close policy reading `hasScreenRecording`, observed at the runner -// release below. The daemon-lane cleanup fixture answers retention with a canned false, which -// would let a policy that ignores the recording pass these cases. -const handleSessionCommands = (params: Parameters[0]) => - handleHarnessSessionCommands({ - ...params, - lifecycleOperationsLoader: composedLifecycleRuntimeFixture, - platformResourceCleanup, - }); - -// These cases pin the REAL Apple close finalization around an active recording: the recorder is -// signaled and cleaned before the runner release, and a finalization failure surfaces through the -// cleanup-failure channel without skipping later cleanup. The canned port fixture answers finalize -// without recording or runner staging, so they run in the composition lane. -beforeEach(resetSessionCloseShutdownMocks); - -test('close finalizes an active iOS simulator recording before deleting the session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-active-recording-close-session'; - const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); - const finish = recordingFinishMock(session); - sessionStore.publish(sessionName, session); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - // The recorder was signaled (SIGINT finalizes the simctl mp4), the recording - // was detached, and the session was deleted — no orphaned recordVideo child. - expect(finish).toHaveBeenCalledOnce(); - expect(sessionStore.get(sessionName)).toBeUndefined(); - // An active recording at close time still defeats iOS runner retention even - // though the recording is finalized (and cleared) before the retention step. - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); - expect(finish.mock.invocationCallOrder[0]).toBeLessThan( - mockReleaseRunnerOnClose.mock.invocationCallOrder[0]!, - ); -}); - -test('close surfaces a recording finalization failure through the cleanup-failure channel', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-recording-close-failure-session'; - const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { - recorderExitCode: 1, - }); - const finish = recordingFinishMock(session); - const forceCleanup = recordingCleanupMock(session); - sessionStore.publish(sessionName, session); - - await expect( - handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }), - ).rejects.toThrow(/recording: .*failed to stop recording/); - - // Cleanup failure is reported, later cleanup still ran, session still deleted. - expect(finish).toHaveBeenCalledOnce(); - expect(forceCleanup).toHaveBeenCalledOnce(); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); diff --git a/src/platform-runtime-android-application-tools.test.ts b/src/platform-runtime-android-application-tools.test.ts new file mode 100644 index 0000000000..c918202439 --- /dev/null +++ b/src/platform-runtime-android-application-tools.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +const runtimeHints = vi.hoisted(() => ({ + evaluated: false, + clearRuntimeHintValues: vi.fn(async () => {}), +})); + +vi.mock('./platform-runtime-runtime-hints.ts', () => { + runtimeHints.evaluated = true; + return { clearRuntimeHintValues: runtimeHints.clearRuntimeHintValues }; +}); + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +beforeEach(() => { + vi.resetModules(); + runtimeHints.evaluated = false; + runtimeHints.clearRuntimeHintValues.mockClear(); +}); + +async function createTools() { + const { createAndroidApplicationTools } = + await import('./platform-runtime-android-application-tools.ts'); + return createAndroidApplicationTools(); +} + +test('building the Android application tools does not load the runtime-hints operation', async () => { + await createTools(); + + expect(runtimeHints.evaluated).toBe(false); + expect(runtimeHints.clearRuntimeHintValues).not.toHaveBeenCalled(); +}); + +test('clearing runtime hints loads the runtime-hints operation and hands it the bound device and app', async () => { + const tools = await createTools(); + + await tools.clearRuntimeHints(device, { + appId: 'com.example.app', + values: { metroHost: '10.0.2.2', metroPort: '8081' }, + }); + + expect(runtimeHints.evaluated).toBe(true); + expect(runtimeHints.clearRuntimeHintValues).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + device, + appId: 'com.example.app', + values: { metroHost: '10.0.2.2', metroPort: '8081' }, + }), + ); +}); From 024690cd7b385e558fc27e22b14e44da242dd3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 14:26:47 +0200 Subject: [PATCH 3/9] test: redesign session-open composition tests (a/c splits) session-open-surface, session-open-target: (c) merged into src/platform-runtime-open-target.test.ts. session-open-existing: (a) resolved-identity storage, surface refusal and frontmost surface carry into the in-package session-open-existing twin; (c) open-target and surface rules into src/platform-runtime-open-target.test.ts, macOS frontmost resolution into src/platform-runtime-apple-application-tools.test.ts. session-open-post-open-observation: (a) settle consequence of the observation verdict into the session-open-execution-runtime twin; (c) runner prewarm adapter into the apple tools test. session-open-url-prewarm: (a) prewarm flag/timing/cold-boot request and the prepare response shape (session-open-execution-runtime twin, session-prepare twin); (c) URL identity rules into the open-target test, prepareRunner adapter into the apple tools test, cold-boot cache prewarm, Maestro simulator reset, blocking prewarm failure and no-bundle-id prewarm skip into platform-apple lifecycle.test. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/session-prepare.test.ts | 192 ++++- .../session-open-execution-runtime.test.ts | 191 ++++ .../__tests__/session-open-existing.test.ts | 248 +++++- packages/platform-apple/src/lifecycle.test.ts | 117 +++ .../session-open-existing.test.ts | 408 --------- ...session-open-post-open-observation.test.ts | 162 ---- .../session-open-surface.test.ts | 24 - .../session-open-target.test.ts | 37 - .../session-open-url-prewarm.test.ts | 813 ------------------ ...rm-runtime-apple-application-tools.test.ts | 114 +++ src/platform-runtime-open-target.test.ts | 196 +++++ 11 files changed, 1056 insertions(+), 1446 deletions(-) delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-open-surface.test.ts delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-open-target.test.ts delete mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-open-url-prewarm.test.ts create mode 100644 src/platform-runtime-apple-application-tools.test.ts create mode 100644 src/platform-runtime-open-target.test.ts diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts index 243d1fe4c3..0408f5f1da 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts @@ -9,7 +9,13 @@ import { type RuntimeFacts, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import { IOS_SIMULATOR } from '@agent-device/test-kit/device-fixtures'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; +import { + ANDROID_EMULATOR, + IOS_SIMULATOR, + MACOS_DEVICE, +} from '@agent-device/test-kit/device-fixtures'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit/runtime-operation-facts'; import type { @@ -111,6 +117,190 @@ async function runPrepare(flags: { timeoutMs?: number }): Promise<{ return { handlerTimeoutMs: handlerTimeoutMs!, envelopeMs }; } +type PrepareRunnerResult = Awaited< + ReturnType> +>; + +/** + * Runs `prepare ios-runner` against `device` with no session, answering `prepareAppleRunner` + * through the injected binding. Facts admit the operation only for Apple devices, as the + * platform owners publish it. + */ +async function invokePrepare(params: { + device: DeviceInfo; + flags?: CommandFlags; + positionals?: string[]; + prepareAppleRunner?: (input: { + timeoutMs: number; + execution: { requestId?: string; logPath?: string }; + }) => Promise; +}) { + const { device } = params; + const sessionStore = makeSessionStore('agent-device-prepare-handler-'); + const unavailableFacts = createUnavailableRuntimeFactsForTest(device, localRuntimeOwner('apple')); + const facts: RuntimeFacts = { + ...unavailableFacts, + operations: { + ...unavailableFacts.operations, + ...(device.platform === 'apple' ? { prepareAppleRunner: { available: true } } : {}), + }, + }; + const prepareAppleRunner = vi.fn( + params.prepareAppleRunner ?? + (async () => ({ runner: { currentUptimeMs: 42 }, connectMs: 3, healthCheckMs: 3 })), + ); + const inspectFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(async (candidate, use) => + narrowDeviceBinding( + { + device: candidate, + owner: localRuntimeOwner('apple'), + facts, + operations: { prepareAppleRunner }, + [Symbol.asyncDispose]: async () => {}, + }, + use, + ), + ); + vi.mocked(resolveTargetDevice).mockResolvedValue(device); + const response = await handlePrepareCommand({ + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'prepare-session', + command: 'prepare', + positionals: params.positionals ?? ['ios-runner'], + flags: params.flags ?? { udid: device.id }, + meta: { requestId: 'prepare-request' }, + }, + sessionName: 'prepare-session', + logPath: '/dev/null', + sessionStore, + inspectFacts, + bindDevice, + }); + return { response, sessionStore, prepareAppleRunner, inspectFacts, bindDevice }; +} + +test.each([ + { device: IOS_SIMULATOR, platform: 'ios' }, + { device: MACOS_DEVICE, platform: 'macos' }, +])( + 'prepare ios-runner on $device.name hands the runner the requested budget and reports the device', + async ({ device, platform }) => { + const { response, sessionStore, prepareAppleRunner, inspectFacts, bindDevice } = + await invokePrepare({ device, flags: { udid: device.id, timeoutMs: 240_000 } }); + + expect(response.ok).toBe(true); + expect(inspectFacts).toHaveBeenCalledTimes(1); + expect(bindDevice).toHaveBeenCalledTimes(1); + expect(prepareAppleRunner).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + timeoutMs: 240_000, + execution: expect.objectContaining({ + requestId: 'prepare-request', + logPath: '/dev/null', + }), + }), + ); + if (response.ok) { + expect(response.data).toMatchObject({ + action: 'ios-runner', + platform, + deviceId: device.id, + deviceName: device.name, + kind: device.kind, + connectMs: 3, + healthCheckMs: 3, + runner: { currentUptimeMs: 42 }, + message: `Prepared Apple runner: ${device.name}`, + }); + } + expect(sessionStore.get('prepare-session')).toBeUndefined(); + }, +); + +test('prepare ios-runner explains overlapping timing fields with additive parts', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(1_000)); + try { + const { response } = await invokePrepare({ + device: IOS_SIMULATOR, + prepareAppleRunner: async () => { + vi.setSystemTime(new Date(28_337)); + return { + runner: { currentUptimeMs: 42 }, + buildMs: 10_642, + connectMs: 12_635, + healthCheckMs: 14_702, + }; + }, + }); + + expect(response.ok).toBe(true); + if (!response.ok) return; + const data = response.data as Record; + expect(data).toMatchObject({ + buildMs: 10_642, + connectMs: 12_635, + healthCheckMs: 14_702, + timing: { + totalMs: expect.any(Number), + additiveParts: { + buildMs: 10_642, + connectAfterBuildMs: 1_993, + healthCheckMs: 14_702, + }, + containment: { + connectMs: ['buildMs'], + healthCheckMs: [], + }, + }, + }); + expect(data.durationMs).toBe(27_337); + expect(data.timing.totalMs).toBe(data.durationMs); + expect(String(data.timing.note)).toMatch(/top-level prepare timing fields.*may overlap/i); + const additiveParts = data.timing.additiveParts as Record; + const additiveTotalMs = Object.values(additiveParts).reduce((sum, value) => sum + value, 0); + expect(additiveTotalMs).toBeLessThanOrEqual(data.timing.totalMs); + expect(data.buildMs + data.connectMs + data.healthCheckMs).toBeGreaterThan(data.durationMs); + } finally { + vi.useRealTimers(); + } +}); + +test('prepare ios-runner rejects a device whose owner publishes no runner preparation', async () => { + const { response, inspectFacts, bindDevice, prepareAppleRunner } = await invokePrepare({ + device: ANDROID_EMULATOR, + flags: { platform: 'android', serial: ANDROID_EMULATOR.id }, + }); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toBe('prepare is not supported on this device'); + } + expect(inspectFacts).toHaveBeenCalledTimes(1); + expect(bindDevice).not.toHaveBeenCalled(); + expect(prepareAppleRunner).not.toHaveBeenCalled(); +}); + +test('prepare requires the ios-runner subcommand', async () => { + const { response, bindDevice } = await invokePrepare({ + device: IOS_SIMULATOR, + positionals: [], + flags: { platform: 'ios' }, + }); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toBe('prepare requires a subcommand: ios-runner'); + } + expect(resolveTargetDevice).not.toHaveBeenCalled(); + expect(bindDevice).not.toHaveBeenCalled(); +}); + test('prepare ios-runner with no --timeout keeps the daemon-result margin under the envelope', async () => { const { handlerTimeoutMs, envelopeMs } = await runPrepare({}); expect(envelopeMs).toBeDefined(); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 804053fac4..8a10c67a6a 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -6,7 +6,10 @@ import fs from 'node:fs'; import type { ApplicationLifecycleRuntimeOperations, OpenApplicationInput, + PostOpenObservation, } from '@agent-device/contracts/application-lifecycle-runtime'; +import { pendingSurfaceStabilization } from '../../../../core/deferred-interaction-outcome.ts'; +import { lifecycleOperationsSpy } from '../../../../__tests__/support/lifecycle-operations-spy.ts'; import type { DaemonRequest } from '../../../../core/daemon-request.ts'; import { registerRequestAbort, @@ -58,14 +61,17 @@ import { mockInspectDeviceRuntimeFacts, } from '../../../../__tests__/support/handlers/session-command-harness.ts'; import { + applicationLifecycleRuntimeFixture, applyRuntimeHintsFixture, clearRuntimeHintsFixture, dispatchApplicationLifecycleEffect, resolveOpenTargetFixture, + type LifecycleOperationsLoader, } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { makeAndroidEmulator, + makeAppleSimulator, makeSession, makeSessionStore, noopInvoke, @@ -663,3 +669,188 @@ for (const transition of ['rebuild', 'retire'] as const) { } }); } + +async function openIosApp(params: { + sessionName: string; + flags?: Record; + openApplication: ApplicationLifecycleRuntimeOperations['openApplication']; +}) { + mockResolveTargetDevice.mockResolvedValue(makeAppleSimulator()); + const { loader } = lifecycleOperationsSpy({ openApplication: params.openApplication }); + return await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { + token: 't', + session: params.sessionName, + command: 'open', + positionals: ['com.example.app', 'rne://screen-layout'], + flags: { platform: 'ios', ...params.flags }, + }, + sessionName: params.sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore: makeSessionStore(), + invoke: noopInvoke, + }); +} + +test.each([ + { flags: { maestro: { prewarmRunnerBeforeOpen: true } }, expected: true }, + { flags: {}, expected: false }, +])( + 'open hands the lifecycle prewarmRunnerBeforeOpen=$expected for flags $flags', + async ({ flags, expected }) => { + const openApplication = vi.fn( + async () => ({ timing: {} }), + ); + + const response = await openIosApp({ sessionName: 'maestro-open-link', flags, openApplication }); + + expect(response?.ok).toBe(true); + expect(openApplication).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ prewarmRunnerBeforeOpen: expected }), + ); + }, +); + +test('open reports the runner prewarm timing the lifecycle measured', async () => { + const response = await openIosApp({ + sessionName: 'prewarm-timing-open', + flags: { maestro: { prewarmRunnerBeforeOpen: true } }, + openApplication: async () => ({ + timing: { + runnerPrewarmKind: 'session', + runnerPrewarmScheduled: true, + runnerPrewarmWaited: true, + }, + }), + }); + + expect(response?.ok).toBe(true); + expect((response as { data?: Record }).data?.timing).toMatchObject({ + runnerPrewarmKind: 'session', + runnerPrewarmScheduled: true, + runnerPrewarmWaited: true, + }); +}); + +test('open rethrows a lifecycle open failure with its typed code, message, and hint', async () => { + const prewarmFailure = new AppError( + 'COMMAND_FAILED', + 'Developer mode is disabled for Apple development tools', + { hint: 'Run `sudo DevToolsSecurity -enable`.' }, + ); + + await expect( + openIosApp({ + sessionName: 'prewarm-failed-open', + flags: { maestro: { prewarmRunnerBeforeOpen: true } }, + openApplication: async () => { + throw prewarmFailure; + }, + }), + ).rejects.toBe(prewarmFailure); + + expect(prewarmFailure).toMatchObject({ + code: 'COMMAND_FAILED', + message: 'Developer mode is disabled for Apple development tools', + details: { hint: expect.stringContaining('DevToolsSecurity -enable') }, + }); + expect(mockDispatch).not.toHaveBeenCalled(); +}); + +test.each([ + { target: 'Demo', expected: true }, + { target: 'myapp://screen/to', expected: false }, +])( + 'open asks the lifecycle to warm the runner cache on cold boot only for an app target: $target', + async ({ target, expected }) => { + mockResolveTargetDevice.mockResolvedValue(makeAppleSimulator()); + const prepareApplicationOpen = vi.fn< + ApplicationLifecycleRuntimeOperations['prepareApplicationOpen'] + >(async () => {}); + const { loader } = lifecycleOperationsSpy({ prepareApplicationOpen }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { + token: 't', + session: 'cold-boot-prewarm-open', + command: 'open', + positionals: [target], + flags: { platform: 'ios' }, + meta: { requestId: 'open-request' }, + }, + sessionName: 'cold-boot-prewarm-open', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore: makeSessionStore(), + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(prepareApplicationOpen).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + prewarmRunnerOnColdBoot: expected, + execution: expect.objectContaining({ requestId: 'open-request' }), + }), + ); + }, +); + +async function openReportingObservation( + sessionName: string, + postOpenObservation: PostOpenObservation, +) { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAppleSimulator()); + const loader: LifecycleOperationsLoader = async (device, signal, shutdownTarget) => { + const base = await applicationLifecycleRuntimeFixture(device, signal, shutdownTarget); + return Object.freeze({ + ...base, + openApplication: async (input: OpenApplicationInput) => { + const outcome = await base.openApplication(input); + return { ...outcome, timing: { ...outcome.timing, postOpenObservation } }; + }, + }); + }; + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['Demo'], + flags: { platform: 'ios' }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response?.ok).toBe(true); + return sessionStore.get(sessionName); +} + +test('a fresh iOS open that could not observe its app leaves the next capture to settle', async () => { + const session = await openReportingObservation('unobserved-open', 'unobservable'); + + expect(pendingSurfaceStabilization(session)?.action).toBe('open'); +}); + +test('a fresh iOS open whose observation probe failed leaves the next capture to settle', async () => { + const session = await openReportingObservation('probe-failed-open', 'probe-failed'); + + expect(pendingSurfaceStabilization(session)?.action).toBe('open'); +}); + +test('a fresh iOS open that observed its app leaves nothing pending', async () => { + const session = await openReportingObservation('observed-open', 'observable'); + + expect(pendingSurfaceStabilization(session)).toBeUndefined(); +}); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts index 5b6ee39d2f..ead55a938a 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts @@ -1,5 +1,5 @@ import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; -import { test, expect } from 'vitest'; +import { test, expect, vi } from 'vitest'; import * as path from 'node:path'; import { buildSnapshotSignatures } from '@agent-device/capture-kit/snapshot-freshness'; import { AppError } from '@agent-device/kernel/errors'; @@ -9,13 +9,17 @@ import { makeSessionStore, makeSession, noopInvoke, + assertInvalidArgsMessage, withMockedPlatform, } from '../../../../__tests__/support/handlers/session-test-harness.ts'; +import { resolveOpenTargetFixture } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; import type { SessionState } from '../../../../core/session-state.ts'; import { handleSessionCommands } from '../../../../__tests__/support/handlers/session-command-harness.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; +const mockResolveOpenTarget = vi.mocked(resolveOpenTargetFixture); + test('open on existing iOS session refreshes unavailable simulator by name', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-session'; @@ -141,3 +145,245 @@ test('open on existing Android session preserves a comparable freshness baseline routeComparable: true, }); }); + +test.each([ + { + label: 'a web URL on an iOS device session with no active app', + device: { platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device' }, + existing: {}, + target: 'https://example.com/path', + resolved: { appBundleId: 'com.apple.mobilesafari', appName: 'https://example.com/path' }, + }, + { + label: 'a web URL on an iOS simulator session with no active app', + device: { + platform: 'apple', + id: 'ios-simulator-1', + name: 'iPhone Simulator', + kind: 'simulator', + }, + existing: {}, + target: 'https://example.com/path', + resolved: { appBundleId: 'com.apple.mobilesafari', appName: 'https://example.com/path' }, + }, + { + label: 'an app name on a macOS session', + device: { + platform: 'apple', + appleOs: 'macos', + id: 'host-mac', + name: 'Mac', + kind: 'device', + target: 'desktop', + }, + existing: { appBundleId: 'com.example.old', appName: 'Old App' }, + target: 'settings', + resolved: { appBundleId: 'com.apple.systempreferences', appName: 'settings' }, + }, + { + label: 'an app name on an Android session', + device: { platform: 'android', id: 'emulator-5554', name: 'Pixel Emulator', kind: 'emulator' }, + existing: { appName: 'Old App' }, + target: 'RNCLI83', + resolved: { appBundleId: 'org.reactjs.native.example.RNCLI83', appName: 'RNCLI83' }, + }, + { + label: 'an intent target on an Android session with a stale package', + device: { platform: 'android', id: 'emulator-5554', name: 'Pixel Emulator', kind: 'emulator' }, + existing: { appBundleId: 'com.example.old', appName: 'Old App' }, + target: 'settings', + resolved: { appBundleId: undefined, appName: 'settings' }, + }, +] as const)( + 'open on an existing session stores the platform-resolved identity for $label', + async ({ device, existing, target, resolved }) => { + const sessionStore = makeSessionStore(); + const sessionName = 'existing-open-identity'; + sessionStore.publish(sessionName, { + ...makeSession(sessionName, { ...device, booted: true } as SessionState['device']), + ...existing, + }); + mockResolveTargetDevice.mockResolvedValue({ + ...device, + booted: true, + } as SessionState['device']); + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + mockResolveOpenTarget.mockResolvedValueOnce(resolved); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [target], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(mockResolveOpenTarget).toHaveBeenCalledWith( + expect.objectContaining({ id: device.id }), + expect.any(Function), + expect.objectContaining({ + target, + currentAppBundleId: 'appBundleId' in existing ? existing.appBundleId : undefined, + }), + ); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe(resolved.appBundleId); + expect(updated?.appName).toBe(resolved.appName); + expect(dispatchedContext?.appBundleId).toBe(resolved.appBundleId); + }, +); + +test('open app and URL on existing iOS device session keeps app context', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-session'; + sessionStore.publish(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.previous', + appName: 'Previous App', + }); + let dispatchedPositionals: string[] | undefined; + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, positionals, _out, context) => { + dispatchedPositionals = positionals; + dispatchedContext = context as Record | undefined; + return {}; + }); + mockResolveOpenTarget.mockResolvedValueOnce({ + appBundleId: 'com.apple.Preferences', + appName: 'Settings', + }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['Settings', 'myapp://screen/to'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(response?.ok && response.data?.sessionReused).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.apple.Preferences'); + expect(updated?.appName).toBe('Settings'); + expect(dispatchedPositionals).toEqual(['Settings', 'myapp://screen/to']); + expect(dispatchedContext?.appBundleId).toBe('com.apple.Preferences'); +}); + +test('open reports a platform surface refusal as an INVALID_ARGS response', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + const resolveRequestedOpenSurface = vi.fn(() => { + throw new AppError('INVALID_ARGS', 'surface is only supported on macOS and Linux'); + }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture({ resolveRequestedOpenSurface }), + req: { + token: 't', + session: 'ios-surface', + command: 'open', + positionals: ['Notes'], + flags: { platform: 'ios', surface: 'frontmost-app' }, + }, + sessionName: 'ios-surface', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage(response, 'surface is only supported on macOS and Linux'); + expect(resolveRequestedOpenSurface).toHaveBeenCalledWith( + expect.objectContaining({ surfaceFlag: 'frontmost-app', openTarget: 'Notes' }), + ); + expect(mockDispatch).not.toHaveBeenCalled(); +}); + +test('open on existing macOS frontmost-app session carries the stored surface without a --surface flag', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-frontmost-existing'; + sessionStore.publish(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + surface: 'frontmost-app', + appBundleId: 'com.apple.TextEdit', + appName: 'TextEdit', + }); + mockDispatch.mockImplementation(async (_device, _command, positionals) => { + expect(positionals).toEqual([]); + return {}; + }); + mockResolveOpenTarget.mockResolvedValueOnce({ + appBundleId: 'com.apple.TextEdit', + appName: 'TextEdit', + }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { platform: 'macos' }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(mockResolveOpenTarget).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + expect.objectContaining({ surface: 'frontmost-app' }), + ); + const session = sessionStore.get(sessionName); + expect(session?.surface).toBe('frontmost-app'); + expect(session?.appBundleId).toBe('com.apple.TextEdit'); + expect(session?.appName).toBe('TextEdit'); + if (response?.ok) expect(response.data?.surface).toBe('frontmost-app'); +}); diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 749bbac780..13840c1d6f 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -432,6 +432,123 @@ test('open forwards its startup deadline to the Simulator boot wait', async () = } }); +test.each([ + [true, 1], + [false, 0], +] as const)( + 'a cold-booting Simulator open with prewarmRunnerOnColdBoot=%s warms the runner build cache %s time(s)', + async (prewarmRunnerOnColdBoot, expectedCalls) => { + const { host } = coldSimulatorLifecycleHost(); + const prewarmRunnerCache = vi.fn(async () => {}); + const signal = new AbortController().signal; + const lifecycle = bindAppleApplicationLifecycle({ + host: { + ...host, + appleApplications: { ...host.appleApplications, prewarmRunnerCache }, + } as unknown as PlatformRuntimeHost, + device: { ...simulator, booted: false }, + signal, + }); + + await lifecycle.prepareApplicationOpen({ + target: 'com.example.app', + hasExistingSession: false, + surface: 'app', + prewarmRunnerOnColdBoot, + execution: { requestId: 'open-request' }, + }); + + expect(prewarmRunnerCache).toHaveBeenCalledTimes(expectedCalls); + if (expectedCalls > 0) { + expect(prewarmRunnerCache).toHaveBeenCalledWith( + expect.objectContaining({ id: simulator.id }), + { requestId: 'open-request' }, + signal, + ); + } + }, +); + +test('a Maestro app link on a Simulator resets the runner target it prewarmed before the URL dispatch', async () => { + const events: string[] = []; + const { host, notifyRunnerAppRelaunched } = simulatorHost({ events }); + const signal = new AbortController().signal; + const lifecycle = bindAppleApplicationLifecycle({ host, device: simulator, signal }); + + await lifecycle.openApplication({ + ...openInput(), + relaunch: false, + prewarmRunnerBeforeOpen: true, + }); + + expect(events).toEqual(['prewarm', 'open', 'reset']); + expect(notifyRunnerAppRelaunched).toHaveBeenCalledWith(simulator, {}, signal); +}); + +test('a blocking runner prewarm failure rejects the open before the app launches', async () => { + const events: string[] = []; + const prewarmFailure = new AppError( + 'COMMAND_FAILED', + 'Developer mode is disabled for Apple development tools', + { hint: 'Run `sudo DevToolsSecurity -enable`.' }, + ); + const interactor = { + open: vi.fn(async () => { + events.push('open'); + }), + } as unknown as Interactor; + const baseHost = platformRuntimeHostFixture(); + const prewarmRunnerSession = vi.fn(async () => { + throw prewarmFailure; + }); + const host = { + ...baseHost, + localInteractors: { resolve: async () => interactor }, + appleApplications: { ...baseHost.appleApplications, prewarmRunnerSession }, + } as unknown as PlatformRuntimeHost; + const signal = new AbortController().signal; + const lifecycle = bindAppleApplicationLifecycle({ host, device, signal }); + + await expect( + lifecycle.openApplication({ ...openInput(), relaunch: false, prewarmRunnerBeforeOpen: true }), + ).rejects.toBe(prewarmFailure); + + expect(prewarmRunnerSession).toHaveBeenCalledWith(device, {}, signal, true); + expect(events).toEqual([]); +}); + +test('an open with no resolved app bundle id starts no runner prewarm', async () => { + const events: string[] = []; + const interactor = { + open: vi.fn(async () => { + events.push('open'); + }), + } as unknown as Interactor; + const baseHost = platformRuntimeHostFixture(); + const prewarmRunnerSession = vi.fn(async () => {}); + const host = { + ...baseHost, + localInteractors: { resolve: async () => interactor }, + appleApplications: { ...baseHost.appleApplications, prewarmRunnerSession }, + } as unknown as PlatformRuntimeHost; + const lifecycle = bindAppleApplicationLifecycle({ + host, + device, + signal: new AbortController().signal, + }); + + await lifecycle.openApplication({ + ...openInput(), + target: 'myapp://screen/to', + positionals: ['myapp://screen/to'], + appBundleId: undefined, + relaunch: false, + }); + + expect(events).toEqual(['open']); + expect(prewarmRunnerSession).not.toHaveBeenCalled(); +}); + test('a Simulator open whose plan is observation-only starts no runner, releases a speculative one, and reports demand none', async () => { const events: string[] = []; const { host, prewarmRunnerSession, notifyRunnerAppRelaunched, releaseSpeculativeRunner } = diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts deleted file mode 100644 index 42af124701..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { test, expect } from 'vitest'; -import * as path from 'node:path'; -import { - mockLifecycleDispatch as mockDispatch, - mockResolveTargetDevice, - mockResolveAndroidPackage, - mockRunCmd, - makeSessionStore, - makeSession, - noopInvoke, - assertInvalidArgsMessage, -} from '@agent-device/daemon/testing/handlers/__tests__/session-test-harness'; -import { handleSessionCommands as handleHarnessSessionCommands } from '@agent-device/daemon/testing/handlers/__tests__/session-command-harness'; -import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; - -// These open cases pin what the REAL composed open-target and surface rules answer through the -// daemon open flow: the Safari and deep-link fallbacks, iOS and Android target resolution, the -// macOS frontmost surface, and the non-desktop `--surface` refusal. The daemon-lane port fixture -// answers those asks with canned spies (its own siblings pin the daemon's open bookkeeping), so -// the rule-pinning cases run here against the real composed services AND the real composed -// lifecycle. The harness takes the lifecycle-operations loader per call, exactly like the gateway -// factories, so no module-global install or import-order dependency is involved. -const handleSessionCommands = (params: Parameters[0]) => - handleHarnessSessionCommands({ - ...params, - lifecycleOperationsLoader: composedLifecycleRuntimeFixture, - }); - -test('open web URL on iOS device session without active app falls back to Safari', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - ); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['https://example.com/path'], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.apple.mobilesafari'); - expect(updated?.appName).toBe('https://example.com/path'); - expect(dispatchedContext?.appBundleId).toBe('com.apple.mobilesafari'); -}); - -test('open web URL on iOS simulator session without active app falls back to Safari', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-simulator-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'ios-simulator-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, - }), - ); - - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'ios-simulator-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['https://example.com/path'], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.apple.mobilesafari'); - expect(updated?.appName).toBe('https://example.com/path'); - expect(dispatchedContext?.appBundleId).toBe('com.apple.mobilesafari'); -}); - -test('open app and URL on existing iOS device session keeps app context', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-session'; - sessionStore.publish(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }), - appBundleId: 'com.example.previous', - appName: 'Previous App', - }); - - let dispatchedPositionals: string[] | undefined; - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, positionals, _out, context) => { - dispatchedPositionals = positionals; - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['Settings', 'myapp://screen/to'], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(response?.ok && response.data?.sessionReused).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.apple.Preferences'); - expect(updated?.appName).toBe('Settings'); - expect(dispatchedPositionals).toEqual(['Settings', 'myapp://screen/to']); - expect(dispatchedContext?.appBundleId).toBe('com.apple.Preferences'); -}); - -test('open app on existing macOS session resolves and stores bundle id', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-session'; - sessionStore.publish(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-mac', - name: 'Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - appBundleId: 'com.example.old', - appName: 'Old App', - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['settings'], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.apple.systempreferences'); - expect(updated?.appName).toBe('settings'); - expect(dispatchedContext?.appBundleId).toBe('com.apple.systempreferences'); -}); - -test('open rejects --surface on non-macOS devices', async () => { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: 'ios-surface', - command: 'open', - positionals: ['Notes'], - flags: { - platform: 'ios', - surface: 'frontmost-app', - }, - }, - sessionName: 'ios-surface', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage(response, 'surface is only supported on macOS and Linux'); -}); - -test('open on existing macOS frontmost-app session preserves surface without --surface flag', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-frontmost-existing'; - sessionStore.publish(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - surface: 'frontmost-app', - appBundleId: 'com.apple.TextEdit', - appName: 'TextEdit', - }); - - const prevHelper = process.env.AGENT_DEVICE_MACOS_HELPER_BIN; - process.env.AGENT_DEVICE_MACOS_HELPER_BIN = '/usr/bin/true'; - mockRunCmd.mockResolvedValue({ - stdout: '{"ok":true,"data":{"bundleId":"com.apple.TextEdit","appName":"TextEdit","pid":123}}', - stderr: '', - exitCode: 0, - }); - mockDispatch.mockImplementation(async (_device, _command, positionals) => { - expect(positionals).toEqual([]); - return {}; - }); - - try { - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: [], - flags: { - platform: 'macos', - }, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - const session = sessionStore.get(sessionName); - expect(session?.surface).toBe('frontmost-app'); - expect(session?.appBundleId).toBe('com.apple.TextEdit'); - expect(session?.appName).toBe('TextEdit'); - if (response && response.ok) { - expect(response.data?.surface).toBe('frontmost-app'); - } - } finally { - if (prevHelper === undefined) delete process.env.AGENT_DEVICE_MACOS_HELPER_BIN; - else process.env.AGENT_DEVICE_MACOS_HELPER_BIN = prevHelper; - } -}); - -test('open app on existing Android session resolves and stores package id', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-session'; - sessionStore.publish(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - appName: 'Old App', - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - mockResolveAndroidPackage.mockResolvedValue('org.reactjs.native.example.RNCLI83'); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['RNCLI83'], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('org.reactjs.native.example.RNCLI83'); - expect(updated?.appName).toBe('RNCLI83'); - expect(dispatchedContext?.appBundleId).toBe('org.reactjs.native.example.RNCLI83'); -}); - -test('open intent target on existing Android session clears stale package context', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-session'; - sessionStore.publish(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.old', - appName: 'Old App', - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return {}; - }); - mockResolveAndroidPackage.mockResolvedValue(undefined); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['settings'], - flags: {}, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe(undefined); - expect(updated?.appName).toBe('settings'); - expect(dispatchedContext?.appBundleId).toBe(undefined); -}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts deleted file mode 100644 index 7a39354558..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { test, expect, vi, beforeEach } from 'vitest'; -import path from 'node:path'; -import type { - ApplicationLifecycleRuntimeOperations, - OpenApplicationInput, - PostOpenObservation, -} from '@agent-device/contracts/application-lifecycle-runtime'; - -const { mockResolveTargetDevice, mockPrewarmRunnerSession } = vi.hoisted(() => ({ - mockResolveTargetDevice: vi.fn(), - mockPrewarmRunnerSession: vi.fn(async () => {}), -})); - -vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { - const actual = - await importOriginal(); - const { selectionFromResolveTargetDevice } = - await import('@agent-device/daemon/testing/__tests__/device-selection-stub'); - return { - ...actual, - resolveTargetDevice: mockResolveTargetDevice, - resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(mockResolveTargetDevice)), - }; -}); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); -vi.mock('../../../platform-runtime-apple-application-tools.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - createAppleApplicationTools: () => ({ - ...actual.createAppleApplicationTools(), - prewarmRunnerSession: mockPrewarmRunnerSession, - }), - }; -}); -vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, resolveIosApp: vi.fn(async () => 'com.example.demo') }; -}); -vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - activateAndroidTestIme: vi.fn(async () => ({ activated: false })), - resolveAndroidPackageForOpen: vi.fn(async () => undefined), - }; -}); -vi.mock('@agent-device/host-kit/process', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, readProcessStartTime: vi.fn(() => 'test-process-start') }; -}); - -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; -import { - handleSessionCommands as handleHarnessSessionCommands, - mockBindDeviceRuntime, -} from '@agent-device/daemon/testing/handlers/__tests__/session-command-harness'; -import { - composedLifecycleRuntimeFixture, - dispatchApplicationLifecycleEffect, -} from '../application-lifecycle-runtime-fixture.ts'; -import { pendingSurfaceStabilization } from '@agent-device/daemon/testing/deferred-interaction-outcome'; - -// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: -// these composition cases pass the REAL composed fixture instead of the daemon's canned default. -const handleSessionCommands = (params: Parameters[0]) => - handleHarnessSessionCommands({ - ...params, - lifecycleOperationsLoader: composedLifecycleRuntimeFixture, - }); -import { - makeAppleSimulator, - makeSessionStore, - noopInvoke, -} from '@agent-device/daemon/testing/session-lifecycle/internal/__tests__/session-open-runtime.fixtures'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; - -const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); - -// These cases pin the REAL composed runner staging around an open (the post-open prewarm calls -// below) and how the daemon records the settle consequence of an open's observation timing -// (postOpenObservation). The observation verdict itself is a canned per-case answer injected at -// the binding seam, because the composed probe needs a live Simulator bridge; what the REAL Apple -// open reports into its timing for each verdict (observable / unobservable / probe-failed with -// typed failure) is pinned by the platform's own lane in -// `packages/platform-apple/src/lifecycle.test.ts`. -beforeEach(() => { - vi.clearAllMocks(); - mockDispatch.mockImplementation(async () => ({})); -}); - -async function openReportingObservation( - sessionName: string, - postOpenObservation: PostOpenObservation, -) { - const sessionStore = makeSessionStore(); - mockResolveTargetDevice.mockResolvedValue(makeAppleSimulator()); - const bindDefault = mockBindDeviceRuntime.getMockImplementation(); - if (!bindDefault) throw new Error('the harness binds a default runtime'); - mockBindDeviceRuntime.mockImplementationOnce(async (device, use, loader) => { - const binding = await bindDefault(device, use, loader); - const operations = binding.operations as ApplicationLifecycleRuntimeOperations; - return { - ...binding, - operations: { - ...binding.operations, - openApplication: async (input: OpenApplicationInput) => { - const outcome = await operations.openApplication(input); - return { ...outcome, timing: { ...outcome.timing, postOpenObservation } }; - }, - }, - }; - }); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'open', - positionals: ['Demo'], - flags: { platform: 'ios' }, - }, - sessionName, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response?.ok).toBe(true); - expect(mockPrewarmRunnerSession).toHaveBeenCalledTimes(1); - expect(mockPrewarmRunnerSession).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', kind: 'simulator' }), - expect.any(Object), - expect.any(AbortSignal), - false, - ); - return sessionStore.get(sessionName); -} - -test('a fresh iOS open that could not observe its app leaves the next capture to settle', async () => { - const session = await openReportingObservation('unobserved-open', 'unobservable'); - - expect(pendingSurfaceStabilization(session)?.action).toBe('open'); -}); - -test('a fresh iOS open whose observation probe failed leaves the next capture to settle', async () => { - const session = await openReportingObservation('probe-failed-open', 'probe-failed'); - - expect(pendingSurfaceStabilization(session)?.action).toBe('open'); -}); - -test('a fresh iOS open that observed its app leaves nothing pending', async () => { - const session = await openReportingObservation('observed-open', 'observable'); - - expect(pendingSurfaceStabilization(session)).toBeUndefined(); -}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-open-surface.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-open-surface.test.ts deleted file mode 100644 index 2c44185891..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-open-surface.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; -import { resolveRequestedOpenSurface } from '../../../platform-runtime-open-target.ts'; - -test('resolveRequestedOpenSurface rejects surface flag on iOS', () => { - assert.throws( - () => - resolveRequestedOpenSurface({ - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17', - kind: 'simulator', - }, - surfaceFlag: 'desktop', - openTarget: undefined, - }), - (error: unknown) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - /surface is only supported on macOS/i.test(error.message), - ); -}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-open-target.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-open-target.test.ts deleted file mode 100644 index 3c04c5b54b..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-open-target.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; -import { resolveSessionAppBundleIdForTarget } from '../../../platform-runtime-open-target.ts'; - -vi.mock('@agent-device/platform-android/mechanics', () => ({ - resolveAndroidPackageForOpen: vi.fn(), -})); - -const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); -const harmonyDevice: DeviceInfo = { - platform: 'harmonyos', - id: '127.0.0.1:5555', - name: 'HarmonyOS Emulator', - kind: 'emulator', - booted: true, -}; - -beforeEach(() => { - vi.clearAllMocks(); -}); - -test('HarmonyOS adopts an explicit bundle-id target for app-scoped commands', async () => { - await expect( - resolveSessionAppBundleIdForTarget(harmonyDevice, 'com.example.application', undefined), - ).resolves.toBe('com.example.application'); - expect(mockResolveAndroidPackage).not.toHaveBeenCalled(); -}); - -test.each(['myapp://login', 'https://example.com', 'Demo App'])( - 'HarmonyOS retains the existing app across non-bundle targets: %s', - async (openTarget) => { - await expect( - resolveSessionAppBundleIdForTarget(harmonyDevice, openTarget, 'com.example.application'), - ).resolves.toBe('com.example.application'); - }, -); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-open-url-prewarm.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-open-url-prewarm.test.ts deleted file mode 100644 index a02ea758eb..0000000000 --- a/src/__tests__/daemon-composition/session-lifecycle/session-open-url-prewarm.test.ts +++ /dev/null @@ -1,813 +0,0 @@ -import { test, expect, vi, beforeEach } from 'vitest'; -import * as path from 'node:path'; -import { AppError } from '@agent-device/kernel/errors'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; - -vi.mock('node:timers/promises', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, setTimeout: vi.fn(async () => undefined) }; -}); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - prepareIosRunner: vi.fn(async () => ({ - runner: { currentUptimeMs: 42 }, - connectMs: 3, - healthCheckMs: 3, - })), - prewarmAppleRunnerCache: vi.fn(), - prewarmIosRunnerSession: vi.fn(), - notifyIosRunnerAppRelaunched: vi.fn(async () => {}), - stopIosRunnerSession: vi.fn(async () => {}), - }; -}); -vi.mock('@agent-device/platform-apple/macos', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) }; -}); -vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - resolveIosApp: vi.fn(async (_device, app: string) => app), - resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), - }; -}); -vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); - -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { - createRealLifecycleDeviceRuntimeGatewaySpies, - realLifecycleDeviceRuntimeGateway, -} from '../lifecycle-runtime-gateway.ts'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; -import { dispatchApplicationLifecycleEffect } from '../application-lifecycle-runtime-fixture.ts'; -import { getResolveTargetDeviceMock } from '@agent-device/daemon/testing/__tests__/request-router-dispatch-mocks'; -import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { makeSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import type { DaemonRequest } from '@agent-device/daemon'; -import { - prepareIosRunner, - prewarmAppleRunnerCache, - prewarmIosRunnerSession, - notifyIosRunnerAppRelaunched, -} from '@agent-device/platform-apple/runner/operations'; -import { - resolveIosApp, - resolveIosSimulatorDeepLinkBundleId, -} from '@agent-device/platform-apple/app-resolution'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; - -const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); -const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -const mockPrepareIosRunner = vi.mocked(prepareIosRunner); -const mockPrewarmAppleRunnerCache = vi.mocked(prewarmAppleRunnerCache); -const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); -const mockNotifyIosRunnerAppRelaunched = vi.mocked(notifyIosRunnerAppRelaunched); -const mockResolveIosApp = vi.mocked(resolveIosApp); -const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked(resolveIosSimulatorDeepLinkBundleId); - -beforeEach(() => { - vi.useRealTimers(); - mockResolveTargetDevice.mockReset(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue(undefined); - mockPrepareIosRunner.mockReset(); - mockPrepareIosRunner.mockResolvedValue({ - runner: { currentUptimeMs: 42 }, - connectMs: 3, - healthCheckMs: 3, - }); - mockPrewarmAppleRunnerCache.mockReset(); - mockPrewarmIosRunnerSession.mockReset(); - mockNotifyIosRunnerAppRelaunched.mockReset(); - mockNotifyIosRunnerAppRelaunched.mockResolvedValue(undefined); - mockResolveIosApp.mockReset(); - mockResolveIosApp.mockImplementation(async (_device, app: string) => - app.includes('.') ? app : `com.example.${app.toLowerCase()}`, - ); - mockResolveIosSimulatorDeepLinkBundleId.mockReset(); - mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue(undefined); -}); - -function createHandler( - sessionStore: ReturnType, - deviceRuntimeGateway = realLifecycleDeviceRuntimeGateway, -) { - return createRequestHandler({ - createCommandSurface: fakeCreateCommandSurface, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - token: 'test-token', - sessionStore, - leaseRegistry: new LeaseRegistry(), - deviceRuntimeGateway, - platformResourceCleanup, - deviceInventoryGateways: createTestDeviceInventoryGateways(), - trackDownloadableArtifact: () => 'artifact-id', - }); -} - -let requestSeq = 0; - -function sessionRequest( - session: string, - command: 'open' | 'prepare', - options: { - positionals?: string[]; - flags?: Record; - meta?: DaemonRequest['meta']; - } = {}, -): DaemonRequest { - requestSeq += 1; - return { - token: 'test-token', - session, - command, - positionals: options.positionals ?? [], - flags: options.flags ?? {}, - meta: { requestId: `req-${command}-${requestSeq}`, ...options.meta }, - }; -} - -test('open web URL on existing iOS simulator session binds Safari and drops stale app bundle id', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 15', - kind: 'simulator', - booted: true, - }, - appBundleId: 'com.example.old', - appName: 'Old App', - }), - ); - - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 15', - kind: 'simulator', - booted: true, - }); - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return undefined; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { positionals: ['https://example.com/path'] }), - ); - - expect(response.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.apple.mobilesafari'); - expect(updated?.appName).toBe('https://example.com/path'); - expect(dispatchedContext?.appBundleId).toBe('com.apple.mobilesafari'); -}); - -test('open URL on existing macOS session clears stale app bundle id', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'macos-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - appleOs: 'macos', - id: 'host-mac', - name: 'Mac', - kind: 'device', - target: 'desktop', - booted: true, - }, - appBundleId: 'com.example.old', - appName: 'Old App', - }), - ); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return undefined; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { positionals: ['https://example.com/path'] }), - ); - - expect(response.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe(undefined); - expect(updated?.appName).toBe('https://example.com/path'); - expect(dispatchedContext?.appBundleId).toBe(undefined); -}); - -test('open URL on existing iOS device session preserves app bundle id context', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-device-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }, - appBundleId: 'com.example.app', - appName: 'Example App', - }), - ); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return undefined; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { positionals: ['myapp://item/42'] }), - ); - - expect(response.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.example.app'); - expect(updated?.appName).toBe('myapp://item/42'); - expect(dispatchedContext?.appBundleId).toBe('com.example.app'); -}); - -test('open custom URL on existing iOS simulator session preserves app bundle id context', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-simulator-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - appBundleId: 'com.example.app', - appName: 'Example App', - }), - ); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return undefined; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { positionals: ['myapp://item/42'] }), - ); - - expect(response.ok).toBe(true); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('com.example.app'); - expect(updated?.appName).toBe('myapp://item/42'); - expect(dispatchedContext?.appBundleId).toBe('com.example.app'); -}); - -test('open custom URL on fresh iOS simulator session infers app bundle id from URL scheme', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-simulator-url-session'; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue('org.reactnavigation.playground'); - - let dispatchedContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { - dispatchedContext = context as Record | undefined; - return undefined; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { - positionals: ['rne://navigator-layout'], - flags: { platform: 'ios', udid: 'sim-1' }, - }), - ); - - expect(response.ok).toBe(true); - expect(mockResolveIosSimulatorDeepLinkBundleId).toHaveBeenCalledWith( - expect.objectContaining({ id: 'sim-1', kind: 'simulator' }), - 'rne://navigator-layout', - ); - const updated = sessionStore.get(sessionName); - expect(updated?.appBundleId).toBe('org.reactnavigation.playground'); - expect(updated?.appName).toBe('rne://navigator-layout'); - expect(dispatchedContext?.appBundleId).toBe('org.reactnavigation.playground'); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); -}); - -test('open iOS simulator app prewarms runner cache during cold boot', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-simulator-cold-boot-cache-prewarm'; - const device = { - platform: 'apple' as const, - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator' as const, - booted: false, - }; - mockResolveTargetDevice.mockResolvedValue(device); - mockResolveIosApp.mockResolvedValueOnce('com.example.app'); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { - positionals: ['Demo'], - flags: { platform: 'ios', udid: 'sim-1' }, - meta: { requestId: 'open-request' }, - }), - ); - - expect(response.ok).toBe(true); - await vi.waitFor(() => { - expect(mockPrewarmAppleRunnerCache).toHaveBeenCalledWith( - device, - expect.objectContaining({ - logPath: expect.stringMatching(/runner\.log$/), - requestId: 'open-request', - }), - ); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); - }); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith( - { ...device, booted: true }, - expect.anything(), - ); -}); - -test('open iOS app session prewarms runner session when app bundle id is known', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-device-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }, - appBundleId: 'com.example.previous', - appName: 'Previous App', - }), - ); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { positionals: ['Settings', 'myapp://screen/to'] }), - ); - - expect(response.ok).toBe(true); - await vi.waitFor(() => { - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: 'ios-device-1' }), - expect.objectContaining({ logPath: expect.stringMatching(/runner\.log$/) }), - ); - }); -}); - -test('open iOS Maestro app link waits for runner prewarm before launching app', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-maestro-open-link'; - const events: string[] = []; - let finishPrewarm: (() => void) | undefined; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }, - appBundleId: 'com.example.previous', - appName: 'Previous App', - }), - ); - - mockPrewarmIosRunnerSession.mockImplementation( - () => - new Promise((resolve) => { - events.push('prewarm-start'); - finishPrewarm = () => { - events.push('prewarm-finish'); - resolve(); - }; - }), - ); - mockDispatch.mockImplementation(async (_device, command) => { - events.push(`dispatch:${command}`); - return undefined; - }); - - const responsePromise = createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { - positionals: ['com.example.app', 'rne://screen-layout'], - flags: { maestro: { prewarmRunnerBeforeOpen: true } }, - }), - ); - - await vi.waitFor(() => expect(events).toEqual(['prewarm-start'])); - - finishPrewarm?.(); - const response = await responsePromise; - - expect(response.ok).toBe(true); - expect(events).toEqual(['prewarm-start', 'prewarm-finish', 'dispatch:open']); - expect((response as { data?: Record }).data?.timing).toMatchObject({ - runnerPrewarmKind: 'session', - runnerPrewarmScheduled: true, - runnerPrewarmWaited: true, - }); -}); - -test('open iOS Maestro app link resets a simulator runner prewarmed before URL dispatch', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-simulator-maestro-open-link'; - const events: string[] = []; - const device = { - platform: 'apple' as const, - id: 'ios-simulator-1', - name: 'iPhone Simulator', - kind: 'simulator' as const, - booted: true, - }; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device, - appBundleId: 'com.example.app', - appName: 'Example App', - }), - ); - mockResolveTargetDevice.mockResolvedValue(device); - - mockPrewarmIosRunnerSession.mockImplementation(async () => { - events.push('prewarm'); - }); - mockDispatch.mockImplementation(async (_device, command) => { - events.push(`dispatch:${command}`); - return undefined; - }); - mockNotifyIosRunnerAppRelaunched.mockImplementation(async () => { - events.push('target-reset'); - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { - positionals: ['com.example.app', 'rne://screen-layout'], - flags: { maestro: { prewarmRunnerBeforeOpen: true } }, - }), - ); - - expect(response.ok).toBe(true); - expect(events).toEqual(['prewarm', 'dispatch:open', 'target-reset']); - expect(mockNotifyIosRunnerAppRelaunched).toHaveBeenCalledWith( - expect.objectContaining({ id: 'ios-simulator-1' }), - expect.any(Object), - ); -}); - -test('open iOS Maestro app link reports blocking runner prewarm failures before launching app', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-maestro-open-link-prewarm-failed'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }, - appBundleId: 'com.example.previous', - appName: 'Previous App', - }), - ); - mockPrewarmIosRunnerSession.mockRejectedValueOnce( - new AppError('COMMAND_FAILED', 'Developer mode is disabled for Apple development tools', { - hint: 'Run `sudo DevToolsSecurity -enable`.', - }), - ); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { - positionals: ['com.example.app', 'rne://screen-layout'], - flags: { maestro: { prewarmRunnerBeforeOpen: true } }, - }), - ); - - expect(response.ok).toBe(false); - if (!response.ok) { - expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toBe('Developer mode is disabled for Apple development tools'); - expect(response.error.hint).toEqual(expect.stringContaining('DevToolsSecurity -enable')); - } - expect(mockDispatch).not.toHaveBeenCalled(); - expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: 'ios-device-1' }), - expect.objectContaining({ propagateError: true }), - ); -}); - -test('open iOS URL without app bundle id skips runner prewarm', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'ios-device-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'ios-device-1', - name: 'iPhone Device', - kind: 'device', - booted: true, - }, - }), - ); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { positionals: ['myapp://screen/to'] }), - ); - - expect(response.ok).toBe(true); - expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled(); -}); - -test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', async ({ - onTestFinished, -}) => { - vi.useFakeTimers({ toFake: ['Date'] }); - onTestFinished(() => { - vi.useRealTimers(); - }); - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'prepare-ios-runner'; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - const { gateway, inspectFacts, bind } = createRealLifecycleDeviceRuntimeGatewaySpies(); - - const response = await createHandler( - sessionStore, - gateway, - )( - sessionRequest(sessionName, 'prepare', { - positionals: ['ios-runner'], - flags: { platform: 'ios', udid: 'sim-1', timeoutMs: 240000 }, - meta: { requestId: 'prepare-request' }, - }), - ); - - expect(response.ok).toBe(true); - expect(inspectFacts).toHaveBeenCalledTimes(1); - expect(bind).toHaveBeenCalledTimes(1); - // Readiness runs inside the admitted Apple binding, so `prepare` proves it by reaching the - // runner at all rather than by observing a retired root readiness call. - expect(mockPrepareIosRunner).toHaveBeenCalledTimes(1); - expect(mockPrepareIosRunner).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: 'sim-1' }), - expect.objectContaining({ - cleanStaleBundles: true, - logPath: expect.stringMatching(/runner\.log$/), - prepareDeadline: expect.objectContaining({ - elapsedMs: expect.any(Function), - isExpired: expect.any(Function), - remainingMs: expect.any(Function), - }), - requestId: 'prepare-request', - }), - ); - // `prepareAppleRunner` spends one budget across the boot wait and the runner, so what reaches - // the runner is `--timeout` minus whatever readiness already used. Asserting the exact request - // asserts that zero wall-clock time passed, which is an accident of scheduling rather than a - // property of the system; the guarantee is that each budget is wired and never re-spent. - const [, prepareOptions] = mockPrepareIosRunner.mock.calls[0]!; - for (const field of ['buildTimeoutMs', 'healthTimeoutMs', 'startupTimeoutMs'] as const) { - expect - .soft(prepareOptions[field], `${field} carries the unspent remainder of --timeout`) - .toBeGreaterThan(239_000); - expect - .soft(prepareOptions[field], `${field} never exceeds --timeout`) - .toBeLessThanOrEqual(240_000); - } - if (response.ok) { - expect(response.data).toMatchObject({ - action: 'ios-runner', - platform: 'ios', - deviceId: 'sim-1', - deviceName: 'iPhone 17 Pro', - kind: 'simulator', - connectMs: 3, - healthCheckMs: 3, - runner: { currentUptimeMs: 42 }, - message: 'Prepared Apple runner: iPhone 17 Pro', - }); - } - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('prepare ios-runner explains overlapping timing fields with additive parts', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'prepare-ios-runner-timing'; - vi.useFakeTimers(); - vi.setSystemTime(new Date(1_000)); - try { - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - mockPrepareIosRunner.mockImplementationOnce(async () => { - vi.setSystemTime(new Date(28_337)); - return { - runner: { currentUptimeMs: 42 }, - buildMs: 10_642, - connectMs: 12_635, - healthCheckMs: 14_702, - }; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'prepare', { - positionals: ['ios-runner'], - flags: { platform: 'ios', udid: 'sim-1' }, - }), - ); - - expect(response.ok).toBe(true); - if (!response.ok) return; - const data = response.data as Record; - expect(data).toMatchObject({ - buildMs: 10_642, - connectMs: 12_635, - healthCheckMs: 14_702, - timing: { - totalMs: expect.any(Number), - additiveParts: { - buildMs: 10_642, - connectAfterBuildMs: 1_993, - healthCheckMs: 14_702, - }, - containment: { - connectMs: ['buildMs'], - healthCheckMs: [], - }, - }, - }); - expect(data.durationMs).toBe(27_337); - expect(data.timing.totalMs).toBe(data.durationMs); - expect(String(data.timing.note)).toMatch(/top-level prepare timing fields.*may overlap/i); - const additiveParts = data.timing.additiveParts as Record; - const additiveTotalMs = Object.values(additiveParts).reduce((sum, value) => sum + value, 0); - expect(additiveTotalMs).toBeLessThanOrEqual(data.timing.totalMs); - expect(data.buildMs + data.connectMs + data.healthCheckMs).toBeGreaterThan(data.durationMs); - } finally { - vi.useRealTimers(); - } -}); - -test('prepare ios-runner starts the XCTest runner on an explicit macOS selector', async ({ - onTestFinished, -}) => { - vi.useFakeTimers({ toFake: ['Date'] }); - onTestFinished(() => { - vi.useRealTimers(); - }); - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - const sessionName = 'prepare-macos-runner'; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'prepare', { - positionals: ['ios-runner'], - flags: { platform: 'macos', timeoutMs: 240000 }, - meta: { requestId: 'prepare-macos-request' }, - }), - ); - - expect(response.ok).toBe(true); - expect(mockPrepareIosRunner).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple', id: 'host-macos-local' }), - expect.objectContaining({ - buildTimeoutMs: 240000, - healthTimeoutMs: 240000, - prepareDeadline: expect.objectContaining({ - elapsedMs: expect.any(Function), - isExpired: expect.any(Function), - remainingMs: expect.any(Function), - }), - requestId: 'prepare-macos-request', - }), - ); - if (response.ok) { - expect(response.data).toMatchObject({ - action: 'ios-runner', - platform: 'macos', - deviceId: 'host-macos-local', - deviceName: 'Host Mac', - kind: 'device', - message: 'Prepared Apple runner: Host Mac', - }); - } -}); - -test('prepare ios-runner rejects non-Apple runner devices', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - mockResolveTargetDevice.mockResolvedValue({ - platform: 'android', - id: 'emulator-5554', - name: 'Pixel 9 Pro XL', - kind: 'emulator', - booted: true, - }); - const { gateway, inspectFacts, bind } = createRealLifecycleDeviceRuntimeGatewaySpies(); - - const response = await createHandler( - sessionStore, - gateway, - )( - sessionRequest('prepare-android', 'prepare', { - positionals: ['ios-runner'], - flags: { platform: 'android', serial: 'emulator-5554' }, - }), - ); - - expect(response.ok).toBe(false); - if (!response.ok) { - expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toBe('prepare is not supported on this device'); - } - expect(inspectFacts).toHaveBeenCalledTimes(1); - expect(bind).not.toHaveBeenCalled(); - expect(mockPrepareIosRunner).not.toHaveBeenCalled(); -}); - -test('prepare requires the ios-runner subcommand', async () => { - const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); - - const response = await createHandler(sessionStore)( - sessionRequest('prepare-invalid', 'prepare', { flags: { platform: 'ios' } }), - ); - - expect(response.ok).toBe(false); - if (!response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toBe('prepare requires a subcommand: ios-runner'); - } - expect(mockResolveTargetDevice).not.toHaveBeenCalled(); - expect(mockPrepareIosRunner).not.toHaveBeenCalled(); -}); diff --git a/src/platform-runtime-apple-application-tools.test.ts b/src/platform-runtime-apple-application-tools.test.ts new file mode 100644 index 0000000000..dd52966e66 --- /dev/null +++ b/src/platform-runtime-apple-application-tools.test.ts @@ -0,0 +1,114 @@ +import { expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { resolveFrontmostMacOsApp } from '@agent-device/platform-apple/macos'; +import { + prepareIosRunner, + prewarmIosRunnerSession, +} from '@agent-device/platform-apple/runner/operations'; +import { createAppleApplicationTools } from './platform-runtime-apple-application-tools.ts'; + +vi.mock('@agent-device/platform-apple/macos', () => ({ + resolveFrontmostMacOsApp: vi.fn(), + runMacOsAlertAction: vi.fn(), +})); +vi.mock('@agent-device/platform-apple/runner/operations', () => ({ + prepareIosRunner: vi.fn(async () => ({ + runner: { currentUptimeMs: 42 }, + connectMs: 3, + healthCheckMs: 3, + })), + prewarmIosRunnerSession: vi.fn(async () => {}), +})); + +const mockResolveFrontmostMacOsApp = vi.mocked(resolveFrontmostMacOsApp); +const mockPrepareIosRunner = vi.mocked(prepareIosRunner); +const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); + +const iosSimulator: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, +}; + +const macOsDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, +}; + +test('the runner session prewarm forwards the request execution, signal, and error policy to the runner module', async () => { + const signal = new AbortController().signal; + + await createAppleApplicationTools().prewarmRunnerSession( + iosSimulator, + { requestId: 'req-1', logPath: '/tmp/daemon.log', verbose: true }, + signal, + false, + ); + + expect(mockPrewarmIosRunnerSession).toHaveBeenCalledExactlyOnceWith( + iosSimulator, + expect.objectContaining({ + requestId: 'req-1', + logPath: '/tmp/daemon.log', + verbose: true, + propagateError: false, + signal, + }), + ); +}); + +test('preparing the runner spends the requested budget on stale-bundle cleanup, build, health, and startup', async () => { + const signal = new AbortController().signal; + + const result = await createAppleApplicationTools().prepareRunner( + iosSimulator, + { + timeoutMs: 240_000, + execution: { requestId: 'prepare-request', logPath: '/tmp/state/runner.log' }, + }, + signal, + ); + + expect(result).toEqual({ runner: { currentUptimeMs: 42 }, connectMs: 3, healthCheckMs: 3 }); + expect(mockPrepareIosRunner).toHaveBeenCalledExactlyOnceWith( + iosSimulator, + expect.objectContaining({ + cleanStaleBundles: true, + requestId: 'prepare-request', + logPath: '/tmp/state/runner.log', + buildTimeoutMs: 240_000, + healthTimeoutMs: 240_000, + startupTimeoutMs: 240_000, + prepareDeadline: expect.objectContaining({ + elapsedMs: expect.any(Function), + isExpired: expect.any(Function), + remainingMs: expect.any(Function), + }), + signal, + }), + ); +}); + +test('a macOS frontmost-app open resolves the identity of the frontmost application', async () => { + mockResolveFrontmostMacOsApp.mockResolvedValueOnce({ + bundleId: 'com.apple.TextEdit', + appName: 'TextEdit', + pid: 123, + }); + + await expect( + createAppleApplicationTools().resolveOpenTarget(macOsDevice, { + surface: 'frontmost-app', + currentAppBundleId: 'com.apple.TextEdit', + }), + ).resolves.toEqual({ appBundleId: 'com.apple.TextEdit', appName: 'TextEdit' }); + expect(mockResolveFrontmostMacOsApp).toHaveBeenCalledOnce(); +}); diff --git a/src/platform-runtime-open-target.test.ts b/src/platform-runtime-open-target.test.ts new file mode 100644 index 0000000000..bb8272122b --- /dev/null +++ b/src/platform-runtime-open-target.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; +import { + resolveIosApp, + resolveIosSimulatorDeepLinkBundleId, +} from '@agent-device/platform-apple/app-resolution'; +import { AppError } from '@agent-device/kernel/errors'; +import { + resolveRequestedOpenSurface, + resolveSessionAppBundleIdForTarget, +} from './platform-runtime-open-target.ts'; + +vi.mock('@agent-device/platform-android/mechanics', () => ({ + resolveAndroidPackageForOpen: vi.fn(), +})); +vi.mock('@agent-device/platform-apple/app-resolution', () => ({ + resolveIosApp: vi.fn(), + resolveIosSimulatorDeepLinkBundleId: vi.fn(), +})); + +const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); +const mockResolveIosApp = vi.mocked(resolveIosApp); +const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked(resolveIosSimulatorDeepLinkBundleId); +const iosDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, +}; +const iosSimulator: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'ios-simulator-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, +}; +const macOsDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + id: 'host-mac', + name: 'Mac', + kind: 'device', + target: 'desktop', + booted: true, +}; +const androidEmulator: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, +}; +const harmonyDevice: DeviceInfo = { + platform: 'harmonyos', + id: '127.0.0.1:5555', + name: 'HarmonyOS Emulator', + kind: 'emulator', + booted: true, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('HarmonyOS adopts an explicit bundle-id target for app-scoped commands', async () => { + await expect( + resolveSessionAppBundleIdForTarget(harmonyDevice, 'com.example.application', undefined), + ).resolves.toBe('com.example.application'); + expect(mockResolveAndroidPackage).not.toHaveBeenCalled(); +}); + +test.each(['myapp://login', 'https://example.com', 'Demo App'])( + 'HarmonyOS retains the existing app across non-bundle targets: %s', + async (openTarget) => { + await expect( + resolveSessionAppBundleIdForTarget(harmonyDevice, openTarget, 'com.example.application'), + ).resolves.toBe('com.example.application'); + }, +); + +test.each([ + ['an iOS device', iosDevice], + ['an iOS simulator', iosSimulator], +])('a web URL on %s with no active app targets Safari', async (_label, device) => { + await expect( + resolveSessionAppBundleIdForTarget(device, 'https://example.com/path', undefined), + ).resolves.toBe('com.apple.mobilesafari'); + expect(mockResolveIosApp).not.toHaveBeenCalled(); +}); + +test('a web URL on an iOS device keeps the active app context', async () => { + await expect( + resolveSessionAppBundleIdForTarget(iosDevice, 'https://example.com/path', 'com.example.app'), + ).resolves.toBe('com.example.app'); +}); + +test.each([ + ['an iOS device', iosDevice, 'Settings', 'com.apple.Preferences'], + ['a macOS device', macOsDevice, 'settings', 'com.apple.systempreferences'], +])('an app name on %s resolves through the Apple app resolver', async (_l, device, target, id) => { + mockResolveIosApp.mockResolvedValueOnce(id); + + await expect(resolveSessionAppBundleIdForTarget(device, target, 'com.example.old')).resolves.toBe( + id, + ); + expect(mockResolveIosApp).toHaveBeenCalledWith(device, target); +}); + +test('a URL on a macOS session clears the stale app bundle id', async () => { + await expect( + resolveSessionAppBundleIdForTarget(macOsDevice, 'https://example.com/path', 'com.example.old'), + ).resolves.toBeUndefined(); +}); + +test.each([ + ['an iOS device', iosDevice], + ['an iOS simulator', iosSimulator], +])('a custom URL on %s keeps the active app bundle id', async (_label, device) => { + await expect( + resolveSessionAppBundleIdForTarget(device, 'myapp://item/42', 'com.example.app'), + ).resolves.toBe('com.example.app'); + expect(mockResolveIosSimulatorDeepLinkBundleId).not.toHaveBeenCalled(); +}); + +test('a custom URL on a fresh iOS simulator infers the app bundle id from the URL scheme', async () => { + mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValueOnce('org.reactnavigation.playground'); + + await expect( + resolveSessionAppBundleIdForTarget(iosSimulator, 'rne://navigator-layout', undefined), + ).resolves.toBe('org.reactnavigation.playground'); + expect(mockResolveIosSimulatorDeepLinkBundleId).toHaveBeenCalledWith( + iosSimulator, + 'rne://navigator-layout', + ); +}); + +test('an Android app name resolves to the installed package', async () => { + mockResolveAndroidPackage.mockResolvedValueOnce('org.reactjs.native.example.RNCLI83'); + + await expect( + resolveSessionAppBundleIdForTarget(androidEmulator, 'RNCLI83', undefined), + ).resolves.toBe('org.reactjs.native.example.RNCLI83'); +}); + +test('an Android intent target drops the stale package context', async () => { + mockResolveAndroidPackage.mockResolvedValueOnce(undefined); + + await expect( + resolveSessionAppBundleIdForTarget(androidEmulator, 'settings', 'com.example.old'), + ).resolves.toBeUndefined(); +}); + +test('an Android deep link keeps the current package context', async () => { + mockResolveAndroidPackage.mockResolvedValueOnce(undefined); + + await expect( + resolveSessionAppBundleIdForTarget(androidEmulator, 'myapp://screen', 'com.example.old'), + ).resolves.toBe('com.example.old'); +}); + +test('resolveRequestedOpenSurface keeps the stored macOS surface when no --surface flag is given', () => { + expect( + resolveRequestedOpenSurface({ + device: macOsDevice, + surfaceFlag: undefined, + openTarget: undefined, + existingSurface: 'frontmost-app', + }), + ).toBe('frontmost-app'); +}); + +test('resolveRequestedOpenSurface rejects surface flag on iOS', () => { + const resolveDesktopSurface = () => + resolveRequestedOpenSurface({ + device: { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17', + kind: 'simulator', + }, + surfaceFlag: 'desktop', + openTarget: undefined, + }); + + expect(resolveDesktopSurface).toThrow(AppError); + expect(resolveDesktopSurface).toThrow(/surface is only supported on macOS/i); + try { + resolveDesktopSurface(); + } catch (error) { + expect((error as AppError).code).toBe('INVALID_ARGS'); + } +}); From 3a50cdcc6deb785cbcb3779bb1bc6ee6549344cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 14:30:23 +0200 Subject: [PATCH 4/9] test: redesign device-ready, replay flag-policy and replay-target-guard composition tests device-ready: (a) readiness cache/TTL contract against a spy port into packages/daemon/src/core/device/device-ready.test.ts; (c) per-family readiness mechanics into src/platform-runtime-device-ready.test.ts. session-test-suite-command-flag-policy: (c) CLI-schema vs policy parity into src/commands/replay/index.test.ts; raw-wire rejections into packages/replay-port/src/daemon-port/test-command-flag-policy.test.ts (no daemon in the loop). session-replay-target-guard: (c) post-resolution guard cases relocated to src/commands/interaction/runtime/replay-target-guard.test.ts; the two classifyReplayTarget verification cases move to replay-port target-classification.test.ts. Co-Authored-By: Claude Opus 5.5 --- .../application-lifecycle-runtime-fixture.ts | 6 +- .../support/platform-services-fixture.ts | 2 +- .../src/core/device/device-ready.test.ts | 84 +++++++ .../__tests__/target-classification.test.ts | 89 ++++++++ .../test-command-flag-policy.test.ts | 65 ++++++ .../device/device-ready.test.ts | 124 ---------- ...ion-test-suite-command-flag-policy.test.ts | 214 ------------------ .../runtime/replay-target-guard.test.ts} | 82 +------ src/commands/replay/index.test.ts | 18 ++ src/platform-runtime-device-ready.test.ts | 80 +++++++ 10 files changed, 344 insertions(+), 420 deletions(-) create mode 100644 packages/daemon/src/core/device/device-ready.test.ts create mode 100644 packages/replay-port/src/daemon-port/test-command-flag-policy.test.ts delete mode 100644 src/__tests__/daemon-composition/device/device-ready.test.ts delete mode 100644 src/__tests__/daemon-composition/replay-suite/session-test-suite-command-flag-policy.test.ts rename src/{__tests__/daemon-composition/replay-target/session-replay-target-guard.test.ts => commands/interaction/runtime/replay-target-guard.test.ts} (78%) create mode 100644 src/platform-runtime-device-ready.test.ts diff --git a/packages/daemon/src/__tests__/support/application-lifecycle-runtime-fixture.ts b/packages/daemon/src/__tests__/support/application-lifecycle-runtime-fixture.ts index c1702955b5..7ee0a65106 100644 --- a/packages/daemon/src/__tests__/support/application-lifecycle-runtime-fixture.ts +++ b/packages/daemon/src/__tests__/support/application-lifecycle-runtime-fixture.ts @@ -76,9 +76,9 @@ const prepareAppleRunnerFixture = vi.fn( * the fixture owner (a dotted, non-deep-link target is the bundle id; otherwise the session's * current one), reached through the `directResolve` the fixture passes in. No open-target, surface, * or platform rule lives here — the real owners' resolution rules (apple/android target identity, - * macOS frontmost surfaces, Safari and deep-link fallbacks) are pinned under - * `src/__tests__/daemon-composition/` (`session-open-target.test.ts` and siblings). A daemon suite - * that needs a different bundle id answers per case through this spy. + * macOS frontmost surfaces, Safari and deep-link fallbacks) are pinned beside the root modules + * (`src/platform-runtime-open-target.test.ts`, `src/platform-runtime-apple-application-tools.test.ts`). + * A daemon suite that needs a different bundle id answers per case through this spy. */ export const resolveOpenTargetFixture = vi.fn( async ( diff --git a/packages/daemon/src/__tests__/support/platform-services-fixture.ts b/packages/daemon/src/__tests__/support/platform-services-fixture.ts index cf27bae977..21e80b88e3 100644 --- a/packages/daemon/src/__tests__/support/platform-services-fixture.ts +++ b/packages/daemon/src/__tests__/support/platform-services-fixture.ts @@ -82,7 +82,7 @@ const validateOpenRelaunchTargetFixture = vi.fn( /** * The canned open-target answer: target resolution changes no identity — the session keeps its * current app. Suites asserting a resolved bundle id answer per case; the resolution rules live - * in the composition lane (`session-open-target.test.ts` and siblings). + * in `src/platform-runtime-open-target.test.ts`. */ export const resolveSessionAppBundleIdForTargetFixture = vi.fn( async ( diff --git a/packages/daemon/src/core/device/device-ready.test.ts b/packages/daemon/src/core/device/device-ready.test.ts new file mode 100644 index 0000000000..ddfe311932 --- /dev/null +++ b/packages/daemon/src/core/device/device-ready.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + ANDROID_EMULATOR, + IOS_DEVICE, + IOS_SIMULATOR, +} from '@agent-device/test-kit/device-fixtures'; +import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; +import { DEVICE_READY_CACHE_TTL_MS, ensureDeviceReady } from './device-ready.ts'; + +const ensureLocalDeviceReady = vi.fn(async (_device: DeviceInfo) => true); +const platformServices = daemonPlatformServicesFixture({ ensureLocalDeviceReady }); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z')); + ensureLocalDeviceReady.mockReset(); + ensureLocalDeviceReady.mockResolvedValue(true); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('ensureDeviceReady caches successful simulator readiness checks', async () => { + const device: DeviceInfo = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }; + + await ensureDeviceReady(device, platformServices); + await ensureDeviceReady({ ...device }, platformServices); + + expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(1); + expect(ensureLocalDeviceReady).toHaveBeenCalledWith(device); +}); + +test('ensureDeviceReady caches successful iOS physical device readiness checks', async () => { + await ensureDeviceReady(IOS_DEVICE, platformServices); + await ensureDeviceReady( + { ...IOS_DEVICE, simulatorSetPath: '/ignored-for-physical-device' }, + platformServices, + ); + + expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(1); +}); + +test('ensureDeviceReady includes simulator set path in the cache key', async () => { + await ensureDeviceReady( + { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }, + platformServices, + ); + await ensureDeviceReady( + { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-b' }, + platformServices, + ); + + expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(2); +}); + +test('ensureDeviceReady expires cached readiness checks after the ttl', async () => { + await ensureDeviceReady(ANDROID_EMULATOR, platformServices); + vi.setSystemTime(new Date(Date.now() + DEVICE_READY_CACHE_TTL_MS - 1)); + await ensureDeviceReady({ ...ANDROID_EMULATOR }, platformServices); + vi.setSystemTime(new Date(Date.now() + 1)); + await ensureDeviceReady({ ...ANDROID_EMULATOR }, platformServices); + + expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(2); +}); + +test('ensureDeviceReady does not cache failed readiness checks', async () => { + ensureLocalDeviceReady.mockRejectedValueOnce(new Error('boot failed')); + + await expect(ensureDeviceReady(IOS_SIMULATOR, platformServices)).rejects.toThrow('boot failed'); + await ensureDeviceReady(IOS_SIMULATOR, platformServices); + + expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(2); +}); + +test('ensureDeviceReady does not cache a device the platform has no readiness step for', async () => { + ensureLocalDeviceReady.mockResolvedValue(false); + + await ensureDeviceReady(ANDROID_EMULATOR, platformServices); + await ensureDeviceReady(ANDROID_EMULATOR, platformServices); + + expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(2); +}); diff --git a/packages/replay-port/src/daemon-port/__tests__/target-classification.test.ts b/packages/replay-port/src/daemon-port/__tests__/target-classification.test.ts index e37262a63c..fa5aa68d92 100644 --- a/packages/replay-port/src/daemon-port/__tests__/target-classification.test.ts +++ b/packages/replay-port/src/daemon-port/__tests__/target-classification.test.ts @@ -5,6 +5,8 @@ import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import { computeTargetEvidence } from '@agent-device/selectors/target-evidence'; import { buildSelectorChainForNode, resolveRecordedTarget } from '@agent-device/selectors'; import { resolvePressRecordingTarget } from '@agent-device/selectors/press-retarget'; +import { readNodeLocalIdentity } from '@agent-device/ad-script'; +import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; import { classifyReplayTarget } from '../target-classification.ts'; import { resolveElementReportedTwice } from '@agent-device/selectors/interaction-targeting'; import { @@ -702,3 +704,90 @@ test('#2498 e2e: matches in distinct subtrees stay a refusal, not a collapse to assert.equal(result.kind, 'identity-mismatch'); assert.equal(result.matchCount, 4); }); + +// Split resolver: dispatch resolution runs occlusion/visibility filters verification does not +// replicate, so dispatch can resolve a different member than the one verified. These fixtures pin +// the verification side; the post-resolution guard that refuses the split pre-action is pinned +// beside `src/commands/interaction/runtime/replay-target-guard.ts`. + +/** Two "Save" buttons; A (deeper) is covered, B is the visible sibling. */ +function coveredDeeperSaveButtons(identifiers: { a: string; b: string }): SnapshotNode[] { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + index: 1, + depth: 3, + parentIndex: 0, + type: 'Button', + identifier: identifiers.a, + label: 'Save', + rect: { x: 16, y: 120, width: 140, height: 44 }, + interactionBlocked: 'covered', + }, + { + index: 2, + depth: 1, + parentIndex: 0, + type: 'Button', + identifier: identifiers.b, + label: 'Save', + rect: { x: 16, y: 790, width: 140, height: 44 }, + hittable: true, + }, + ]).nodes; +} + +function classifySaveButton(nodes: SnapshotNode[], recorded: TargetAnnotationV1) { + return classifyReplayTarget({ + recorded, + token: 'label="Save"', + nodes, + platform: 'ios', + refLabel: undefined, + requireRect: true, + allowDisambiguation: true, + }); +} + +test('split resolver: verification verifies the covered deeper node while dispatch would resolve its visible sibling', () => { + const nodes = coveredDeeperSaveButtons({ a: 'save-a', b: 'save-b' }); + const nodeA = nodes.find((node) => node.identifier === 'save-a'); + assert.ok(nodeA); + const result = classifySaveButton(nodes, { + id: 'save-a', + role: 'button', + label: 'Save', + ancestry: [{ role: 'application', label: 'Example' }], + sibling: 0, + viewportOrder: 0, + verification: 'verified', + }); + // Verification's unfiltered domain: both buttons match; the identity set isolates A; the + // unfiltered disambiguation winner is also A (deepest) — path 4 verified. + assertVerified(result, { winnerRef: nodeA.ref!, matchCount: 2 }); +}); + +test('same-identity duplicates: verification denotes the covered member A among two identical id/role/label buttons', () => { + const nodes = coveredDeeperSaveButtons({ a: 'save', b: 'save' }); + const nodeA = nodes[1]!; + const nodeB = nodes[2]!; + assert.deepEqual(readNodeLocalIdentity(nodeA), readNodeLocalIdentity(nodeB)); + const result = classifySaveButton(nodes, { + id: 'save', + role: 'button', + label: 'Save', + ancestry: [{ role: 'application', label: 'Example' }], + sibling: 0, + viewportOrder: 0, + verification: 'verified', + }); + // matchCount 2 (both match); path 6 sibling ordinal isolates A, which is also the unfiltered + // disambiguation winner → verified on A. + assertVerified(result, { winnerRef: nodeA.ref!, matchCount: 2 }); +}); diff --git a/packages/replay-port/src/daemon-port/test-command-flag-policy.test.ts b/packages/replay-port/src/daemon-port/test-command-flag-policy.test.ts new file mode 100644 index 0000000000..2f5149a482 --- /dev/null +++ b/packages/replay-port/src/daemon-port/test-command-flag-policy.test.ts @@ -0,0 +1,65 @@ +import { expect, test, vi } from 'vitest'; +import type { CommandFlags } from '@agent-device/contracts/command'; +import type { ReplaySession, ReplayTestCommand } from './command-types.ts'; +import { runReplayTestCommand } from './test-command.ts'; + +function rejectedTestCommand(flags: CommandFlags) { + const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); + const createSession = vi.fn(() => { + throw new Error('test must not start a suite attempt when a replay-only flag is rejected'); + }); + const command: ReplayTestCommand = { + request: { + token: 'token', + session: 'default', + command: 'test', + positionals: ['/suite/flow.ad'], + flags, + meta: { cwd: '/suite' }, + }, + session: { name: 'default', logPath: '/suite/daemon.log' } as unknown as ReplaySession, + createSession, + invoke, + dependencies: { + resolveSessionScope: () => { + throw new Error( + 'test must not resolve a session scope when a replay-only flag is rejected', + ); + }, + }, + cleanupSession: async () => {}, + }; + return { command, invoke, createSession }; +} + +// ADR 0012 decision 4 (`--from`/`--plan-digest`), decision 6 (`--save-script`/`--force`), and the +// replay-only `--keep-session`: the raw wire refuses them before a suite runs. +test.each([ + { label: '--keep-session', flags: { replayKeepSession: true }, message: /--keep-session/ }, + { + label: '--from', + flags: { replayFrom: 2, replayPlanDigest: 'deadbeef' }, + message: /--from/, + }, + { + label: '--plan-digest alone', + flags: { replayPlanDigest: 'deadbeef' }, + message: /--plan-digest/, + }, + { label: '--save-script', flags: { saveScript: true }, message: /--save-script/ }, + { label: '--force without --save-script', flags: { force: true }, message: /--force/ }, +] as const)( + 'test rejects raw $label with INVALID_ARGS before running the suite', + async ({ flags, message }) => { + const { command, invoke, createSession } = rejectedTestCommand(flags); + + const response = await runReplayTestCommand(command); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(message); + expect(invoke).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + }, +); diff --git a/src/__tests__/daemon-composition/device/device-ready.test.ts b/src/__tests__/daemon-composition/device/device-ready.test.ts deleted file mode 100644 index 9342e56114..0000000000 --- a/src/__tests__/daemon-composition/device/device-ready.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, beforeEach, expect, test, vi } from 'vitest'; -import { promises as fs } from 'node:fs'; -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; -import type { DeviceInfo } from '@agent-device/kernel/device'; - -vi.mock('@agent-device/host-kit/command', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - runCmd: vi.fn(), - runCmdSync: vi.fn(), - whichCmd: vi.fn(async () => true), - }; -}); -vi.mock('@agent-device/platform-apple/simulator', async (importOriginal) => ({ - ...(await importOriginal()), - ensureBootedSimulator: vi.fn(async () => {}), -})); -vi.mock('@agent-device/platform-android/mechanics', () => ({ - waitForAndroidBoot: vi.fn(async () => {}), -})); - -import { runCmd } from '@agent-device/host-kit/command'; -import { waitForAndroidBoot } from '@agent-device/platform-android/mechanics'; -import { ensureBootedSimulator } from '@agent-device/platform-apple/simulator'; -import { - ANDROID_EMULATOR, - IOS_DEVICE, - IOS_SIMULATOR, -} from '@agent-device/test-kit/device-fixtures'; -import { - DEVICE_READY_CACHE_TTL_MS, - ensureDeviceReady, -} from '@agent-device/daemon/testing/device/device-ready'; - -const mockRunCmd = vi.mocked(runCmd); -const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); -const mockWaitForAndroidBoot = vi.mocked(waitForAndroidBoot); -// This suite pins what the REAL composed readiness port does per device family: it runs in the -// composition lane on the root-composed services fixture, with the platform leaves mocked above. -// The daemon-lane port fixture answers readiness with a canned spy; this suite separately -// pins the readiness cache contract, including its TTL behavior. -const platformServices = composedPlatformServicesFixture(); - -beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z')); - mockRunCmd.mockReset(); - mockEnsureBootedSimulator.mockReset(); - mockEnsureBootedSimulator.mockResolvedValue(undefined); - mockWaitForAndroidBoot.mockReset(); - mockWaitForAndroidBoot.mockResolvedValue(undefined); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -test('ensureDeviceReady caches successful simulator readiness checks', async () => { - const device: DeviceInfo = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }; - - await ensureDeviceReady(device, platformServices); - await ensureDeviceReady({ ...device }, platformServices); - - expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(1); - expect(mockEnsureBootedSimulator).toHaveBeenCalledWith(device); -}); - -test('ensureDeviceReady caches successful iOS physical device readiness checks', async () => { - mockRunCmd.mockImplementation(async (_cmd, args) => { - const jsonPath = args[args.indexOf('--json-output') + 1]!; - await fs.writeFile( - jsonPath, - JSON.stringify({ - result: { - connectionProperties: { - tunnelState: 'connected', - }, - }, - }), - ); - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - await ensureDeviceReady(IOS_DEVICE, platformServices); - await ensureDeviceReady( - { ...IOS_DEVICE, simulatorSetPath: '/ignored-for-physical-device' }, - platformServices, - ); - - expect(mockRunCmd).toHaveBeenCalledTimes(1); -}); - -test('ensureDeviceReady includes simulator set path in the cache key', async () => { - await ensureDeviceReady( - { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }, - platformServices, - ); - await ensureDeviceReady( - { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-b' }, - platformServices, - ); - - expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); -}); - -test('ensureDeviceReady expires cached readiness checks after the ttl', async () => { - await ensureDeviceReady(ANDROID_EMULATOR, platformServices); - vi.setSystemTime(new Date(Date.now() + DEVICE_READY_CACHE_TTL_MS - 1)); - await ensureDeviceReady({ ...ANDROID_EMULATOR }, platformServices); - vi.setSystemTime(new Date(Date.now() + 1)); - await ensureDeviceReady({ ...ANDROID_EMULATOR }, platformServices); - - expect(mockWaitForAndroidBoot).toHaveBeenCalledTimes(2); -}); - -test('ensureDeviceReady does not cache failed readiness checks', async () => { - mockEnsureBootedSimulator.mockRejectedValueOnce(new Error('boot failed')); - - await expect(ensureDeviceReady(IOS_SIMULATOR, platformServices)).rejects.toThrow('boot failed'); - await ensureDeviceReady(IOS_SIMULATOR, platformServices); - - expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); -}); diff --git a/src/__tests__/daemon-composition/replay-suite/session-test-suite-command-flag-policy.test.ts b/src/__tests__/daemon-composition/replay-suite/session-test-suite-command-flag-policy.test.ts deleted file mode 100644 index 76ed11faaa..0000000000 --- a/src/__tests__/daemon-composition/replay-suite/session-test-suite-command-flag-policy.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * The raw-wire flag policy `test` enforces before a suite runs: replay-only flags must be refused - * at the daemon boundary, not silently fanned into every attempt. Moved here with the command - * itself when the test-suite command left the replay command handler (AGENTS.md, tests mirror source - * topology). - */ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { test, vi } from 'vitest'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { - createReplaySession, - replayDaemonDependencies, -} from '@agent-device/daemon/testing/handlers/session-replay-command'; -import { runReplayTestCommand } from '@agent-device/replay-port/test-command'; -import { - replayInvokeOverDispatch, - splitReplayCommandRequest, -} from '@agent-device/replay-port/replay-dispatch-envelope'; -import type { ReplayTestCommand } from '@agent-device/replay-port/command-types'; -import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '@agent-device/replay-port/session-replay-test-policy'; -import { replayCommandFamily } from '../../../commands/replay/index.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import type { DaemonInvokeFn, DaemonRequest } from '@agent-device/daemon'; - -// --- ADR 0012 decision 4 / migration step 5: `--from` is replay-only --- - -test('raw test-request guards enumerate every daemon-visible replay-only CLI flag', () => { - const replayFlags = replayCommandFamily.cliSchemas.replay?.allowedFlags ?? []; - const testFlags = new Set(replayCommandFamily.cliSchemas.test?.allowedFlags ?? []); - const clientOnlyReplayFlags = new Set(['out']); - const expectedDaemonFlags = replayFlags - .filter((flag) => !testFlags.has(flag) && !clientOnlyReplayFlags.has(flag)) - .sort(); - - const guardedDaemonFlags = REPLAY_ONLY_TEST_FLAG_REJECTIONS.flatMap( - (rejection) => rejection.keys, - ).sort(); - assert.deepEqual(guardedDaemonFlags, expectedDaemonFlags); -}); - -function testCommand( - req: DaemonRequest, - root: string, - sessionStore: SessionStore, - invoke: DaemonInvokeFn, -): ReplayTestCommand { - return { - ...splitReplayCommandRequest(req), - session: createReplaySession(req.session, path.join(root, 'daemon.log'), sessionStore), - createSession: (sessionName, logPath) => - createReplaySession(sessionName, logPath, sessionStore), - invoke: replayInvokeOverDispatch(invoke, req), - dependencies: replayDaemonDependencies, - cleanupSession: async () => {}, - }; -} - -test('test rejects raw --keep-session with INVALID_ARGS before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-keep-session-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - - const response = await runReplayTestCommand( - testCommand( - { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { replayKeepSession: true }, - meta: { cwd: root }, - }, - root, - sessionStore, - invoke, - ), - ); - - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /--keep-session/); - assert.equal(invoke.mock.calls.length, 0); -}); - -test('test rejects --from with INVALID_ARGS before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-from-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - - const response = await runReplayTestCommand( - testCommand( - { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { replayFrom: 2, replayPlanDigest: 'deadbeef' }, - meta: { cwd: root }, - }, - root, - sessionStore, - async () => { - throw new Error('test must not start executing when --from is rejected'); - }, - ), - ); - - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /--from/); -}); - -test('test rejects --plan-digest alone with INVALID_ARGS before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-digest-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - - const response = await runReplayTestCommand( - testCommand( - { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { replayPlanDigest: 'deadbeef' }, - meta: { cwd: root }, - }, - root, - sessionStore, - async () => { - throw new Error('test must not start executing when --plan-digest is rejected'); - }, - ), - ); - - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); -}); - -// --- ADR 0012 decision 6: `--save-script` is replay-only --- - -test('test rejects --save-script with INVALID_ARGS before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-savescript-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - - const response = await runReplayTestCommand( - testCommand( - { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { saveScript: true }, - meta: { cwd: root }, - }, - root, - sessionStore, - async () => { - throw new Error('test must not start executing when --save-script is rejected'); - }, - ), - ); - - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /--save-script/); -}); - -test('test rejects raw --force without --save-script before running the suite', async () => { - const root = mkdtempForTestSync('agent-device-test-force-rejected-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open "Demo"\n'); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - - const response = await runReplayTestCommand( - testCommand( - { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { force: true }, - meta: { cwd: root }, - }, - root, - sessionStore, - invoke, - ), - ); - - if (!response) throw new Error('Expected response'); - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'INVALID_ARGS'); - assert.match(response.error.message, /--force/); - assert.equal(invoke.mock.calls.length, 0); -}); diff --git a/src/__tests__/daemon-composition/replay-target/session-replay-target-guard.test.ts b/src/commands/interaction/runtime/replay-target-guard.test.ts similarity index 78% rename from src/__tests__/daemon-composition/replay-target/session-replay-target-guard.test.ts rename to src/commands/interaction/runtime/replay-target-guard.test.ts index fc451b6959..be446b3fd0 100644 --- a/src/__tests__/daemon-composition/replay-target/session-replay-target-guard.test.ts +++ b/src/commands/interaction/runtime/replay-target-guard.test.ts @@ -6,18 +6,12 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; -import type { - ReplayTargetGuardDenotation, - TargetAnnotationV1, -} from '@agent-device/contracts/replay'; +import type { ReplayTargetGuardDenotation } from '@agent-device/contracts/replay'; import { readNodeLocalIdentity, readNodeStructuralDenotation } from '@agent-device/ad-script'; import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; -import { - ref as interactionRef, - selector, -} from '../../../commands/interaction/runtime/selector-read-utils.ts'; -import { createInteractionDevice } from '../../../commands/interaction/runtime/__tests__/test-utils/index.ts'; -import { classifyReplayTarget } from '@agent-device/replay-port/target-classification'; +import { ref as interactionRef, selector } from './selector-read-utils.ts'; +import { createInteractionDevice } from './__tests__/test-utils/index.ts'; + /** The verified-member guard denotation the replay loop mints (identity + structural position). */ function guardFor(node: SnapshotNode, nodes: SnapshotNode[]): ReplayTargetGuardDenotation { return { @@ -26,16 +20,6 @@ function guardFor(node: SnapshotNode, nodes: SnapshotNode[]): ReplayTargetGuardD }; } -function assertVerified( - result: ReturnType, - expected: { winnerRef: string; matchCount: number }, -): void { - assert.equal(result.verified, true); - if (!result.verified) throw new Error('unreachable'); - assert.equal(result.winnerNode.ref, expected.winnerRef); - assert.equal(result.matchCount, expected.matchCount); -} - // --------------------------------------------------------------------------- /** @@ -80,35 +64,6 @@ function splitResolverSnapshot(): SnapshotState { ]); } -test('split resolver: verification verifies the covered deeper node while dispatch would resolve its visible sibling', () => { - const nodes = splitResolverSnapshot().nodes; - const nodeA = nodes.find((node) => node.identifier === 'save-a'); - assert.ok(nodeA); - const recorded: TargetAnnotationV1 = { - id: 'save-a', - role: 'button', - label: 'Save', - ancestry: [{ role: 'application', label: 'Example' }], - sibling: 0, - viewportOrder: 0, - verification: 'verified', - }; - const result = classifyReplayTarget({ - recorded, - token: 'label="Save"', - nodes, - platform: 'ios', - refLabel: undefined, - requireRect: true, - allowDisambiguation: true, - }); - // Verification's unfiltered domain: both buttons match; the identity set - // isolates A; the unfiltered disambiguation winner is also A (deepest) — - // path 4 verified. Dispatch would resolve B: exactly the split the - // post-resolution guard exists to catch. - assertVerified(result, { winnerRef: nodeA.ref!, matchCount: 2 }); -}); - test('split resolver: the post-resolution guard refuses pre-action when dispatch resolves a different element', async () => { const snapshot = splitResolverSnapshot(); const nodeA = snapshot.nodes.find((node) => node.identifier === 'save-a'); @@ -249,35 +204,6 @@ function sameIdentityDuplicatesSnapshot(): SnapshotState { ]); } -test('same-identity duplicates: verification denotes the covered member A among two identical id/role/label buttons', () => { - const nodes = sameIdentityDuplicatesSnapshot().nodes; - const nodeA = nodes[1]!; // document order 1, covered - const nodeB = nodes[2]!; // document order 2, visible - // Sanity: A and B are genuinely indistinguishable by local identity. - assert.deepEqual(readNodeLocalIdentity(nodeA), readNodeLocalIdentity(nodeB)); - const recorded: TargetAnnotationV1 = { - id: 'save', - role: 'button', - label: 'Save', - ancestry: [{ role: 'application', label: 'Example' }], - sibling: 0, // isolates A among the two same-identity duplicates - viewportOrder: 0, - verification: 'verified', - }; - const result = classifyReplayTarget({ - recorded, - token: 'label="Save"', - nodes, - platform: 'ios', - refLabel: undefined, - requireRect: true, - allowDisambiguation: true, - }); - // matchCount 2 (both match); path 6 sibling ordinal isolates A, which is - // also the unfiltered disambiguation winner → verified on A. - assertVerified(result, { winnerRef: nodeA.ref!, matchCount: 2 }); -}); - test('same-identity duplicates: the structural guard refuses pre-action when dispatch resolves the OTHER duplicate (P1 regression)', async () => { const snapshot = sameIdentityDuplicatesSnapshot(); const nodeA = snapshot.nodes[1]!; diff --git a/src/commands/replay/index.test.ts b/src/commands/replay/index.test.ts index df1ffa6946..bf05263583 100644 --- a/src/commands/replay/index.test.ts +++ b/src/commands/replay/index.test.ts @@ -3,7 +3,9 @@ import path from 'node:path'; import { afterEach, describe, expect, test } from 'vitest'; import type { CliFlags } from '@agent-device/contracts/command'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '@agent-device/replay-port/session-replay-test-policy'; import { + replayCommandFamily, replayCliReader, replayCommandFacet, replayCommandMetadata, @@ -345,3 +347,19 @@ describe('replay --save-script arming (ADR 0012 decision 6, R1/R6)', () => { ); }); }); + +describe('replay-only flags on the raw test request (ADR 0012 decision 4)', () => { + test('raw test-request guards enumerate every daemon-visible replay-only CLI flag', () => { + const replayFlags = replayCommandFamily.cliSchemas.replay?.allowedFlags ?? []; + const testFlags = new Set(replayCommandFamily.cliSchemas.test?.allowedFlags ?? []); + const clientOnlyReplayFlags = new Set(['out']); + const expectedDaemonFlags = replayFlags + .filter((flag) => !testFlags.has(flag) && !clientOnlyReplayFlags.has(flag)) + .sort(); + + const guardedDaemonFlags = REPLAY_ONLY_TEST_FLAG_REJECTIONS.flatMap( + (rejection) => rejection.keys, + ).sort(); + expect(guardedDaemonFlags).toEqual(expectedDaemonFlags); + }); +}); diff --git a/src/platform-runtime-device-ready.test.ts b/src/platform-runtime-device-ready.test.ts new file mode 100644 index 0000000000..a142399149 --- /dev/null +++ b/src/platform-runtime-device-ready.test.ts @@ -0,0 +1,80 @@ +import { promises as fs } from 'node:fs'; +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(), + runCmdSync: vi.fn(), + whichCmd: vi.fn(async () => true), + }; +}); +vi.mock('@agent-device/platform-apple/simulator', async (importOriginal) => ({ + ...(await importOriginal()), + ensureBootedSimulator: vi.fn(async () => {}), +})); +vi.mock('@agent-device/platform-android/mechanics', () => ({ + waitForAndroidBoot: vi.fn(async () => {}), +})); + +import { runCmd } from '@agent-device/host-kit/command'; +import { waitForAndroidBoot } from '@agent-device/platform-android/mechanics'; +import { ensureBootedSimulator } from '@agent-device/platform-apple/simulator'; +import { + ANDROID_EMULATOR, + IOS_DEVICE, + IOS_SIMULATOR, + LINUX_DEVICE, +} from '@agent-device/test-kit/device-fixtures'; +import { ensureLocalPlatformDeviceReady } from './platform-runtime-device-ready.ts'; + +const mockRunCmd = vi.mocked(runCmd); +const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); +const mockWaitForAndroidBoot = vi.mocked(waitForAndroidBoot); + +beforeEach(() => { + mockRunCmd.mockReset(); + mockEnsureBootedSimulator.mockReset(); + mockEnsureBootedSimulator.mockResolvedValue(undefined); + mockWaitForAndroidBoot.mockReset(); + mockWaitForAndroidBoot.mockResolvedValue(undefined); +}); + +test('an iOS simulator is readied by booting it through the Apple simulator leaf', async () => { + const device = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }; + + await expect(ensureLocalPlatformDeviceReady(device)).resolves.toBe(true); + + expect(mockEnsureBootedSimulator).toHaveBeenCalledExactlyOnceWith(device); +}); + +test('a physical iOS device is readied by checking its connection tunnel', async () => { + mockRunCmd.mockImplementation(async (_cmd, args) => { + const jsonPath = args[args.indexOf('--json-output') + 1]!; + await fs.writeFile( + jsonPath, + JSON.stringify({ result: { connectionProperties: { tunnelState: 'connected' } } }), + ); + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + await expect(ensureLocalPlatformDeviceReady(IOS_DEVICE)).resolves.toBe(true); + + expect(mockRunCmd).toHaveBeenCalledTimes(1); + expect(mockEnsureBootedSimulator).not.toHaveBeenCalled(); +}); + +test('an Android device is readied by waiting for boot completion', async () => { + await expect(ensureLocalPlatformDeviceReady(ANDROID_EMULATOR)).resolves.toBe(true); + + expect(mockWaitForAndroidBoot).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR.id); +}); + +test('a platform without a readiness step reports it did not handle the device', async () => { + await expect(ensureLocalPlatformDeviceReady(LINUX_DEVICE)).resolves.toBe(false); + + expect(mockEnsureBootedSimulator).not.toHaveBeenCalled(); + expect(mockWaitForAndroidBoot).not.toHaveBeenCalled(); + expect(mockRunCmd).not.toHaveBeenCalled(); +}); From e48c0b57a032c68fd1589e389d6f9c6445765ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 14:31:52 +0200 Subject: [PATCH 5/9] test(daemon): type the prepare handler binding spy Co-Authored-By: Claude Opus 5.5 --- .../handlers/__tests__/session-prepare.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts index 0408f5f1da..8ac99cbb5e 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts @@ -149,9 +149,11 @@ async function invokePrepare(params: { params.prepareAppleRunner ?? (async () => ({ runner: { currentUptimeMs: 42 }, connectMs: 3, healthCheckMs: 3 })), ); - const inspectFacts = vi.fn(async () => facts); - const bindDevice = vi.fn(async (candidate, use) => - narrowDeviceBinding( + const inspectFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(); + const bindDeviceRuntime: BindDeviceRuntime = async (candidate, use) => { + bindDevice(); + return narrowDeviceBinding( { device: candidate, owner: localRuntimeOwner('apple'), @@ -160,8 +162,8 @@ async function invokePrepare(params: { [Symbol.asyncDispose]: async () => {}, }, use, - ), - ); + ); + }; vi.mocked(resolveTargetDevice).mockResolvedValue(device); const response = await handlePrepareCommand({ platformServices: daemonPlatformServicesFixture(), @@ -177,7 +179,7 @@ async function invokePrepare(params: { logPath: '/dev/null', sessionStore, inspectFacts, - bindDevice, + bindDevice: bindDeviceRuntime, }); return { response, sessionStore, prepareAppleRunner, inspectFacts, bindDevice }; } From 54836eb3b17317e8faff8ff2f58bac68bd01d191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 15:13:07 +0200 Subject: [PATCH 6/9] chore(daemon): drop the four testing subpaths the session-lifecycle batch left without importers Co-Authored-By: Claude Opus 5.5 --- packages/daemon/package.json | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/daemon/package.json b/packages/daemon/package.json index cb05d40f66..d606016ff5 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -44,18 +44,10 @@ "types": "./src/__tests__/support/application-lifecycle-runtime-fixture.ts", "default": "./src/__tests__/support/application-lifecycle-runtime-fixture.ts" }, - "./testing/__tests__/application-lifecycle-runtime-harness": { - "types": "./src/__tests__/support/application-lifecycle-runtime-harness.ts", - "default": "./src/__tests__/support/application-lifecycle-runtime-harness.ts" - }, "./testing/__tests__/capture-disclosure-target-activation-fact": { "types": "./src/core/__tests__/capture-disclosure-target-activation-fact.ts", "default": "./src/core/__tests__/capture-disclosure-target-activation-fact.ts" }, - "./testing/__tests__/device-selection-stub": { - "types": "./src/__tests__/support/device-selection-stub.ts", - "default": "./src/__tests__/support/device-selection-stub.ts" - }, "./testing/__tests__/human-control-fixtures": { "types": "./src/__tests__/support/human-control-fixtures.ts", "default": "./src/__tests__/support/human-control-fixtures.ts" @@ -188,10 +180,6 @@ "types": "./src/sessions/handlers/session-appstate.ts", "default": "./src/sessions/handlers/session-appstate.ts" }, - "./testing/handlers/session-replay-command": { - "types": "./src/sessions/handlers/session-replay-command.ts", - "default": "./src/sessions/handlers/session-replay-command.ts" - }, "./testing/handlers/snapshot": { "types": "./src/sessions/handlers/snapshot.ts", "default": "./src/sessions/handlers/snapshot.ts" @@ -332,10 +320,6 @@ "types": "./src/core/session-capture-binding.ts", "default": "./src/core/session-capture-binding.ts" }, - "./testing/session-lifecycle/internal/__tests__/*": { - "types": "./src/sessions/session-lifecycle/internal/__tests__/*.ts", - "default": "./src/sessions/session-lifecycle/internal/__tests__/*.ts" - }, "./testing/session-observability/__tests__/*": { "types": "./src/sessions/session-observability/__tests__/*.ts", "default": "./src/sessions/session-observability/__tests__/*.ts" From 73c65f14705ad716e2fff6792f1942926d340a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 15:30:52 +0200 Subject: [PATCH 7/9] test(platform-apple): move the added lifecycle cases beside lifecycle.ts so lifecycle.test.ts stays under the size tripwire Co-Authored-By: Claude Opus 5.5 --- .../src/lifecycle-open-prewarm.test.ts | 131 +++++++++++++ .../src/lifecycle-physical-close.test.ts | 70 +++++++ packages/platform-apple/src/lifecycle.test.ts | 179 ------------------ 3 files changed, 201 insertions(+), 179 deletions(-) create mode 100644 packages/platform-apple/src/lifecycle-open-prewarm.test.ts create mode 100644 packages/platform-apple/src/lifecycle-physical-close.test.ts diff --git a/packages/platform-apple/src/lifecycle-open-prewarm.test.ts b/packages/platform-apple/src/lifecycle-open-prewarm.test.ts new file mode 100644 index 0000000000..6f5c5ffbdb --- /dev/null +++ b/packages/platform-apple/src/lifecycle-open-prewarm.test.ts @@ -0,0 +1,131 @@ +import { expect, test, vi } from 'vitest'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import { AppError } from '@agent-device/kernel/errors'; + +import { bindAppleApplicationLifecycle } from './lifecycle.ts'; +import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; +import { + coldSimulatorLifecycleHost, + device, + openInput, + simulator, + simulatorHost, +} from './lifecycle.fixtures.ts'; + +test.each([ + [true, 1], + [false, 0], +] as const)( + 'a cold-booting Simulator open with prewarmRunnerOnColdBoot=%s warms the runner build cache %s time(s)', + async (prewarmRunnerOnColdBoot, expectedCalls) => { + const { host } = coldSimulatorLifecycleHost(); + const prewarmRunnerCache = vi.fn(async () => {}); + const signal = new AbortController().signal; + const lifecycle = bindAppleApplicationLifecycle({ + host: { + ...host, + appleApplications: { ...host.appleApplications, prewarmRunnerCache }, + } as unknown as PlatformRuntimeHost, + device: { ...simulator, booted: false }, + signal, + }); + + await lifecycle.prepareApplicationOpen({ + target: 'com.example.app', + hasExistingSession: false, + surface: 'app', + prewarmRunnerOnColdBoot, + execution: { requestId: 'open-request' }, + }); + + expect(prewarmRunnerCache).toHaveBeenCalledTimes(expectedCalls); + if (expectedCalls > 0) { + expect(prewarmRunnerCache).toHaveBeenCalledWith( + expect.objectContaining({ id: simulator.id }), + { requestId: 'open-request' }, + signal, + ); + } + }, +); + +test('a Maestro app link on a Simulator resets the runner target it prewarmed before the URL dispatch', async () => { + const events: string[] = []; + const { host, notifyRunnerAppRelaunched } = simulatorHost({ events }); + const signal = new AbortController().signal; + const lifecycle = bindAppleApplicationLifecycle({ host, device: simulator, signal }); + + await lifecycle.openApplication({ + ...openInput(), + relaunch: false, + prewarmRunnerBeforeOpen: true, + }); + + expect(events).toEqual(['prewarm', 'open', 'reset']); + expect(notifyRunnerAppRelaunched).toHaveBeenCalledWith(simulator, {}, signal); +}); + +test('a blocking runner prewarm failure rejects the open before the app launches', async () => { + const events: string[] = []; + const prewarmFailure = new AppError( + 'COMMAND_FAILED', + 'Developer mode is disabled for Apple development tools', + { hint: 'Run `sudo DevToolsSecurity -enable`.' }, + ); + const interactor = { + open: vi.fn(async () => { + events.push('open'); + }), + } as unknown as Interactor; + const baseHost = platformRuntimeHostFixture(); + const prewarmRunnerSession = vi.fn(async () => { + throw prewarmFailure; + }); + const host = { + ...baseHost, + localInteractors: { resolve: async () => interactor }, + appleApplications: { ...baseHost.appleApplications, prewarmRunnerSession }, + } as unknown as PlatformRuntimeHost; + const signal = new AbortController().signal; + const lifecycle = bindAppleApplicationLifecycle({ host, device, signal }); + + await expect( + lifecycle.openApplication({ ...openInput(), relaunch: false, prewarmRunnerBeforeOpen: true }), + ).rejects.toBe(prewarmFailure); + + expect(prewarmRunnerSession).toHaveBeenCalledWith(device, {}, signal, true); + expect(events).toEqual([]); +}); + +test('an open with no resolved app bundle id starts no runner prewarm', async () => { + const events: string[] = []; + const interactor = { + open: vi.fn(async () => { + events.push('open'); + }), + } as unknown as Interactor; + const baseHost = platformRuntimeHostFixture(); + const prewarmRunnerSession = vi.fn(async () => {}); + const host = { + ...baseHost, + localInteractors: { resolve: async () => interactor }, + appleApplications: { ...baseHost.appleApplications, prewarmRunnerSession }, + } as unknown as PlatformRuntimeHost; + const lifecycle = bindAppleApplicationLifecycle({ + host, + device, + signal: new AbortController().signal, + }); + + await lifecycle.openApplication({ + ...openInput(), + target: 'myapp://screen/to', + positionals: ['myapp://screen/to'], + appBundleId: undefined, + relaunch: false, + }); + + expect(events).toEqual(['open']); + expect(prewarmRunnerSession).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/lifecycle-physical-close.test.ts b/packages/platform-apple/src/lifecycle-physical-close.test.ts new file mode 100644 index 0000000000..1379af456d --- /dev/null +++ b/packages/platform-apple/src/lifecycle-physical-close.test.ts @@ -0,0 +1,70 @@ +import { expect, test, vi } from 'vitest'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import { AppError } from '@agent-device/kernel/errors'; + +import { bindAppleApplicationLifecycle } from './lifecycle.ts'; +import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; +import { device } from './lifecycle.fixtures.ts'; + +test('close stops a physical Apple runner before the platform close and skips the close when the stop fails', async () => { + const signal = new AbortController().signal; + const events: string[] = []; + const preCloseError = new AppError('RUNNER_UNAVAILABLE', 'runner stop failed', { + reason: 'runner_stop_failed', + }); + const interactor = { + close: vi.fn(async () => { + events.push('close'); + }), + } as unknown as Interactor; + const baseHost = platformRuntimeHostFixture(); + const stopRunnerSession = vi.fn(async () => { + events.push('stop'); + throw preCloseError; + }); + const host = { + ...baseHost, + localInteractors: { resolve: async () => interactor }, + appleApplications: { ...baseHost.appleApplications, stopRunnerSession }, + } as unknown as PlatformRuntimeHost; + const lifecycle = bindAppleApplicationLifecycle({ host, device, signal }); + + await expect( + lifecycle.closeApplication({ + positionals: ['com.example.app'], + surface: 'app', + execution: {}, + }), + ).rejects.toBe(preCloseError); + + expect(events).toEqual(['stop']); + expect(stopRunnerSession).toHaveBeenCalledWith(device.id); + expect(interactor.close).not.toHaveBeenCalled(); +}); + +test('close --shutdown is ignored for a physical Apple device', async () => { + const signal = new AbortController().signal; + const baseHost = platformRuntimeHostFixture(); + const shutdownTarget = vi.fn(async () => ({ + success: true, + exitCode: 0, + stdout: '', + stderr: '', + })); + const host = { + ...baseHost, + deviceShutdown: { ...baseHost.deviceShutdown, apple: { shutdownTarget } }, + } as unknown as PlatformRuntimeHost; + const lifecycle = bindAppleApplicationLifecycle({ host, device, signal }); + + const result = await lifecycle.finalizeApplicationClose({ + surface: 'app', + retainRunner: false, + stateDir: '/tmp', + shutdownTarget: true, + }); + + expect(shutdownTarget).not.toHaveBeenCalled(); + expect(result).toEqual({}); +}); diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 13840c1d6f..d04dedcd58 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -263,42 +263,6 @@ test('discards a retained physical iOS runner when relaunch fails and preserves expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); }); -test('close stops a physical Apple runner before the platform close and skips the close when the stop fails', async () => { - const signal = new AbortController().signal; - const events: string[] = []; - const preCloseError = new AppError('RUNNER_UNAVAILABLE', 'runner stop failed', { - reason: 'runner_stop_failed', - }); - const interactor = { - close: vi.fn(async () => { - events.push('close'); - }), - } as unknown as Interactor; - const baseHost = platformRuntimeHostFixture(); - const stopRunnerSession = vi.fn(async () => { - events.push('stop'); - throw preCloseError; - }); - const host = { - ...baseHost, - localInteractors: { resolve: async () => interactor }, - appleApplications: { ...baseHost.appleApplications, stopRunnerSession }, - } as unknown as PlatformRuntimeHost; - const lifecycle = bindAppleApplicationLifecycle({ host, device, signal }); - - await expect( - lifecycle.closeApplication({ - positionals: ['com.example.app'], - surface: 'app', - execution: {}, - }), - ).rejects.toBe(preCloseError); - - expect(events).toEqual(['stop']); - expect(stopRunnerSession).toHaveBeenCalledWith(device.id); - expect(interactor.close).not.toHaveBeenCalled(); -}); - test.each([true, false])( 'close finalization delegates the runner release to the runner module with retain=%s (#2552)', async (retainRunner) => { @@ -349,32 +313,6 @@ test('daemon-shutdown finalization dismisses alerts and defers the runner releas expect(dismissCloseAlerts).toHaveBeenCalled(); }); -test('close --shutdown is ignored for a physical Apple device', async () => { - const signal = new AbortController().signal; - const baseHost = platformRuntimeHostFixture(); - const shutdownTarget = vi.fn(async () => ({ - success: true, - exitCode: 0, - stdout: '', - stderr: '', - })); - const host = { - ...baseHost, - deviceShutdown: { ...baseHost.deviceShutdown, apple: { shutdownTarget } }, - } as unknown as PlatformRuntimeHost; - const lifecycle = bindAppleApplicationLifecycle({ host, device, signal }); - - const result = await lifecycle.finalizeApplicationClose({ - surface: 'app', - retainRunner: false, - stateDir: '/tmp', - shutdownTarget: true, - }); - - expect(shutdownTarget).not.toHaveBeenCalled(); - expect(result).toEqual({}); -}); - test('prepare shares one startup budget across the Simulator boot and the runner preparation', async () => { vi.useFakeTimers(); try { @@ -432,123 +370,6 @@ test('open forwards its startup deadline to the Simulator boot wait', async () = } }); -test.each([ - [true, 1], - [false, 0], -] as const)( - 'a cold-booting Simulator open with prewarmRunnerOnColdBoot=%s warms the runner build cache %s time(s)', - async (prewarmRunnerOnColdBoot, expectedCalls) => { - const { host } = coldSimulatorLifecycleHost(); - const prewarmRunnerCache = vi.fn(async () => {}); - const signal = new AbortController().signal; - const lifecycle = bindAppleApplicationLifecycle({ - host: { - ...host, - appleApplications: { ...host.appleApplications, prewarmRunnerCache }, - } as unknown as PlatformRuntimeHost, - device: { ...simulator, booted: false }, - signal, - }); - - await lifecycle.prepareApplicationOpen({ - target: 'com.example.app', - hasExistingSession: false, - surface: 'app', - prewarmRunnerOnColdBoot, - execution: { requestId: 'open-request' }, - }); - - expect(prewarmRunnerCache).toHaveBeenCalledTimes(expectedCalls); - if (expectedCalls > 0) { - expect(prewarmRunnerCache).toHaveBeenCalledWith( - expect.objectContaining({ id: simulator.id }), - { requestId: 'open-request' }, - signal, - ); - } - }, -); - -test('a Maestro app link on a Simulator resets the runner target it prewarmed before the URL dispatch', async () => { - const events: string[] = []; - const { host, notifyRunnerAppRelaunched } = simulatorHost({ events }); - const signal = new AbortController().signal; - const lifecycle = bindAppleApplicationLifecycle({ host, device: simulator, signal }); - - await lifecycle.openApplication({ - ...openInput(), - relaunch: false, - prewarmRunnerBeforeOpen: true, - }); - - expect(events).toEqual(['prewarm', 'open', 'reset']); - expect(notifyRunnerAppRelaunched).toHaveBeenCalledWith(simulator, {}, signal); -}); - -test('a blocking runner prewarm failure rejects the open before the app launches', async () => { - const events: string[] = []; - const prewarmFailure = new AppError( - 'COMMAND_FAILED', - 'Developer mode is disabled for Apple development tools', - { hint: 'Run `sudo DevToolsSecurity -enable`.' }, - ); - const interactor = { - open: vi.fn(async () => { - events.push('open'); - }), - } as unknown as Interactor; - const baseHost = platformRuntimeHostFixture(); - const prewarmRunnerSession = vi.fn(async () => { - throw prewarmFailure; - }); - const host = { - ...baseHost, - localInteractors: { resolve: async () => interactor }, - appleApplications: { ...baseHost.appleApplications, prewarmRunnerSession }, - } as unknown as PlatformRuntimeHost; - const signal = new AbortController().signal; - const lifecycle = bindAppleApplicationLifecycle({ host, device, signal }); - - await expect( - lifecycle.openApplication({ ...openInput(), relaunch: false, prewarmRunnerBeforeOpen: true }), - ).rejects.toBe(prewarmFailure); - - expect(prewarmRunnerSession).toHaveBeenCalledWith(device, {}, signal, true); - expect(events).toEqual([]); -}); - -test('an open with no resolved app bundle id starts no runner prewarm', async () => { - const events: string[] = []; - const interactor = { - open: vi.fn(async () => { - events.push('open'); - }), - } as unknown as Interactor; - const baseHost = platformRuntimeHostFixture(); - const prewarmRunnerSession = vi.fn(async () => {}); - const host = { - ...baseHost, - localInteractors: { resolve: async () => interactor }, - appleApplications: { ...baseHost.appleApplications, prewarmRunnerSession }, - } as unknown as PlatformRuntimeHost; - const lifecycle = bindAppleApplicationLifecycle({ - host, - device, - signal: new AbortController().signal, - }); - - await lifecycle.openApplication({ - ...openInput(), - target: 'myapp://screen/to', - positionals: ['myapp://screen/to'], - appBundleId: undefined, - relaunch: false, - }); - - expect(events).toEqual(['open']); - expect(prewarmRunnerSession).not.toHaveBeenCalled(); -}); - test('a Simulator open whose plan is observation-only starts no runner, releases a speculative one, and reports demand none', async () => { const events: string[] = []; const { host, prewarmRunnerSession, notifyRunnerAppRelaunched, releaseSpeculativeRunner } = From f9d77d51ab533bdcf8f34edaf993b168cdb0a99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 16:19:44 +0200 Subject: [PATCH 8/9] test: scope the call-history assertions per case and make the Maestro app-link case dispatch a URL Co-Authored-By: Claude Opus 5.5 --- .../handlers/__tests__/session-prepare.test.ts | 1 + .../__tests__/session-open-existing.test.ts | 1 + .../src/lifecycle-open-prewarm.test.ts | 17 ++++++++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts index 8ac99cbb5e..133ba0724f 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-prepare.test.ts @@ -164,6 +164,7 @@ async function invokePrepare(params: { use, ); }; + vi.mocked(resolveTargetDevice).mockClear(); vi.mocked(resolveTargetDevice).mockResolvedValue(device); const response = await handlePrepareCommand({ platformServices: daemonPlatformServicesFixture(), diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts index ead55a938a..44ae5437d3 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts @@ -212,6 +212,7 @@ test.each([ dispatchedContext = context as Record | undefined; return {}; }); + mockResolveOpenTarget.mockClear(); mockResolveOpenTarget.mockResolvedValueOnce(resolved); const response = await handleSessionCommands({ diff --git a/packages/platform-apple/src/lifecycle-open-prewarm.test.ts b/packages/platform-apple/src/lifecycle-open-prewarm.test.ts index 6f5c5ffbdb..692188bc78 100644 --- a/packages/platform-apple/src/lifecycle-open-prewarm.test.ts +++ b/packages/platform-apple/src/lifecycle-open-prewarm.test.ts @@ -53,16 +53,31 @@ test.each([ test('a Maestro app link on a Simulator resets the runner target it prewarmed before the URL dispatch', async () => { const events: string[] = []; const { host, notifyRunnerAppRelaunched } = simulatorHost({ events }); + const open = vi.fn(async (_app: string, _options?: Record) => { + events.push('open'); + }); const signal = new AbortController().signal; - const lifecycle = bindAppleApplicationLifecycle({ host, device: simulator, signal }); + const lifecycle = bindAppleApplicationLifecycle({ + host: { + ...host, + localInteractors: { resolve: async () => ({ open }) as unknown as Interactor }, + } as unknown as PlatformRuntimeHost, + device: simulator, + signal, + }); await lifecycle.openApplication({ ...openInput(), + positionals: ['com.example.app', 'https://example.com/deal'], relaunch: false, prewarmRunnerBeforeOpen: true, }); expect(events).toEqual(['prewarm', 'open', 'reset']); + expect(open).toHaveBeenCalledWith( + 'com.example.app', + expect.objectContaining({ url: 'https://example.com/deal' }), + ); expect(notifyRunnerAppRelaunched).toHaveBeenCalledWith(simulator, {}, signal); }); From 433801808b73ffa16654eb717175592c7679dc74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 17:59:28 +0200 Subject: [PATCH 9/9] test: restore the simulator web-URL-with-stale-app case beside the open-target module and in the existing-session table Co-Authored-By: Claude Opus 5.5 --- .../internal/__tests__/session-open-existing.test.ts | 12 ++++++++++++ src/platform-runtime-open-target.test.ts | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts index 44ae5437d3..892dd54cd8 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts @@ -166,6 +166,18 @@ test.each([ target: 'https://example.com/path', resolved: { appBundleId: 'com.apple.mobilesafari', appName: 'https://example.com/path' }, }, + { + label: 'a web URL on an iOS simulator session with a stale app bundle id', + device: { + platform: 'apple', + id: 'ios-simulator-1', + name: 'iPhone Simulator', + kind: 'simulator', + }, + existing: { appBundleId: 'com.example.old', appName: 'Old App' }, + target: 'https://example.com/path', + resolved: { appBundleId: 'com.apple.mobilesafari', appName: 'https://example.com/path' }, + }, { label: 'an app name on a macOS session', device: { diff --git a/src/platform-runtime-open-target.test.ts b/src/platform-runtime-open-target.test.ts index bb8272122b..9ddd221cf0 100644 --- a/src/platform-runtime-open-target.test.ts +++ b/src/platform-runtime-open-target.test.ts @@ -92,6 +92,13 @@ test.each([ expect(mockResolveIosApp).not.toHaveBeenCalled(); }); +test('a web URL on an iOS simulator targets Safari whatever the active app is', async () => { + await expect( + resolveSessionAppBundleIdForTarget(iosSimulator, 'https://example.com/path', 'com.example.old'), + ).resolves.toBe('com.apple.mobilesafari'); + expect(mockResolveIosApp).not.toHaveBeenCalled(); +}); + test('a web URL on an iOS device keeps the active app context', async () => { await expect( resolveSessionAppBundleIdForTarget(iosDevice, 'https://example.com/path', 'com.example.app'),