From 620eabaee17445d8787627571ef8cc9293e276f9 Mon Sep 17 00:00:00 2001 From: apex-agent Date: Mon, 12 Oct 2026 20:32:23 +0200 Subject: [PATCH 01/23] refactor(daemon): fake the host-diagnostics, perf-capture, android-observation, and platform-services ports in daemon suites Daemon suites no longer import root platform-runtime modules: the host-diagnostics, perf-capture, android-observation, and platform-services/ claim-recovery edges are answered by daemon-local port fixtures whose members are spies or resolve through platform-package leaves (Safari/deep-link rules, iOS app resolution, android package mechanics), while suites that pin what the real composed root modules do keep a root-owned composition fixture at src/__tests__/daemon-composition/. Relocated to the composition lane: the perf-capture session-resource suite, readiness spawn suites, the runner-retention trio, the foreign-dead-owner claim-recovery cases, and three android system-dialog suites converted to port spies where their assertions were daemon policy. --- .../session-close-runner-retention.test.ts | 218 +++++++++++++++ .../handlers}/session-doctor-warmup.test.ts | 14 +- ...ion-touch-android-readiness-spawns.test.ts | 10 +- .../perf-capture-session-resource.test.ts | 6 +- .../platform-services-fixture.ts | 26 ++ ...request-router-open-claim-recovery.test.ts | 258 ++++++++++++++++++ .../__tests__/android-observation-fixture.ts | 38 ++- .../android-system-dialog-occlusion.test.ts | 2 +- .../android-system-dialog-ref-frame.test.ts | 2 +- .../__tests__/android-system-dialog.test.ts | 2 +- src/daemon/__tests__/daemon-policy.test.ts | 4 +- .../__tests__/platform-services-fixture.ts | 225 ++++++++++++++- .../__tests__/request-router-open.test.ts | 145 ---------- .../support/host-diagnostics-fixture.ts | 20 ++ .../unavailable-device-runtime-gateway.ts | 62 +++++ .../__tests__/test-device-runtime-gateway.ts | 76 ++---- .../session-doctor-app-runtime.test.ts | 6 +- .../__tests__/session-relaunch-close.test.ts | 87 ------ 18 files changed, 873 insertions(+), 328 deletions(-) create mode 100644 src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts rename src/{daemon/handlers/__tests__ => __tests__/daemon-composition/handlers}/session-doctor-warmup.test.ts (92%) rename src/{daemon/interaction/internal/__tests__ => __tests__/daemon-composition}/interaction-touch-android-readiness-spawns.test.ts (77%) rename src/{daemon/__tests__ => __tests__/daemon-composition}/perf-capture-session-resource.test.ts (95%) create mode 100644 src/__tests__/daemon-composition/platform-services-fixture.ts create mode 100644 src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts create mode 100644 src/daemon/__tests__/support/host-diagnostics-fixture.ts create mode 100644 src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts diff --git a/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts b/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts new file mode 100644 index 0000000000..c9a9a83e14 --- /dev/null +++ b/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts @@ -0,0 +1,218 @@ +import { test, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; +import { LeaseRegistry } from '../../../daemon/lease-registry.ts'; +import { providerDeviceAdmissionFixture } from '../../../daemon/__tests__/support/provider-device-admission-fixture.ts'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; + +providerDeviceAdmissionFixture(); +import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { makeSessionStore } from '../../../daemon/__tests__/support/store-factory.ts'; +import { makeSession } from '../../../daemon/__tests__/support/session-factories.ts'; +import { getResolveTargetDeviceMock } from '../../../daemon/__tests__/request-router-dispatch-mocks.ts'; +import type { DaemonRequest } from '../../../daemon/daemon-request.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; + +// The runner-retention decision is the real `PlatformResourceCleanup` rule (a booted simulator +// keeps its runner host across a plain close), so these close-policy cases run the composed +// cleanup, the way the daemon-composition lane composes it. + +vi.mock('node:timers/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, setTimeout: vi.fn(async () => undefined) }; +}); +vi.mock('../../../daemon/device/device-ready.ts', () => ({ + 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, + })), + prewarmIosRunnerSession: vi.fn(), + notifyIosRunnerAppRelaunched: vi.fn(async () => {}), + hasLiveIosRunnerSession: vi.fn(() => true), + stopIosRunnerSession: vi.fn(async () => {}), + releaseIosRunnerOnClose: 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, + lifecycleDeviceRuntimeGateway, +} from '../../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { + dispatchApplicationLifecycleEffect, + awaitFixtureReadiness, + discoverReadyAndroidEmulators, +} from '../../../daemon/__tests__/application-lifecycle-runtime-fixture.ts'; +import { + stopIosRunnerSession, + releaseIosRunnerOnClose, +} from '@agent-device/platform-apple/runner/operations'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; + +const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); +const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); +const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); +const mockStopIosRunner = vi.mocked(stopIosRunnerSession); +const mockReleaseRunnerOnClose = vi.mocked(releaseIosRunnerOnClose); + +beforeEach(() => { + vi.useRealTimers(); + vi.mocked(getResolveTargetDeviceMock()).mockReset(); + mockDispatch.mockReset(); + mockDispatch.mockResolvedValue(undefined); + mockAwaitFixtureReadiness.mockReset(); + mockAwaitFixtureReadiness.mockResolvedValue(undefined); + mockDiscoverReadyAndroidEmulators.mockReset(); + mockDiscoverReadyAndroidEmulators.mockImplementation(async (device) => [ + { + ...device, + id: device.id.startsWith('emulator-') ? device.id : `emulator-${device.id}`, + booted: true, + }, + ]); + mockStopIosRunner.mockReset(); + mockStopIosRunner.mockResolvedValue(undefined); + mockReleaseRunnerOnClose.mockReset(); +}); + +function createHandler(sessionStore: ReturnType) { + return createRequestHandler({ + createCommandSurface: fakeCreateCommandSurface, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + deviceInventoryGateways: createTestDeviceInventoryGateways(), + platformResourceCleanup, + trackDownloadableArtifact: () => 'artifact-id', + }); +} + +let requestSeq = 0; + +function sessionRequest( + session: string, + command: 'close', + options: { + positionals?: string[]; + flags?: Record; + } = {}, +): DaemonRequest { + requestSeq += 1; + return { + token: 'test-token', + session, + command, + positionals: options.positionals ?? [], + flags: options.flags ?? {}, + meta: { requestId: `req-close-retention-${requestSeq}` }, + }; +} + +test('close on iOS simulator session retains runner and deletes the session', async () => { + const sessionStore = makeSessionStore('agent-device-close-retention-'); + const sessionName = 'ios-simulator-session'; + sessionStore.publish( + sessionName, + makeSession(sessionName, { + device: { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }, + appName: 'com.example.app', + }), + ); + + const response = await createHandler(sessionStore)(sessionRequest(sessionName, 'close')); + + expect(response.ok).toBe(true); + expect(mockStopIosRunner).not.toHaveBeenCalled(); + expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: true }); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('close on iOS simulator with scoped simulator set retains runner and deletes the session', async () => { + const sessionStore = makeSessionStore('agent-device-close-retention-'); + const sessionName = 'ios-scoped-simulator-session'; + sessionStore.publish( + sessionName, + makeSession(sessionName, { + device: { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + simulatorSetPath: '/tmp/tenant-a/simulator-set', + }, + appName: 'com.example.app', + }), + ); + + const response = await createHandler(sessionStore)(sessionRequest(sessionName, 'close')); + + expect(response.ok).toBe(true); + expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: true }); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('close on iOS simulator retains runner while terminating app', async () => { + const sessionStore = makeSessionStore('agent-device-close-retention-'); + const sessionName = 'ios-simulator-close-session'; + sessionStore.publish( + sessionName, + makeSession(sessionName, { + device: { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }, + appName: 'com.example.app', + }), + ); + + const calls: string[] = []; + mockStopIosRunner.mockImplementation(async () => { + calls.push('stop-runner'); + }); + mockReleaseRunnerOnClose.mockImplementation(async (_deviceId, options) => { + calls.push(`release-runner:${options.retain ? 'retain' : 'stop'}`); + }); + mockDispatch.mockImplementation(async (_device, command, positionals) => { + calls.push(`${command}:${(positionals ?? []).join(' ')}`); + return {}; + }); + + const response = await createHandler(sessionStore)( + sessionRequest(sessionName, 'close', { positionals: ['com.example.app'] }), + ); + + expect(response.ok).toBe(true); + expect(calls).toEqual(['close:com.example.app', 'release-runner:retain']); +}); diff --git a/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts b/src/__tests__/daemon-composition/handlers/session-doctor-warmup.test.ts similarity index 92% rename from src/daemon/handlers/__tests__/session-doctor-warmup.test.ts rename to src/__tests__/daemon-composition/handlers/session-doctor-warmup.test.ts index c584f3b333..e40a30178a 100644 --- a/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts +++ b/src/__tests__/daemon-composition/handlers/session-doctor-warmup.test.ts @@ -1,11 +1,11 @@ import { beforeEach, expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { isActiveProviderDevice } from '../../provider-device-admission.ts'; -import { handleDoctorCommand } from '../session-doctor.ts'; +import { isActiveProviderDevice } from '../../../daemon/provider-device-admission.ts'; +import { handleDoctorCommand } from '../../../daemon/handlers/session-doctor.ts'; import { createHostDiagnostics } from '../../../platform-runtime-host-diagnostics.ts'; -import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { makeSessionStore } from '../../../daemon/__tests__/support/store-factory.ts'; import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import type { DaemonResponse } from '../../daemon-request.ts'; +import type { DaemonResponse } from '../../../daemon/daemon-request.ts'; const { mockAppleRunnerWarmupCheck } = vi.hoisted(() => ({ mockAppleRunnerWarmupCheck: vi.fn(), @@ -15,13 +15,13 @@ vi.mock('@agent-device/platform-apple/doctor', async (importOriginal) => ({ ...(await importOriginal()), appleRunnerWarmupCheck: mockAppleRunnerWarmupCheck, })); -vi.mock('../session-doctor-app.ts', () => ({ +vi.mock('../../../daemon/handlers/session-doctor-app.ts', () => ({ appendAppChecks: vi.fn(async () => {}), })); -vi.mock('../session-doctor-metro.ts', () => ({ +vi.mock('../../../daemon/handlers/session-doctor-metro.ts', () => ({ probeMetro: vi.fn(async () => ({ id: 'metro', status: 'pass', summary: 'mocked' })), })); -vi.mock('../../provider-device-admission.ts', () => ({ +vi.mock('../../../daemon/provider-device-admission.ts', () => ({ isActiveProviderDevice: vi.fn(() => false), })); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness-spawns.test.ts b/src/__tests__/daemon-composition/interaction-touch-android-readiness-spawns.test.ts similarity index 77% rename from src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness-spawns.test.ts rename to src/__tests__/daemon-composition/interaction-touch-android-readiness-spawns.test.ts index ef845af7b1..daafe00e2b 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness-spawns.test.ts +++ b/src/__tests__/daemon-composition/interaction-touch-android-readiness-spawns.test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { makeAndroidSession } from '../../../__tests__/support/session-factories.ts'; -import { withFakeAdb } from '../../../../__tests__/test-utils/fake-adb.ts'; -import { expireRefFrame } from '../../../ref-frame.ts'; -import { runWithAndroidDialogReadinessCheck } from '../interaction-touch-android-readiness.ts'; -import { androidObservation } from '../../../../platform-runtime.ts'; +import { makeAndroidSession } from '../../daemon/__tests__/support/session-factories.ts'; +import { withFakeAdb } from '../test-utils/fake-adb.ts'; +import { expireRefFrame } from '../../daemon/ref-frame.ts'; +import { runWithAndroidDialogReadinessCheck } from '../../daemon/interaction/internal/interaction-touch-android-readiness.ts'; +import { androidObservationPolicyFixture as androidObservation } from '../../daemon/__tests__/android-observation-fixture.ts'; // What blocking-dialog readiness costs a run of taps, end to end. Every one of these `dumpsys` // spawns is a device round trip on the critical path of an interaction, so the budget is pinned diff --git a/src/daemon/__tests__/perf-capture-session-resource.test.ts b/src/__tests__/daemon-composition/perf-capture-session-resource.test.ts similarity index 95% rename from src/daemon/__tests__/perf-capture-session-resource.test.ts rename to src/__tests__/daemon-composition/perf-capture-session-resource.test.ts index 10b895d03a..8c8cf0f8b0 100644 --- a/src/daemon/__tests__/perf-capture-session-resource.test.ts +++ b/src/__tests__/daemon-composition/perf-capture-session-resource.test.ts @@ -1,9 +1,9 @@ -import { bindSessionPerfCapture } from '../session-capture-binding.ts'; +import { bindSessionPerfCapture } from '../../daemon/session-capture-binding.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { makeSessionStore } from './support/store-factory.ts'; +import { makeSessionStore } from '../../daemon/__tests__/support/store-factory.ts'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import { perfCaptureResourceStore } from '@agent-device/capture-kit/perf-capture-resource-store'; import { @@ -11,7 +11,7 @@ import { finishLivePerfCapture, } from '@agent-device/capture-kit/perf-capture-session-resource'; import { startAndroidPerfCapture } from '../../platform-runtime-perf-capture-host.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionState } from '../../daemon/session-state.ts'; const androidNative = vi.hoisted(() => ({ startAndroidSimpleperfProfile: vi.fn(), diff --git a/src/__tests__/daemon-composition/platform-services-fixture.ts b/src/__tests__/daemon-composition/platform-services-fixture.ts new file mode 100644 index 0000000000..88ac8cead0 --- /dev/null +++ b/src/__tests__/daemon-composition/platform-services-fixture.ts @@ -0,0 +1,26 @@ +import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts'; +import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts'; +import type { DaemonPlatformServices } from '../../daemon/platform-services.ts'; +import type { ClaimRecoveryGatewayFactory } from '../../daemon/device/claim-recovery-gateway.ts'; + +/** + * The port the production composition root builds, handed to a test the same way the shared router + * fixture hands the real resource-cleanup capability: tests that exercise command behaviour rather + * than readiness or observation policy keep seeing the real adapters, and a test that cares about + * one ask replaces just that member — a readiness spy overrides `ensureLocalDeviceReady` rather + * than mocking a platform module behind the port. + */ +export function daemonPlatformServicesFixture( + overrides: Partial = {}, +): DaemonPlatformServices { + return Object.freeze({ ...createDaemonPlatformServices(), ...overrides }); +} + +/** + * The recovery-gateway half of root composition, same rule as the services above: tests that + * route a stale claim see the real composed gateway, and a test that cares about composition + * passes its own factory. + */ +export function daemonClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory { + return createClaimRecoveryGateway; +} diff --git a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts new file mode 100644 index 0000000000..9e127e97a7 --- /dev/null +++ b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts @@ -0,0 +1,258 @@ +import { test, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { providerDeviceAdmissionFixture } from '../../daemon/__tests__/support/provider-device-admission-fixture.ts'; +import { fakeCreateCommandSurface } from '../../daemon/__tests__/support/command-surface-fake.ts'; +import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { + daemonClaimRecoveryGatewayFixture, + daemonPlatformServicesFixture, +} from './platform-services-fixture.ts'; +import { getResolveTargetDeviceMock } from '../../daemon/__tests__/request-router-dispatch-mocks.ts'; +import { LeaseRegistry } from '../../daemon/lease-registry.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +// Open-time claim reconciliation runs on the root-composed claim-recovery gateway: the router +// must hand the acquire path the OWNER-scoped reconciler, so a foreign dead owner's recording +// recovery executes against that owner's state dir, never the daemon's own store (#2168). + +providerDeviceAdmissionFixture(); + +vi.mock('../../daemon/device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('@agent-device/host-kit/process', async (importOriginal) => { + const actual = await importOriginal(); + const startTime = 'test-process-start'; + return { + ...actual, + readProcessStartTime: vi.fn(() => startTime), + // The ownership decision reads all three facts in one call, so it has to answer with the + // same fabricated start time this fixture writes into its markers. + readProcessIdentityFacts: vi.fn(async (pid: number) => ({ + startTime, + command: actual.readProcessCommand(pid), + zombie: false, + })), + }; +}); +vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + detachIosRunnerSessionsForShutdown: vi.fn(async () => {}), + notifyIosRunnerAppRelaunched: vi.fn(async () => {}), + prewarmAppleRunnerCache: vi.fn(async () => {}), + prewarmIosRunnerSession: vi.fn(async () => {}), + prepareIosRunner: vi.fn(async () => ({ + runner: { currentUptimeMs: 42 }, + connectMs: 0, + healthCheckMs: 0, + })), + resolveRunnerAppBundleId: vi.fn(() => 'com.callstack.agentdevice.runner'), + stopIosRunnerSession: vi.fn(async () => {}), + stopAllIosRunnerSessions: vi.fn(async () => {}), + }; +}); +vi.mock('@agent-device/platform-apple/app-lifecycle', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, closeIosApp: 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) => app), + }; +}); + +import { + createRequestHandler, + lifecycleDeviceRuntimeGateway, +} from '../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { + awaitFixtureReadiness, + dispatchApplicationLifecycleEffect, + discoverReadyAndroidEmulators, +} from '../../daemon/__tests__/application-lifecycle-runtime-fixture.ts'; +import { makeSessionStore } from '../../daemon/__tests__/support/store-factory.ts'; +import { inspectDeviceClaims } from '../../daemon/device/device-claim-inspection.ts'; +import { + createOwnedProcessRecordStore, + isProcessAlive, + readProcessCommand, + readProcessStartTime, +} from '@agent-device/host-kit/process'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { screenRecordingDurableResource } from '@agent-device/capture-kit/screen-recording-session-resource'; +import { makeIosDevice, openRequest } from '../../daemon/__tests__/request-router-open-harness.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; + +const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); +const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); +const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); +const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); + +function createOpenHandler(sessionStore: ReturnType) { + return createRequestHandler({ + createCommandSurface: fakeCreateCommandSurface, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + deviceInventoryGateways: createTestDeviceInventoryGateways(), + trackDownloadableArtifact: () => 'artifact-id', + }); +} + +beforeEach(() => { + mockResolveTargetDevice.mockReset(); + mockDispatch.mockReset(); + mockDispatch.mockResolvedValue(undefined); + mockAwaitFixtureReadiness.mockReset(); + mockAwaitFixtureReadiness.mockResolvedValue(undefined); + mockDiscoverReadyAndroidEmulators.mockReset(); +}); + +// The dead owner's recorded recorder must still run as an orphan: recovery +// then takes the descriptor-cleanup path — the one that terminates it and +// clears the owned-process store it was composed with. A plain sleeper with +// its real observed command and start time satisfies the exact match. +async function spawnOrphanRecorder() { + const child = spawn('sleep', ['120'], { stdio: 'ignore' }); + await new Promise((resolve) => child.once('spawn', resolve)); + const pid = child.pid ?? 0; + const marker = { + pid, + startTime: readProcessStartTime(pid) ?? '', + command: readProcessCommand(pid) ?? '', + }; + expect(pid).toBeGreaterThan(0); + expect(marker.startTime.length).toBeGreaterThan(0); + expect(marker.command.length).toBeGreaterThan(0); + return { child, marker }; +} + +function ownedStoreFor(stateDir: string) { + const paths = resolveDaemonPaths(stateDir); + return createOwnedProcessRecordStore({ + stateDir: paths.baseDir, + sessionsDir: paths.sessionsDir, + resolveSessionDir: (sessionId) => path.join(paths.sessionsDir, sessionId), + }); +} + +function seedForeignRecordingOwner( + foreignStateDir: string, + device: DeviceInfo, + marker: { pid: number; startTime: string; command: string }, +) { + const foreignSessionDir = path.join(resolveDaemonPaths(foreignStateDir).sessionsDir, 'shared'); + const resourcePath = screenRecordingDurableResource.store.resolvePath(foreignSessionDir); + screenRecordingDurableResource.store.write( + resourcePath, + createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: 'shared', + device: { id: device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' }, + owner: { kind: 'local-family', family: 'apple' }, + fence: { token: 'open-recovery-fence', generation: 1 }, + lifecycle: 'open', + descriptor: { + version: 1, + body: { + backend: 'simctl', + outputPath: path.join(foreignSessionDir, 'recording.mp4'), + processes: [marker], + }, + }, + }), + ); + ownedStoreFor(foreignStateDir).replace({ kind: 'session', sessionId: 'shared' }, [ + { ...marker, purpose: 'simctl-screen-recording' }, + ]); + return { foreignSessionDir, resourcePath }; +} + +function seedDeadForeignClaim(claimsDir: string, device: DeviceInfo, foreignStateDir: string) { + const deviceKey = `local:apple:ios:${device.id}`; + fs.writeFileSync( + path.join(claimsDir, `${crypto.createHash('sha256').update(deviceKey).digest('hex')}.json`), + JSON.stringify({ + schemaVersion: 1, + deviceKey, + device: { platform: 'ios', id: device.id, name: device.name, kind: 'simulator' }, + session: 'shared', + workspace: '/worktrees/dead', + stateDir: foreignStateDir, + ownerPid: 999_999_999, + ownerStartTime: 'old-start-time', + ownerToken: 'open-recovery-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); +} + +test('open reconciles a foreign dead owner through that owner state dir, never the daemon store', async () => { + // #2168 acquire-path wiring: the router must hand open-time claim + // reconciliation the owner-scoped reconciler. With the caller-scoped + // reconciler this test goes red — the foreign recording recovery runs + // against the daemon's own gateway and store instead of the dead owner's. + const sessionStore = makeSessionStore('agent-device-router-open-recovery-'); + const device = makeIosDevice('SIM-FOREIGN-RECOVERY'); + mockResolveTargetDevice.mockResolvedValue(device); + const claimsDir = mkdtempForTestSync('agent-device-router-open-claims-'); + const foreignStateDir = mkdtempForTestSync('agent-device-router-open-foreign-'); + const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; + process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + const orphan = await spawnOrphanRecorder(); + + try { + const { foreignSessionDir, resourcePath } = seedForeignRecordingOwner( + foreignStateDir, + device, + orphan.marker, + ); + const daemonStateDir = sessionStore.resolveDaemonStateDir(); + ownedStoreFor(daemonStateDir).replace({ kind: 'session', sessionId: 'shared' }, [ + { pid: process.pid, startTime: 'live-marker', command: 'live-probe', purpose: 'test-probe' }, + ]); + const liveRecordPath = path.join( + resolveDaemonPaths(daemonStateDir).sessionsDir, + 'shared', + 'owned-processes.json', + ); + const liveRecordBefore = fs.readFileSync(liveRecordPath, 'utf8'); + seedDeadForeignClaim(claimsDir, device, foreignStateDir); + + const response = await createOpenHandler(sessionStore)( + openRequest('takeover', { platform: 'ios' }, 'req-open-foreign-recovery'), + ); + + expect(response.ok).toBe(true); + const settled = screenRecordingDurableResource.store.read(resourcePath); + expect(settled).toMatchObject({ status: 'decoded', envelope: { lifecycle: 'completed' } }); + expect(fs.existsSync(path.join(foreignSessionDir, 'owned-processes.json'))).toBe(false); + expect(fs.readFileSync(liveRecordPath, 'utf8')).toBe(liveRecordBefore); + expect(isProcessAlive(orphan.marker.pid)).toBe(false); + expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('takeover'); + } finally { + try { + orphan.child.kill('SIGKILL'); + } catch { + // The orphan is already gone once recovery terminated it. + } + if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; + else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; + fs.rmSync(claimsDir, { recursive: true, force: true }); + fs.rmSync(foreignStateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/__tests__/android-observation-fixture.ts b/src/daemon/__tests__/android-observation-fixture.ts index 190d3ca25d..ddea20bc0b 100644 --- a/src/daemon/__tests__/android-observation-fixture.ts +++ b/src/daemon/__tests__/android-observation-fixture.ts @@ -1,16 +1,43 @@ -import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; -import { androidObservation } from '../../platform-runtime.ts'; +import type { + AndroidObservationAdapter, + AndroidObservationHost, +} from '@agent-device/contracts/android-observation'; +import { createAndroidObservationAdapter } from '@agent-device/platform-android'; import { + androidSnapshotPublicationInput, createAndroidWindowDumpReader, getAndroidAppState, getAndroidBlockingDialogObservation, getAndroidScreenSize, isAndroidPermissionPackage, + openAndroidApp, + runAndroidAdb, + snapshotAndroid, } from '@agent-device/platform-android/mechanics'; +import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; -/** Test adapter that preserves existing platform-leaf mocks while production uses package policy. */ +/** The Android observation port: package policy over mechanics, so suite mocks on `@agent-device/platform-android/mechanics` answer every read. */ +const mechanicsObservationHost: AndroidObservationHost = Object.freeze({ + async runAdb(device, args, options) { + const result = await runAndroidAdb(device, args, options); + return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; + }, + async readSnapshotNodes(device) { + const rawSnapshot = await snapshotAndroid(device, { interactiveOnly: false }); + return buildSnapshotState(androidSnapshotPublicationInput(rawSnapshot), undefined).nodes; + }, + async openApp(device, appBundleId) { + await openAndroidApp(device, appBundleId); + }, +}); + +/** Package observation policy with a mechanics-backed host, as the production adapter is composed. */ +export const androidObservationPolicyFixture: AndroidObservationAdapter = + createAndroidObservationAdapter(mechanicsObservationHost); + +/** Adapter preserving the platform-leaf mock surface: policy members delegate to the mechanics leaves. */ export const androidObservationFixture: AndroidObservationAdapter = Object.freeze({ - ...androidObservation, + ...androidObservationPolicyFixture, readAppState: async (device) => await getAndroidAppState(device), readBlockingDialog: async (device) => await getAndroidBlockingDialogObservation(device), async readAppFocus(device, appBundleId, options = {}) { @@ -24,9 +51,10 @@ export const androidObservationFixture: AndroidObservationAdapter = Object.freez readScreenSize: async (device) => await getAndroidScreenSize(device), isPermissionPackage: async (packageName) => isAndroidPermissionPackage(packageName), }); + /** Benign router default for tests that exercise locking or response shape, not Android state. */ export const clearAndroidObservationFixture = Object.freeze({ - ...androidObservation, + ...androidObservationPolicyFixture, readAppState: async () => ({}), readBlockingDialog: async () => ({ status: 'clear' }) as const, readAppFocus: async () => true, diff --git a/src/daemon/__tests__/android-system-dialog-occlusion.test.ts b/src/daemon/__tests__/android-system-dialog-occlusion.test.ts index 58f2005c0b..5cf08ceff7 100644 --- a/src/daemon/__tests__/android-system-dialog-occlusion.test.ts +++ b/src/daemon/__tests__/android-system-dialog-occlusion.test.ts @@ -11,7 +11,7 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { import { runAndroidAdb, snapshotAndroid } from '@agent-device/platform-android/mechanics'; import { recoverAndroidBlockingSystemDialog as recoverOwnedAndroidBlockingSystemDialog } from '../android-system-dialog.ts'; -import { androidObservation } from '../../platform-runtime.ts'; +import { androidObservationPolicyFixture as androidObservation } from './android-observation-fixture.ts'; import { makeAndroidSession } from './support/session-factories.ts'; import { makeTestScreenRecordingResource } from './support/screen-recording-live-handle.ts'; import { makeAndroidSnapshotCapture } from '@agent-device/platform-android/snapshot-capture-fixtures'; diff --git a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts index 6e787f6b7b..3a996c8e2c 100644 --- a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts +++ b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts @@ -9,7 +9,7 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { import { runAndroidAdb, snapshotAndroid } from '@agent-device/platform-android/mechanics'; import { recoverAndroidBlockingSystemDialog as recoverOwnedAndroidBlockingSystemDialog } from '../android-system-dialog.ts'; -import { androidObservation } from '../../platform-runtime.ts'; +import { androidObservationPolicyFixture as androidObservation } from './android-observation-fixture.ts'; import { makeAndroidSession } from './support/session-factories.ts'; import { makeTestScreenRecordingResource } from './support/screen-recording-live-handle.ts'; import { makeAndroidSnapshotCapture } from '@agent-device/platform-android/snapshot-capture-fixtures'; diff --git a/src/daemon/__tests__/android-system-dialog.test.ts b/src/daemon/__tests__/android-system-dialog.test.ts index b1b4045a55..11e9792374 100644 --- a/src/daemon/__tests__/android-system-dialog.test.ts +++ b/src/daemon/__tests__/android-system-dialog.test.ts @@ -10,7 +10,7 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { import { snapshotAndroid } from '@agent-device/platform-android/mechanics'; import { recoverAndroidBlockingSystemDialog as recoverOwnedAndroidBlockingSystemDialog } from '../android-system-dialog.ts'; -import { androidObservation } from '../../platform-runtime.ts'; +import { androidObservationPolicyFixture as androidObservation } from './android-observation-fixture.ts'; const recoverAndroidBlockingSystemDialog = ( params: Omit[0], 'observation'>, diff --git a/src/daemon/__tests__/daemon-policy.test.ts b/src/daemon/__tests__/daemon-policy.test.ts index a3f85fb172..5e5746003f 100644 --- a/src/daemon/__tests__/daemon-policy.test.ts +++ b/src/daemon/__tests__/daemon-policy.test.ts @@ -16,7 +16,7 @@ import { parseDaemonPolicy } from '@agent-device/daemon-contracts/daemon-policy- import { assertDaemonPolicyAdmitsRequest } from '../daemon-policy.ts'; import type { DaemonRequest } from '../daemon-request.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { createHostDiagnostics } from '../../platform-runtime-host-diagnostics.ts'; +import { hostDiagnosticsFixture } from './support/host-diagnostics-fixture.ts'; import { createRequestHandler, lifecycleDeviceRuntimeGateway, @@ -67,7 +67,7 @@ function makeHandler( : undefined, }), trackDownloadableArtifact: () => 'artifact-id', - hostDiagnostics: createHostDiagnostics(), + hostDiagnostics: hostDiagnosticsFixture(), daemonPolicy, }); return { handler, bind, inspectFacts }; diff --git a/src/daemon/__tests__/platform-services-fixture.ts b/src/daemon/__tests__/platform-services-fixture.ts index 0121a9c7a2..d9221dad9e 100644 --- a/src/daemon/__tests__/platform-services-fixture.ts +++ b/src/daemon/__tests__/platform-services-fixture.ts @@ -1,26 +1,221 @@ -import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts'; -import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts'; -import type { DaemonPlatformServices } from '../platform-services.ts'; +import { vi } from 'vitest'; +import { + IOS_SAFARI_BUNDLE_ID, + isDeepLinkTarget, + isWebUrl, + resolveIosDeviceDeepLinkBundleId, +} from '@agent-device/contracts/command'; +import { parseSessionSurface, type SessionSurface } from '@agent-device/contracts/session'; +import { isApplePlatform, isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { + classifyAndroidAppTarget, + formatAndroidInstalledPackageRequiredMessage, + resolveAndroidPackageForOpen, +} from '@agent-device/platform-android/mechanics'; import type { ClaimRecoveryGatewayFactory } from '../device/claim-recovery-gateway.ts'; +import type { DaemonPlatformServices } from '../platform-services.ts'; +import { unavailableDeviceRuntimeGateway } from './support/unavailable-device-runtime-gateway.ts'; -/** - * The port the production composition root builds, handed to a test the same way the shared router - * fixture hands the real resource-cleanup capability: tests that exercise command behaviour rather - * than readiness or observation policy keep seeing the real adapters, and a test that cares about - * one ask replaces just that member — a readiness spy overrides `ensureLocalDeviceReady` rather - * than mocking a platform module behind the port. - */ +/** The `DaemonPlatformServices` port: daemon suites assert when each ask happens; every host leaf behind a member resolves through the platform packages, so suite mocks on the package leaves answer the member. A suite overrides the single member it cares about, as production wiring replaces one composed capability. Members are module-level stable references so two fixture instances deep-compare equal. */ export function daemonPlatformServicesFixture( overrides: Partial = {}, ): DaemonPlatformServices { - return Object.freeze({ ...createDaemonPlatformServices(), ...overrides }); + return Object.freeze({ + appleSessionObservation: fixtureAppleSessionObservation, + deviceBootObservation: fixtureDeviceBootObservation, + ensureLocalDeviceReady: fixtureEnsureLocalDeviceReady, + resolveRequestedOpenSurface: fixtureResolveRequestedOpenSurface, + validateOpenRelaunchTarget: fixtureValidateOpenRelaunchTarget, + resolveSessionAppBundleIdForTarget: fixtureResolveSessionAppBundleIdForTarget, + ...overrides, + }); +} + +const fixtureAppleSessionObservation = Object.freeze({ + observeRunnerSession: async () => undefined, + resolveSoleForegroundApp: async () => undefined, +}); + +const fixtureDeviceBootObservation = Object.freeze({ + observeBootTimeMs: async (device: DeviceInfo) => { + if (isIosFamily(device) && device.kind === 'simulator') { + const { observeSimulatorBootTimeMs } = + await import('@agent-device/platform-apple/simulator-boot'); + return await observeSimulatorBootTimeMs(device); + } + if (device.platform === 'android') { + const { observeAndroidBootTimeMs } = await import('@agent-device/platform-android/device-boot'); + return await observeAndroidBootTimeMs(device); + } + return { observed: false, reason: 'unsupported-device' } as const; + }, +}); + +async function fixtureEnsureLocalDeviceReady(device: DeviceInfo): Promise { + if (isIosFamily(device)) { + if (device.kind === 'simulator') { + const { ensureBootedSimulator } = await import('@agent-device/platform-apple/simulator'); + await ensureBootedSimulator(device); + return true; + } + if (device.kind === 'device') { + const { resolveIosPhysicalDeviceControl } = + await import('@agent-device/platform-apple/physical-device'); + const control = resolveIosPhysicalDeviceControl(device); + await control.ensureReady(device); + return true; + } + } + if (device.platform === 'android') { + const { waitForAndroidBoot } = await import('@agent-device/platform-android/mechanics'); + await waitForAndroidBoot(device.id); + return true; + } + return false; +} + +function fixtureResolveRequestedOpenSurface(params: { + device: DeviceInfo; + surfaceFlag: string | undefined; + openTarget: string | undefined; + existingSurface?: SessionSurface; +}): SessionSurface { + const { device, surfaceFlag, openTarget, existingSurface } = params; + if (device.platform === 'linux') { + return resolveSurfaceFor('linux', surfaceFlag, openTarget, existingSurface); + } + if (isMacOs(device)) { + return resolveSurfaceFor('macos', surfaceFlag, openTarget, existingSurface); + } + if (surfaceFlag) { + throw new AppError('INVALID_ARGS', 'surface is only supported on macOS and Linux'); + } + return 'app'; +} + +async function fixtureValidateOpenRelaunchTarget(params: { + target: string | undefined; + platform: string | undefined; + surface?: SessionSurface; +}): Promise { + const { target, platform, surface } = params; + if (target && isDeepLinkTarget(target)) { + return 'open --relaunch does not support URL targets.'; + } + if (surface !== undefined && surface !== 'app') { + return 'open --relaunch is supported only for app surfaces.'; + } + if (platform === 'android' && target) { + if (classifyAndroidAppTarget(target) === 'binary') { + return formatAndroidInstalledPackageRequiredMessage(target); + } + } + return undefined; +} + +async function fixtureResolveSessionAppBundleIdForTarget( + device: DeviceInfo, + openTarget: string | undefined, + currentAppBundleId: string | undefined, +): Promise { + if (device.platform === 'harmonyos') { + return bundleIdFromOpenTarget(openTarget) ?? currentAppBundleId; + } + return ( + (await resolveIosBundleIdForOpen(device, openTarget, currentAppBundleId)) ?? + (await resolveAndroidPackage(device, openTarget)) ?? + (device.platform === 'android' && openTarget && isDeepLinkTarget(openTarget) + ? currentAppBundleId + : undefined) + ); +} + +const LINUX_SURFACES: ReadonlySet = new Set(['app', 'desktop', 'frontmost-app']); + +function resolveSurfaceFor( + os: 'linux' | 'macos', + surfaceFlag: string | undefined, + openTarget: string | undefined, + existingSurface: SessionSurface | undefined, +): SessionSurface { + if (!surfaceFlag) return existingSurface ?? 'app'; + const surface = parseSessionSurface(surfaceFlag); + if (os === 'linux' && !LINUX_SURFACES.has(surface)) { + throw new AppError( + 'INVALID_ARGS', + `Linux supports --surface app, desktop, and frontmost-app (got "${surfaceFlag}")`, + ); + } + if (surface !== 'app' && surface !== 'menubar' && openTarget) { + throw new AppError('INVALID_ARGS', `open --surface ${surface} does not accept an app target`); + } + return surface; +} + +async function resolveIosBundleIdForOpen( + device: DeviceInfo, + openTarget: string | undefined, + currentAppBundleId: string | undefined, +): Promise { + if (!isApplePlatform(device.platform) || !openTarget) return undefined; + if (isDeepLinkTarget(openTarget)) { + if (isMacOs(device)) return undefined; + if (device.kind === 'device') { + return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget); + } + if (!isWebUrl(openTarget)) { + return currentAppBundleId ?? (await resolveSimulatorDeepLinkBundleId(device, openTarget)); + } + // simctl openurl opens a web URL in Safari, matching the real reader's Safari rule. + return IOS_SAFARI_BUNDLE_ID; + } + try { + const { resolveIosApp } = await import('@agent-device/platform-apple/app-resolution'); + return await resolveIosApp(device, openTarget); + } catch { + return undefined; + } +} + +async function resolveSimulatorDeepLinkBundleId( + device: DeviceInfo, + openTarget: string, +): Promise { + try { + const { resolveIosSimulatorDeepLinkBundleId } = + await import('@agent-device/platform-apple/app-resolution'); + return await resolveIosSimulatorDeepLinkBundleId(device, openTarget); + } catch { + return undefined; + } +} + +async function resolveAndroidPackage( + device: DeviceInfo, + openTarget: string | undefined, +): Promise { + if (device.platform !== 'android' || !openTarget) return undefined; + try { + return await resolveAndroidPackageForOpen(device, openTarget); + } catch { + return undefined; + } +} + +function bundleIdFromOpenTarget(openTarget: string | undefined): string | undefined { + const trimmed = openTarget?.trim(); + if (!trimmed || isDeepLinkTarget(trimmed) || !trimmed.includes('.')) return undefined; + return trimmed; } /** - * The recovery-gateway half of root composition, same rule as the services above: tests that - * route a stale claim see the real composed gateway, and a test that cares about composition - * passes its own factory. + * The claim-recovery gateway factory port: recovery transactions in daemon suites get a gateway + * that binds nothing and reports every owner unavailable, because the suites assert the + * transaction's decisions (retain, settle, refuse) around the gateway, not what a composed + * platform gateway does on the device. `vi.fn` per call so a suite can count compositions and + * hand its own unavailable bindings where recovery must proceed. */ export function daemonClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory { - return createClaimRecoveryGateway; + return vi.fn(() => unavailableDeviceRuntimeGateway); } diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index d8f52d2688..51717698c0 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -90,17 +90,6 @@ import type { DeviceBootObservation } from '@agent-device/contracts/device-boot' import { AppError } from '@agent-device/kernel/errors'; import { makeSessionStore } from './support/store-factory.ts'; import { inspectDeviceClaims } from '../device/device-claim-inspection.ts'; -import { spawn } from 'node:child_process'; -import crypto from 'node:crypto'; -import { - createOwnedProcessRecordStore, - isProcessAlive, - readProcessCommand, - readProcessStartTime, -} from '@agent-device/host-kit/process'; -import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { screenRecordingDurableResource } from '@agent-device/capture-kit/screen-recording-session-resource'; import { makeAndroidDevice, makeIosDevice, @@ -704,140 +693,6 @@ test('router allows pre-open requests for different devices to proceed concurren expect(maxActiveEnsures).toBe(2); }); -// The dead owner's recorded recorder must still run as an orphan: recovery -// then takes the descriptor-cleanup path — the one that terminates it and -// clears the owned-process store it was composed with. A plain sleeper with -// its real observed command and start time satisfies the exact match. -async function spawnOrphanRecorder() { - const child = spawn('sleep', ['120'], { stdio: 'ignore' }); - await new Promise((resolve) => child.once('spawn', resolve)); - const pid = child.pid ?? 0; - const marker = { - pid, - startTime: readProcessStartTime(pid) ?? '', - command: readProcessCommand(pid) ?? '', - }; - expect(pid).toBeGreaterThan(0); - expect(marker.startTime.length).toBeGreaterThan(0); - expect(marker.command.length).toBeGreaterThan(0); - return { child, marker }; -} - -function ownedStoreFor(stateDir: string) { - const paths = resolveDaemonPaths(stateDir); - return createOwnedProcessRecordStore({ - stateDir: paths.baseDir, - sessionsDir: paths.sessionsDir, - resolveSessionDir: (sessionId) => path.join(paths.sessionsDir, sessionId), - }); -} - -function seedForeignRecordingOwner( - foreignStateDir: string, - device: DeviceInfo, - marker: { pid: number; startTime: string; command: string }, -) { - const foreignSessionDir = path.join(resolveDaemonPaths(foreignStateDir).sessionsDir, 'shared'); - const resourcePath = screenRecordingDurableResource.store.resolvePath(foreignSessionDir); - screenRecordingDurableResource.store.write( - resourcePath, - createDurableResourceEnvelope({ - resourceKind: 'screen-recording', - sessionId: 'shared', - device: { id: device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' }, - owner: { kind: 'local-family', family: 'apple' }, - fence: { token: 'open-recovery-fence', generation: 1 }, - lifecycle: 'open', - descriptor: { - version: 1, - body: { - backend: 'simctl', - outputPath: path.join(foreignSessionDir, 'recording.mp4'), - processes: [marker], - }, - }, - }), - ); - ownedStoreFor(foreignStateDir).replace({ kind: 'session', sessionId: 'shared' }, [ - { ...marker, purpose: 'simctl-screen-recording' }, - ]); - return { foreignSessionDir, resourcePath }; -} - -function seedDeadForeignClaim(claimsDir: string, device: DeviceInfo, foreignStateDir: string) { - const deviceKey = `local:apple:ios:${device.id}`; - fs.writeFileSync( - path.join(claimsDir, `${crypto.createHash('sha256').update(deviceKey).digest('hex')}.json`), - JSON.stringify({ - schemaVersion: 1, - deviceKey, - device: { platform: 'ios', id: device.id, name: device.name, kind: 'simulator' }, - session: 'shared', - workspace: '/worktrees/dead', - stateDir: foreignStateDir, - ownerPid: 999_999_999, - ownerStartTime: 'old-start-time', - ownerToken: 'open-recovery-token', - createdAtMs: 1, - updatedAtMs: 1, - }), - ); -} - -test('open reconciles a foreign dead owner through that owner state dir, never the daemon store', async () => { - // #2168 acquire-path wiring: the router must hand open-time claim - // reconciliation the owner-scoped reconciler. With the caller-scoped - // reconciler this test goes red — the foreign recording recovery runs - // against the daemon's own gateway and store instead of the dead owner's. - const sessionStore = makeSessionStore('agent-device-router-open-recovery-'); - const device = makeIosDevice('SIM-FOREIGN-RECOVERY'); - mockResolveTargetDevice.mockResolvedValue(device); - const claimsDir = mkdtempForTestSync('agent-device-router-open-claims-'); - const foreignStateDir = mkdtempForTestSync('agent-device-router-open-foreign-'); - const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; - process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; - const orphan = await spawnOrphanRecorder(); - - try { - const { foreignSessionDir, resourcePath } = seedForeignRecordingOwner( - foreignStateDir, - device, - orphan.marker, - ); - const daemonStateDir = sessionStore.resolveDaemonStateDir(); - ownedStoreFor(daemonStateDir).replace({ kind: 'session', sessionId: 'shared' }, [ - { pid: process.pid, startTime: 'live-marker', command: 'live-probe', purpose: 'test-probe' }, - ]); - const liveRecordPath = path.join( - resolveDaemonPaths(daemonStateDir).sessionsDir, - 'shared', - 'owned-processes.json', - ); - const liveRecordBefore = fs.readFileSync(liveRecordPath, 'utf8'); - seedDeadForeignClaim(claimsDir, device, foreignStateDir); - - const response = await createOpenHandler(sessionStore)( - openRequest('takeover', { platform: 'ios' }, 'req-open-foreign-recovery'), - ); - - expect(response.ok).toBe(true); - const settled = screenRecordingDurableResource.store.read(resourcePath); - expect(settled).toMatchObject({ status: 'decoded', envelope: { lifecycle: 'completed' } }); - expect(fs.existsSync(path.join(foreignSessionDir, 'owned-processes.json'))).toBe(false); - expect(fs.readFileSync(liveRecordPath, 'utf8')).toBe(liveRecordBefore); - expect(isProcessAlive(orphan.marker.pid)).toBe(false); - expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('takeover'); - } finally { - try { - orphan.child.kill('SIGKILL'); - } catch {} - if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; - else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; - fs.rmSync(claimsDir, { recursive: true, force: true }); - fs.rmSync(foreignStateDir, { recursive: true, force: true }); - } -}); - // Preparation can boot the device an open is returning to, and the claim has to cover that boot: // otherwise the boot an owner caused for itself is what takes the device away from it. The device // boots strictly between the two renewals of one reopen, so only the post-preparation renewal can diff --git a/src/daemon/__tests__/support/host-diagnostics-fixture.ts b/src/daemon/__tests__/support/host-diagnostics-fixture.ts new file mode 100644 index 0000000000..d3065cd189 --- /dev/null +++ b/src/daemon/__tests__/support/host-diagnostics-fixture.ts @@ -0,0 +1,20 @@ +import { vi } from 'vitest'; +import type { DoctorCheck } from '@agent-device/contracts/observability'; +import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; + +/** + * The `HostDiagnostics` port as the daemon suites need it: the doctor route's decisions — which + * probe runs for which device, how probe results are appended, ordered, and shaped into check + * lines, and what policy admits — are daemon code; the toolchain probes themselves belong to the + * platform packages. Suite code overrides the single probe it cares about or asserts on the + * spy's call arguments, exactly as production wiring replaces one composed capability. + */ +export function hostDiagnosticsFixture(overrides: Partial = {}): HostDiagnostics { + return Object.freeze({ + toolchainCheck: vi.fn(async () => undefined), + deviceChecks: vi.fn(async () => [] as readonly DoctorCheck[]), + ambientChecks: vi.fn(async () => [] as readonly DoctorCheck[]), + warmupCheck: vi.fn(async () => undefined), + ...overrides, + }); +} diff --git a/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts b/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts new file mode 100644 index 0000000000..ba75c34723 --- /dev/null +++ b/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts @@ -0,0 +1,62 @@ +import { localRuntimeOwner, type DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit/runtime-operation-facts'; +import type { + BindDeviceRuntime, + BindExactDeviceRuntime, +} from '../../request-runtime-binding.ts'; +import { narrowDeviceBinding } from '@agent-device/contracts/platform-runtime'; + +/** + * The `DeviceRuntimeGateway` port answering that no owner on this host can bind the device: + * every operation fact unavailable, no operations, nothing to dispose. + */ +export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway = + Object.freeze({ + inspectFacts: async (device) => (await unavailableBinding(device)).facts, + bind: async ({ device }) => { + const owner = localRuntimeOwner(device.platform); + return { + device, + owner, + facts: createUnavailableRuntimeFactsForTest(device, owner), + operations: {}, + [Symbol.asyncDispose]: async () => {}, + }; + }, + shutdown: async () => {}, + }); + +export async function unavailableBinding(device: DeviceInfo) { + return await unavailableDeviceRuntimeGateway.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); +} + +export const unavailableBindDevice: BindDeviceRuntime = async (device, use) => + narrowDeviceBinding(await unavailableBinding(device), use); + +export const unavailableInspectFacts = unavailableDeviceRuntimeGateway.inspectFacts; + +export const unavailableBindExactDevice: BindExactDeviceRuntime = async ( + device, + owner, + fence, + use, + scope, +) => + narrowDeviceBinding( + await unavailableDeviceRuntimeGateway.bind({ + device, + intent: { kind: 'exact-owner', owner, fence }, + scope, + }), + use, + ); diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index a51d2fec58..adbb308fbb 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -18,16 +18,18 @@ import { createRequestHandler as createProductionRequestHandler, type RequestRouterDeps, } from '../request-router.ts'; -import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; +import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { applicationLifecycleRuntimeFixture } from './application-lifecycle-runtime-fixture.ts'; import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit/runtime-operation-facts'; import { withClientReplayScriptSources } from '@agent-device/replay-port/replay-script-source-fixtures'; import type { DaemonInvokeFn } from '../daemon-request.ts'; import { clearAndroidObservationFixture } from './android-observation-fixture.ts'; -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; -import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts'; -import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; +import { + daemonClaimRecoveryGatewayFixture, + daemonPlatformServicesFixture, +} from './platform-services-fixture.ts'; +import { platformResourceCleanupFixture } from './support/platform-resource-cleanup-fixture.ts'; import { providerCredentialsFixture } from './support/provider-credentials-fixture.ts'; const unavailable = Object.freeze({ @@ -111,21 +113,21 @@ export function createLifecycleDeviceRuntimeGatewaySpies() { return { gateway, inspectFacts, bind }; } -export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway = - Object.freeze({ - inspectFacts: async (device) => (await unavailableBinding(device)).facts, - bind: async ({ device }) => { - const owner = localRuntimeOwner(device.platform); - return { - device, - owner, - facts: createUnavailableRuntimeFactsForTest(device, owner), - operations: {}, - [Symbol.asyncDispose]: async () => {}, - }; - }, - shutdown: async () => {}, - }); +import { + unavailableBinding, + unavailableBindDevice, + unavailableBindExactDevice, + unavailableDeviceRuntimeGateway, + unavailableInspectFacts, +} from './support/unavailable-device-runtime-gateway.ts'; + +export { + unavailableBinding, + unavailableBindDevice, + unavailableBindExactDevice, + unavailableDeviceRuntimeGateway, + unavailableInspectFacts, +}; /** * Spies for the gesture surface a router-level test drives. They replace the retired @@ -190,38 +192,6 @@ async function gestureBinding(device: DeviceInfo) { } as unknown as Awaited['bind']>>; } -async function unavailableBinding(device: DeviceInfo) { - return await unavailableDeviceRuntimeGateway.bind({ - device, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); -} - -export const unavailableBindDevice: BindDeviceRuntime = async (device, use) => - narrowDeviceBinding(await unavailableBinding(device), use); - -export const unavailableInspectFacts = unavailableDeviceRuntimeGateway.inspectFacts; - -export const unavailableBindExactDevice: BindExactDeviceRuntime = async ( - device, - owner, - fence, - use, - scope, -) => - narrowDeviceBinding( - await unavailableDeviceRuntimeGateway.bind({ - device, - intent: { kind: 'exact-owner', owner, fence }, - scope, - }), - use, - ); export function createRequestHandler( deps: Omit< @@ -239,12 +209,12 @@ export function createRequestHandler( deviceRuntimeGateway = unavailableDeviceRuntimeGateway, providerCredentials = providerCredentialsFixture({}, path.dirname(deps.logPath)), platformServices = daemonPlatformServicesFixture(), - claimRecoveryGateway = createClaimRecoveryGateway, + claimRecoveryGateway = daemonClaimRecoveryGatewayFixture(), ...rest } = deps; const handle = createProductionRequestHandler({ androidObservation: clearAndroidObservationFixture, - platformResourceCleanup, + platformResourceCleanup: platformResourceCleanupFixture(), ...rest, platformServices, claimRecoveryGateway, diff --git a/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts b/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts index b7338acf9d..3181acc0de 100644 --- a/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts @@ -15,7 +15,7 @@ import { import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit/runtime-operation-facts'; import { handleDoctorCommand } from '../session-doctor.ts'; -import { createHostDiagnostics } from '../../../platform-runtime-host-diagnostics.ts'; +import { hostDiagnosticsFixture } from '../../__tests__/support/host-diagnostics-fixture.ts'; vi.mock('../session-doctor-device.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -45,7 +45,7 @@ test('doctor app checks retain runtime admission failures as a failed target-app }); const response = await handleDoctorCommand({ - hostDiagnostics: createHostDiagnostics(), + hostDiagnostics: hostDiagnosticsFixture(), req: { token: 't', session: sessionName, @@ -143,7 +143,7 @@ test('doctor preserves the legacy unsupported HarmonyOS target-app check', async }); const response = await handleDoctorCommand({ - hostDiagnostics: createHostDiagnostics(), + hostDiagnostics: hostDiagnosticsFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 9c624929f1..e2a81c0a50 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -549,56 +549,6 @@ test('close on macOS session stops runner and dismisses automation alert before expect(sessionStore.get(sessionName)).toBe(undefined); }); -test('close on iOS simulator session retains runner and deletes the session', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-simulator-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - appName: 'com.example.app', - }), - ); - - const response = await createHandler(sessionStore)(sessionRequest(sessionName, 'close')); - - expect(response.ok).toBe(true); - expect(mockStopIosRunner).not.toHaveBeenCalled(); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: true }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('close on iOS simulator with scoped simulator set retains runner and deletes the session', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-scoped-simulator-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - simulatorSetPath: '/tmp/tenant-a/simulator-set', - }, - appName: 'com.example.app', - }), - ); - - const response = await createHandler(sessionStore)(sessionRequest(sessionName, 'close')); - - expect(response.ok).toBe(true); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: true }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - test('close on leased iOS simulator session stops runner before deleting session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-leased-simulator-session'; @@ -706,43 +656,6 @@ test('close on iOS stops runner before app close dispatch and performs fin expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'release-runner:stop']); }); -test('close on iOS simulator retains runner while terminating app', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-simulator-close-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - appName: 'com.example.app', - }), - ); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - mockReleaseRunnerOnClose.mockImplementation(async (_deviceId, options) => { - calls.push(`release-runner:${options.retain ? 'retain' : 'stop'}`); - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${(positionals ?? []).join(' ')}`); - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'close', { positionals: ['com.example.app'] }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual(['close:com.example.app', 'release-runner:retain']); -}); - test('app-only close terminates an iOS simulator app without ending its session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-simulator-app-only-close'; From 4d8d80e2c7ddc219ea0d70f3ddefd636b27e4bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 00:33:13 +0200 Subject: [PATCH 02/23] refactor(daemon): fake the application-lifecycle runtime-binding port in daemon suites The daemon lane now binds a canned lifecycle fixture built on the contracts direct-lifecycle binding plus port spies (readiness pause, android discovery, dispatch, hint apply/clear, runner prepare). The real composed fixture moves to the root-owned daemon-composition lane, built on the same seam set so both lanes observe one spy family. Hint-observation suites retarget to the fixture's port spies (assertion bodies unchanged); suites whose assertions pin what the real apple/android runtime modules do under composition relocate: session-relaunch-close and session-open-url-prewarm whole-file, and the close-finalization, shutdown-gating, recording-finalization, post-open observation, runtime-hint lazy-evaluation, and android IME cancellation cases as extracted composition siblings. The gateway and session-command harness take an injectable lifecycle-operations loader so composition suites build the real gateway behind the same handler harness. Scan for daemon -> non-package src/ edges: 0. --- .../application-lifecycle-runtime-fixture.ts | 186 ++++++++++++ .../install-real-lifecycle-harness.ts | 8 + .../session-close-runner-retention.test.ts | 42 ++- ...session-device-claims-cancellation.test.ts | 143 +++++++++ .../handlers}/session-relaunch-close.test.ts | 34 ++- .../lifecycle-runtime-gateway.ts | 17 ++ ...request-router-open-claim-recovery.test.ts | 4 +- .../request-router-replay-test-ime.test.ts | 8 +- .../session-close-error-precedence.test.ts | 144 +++++++++ .../session-close-runtime-hints.test.ts | 248 ++++++++++++++++ .../session-close-save-script.test.ts | 99 +++++++ .../session-close-shutdown-platform.test.ts | 101 +++++++ ...session-open-post-open-observation.test.ts | 149 ++++++++++ .../session-open-url-prewarm.test.ts | 34 ++- .../session-teardown-resources.test.ts | 95 ++++++ .../application-lifecycle-runtime-fixture.ts | 277 +++++++----------- .../__tests__/platform-services-fixture.ts | 22 +- .../unavailable-device-runtime-gateway.ts | 12 +- .../__tests__/test-device-runtime-gateway.ts | 39 ++- .../__tests__/session-command-harness.ts | 27 +- .../__tests__/session-device-claims.test.ts | 43 +-- .../__tests__/session-test-harness.ts | 41 +-- .../session-close-error-precedence.test.ts | 117 -------- .../session-close-lifecycle-runtime.test.ts | 180 ------------ .../session-close-save-script.test.ts | 79 ----- .../session-close-shutdown-platform.test.ts | 80 ----- .../session-open-execution-runtime.test.ts | 107 +------ .../__tests__/session-open-runtime.test.ts | 17 +- .../session-teardown-resources.test.ts | 78 ----- .../command-coverage/declarations.ts | 2 +- 30 files changed, 1470 insertions(+), 963 deletions(-) create mode 100644 src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts create mode 100644 src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts create mode 100644 src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts rename src/{daemon/handlers/__tests__ => __tests__/daemon-composition/handlers}/session-relaunch-close.test.ts (95%) create mode 100644 src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts create mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-close-error-precedence.test.ts create mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-close-runtime-hints.test.ts create mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-close-save-script.test.ts create mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-close-shutdown-platform.test.ts create mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts rename src/{daemon/session-lifecycle/internal/__tests__ => __tests__/daemon-composition/session-lifecycle}/session-open-url-prewarm.test.ts (95%) create mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts diff --git a/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts new file mode 100644 index 0000000000..947b582345 --- /dev/null +++ b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts @@ -0,0 +1,186 @@ +import type { ApplicationLifecycleRuntimeOperations } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { DeviceBinding } from '@agent-device/contracts/platform-runtime'; +import type { + PlatformRuntimeHost, + PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { TargetShutdownResult } from '@agent-device/contracts/device'; +// The lifecycle seams (dispatch spy, readiness pauses, fixture interactor) have ONE owner: the +// daemon's canned lifecycle fixture. This real-composition fixture builds the REAL root gateway +// and platform application tools on top of the same seams, so every lane observes one spy set. +export { + awaitFixtureReadiness, + discoverReadyAndroidEmulators, + dispatchApplicationLifecycleEffect, +} from '../../daemon/__tests__/application-lifecycle-runtime-fixture.ts'; +import { + applicationLifecycleFixtureInteractor, + awaitFixtureReadiness, + discoverReadyAndroidEmulators, +} from '../../daemon/__tests__/application-lifecycle-runtime-fixture.ts'; +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import { createAndroidApplicationTools } from '../../platform-runtime-android-application-tools.ts'; +import { createAppleApplicationTools } from '../../platform-runtime-apple-application-tools.ts'; +import { createComposedPlatformRuntimeGateway } from '../../platform-runtime-gateway.ts'; +import { platformRuntimeModules } from '../../platform-runtime.ts'; + +export async function applicationLifecycleRuntimeFixture( + device: DeviceInfo, + signal: AbortSignal = new AbortController().signal, + shutdownTarget: (device: DeviceInfo) => Promise = async () => ({ + success: true, + exitCode: 0, + stdout: '', + stderr: '', + }), +): Promise { + const gateway = createComposedPlatformRuntimeGateway({ + modules: platformRuntimeModules, + loadHost: async () => fixtureHost(device, shutdownTarget), + }); + const binding = await gateway.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { signal, diagnostics: { emit: () => {} }, progress: { report: () => {} } }, + }); + return lifecycleOperations(binding); +} + +function lifecycleOperations( + binding: DeviceBinding, +): ApplicationLifecycleRuntimeOperations { + const operations = binding.operations; + const keys = [ + 'resolveOpenTarget', + 'prepareApplicationOpen', + 'openApplication', + 'applyRuntimeHints', + 'clearRuntimeHints', + 'closeApplication', + 'finalizeApplicationClose', + 'prepareAppleRunner', + 'configureProviderPortReverse', + ] as const satisfies readonly (keyof ApplicationLifecycleRuntimeOperations)[]; + const selected = Object.fromEntries( + keys.flatMap((key) => { + const operation = operations[key]; + return operation ? [[key, operation]] : []; + }), + ); + return Object.freeze(selected) as ApplicationLifecycleRuntimeOperations; +} + +function fixtureHost( + readyDevice: DeviceInfo, + shutdownTarget: (device: DeviceInfo) => Promise, +): PlatformRuntimeHost { + let simulatorState = readyDevice.booted === false ? 'Shutdown' : 'Booted'; + const localInteractors = Object.freeze({ + resolve: async (device: DeviceInfo, runner: RunnerContext): Promise => + applicationLifecycleFixtureInteractor(device, runner), + }); + return { + localInteractors, + appleApplications: createAppleApplicationTools(), + androidApplications: createAndroidApplicationTools(), + deviceShutdown: Object.freeze({ + apple: Object.freeze({ + shutdownTarget: async (device: DeviceInfo) => await shutdownTarget(device), + }), + android: Object.freeze({ + shutdownTarget: async (device: DeviceInfo) => await shutdownTarget(device), + }), + close: Object.freeze({ + canShutdownTarget: async (device: DeviceInfo) => + (device.platform === 'apple' && device.kind === 'simulator') || + (device.platform === 'android' && device.kind === 'emulator'), + shutdownTarget: async (device: DeviceInfo) => await shutdownTarget(device), + }), + }), + // Readiness is package-owned; these handler fixtures exercise lifecycle dispatch, so the + // narrow ports report an already-ready device instead of booting anything. + deviceReadiness: Object.freeze({ + applePhysical: Object.freeze({ + ensureConnected: async (_device: DeviceInfo, signal: AbortSignal) => { + signal.throwIfAborted(); + }, + }), + appleAutomation: Object.freeze({ + keepHot: () => undefined, + markBooted: () => {}, + wasRecentlyObservedBooted: async () => false, + }), + androidEmulator: Object.freeze({ + discover: async (_request: unknown, signal: AbortSignal) => { + signal.throwIfAborted(); + await awaitFixtureReadiness(readyDevice); + return await discoverReadyAndroidEmulators(readyDevice); + }, + launch: () => 1, + terminate: async () => undefined, + }), + }), + // Readiness reads the device's own boot state, so a `booted: false` fixture still exercises + // the cold-boot path (and its runner-cache prewarm hook) before settling. + appleTools: Object.freeze({ + isXcrunAvailable: async () => true, + run: async (request: Readonly<{ args: readonly string[] }>) => { + await awaitFixtureReadiness(readyDevice); + if (request.args.includes('boot')) { + simulatorState = 'Booted'; + return { stdout: '', stderr: '', exitCode: 0 }; + } + return { + stdout: JSON.stringify({ + devices: { fixture: [{ udid: readyDevice.id, state: simulatorState }] }, + }), + stderr: '', + exitCode: 0, + }; + }, + }), + commands: Object.freeze({ + which: async (executable: string) => executable, + run: async () => ({ stdout: '1', stderr: '', exitCode: 0 }), + }), + toolchains: Object.freeze({ prepare: async () => undefined }), + clock: Object.freeze({ + now: () => Date.now(), + sleep: async () => undefined, + }), + processTransports: Object.freeze({ + resolve: async () => ({ mode: 'local' as const }), + }), + networkTransports: Object.freeze({ + resolve: async () => ({ mode: 'local' as const }), + }), + screenRecording: Object.freeze({ + apple: Object.freeze({ + availability: async () => ({ + available: false as const, + hint: 'Screen recording is outside this lifecycle fixture.', + }), + }), + android: Object.freeze({ resolve: async () => ({}) }), + web: Object.freeze({ resolve: async () => undefined }), + }), + audioProbe: Object.freeze({ + hostCapture: Object.freeze({ + info: Object.freeze({ + source: 'system-audio' as const, + backend: 'fixture', + sourceCount: 0, + notes: () => [], + }), + start: async () => { + throw new Error('Audio probe is outside this lifecycle fixture.'); + }, + inspectProcess: async () => 'missing' as const, + terminateProcess: async () => 'already-missing' as const, + }), + web: Object.freeze({ resolve: async () => undefined }), + ownedProcesses: Object.freeze({ replace: () => {}, clear: () => {} }), + }), + } as unknown as PlatformRuntimeHost; +} diff --git a/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts b/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts new file mode 100644 index 0000000000..24f32b7e72 --- /dev/null +++ b/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts @@ -0,0 +1,8 @@ +import { setHarnessLifecycleOperationsLoader } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; +import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; + +// Composition suites bind the REAL composed lifecycle (root gateway + apple/android application +// tools) behind the daemon session-command harness. Import this module before any module that +// captures the harness's `handleSessionCommands`: vitest evaluates this file's module graph first, +// and every test file gets its own module instance, so the install cannot leak into daemon files. +setHarnessLifecycleOperationsLoader(applicationLifecycleRuntimeFixture); diff --git a/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts b/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts index c9a9a83e14..22f374d089 100644 --- a/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts +++ b/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts @@ -54,15 +54,13 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; }); -import { - createRequestHandler, - lifecycleDeviceRuntimeGateway, -} from '../../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { createRequestHandler } from '../../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { realLifecycleDeviceRuntimeGateway } from '../lifecycle-runtime-gateway.ts'; import { dispatchApplicationLifecycleEffect, awaitFixtureReadiness, discoverReadyAndroidEmulators, -} from '../../../daemon/__tests__/application-lifecycle-runtime-fixture.ts'; +} from '../application-lifecycle-runtime-fixture.ts'; import { stopIosRunnerSession, releaseIosRunnerOnClose, @@ -102,7 +100,7 @@ function createHandler(sessionStore: ReturnType) { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), - deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + deviceRuntimeGateway: realLifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), platformResourceCleanup, trackDownloadableArtifact: () => 'artifact-id', @@ -137,11 +135,11 @@ test('close on iOS simulator session retains runner and deletes the session', as sessionName, makeSession(sessionName, { device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, }, appName: 'com.example.app', }), @@ -162,12 +160,12 @@ test('close on iOS simulator with scoped simulator set retains runner and delete sessionName, makeSession(sessionName, { device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - simulatorSetPath: '/tmp/tenant-a/simulator-set', + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + simulatorSetPath: '/tmp/tenant-a/simulator-set', }, appName: 'com.example.app', }), @@ -187,11 +185,11 @@ test('close on iOS simulator retains runner while terminating app', async sessionName, makeSession(sessionName, { device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, }, appName: 'com.example.app', }), diff --git a/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts new file mode 100644 index 0000000000..84681cf7b8 --- /dev/null +++ b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts @@ -0,0 +1,143 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; + +vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { + const actual = + await importOriginal(); + const { selectionFromResolveTargetDevice } = + await import('../../../daemon/__tests__/device-selection-stub.ts'); + const resolveTargetDevice = vi.fn(); + return { + ...actual, + resolveTargetDevice, + resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(resolveTargetDevice)), + }; +}); +vi.mock('../../../daemon/device/device-ready.ts', () => ({ + ensureDeviceReady: vi.fn(async () => {}), +})); +vi.mock('@agent-device/platform-android/mechanics', () => ({ + activateAndroidTestIme: vi.fn(async () => ({ activated: false })), + restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })), + stopAndroidSnapshotHelperSessionForDevice: vi.fn(async () => {}), + resolveAndroidPackageForOpen: vi.fn(async () => undefined), + inferAndroidPackageAfterOpen: vi.fn( + async (_device, _target, currentAppBundleId) => currentAppBundleId, + ), +})); +vi.mock('@agent-device/host-kit/process', async (importOriginal) => + (await import('@agent-device/test-kit/host-process-mock')).pinOwnProcessStartTime(importOriginal), +); + +import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; +import { ensureDeviceReady } from '../../../daemon/device/device-ready.ts'; +import { + activateAndroidTestIme, + resolveAndroidPackageForOpen, +} from '@agent-device/platform-android/mechanics'; +import { clearRequestCanceled, markRequestCanceled } from '@agent-device/host-kit/request'; +import { inspectDeviceClaims } from '../../../daemon/device/device-claim-inspection.ts'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { BindDeviceRuntime } from '../../../daemon/request-runtime-binding.ts'; +import { + applicationLifecycleRuntimeFixture, + dispatchApplicationLifecycleEffect, +} from '../application-lifecycle-runtime-fixture.ts'; +import { SessionStore } from '../../../daemon/session-store.ts'; +import { handleSessionOpenCommands as handleProductionOpenCommand } from '../../../daemon/session-lifecycle/index.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { inspectLifecycleRuntimeFacts } from '../../../daemon/__tests__/application-lifecycle-runtime-harness.ts'; + +const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); +const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); +const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); +const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); +const reconcileOrphanedDeviceClaim = async () => ({ + status: 'retained' as const, + reason: 'test-no-recovery', +}); + +// The post-open test-IME activation is the real Android lifecycle's sequencing step after the +// open dispatch — the daemon-lane canned binding performs no IME leg, so this claim-retention +// case runs in the composition lane on the real composed lifecycle. +async function realLifecycleBinding( + device: DeviceInfo, +): Promise> { + return { + device, + owner: localRuntimeOwner(device.platform), + facts: await inspectLifecycleRuntimeFacts(device), + operations: await applicationLifecycleRuntimeFixture(device), + [Symbol.asyncDispose]: async () => {}, + }; +} + +const bindRealLifecycleRuntime: BindDeviceRuntime = async (device, use) => + narrowDeviceBinding(await realLifecycleBinding(device), use); + +afterEach(() => { + vi.clearAllMocks(); + mockEnsureDeviceReady.mockResolvedValue(undefined); + mockResolveAndroidPackage.mockResolvedValue(undefined); +}); + +function setup(): { store: SessionStore; stateDir: string } { + const stateDir = mkdtempForTestSync('agent-device-session-device-claim-'); + const claimsDir = path.join(stateDir, 'claims'); + process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + return { store: new SessionStore(path.join(stateDir, 'sessions')), stateDir }; +} + +const android: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +test('cancellation after local device setup retains the device claim for recovery', async () => { + const { store, stateDir } = setup(); + const requestId = 'claim-canceled-after-dispatch'; + mockResolveTargetDevice.mockResolvedValue(android); + mockDispatch.mockResolvedValue(undefined); + markRequestCanceled(requestId); + + try { + const response = await handleProductionOpenCommand({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + command: 'open', + token: 'test', + session: 'claim-canceled-after-dispatch', + positionals: ['Demo'], + flags: { platform: 'android' }, + meta: { requestId }, + }, + sessionName: 'claim-canceled-after-dispatch', + logPath: path.join(stateDir, 'daemon.log'), + sessionStore: store, + inspectFacts: inspectLifecycleRuntimeFacts, + bindDevice: bindRealLifecycleRuntime, + reconcileOrphanedDeviceClaim, + }); + + assert.equal(response.ok, false); + assert.equal(mockDispatch.mock.calls.length, 1); + assert.equal(vi.mocked(activateAndroidTestIme).mock.calls.length, 1); + assert.equal(inspectDeviceClaims({ serial: android.id })[0]?.classification, 'live'); + assert.equal(store.get('claim-canceled-after-dispatch'), undefined); + } finally { + clearRequestCanceled(requestId); + } +}); diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts similarity index 95% rename from src/daemon/handlers/__tests__/session-relaunch-close.test.ts rename to src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts index e2a81c0a50..680a1d8dfb 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts @@ -1,23 +1,25 @@ import { test, expect, vi, beforeEach } from 'vitest'; import * as path from 'node:path'; -import { LeaseRegistry } from '../../lease-registry.ts'; -import { providerDeviceAdmissionFixture } from '../../__tests__/support/provider-device-admission-fixture.ts'; -import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; +import { LeaseRegistry } from '../../../daemon/lease-registry.ts'; +import { providerDeviceAdmissionFixture } from '../../../daemon/__tests__/support/provider-device-admission-fixture.ts'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; // The daemon reads provider ownership through its own typed admission seam; these suites // install the seam over runtime fakes, the way root composition installs the real one. const providerAdmission = providerDeviceAdmissionFixture(); import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; -import { makeSession } from '../../__tests__/support/session-factories.ts'; -import { getResolveTargetDeviceMock } from '../../__tests__/request-router-dispatch-mocks.ts'; -import type { DaemonRequest } from '../../daemon-request.ts'; +import { makeSessionStore } from '../../../daemon/__tests__/support/store-factory.ts'; +import { makeSession } from '../../../daemon/__tests__/support/session-factories.ts'; +import { getResolveTargetDeviceMock } from '../../../daemon/__tests__/request-router-dispatch-mocks.ts'; +import type { DaemonRequest } from '../../../daemon/daemon-request.ts'; vi.mock('node:timers/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, setTimeout: vi.fn(async () => undefined) }; }); -vi.mock('../../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../../../daemon/device/device-ready.ts', () => ({ + ensureDeviceReady: vi.fn(async () => {}), +})); vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = await importOriginal(); @@ -55,16 +57,15 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; }); -import { - createRequestHandler, - lifecycleDeviceRuntimeGateway, -} from '../../__tests__/test-device-runtime-gateway.ts'; +import { createRequestHandler } from '../../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { realLifecycleDeviceRuntimeGateway } from '../lifecycle-runtime-gateway.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import { dispatchApplicationLifecycleEffect, awaitFixtureReadiness, discoverReadyAndroidEmulators, -} from '../../__tests__/application-lifecycle-runtime-fixture.ts'; -import { ensureDeviceReady } from '../../device/device-ready.ts'; +} from '../application-lifecycle-runtime-fixture.ts'; +import { ensureDeviceReady } from '../../../daemon/device/device-ready.ts'; import { prewarmIosRunnerSession, notifyIosRunnerAppRelaunched, @@ -72,7 +73,7 @@ import { releaseIosRunnerOnClose, } from '@agent-device/platform-apple/runner/operations'; import { runMacOsAlertAction } from '@agent-device/platform-apple/macos'; -import { refFrameState } from '../../ref-frame.ts'; +import { refFrameState } from '../../../daemon/ref-frame.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); @@ -123,7 +124,8 @@ function createHandler( token: 'test-token', sessionStore, leaseRegistry, - deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + deviceRuntimeGateway: realLifecycleDeviceRuntimeGateway, + platformResourceCleanup, deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts b/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts new file mode 100644 index 0000000000..8233ef4fdb --- /dev/null +++ b/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts @@ -0,0 +1,17 @@ +import { + createLifecycleDeviceRuntimeGateway, + createLifecycleDeviceRuntimeGatewaySpies, +} from '../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { applicationLifecycleRuntimeFixture } from './application-lifecycle-runtime-fixture.ts'; + +/** + * The lifecycle gateway over the REAL composed platform runtime (root gateway + apple/android + * application tools). Composition suites that pin what the real runtime modules answer bind this; + * daemon suites bind the canned port fixture instead. + */ +export const realLifecycleDeviceRuntimeGateway = createLifecycleDeviceRuntimeGateway( + applicationLifecycleRuntimeFixture, +); + +export const createRealLifecycleDeviceRuntimeGatewaySpies = () => + createLifecycleDeviceRuntimeGatewaySpies(applicationLifecycleRuntimeFixture); diff --git a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts index 9e127e97a7..6ac745bea6 100644 --- a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts +++ b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts @@ -20,7 +20,9 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; providerDeviceAdmissionFixture(); -vi.mock('../../daemon/device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../../daemon/device/device-ready.ts', () => ({ + ensureDeviceReady: vi.fn(async () => {}), +})); vi.mock('@agent-device/host-kit/process', async (importOriginal) => { const actual = await importOriginal(); const startTime = 'test-process-start'; diff --git a/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts b/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts index 8f02895b41..f967106802 100644 --- a/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts +++ b/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts @@ -40,10 +40,8 @@ import { ANDROID_DEVICE } from '@agent-device/test-kit/device-fixtures'; import { makeSessionStore } from '../../daemon/__tests__/support/store-factory.ts'; import { replayScriptSourceBundleFor } from '@agent-device/replay-port/replay-script-source-fixtures'; import { LeaseRegistry } from '../../daemon/lease-registry.ts'; -import { - createRequestHandler, - lifecycleDeviceRuntimeGateway, -} from '../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { createRequestHandler } from '../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { realLifecycleDeviceRuntimeGateway } from './lifecycle-runtime-gateway.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockActivateAndroidTestIme = vi.mocked(activateAndroidTestIme); @@ -70,7 +68,7 @@ async function runAndroidFlowReplay(session: string, flags: Record 'artifact-id', }); 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 new file mode 100644 index 0000000000..f3ec6c17b6 --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-close-error-precedence.test.ts @@ -0,0 +1,144 @@ +import '../handlers/install-real-lifecycle-harness.ts'; +import { beforeEach, expect, test } from 'vitest'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { + sessionCloseShutdownFixture, + type SessionState, +} from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; + +const { + AppError, + handleSessionCommands, + makeIosSimulatorRecordingSession, + makeSession, + makeSessionStore, + mkdtempForTestSync, + mockDispatchCommand, + mockReleaseRunnerOnClose, + mockStopIosRunnerSession, + noopInvoke, + path, + resetSessionCloseShutdownMocks, +} = sessionCloseShutdownFixture; + +// 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: daemonPlatformServicesFixture(), + 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 own two internal calls (pre-close stop, then the later + // independent-cleanup retry) — a persistent `mockRejectedValue` here would leak into every + // later test that exercises an Apple-platform runner stop, since `vi.clearAllMocks()` in + // `beforeEach` clears call history but not a mock's implementation. + mockStopIosRunnerSession.mockRejectedValueOnce(preCloseError); + + await expect( + handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + 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 new file mode 100644 index 0000000000..4c9f83d5f0 --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-close-runtime-hints.test.ts @@ -0,0 +1,248 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import path from 'node:path'; +import { SessionStore } from '../../../daemon/session-store.ts'; +import type { DaemonRequest, DaemonResponse } from '../../../daemon/daemon-request.ts'; +import type { SessionState } from '../../../daemon/session-state.ts'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; + +// 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 { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import '../handlers/install-real-lifecycle-harness.ts'; +import { + handleSessionCommands, + mockBindDeviceRuntime, + mockInspectDeviceRuntimeFacts, +} from '../../../daemon/handlers/__tests__/session-command-harness.ts'; +import { lifecycleRuntimeFacts } from '../../../daemon/__tests__/application-lifecycle-runtime-harness.ts'; +import { dispatchApplicationLifecycleEffect } from '../application-lifecycle-runtime-fixture.ts'; +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: daemonPlatformServicesFixture(), + req: closeRequest(params.sessionName, params.positionals ?? [], params.internal), + sessionName: params.sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore: params.sessionStore, + invoke: noopInvoke, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + 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 new file mode 100644 index 0000000000..34fc8a596f --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-close-save-script.test.ts @@ -0,0 +1,99 @@ +import '../handlers/install-real-lifecycle-harness.ts'; +import { beforeEach, expect, test } from 'vitest'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; + +const { + fs, + handleSessionCommands, + makeIosSimulatorRecordingSession, + makeSessionStore, + mockReleaseRunnerOnClose, + mockStopIosRunnerSession, + noopInvoke, + path, + recordingFinishMock, + resetSessionCloseShutdownMocks, +} = sessionCloseShutdownFixture; + +// 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: daemonPlatformServicesFixture(), + 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: daemonPlatformServicesFixture(), + 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 new file mode 100644 index 0000000000..d087f441e5 --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-close-shutdown-platform.test.ts @@ -0,0 +1,101 @@ +import '../handlers/install-real-lifecycle-harness.ts'; +import { beforeEach, expect, test } from 'vitest'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; + +const { + handleSessionCommands, + makeSession, + makeSessionStore, + mockShutdownTargetRuntime, + noopInvoke, + path, + resetSessionCloseShutdownMocks, +} = sessionCloseShutdownFixture; + +// 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: daemonPlatformServicesFixture(), + 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: daemonPlatformServicesFixture(), + 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-open-post-open-observation.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts new file mode 100644 index 0000000000..fa7e79db0a --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts @@ -0,0 +1,149 @@ +import '../handlers/install-real-lifecycle-harness.ts'; +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('../../../daemon/__tests__/device-selection-stub.ts'); + return { + ...actual, + resolveTargetDevice: mockResolveTargetDevice, + resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(mockResolveTargetDevice)), + }; +}); +vi.mock('../../../daemon/device/device-ready.ts', () => ({ + 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 { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { + handleSessionCommands, + mockBindDeviceRuntime, +} from '../../../daemon/handlers/__tests__/session-command-harness.ts'; +import { dispatchApplicationLifecycleEffect } from '../application-lifecycle-runtime-fixture.ts'; +import { pendingSurfaceStabilization } from '../../../daemon/deferred-interaction-outcome.ts'; +import { + makeAppleSimulator, + makeSessionStore, + noopInvoke, +} from '../../../daemon/session-lifecycle/internal/__tests__/session-open-runtime.fixtures.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; + +const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); + +// These cases pin how the REAL Apple open reports app observation in its timing (postOpenObservation) +// and stages runner prewarm after the open, and how the daemon records the settle consequence. +// The canned port fixture returns no observation timing and no runner staging, so they run in the +// composition lane. +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) => { + const binding = await bindDefault(device, use); + 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: daemonPlatformServicesFixture(), + 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/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-open-url-prewarm.test.ts similarity index 95% rename from src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts rename to src/__tests__/daemon-composition/session-lifecycle/session-open-url-prewarm.test.ts index ecde5dd772..180cfe4486 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts +++ b/src/__tests__/daemon-composition/session-lifecycle/session-open-url-prewarm.test.ts @@ -1,13 +1,15 @@ import { test, expect, vi, beforeEach } from 'vitest'; import * as path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; vi.mock('node:timers/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, setTimeout: vi.fn(async () => undefined) }; }); -vi.mock('../../../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../../../daemon/device/device-ready.ts', () => ({ + ensureDeviceReady: vi.fn(async () => {}), +})); vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = await importOriginal(); @@ -42,18 +44,19 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; }); +import { createRequestHandler } from '../../../daemon/__tests__/test-device-runtime-gateway.ts'; import { - createLifecycleDeviceRuntimeGatewaySpies, - createRequestHandler, - lifecycleDeviceRuntimeGateway, -} from '../../../__tests__/test-device-runtime-gateway.ts'; -import { dispatchApplicationLifecycleEffect } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; -import { getResolveTargetDeviceMock } from '../../../__tests__/request-router-dispatch-mocks.ts'; + 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 '../../../daemon/__tests__/request-router-dispatch-mocks.ts'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; -import { makeSession } from '../../../__tests__/support/session-factories.ts'; -import { LeaseRegistry } from '../../../lease-registry.ts'; -import type { DaemonRequest } from '../../../daemon-request.ts'; +import { makeSessionStore } from '../../../daemon/__tests__/support/store-factory.ts'; +import { makeSession } from '../../../daemon/__tests__/support/session-factories.ts'; +import { LeaseRegistry } from '../../../daemon/lease-registry.ts'; +import type { DaemonRequest } from '../../../daemon/daemon-request.ts'; import { prepareIosRunner, prewarmAppleRunnerCache, @@ -100,7 +103,7 @@ beforeEach(() => { function createHandler( sessionStore: ReturnType, - deviceRuntimeGateway = lifecycleDeviceRuntimeGateway, + deviceRuntimeGateway = realLifecycleDeviceRuntimeGateway, ) { return createRequestHandler({ createCommandSurface: fakeCreateCommandSurface, @@ -109,6 +112,7 @@ function createHandler( sessionStore, leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway, + platformResourceCleanup, deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -585,7 +589,7 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', kind: 'simulator', booted: true, }); - const { gateway, inspectFacts, bind } = createLifecycleDeviceRuntimeGatewaySpies(); + const { gateway, inspectFacts, bind } = createRealLifecycleDeviceRuntimeGatewaySpies(); const response = await createHandler( sessionStore, @@ -770,7 +774,7 @@ test('prepare ios-runner rejects non-Apple runner devices', async () => { kind: 'emulator', booted: true, }); - const { gateway, inspectFacts, bind } = createLifecycleDeviceRuntimeGatewaySpies(); + const { gateway, inspectFacts, bind } = createRealLifecycleDeviceRuntimeGatewaySpies(); const response = await createHandler( sessionStore, 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 new file mode 100644 index 0000000000..e0fabac6b6 --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts @@ -0,0 +1,95 @@ +import '../handlers/install-real-lifecycle-harness.ts'; +import { beforeEach, expect, test } from 'vitest'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; + +const { + handleSessionCommands, + makeIosSimulatorRecordingSession, + makeSessionStore, + mockReleaseRunnerOnClose, + noopInvoke, + path, + recordingCleanupMock, + recordingFinishMock, + resetSessionCloseShutdownMocks, +} = sessionCloseShutdownFixture; + +// 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: daemonPlatformServicesFixture(), + 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: daemonPlatformServicesFixture(), + 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/daemon/__tests__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index 8e061f1d15..b3314fe979 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -1,20 +1,17 @@ +import { vi } from 'vitest'; +import { + bindDirectApplicationLifecycle, + bindLocalApplicationLifecycleInteractor, +} from '@agent-device/contracts/application-lifecycle-interaction'; import type { ApplicationLifecycleExecution, ApplicationLifecycleRuntimeOperations, + RuntimeHintsApplicationInput, } from '@agent-device/contracts/application-lifecycle-runtime'; -import type { DeviceBinding } from '@agent-device/contracts/platform-runtime'; -import type { - PlatformRuntimeHost, - PlatformRuntimeOperations, -} from '@agent-device/contracts/platform-runtime-operations'; +import { fixtureResolveSessionAppBundleIdForTarget } from './platform-services-fixture.ts'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; -import type { DeviceInfo } from '@agent-device/kernel/device'; +import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import type { TargetShutdownResult } from '@agent-device/contracts/device'; -import { vi } from 'vitest'; -import { createAndroidApplicationTools } from '../../platform-runtime-android-application-tools.ts'; -import { createAppleApplicationTools } from '../../platform-runtime-apple-application-tools.ts'; -import { createComposedPlatformRuntimeGateway } from '../../platform-runtime-gateway.ts'; -import { platformRuntimeModules } from '../../platform-runtime.ts'; type LifecycleEffectContext = ApplicationLifecycleExecution & Readonly<{ @@ -23,16 +20,6 @@ type LifecycleEffectContext = ApplicationLifecycleExecution & terminateRunningApp?: boolean; }>; -/** - * Handler tests observe an admitted lifecycle effect through this test-only seam. It is not a - * wrapper around the retired command dispatcher: package lifecycle tests own dispatch mechanics, - * while handler tests assert facts admission, binding, session policy, and error precedence. - */ -/** - * Readiness discovery, as a seam. It reports the device under test as an already-running emulator - * so lifecycle fixtures never launch or poll a boot; a test that needs a pre-effect readiness - * failure rejects this instead. - */ /** * A platform-neutral readiness pause point. Package-owned readiness runs inside the admitted * binding, so a router-level test that needs to hold an open mid-readiness awaits this instead of @@ -40,6 +27,11 @@ type LifecycleEffectContext = ApplicationLifecycleExecution & */ export const awaitFixtureReadiness = vi.fn(async (_device: DeviceInfo): Promise => undefined); +/** + * Readiness discovery, as a seam. It reports the device under test as an already-running emulator + * so lifecycle fixtures never launch or poll a boot; a test that needs a pre-effect readiness + * failure rejects this instead. + */ export const discoverReadyAndroidEmulators = vi.fn(async (readyDevice: DeviceInfo) => [ { ...readyDevice, @@ -48,6 +40,11 @@ export const discoverReadyAndroidEmulators = vi.fn(async (readyDevice: DeviceInf }, ]); +/** + * Handler tests observe an admitted lifecycle open/close through this spy: package lifecycle + * suites own dispatch mechanics, while handler tests assert facts admission, binding, session + * policy, and error precedence around this call. + */ export const dispatchApplicationLifecycleEffect = vi.fn( async ( _device: DeviceInfo, @@ -58,10 +55,29 @@ export const dispatchApplicationLifecycleEffect = vi.fn( ): Promise => undefined, ); +/** The runtime-hint asks as the fixture answers them, at the port, with the bound device. */ +export const applyRuntimeHintsFixture = vi.fn( + async (_input: RuntimeHintsApplicationInput & Readonly<{ device: DeviceInfo }>) => undefined, +); +export const clearRuntimeHintsFixture = vi.fn( + async (_input: RuntimeHintsApplicationInput & Readonly<{ device: DeviceInfo }>) => undefined, +); + +/** The fixture's canned `prepareAppleRunner` answer, spied at the port. */ +const prepareAppleRunnerFixture = vi.fn( + async (): Promise<{ + runner: Record; + connectMs: number; + healthCheckMs: number; + }> => ({ runner: { currentUptimeMs: 42 }, connectMs: 3, healthCheckMs: 3 }), +); + /** - * Handler fixtures enter each package through its public runtime module. The only test double is - * the final interactor effect, so handler tests exercise package-owned lifecycle sequencing - * without reaching into a package implementation or recreating a root lifecycle dispatcher. + * The `ApplicationLifecycleRuntimeOperations` port as the daemon suites need it: the contract's + * canonical direct lifecycle over the fixture interactor's spies, with readiness, runtime hints, + * and runner preparation answered by port spies. The real apple/android sequencing (boot, runner + * prewarm policy, relaunch identity, post-open observation) belongs to the platform packages and + * is pinned under `src/__tests__/daemon-composition/`. */ export async function applicationLifecycleRuntimeFixture( device: DeviceInfo, @@ -73,154 +89,75 @@ export async function applicationLifecycleRuntimeFixture( stderr: '', }), ): Promise { - const gateway = createComposedPlatformRuntimeGateway({ - modules: platformRuntimeModules, - loadHost: async () => fixtureHost(device, shutdownTarget), - }); - const binding = await gateway.bind({ - device, - intent: { kind: 'ordinary' }, - scope: { signal, diagnostics: { emit: () => {} }, progress: { report: () => {} } }, - }); - return lifecycleOperations(binding); -} - -function lifecycleOperations( - binding: DeviceBinding, -): ApplicationLifecycleRuntimeOperations { - const operations = binding.operations; - const keys = [ - 'resolveOpenTarget', - 'prepareApplicationOpen', - 'openApplication', - 'applyRuntimeHints', - 'clearRuntimeHints', - 'closeApplication', - 'finalizeApplicationClose', - 'prepareAppleRunner', - 'configureProviderPortReverse', - ] as const satisfies readonly (keyof ApplicationLifecycleRuntimeOperations)[]; - const selected = Object.fromEntries( - keys.flatMap((key) => { - const operation = operations[key]; - return operation ? [[key, operation]] : []; + const direct = bindDirectApplicationLifecycle({ + owner: 'lifecycle-fixture', + openTargetIdentity: 'bundle-id', + // The fixture owner's canned open does not replace a running application, so relaunch closes + // first — the contract's own knob, not a per-platform copy of the real owners' choices. + closeBeforeRelaunch: true, + binding: bindLocalApplicationLifecycleInteractor({ + device, + signal, + resolveInteractor: async (target, runner) => + applicationLifecycleFixtureInteractor(target, runner), }), - ); - return Object.freeze(selected) as ApplicationLifecycleRuntimeOperations; -} - -function fixtureHost( - readyDevice: DeviceInfo, - shutdownTarget: (device: DeviceInfo) => Promise, -): PlatformRuntimeHost { - let simulatorState = readyDevice.booted === false ? 'Shutdown' : 'Booted'; - const localInteractors = Object.freeze({ - resolve: async (device: DeviceInfo, runner: RunnerContext): Promise => - applicationLifecycleFixtureInteractor(device, runner), }); - return { - localInteractors, - appleApplications: createAppleApplicationTools(), - androidApplications: createAndroidApplicationTools(), - deviceShutdown: Object.freeze({ - apple: Object.freeze({ - shutdownTarget: async (device: DeviceInfo) => await shutdownTarget(device), - }), - android: Object.freeze({ - shutdownTarget: async (device: DeviceInfo) => await shutdownTarget(device), - }), - close: Object.freeze({ - canShutdownTarget: async (device: DeviceInfo) => - (device.platform === 'apple' && device.kind === 'simulator') || - (device.platform === 'android' && device.kind === 'emulator'), - shutdownTarget: async (device: DeviceInfo) => await shutdownTarget(device), - }), - }), - // Readiness is package-owned; these handler fixtures exercise lifecycle dispatch, so the - // narrow ports report an already-ready device instead of booting anything. - deviceReadiness: Object.freeze({ - applePhysical: Object.freeze({ - ensureConnected: async (_device: DeviceInfo, signal: AbortSignal) => { - signal.throwIfAborted(); - }, - }), - appleAutomation: Object.freeze({ - keepHot: () => undefined, - markBooted: () => {}, - wasRecentlyObservedBooted: async () => false, - }), - androidEmulator: Object.freeze({ - discover: async (_request: unknown, signal: AbortSignal) => { - signal.throwIfAborted(); - await awaitFixtureReadiness(readyDevice); - return await discoverReadyAndroidEmulators(readyDevice); - }, - launch: () => 1, - terminate: async () => undefined, - }), - }), - // Readiness reads the device's own boot state, so a `booted: false` fixture still exercises - // the cold-boot path (and its runner-cache prewarm hook) before settling. - appleTools: Object.freeze({ - isXcrunAvailable: async () => true, - run: async (request: Readonly<{ args: readonly string[] }>) => { - await awaitFixtureReadiness(readyDevice); - if (request.args.includes('boot')) { - simulatorState = 'Booted'; - return { stdout: '', stderr: '', exitCode: 0 }; - } - return { - stdout: JSON.stringify({ - devices: { fixture: [{ udid: readyDevice.id, state: simulatorState }] }, - }), - stderr: '', - exitCode: 0, - }; - }, - }), - commands: Object.freeze({ - which: async (executable: string) => executable, - run: async () => ({ stdout: '1', stderr: '', exitCode: 0 }), - }), - toolchains: Object.freeze({ prepare: async () => undefined }), - clock: Object.freeze({ - now: () => Date.now(), - sleep: async () => undefined, - }), - processTransports: Object.freeze({ - resolve: async () => ({ mode: 'local' as const }), - }), - networkTransports: Object.freeze({ - resolve: async () => ({ mode: 'local' as const }), - }), - screenRecording: Object.freeze({ - apple: Object.freeze({ - availability: async () => ({ - available: false as const, - hint: 'Screen recording is outside this lifecycle fixture.', - }), - }), - android: Object.freeze({ resolve: async () => ({}) }), - web: Object.freeze({ resolve: async () => undefined }), - }), - audioProbe: Object.freeze({ - hostCapture: Object.freeze({ - info: Object.freeze({ - source: 'system-audio' as const, - backend: 'fixture', - sourceCount: 0, - notes: () => [], - }), - start: async () => { - throw new Error('Audio probe is outside this lifecycle fixture.'); - }, - inspectProcess: async () => 'missing' as const, - terminateProcess: async () => 'already-missing' as const, - }), - web: Object.freeze({ resolve: async () => undefined }), - ownedProcesses: Object.freeze({ replace: () => {}, clear: () => {} }), - }), - } as unknown as PlatformRuntimeHost; + const directOpenApplication = direct.openApplication; + return Object.freeze({ + ...direct, + resolveOpenTarget: async (input) => { + if ( + isMacOs(device) && + input.surface !== undefined && + input.surface !== 'app' && + input.surface !== 'desktop' && + input.surface !== 'menubar' + ) { + // Frontmost surfaces resolve through the macos package leaf the suites already mock. + const { resolveFrontmostMacOsApp } = await import('@agent-device/platform-apple/macos'); + const frontmost = await resolveFrontmostMacOsApp(); + return Object.freeze({ + ...(frontmost.bundleId === undefined ? {} : { appBundleId: frontmost.bundleId }), + ...(input.target === undefined + ? frontmost.appName === undefined + ? {} + : { appName: frontmost.appName } + : { appName: input.target }), + }); + } + const appBundleId = await fixtureResolveSessionAppBundleIdForTarget( + device, + input.target, + input.currentAppBundleId, + ); + return Object.freeze({ + ...(appBundleId === undefined ? {} : { appBundleId }), + ...(input.target === undefined ? {} : { appName: input.target }), + }); + }, + prepareApplicationOpen: async (input) => { + await awaitFixtureReadiness(device); + if (device.platform === 'android' && device.booted !== true) { + await discoverReadyAndroidEmulators(device); + } + void input; + }, + openApplication: async (input) => { + if (input.applyRuntimeHints) { + await input.applyRuntimeHints({ appId: input.appBundleId, values: input.runtimeHints }); + } + return await directOpenApplication(input); + }, + applyRuntimeHints: async (input) => { + await applyRuntimeHintsFixture({ device, ...input }); + }, + clearRuntimeHints: async (input) => { + await clearRuntimeHintsFixture({ device, ...input }); + }, + finalizeApplicationClose: async (input) => + input.shutdownTarget ? { shutdown: await shutdownTarget(device) } : {}, + prepareAppleRunner: async () => await prepareAppleRunnerFixture(), + }); } export function applicationLifecycleFixtureInteractor( diff --git a/src/daemon/__tests__/platform-services-fixture.ts b/src/daemon/__tests__/platform-services-fixture.ts index d9221dad9e..98b04e5a86 100644 --- a/src/daemon/__tests__/platform-services-fixture.ts +++ b/src/daemon/__tests__/platform-services-fixture.ts @@ -6,12 +6,16 @@ import { resolveIosDeviceDeepLinkBundleId, } from '@agent-device/contracts/command'; import { parseSessionSurface, type SessionSurface } from '@agent-device/contracts/session'; -import { isApplePlatform, isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { + isApplePlatform, + isIosFamily, + isMacOs, + type DeviceInfo, +} from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { classifyAndroidAppTarget, formatAndroidInstalledPackageRequiredMessage, - resolveAndroidPackageForOpen, } from '@agent-device/platform-android/mechanics'; import type { ClaimRecoveryGatewayFactory } from '../device/claim-recovery-gateway.ts'; import type { DaemonPlatformServices } from '../platform-services.ts'; @@ -45,7 +49,8 @@ const fixtureDeviceBootObservation = Object.freeze({ return await observeSimulatorBootTimeMs(device); } if (device.platform === 'android') { - const { observeAndroidBootTimeMs } = await import('@agent-device/platform-android/device-boot'); + const { observeAndroidBootTimeMs } = + await import('@agent-device/platform-android/device-boot'); return await observeAndroidBootTimeMs(device); } return { observed: false, reason: 'unsupported-device' } as const; @@ -114,7 +119,12 @@ async function fixtureValidateOpenRelaunchTarget(params: { return undefined; } -async function fixtureResolveSessionAppBundleIdForTarget( +/** + * The open-target bundle-id rules as the fixture answers them: contract Safari/deep-link rules and + * the platform packages' own resolvers, no root open-target module. Both the services port and the + * canned lifecycle `resolveOpenTarget` answer through this single implementation. + */ +export async function fixtureResolveSessionAppBundleIdForTarget( device: DeviceInfo, openTarget: string | undefined, currentAppBundleId: string | undefined, @@ -197,6 +207,10 @@ async function resolveAndroidPackage( ): Promise { if (device.platform !== 'android' || !openTarget) return undefined; try { + // Dynamic like the Apple branch: suites register their mechanics mocks after this fixture's + // module evaluates, so a static import would bind the unmocked leaf. + const { resolveAndroidPackageForOpen } = + await import('@agent-device/platform-android/mechanics'); return await resolveAndroidPackageForOpen(device, openTarget); } catch { return undefined; diff --git a/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts b/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts index ba75c34723..84aa8f08a3 100644 --- a/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts +++ b/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts @@ -1,12 +1,12 @@ -import { localRuntimeOwner, type DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceRuntimeGateway, +} from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit/runtime-operation-facts'; -import type { - BindDeviceRuntime, - BindExactDeviceRuntime, -} from '../../request-runtime-binding.ts'; -import { narrowDeviceBinding } from '@agent-device/contracts/platform-runtime'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../../request-runtime-binding.ts'; /** * The `DeviceRuntimeGateway` port answering that no owner on this host can bind the device: diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index adbb308fbb..14e4c8a902 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { vi } from 'vitest'; import { type ApplicationLifecycleOperationFacts, + type ApplicationLifecycleRuntimeOperations, applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; @@ -52,7 +53,15 @@ function lifecycleFactsForTest(device: DeviceInfo): ApplicationLifecycleOperatio }); } -async function lifecycleBindingForTest(device: DeviceInfo) { +/** Loads the lifecycle operations a lifecycle gateway binds; the daemon default is the canned port fixture. */ +export type LifecycleOperationsLoader = ( + device: DeviceInfo, +) => Promise; + +async function lifecycleBindingForTest( + device: DeviceInfo, + loadLifecycleOperations: LifecycleOperationsLoader = applicationLifecycleRuntimeFixture, +) { const lifecycleFacts = lifecycleFactsForTest(device); const base = createUnavailableRuntimeFactsForTest(device, localRuntimeOwner(device.platform)); return { @@ -69,7 +78,7 @@ async function lifecycleBindingForTest(device: DeviceInfo) { }, operations: { ...availableApplicationLifecycleOperations( - await applicationLifecycleRuntimeFixture(device), + await loadLifecycleOperations(device), lifecycleFacts, ), // The gesture family rides along: replay flows drive `scroll`/`swipe` through this gateway, @@ -83,7 +92,7 @@ async function lifecycleBindingForTest(device: DeviceInfo) { }; } -/** Opt-in lifecycle fixture: commands bind the real root host after fact admission. */ +/** Opt-in lifecycle fixture: commands bind the canned lifecycle port after fact admission. */ export const lifecycleDeviceRuntimeGateway: DeviceRuntimeGateway = Object.freeze({ inspectFacts: async (device) => (await lifecycleBindingForTest(device)).facts, @@ -91,6 +100,18 @@ export const lifecycleDeviceRuntimeGateway: DeviceRuntimeGateway {}, }); +/** The same gateway shape bound to a caller-supplied lifecycle-ops loader (root composition lanes pass the real fixture). */ +export function createLifecycleDeviceRuntimeGateway( + loadLifecycleOperations: LifecycleOperationsLoader, +): DeviceRuntimeGateway { + return Object.freeze({ + inspectFacts: async (device) => + (await lifecycleBindingForTest(device, loadLifecycleOperations)).facts, + bind: async ({ device }) => await lifecycleBindingForTest(device, loadLifecycleOperations), + shutdown: async () => {}, + }); +} + /** * A fresh, unfrozen `lifecycleDeviceRuntimeGateway`-equivalent whose `inspectFacts`/`bind` are * `vi.fn()` spies. The shared `lifecycleDeviceRuntimeGateway` above is `Object.freeze`d and reused @@ -98,12 +119,16 @@ export const lifecycleDeviceRuntimeGateway: DeviceRuntimeGateway (await lifecycleBindingForTest(device)).facts, + async (device: DeviceInfo) => + (await lifecycleBindingForTest(device, loadLifecycleOperations)).facts, ); const bind = vi.fn( - async (request: DeviceBindingRequest) => await lifecycleBindingForTest(request.device), + async (request: DeviceBindingRequest) => + await lifecycleBindingForTest(request.device, loadLifecycleOperations), ); const gateway: DeviceRuntimeGateway = { inspectFacts, @@ -122,7 +147,6 @@ import { } from './support/unavailable-device-runtime-gateway.ts'; export { - unavailableBinding, unavailableBindDevice, unavailableBindExactDevice, unavailableDeviceRuntimeGateway, @@ -192,7 +216,6 @@ async function gestureBinding(device: DeviceInfo) { } as unknown as Awaited['bind']>>; } - export function createRequestHandler( deps: Omit< RequestRouterDeps, diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index 112d3f7119..bbfbc4f524 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -23,6 +23,7 @@ import type { TargetShutdownResult } from '@agent-device/contracts/device'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { beforeEach, vi } from 'vitest'; import { applicationLifecycleRuntimeFixture } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; +import type { ApplicationLifecycleRuntimeOperations } from '@agent-device/contracts/application-lifecycle-runtime'; import { withClientReplayScriptSources } from '@agent-device/replay-port/replay-script-source-fixtures'; import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit/runtime-operation-facts'; import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; @@ -67,6 +68,30 @@ export const mockBindDeviceRuntime = vi.fn(async (device: DeviceInfo, use) => narrowDeviceBinding(await readinessBinding(device), use), ); +/** + * Test-harness seam (per test-file module graph): points the harness bindings at another + * lifecycle-ops fixture. Root-owned composition suites install the real composed fixture via + * `src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts`; daemon suites keep + * the canned port fixture default. + */ +export function setHarnessLifecycleOperationsLoader( + loader: ( + device: DeviceInfo, + signal?: AbortSignal, + shutdownTarget?: (device: DeviceInfo) => Promise, + ) => Promise, +): void { + loadLifecycleOperations = loader; +} + +type LifecycleOperationsLoader = ( + device: DeviceInfo, + signal?: AbortSignal, + shutdownTarget?: (device: DeviceInfo) => Promise, +) => Promise; + +let loadLifecycleOperations: LifecycleOperationsLoader = applicationLifecycleRuntimeFixture; + beforeEach(() => { mockInspectDeviceRuntimeFacts.mockClear(); mockEnsureReadyRuntime.mockClear(); @@ -173,7 +198,7 @@ async function readinessBinding( device: DeviceInfo, ): Promise> { const facts = readinessFacts(device); - const lifecycle = await applicationLifecycleRuntimeFixture( + const lifecycle = await loadLifecycleOperations( device, new AbortController().signal, async () => await mockShutdownTargetRuntime(), diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index 9bd6075363..398ac2ad1a 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -19,11 +19,6 @@ vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal }; }); vi.mock('../../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, applyRuntimeHintValues: vi.fn(async () => {}) }; -}); vi.mock('@agent-device/platform-android/mechanics', () => ({ activateAndroidTestIme: vi.fn(async () => ({ activated: false })), restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })), @@ -39,12 +34,12 @@ vi.mock('@agent-device/host-kit/process', async (importOriginal) => import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { ensureDeviceReady } from '../../device/device-ready.ts'; -import { applyRuntimeHintValues } from '../../../platform-runtime-runtime-hints.ts'; import { activateAndroidTestIme, resolveAndroidPackageForOpen, } from '@agent-device/platform-android/mechanics'; import { + applyRuntimeHintsFixture, discoverReadyAndroidEmulators, dispatchApplicationLifecycleEffect, } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; @@ -73,7 +68,7 @@ const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintValues); +const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); const reconcileOrphanedDeviceClaim = async () => ({ status: 'retained' as const, @@ -253,40 +248,6 @@ test('failed local open response rolls its device claim back', async () => { assert.deepEqual(inspectDeviceClaims({ serial: android.id }), []); }); -test('cancellation after local device setup retains the device claim for recovery', async () => { - const { store, stateDir } = setup(); - const requestId = 'claim-canceled-after-dispatch'; - mockResolveTargetDevice.mockResolvedValue(android); - mockDispatch.mockResolvedValue(undefined); - markRequestCanceled(requestId); - - try { - const response = await handleOpenCommand({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - command: 'open', - token: 'test', - session: 'claim-canceled-after-dispatch', - positionals: ['Demo'], - flags: { platform: 'android' }, - meta: { requestId }, - }, - sessionName: 'claim-canceled-after-dispatch', - logPath: path.join(stateDir, 'daemon.log'), - sessionStore: store, - }); - - assert.equal(response.ok, false); - assert.equal(mockDispatch.mock.calls.length, 1); - assert.equal(vi.mocked(activateAndroidTestIme).mock.calls.length, 1); - assert.equal(inspectDeviceClaims({ serial: android.id })[0]?.classification, 'live'); - assert.equal(store.get('claim-canceled-after-dispatch'), undefined); - } finally { - clearRequestCanceled(requestId); - } -}); - test('a canceled attempt lets the next attempt of the same suite open the device', async () => { const { store, stateDir } = setup(); const requestId = 'suite:1-gesture-pan-duration:attempt:1'; diff --git a/src/daemon/handlers/__tests__/session-test-harness.ts b/src/daemon/handlers/__tests__/session-test-harness.ts index 73ad38db41..108862588a 100644 --- a/src/daemon/handlers/__tests__/session-test-harness.ts +++ b/src/daemon/handlers/__tests__/session-test-harness.ts @@ -26,15 +26,6 @@ vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal }; }); vi.mock('../../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - applyRuntimeHintValues: vi.fn(async () => {}), - clearRuntimeHintValues: vi.fn(async () => {}), - }; -}); vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = await importOriginal(); @@ -94,9 +85,10 @@ import type { SessionState } from '../../session-state.ts'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { ensureDeviceReady } from '../../device/device-ready.ts'; import { - applyRuntimeHintValues, - clearRuntimeHintValues, -} from '../../../platform-runtime-runtime-hints.ts'; + applyRuntimeHintsFixture, + clearRuntimeHintsFixture, + dispatchApplicationLifecycleEffect, +} from '../../__tests__/application-lifecycle-runtime-fixture.ts'; import { prepareIosRunner, prewarmAppleRunnerCache, @@ -112,31 +104,26 @@ import { } from '@agent-device/platform-apple/app-resolution'; import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { runCmd } from '@agent-device/host-kit/command'; -import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; export const mockLifecycleDispatch = vi.mocked(dispatchApplicationLifecycleEffect); export const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); export const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintValues); -export const mockClearRuntimeHints = vi.mocked(clearRuntimeHintValues); -export const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); -export const mockNotifyIosRunnerAppRelaunched = vi.mocked(notifyIosRunnerAppRelaunched); +const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); +export const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFixture); +const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); +const mockNotifyIosRunnerAppRelaunched = vi.mocked(notifyIosRunnerAppRelaunched); export const mockPrewarmAppleRunnerCache = vi.mocked(prewarmAppleRunnerCache); -export const mockPrepareIosRunner = vi.mocked(prepareIosRunner); -export const mockStopIosRunner = vi.mocked(stopIosRunnerSession); +const mockPrepareIosRunner = vi.mocked(prepareIosRunner); +const mockStopIosRunner = vi.mocked(stopIosRunnerSession); const mockReleaseRunnerOnClose = vi.mocked(releaseIosRunnerOnClose); -export const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); +const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); export const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); -export const mockCleanupRetainedMaterializedPaths = vi.mocked( - cleanupRetainedMaterializedPathsForSession, -); +const mockCleanupRetainedMaterializedPaths = vi.mocked(cleanupRetainedMaterializedPathsForSession); export const mockRunCmd = vi.mocked(runCmd); /** The retired dispatcher's snapshot leg, now an owned test double (R58). */ export const mockDispatch = legacyDispatchCapture; -export const mockResolveIosApp = vi.mocked(resolveIosApp); -export const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked( - resolveIosSimulatorDeepLinkBundleId, -); +const mockResolveIosApp = vi.mocked(resolveIosApp); +const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked(resolveIosSimulatorDeepLinkBundleId); beforeEach(() => { vi.useRealTimers(); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts index d42b087ca0..f8460cfcd2 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts @@ -18,8 +18,6 @@ const { makeSessionStore, mkdtempForTestSync, mockDispatchCommand, - mockReleaseRunnerOnClose, - mockStopIosRunnerSession, noopInvoke, path, resetSessionCloseShutdownMocks, @@ -28,58 +26,6 @@ const { 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: daemonPlatformServicesFixture(), - 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(); -}); - // #1478-adjacent (device-claim retention observability): a failed platform close deliberately // keeps the enforced device claim (handing an unconfirmed device to the next session would be // worse), but the session record is still deleted on the very next line. Before this pair of @@ -357,69 +303,6 @@ test('a successful close clears the device claim', async () => { } }); -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 own two internal calls (pre-close stop, then the later - // independent-cleanup retry) — a persistent `mockRejectedValue` here would leak into every - // later test that exercises an Apple-platform runner stop, since `vi.clearAllMocks()` in - // `beforeEach` clears call history but not a mock's implementation. - mockStopIosRunnerSession.mockRejectedValueOnce(preCloseError); - - await expect( - handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - 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(); -}); - // 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/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index f16b245015..d7036354b7 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -12,16 +12,6 @@ import { } from '../../../ref-frame.ts'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; -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 { handleSessionCommands, mockBindDeviceRuntime, @@ -77,7 +67,6 @@ async function close(params: { beforeEach(() => { vi.clearAllMocks(); - runtimeHintsModule.evaluated = false; }); test('an app-only close without a target fails before admission or ref-frame expiry', async () => { @@ -154,175 +143,6 @@ test('close rejects a false close-target fact before its one implementation bind expect(sessionStore.get(sessionName)).toMatchObject({ name: sessionName }); }); -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' }), - }), - ); -}); - test('close expires the ref frame immediately before its admitted platform mutation', async () => { const sessionStore = makeSessionStore(); const sessionName = 'close-ref-frame-seam'; diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts index c05838d429..8d480525bd 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts @@ -5,95 +5,16 @@ import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; const { - fs, handleSessionCommands, - makeIosSimulatorRecordingSession, makeSession, makeSessionStore, - mockReleaseRunnerOnClose, - mockStopIosRunnerSession, noopInvoke, path, - recordingFinishMock, resetSessionCloseShutdownMocks, } = sessionCloseShutdownFixture; 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: daemonPlatformServicesFixture(), - 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: daemonPlatformServicesFixture(), - 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(); -}); - test('close --save-script on a session with an active .ad repair transaction is unaffected by the unarmed-authoring guard', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-repair-close-save-script-session'; diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts index 8479aeb2cb..5302ffca76 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts @@ -214,86 +214,6 @@ test('close --shutdown calls shutdownAndroidEmulator for Android emulator and in } }); -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: daemonPlatformServicesFixture(), - 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: daemonPlatformServicesFixture(), - 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 returns success and failure payload when shutdownAndroidEmulator throws', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-shutdown-failure-session'; diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 2e2641a5bc..bada713271 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -6,7 +6,6 @@ import fs from 'node:fs'; import type { ApplicationLifecycleRuntimeOperations, OpenApplicationInput, - PostOpenObservation, } from '@agent-device/contracts/application-lifecycle-runtime'; import type { DaemonRequest } from '../../../daemon-request.ts'; import { @@ -17,9 +16,8 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; -const { mockResolveTargetDevice, mockPrewarmRunnerSession } = vi.hoisted(() => ({ +const { mockResolveTargetDevice } = vi.hoisted(() => ({ mockResolveTargetDevice: vi.fn(), - mockPrewarmRunnerSession: vi.fn(async () => {}), })); vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { @@ -34,28 +32,6 @@ vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal }; }); vi.mock('../../../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -vi.mock('../../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - applyRuntimeHintValues: vi.fn(async () => {}), - clearRuntimeHintValues: vi.fn(async () => {}), - }; -}); -vi.mock('../../../../platform-runtime-apple-application-tools.ts', async (importOriginal) => { - const actual = - await importOriginal< - typeof import('../../../../platform-runtime-apple-application-tools.ts') - >(); - return { - ...actual, - createAppleApplicationTools: () => ({ - ...actual.createAppleApplicationTools(), - prewarmRunnerSession: mockPrewarmRunnerSession, - }), - }; -}); vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => { const actual = await importOriginal(); @@ -80,15 +56,13 @@ import { mockInspectDeviceRuntimeFacts, } from '../../../handlers/__tests__/session-command-harness.ts'; import { - applyRuntimeHintValues, - clearRuntimeHintValues, -} from '../../../../platform-runtime-runtime-hints.ts'; + applyRuntimeHintsFixture, + clearRuntimeHintsFixture, + dispatchApplicationLifecycleEffect, +} from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; -import { dispatchApplicationLifecycleEffect } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; -import { pendingSurfaceStabilization } from '../../../deferred-interaction-outcome.ts'; import { makeAndroidEmulator, - makeAppleSimulator, makeSession, makeSessionStore, noopInvoke, @@ -96,8 +70,8 @@ import { import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintValues); -const mockClearRuntimeHints = vi.mocked(clearRuntimeHintValues); +const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); +const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFixture); const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); beforeEach(() => { @@ -469,73 +443,6 @@ test('open reports the launch confirmation its platform answered', async () => { if (response?.ok) expect(response.data?.launchConfirmation).toBe('accepted'); }); -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) => { - const binding = await bindDefault(device, use); - 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: daemonPlatformServicesFixture(), - 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(); -}); - function holdNativeOpen() { let entered!: () => void; let release!: () => void; diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts index 5f721a8f76..8efbc45422 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts @@ -31,15 +31,6 @@ vi.mock('../../../../platform-runtime-apple-application-tools.ts', async (import }), }; }); -vi.mock('../../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - applyRuntimeHintValues: vi.fn(async () => {}), - clearRuntimeHintValues: vi.fn(async () => {}), - }; -}); vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = await importOriginal(); @@ -73,9 +64,11 @@ import { mockBindDeviceRuntime, mockInspectDeviceRuntimeFacts, } from '../../../handlers/__tests__/session-command-harness.ts'; -import { applyRuntimeHintValues } from '../../../../platform-runtime-runtime-hints.ts'; +import { + applyRuntimeHintsFixture, + dispatchApplicationLifecycleEffect, +} from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; -import { dispatchApplicationLifecycleEffect } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { lifecycleRuntimeFacts } from '../../../__tests__/application-lifecycle-runtime-harness.ts'; import { makeAndroidDevice, @@ -87,7 +80,7 @@ import { import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintValues); +const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); beforeEach(() => { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts index 10a5ce772a..2628f857c0 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts @@ -1,24 +1,17 @@ -import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { sessionCloseShutdownFixture, type SessionState, } 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 { platformResourceCleanupFixture } from '../../../__tests__/support/platform-resource-cleanup-fixture.ts'; const { AppError, - handleSessionCommands, makeIosSimulatorRecordingSession, makeSession, makeSessionStore, - mockReleaseRunnerOnClose, mockStopIosRunnerSession, mockStopIosRunnerSession: stopIosRunnerSession, - noopInvoke, - path, recordingCleanupMock, recordingFinishMock, resetSessionCloseShutdownMocks, @@ -29,77 +22,6 @@ const { 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: daemonPlatformServicesFixture(), - 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: daemonPlatformServicesFixture(), - 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(); -}); - test('daemon resource teardown finalizes recording before lifecycle runner disposal', async () => { const sessionName = 'ios-active-recording-teardown-session'; const sessionStore = makeSessionStore(); diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index 009d2d9b88..0e63314d3f 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -632,7 +632,7 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'session inventory proves the app lease is removed', ), macos: macos.contract( - 'src/daemon/handlers/__tests__/session-relaunch-close.test.ts', + 'src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts', 'close on macOS session stops runner and dismisses automation alert before delete', 'macOS close stops the runner, dismisses automation state, and deletes the session', ), From b9d7bea25df8cd6d0bc6105a59407c2b1e0c25f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 05:16:50 +0200 Subject: [PATCH 03/23] refactor(daemon): delete the five dead root-module mocks from daemon suites The four replay-repair suites mocked clearRuntimeHintValues out of platform-runtime-runtime-hints.ts, and session-open-runtime patched createAppleApplicationTools for a prewarm spy. Nothing under src/daemon imports either module any more (hint apply/clear reach the device through the admitted binding's operations, observed at the fixture port spies), so the mocks answered no call. Deleted; the corrected residue scan now reports 0 edges. --- .../install-real-lifecycle-harness.ts | 6 ++--- .../session-close-runner-retention.test.ts | 3 --- ...session-device-claims-cancellation.test.ts | 4 ++- ...request-router-open-claim-recovery.test.ts | 2 -- .../application-lifecycle-runtime-fixture.ts | 3 +-- ...ion-replay-repair-record-exclusion.test.ts | 5 ---- ...-repair-transaction-close-ordering.test.ts | 5 ---- ...on-replay-repair-transaction-force.test.ts | 5 ---- .../session-replay-repair-transaction.test.ts | 5 ---- .../__tests__/test-device-runtime-gateway.ts | 25 ++++++++----------- .../__tests__/session-open-runtime.test.ts | 13 ---------- 11 files changed, 17 insertions(+), 59 deletions(-) diff --git a/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts b/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts index 24f32b7e72..549a6796df 100644 --- a/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts +++ b/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts @@ -2,7 +2,7 @@ import { setHarnessLifecycleOperationsLoader } from '../../../daemon/handlers/__ import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; // Composition suites bind the REAL composed lifecycle (root gateway + apple/android application -// tools) behind the daemon session-command harness. Import this module before any module that -// captures the harness's `handleSessionCommands`: vitest evaluates this file's module graph first, -// and every test file gets its own module instance, so the install cannot leak into daemon files. +// tools) behind the daemon session-command harness. The loader is read when bindings are built, +// so any import position works; vitest gives every test file its own module instance, so the +// install cannot leak into daemon-lane files. setHarnessLifecycleOperationsLoader(applicationLifecycleRuntimeFixture); diff --git a/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts b/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts index 22f374d089..08ed51dd7b 100644 --- a/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts +++ b/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts @@ -1,10 +1,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import * as path from 'node:path'; import { LeaseRegistry } from '../../../daemon/lease-registry.ts'; -import { providerDeviceAdmissionFixture } from '../../../daemon/__tests__/support/provider-device-admission-fixture.ts'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; - -providerDeviceAdmissionFixture(); import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { makeSessionStore } from '../../../daemon/__tests__/support/store-factory.ts'; import { makeSession } from '../../../daemon/__tests__/support/session-factories.ts'; diff --git a/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts index 84681cf7b8..3e6197f11e 100644 --- a/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts +++ b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts @@ -68,7 +68,9 @@ const reconcileOrphanedDeviceClaim = async () => ({ // The post-open test-IME activation is the real Android lifecycle's sequencing step after the // open dispatch — the daemon-lane canned binding performs no IME leg, so this claim-retention -// case runs in the composition lane on the real composed lifecycle. +// case runs in the composition lane on the real composed lifecycle. The gateway seam's bind +// takes a full request context, which this suite's open call owns; the binding shape here +// mirrors it at the (device, use) seam the open command passes. async function realLifecycleBinding( device: DeviceInfo, ): Promise> { diff --git a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts index 6ac745bea6..39705d0723 100644 --- a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts +++ b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts @@ -3,7 +3,6 @@ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; -import { providerDeviceAdmissionFixture } from '../../daemon/__tests__/support/provider-device-admission-fixture.ts'; import { fakeCreateCommandSurface } from '../../daemon/__tests__/support/command-surface-fake.ts'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { @@ -18,7 +17,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; // must hand the acquire path the OWNER-scoped reconciler, so a foreign dead owner's recording // recovery executes against that owner's state dir, never the daemon's own store (#2168). -providerDeviceAdmissionFixture(); vi.mock('../../daemon/device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), diff --git a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index b3314fe979..1bbe1ac4e3 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -135,12 +135,11 @@ export async function applicationLifecycleRuntimeFixture( ...(input.target === undefined ? {} : { appName: input.target }), }); }, - prepareApplicationOpen: async (input) => { + prepareApplicationOpen: async (_input) => { await awaitFixtureReadiness(device); if (device.platform === 'android' && device.booted !== true) { await discoverReadyAndroidEmulators(device); } - void input; }, openApplication: async (input) => { if (input.applyRuntimeHints) { diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts index 47ea78118a..0056857fa6 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts @@ -34,11 +34,6 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn() }; }); -vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, clearRuntimeHintValues: vi.fn(async () => {}) }; -}); vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts index 4099b211c9..4cdd31a880 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts @@ -27,11 +27,6 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn() }; }); -vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, clearRuntimeHintValues: vi.fn(async () => {}) }; -}); vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, resolveTargetDevice: vi.fn() }; diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts index 0376fe3210..ef187f5222 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts @@ -25,11 +25,6 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn() }; }); -vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, clearRuntimeHintValues: vi.fn(async () => {}) }; -}); vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, resolveTargetDevice: vi.fn() }; diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts index 96d13e205a..db1ea9bd46 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts @@ -42,11 +42,6 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn() }; }); -vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, clearRuntimeHintValues: vi.fn(async () => {}) }; -}); vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, resolveTargetDevice: vi.fn() }; diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 14e4c8a902..8a631d469c 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -32,6 +32,16 @@ import { } from './platform-services-fixture.ts'; import { platformResourceCleanupFixture } from './support/platform-resource-cleanup-fixture.ts'; import { providerCredentialsFixture } from './support/provider-credentials-fixture.ts'; +import { + unavailableBinding, + unavailableDeviceRuntimeGateway, +} from './support/unavailable-device-runtime-gateway.ts'; +export { + unavailableBindDevice, + unavailableBindExactDevice, + unavailableDeviceRuntimeGateway, + unavailableInspectFacts, +} from './support/unavailable-device-runtime-gateway.ts'; const unavailable = Object.freeze({ available: false as const, @@ -138,21 +148,6 @@ export function createLifecycleDeviceRuntimeGatewaySpies( return { gateway, inspectFacts, bind }; } -import { - unavailableBinding, - unavailableBindDevice, - unavailableBindExactDevice, - unavailableDeviceRuntimeGateway, - unavailableInspectFacts, -} from './support/unavailable-device-runtime-gateway.ts'; - -export { - unavailableBindDevice, - unavailableBindExactDevice, - unavailableDeviceRuntimeGateway, - unavailableInspectFacts, -}; - /** * Spies for the gesture surface a router-level test drives. They replace the retired * `dispatchGesturePlan` / `dispatchGestureViewport` module mocks: gestures now reach the platform diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts index 8efbc45422..4f2fda9860 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts @@ -18,19 +18,6 @@ vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal }; }); vi.mock('../../../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -vi.mock('../../../../platform-runtime-apple-application-tools.ts', async (importOriginal) => { - const actual = - await importOriginal< - typeof import('../../../../platform-runtime-apple-application-tools.ts') - >(); - return { - ...actual, - createAppleApplicationTools: () => ({ - ...actual.createAppleApplicationTools(), - prewarmRunnerSession: vi.fn(async () => {}), - }), - }; -}); vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = await importOriginal(); From 3b67712ade0d83c6fc90a5ed7b950e48842ae6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 06:10:36 +0200 Subject: [PATCH 04/23] refactor(daemon): answer the platform-services and lifecycle ports with canned spies Per review: the composition lane owns the platform rules, so the daemon fixtures stop re-implementing them. Every services/lifecycle member is now a plain vi.fn spy with a canned default a test overrides per case: - platform-services-fixture: appleSessionObservation, deviceBootObservation, ensureLocalDeviceReady, resolveRequestedOpenSurface, validateOpenRelaunchTarget and resolveSessionAppBundleIdForTarget are spies; the open-target/surface/readiness/boot rule copies are deleted, and the shared fixtureResolveSessionAppBundleIdForTarget the lifecycle fixture imported goes with them. - application-lifecycle-runtime-fixture: resolveOpenTarget, prepareApplicationOpen and openApplication are spies delegating to the contract's canonical direct-lifecycle members; the macOS frontmost rule and the android stopped-AVD discovery branch are gone. Tests that pinned real rules move to the composition lane unchanged: 8 cases to session-lifecycle/session-open-existing.test.ts, 5 relaunch guards to handlers/session-relaunch-guards.test.ts, and device-ready whole-file (it pins what the composed readiness port does per family). 11 cases keep their names and assertion bodies daemon-side, retargeted to the port spies (boot observation, open-target identity per case, readiness failure injection). Suites under src/__tests__/daemon-composition now use the lane's own root-composed services fixture. Per-file test-name parity holds (2744 names, moved or kept); scan edges 0. --- .../device}/device-ready.test.ts | 13 +- .../daemon-composition/generic-settle.test.ts | 2 +- .../gesture-admission-parity.test.ts | 2 +- .../handlers/session-relaunch-guards.test.ts | 152 +++++++ .../snapshot-handler-freshness.test.ts | 2 +- .../handlers/snapshot-handler.test.ts | 2 +- .../interaction-dispatch-disclosure.test.ts | 2 +- .../interaction-gesture-drag.test.ts | 2 +- .../interaction/interaction-settle.test.ts | 2 +- .../interaction-touch-response.test.ts | 2 +- .../daemon-composition/is-runtime.test.ts | 2 +- .../request-handler-chain.test.ts | 2 +- ...request-router-open-claim-recovery.test.ts | 1 - .../request-router-recording-health.test.ts | 2 +- .../screenshot-runtime.test.ts | 2 +- ...elector-runtime-ambiguity-issuance.test.ts | 2 +- .../selector-runtime-backend.test.ts | 2 +- .../selector-runtime.test.ts | 2 +- .../session-open-existing.test.ts | 407 ++++++++++++++++++ .../snapshot-command-runtime.test.ts | 2 +- .../snapshot-diff-runtime.test.ts | 2 +- .../sparse-fallback-screenshot.test.ts | 2 +- .../daemon-composition/wait-runtime.test.ts | 2 +- .../application-lifecycle-runtime-fixture.ts | 105 +++-- .../__tests__/platform-services-fixture.ts | 275 ++++-------- .../request-router-open-claim.test.ts | 29 +- .../__tests__/session-device-claims.test.ts | 10 +- .../__tests__/session-relaunch-guards.test.ts | 135 ------ .../session-selector-dispatch.test.ts | 11 +- .../session-open-execution-runtime.test.ts | 9 +- .../__tests__/session-open-existing.test.ts | 382 ---------------- .../__tests__/session-open-runtime.test.ts | 10 +- 32 files changed, 758 insertions(+), 819 deletions(-) rename src/{daemon/device/__tests__ => __tests__/daemon-composition/device}/device-ready.test.ts (88%) create mode 100644 src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts create mode 100644 src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts diff --git a/src/daemon/device/__tests__/device-ready.test.ts b/src/__tests__/daemon-composition/device/device-ready.test.ts similarity index 88% rename from src/daemon/device/__tests__/device-ready.test.ts rename to src/__tests__/daemon-composition/device/device-ready.test.ts index 4f6e10e119..7939fbcd34 100644 --- a/src/daemon/device/__tests__/device-ready.test.ts +++ b/src/__tests__/daemon-composition/device/device-ready.test.ts @@ -1,6 +1,6 @@ -import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { promises as fs } from 'node:fs'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => { @@ -28,13 +28,18 @@ import { IOS_DEVICE, IOS_SIMULATOR, } from '@agent-device/test-kit/device-fixtures'; -import { DEVICE_READY_CACHE_TTL_MS, ensureDeviceReady } from '../device-ready.ts'; +import { + DEVICE_READY_CACHE_TTL_MS, + ensureDeviceReady, +} from '../../../daemon/device/device-ready.ts'; const mockRunCmd = vi.mocked(runCmd); const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); const mockWaitForAndroidBoot = vi.mocked(waitForAndroidBoot); -// Readiness mechanics stay the real ones: the fixture delegates to the root adapter, so the -// platform-leaf mocks above still own what the port actually calls. +// 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 and owns only the TTL-cache +// contract, pinned by the daemon's own claim suites. const platformServices = daemonPlatformServicesFixture(); beforeEach(() => { diff --git a/src/__tests__/daemon-composition/generic-settle.test.ts b/src/__tests__/daemon-composition/generic-settle.test.ts index 735294fd80..c216df04e0 100644 --- a/src/__tests__/daemon-composition/generic-settle.test.ts +++ b/src/__tests__/daemon-composition/generic-settle.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; diff --git a/src/__tests__/daemon-composition/gesture-admission-parity.test.ts b/src/__tests__/daemon-composition/gesture-admission-parity.test.ts index 5feb630824..0217c1f6a1 100644 --- a/src/__tests__/daemon-composition/gesture-admission-parity.test.ts +++ b/src/__tests__/daemon-composition/gesture-admission-parity.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test } from 'vitest'; import type { GestureCommandInput, diff --git a/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts b/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts new file mode 100644 index 0000000000..d6a7efc6e7 --- /dev/null +++ b/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts @@ -0,0 +1,152 @@ +import { test, expect } from 'vitest'; +import * as path from 'node:path'; +import { + mockResolveTargetDevice, + makeSessionStore, + makeSession, + noopInvoke, + assertInvalidArgsMessage, +} from '../../../daemon/handlers/__tests__/session-test-harness.ts'; +import { handleSessionCommands } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; + +// These relaunch cases pin the REAL open-target validation rules (the URL-target refusal and the +// Android installed-package classification with its exact message) reached through the daemon's +// `open --relaunch` flow. The daemon-lane port fixture answers `validateOpenRelaunchTarget` with a +// canned accept, so the rule-pinning cases run here on the real composed services. + +test('open --relaunch rejects URL targets', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['https://example.com/path'], + flags: { relaunch: true }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/does not support URL targets/i); + } +}); + +test('open --relaunch rejects Android app binary paths', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['/tmp/app-debug.apk'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', + ); +}); + +test('open --relaunch rejects bare Android app binary filenames', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['app-debug.apk'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Android runtime hints require an installed package name, not "app-debug.apk". Install or reinstall the app first, then relaunch by package.', + ); +}); + +test('open --relaunch rejects Android app binary paths for active sessions', async () => { + const sessionStore = makeSessionStore(); + const session = makeSession('default', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }); + session.appName = 'com.example.app'; + session.appBundleId = 'com.example.app'; + sessionStore.publish('default', session); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['/tmp/app-debug.apk'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', + ); +}); + +test('open --relaunch rejects Android app binary paths before resolving a new device', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'default', + command: 'open', + positionals: ['/tmp/app-debug.apk'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage( + response, + 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', + ); + expect(mockResolveTargetDevice).not.toHaveBeenCalled(); +}); diff --git a/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts b/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts index 456c151e15..9ac56b6af1 100644 --- a/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts +++ b/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { test, expect, vi, afterEach, beforeEach } from 'vitest'; import { legacyDispatchCapture } from '../../../daemon/__tests__/legacy-snapshot-capture-fixture.ts'; import { resetGetRuntimeFixture } from '../../../daemon/__tests__/interaction-get-runtime-fixture.ts'; diff --git a/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts b/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts index 8fc3e5ef25..ff3ed1f6c6 100644 --- a/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts +++ b/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { test, expect, vi, afterEach, beforeEach } from 'vitest'; import { legacyDispatchCapture } from '../../../daemon/__tests__/legacy-snapshot-capture-fixture.ts'; import { resetGetRuntimeFixture } from '../../../daemon/__tests__/interaction-get-runtime-fixture.ts'; diff --git a/src/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts b/src/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts index b1cea82bbf..d7ff44e617 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { beforeEach, test, vi } from 'vitest'; diff --git a/src/__tests__/daemon-composition/interaction/interaction-gesture-drag.test.ts b/src/__tests__/daemon-composition/interaction/interaction-gesture-drag.test.ts index c19b200dea..8c9d1dc5f3 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-gesture-drag.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-gesture-drag.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { beforeEach, expect, test } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { diff --git a/src/__tests__/daemon-composition/interaction/interaction-settle.test.ts b/src/__tests__/daemon-composition/interaction/interaction-settle.test.ts index 0f5a4f51ef..0e346fd42d 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-settle.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-settle.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import { legacyDispatchCapture } from '../../../daemon/__tests__/legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; diff --git a/src/__tests__/daemon-composition/interaction/interaction-touch-response.test.ts b/src/__tests__/daemon-composition/interaction/interaction-touch-response.test.ts index ab22406e52..d0f75322e9 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-touch-response.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-touch-response.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { diff --git a/src/__tests__/daemon-composition/is-runtime.test.ts b/src/__tests__/daemon-composition/is-runtime.test.ts index a319bb5bc6..df9b5ab578 100644 --- a/src/__tests__/daemon-composition/is-runtime.test.ts +++ b/src/__tests__/daemon-composition/is-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { SnapshotResult } from '@agent-device/contracts/snapshot-runtime'; import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; diff --git a/src/__tests__/daemon-composition/request-handler-chain.test.ts b/src/__tests__/daemon-composition/request-handler-chain.test.ts index 599ee3230e..37d840d871 100644 --- a/src/__tests__/daemon-composition/request-handler-chain.test.ts +++ b/src/__tests__/daemon-composition/request-handler-chain.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts index 39705d0723..0f591817c5 100644 --- a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts +++ b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts @@ -17,7 +17,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; // must hand the acquire path the OWNER-scoped reconciler, so a foreign dead owner's recording // recovery executes against that owner's state dir, never the daemon's own store (#2168). - vi.mock('../../daemon/device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); diff --git a/src/__tests__/daemon-composition/request-router-recording-health.test.ts b/src/__tests__/daemon-composition/request-router-recording-health.test.ts index e3109cb15e..fc76800eb9 100644 --- a/src/__tests__/daemon-composition/request-router-recording-health.test.ts +++ b/src/__tests__/daemon-composition/request-router-recording-health.test.ts @@ -5,7 +5,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import path from 'node:path'; import { testCreateCommandSurface } from './command-surface-fixture.ts'; -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { createRequestHandler, gestureDeviceRuntimeGateway, diff --git a/src/__tests__/daemon-composition/screenshot-runtime.test.ts b/src/__tests__/daemon-composition/screenshot-runtime.test.ts index 0ee2edd9b1..8c038268dc 100644 --- a/src/__tests__/daemon-composition/screenshot-runtime.test.ts +++ b/src/__tests__/daemon-composition/screenshot-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts b/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts index c475a8ca2b..86acfbd783 100644 --- a/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts +++ b/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../daemon/__tests__/support/store-factory.ts'; diff --git a/src/__tests__/daemon-composition/selector-runtime-backend.test.ts b/src/__tests__/daemon-composition/selector-runtime-backend.test.ts index 584f87f1b9..555a9bd984 100644 --- a/src/__tests__/daemon-composition/selector-runtime-backend.test.ts +++ b/src/__tests__/daemon-composition/selector-runtime-backend.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import type { diff --git a/src/__tests__/daemon-composition/selector-runtime.test.ts b/src/__tests__/daemon-composition/selector-runtime.test.ts index 464b4167e7..dea473e801 100644 --- a/src/__tests__/daemon-composition/selector-runtime.test.ts +++ b/src/__tests__/daemon-composition/selector-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test } from 'vitest'; import { localRuntimeOwner, 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 new file mode 100644 index 0000000000..3e5c909f7b --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts @@ -0,0 +1,407 @@ +import { test, expect } from 'vitest'; +import * as path from 'node:path'; +import { + mockLifecycleDispatch as mockDispatch, + mockResolveTargetDevice, + mockResolveAndroidPackage, + mockRunCmd, + makeSessionStore, + makeSession, + noopInvoke, + assertInvalidArgsMessage, +} from '../../../daemon/handlers/__tests__/session-test-harness.ts'; +import { + handleSessionCommands, + setHarnessLifecycleOperationsLoader, +} from '../../../daemon/handlers/__tests__/session-command-harness.ts'; +import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { daemonPlatformServicesFixture } 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's leaf mocks must register first, so the loader is installed here +// (the loader is read when bindings are built) instead of importing the install module on top. +setHarnessLifecycleOperationsLoader(applicationLifecycleRuntimeFixture); + +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: daemonPlatformServicesFixture(), + 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: daemonPlatformServicesFixture(), + 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: 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).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: daemonPlatformServicesFixture(), + 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: daemonPlatformServicesFixture(), + 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: 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); + 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: daemonPlatformServicesFixture(), + 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: daemonPlatformServicesFixture(), + 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/snapshot-command-runtime.test.ts b/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts index f4bcd18c48..746d0cd923 100644 --- a/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts +++ b/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { afterEach, expect, test } from 'vitest'; import { makeAndroidSession } from '../../daemon/__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../daemon/__tests__/support/store-factory.ts'; diff --git a/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts b/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts index 0adbfbc7c6..9b6d231c90 100644 --- a/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts +++ b/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { type DeviceBinding, diff --git a/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts b/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts index b9a272a193..6a39245301 100644 --- a/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts +++ b/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import path from 'node:path'; import { expect, test } from 'vitest'; import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; diff --git a/src/__tests__/daemon-composition/wait-runtime.test.ts b/src/__tests__/daemon-composition/wait-runtime.test.ts index 0246853bb4..7044063f0a 100644 --- a/src/__tests__/daemon-composition/wait-runtime.test.ts +++ b/src/__tests__/daemon-composition/wait-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { WAIT_REASONS, type ReadinessPhase } from '@agent-device/contracts/wait'; import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; diff --git a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index 1bbe1ac4e3..7a38e42293 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -8,9 +8,8 @@ import type { ApplicationLifecycleRuntimeOperations, RuntimeHintsApplicationInput, } from '@agent-device/contracts/application-lifecycle-runtime'; -import { fixtureResolveSessionAppBundleIdForTarget } from './platform-services-fixture.ts'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; -import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { TargetShutdownResult } from '@agent-device/contracts/device'; type LifecycleEffectContext = ApplicationLifecycleExecution & @@ -72,12 +71,58 @@ const prepareAppleRunnerFixture = vi.fn( }> => ({ runner: { currentUptimeMs: 42 }, connectMs: 3, healthCheckMs: 3 }), ); +/** + * The canned `resolveOpenTarget` answer: the contract's canonical direct-lifecycle resolution for + * 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. + */ +export const resolveOpenTargetFixture = vi.fn( + async ( + _device: DeviceInfo, + directResolve: NonNullable, + input: Parameters>[0], + ) => await directResolve(input), +); + +/** + * The canned `prepareApplicationOpen` answer: pause at the fixture's readiness seam and succeed. + * The per-platform readiness sequencing (android discovery for a stopped AVD, simulator cold boot) + * is the composed owners' rule, pinned under `src/__tests__/daemon-composition/`. + */ +export const prepareApplicationOpenFixture = vi.fn(async (device: DeviceInfo): Promise => { + await awaitFixtureReadiness(device); +}); + +/** + * The canned `openApplication` answer: apply the fact-admitted runtime hints at the port when the + * open requests them, then run the contract's canonical direct open (the `directOpen` the fixture + * passes in). Relaunch identity, runner prewarm policy, and post-open observation are the composed + * owners' rules, pinned under `src/__tests__/daemon-composition/`. + */ +export const openApplicationFixture = vi.fn( + async ( + _device: DeviceInfo, + directOpen: NonNullable, + input: Parameters>[0], + ) => { + if (input.applyRuntimeHints) { + await input.applyRuntimeHints({ appId: input.appBundleId, values: input.runtimeHints }); + } + return await directOpen(input); + }, +); + /** * The `ApplicationLifecycleRuntimeOperations` port as the daemon suites need it: the contract's - * canonical direct lifecycle over the fixture interactor's spies, with readiness, runtime hints, - * and runner preparation answered by port spies. The real apple/android sequencing (boot, runner - * prewarm policy, relaunch identity, post-open observation) belongs to the platform packages and - * is pinned under `src/__tests__/daemon-composition/`. + * canonical direct lifecycle over the fixture interactor's spies, with open-target resolution, + * readiness, open sequencing, runtime hints, and runner preparation answered by port spies whose + * canned answers carry no platform rule. The real apple/android sequencing (boot, runner prewarm + * policy, relaunch identity, post-open observation) belongs to the platform packages and is pinned + * under `src/__tests__/daemon-composition/`. */ export async function applicationLifecycleRuntimeFixture( device: DeviceInfo, @@ -103,50 +148,16 @@ export async function applicationLifecycleRuntimeFixture( }), }); const directOpenApplication = direct.openApplication; + const directResolveOpenTarget = direct.resolveOpenTarget; return Object.freeze({ ...direct, - resolveOpenTarget: async (input) => { - if ( - isMacOs(device) && - input.surface !== undefined && - input.surface !== 'app' && - input.surface !== 'desktop' && - input.surface !== 'menubar' - ) { - // Frontmost surfaces resolve through the macos package leaf the suites already mock. - const { resolveFrontmostMacOsApp } = await import('@agent-device/platform-apple/macos'); - const frontmost = await resolveFrontmostMacOsApp(); - return Object.freeze({ - ...(frontmost.bundleId === undefined ? {} : { appBundleId: frontmost.bundleId }), - ...(input.target === undefined - ? frontmost.appName === undefined - ? {} - : { appName: frontmost.appName } - : { appName: input.target }), - }); - } - const appBundleId = await fixtureResolveSessionAppBundleIdForTarget( - device, - input.target, - input.currentAppBundleId, - ); - return Object.freeze({ - ...(appBundleId === undefined ? {} : { appBundleId }), - ...(input.target === undefined ? {} : { appName: input.target }), - }); - }, - prepareApplicationOpen: async (_input) => { - await awaitFixtureReadiness(device); - if (device.platform === 'android' && device.booted !== true) { - await discoverReadyAndroidEmulators(device); - } - }, - openApplication: async (input) => { - if (input.applyRuntimeHints) { - await input.applyRuntimeHints({ appId: input.appBundleId, values: input.runtimeHints }); - } - return await directOpenApplication(input); + resolveOpenTarget: async (input) => + await resolveOpenTargetFixture(device, directResolveOpenTarget, input), + prepareApplicationOpen: async () => { + await prepareApplicationOpenFixture(device); }, + openApplication: async (input) => + await openApplicationFixture(device, directOpenApplication, input), applyRuntimeHints: async (input) => { await applyRuntimeHintsFixture({ device, ...input }); }, diff --git a/src/daemon/__tests__/platform-services-fixture.ts b/src/daemon/__tests__/platform-services-fixture.ts index 98b04e5a86..28455be6f4 100644 --- a/src/daemon/__tests__/platform-services-fixture.ts +++ b/src/daemon/__tests__/platform-services-fixture.ts @@ -1,227 +1,96 @@ import { vi } from 'vitest'; -import { - IOS_SAFARI_BUNDLE_ID, - isDeepLinkTarget, - isWebUrl, - resolveIosDeviceDeepLinkBundleId, -} from '@agent-device/contracts/command'; -import { parseSessionSurface, type SessionSurface } from '@agent-device/contracts/session'; -import { - isApplePlatform, - isIosFamily, - isMacOs, - type DeviceInfo, -} from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; -import { - classifyAndroidAppTarget, - formatAndroidInstalledPackageRequiredMessage, -} from '@agent-device/platform-android/mechanics'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { SessionSurface } from '@agent-device/contracts/session'; +import type { DeviceBootObservation } from '@agent-device/contracts/device-boot'; import type { ClaimRecoveryGatewayFactory } from '../device/claim-recovery-gateway.ts'; import type { DaemonPlatformServices } from '../platform-services.ts'; import { unavailableDeviceRuntimeGateway } from './support/unavailable-device-runtime-gateway.ts'; -/** The `DaemonPlatformServices` port: daemon suites assert when each ask happens; every host leaf behind a member resolves through the platform packages, so suite mocks on the package leaves answer the member. A suite overrides the single member it cares about, as production wiring replaces one composed capability. Members are module-level stable references so two fixture instances deep-compare equal. */ +/** + * The `DaemonPlatformServices` port as the daemon suites answer it: every member is a plain + * `vi.fn` spy with a canned answer, never a re-implementation of a root platform rule. A suite + * that cares about one ask overrides just that member — per case through the `overrides` + * argument, or by mocking this module's exported spy — and asserts the daemon's branching on + * the answer. What the composed members actually do (surface, open-target, readiness, and boot + * rules over the platform packages) is owned by the composition lane, which builds the real + * port through `src/__tests__/daemon-composition/platform-services-fixture.ts`. Members are + * module-level stable references so two fixture instances deep-compare equal. + */ export function daemonPlatformServicesFixture( overrides: Partial = {}, ): DaemonPlatformServices { return Object.freeze({ - appleSessionObservation: fixtureAppleSessionObservation, - deviceBootObservation: fixtureDeviceBootObservation, - ensureLocalDeviceReady: fixtureEnsureLocalDeviceReady, - resolveRequestedOpenSurface: fixtureResolveRequestedOpenSurface, - validateOpenRelaunchTarget: fixtureValidateOpenRelaunchTarget, - resolveSessionAppBundleIdForTarget: fixtureResolveSessionAppBundleIdForTarget, + appleSessionObservation: Object.freeze({ + observeRunnerSession: observeRunnerSessionFixture, + resolveSoleForegroundApp: resolveSoleForegroundAppFixture, + }), + deviceBootObservation: Object.freeze({ + observeBootTimeMs: observeBootTimeMsFixture, + }), + ensureLocalDeviceReady: ensureLocalDeviceReadyFixture, + resolveRequestedOpenSurface: resolveRequestedOpenSurfaceFixture, + validateOpenRelaunchTarget: validateOpenRelaunchTargetFixture, + resolveSessionAppBundleIdForTarget: resolveSessionAppBundleIdForTargetFixture, ...overrides, }); } -const fixtureAppleSessionObservation = Object.freeze({ - observeRunnerSession: async () => undefined, - resolveSoleForegroundApp: async () => undefined, -}); - -const fixtureDeviceBootObservation = Object.freeze({ - observeBootTimeMs: async (device: DeviceInfo) => { - if (isIosFamily(device) && device.kind === 'simulator') { - const { observeSimulatorBootTimeMs } = - await import('@agent-device/platform-apple/simulator-boot'); - return await observeSimulatorBootTimeMs(device); - } - if (device.platform === 'android') { - const { observeAndroidBootTimeMs } = - await import('@agent-device/platform-android/device-boot'); - return await observeAndroidBootTimeMs(device); - } - return { observed: false, reason: 'unsupported-device' } as const; - }, -}); - -async function fixtureEnsureLocalDeviceReady(device: DeviceInfo): Promise { - if (isIosFamily(device)) { - if (device.kind === 'simulator') { - const { ensureBootedSimulator } = await import('@agent-device/platform-apple/simulator'); - await ensureBootedSimulator(device); - return true; - } - if (device.kind === 'device') { - const { resolveIosPhysicalDeviceControl } = - await import('@agent-device/platform-apple/physical-device'); - const control = resolveIosPhysicalDeviceControl(device); - await control.ensureReady(device); - return true; - } - } - if (device.platform === 'android') { - const { waitForAndroidBoot } = await import('@agent-device/platform-android/mechanics'); - await waitForAndroidBoot(device.id); - return true; - } - return false; -} - -function fixtureResolveRequestedOpenSurface(params: { - device: DeviceInfo; - surfaceFlag: string | undefined; - openTarget: string | undefined; - existingSurface?: SessionSurface; -}): SessionSurface { - const { device, surfaceFlag, openTarget, existingSurface } = params; - if (device.platform === 'linux') { - return resolveSurfaceFor('linux', surfaceFlag, openTarget, existingSurface); - } - if (isMacOs(device)) { - return resolveSurfaceFor('macos', surfaceFlag, openTarget, existingSurface); - } - if (surfaceFlag) { - throw new AppError('INVALID_ARGS', 'surface is only supported on macOS and Linux'); - } - return 'app'; -} +/** The canned runner-session observation: no live runner on any device. */ +export const observeRunnerSessionFixture = vi.fn(async (_deviceId: string) => undefined); -async function fixtureValidateOpenRelaunchTarget(params: { - target: string | undefined; - platform: string | undefined; - surface?: SessionSurface; -}): Promise { - const { target, platform, surface } = params; - if (target && isDeepLinkTarget(target)) { - return 'open --relaunch does not support URL targets.'; - } - if (surface !== undefined && surface !== 'app') { - return 'open --relaunch is supported only for app surfaces.'; - } - if (platform === 'android' && target) { - if (classifyAndroidAppTarget(target) === 'binary') { - return formatAndroidInstalledPackageRequiredMessage(target); - } - } - return undefined; -} +/** The canned foreground answer: nothing observable to name a sole foreground app. */ +export const resolveSoleForegroundAppFixture = vi.fn( + async (_params: Readonly<{ simulatorSetPath?: string }>) => undefined, +); /** - * The open-target bundle-id rules as the fixture answers them: contract Safari/deep-link rules and - * the platform packages' own resolvers, no root open-target module. Both the services port and the - * canned lifecycle `resolveOpenTarget` answer through this single implementation. + * The canned boot answer: the boot moment stays unobserved, which keeps callers as cautious as + * an unanswered probe leaves them. The unobserved-vs-unsupported split the real observation + * reports per device family is pinned in the composition lane. */ -export async function fixtureResolveSessionAppBundleIdForTarget( - device: DeviceInfo, - openTarget: string | undefined, - currentAppBundleId: string | undefined, -): Promise { - if (device.platform === 'harmonyos') { - return bundleIdFromOpenTarget(openTarget) ?? currentAppBundleId; - } - return ( - (await resolveIosBundleIdForOpen(device, openTarget, currentAppBundleId)) ?? - (await resolveAndroidPackage(device, openTarget)) ?? - (device.platform === 'android' && openTarget && isDeepLinkTarget(openTarget) - ? currentAppBundleId - : undefined) - ); -} - -const LINUX_SURFACES: ReadonlySet = new Set(['app', 'desktop', 'frontmost-app']); +export const observeBootTimeMsFixture = vi.fn( + async (_device: DeviceInfo): Promise => + ({ observed: false, reason: 'unobserved' }) as const, +); -function resolveSurfaceFor( - os: 'linux' | 'macos', - surfaceFlag: string | undefined, - openTarget: string | undefined, - existingSurface: SessionSurface | undefined, -): SessionSurface { - if (!surfaceFlag) return existingSurface ?? 'app'; - const surface = parseSessionSurface(surfaceFlag); - if (os === 'linux' && !LINUX_SURFACES.has(surface)) { - throw new AppError( - 'INVALID_ARGS', - `Linux supports --surface app, desktop, and frontmost-app (got "${surfaceFlag}")`, - ); - } - if (surface !== 'app' && surface !== 'menubar' && openTarget) { - throw new AppError('INVALID_ARGS', `open --surface ${surface} does not accept an app target`); - } - return surface; -} - -async function resolveIosBundleIdForOpen( - device: DeviceInfo, - openTarget: string | undefined, - currentAppBundleId: string | undefined, -): Promise { - if (!isApplePlatform(device.platform) || !openTarget) return undefined; - if (isDeepLinkTarget(openTarget)) { - if (isMacOs(device)) return undefined; - if (device.kind === 'device') { - return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget); - } - if (!isWebUrl(openTarget)) { - return currentAppBundleId ?? (await resolveSimulatorDeepLinkBundleId(device, openTarget)); - } - // simctl openurl opens a web URL in Safari, matching the real reader's Safari rule. - return IOS_SAFARI_BUNDLE_ID; - } - try { - const { resolveIosApp } = await import('@agent-device/platform-apple/app-resolution'); - return await resolveIosApp(device, openTarget); - } catch { - return undefined; - } -} +/** The canned readiness answer: local readiness succeeds; a suite rejects this to fail an open. */ +export const ensureLocalDeviceReadyFixture = vi.fn(async (_device: DeviceInfo) => true); -async function resolveSimulatorDeepLinkBundleId( - device: DeviceInfo, - openTarget: string, -): Promise { - try { - const { resolveIosSimulatorDeepLinkBundleId } = - await import('@agent-device/platform-apple/app-resolution'); - return await resolveIosSimulatorDeepLinkBundleId(device, openTarget); - } catch { - return undefined; - } -} +/** + * The canned surface answer: no platform validation, just the session's own surface carried + * forward or `app`. Suites needing another surface pass one through `overrides`. The macOS and + * Linux surface rules are pinned in the composition lane. + */ +export const resolveRequestedOpenSurfaceFixture = vi.fn( + (params: { + device: DeviceInfo; + surfaceFlag: string | undefined; + openTarget: string | undefined; + existingSurface?: SessionSurface; + }): SessionSurface => params.existingSurface ?? 'app', +); -async function resolveAndroidPackage( - device: DeviceInfo, - openTarget: string | undefined, -): Promise { - if (device.platform !== 'android' || !openTarget) return undefined; - try { - // Dynamic like the Apple branch: suites register their mechanics mocks after this fixture's - // module evaluates, so a static import would bind the unmocked leaf. - const { resolveAndroidPackageForOpen } = - await import('@agent-device/platform-android/mechanics'); - return await resolveAndroidPackageForOpen(device, openTarget); - } catch { - return undefined; - } -} +/** The canned relaunch answer: every requested relaunch target is acceptable. */ +export const validateOpenRelaunchTargetFixture = vi.fn( + async (_params: { + target: string | undefined; + platform: string | undefined; + surface?: SessionSurface; + }) => undefined, +); -function bundleIdFromOpenTarget(openTarget: string | undefined): string | undefined { - const trimmed = openTarget?.trim(); - if (!trimmed || isDeepLinkTarget(trimmed) || !trimmed.includes('.')) return undefined; - return trimmed; -} +/** + * 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). + */ +export const resolveSessionAppBundleIdForTargetFixture = vi.fn( + async ( + _device: DeviceInfo, + _openTarget: string | undefined, + currentAppBundleId: string | undefined, + ): Promise => currentAppBundleId, +); /** * The claim-recovery gateway factory port: recovery transactions in daemon suites get a gateway diff --git a/src/daemon/__tests__/request-router-open-claim.test.ts b/src/daemon/__tests__/request-router-open-claim.test.ts index 65ddd3e71a..b8d55c25b3 100644 --- a/src/daemon/__tests__/request-router-open-claim.test.ts +++ b/src/daemon/__tests__/request-router-open-claim.test.ts @@ -44,17 +44,9 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosApp: vi.fn(async (_device, app) => app), }; }); -// Claim settlement asks the device when its current boot began; these tests answer by hand rather -// than reading the host's real process table. -const mockObserveSimulatorBoot = vi.hoisted(() => - vi.fn(async (): Promise => ({ - observed: false, - reason: 'unobserved', - })), -); -vi.mock('@agent-device/platform-apple/simulator-boot', () => ({ - observeSimulatorBootTimeMs: mockObserveSimulatorBoot, -})); +// Claim settlement asks the device when its current boot began; the daemon port fixture answers at +// `observeBootTimeMsFixture`, so these tests answer through that spy rather than mocking a leaf. +import { observeBootTimeMsFixture } from './platform-services-fixture.ts'; import { createRequestHandler, @@ -67,7 +59,6 @@ import { } from './application-lifecycle-runtime-fixture.ts'; import { ensureDeviceReady } from '../device/device-ready.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { DeviceBootObservation } from '@agent-device/contracts/device-boot'; import { makeSessionStore } from './support/store-factory.ts'; import { inspectDeviceClaims } from '../device/device-claim-inspection.ts'; import { makeIosDevice, openRequest, storedClaimUpdatedAt } from './request-router-open-harness.ts'; @@ -76,6 +67,7 @@ import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); +const mockObserveBoot = vi.mocked(observeBootTimeMsFixture); const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); function createOpenHandler( @@ -100,11 +92,8 @@ beforeEach(() => { mockEnsureDeviceReady.mockResolvedValue(undefined); mockAwaitFixtureReadiness.mockReset(); mockAwaitFixtureReadiness.mockResolvedValue(undefined); - mockObserveSimulatorBoot.mockReset(); - mockObserveSimulatorBoot.mockImplementation(async () => ({ - observed: false, - reason: 'unobserved', - })); + mockObserveBoot.mockReset(); + mockObserveBoot.mockResolvedValue({ observed: false, reason: 'unobserved' }); mockDiscoverReadyAndroidEmulators.mockReset(); }); @@ -145,7 +134,7 @@ test('open takes a live foreign claim whose device rebooted after the claim was const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; const bootedAtMs = Date.now(); - mockObserveSimulatorBoot.mockResolvedValueOnce({ observed: true, bootedAtMs }); + mockObserveBoot.mockResolvedValueOnce({ observed: true, bootedAtMs }); try { seedLiveForeignClaim(claimsDir, device, foreignStateDir); @@ -156,7 +145,7 @@ test('open takes a live foreign claim whose device rebooted after the claim was expect(response.ok).toBe(true); if (!response.ok) return; - expect(mockObserveSimulatorBoot).toHaveBeenCalledWith(device); + expect(mockObserveBoot).toHaveBeenCalledWith(device); expect(response.data?.warnings).toContain( 'Took the device from session "shared" in workspace "/worktrees/live": that device rebooted after its claim was taken, so its app and runner were already gone.', ); @@ -194,7 +183,7 @@ test('open renews the claim of an existing session that reopened its app after a expect(reopened.ok).toBe(true); expect(storedClaimUpdatedAt(device)).toBeGreaterThan(claimedAtMs); - mockObserveSimulatorBoot.mockResolvedValueOnce({ + mockObserveBoot.mockResolvedValueOnce({ observed: true, bootedAtMs: claimedAtMs + 1, }); diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index 398ac2ad1a..6c3891ad91 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -40,8 +40,8 @@ import { } from '@agent-device/platform-android/mechanics'; import { applyRuntimeHintsFixture, - discoverReadyAndroidEmulators, dispatchApplicationLifecycleEffect, + prepareApplicationOpenFixture, } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; import { clearRequestCanceled, markRequestCanceled } from '@agent-device/host-kit/request'; import { acquireDeviceClaim as acquireProductionDeviceClaim } from '../../device/device-claims.ts'; @@ -65,7 +65,7 @@ import { import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); +const mockPrepareApplicationOpen = vi.mocked(prepareApplicationOpenFixture); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); @@ -128,14 +128,14 @@ const android: DeviceInfo = { test('failed local open before device setup rolls its device claim back', async () => { const { store, stateDir } = setup(); - // A stopped AVD, so preparation actually runs emulator discovery. Readiness is package-owned - // now, so this is where a pre-device-effect failure surfaces. const stoppedAvd = { ...android, id: 'pixel-avd', booted: false }; mockResolveTargetDevice.mockResolvedValue(stoppedAvd); // The point of this test is claim-rollback behavior around an opaque pre-device-effect // failure, not any particular error shape, so assert identity of the propagated error. + // Readiness is package-owned, so the failure is injected at the lifecycle port's prepare + // step; which devices the real owners run discovery for is pinned in the composition lane. const rejectionError = new Error('device not ready'); - mockDiscoverReadyAndroidEmulators.mockRejectedValue(rejectionError); + mockPrepareApplicationOpen.mockRejectedValueOnce(rejectionError); await assert.rejects( async () => diff --git a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts index c47a1e74ba..1276065c3f 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts @@ -7,39 +7,12 @@ import { makeSessionStore, makeSession, noopInvoke, - assertInvalidArgsMessage, } from './session-test-harness.ts'; import { handleSessionCommands } from './session-command-harness.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/support/screen-recording-live-handle.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; -test('open --relaunch rejects URL targets', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['https://example.com/path'], - flags: { relaunch: true }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/does not support URL targets/i); - } -}); - test('open --relaunch fails without app when no session exists', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ @@ -66,114 +39,6 @@ test('open --relaunch fails without app when no session exists', async () => { } }); -test('open --relaunch rejects Android app binary paths', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects bare Android app binary filenames', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects Android app binary paths for active sessions', async () => { - const sessionStore = makeSessionStore(); - const session = makeSession('default', { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }); - session.appName = 'com.example.app'; - session.appBundleId = 'com.example.app'; - sessionStore.publish('default', session); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects Android app binary paths before resolving a new device', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); - expect(mockResolveTargetDevice).not.toHaveBeenCalled(); -}); - test('open on in-use device returns DEVICE_IN_USE before readiness checks', async () => { const sessionStore = makeSessionStore(); sessionStore.publish( diff --git a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts index 464129e73a..cc008dd3ba 100644 --- a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts +++ b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts @@ -1,4 +1,7 @@ -import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; +import { + daemonPlatformServicesFixture, + resolveSessionAppBundleIdForTargetFixture, +} from '../../__tests__/platform-services-fixture.ts'; import { test, expect, vi } from 'vitest'; import * as path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -39,10 +42,16 @@ const keyboardFamilyDenial = Object.freeze({ reason: 'owner-capability-missing' as const, }); +const mockResolveSessionAppBundleId = vi.mocked(resolveSessionAppBundleIdForTargetFixture); + test.each(['rebuild', 'retire'] as const)( 'app-event metadata and action stay in the admitted lifetime across %s', async (transition) => { vi.stubEnv('AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE', 'https://example.test/{event}'); + // The event URL's app identity is a canned per-case answer at the services port (what the + // real open-target rule answers for a web URL is pinned in the composition lane); this + // suite pins the daemon's admitted-lifetime behavior around it. + mockResolveSessionAppBundleId.mockResolvedValue('com.apple.mobilesafari'); try { const sessionStore = makeSessionStore(); const address = 'cwd:held-app-event:default'; diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index bada713271..7bac120923 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -59,6 +59,7 @@ import { applyRuntimeHintsFixture, clearRuntimeHintsFixture, dispatchApplicationLifecycleEffect, + resolveOpenTargetFixture, } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { @@ -73,6 +74,7 @@ const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFixture); const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); +const mockResolveOpenTarget = vi.mocked(resolveOpenTargetFixture); beforeEach(() => { vi.clearAllMocks(); @@ -99,7 +101,12 @@ test('open runtime payload replaces stored session runtime atomically', async () }> = []; mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator()); - mockResolveAndroidPackage.mockResolvedValue('com.example.demo'); + // The open-target identity is a canned per-case answer at the port; what the real owners + // resolve for an Android name is pinned in the composition lane. + mockResolveOpenTarget.mockImplementation(async (_device, _direct, input) => ({ + appBundleId: 'com.example.demo', + appName: input.target, + })); mockApplyRuntimeHints.mockImplementation(async ({ appId, values }) => { runtimeApplyCalls.push({ appId, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts index 3f63694342..2b101ddc64 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts @@ -6,12 +6,9 @@ import { AppError } from '@agent-device/kernel/errors'; import { mockLifecycleDispatch as mockDispatch, mockResolveTargetDevice, - mockResolveAndroidPackage, - mockRunCmd, makeSessionStore, makeSession, noopInvoke, - assertInvalidArgsMessage, withMockedPlatform, } from '../../../handlers/__tests__/session-test-harness.ts'; import type { SessionState } from '../../../session-state.ts'; @@ -19,293 +16,6 @@ import { handleSessionCommands } from '../../../handlers/__tests__/session-comma import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; -test('open web URL on iOS device session without active app falls back to Safari', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-session'; - sessionStore.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: daemonPlatformServicesFixture(), - 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: daemonPlatformServicesFixture(), - 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: 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).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: daemonPlatformServicesFixture(), - 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: daemonPlatformServicesFixture(), - 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: 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); - const session = sessionStore.get(sessionName); - expect(session?.surface).toBe('frontmost-app'); - expect(session?.appBundleId).toBe('com.apple.TextEdit'); - expect(session?.appName).toBe('TextEdit'); - if (response && response.ok) { - expect(response.data?.surface).toBe('frontmost-app'); - } - } finally { - if (prevHelper === undefined) delete process.env.AGENT_DEVICE_MACOS_HELPER_BIN; - else process.env.AGENT_DEVICE_MACOS_HELPER_BIN = prevHelper; - } -}); - test('open on existing iOS session refreshes unavailable simulator by name', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-session'; @@ -374,97 +84,6 @@ test('open on existing iOS session refreshes unavailable simulator by name', asy } }); -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: daemonPlatformServicesFixture(), - 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: daemonPlatformServicesFixture(), - 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); -}); - test('open on existing Android session preserves a comparable freshness baseline', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-open-freshness'; @@ -494,7 +113,6 @@ test('open on existing Android session preserves a comparable freshness baseline }); mockDispatch.mockResolvedValue(undefined); - mockResolveAndroidPackage.mockResolvedValue('com.android.settings'); const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts index 4f2fda9860..eca8ad39b7 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts @@ -54,6 +54,7 @@ import { import { applyRuntimeHintsFixture, dispatchApplicationLifecycleEffect, + resolveOpenTargetFixture, } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { lifecycleRuntimeFacts } from '../../../__tests__/application-lifecycle-runtime-harness.ts'; @@ -69,11 +70,13 @@ import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); +const mockResolveOpenTarget = vi.mocked(resolveOpenTargetFixture); beforeEach(() => { vi.clearAllMocks(); mockDispatch.mockImplementation(async () => ({})); mockResolveAndroidPackage.mockResolvedValue(undefined); + mockResolveOpenTarget.mockReset(); }); test('open applies stored runtime launchUrl and reports runtime hints', async () => { @@ -89,7 +92,12 @@ test('open applies stored runtime launchUrl and reports runtime hints', async () const callOrder: string[] = []; mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator()); - mockResolveAndroidPackage.mockResolvedValue('com.example.demo'); + // The open-target identity is a canned per-case answer at the port; what the real owners + // resolve for an Android name is pinned in the composition lane. + mockResolveOpenTarget.mockImplementation(async (_device, _direct, input) => ({ + appBundleId: 'com.example.demo', + appName: input.target, + })); mockApplyRuntimeHints.mockImplementation(async ({ appId, values }) => { callOrder.push('runtime'); runtimeApplyCalls.push({ From e3375a6df1d01dc4e944d5ddd65195b5699c8f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 06:11:21 +0200 Subject: [PATCH 05/23] chore(gates): commit the daemon residue scan the 11a slices measure with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review could not find the scan the PR body reported, and the previous ad-hoc copy under-reported edges: its single alternation bridged `import|export … [\s\S]*? from`, so a vi.mock() specifier sitting between an importOriginal type-import and the file's next static from clause was swallowed by the earlier match and never reported. Five mocks of root platform-runtime modules went unnoticed under it. This scan matches every specifier form independently — statement-anchored static import/export, side-effect import, dynamic import()/require(), vi.mock/doMock/unmock/importMock/importActual/importOriginal with string or template-literal arguments, and importOriginal — and exits 1 on any relative edge leaving src/daemon. Run: node scripts/daemon-edge-residue-scan.mjs. 11b's R11 package-boundary rule (#3546) makes it permanent once the daemon is its own package. --- scripts/daemon-edge-residue-scan.mjs | 84 ++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 scripts/daemon-edge-residue-scan.mjs diff --git a/scripts/daemon-edge-residue-scan.mjs b/scripts/daemon-edge-residue-scan.mjs new file mode 100644 index 0000000000..7c4f37119a --- /dev/null +++ b/scripts/daemon-edge-residue-scan.mjs @@ -0,0 +1,84 @@ +// Daemon residue scan (11a-4): every module specifier in a file under src/daemon/ that resolves +// to a non-package module outside src/daemon/. The rule: daemon files may import workspace +// packages, externals, and each other — nothing else. Once the daemon is its own package, R11 in +// the layering guard (#3546) enforces this permanently; until then this scan is the measurement. +// +// Why a naive regex reported zero edges while five mocks survived: a single alternation that +// starts at `import|export` and bridges to the next `from` with `[\s\S]*?` swallows everything in +// between — including a `vi.mock('../../../platform-runtime-….ts', …)` whose own specifier sits +// between an earlier `importOriginal` and the file's next static `from`. The +// mocked specifier is then attributed to the wrong (allowed) capture or skipped entirely. So each +// specifier form is matched independently, statement-anchored where the language is: +// +// - static `import`/`export … from '…'` and side-effect `import '…'`, anchored at a statement +// start (line start or `;`), never bridging statements; +// - `import type` / `export type` variants (same anchor); +// - dynamic `import('…')` / `require('…')` with a string or no-substitution template argument; +// - `vi.mock` / `vi.doMock` / `vi.unmock` / `vi.importMock` / `vi.importActual` / +// `vi.importOriginal` first argument, any position, string or template literal; +// - `importOriginal` type-position specifiers (the dynamic-import form). +// +// Known limits: computed specifiers (variables, string concatenation) are out of scope — none +// exist in the daemon tree, and a runtime resolver is the only tool that could see them. +// +// Usage: `node scripts/daemon-edge-residue-scan.mjs` from the repo root. Exit code 1 when any +// edge is found. +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); + +function walk(dir, out) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) walk(p, out); + else if (/\.(ts|tsx|mjs)$/.test(ent.name)) out.push(p); + } + return out; +} + +// A quoted string or no-substitution template literal as the specifier position. +const SPEC = '(?:\'([^\']*)\'|"([^"]*)"|`([^`]*)`)'; +const forms = [ + // Static import/export with a `from` clause; the keyword must start a statement. + new RegExp(`(?:^|[;\\n])\\s*(?:import|export)\\b[^'"()\`]*?\\bfrom\\s*${SPEC}`, 'gm'), + // Side-effect import. + new RegExp(`(?:^|[;\\n])\\s*import\\s+${SPEC}`, 'gm'), + // Dynamic import (also catches `typeof import('…')` in type positions) and require. + new RegExp(`\\bimport\\s*\\(\\s*${SPEC}`, 'g'), + new RegExp(`\\brequire\\s*\\(\\s*${SPEC}`, 'g'), + // Vitest module-factory hoists, any position in the file. + new RegExp( + `\\bvi\\.(?:mock|doMock|unmock|importMock|importActual|importOriginal)\\s*\\(\\s*${SPEC}`, + 'g', + ), +]; + +const files = walk(path.join(root, 'src/daemon'), []); +const edges = []; +for (const file of files) { + const rel = path.relative(root, file); + const source = fs.readFileSync(file, 'utf8'); + const seen = new Set(); + for (const form of forms) { + form.lastIndex = 0; + let match; + while ((match = form.exec(source))) { + const specifier = match[1] ?? match[2] ?? match[3]; + if (!specifier) continue; + if (!specifier.startsWith('.')) continue; // bare specifier: package or external, allowed + const resolved = path + .normalize(path.join(path.dirname(rel), specifier)) + .replace(/\.tsx?$/, ''); + if (resolved === 'src/daemon' || resolved.startsWith('src/daemon/')) continue; + if (seen.has(resolved)) continue; + seen.add(resolved); + const line = source.slice(0, match.index).split('\n').length; + edges.push(`${rel}:${line} -> ${resolved}`); + } + } +} +edges.sort(); +if (edges.length > 0) process.stdout.write(edges.join('\n') + '\n'); +process.stdout.write(`daemon residue edges: ${edges.length}\n`); +if (edges.length > 0) process.exit(1); From 4d38064c3e1f21269bb240e3657ea68a4f96a312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 06:14:38 +0200 Subject: [PATCH 06/23] refactor(daemon): keep the canned-answer spies unexported and register the scan entry Only the spies suites actually observe per case are exported; the rest stay module-private behind the fixture factory. The residue scan joins the .fallowrc entry list beside the other hand-run scripts. --- .fallowrc.json | 3 +++ .../__tests__/application-lifecycle-runtime-fixture.ts | 2 +- src/daemon/__tests__/platform-services-fixture.ts | 10 +++++----- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.fallowrc.json b/.fallowrc.json index 11c4c350f6..f41e7f33e9 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -29,6 +29,9 @@ "scripts/patch-xcuitest-runner-icon.ts", "scripts/write-xcuitest-cache-metadata.ts", "scripts/xcuitest-build-settings.ts", + // The 11a-4 residue scan runs by hand (and from CI prose until R11 in #3546 owns + // the rule); nothing in the dependency graph imports it. + "scripts/daemon-edge-residue-scan.mjs", "packages/capture-kit/src/ios-snapshot-engine/replay.ts", // #1596 regression fixture: runs as a real `node --experimental-strip-types` // subprocess (test/integration/daemon-replace-exit-flush.test.ts), so diff --git a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index 7a38e42293..64d5f69892 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -103,7 +103,7 @@ export const prepareApplicationOpenFixture = vi.fn(async (device: DeviceInfo): P * passes in). Relaunch identity, runner prewarm policy, and post-open observation are the composed * owners' rules, pinned under `src/__tests__/daemon-composition/`. */ -export const openApplicationFixture = vi.fn( +const openApplicationFixture = vi.fn( async ( _device: DeviceInfo, directOpen: NonNullable, diff --git a/src/daemon/__tests__/platform-services-fixture.ts b/src/daemon/__tests__/platform-services-fixture.ts index 28455be6f4..62dc9ae641 100644 --- a/src/daemon/__tests__/platform-services-fixture.ts +++ b/src/daemon/__tests__/platform-services-fixture.ts @@ -36,10 +36,10 @@ export function daemonPlatformServicesFixture( } /** The canned runner-session observation: no live runner on any device. */ -export const observeRunnerSessionFixture = vi.fn(async (_deviceId: string) => undefined); +const observeRunnerSessionFixture = vi.fn(async (_deviceId: string) => undefined); /** The canned foreground answer: nothing observable to name a sole foreground app. */ -export const resolveSoleForegroundAppFixture = vi.fn( +const resolveSoleForegroundAppFixture = vi.fn( async (_params: Readonly<{ simulatorSetPath?: string }>) => undefined, ); @@ -54,14 +54,14 @@ export const observeBootTimeMsFixture = vi.fn( ); /** The canned readiness answer: local readiness succeeds; a suite rejects this to fail an open. */ -export const ensureLocalDeviceReadyFixture = vi.fn(async (_device: DeviceInfo) => true); +const ensureLocalDeviceReadyFixture = vi.fn(async (_device: DeviceInfo) => true); /** * The canned surface answer: no platform validation, just the session's own surface carried * forward or `app`. Suites needing another surface pass one through `overrides`. The macOS and * Linux surface rules are pinned in the composition lane. */ -export const resolveRequestedOpenSurfaceFixture = vi.fn( +const resolveRequestedOpenSurfaceFixture = vi.fn( (params: { device: DeviceInfo; surfaceFlag: string | undefined; @@ -71,7 +71,7 @@ export const resolveRequestedOpenSurfaceFixture = vi.fn( ); /** The canned relaunch answer: every requested relaunch target is acceptable. */ -export const validateOpenRelaunchTargetFixture = vi.fn( +const validateOpenRelaunchTargetFixture = vi.fn( async (_params: { target: string | undefined; platform: string | undefined; From 2182ba23d58ea9ffb01bec19308025821b41b155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 06:18:06 +0200 Subject: [PATCH 07/23] refactor(daemon): observe the boot probe at the services port in the open router suite request-router-open pinned the reboot-stale claim answers by mocking the apple simulator-boot leaf, which the daemon no longer loads; both cases now arm observeBootTimeMsFixture, the port member the claim settlement actually asks. --- .../__tests__/request-router-open.test.ts | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 51717698c0..8dc2eff207 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -2,6 +2,7 @@ import { createTestDeviceInventoryGateways } from '@agent-device/device-selectio import { daemonPlatformServicesFixture, daemonClaimRecoveryGatewayFixture, + observeBootTimeMsFixture, } from './platform-services-fixture.ts'; import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; @@ -61,16 +62,6 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosApp: vi.fn(async (_device, app) => app), }; }); -// The reboot-stale claim probe (#2538) must never reach the host's real process table here. -const mockObserveSimulatorBoot = vi.hoisted(() => - vi.fn(async (): Promise => ({ - observed: false, - reason: 'unobserved', - })), -); -vi.mock('@agent-device/platform-apple/simulator-boot', () => ({ - observeSimulatorBootTimeMs: mockObserveSimulatorBoot, -})); import { createRequestHandler, @@ -86,7 +77,6 @@ import { discoverReadyAndroidEmulators, } from './application-lifecycle-runtime-fixture.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { DeviceBootObservation } from '@agent-device/contracts/device-boot'; import { AppError } from '@agent-device/kernel/errors'; import { makeSessionStore } from './support/store-factory.ts'; import { inspectDeviceClaims } from '../device/device-claim-inspection.ts'; @@ -103,6 +93,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); // The open path reaches readiness through its admitted package binding, so router-level // serialization is observed at the fixture's emulator-discovery seam. const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); +const mockObserveBoot = vi.mocked(observeBootTimeMsFixture); const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); function createOpenHandler( @@ -130,11 +121,8 @@ beforeEach(() => { mockAwaitFixtureReadiness.mockReset(); mockAwaitFixtureReadiness.mockResolvedValue(undefined); mockDiscoverReadyAndroidEmulators.mockReset(); - mockObserveSimulatorBoot.mockReset(); - mockObserveSimulatorBoot.mockImplementation(async () => ({ - observed: false, - reason: 'unobserved', - })); + mockObserveBoot.mockReset(); + mockObserveBoot.mockResolvedValue({ observed: false, reason: 'unobserved' }); mockDiscoverReadyAndroidEmulators.mockImplementation(async (device) => [ { ...device, @@ -711,10 +699,10 @@ test('an open that booted the device keeps the device against a foreign open', a // during preparation arms it here and the fake device reports that boot to every later probe. let bootedAtMs: number | undefined; let bootArmed = false; - mockObserveSimulatorBoot.mockImplementation(async () => + mockObserveBoot.mockImplementation(async () => bootedAtMs === undefined - ? { observed: false, reason: 'unobserved' } - : { observed: true, bootedAtMs }, + ? ({ observed: false, reason: 'unobserved' } as const) + : ({ observed: true, bootedAtMs } as const), ); mockAwaitFixtureReadiness.mockImplementation(async () => { if (bootArmed) { From 9acaf1f8dd657c1cc1a4f0532eb45ef3a0c3de84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 19:23:45 +0200 Subject: [PATCH 08/23] refactor(daemon): take the harness lifecycle loader as a parameter, one shared type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the session-command harness picks its lifecycle-operations loader the same way the gateway factories do — a per-call parameter — so the module-global install (setHarnessLifecycleOperationsLoader), the side-effect install module, and the direct install call in session-open-existing.test.ts are gone, and nothing depends on import order or per-file module isolation to pick the real composed lifecycle. Composition suites pass applicationLifecycleRuntimeFixture through the new handleSessionCommands lifecycleOperationsLoader option. LifecycleOperationsLoader is declared once, exported from the daemon lifecycle fixture; the harness and the gateway factories both import it instead of declaring their own. Callers of test-device-runtime-gateway.ts import the unavailable* helpers from support/unavailable-device-runtime-gateway.ts directly; the gateway's re-export is deleted. --- .../install-real-lifecycle-harness.ts | 8 --- .../request-handler-chain.test.ts | 2 +- .../session-close-error-precedence.test.ts | 12 +++- .../session-close-runtime-hints.test.ts | 16 ++++- .../session-close-save-script.test.ts | 12 +++- .../session-close-shutdown-platform.test.ts | 12 +++- .../session-open-existing.test.ts | 15 ++--- ...session-open-post-open-observation.test.ts | 20 +++++-- .../session-teardown-resources.test.ts | 12 +++- .../snapshot-diff-runtime.test.ts | 2 +- .../application-lifecycle-runtime-fixture.ts | 11 ++++ .../__tests__/daemon-runtime-app-log.test.ts | 2 +- ...n-replay-maestro-remote-evalscript.test.ts | 2 +- .../session-test-suite-command-video.test.ts | 2 +- .../__tests__/request-handler-catalog.test.ts | 2 +- ...quest-handler-chain-provider-scope.test.ts | 2 +- .../__tests__/screenshot-runtime-fixture.ts | 2 +- .../__tests__/selector-capture-fixture.ts | 2 +- .../__tests__/snapshot-runtime-fixture.ts | 2 +- .../__tests__/test-device-runtime-gateway.ts | 17 ++---- .../__tests__/device-claim-admission.test.ts | 2 +- .../__tests__/session-command-harness.ts | 60 +++++++++---------- .../__tests__/session-replay-command.test.ts | 2 +- .../integration/provider-scenarios/harness.ts | 2 +- 24 files changed, 131 insertions(+), 90 deletions(-) delete mode 100644 src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts diff --git a/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts b/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts deleted file mode 100644 index 549a6796df..0000000000 --- a/src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { setHarnessLifecycleOperationsLoader } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; -import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; - -// Composition suites bind the REAL composed lifecycle (root gateway + apple/android application -// tools) behind the daemon session-command harness. The loader is read when bindings are built, -// so any import position works; vitest gives every test file its own module instance, so the -// install cannot leak into daemon-lane files. -setHarnessLifecycleOperationsLoader(applicationLifecycleRuntimeFixture); diff --git a/src/__tests__/daemon-composition/request-handler-chain.test.ts b/src/__tests__/daemon-composition/request-handler-chain.test.ts index 37d840d871..c68106102f 100644 --- a/src/__tests__/daemon-composition/request-handler-chain.test.ts +++ b/src/__tests__/daemon-composition/request-handler-chain.test.ts @@ -22,7 +22,7 @@ import { unavailableBindExactDevice, unavailableDeviceRuntimeGateway, unavailableInspectFacts, -} from '../../daemon/__tests__/test-device-runtime-gateway.ts'; +} from '../../daemon/__tests__/support/unavailable-device-runtime-gateway.ts'; import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; 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 index f3ec6c17b6..25e0e1fbbc 100644 --- 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 @@ -1,4 +1,3 @@ -import '../handlers/install-real-lifecycle-harness.ts'; import { beforeEach, expect, test } from 'vitest'; import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { @@ -6,10 +5,11 @@ import { type SessionState, } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; const { AppError, - handleSessionCommands, + handleSessionCommands: handleHarnessSessionCommands, makeIosSimulatorRecordingSession, makeSession, makeSessionStore, @@ -22,6 +22,14 @@ const { 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: applicationLifecycleRuntimeFixture, + }); + // 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 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 index 4c9f83d5f0..7b090c03e4 100644 --- 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 @@ -19,14 +19,24 @@ vi.mock('../../../platform-runtime-runtime-hints.ts', () => { }); import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; -import '../handlers/install-real-lifecycle-harness.ts'; import { - handleSessionCommands, + handleSessionCommands as handleHarnessSessionCommands, mockBindDeviceRuntime, mockInspectDeviceRuntimeFacts, } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; import { lifecycleRuntimeFacts } from '../../../daemon/__tests__/application-lifecycle-runtime-harness.ts'; -import { dispatchApplicationLifecycleEffect } from '../application-lifecycle-runtime-fixture.ts'; +import { + applicationLifecycleRuntimeFixture, + 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: applicationLifecycleRuntimeFixture, + }); import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); 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 index 34fc8a596f..3c88d70cf2 100644 --- 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 @@ -1,13 +1,13 @@ -import '../handlers/install-real-lifecycle-harness.ts'; import { beforeEach, expect, test } from 'vitest'; import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; const { + handleSessionCommands: handleHarnessSessionCommands, fs, - handleSessionCommands, makeIosSimulatorRecordingSession, makeSessionStore, mockReleaseRunnerOnClose, @@ -18,6 +18,14 @@ const { 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: applicationLifecycleRuntimeFixture, + }); + // 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 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 index d087f441e5..463bf9fc12 100644 --- 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 @@ -1,12 +1,12 @@ -import '../handlers/install-real-lifecycle-harness.ts'; import { beforeEach, expect, test } from 'vitest'; import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; const { - handleSessionCommands, + handleSessionCommands: handleHarnessSessionCommands, makeSession, makeSessionStore, mockShutdownTargetRuntime, @@ -15,6 +15,14 @@ const { 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: applicationLifecycleRuntimeFixture, + }); + // 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. 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 index 3e5c909f7b..5759bde5cd 100644 --- a/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts +++ b/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts @@ -10,10 +10,7 @@ import { noopInvoke, assertInvalidArgsMessage, } from '../../../daemon/handlers/__tests__/session-test-harness.ts'; -import { - handleSessionCommands, - setHarnessLifecycleOperationsLoader, -} from '../../../daemon/handlers/__tests__/session-command-harness.ts'; +import { handleSessionCommands as handleHarnessSessionCommands } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; @@ -24,9 +21,13 @@ import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; // 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's leaf mocks must register first, so the loader is installed here -// (the loader is read when bindings are built) instead of importing the install module on top. -setHarnessLifecycleOperationsLoader(applicationLifecycleRuntimeFixture); +// 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: applicationLifecycleRuntimeFixture, + }); test('open web URL on iOS device session without active app falls back to Safari', async () => { const sessionStore = makeSessionStore(); 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 index fa7e79db0a..d42ec8873f 100644 --- 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 @@ -1,4 +1,3 @@ -import '../handlers/install-real-lifecycle-harness.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import path from 'node:path'; import type { @@ -58,11 +57,22 @@ vi.mock('@agent-device/host-kit/process', async (importOriginal) => { import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; import { - handleSessionCommands, + handleSessionCommands as handleHarnessSessionCommands, mockBindDeviceRuntime, } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; -import { dispatchApplicationLifecycleEffect } from '../application-lifecycle-runtime-fixture.ts'; +import { + applicationLifecycleRuntimeFixture, + dispatchApplicationLifecycleEffect, +} from '../application-lifecycle-runtime-fixture.ts'; import { pendingSurfaceStabilization } from '../../../daemon/deferred-interaction-outcome.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: applicationLifecycleRuntimeFixture, + }); import { makeAppleSimulator, makeSessionStore, @@ -89,8 +99,8 @@ async function openReportingObservation( mockResolveTargetDevice.mockResolvedValue(makeAppleSimulator()); const bindDefault = mockBindDeviceRuntime.getMockImplementation(); if (!bindDefault) throw new Error('the harness binds a default runtime'); - mockBindDeviceRuntime.mockImplementationOnce(async (device, use) => { - const binding = await bindDefault(device, use); + mockBindDeviceRuntime.mockImplementationOnce(async (device, use, loader) => { + const binding = await bindDefault(device, use, loader); const operations = binding.operations as ApplicationLifecycleRuntimeOperations; return { ...binding, 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 index e0fabac6b6..eff84fbd42 100644 --- a/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts +++ b/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts @@ -1,12 +1,12 @@ -import '../handlers/install-real-lifecycle-harness.ts'; import { beforeEach, expect, test } from 'vitest'; import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; const { - handleSessionCommands, + handleSessionCommands: handleHarnessSessionCommands, makeIosSimulatorRecordingSession, makeSessionStore, mockReleaseRunnerOnClose, @@ -17,6 +17,14 @@ const { 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: applicationLifecycleRuntimeFixture, + }); + // 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 diff --git a/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts b/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts index 9b6d231c90..b370e15f93 100644 --- a/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts +++ b/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts @@ -21,7 +21,7 @@ import type { } from '../../daemon/request-runtime-binding.ts'; import { dispatchSnapshotDiffViaRuntime } from '../../daemon/snapshot-diff-runtime.ts'; import { expectRefusesUnavailableExactOwnerFact } from '../../daemon/__tests__/runtime-binding-conformance.ts'; -import { unavailableDeviceRuntimeGateway } from '../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { unavailableDeviceRuntimeGateway } from '../../daemon/__tests__/support/unavailable-device-runtime-gateway.ts'; import { testCreateCommandSurface } from './command-surface-fixture.ts'; const available = Object.freeze({ available: true } as const); diff --git a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index 64d5f69892..85733f8d02 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -54,6 +54,17 @@ export const dispatchApplicationLifecycleEffect = vi.fn( ): Promise => undefined, ); +/** + * Loads the lifecycle operations a test binding builds: the daemon default is the canned port + * fixture, and root composition lanes pass their real composed fixture. The gateway factories and + * the session-command harness both take it as a parameter; nothing installs it into a module global. + */ +export type LifecycleOperationsLoader = ( + device: DeviceInfo, + signal?: AbortSignal, + shutdownTarget?: (device: DeviceInfo) => Promise, +) => Promise; + /** The runtime-hint asks as the fixture answers them, at the port, with the bound device. */ export const applyRuntimeHintsFixture = vi.fn( async (_input: RuntimeHintsApplicationInput & Readonly<{ device: DeviceInfo }>) => undefined, diff --git a/src/daemon/__tests__/daemon-runtime-app-log.test.ts b/src/daemon/__tests__/daemon-runtime-app-log.test.ts index 6126e6cd72..b1e87a04bf 100644 --- a/src/daemon/__tests__/daemon-runtime-app-log.test.ts +++ b/src/daemon/__tests__/daemon-runtime-app-log.test.ts @@ -15,7 +15,7 @@ import { teardownDaemonSessionForShutdown, } from '../server/daemon-runtime.ts'; import type { SessionState } from '../session-state.ts'; -import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; +import { unavailableDeviceRuntimeGateway } from './support/unavailable-device-runtime-gateway.ts'; test('daemon startup awaits app-log recovery after acquiring the lock and before opening servers', () => { const source = fs.readFileSync(new URL('../server/daemon-runtime.ts', import.meta.url), 'utf8'); diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts index cb6c0a952a..aeb15e5ea2 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts @@ -13,7 +13,7 @@ import type { DaemonRequest } from '../../daemon-request.ts'; import { unavailableBindDevice, unavailableBindExactDevice, -} from '../test-device-runtime-gateway.ts'; +} from '../support/unavailable-device-runtime-gateway.ts'; import { handleReplayCommand, handleReplayTestCommand, diff --git a/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts b/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts index 7b202e88c4..477d931847 100644 --- a/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts @@ -17,7 +17,7 @@ import { replayScriptSourceBundleFor } from '@agent-device/replay-port/replay-sc import { unavailableBindDevice, unavailableBindExactDevice, -} from '../test-device-runtime-gateway.ts'; +} from '../support/unavailable-device-runtime-gateway.ts'; import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; import type { RecordRuntimeHandlerParams } from '../../handlers/record-runtime.ts'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index e38d8a1c99..bcd88c517c 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -23,7 +23,7 @@ import { unavailableBindDevice, unavailableBindExactDevice, unavailableInspectFacts, -} from './test-device-runtime-gateway.ts'; +} from './support/unavailable-device-runtime-gateway.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; diff --git a/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts b/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts index c905f74699..1c52f0a259 100644 --- a/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts +++ b/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts @@ -19,7 +19,7 @@ import { unavailableBindDevice, unavailableBindExactDevice, unavailableInspectFacts, -} from './test-device-runtime-gateway.ts'; +} from './support/unavailable-device-runtime-gateway.ts'; import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; diff --git a/src/daemon/__tests__/screenshot-runtime-fixture.ts b/src/daemon/__tests__/screenshot-runtime-fixture.ts index 024c5b8eaf..4049fbfb9f 100644 --- a/src/daemon/__tests__/screenshot-runtime-fixture.ts +++ b/src/daemon/__tests__/screenshot-runtime-fixture.ts @@ -21,7 +21,7 @@ import fs from 'node:fs'; import { vi, type Mock } from 'vitest'; import { PNG } from '@agent-device/capture-kit/png'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; +import { unavailableDeviceRuntimeGateway } from './support/unavailable-device-runtime-gateway.ts'; import { type TapPointInput, touchRuntimeOperationFacts, diff --git a/src/daemon/__tests__/selector-capture-fixture.ts b/src/daemon/__tests__/selector-capture-fixture.ts index 0188688d9e..1cd6bf5998 100644 --- a/src/daemon/__tests__/selector-capture-fixture.ts +++ b/src/daemon/__tests__/selector-capture-fixture.ts @@ -14,7 +14,7 @@ import { import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; import { isActiveProviderDevice } from '../provider-device-admission.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; +import { unavailableDeviceRuntimeGateway } from './support/unavailable-device-runtime-gateway.ts'; const available: RuntimeOperationFact = Object.freeze({ available: true }); diff --git a/src/daemon/__tests__/snapshot-runtime-fixture.ts b/src/daemon/__tests__/snapshot-runtime-fixture.ts index b522f00baa..e97687d0fe 100644 --- a/src/daemon/__tests__/snapshot-runtime-fixture.ts +++ b/src/daemon/__tests__/snapshot-runtime-fixture.ts @@ -41,7 +41,7 @@ import { type DispatchContext } from '../dispatch-context.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; import { isActiveProviderDevice } from '../provider-device-admission.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; +import { unavailableDeviceRuntimeGateway } from './support/unavailable-device-runtime-gateway.ts'; import { writeSolidPng } from './screenshot-runtime-fixture.ts'; /** diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 8a631d469c..b75aa1139b 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -2,7 +2,6 @@ import path from 'node:path'; import { vi } from 'vitest'; import { type ApplicationLifecycleOperationFacts, - type ApplicationLifecycleRuntimeOperations, applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; @@ -21,7 +20,10 @@ import { } from '../request-router.ts'; import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { applicationLifecycleRuntimeFixture } from './application-lifecycle-runtime-fixture.ts'; +import { + applicationLifecycleRuntimeFixture, + type LifecycleOperationsLoader, +} from './application-lifecycle-runtime-fixture.ts'; import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit/runtime-operation-facts'; import { withClientReplayScriptSources } from '@agent-device/replay-port/replay-script-source-fixtures'; import type { DaemonInvokeFn } from '../daemon-request.ts'; @@ -36,12 +38,6 @@ import { unavailableBinding, unavailableDeviceRuntimeGateway, } from './support/unavailable-device-runtime-gateway.ts'; -export { - unavailableBindDevice, - unavailableBindExactDevice, - unavailableDeviceRuntimeGateway, - unavailableInspectFacts, -} from './support/unavailable-device-runtime-gateway.ts'; const unavailable = Object.freeze({ available: false as const, @@ -63,11 +59,6 @@ function lifecycleFactsForTest(device: DeviceInfo): ApplicationLifecycleOperatio }); } -/** Loads the lifecycle operations a lifecycle gateway binds; the daemon default is the canned port fixture. */ -export type LifecycleOperationsLoader = ( - device: DeviceInfo, -) => Promise; - async function lifecycleBindingForTest( device: DeviceInfo, loadLifecycleOperations: LifecycleOperationsLoader = applicationLifecycleRuntimeFixture, diff --git a/src/daemon/device/__tests__/device-claim-admission.test.ts b/src/daemon/device/__tests__/device-claim-admission.test.ts index a123973da3..762cec9a79 100644 --- a/src/daemon/device/__tests__/device-claim-admission.test.ts +++ b/src/daemon/device/__tests__/device-claim-admission.test.ts @@ -19,7 +19,7 @@ import { canonicalLocalDeviceKey, resolveDeviceClaimPath } from '../device-claim import { createRequestExecutionScope } from '../../request-execution-scope.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; import { SessionStore } from '../../session-store.ts'; -import { unavailableDeviceRuntimeGateway } from '../../__tests__/test-device-runtime-gateway.ts'; +import { unavailableDeviceRuntimeGateway } from '../../__tests__/support/unavailable-device-runtime-gateway.ts'; import type { DeviceClaimPolicy } from '@agent-device/command-registry/types'; const setup = isolatedDeviceClaimStores('agent-device-claim-admission-'); diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index bbfbc4f524..ee2de6d05f 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -22,8 +22,10 @@ import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform import type { TargetShutdownResult } from '@agent-device/contracts/device'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { beforeEach, vi } from 'vitest'; -import { applicationLifecycleRuntimeFixture } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; -import type { ApplicationLifecycleRuntimeOperations } from '@agent-device/contracts/application-lifecycle-runtime'; +import { + applicationLifecycleRuntimeFixture, + type LifecycleOperationsLoader, +} from '../../__tests__/application-lifecycle-runtime-fixture.ts'; import { withClientReplayScriptSources } from '@agent-device/replay-port/replay-script-source-fixtures'; import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit/runtime-operation-facts'; import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; @@ -64,33 +66,13 @@ export const mockDeployMaterializedAppRuntime = vi.fn( export const mockPushNotificationRuntime = vi.fn( async (_input: PushNotificationInput): Promise => ({}), ); -export const mockBindDeviceRuntime = vi.fn(async (device: DeviceInfo, use) => - narrowDeviceBinding(await readinessBinding(device), use), -); - -/** - * Test-harness seam (per test-file module graph): points the harness bindings at another - * lifecycle-ops fixture. Root-owned composition suites install the real composed fixture via - * `src/__tests__/daemon-composition/handlers/install-real-lifecycle-harness.ts`; daemon suites keep - * the canned port fixture default. - */ -export function setHarnessLifecycleOperationsLoader( - loader: ( +export const mockBindDeviceRuntime = vi.fn( + async ( device: DeviceInfo, - signal?: AbortSignal, - shutdownTarget?: (device: DeviceInfo) => Promise, - ) => Promise, -): void { - loadLifecycleOperations = loader; -} - -type LifecycleOperationsLoader = ( - device: DeviceInfo, - signal?: AbortSignal, - shutdownTarget?: (device: DeviceInfo) => Promise, -) => Promise; - -let loadLifecycleOperations: LifecycleOperationsLoader = applicationLifecycleRuntimeFixture; + use, + loadLifecycleOperations: LifecycleOperationsLoader = applicationLifecycleRuntimeFixture, + ) => narrowDeviceBinding(await readinessBinding(device, loadLifecycleOperations), use), +); beforeEach(() => { mockInspectDeviceRuntimeFacts.mockClear(); @@ -117,15 +99,26 @@ beforeEach(() => { mockBindDeviceRuntime.mockClear(); }); -/** Unit-handler default is explicitly fail-closed; production must inject exact-owner recovery. */ +/** + * Unit-handler default is explicitly fail-closed; production must inject exact-owner recovery. + * `lifecycleOperationsLoader` points the harness bindings at another lifecycle-ops fixture — + * root composition suites pass the real composed fixture per call, exactly like the gateway + * factories take theirs; calls without it keep the canned port fixture default. + */ export async function handleSessionCommands( - params: Omit, + params: Omit & + Readonly<{ lifecycleOperationsLoader?: LifecycleOperationsLoader }>, ): ReturnType { + const { lifecycleOperationsLoader, ...commandParams } = params; return await handleProductionSessionCommands({ - ...params, + ...commandParams, req: await withClientReplayScriptSources(params.req), inspectFacts: params.inspectFacts ?? mockInspectDeviceRuntimeFacts, - bindDevice: params.bindDevice ?? mockBindDeviceRuntime, + bindDevice: + params.bindDevice ?? + (lifecycleOperationsLoader === undefined + ? mockBindDeviceRuntime + : (device, use) => mockBindDeviceRuntime(device, use, lifecycleOperationsLoader)), platformResourceCleanup: params.platformResourceCleanup ?? platformResourceCleanupFixture(), reconcileOrphanedDeviceClaim: async () => ({ status: 'retained', @@ -141,7 +134,7 @@ export async function handleSessionCommands( export const readinessDeviceRuntimeGateway: DeviceRuntimeGateway = Object.freeze({ inspectFacts: async (device: DeviceInfo) => readinessFacts(device), - bind: async ({ device }) => await readinessBinding(device), + bind: async ({ device }) => await readinessBinding(device, applicationLifecycleRuntimeFixture), shutdown: async () => {}, }); @@ -196,6 +189,7 @@ function operationAvailability(supported: boolean): typeof available | typeof un async function readinessBinding( device: DeviceInfo, + loadLifecycleOperations: LifecycleOperationsLoader, ): Promise> { const facts = readinessFacts(device); const lifecycle = await loadLifecycleOperations( diff --git a/src/daemon/handlers/__tests__/session-replay-command.test.ts b/src/daemon/handlers/__tests__/session-replay-command.test.ts index 395c5478ff..a0ca5b4297 100644 --- a/src/daemon/handlers/__tests__/session-replay-command.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-command.test.ts @@ -4,7 +4,7 @@ import { LeaseRegistry } from '../../lease-registry.ts'; import { unavailableBindDevice, unavailableBindExactDevice, -} from '../../__tests__/test-device-runtime-gateway.ts'; +} from '../../__tests__/support/unavailable-device-runtime-gateway.ts'; import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; import type { SessionCommandParams } from '../session-command-input.ts'; diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index f2803bf4a9..90e8b310f3 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -49,7 +49,7 @@ import { isActiveProviderDevice } from '../../../src/provider-device-runtime.ts' import { installProviderDeviceAdmission } from '../../../src/daemon/provider-device-admission.ts'; import { installInteractorResolution } from '../../../src/daemon/interactor-resolution.ts'; import { getInteractor } from '../../../src/core/interactors.ts'; -import { unavailableDeviceRuntimeGateway } from '../../../src/daemon/__tests__/test-device-runtime-gateway.ts'; +import { unavailableDeviceRuntimeGateway } from '../../../src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts'; import { openWebSessionNames } from '../../../src/daemon/web-session-names.ts'; import { testCreateCommandSurface } from '../../../src/__tests__/daemon-composition/command-surface-fixture.ts'; From fc2f1be7073be54895330659392f8092db0bef6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 19:25:21 +0200 Subject: [PATCH 09/23] refactor(daemon): move the android discovery seam to the composition fixture Nothing in the daemon lane calls discoverReadyAndroidEmulators any more (the canned prepareApplicationOpenFixture performs no discovery leg), so the spy moves to the composition fixture that still drives the real android readiness through it. Dead import/reset lines in the two open router suites go with it, and the readiness-pause doc now names prepareApplicationOpenFixture as the daemon-lane failure seam. --- .../application-lifecycle-runtime-fixture.ts | 20 ++++++++++++++++--- ...request-router-open-claim-recovery.test.ts | 2 +- .../application-lifecycle-runtime-fixture.ts | 17 +++------------- .../request-router-open-claim.test.ts | 7 +------ .../__tests__/request-router-open.test.ts | 16 +-------------- 5 files changed, 23 insertions(+), 39 deletions(-) diff --git a/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts index 947b582345..aac29db2ff 100644 --- a/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts +++ b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts @@ -6,18 +6,19 @@ import type { } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { TargetShutdownResult } from '@agent-device/contracts/device'; -// The lifecycle seams (dispatch spy, readiness pauses, fixture interactor) have ONE owner: the +import { vi } from 'vitest'; +// The lifecycle seams (dispatch spy, readiness pause, fixture interactor) have ONE owner: the // daemon's canned lifecycle fixture. This real-composition fixture builds the REAL root gateway // and platform application tools on top of the same seams, so every lane observes one spy set. +// Android readiness discovery is the composed owners' rule (which devices discovery runs for), +// so its already-running-emulator seam lives HERE, not in the daemon fixture. export { awaitFixtureReadiness, - discoverReadyAndroidEmulators, dispatchApplicationLifecycleEffect, } from '../../daemon/__tests__/application-lifecycle-runtime-fixture.ts'; import { applicationLifecycleFixtureInteractor, awaitFixtureReadiness, - discoverReadyAndroidEmulators, } from '../../daemon/__tests__/application-lifecycle-runtime-fixture.ts'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import { createAndroidApplicationTools } from '../../platform-runtime-android-application-tools.ts'; @@ -25,6 +26,19 @@ import { createAppleApplicationTools } from '../../platform-runtime-apple-applic import { createComposedPlatformRuntimeGateway } from '../../platform-runtime-gateway.ts'; import { platformRuntimeModules } from '../../platform-runtime.ts'; +/** + * Readiness discovery, as a seam for the composed host. It reports the device under test as an + * already-running emulator so composition fixtures never launch or poll a boot; a test that needs + * a pre-effect readiness failure rejects this instead. + */ +export const discoverReadyAndroidEmulators = vi.fn(async (readyDevice: DeviceInfo) => [ + { + ...readyDevice, + id: readyDevice.id.startsWith('emulator-') ? readyDevice.id : `emulator-${readyDevice.id}`, + booted: true, + }, +]); + export async function applicationLifecycleRuntimeFixture( device: DeviceInfo, signal: AbortSignal = new AbortController().signal, diff --git a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts index 0f591817c5..089ae05b87 100644 --- a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts +++ b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts @@ -75,8 +75,8 @@ import { import { awaitFixtureReadiness, dispatchApplicationLifecycleEffect, - discoverReadyAndroidEmulators, } from '../../daemon/__tests__/application-lifecycle-runtime-fixture.ts'; +import { discoverReadyAndroidEmulators } from './application-lifecycle-runtime-fixture.ts'; import { makeSessionStore } from '../../daemon/__tests__/support/store-factory.ts'; import { inspectDeviceClaims } from '../../daemon/device/device-claim-inspection.ts'; import { diff --git a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index 85733f8d02..37df733206 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -22,23 +22,12 @@ type LifecycleEffectContext = ApplicationLifecycleExecution & /** * A platform-neutral readiness pause point. Package-owned readiness runs inside the admitted * binding, so a router-level test that needs to hold an open mid-readiness awaits this instead of - * a per-platform host call. + * a per-platform host call. A daemon-lane test that needs a readiness FAILURE rejects + * `prepareApplicationOpenFixture` (the port's prepare step), which is where the canned answer + * surfaces to the open flow. */ export const awaitFixtureReadiness = vi.fn(async (_device: DeviceInfo): Promise => undefined); -/** - * Readiness discovery, as a seam. It reports the device under test as an already-running emulator - * so lifecycle fixtures never launch or poll a boot; a test that needs a pre-effect readiness - * failure rejects this instead. - */ -export const discoverReadyAndroidEmulators = vi.fn(async (readyDevice: DeviceInfo) => [ - { - ...readyDevice, - id: readyDevice.id.startsWith('emulator-') ? readyDevice.id : `emulator-${readyDevice.id}`, - booted: true, - }, -]); - /** * Handler tests observe an admitted lifecycle open/close through this spy: package lifecycle * suites own dispatch mechanics, while handler tests assert facts admission, binding, session diff --git a/src/daemon/__tests__/request-router-open-claim.test.ts b/src/daemon/__tests__/request-router-open-claim.test.ts index b8d55c25b3..86e9a1a6d5 100644 --- a/src/daemon/__tests__/request-router-open-claim.test.ts +++ b/src/daemon/__tests__/request-router-open-claim.test.ts @@ -53,10 +53,7 @@ import { lifecycleDeviceRuntimeGateway, } from './test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { - awaitFixtureReadiness, - discoverReadyAndroidEmulators, -} from './application-lifecycle-runtime-fixture.ts'; +import { awaitFixtureReadiness } from './application-lifecycle-runtime-fixture.ts'; import { ensureDeviceReady } from '../device/device-ready.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { makeSessionStore } from './support/store-factory.ts'; @@ -66,7 +63,6 @@ import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); const mockObserveBoot = vi.mocked(observeBootTimeMsFixture); const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); @@ -94,7 +90,6 @@ beforeEach(() => { mockAwaitFixtureReadiness.mockResolvedValue(undefined); mockObserveBoot.mockReset(); mockObserveBoot.mockResolvedValue({ observed: false, reason: 'unobserved' }); - mockDiscoverReadyAndroidEmulators.mockReset(); }); function seedLiveForeignClaim(claimsDir: string, device: DeviceInfo, foreignStateDir: string) { diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 8dc2eff207..73880f3db2 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -72,10 +72,7 @@ import { providerCredentialsFixture } from './support/provider-credentials-fixtu import { resolveRequestExecutionLockPlan } from '../request-binding.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { ensureDeviceReady } from '../device/device-ready.ts'; -import { - awaitFixtureReadiness, - discoverReadyAndroidEmulators, -} from './application-lifecycle-runtime-fixture.ts'; +import { awaitFixtureReadiness } from './application-lifecycle-runtime-fixture.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { makeSessionStore } from './support/store-factory.ts'; @@ -90,9 +87,6 @@ import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -// The open path reaches readiness through its admitted package binding, so router-level -// serialization is observed at the fixture's emulator-discovery seam. -const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); const mockObserveBoot = vi.mocked(observeBootTimeMsFixture); const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); @@ -120,16 +114,8 @@ beforeEach(() => { mockEnsureDeviceReady.mockResolvedValue(undefined); mockAwaitFixtureReadiness.mockReset(); mockAwaitFixtureReadiness.mockResolvedValue(undefined); - mockDiscoverReadyAndroidEmulators.mockReset(); mockObserveBoot.mockReset(); mockObserveBoot.mockResolvedValue({ observed: false, reason: 'unobserved' }); - mockDiscoverReadyAndroidEmulators.mockImplementation(async (device) => [ - { - ...device, - id: device.id.startsWith('emulator-') ? device.id : `emulator-${device.id}`, - booted: true, - }, - ]); }); // fallow-ignore-next-line complexity From 6889e5541cfaf0096d97a972d0754105fcc92ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 19:26:13 +0200 Subject: [PATCH 10/23] test(composition): pin the composed snapshot-helper stop rule against the mechanics leaf From #3519's review, landed here so the daemon lane keeps no root mock: one root-owned case calls the REAL platformResourceCleanup.stopSnapshotHelper over the mocked android mechanics leaf and asserts an Android device reaches stopAndroidSnapshotHelperSessionForDevice(device) while a non-Android device does not. Both legs were proven to bite by temporarily inverting and then deleting the platform guard. --- ...m-resource-cleanup-snapshot-helper.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/__tests__/daemon-composition/platform-resource-cleanup-snapshot-helper.test.ts diff --git a/src/__tests__/daemon-composition/platform-resource-cleanup-snapshot-helper.test.ts b/src/__tests__/daemon-composition/platform-resource-cleanup-snapshot-helper.test.ts new file mode 100644 index 0000000000..31efd79fc6 --- /dev/null +++ b/src/__tests__/daemon-composition/platform-resource-cleanup-snapshot-helper.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +// Root composition owns the rule that only an Android device has an adb snapshot-helper session to +// stop. The daemon never names this leaf: it calls the composed `platformResourceCleanup` port, so +// the rule is pinned here against the real composed module over the mocked mechanics leaf. +const mockStopAndroidSnapshotHelperSessionForDevice = vi.hoisted(() => vi.fn(async () => {})); +vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stopAndroidSnapshotHelperSessionForDevice: mockStopAndroidSnapshotHelperSessionForDevice, + }; +}); + +import { ANDROID_EMULATOR, IOS_SIMULATOR } from '@agent-device/test-kit/device-fixtures'; +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('the composed cleanup stops the snapshot helper for an Android device', async () => { + await platformResourceCleanup.stopSnapshotHelper(ANDROID_EMULATOR); + + expect(mockStopAndroidSnapshotHelperSessionForDevice).toHaveBeenCalledExactlyOnceWith( + ANDROID_EMULATOR, + ); +}); + +test('the composed cleanup names no snapshot helper for a non-Android device', async () => { + await platformResourceCleanup.stopSnapshotHelper(IOS_SIMULATOR); + + expect(mockStopAndroidSnapshotHelperSessionForDevice).not.toHaveBeenCalled(); +}); From eeef98b142f70fc68e6e640dfbc8d19fa5bd9f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:30 +0200 Subject: [PATCH 11/23] fix(gates): report residue edges at the keyword line and on native paths The static forms anchor at the preceding ';' or newline, so the line count from match.index pointed one line early for every anchored edge; count to the keyword inside the match instead. The daemon-local skip compared against forward slashes, which on Windows turned every daemon-local relative import into a phantom edge; build the prefix with path.join and path.sep. Generated with assistance. --- scripts/daemon-edge-residue-scan.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/daemon-edge-residue-scan.mjs b/scripts/daemon-edge-residue-scan.mjs index 7c4f37119a..fe96a27118 100644 --- a/scripts/daemon-edge-residue-scan.mjs +++ b/scripts/daemon-edge-residue-scan.mjs @@ -70,10 +70,16 @@ for (const file of files) { const resolved = path .normalize(path.join(path.dirname(rel), specifier)) .replace(/\.tsx?$/, ''); - if (resolved === 'src/daemon' || resolved.startsWith('src/daemon/')) continue; + // path.join yields backslashes on Windows, so compare against the native separator. + const daemonDir = path.join('src', 'daemon'); + if (resolved === daemonDir || resolved.startsWith(`${daemonDir}${path.sep}`)) continue; if (seen.has(resolved)) continue; seen.add(resolved); - const line = source.slice(0, match.index).split('\n').length; + // The static forms anchor at the preceding `;`/newline, so slice(0, match.index) + // counts lines only up to that separator and reports one early. Count to the + // keyword inside the match (position 0 for the unanchored forms). + const keywordAt = match[0].search(/import|export|require|vi\./); + const line = source.slice(0, match.index + keywordAt).split('\n').length; edges.push(`${rel}:${line} -> ${resolved}`); } } From 4a85acfb1a64565791e205dc1671bca24e7cab07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:30 +0200 Subject: [PATCH 12/23] test(composition): park the orphan recorder on the current Node binary The unit-core lane runs on Windows, where `sleep` is not a command and the case failed before exercising recovery; `process.execPath` on a parked timer spawns everywhere and still carries a real command and start time. Generated with assistance. --- .../request-router-open-claim-recovery.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts index 089ae05b87..2a7d70cd8c 100644 --- a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts +++ b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts @@ -123,9 +123,13 @@ beforeEach(() => { // The dead owner's recorded recorder must still run as an orphan: recovery // then takes the descriptor-cleanup path — the one that terminates it and // clears the owned-process store it was composed with. A plain sleeper with -// its real observed command and start time satisfies the exact match. +// its real observed command and start time satisfies the exact match. The +// sleeper is the current Node binary on a parked timer, so the case runs on +// every platform (`sleep` is not a Windows command). async function spawnOrphanRecorder() { - const child = spawn('sleep', ['120'], { stdio: 'ignore' }); + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 120_000)'], { + stdio: 'ignore', + }); await new Promise((resolve) => child.once('spawn', resolve)); const pid = child.pid ?? 0; const marker = { From 17be8b94b1cd940435a6c37fc6fbc0ccb8a827a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:34 +0200 Subject: [PATCH 13/23] refactor(daemon): keep the fixture's relaunch order close, hints, open Applying the admitted hints before the direct owner's close-before-relaunch reversed the sequence every real owner sequences (apple closes, applies hints, then opens). The fixture now closes first itself and opens as a fresh launch so daemon suites cannot encode a reversed lifecycle. Generated with assistance. --- .../application-lifecycle-runtime-fixture.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index 37df733206..c1702955b5 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -98,21 +98,34 @@ export const prepareApplicationOpenFixture = vi.fn(async (device: DeviceInfo): P }); /** - * The canned `openApplication` answer: apply the fact-admitted runtime hints at the port when the - * open requests them, then run the contract's canonical direct open (the `directOpen` the fixture - * passes in). Relaunch identity, runner prewarm policy, and post-open observation are the composed + * The canned `openApplication` answer: run the contract's canonical direct lifecycle (the `direct` + * operations the fixture passes in) with the fact-admitted runtime hints applied at the port when + * the open requests them. A hinted relaunch closes first, then applies the hints, then opens — + * the same order the apple/android owners sequence, so daemon suites cannot encode a reversed + * lifecycle. Relaunch identity, runner prewarm policy, and post-open observation are the composed * owners' rules, pinned under `src/__tests__/daemon-composition/`. */ const openApplicationFixture = vi.fn( async ( _device: DeviceInfo, - directOpen: NonNullable, - input: Parameters>[0], + direct: ApplicationLifecycleRuntimeOperations, + input: Parameters[0], ) => { if (input.applyRuntimeHints) { + // The direct owner's close-before-relaunch happens inside its open; a hinted relaunch + // must close before the hints land, so take the close here and open as a fresh launch. + if (input.relaunch && input.target !== undefined) { + await direct.closeApplication({ + positionals: [input.appBundleId ?? input.target], + surface: input.surface, + execution: input.execution, + }); + await input.applyRuntimeHints({ appId: input.appBundleId, values: input.runtimeHints }); + return await direct.openApplication({ ...input, relaunch: false }); + } await input.applyRuntimeHints({ appId: input.appBundleId, values: input.runtimeHints }); } - return await directOpen(input); + return await direct.openApplication(input); }, ); @@ -147,7 +160,6 @@ export async function applicationLifecycleRuntimeFixture( applicationLifecycleFixtureInteractor(target, runner), }), }); - const directOpenApplication = direct.openApplication; const directResolveOpenTarget = direct.resolveOpenTarget; return Object.freeze({ ...direct, @@ -156,8 +168,7 @@ export async function applicationLifecycleRuntimeFixture( prepareApplicationOpen: async () => { await prepareApplicationOpenFixture(device); }, - openApplication: async (input) => - await openApplicationFixture(device, directOpenApplication, input), + openApplication: async (input) => await openApplicationFixture(device, direct, input), applyRuntimeHints: async (input) => { await applyRuntimeHintsFixture({ device, ...input }); }, From 3a6c9bfe94b2ad7f2dc07ce918f638ab1d5ddcf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:34 +0200 Subject: [PATCH 14/23] refactor(daemon): carry the request signal into lifecycle gateway binds Dropping scope.signal handed the loader a fresh signal, so a canceled request left its bound lifecycle operations running; every bind now passes the request's signal through the loader exactly like the composed gateway. Generated with assistance. --- src/daemon/__tests__/test-device-runtime-gateway.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index b75aa1139b..d4b604d099 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -62,6 +62,7 @@ function lifecycleFactsForTest(device: DeviceInfo): ApplicationLifecycleOperatio async function lifecycleBindingForTest( device: DeviceInfo, loadLifecycleOperations: LifecycleOperationsLoader = applicationLifecycleRuntimeFixture, + signal?: AbortSignal, ) { const lifecycleFacts = lifecycleFactsForTest(device); const base = createUnavailableRuntimeFactsForTest(device, localRuntimeOwner(device.platform)); @@ -79,7 +80,9 @@ async function lifecycleBindingForTest( }, operations: { ...availableApplicationLifecycleOperations( - await loadLifecycleOperations(device), + // The request's signal reaches the loader so a canceled request aborts the bound + // lifecycle operations instead of handing them a fresh, uncontended signal. + await loadLifecycleOperations(device, signal), lifecycleFacts, ), // The gesture family rides along: replay flows drive `scroll`/`swipe` through this gateway, @@ -97,7 +100,8 @@ async function lifecycleBindingForTest( export const lifecycleDeviceRuntimeGateway: DeviceRuntimeGateway = Object.freeze({ inspectFacts: async (device) => (await lifecycleBindingForTest(device)).facts, - bind: async ({ device }) => await lifecycleBindingForTest(device), + bind: async ({ device, scope }) => + await lifecycleBindingForTest(device, applicationLifecycleRuntimeFixture, scope.signal), shutdown: async () => {}, }); @@ -108,7 +112,8 @@ export function createLifecycleDeviceRuntimeGateway( return Object.freeze({ inspectFacts: async (device) => (await lifecycleBindingForTest(device, loadLifecycleOperations)).facts, - bind: async ({ device }) => await lifecycleBindingForTest(device, loadLifecycleOperations), + bind: async ({ device, scope }) => + await lifecycleBindingForTest(device, loadLifecycleOperations, scope.signal), shutdown: async () => {}, }); } @@ -129,7 +134,7 @@ export function createLifecycleDeviceRuntimeGatewaySpies( ); const bind = vi.fn( async (request: DeviceBindingRequest) => - await lifecycleBindingForTest(request.device, loadLifecycleOperations), + await lifecycleBindingForTest(request.device, loadLifecycleOperations, request.scope.signal), ); const gateway: DeviceRuntimeGateway = { inspectFacts, From 0c1d2023b26bbbc65b389c74763db9b8499613c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:35 +0200 Subject: [PATCH 15/23] refactor(daemon): answer exact-owner binds with the requested owner The unavailable fixture replaced every owner with the local one, so exact-owner refusal tests never exercised the identity the caller selected; an exact-owner intent now keeps intent.owner. Generated with assistance. --- .../support/unavailable-device-runtime-gateway.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts b/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts index 84aa8f08a3..84665a6fda 100644 --- a/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts +++ b/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts @@ -10,13 +10,16 @@ import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../../request-ru /** * The `DeviceRuntimeGateway` port answering that no owner on this host can bind the device: - * every operation fact unavailable, no operations, nothing to dispose. + * every operation fact unavailable, no operations, nothing to dispose. An exact-owner intent + * answers with the REQUESTED owner so the refusal exercises the same owner identity the caller + * selected; only an ordinary bind falls back to the local owner. */ export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway = Object.freeze({ inspectFacts: async (device) => (await unavailableBinding(device)).facts, - bind: async ({ device }) => { - const owner = localRuntimeOwner(device.platform); + bind: async ({ device, intent }) => { + const owner = + intent.kind === 'exact-owner' ? intent.owner : localRuntimeOwner(device.platform); return { device, owner, From f0ffe3523bd5f24e8bf296fac93b0966c1a6340f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:39 +0200 Subject: [PATCH 16/23] test(composition): prove lazy runtime-hints loading before the first reset Resetting the evaluated flag in beforeEach erased the only evidence that the mocked module had not been evaluated during test-module loading; the first entry into a test now asserts the flag is still false, so a regression to eager evaluation fails there instead of passing silently. Generated with assistance. --- .../session-close-runtime-hints.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 7b090c03e4..48e95db254 100644 --- 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 @@ -83,8 +83,20 @@ async function close(params: { }); } +// 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; }); From 9b4c47ef85696a71d539b33c10263dd30a1d5f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:39 +0200 Subject: [PATCH 17/23] test(composition): restore the claims-dir override after each case The suite left AGENT_DEVICE_CLAIMS_DIR pointing at its temp root, letting later tests in the same worker inspect claims under the wrong root; the previous value is captured at setup and restored in afterEach. Generated with assistance. --- .../handlers/session-device-claims-cancellation.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts index 3e6197f11e..dc00848a5f 100644 --- a/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts +++ b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts @@ -86,7 +86,14 @@ async function realLifecycleBinding( const bindRealLifecycleRuntime: BindDeviceRuntime = async (device, use) => narrowDeviceBinding(await realLifecycleBinding(device), use); +let previousClaimsDir: string | undefined; + afterEach(() => { + // The claims-dir override is this suite's scope: restore it so later tests in + // the same worker never inspect or write claims under this case's temp root. + if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; + else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; + previousClaimsDir = undefined; vi.clearAllMocks(); mockEnsureDeviceReady.mockResolvedValue(undefined); mockResolveAndroidPackage.mockResolvedValue(undefined); @@ -95,6 +102,7 @@ afterEach(() => { function setup(): { store: SessionStore; stateDir: string } { const stateDir = mkdtempForTestSync('agent-device-session-device-claim-'); const claimsDir = path.join(stateDir, 'claims'); + previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; return { store: new SessionStore(path.join(stateDir, 'sessions')), stateDir }; } From 7daf9ba0e280146a88ed38b935f13d01aaa258c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:39 +0200 Subject: [PATCH 18/23] test(composition): drive close retention through the composed cleanup The harness injected the cleanup fixture whose retention answer is a canned false, so a policy that ignored hasScreenRecording still passed; these composition cases now pass the production-composed platformResourceCleanup and observe its real retention decision at the runner release. Generated with assistance. --- .../session-lifecycle/session-teardown-resources.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 index eff84fbd42..f2abae4726 100644 --- a/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts +++ b/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts @@ -1,4 +1,5 @@ import { beforeEach, expect, test } from 'vitest'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; @@ -19,10 +20,15 @@ const { // 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: applicationLifecycleRuntimeFixture, + platformResourceCleanup, }); // These cases pin the REAL Apple close finalization around an active recording: the recorder is From f5582f4b3968bf6db70825985b114962fe1568d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:43 +0200 Subject: [PATCH 19/23] test(daemon): answer the open-target identity once per case The persistent per-case implementation survived clearAllMocks, so later opens inherited this case's canned bundle id instead of the fixture's directResolve answer; the override is now a one-shot implementation. Generated with assistance. --- .../__tests__/session-open-execution-runtime.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 7bac120923..31477ba42c 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -101,9 +101,11 @@ test('open runtime payload replaces stored session runtime atomically', async () }> = []; mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator()); - // The open-target identity is a canned per-case answer at the port; what the real owners - // resolve for an Android name is pinned in the composition lane. - mockResolveOpenTarget.mockImplementation(async (_device, _direct, input) => ({ + // The open-target identity is a canned one-shot answer at the port; what the real owners + // resolve for an Android name is pinned in the composition lane. One-shot so a later open + // in this file falls back to the fixture's canonical `directResolve` answer instead of + // inheriting this case's bundle id (a persistent implementation survives `clearAllMocks`). + mockResolveOpenTarget.mockImplementationOnce(async (_device, _direct, input) => ({ appBundleId: 'com.example.demo', appName: input.target, })); From 392ceb9d7e274664bf86eab381cc26dd17368ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:43 +0200 Subject: [PATCH 20/23] docs(tests): say what the one-shot rejection and the TTL pin actually cover The runner-stop comment implied a retried call the test never makes (the one-shot rejection is consumed by the pre-close stop; cleanup uses the separate releaseRunnerOnClose port), and the readiness comment credited the claim suites with TTL assertions this suite owns. Generated with assistance. --- .../daemon-composition/device/device-ready.test.ts | 4 ++-- .../session-close-error-precedence.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/__tests__/daemon-composition/device/device-ready.test.ts b/src/__tests__/daemon-composition/device/device-ready.test.ts index 7939fbcd34..8432b5cc5e 100644 --- a/src/__tests__/daemon-composition/device/device-ready.test.ts +++ b/src/__tests__/daemon-composition/device/device-ready.test.ts @@ -38,8 +38,8 @@ 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 and owns only the TTL-cache -// contract, pinned by the daemon's own claim suites. +// 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 = daemonPlatformServicesFixture(); beforeEach(() => { 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 index 25e0e1fbbc..b461adbf5d 100644 --- 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 @@ -109,10 +109,10 @@ test('targeted close skips platform dispatch and preserves the error when the re reason: 'runner_stop_failed', hint: 'Retry once the runner is reachable.', }); - // Scoped to this test's own two internal calls (pre-close stop, then the later - // independent-cleanup retry) — a persistent `mockRejectedValue` here would leak into every - // later test that exercises an Apple-platform runner stop, since `vi.clearAllMocks()` in - // `beforeEach` clears call history but not a mock's implementation. + // 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( From 6817b4398abfdce5c1e76d0e63606773f92d8ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:03:46 +0200 Subject: [PATCH 21/23] docs(tests): scope the post-open observation suite to what it composes The suite injects the observation verdict at the binding seam (the composed probe needs a live Simulator bridge), so its header said it pins more of the Apple open than it does; name the canned seam and point the real verdict reporting at the platform lane that pins it. Generated with assistance. --- .../session-open-post-open-observation.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 index d42ec8873f..f7dd78c3f5 100644 --- 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 @@ -82,10 +82,13 @@ import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -// These cases pin how the REAL Apple open reports app observation in its timing (postOpenObservation) -// and stages runner prewarm after the open, and how the daemon records the settle consequence. -// The canned port fixture returns no observation timing and no runner staging, so they run in the -// composition lane. +// 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 () => ({})); From b5d7af7261730ce24da96a126e5fa6a3011b4001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 05:04:08 +0200 Subject: [PATCH 22/23] test(composition): put the interaction-prepare and integration suites back on the real port interaction-touch-prepare.test.ts kept its import of the daemon's canned platform-services fixture when the suite moved to the composition lane, so it answered port asks with canned spies instead of the composed adapters its 30 sibling suites see. The two integration fixtures moved from the real root fixture to the canned one silently when the canned module took over the export name, because their import path and the call site never changed. All three now import the root composition fixture: the command suites exercise the real adapters and override a member only where the test is about that ask. --- .../interaction/interaction-touch-prepare.test.ts | 2 +- .../nightly/concurrency-torture/real-scope-serialization.ts | 2 +- .../stale-provider-runtime-admission.fixtures.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/daemon-composition/interaction/interaction-touch-prepare.test.ts b/src/__tests__/daemon-composition/interaction/interaction-touch-prepare.test.ts index d200ee305c..2238878a73 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-touch-prepare.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-touch-prepare.test.ts @@ -22,7 +22,7 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../../daemon/request-runtime-binding.ts'; -import { daemonPlatformServicesFixture } from '../../../daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { testCreateCommandSurface } from '../command-surface-fixture.ts'; import { handleInteractionCommands } from '../../../daemon/interaction/index.ts'; import type { SessionStore } from '../../../daemon/session-store.ts'; diff --git a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts index 567a50a7ce..251c61ae7d 100644 --- a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts +++ b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts @@ -2,7 +2,7 @@ import { daemonPlatformServicesFixture, daemonClaimRecoveryGatewayFixture, -} from '../../../../src/daemon/__tests__/platform-services-fixture.ts'; +} from '../../../../src/__tests__/daemon-composition/platform-services-fixture.ts'; // (#1416). The seeded sweep models the mutex GRANT (a seed cannot reproduce // `withKeyedLock`'s native microtask hand-off), so on its own it could stay // green if the PRODUCTION lock APPLICATION path regressed. This guard closes diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts index e6ff9013c9..1ace37aee5 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../../src/daemon/__tests__/platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../../../src/__tests__/daemon-composition/platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { vi } from 'vitest'; From 51281e58083fb9b7551aab0df3213c15ec86ea4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 05:04:33 +0200 Subject: [PATCH 23/23] test(composition): name the root fixtures after their lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fixture lanes exported the same names: daemonPlatformServicesFixture and applicationLifecycleRuntimeFixture existed as canned spies under the daemon and as the real composed ports at the root, so an import that drifted between the two trees stayed invisible in review — the call site read the same. The root composition fixtures now export composedPlatformServicesFixture, composedClaimRecoveryGatewayFixture, and composedLifecycleRuntimeFixture, and every root call site carries the new name, so each import spells which lane it answers and a stray swap shows as a rename. The daemon's canned fixtures keep their names for the daemon lane. --- .../application-lifecycle-runtime-fixture.ts | 2 +- .../device/device-ready.test.ts | 4 +-- .../daemon-composition/generic-settle.test.ts | 6 ++-- .../gesture-admission-parity.test.ts | 4 +-- ...session-device-claims-cancellation.test.ts | 8 +++--- .../handlers/session-relaunch-guards.test.ts | 12 ++++---- .../snapshot-handler-freshness.test.ts | 18 ++++++------ .../handlers/snapshot-handler.test.ts | 28 +++++++++---------- .../interaction-dispatch-disclosure.test.ts | 8 +++--- .../interaction-gesture-drag.test.ts | 4 +-- .../interaction/interaction-settle.test.ts | 4 +-- .../interaction-touch-prepare.test.ts | 4 +-- .../interaction-touch-response.test.ts | 8 +++--- .../daemon-composition/is-runtime.test.ts | 28 +++++++++---------- .../lifecycle-runtime-gateway.ts | 6 ++-- .../platform-services-fixture.ts | 4 +-- .../request-handler-chain.test.ts | 6 ++-- ...request-router-open-claim-recovery.test.ts | 8 +++--- .../request-router-recording-health.test.ts | 4 +-- .../screenshot-runtime.test.ts | 26 ++++++++--------- ...elector-runtime-ambiguity-issuance.test.ts | 8 +++--- .../selector-runtime-backend.test.ts | 6 ++-- .../selector-runtime.test.ts | 4 +-- .../session-close-error-precedence.test.ts | 10 +++---- .../session-close-runtime-hints.test.ts | 8 +++--- .../session-close-save-script.test.ts | 10 +++---- .../session-close-shutdown-platform.test.ts | 10 +++---- .../session-open-existing.test.ts | 22 +++++++-------- ...session-open-post-open-observation.test.ts | 8 +++--- .../session-teardown-resources.test.ts | 10 +++---- .../snapshot-command-runtime.test.ts | 6 ++-- .../snapshot-diff-runtime.test.ts | 8 +++--- .../sparse-fallback-screenshot.test.ts | 4 +-- .../daemon-composition/wait-runtime.test.ts | 10 +++---- .../real-scope-serialization.ts | 8 +++--- ...ale-provider-runtime-admission.fixtures.ts | 4 +-- 36 files changed, 164 insertions(+), 164 deletions(-) diff --git a/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts index aac29db2ff..a36337784c 100644 --- a/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts +++ b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts @@ -39,7 +39,7 @@ export const discoverReadyAndroidEmulators = vi.fn(async (readyDevice: DeviceInf }, ]); -export async function applicationLifecycleRuntimeFixture( +export async function composedLifecycleRuntimeFixture( device: DeviceInfo, signal: AbortSignal = new AbortController().signal, shutdownTarget: (device: DeviceInfo) => Promise = async () => ({ diff --git a/src/__tests__/daemon-composition/device/device-ready.test.ts b/src/__tests__/daemon-composition/device/device-ready.test.ts index 8432b5cc5e..aaf2776f7c 100644 --- a/src/__tests__/daemon-composition/device/device-ready.test.ts +++ b/src/__tests__/daemon-composition/device/device-ready.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { promises as fs } from 'node:fs'; -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => { @@ -40,7 +40,7 @@ const mockWaitForAndroidBoot = vi.mocked(waitForAndroidBoot); // 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 = daemonPlatformServicesFixture(); +const platformServices = composedPlatformServicesFixture(); beforeEach(() => { vi.useFakeTimers(); diff --git a/src/__tests__/daemon-composition/generic-settle.test.ts b/src/__tests__/daemon-composition/generic-settle.test.ts index c216df04e0..1692f33de6 100644 --- a/src/__tests__/daemon-composition/generic-settle.test.ts +++ b/src/__tests__/daemon-composition/generic-settle.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; @@ -214,7 +214,7 @@ async function dispatchGeneric(params: { }; return await dispatchGenericCommand({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req, ref: params.sessionStore.lookup(params.sessionName)!, sessionName: params.sessionName, @@ -654,7 +654,7 @@ test('an ended generic lifetime is refused before dispatch or settle constructio await expect( dispatchGenericCommand({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/__tests__/daemon-composition/gesture-admission-parity.test.ts b/src/__tests__/daemon-composition/gesture-admission-parity.test.ts index 0217c1f6a1..3b64ea4b55 100644 --- a/src/__tests__/daemon-composition/gesture-admission-parity.test.ts +++ b/src/__tests__/daemon-composition/gesture-admission-parity.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test } from 'vitest'; import type { GestureCommandInput, @@ -71,7 +71,7 @@ async function admit(input: GestureCommandInput, target: DeviceInfo) { }); try { return await resolveBoundGestureRuntime({ - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), device: target, input, inspectFacts: bindings.inspectFacts, diff --git a/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts index dc00848a5f..5997af2372 100644 --- a/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts +++ b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import assert from 'node:assert/strict'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; @@ -49,7 +49,7 @@ import { import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import type { BindDeviceRuntime } from '../../../daemon/request-runtime-binding.ts'; import { - applicationLifecycleRuntimeFixture, + composedLifecycleRuntimeFixture, dispatchApplicationLifecycleEffect, } from '../application-lifecycle-runtime-fixture.ts'; import { SessionStore } from '../../../daemon/session-store.ts'; @@ -78,7 +78,7 @@ async function realLifecycleBinding( device, owner: localRuntimeOwner(device.platform), facts: await inspectLifecycleRuntimeFacts(device), - operations: await applicationLifecycleRuntimeFixture(device), + operations: await composedLifecycleRuntimeFixture(device), [Symbol.asyncDispose]: async () => {}, }; } @@ -125,7 +125,7 @@ test('cancellation after local device setup retains the device claim for recover try { const response = await handleProductionOpenCommand({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { command: 'open', token: 'test', diff --git a/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts b/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts index d6a7efc6e7..e1409d1c81 100644 --- a/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts +++ b/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts @@ -10,7 +10,7 @@ import { import { handleSessionCommands } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; // These relaunch cases pin the REAL open-target validation rules (the URL-target refusal and the // Android installed-package classification with its exact message) reached through the daemon's @@ -21,7 +21,7 @@ test('open --relaunch rejects URL targets', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -47,7 +47,7 @@ test('open --relaunch rejects Android app binary paths', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -71,7 +71,7 @@ test('open --relaunch rejects bare Android app binary filenames', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -106,7 +106,7 @@ test('open --relaunch rejects Android app binary paths for active sessions', asy const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -130,7 +130,7 @@ test('open --relaunch rejects Android app binary paths before resolving a new de const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts b/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts index 9ac56b6af1..ff80e7c5c0 100644 --- a/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts +++ b/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { test, expect, vi, afterEach, beforeEach } from 'vitest'; import { legacyDispatchCapture } from '../../../daemon/__tests__/legacy-snapshot-capture-fixture.ts'; import { resetGetRuntimeFixture } from '../../../daemon/__tests__/interaction-get-runtime-fixture.ts'; @@ -185,7 +185,7 @@ test('snapshot annotations survive a deferred post-gesture capture into CLI JSON const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -217,7 +217,7 @@ test('snapshot timeout captures Android screenshot evidence with overlay refs', mockAndroidTimeoutEvidenceDispatch(); const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -248,7 +248,7 @@ test('snapshot warns when recent snapshot node count collapses sharply', async ( const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -296,7 +296,7 @@ test('snapshot does not warn on expected node drop across presentation modes', a const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -335,7 +335,7 @@ test('snapshot automatically retries stale Android trees after recent navigation const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -365,7 +365,7 @@ test('snapshot warns when Android freshness retries still return the previous ro const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -415,7 +415,7 @@ test('snapshot response includes normalized visibility metadata', async () => { const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -445,7 +445,7 @@ test('diff snapshot carries stale-tree warnings for recent Android presses', asy const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'], flags: { snapshotInteractiveOnly: true }, diff --git a/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts b/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts index ff3ed1f6c6..4cb1287762 100644 --- a/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts +++ b/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { test, expect, vi, afterEach, beforeEach } from 'vitest'; import { legacyDispatchCapture } from '../../../daemon/__tests__/legacy-snapshot-capture-fixture.ts'; import { resetGetRuntimeFixture } from '../../../daemon/__tests__/interaction-get-runtime-fixture.ts'; @@ -89,7 +89,7 @@ test('snapshot rejects @ref scope without existing session snapshot', async () = const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotScope: '@e1' } }), sessionName, logPath: '/tmp/daemon.log', @@ -112,7 +112,7 @@ test('snapshot on iOS rejects sessions without a tracked app', async () => { const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -141,7 +141,7 @@ test('snapshot on iOS without a tracked app carries the detected open command as const sessionName = 'ios-sim-no-app-hinted'; sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); - const platformServices = daemonPlatformServicesFixture(); + const platformServices = composedPlatformServicesFixture(); const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, platformServices, @@ -180,7 +180,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -205,7 +205,7 @@ test('diff on local iOS still requires a tracked app', async () => { const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), sessionName, logPath: '/tmp/daemon.log', @@ -235,7 +235,7 @@ test('snapshot on iOS runs when the session tracks an app', async () => { const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -272,7 +272,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy const snapshotResponse = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -286,7 +286,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy const diffResponse = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), sessionName, logPath: '/tmp/daemon.log', @@ -307,7 +307,7 @@ async function runVersionedRefsCommand(params: { }): Promise | undefined> { const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(params.sessionName, params.command, { positionals: params.command === 'diff' ? ['snapshot'] : [], }), @@ -394,7 +394,7 @@ test('daemon-private snapshot observation advances capture state without publish const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { internal: { observationOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -418,7 +418,7 @@ test('snapshot surfaces filtered-to-zero Android guidance for interactive snapsh const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true, snapshotDepth: 3 }, }), @@ -440,7 +440,7 @@ test('diff rejects unsupported kind', async () => { const sessionStore = makeSessionStore(); const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest('default', 'diff', { positionals: ['unknown'] }), sessionName: 'default', logPath: '/tmp/daemon.log', @@ -459,7 +459,7 @@ test('diff screenshot is not handled daemon-side (client-backed command)', async const sessionStore = makeSessionStore(); const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: snapshotRequest('default', 'diff', { positionals: ['screenshot'] }), sessionName: 'default', logPath: '/tmp/daemon.log', diff --git a/src/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts b/src/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts index d7ff44e617..4f116db6e0 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { beforeEach, test, vi } from 'vitest'; @@ -195,7 +195,7 @@ async function swipeRefusedOnSecondRepetition(): Promise { sessionStore.publish(session.name, session); const response = await routeInteraction({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: session.name, @@ -229,7 +229,7 @@ async function gestureDragRefusedBeforeDispatch(): Promise { sessionStore.publish(session.name, session); const response = await routeInteraction({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: session.name, @@ -288,7 +288,7 @@ async function refRefusedAfterAndroidRecovery(command: 'press' | 'gesture'): Pro }; const response = await routeInteraction({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: session.name, diff --git a/src/__tests__/daemon-composition/interaction/interaction-gesture-drag.test.ts b/src/__tests__/daemon-composition/interaction/interaction-gesture-drag.test.ts index 8c9d1dc5f3..28812a6fa0 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-gesture-drag.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-gesture-drag.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { beforeEach, expect, test } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { @@ -64,7 +64,7 @@ function makeDragSession(sessionName: string) { async function runDrag(sessionStore: ReturnType, sessionName: string) { return await handleInteractionCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/__tests__/daemon-composition/interaction/interaction-settle.test.ts b/src/__tests__/daemon-composition/interaction/interaction-settle.test.ts index 0e346fd42d..494bec2804 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-settle.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-settle.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import { legacyDispatchCapture } from '../../../daemon/__tests__/legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; @@ -117,7 +117,7 @@ test('interaction runtime inherits the registered daemon request signal', async try { const runtime = await createInteractionRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/__tests__/daemon-composition/interaction/interaction-touch-prepare.test.ts b/src/__tests__/daemon-composition/interaction/interaction-touch-prepare.test.ts index 2238878a73..9c0a16c89c 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-touch-prepare.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-touch-prepare.test.ts @@ -22,7 +22,7 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../../daemon/request-runtime-binding.ts'; -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { testCreateCommandSurface } from '../command-surface-fixture.ts'; import { handleInteractionCommands } from '../../../daemon/interaction/index.ts'; import type { SessionStore } from '../../../daemon/session-store.ts'; @@ -146,7 +146,7 @@ async function runCommand( sessionName, sessionStore, contextFromFlags, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), inspectFacts, bindDevice: bindings.bindDevice, }); diff --git a/src/__tests__/daemon-composition/interaction/interaction-touch-response.test.ts b/src/__tests__/daemon-composition/interaction/interaction-touch-response.test.ts index d0f75322e9..aa04e9235c 100644 --- a/src/__tests__/daemon-composition/interaction/interaction-touch-response.test.ts +++ b/src/__tests__/daemon-composition/interaction/interaction-touch-response.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { @@ -148,7 +148,7 @@ test.each([false, true])('selector-touch observation after retirement=%s', async sessionRef: ref, sessionStore, contextFromFlags, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), captureSnapshotForSession: vi.fn(), }, result, @@ -225,7 +225,7 @@ test.each([ sessionRef: ref, sessionStore, contextFromFlags, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), captureSnapshotForSession: async () => { startProbe(); await released; @@ -665,7 +665,7 @@ test('an already retired coordinate touch skips its frame probe without a warnin sessionRef: ref, sessionStore, contextFromFlags, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), captureSnapshotForSession: capture, }, result: { kind: 'point', point: { x: 1, y: 2 } }, diff --git a/src/__tests__/daemon-composition/is-runtime.test.ts b/src/__tests__/daemon-composition/is-runtime.test.ts index df9b5ab578..46a5e41211 100644 --- a/src/__tests__/daemon-composition/is-runtime.test.ts +++ b/src/__tests__/daemon-composition/is-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { SnapshotResult } from '@agent-device/contracts/snapshot-runtime'; import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; @@ -83,7 +83,7 @@ test('an admitted is inspects once, binds once, and answers through the bound ca const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-bound', ['visible', 'id=auth_continue']), sessionName: 'is-bound', sessionStore, @@ -106,7 +106,7 @@ test('is absent uses the bound readAny capture for selector-first input without const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-absent', ['label="Removed row"', 'absent']), sessionName: 'is-absent', sessionStore, @@ -149,7 +149,7 @@ test('is absent bypasses a cached no-match snapshot before evaluating the bound const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-absent-fresh', ['absent', 'label="Removed row"']), sessionName: 'is-absent-fresh', sessionStore, @@ -183,7 +183,7 @@ test('is absent fails closed for a quality-less legacy iOS root-only capture', a const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-legacy-sparse', ['absent', 'label="Removed row"']), sessionName: 'is-legacy-sparse', sessionStore, @@ -221,7 +221,7 @@ test('is absent rejects depth and scope before binding with typed invalid argume ] as const) { const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-absent-flags', ['absent', 'label="Gone"'], { [flag]: value }), sessionName: 'is-absent-flags', sessionStore, @@ -252,7 +252,7 @@ test('an unavailable capture fact refuses before any bind', async () => { const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-refused', ['visible', 'id=auth_continue']), sessionName: 'is-refused', sessionStore, @@ -286,7 +286,7 @@ test('an iOS session with no tracked app is refused with the open hint, not answ async () => await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-no-app', ['visible', 'id=auth_continue']), sessionName: 'is-no-app', sessionStore, @@ -314,7 +314,7 @@ test('an iOS session WITH a tracked app still answers, so the refusal is the pla const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-with-app', ['visible', 'label=Continue']), sessionName: 'is-with-app', sessionStore, @@ -333,7 +333,7 @@ test('an Android session with no tracked app proceeds, because the owner adverti const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-android-no-app', ['visible', 'id=auth_continue']), sessionName: 'is-android-no-app', sessionStore, @@ -371,7 +371,7 @@ test('a refused request reaches the device by no route at all', async () => { const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-direct-refused', ['selected', 'id="shipping-pickup"']), sessionName: 'is-direct-refused', sessionStore, @@ -433,7 +433,7 @@ test('a failing predicate answers COMMAND_FAILED from the bound capture', async const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-direct-false', ['text', 'id=account_row', 'Wrong Expected Text']), sessionName: 'is-direct-false', sessionStore, @@ -475,7 +475,7 @@ test('a miss on a surface that never settled carries the unsettled fact, and the const isVisible = () => dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-unsettled', ['visible', 'id=target']), sessionName: 'is-unsettled', sessionStore, @@ -519,7 +519,7 @@ test('a read after a scroll that moved nothing carries the no-effect outcome, an const isVisible = () => dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest('is-no-effect', ['visible', 'id=row']), sessionName: 'is-no-effect', sessionStore, diff --git a/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts b/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts index 8233ef4fdb..97446176cd 100644 --- a/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts +++ b/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts @@ -2,7 +2,7 @@ import { createLifecycleDeviceRuntimeGateway, createLifecycleDeviceRuntimeGatewaySpies, } from '../../daemon/__tests__/test-device-runtime-gateway.ts'; -import { applicationLifecycleRuntimeFixture } from './application-lifecycle-runtime-fixture.ts'; +import { composedLifecycleRuntimeFixture } from './application-lifecycle-runtime-fixture.ts'; /** * The lifecycle gateway over the REAL composed platform runtime (root gateway + apple/android @@ -10,8 +10,8 @@ import { applicationLifecycleRuntimeFixture } from './application-lifecycle-runt * daemon suites bind the canned port fixture instead. */ export const realLifecycleDeviceRuntimeGateway = createLifecycleDeviceRuntimeGateway( - applicationLifecycleRuntimeFixture, + composedLifecycleRuntimeFixture, ); export const createRealLifecycleDeviceRuntimeGatewaySpies = () => - createLifecycleDeviceRuntimeGatewaySpies(applicationLifecycleRuntimeFixture); + createLifecycleDeviceRuntimeGatewaySpies(composedLifecycleRuntimeFixture); diff --git a/src/__tests__/daemon-composition/platform-services-fixture.ts b/src/__tests__/daemon-composition/platform-services-fixture.ts index 88ac8cead0..5cf9783a8a 100644 --- a/src/__tests__/daemon-composition/platform-services-fixture.ts +++ b/src/__tests__/daemon-composition/platform-services-fixture.ts @@ -10,7 +10,7 @@ import type { ClaimRecoveryGatewayFactory } from '../../daemon/device/claim-reco * one ask replaces just that member — a readiness spy overrides `ensureLocalDeviceReady` rather * than mocking a platform module behind the port. */ -export function daemonPlatformServicesFixture( +export function composedPlatformServicesFixture( overrides: Partial = {}, ): DaemonPlatformServices { return Object.freeze({ ...createDaemonPlatformServices(), ...overrides }); @@ -21,6 +21,6 @@ export function daemonPlatformServicesFixture( * route a stale claim see the real composed gateway, and a test that cares about composition * passes its own factory. */ -export function daemonClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory { +export function composedClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory { return createClaimRecoveryGateway; } diff --git a/src/__tests__/daemon-composition/request-handler-chain.test.ts b/src/__tests__/daemon-composition/request-handler-chain.test.ts index c68106102f..1fb425fa73 100644 --- a/src/__tests__/daemon-composition/request-handler-chain.test.ts +++ b/src/__tests__/daemon-composition/request-handler-chain.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -50,7 +50,7 @@ function makeChainParams(req: DaemonRequest) { logPath: '/tmp/agent-device-request-chain.log', sessionStore, leaseRegistry: new LeaseRegistry(), - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), invoke: async (): Promise => ({ ok: true, data: {} }), providerScope: {}, bindDevice: unavailableBindDevice, @@ -321,7 +321,7 @@ test('duration-less public coordinate swipe retains Linux drag behavior', async async () => await handleInteractionCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), inspectFacts: bindings.inspectFacts, bindDevice: bindings.bindDevice, req: { diff --git a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts index 2a7d70cd8c..85b6f72c5e 100644 --- a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts +++ b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts @@ -6,8 +6,8 @@ import { spawn } from 'node:child_process'; import { fakeCreateCommandSurface } from '../../daemon/__tests__/support/command-surface-fake.ts'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { - daemonClaimRecoveryGatewayFixture, - daemonPlatformServicesFixture, + composedClaimRecoveryGatewayFixture, + composedPlatformServicesFixture, } from './platform-services-fixture.ts'; import { getResolveTargetDeviceMock } from '../../daemon/__tests__/request-router-dispatch-mocks.ts'; import { LeaseRegistry } from '../../daemon/lease-registry.ts'; @@ -103,8 +103,8 @@ function createOpenHandler(sessionStore: ReturnType) { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), - platformServices: daemonPlatformServicesFixture(), - claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), + platformServices: composedPlatformServicesFixture(), + claimRecoveryGateway: composedClaimRecoveryGatewayFixture(), deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', diff --git a/src/__tests__/daemon-composition/request-router-recording-health.test.ts b/src/__tests__/daemon-composition/request-router-recording-health.test.ts index fc76800eb9..c5bc86db5f 100644 --- a/src/__tests__/daemon-composition/request-router-recording-health.test.ts +++ b/src/__tests__/daemon-composition/request-router-recording-health.test.ts @@ -5,7 +5,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import path from 'node:path'; import { testCreateCommandSurface } from './command-surface-fixture.ts'; -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { createRequestHandler, gestureDeviceRuntimeGateway, @@ -20,7 +20,7 @@ import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; // The observation spy hangs off the port the router composes with (ADR 0019 section 11), not off // the platform module behind it. const mockObserveRunnerSession = vi.fn(); -const platformServices = daemonPlatformServicesFixture({ +const platformServices = composedPlatformServicesFixture({ appleSessionObservation: { observeRunnerSession: mockObserveRunnerSession, resolveSoleForegroundApp: async () => undefined, diff --git a/src/__tests__/daemon-composition/screenshot-runtime.test.ts b/src/__tests__/daemon-composition/screenshot-runtime.test.ts index 8c038268dc..3ebbdf86a8 100644 --- a/src/__tests__/daemon-composition/screenshot-runtime.test.ts +++ b/src/__tests__/daemon-composition/screenshot-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; @@ -74,7 +74,7 @@ test('admits one capture plan, binds once, and hands the runtime the resolved de const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -101,7 +101,7 @@ test('an iOS simulator session capture skips the redundant boot probe', async () const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -121,7 +121,7 @@ test('refuses an unavailable exact-owner capture fact before binding', async () const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/unsupported.png'] }), session, inspectFacts: fixture.inspectFacts, @@ -152,7 +152,7 @@ test('--overlay-refs is refused up front when the target cannot capture a tree', const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/overlay.png'], flags: { overlayRefs: true } }), session, inspectFacts: fixture.inspectFacts, @@ -186,7 +186,7 @@ test('the default destination is a reserved temp file, removed when the capture const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -206,7 +206,7 @@ test('rejects the retired max-size field before inspecting owner facts', async ( await expect( resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/legacy.png'], flags: { screenshotMaxSize: 720 } as unknown as DaemonRequest['flags'], @@ -226,7 +226,7 @@ test('rejects --pixel-density outside iOS-family simulators before inspecting ow await expect( resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/android.png'], flags: { screenshotPixelDensity: 2 }, @@ -278,7 +278,7 @@ test('the crop runs after the platform write and before the shared scale', async const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -328,7 +328,7 @@ test('a partial crop surfaces its warning once in the result record and annotate const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -352,7 +352,7 @@ test('the crop plan is refused up front when the target cannot capture a tree', const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/crop.png'], flags: { screenshotCropOn: 'label="Save"' }, @@ -389,7 +389,7 @@ test('the result record carries the display rotation the capture reported', asyn const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -410,7 +410,7 @@ test('the result record omits the display rotation when the capture reported non const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, diff --git a/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts b/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts index 86acfbd783..8c11023046 100644 --- a/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts +++ b/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../daemon/__tests__/support/store-factory.ts'; @@ -113,7 +113,7 @@ test('an ambiguous is issues its printed candidates so press acts on the listed const fixture = selectorCaptureFixture({ snapshot: () => ambiguousFullCapture() }); const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest(sessionName, ['visible', 'label="Deploy"']), sessionName, sessionStore, @@ -160,7 +160,7 @@ test('a plain candidate ref from the ambiguity refusal is refused, not silently const fixture = selectorCaptureFixture({ snapshot: () => ambiguousFullCapture() }); const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest(sessionName, ['visible', 'label="Deploy"']), sessionName, sessionStore, @@ -225,7 +225,7 @@ test('an ambiguous is on a sparse capture prints no candidates and issues nothin const fixture = selectorCaptureFixture({ snapshot: sparseCapture }); const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: isRequest(sessionName, ['visible', 'label="Deploy"']), sessionName, sessionStore, diff --git a/src/__tests__/daemon-composition/selector-runtime-backend.test.ts b/src/__tests__/daemon-composition/selector-runtime-backend.test.ts index 555a9bd984..7fcd66ce65 100644 --- a/src/__tests__/daemon-composition/selector-runtime-backend.test.ts +++ b/src/__tests__/daemon-composition/selector-runtime-backend.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import type { @@ -55,7 +55,7 @@ test('wait text passes its poll deadline signal to the Apple runner fast path', ); const runtime = await createSelectorRuntimeForDevice({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), ref: sessionStore.lookup(sessionName), req: { token: 't', @@ -118,7 +118,7 @@ test('daemon wait stable pins private-ax on emitted snapshot runner requests', a })); const runtime = await createSelectorRuntimeForDevice({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), ref: sessionStore.lookup(sessionName), req: { token: 't', diff --git a/src/__tests__/daemon-composition/selector-runtime.test.ts b/src/__tests__/daemon-composition/selector-runtime.test.ts index dea473e801..2b22923d76 100644 --- a/src/__tests__/daemon-composition/selector-runtime.test.ts +++ b/src/__tests__/daemon-composition/selector-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test } from 'vitest'; import { localRuntimeOwner, @@ -85,7 +85,7 @@ test.each(['rebuild', 'retire'] as const)( }; const running = dispatchGetViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: 'default', 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 index b461adbf5d..62d6d9799c 100644 --- 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 @@ -1,11 +1,11 @@ import { beforeEach, expect, test } from 'vitest'; -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { sessionCloseShutdownFixture, type SessionState, } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; -import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; +import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; const { AppError, @@ -27,7 +27,7 @@ const { const handleSessionCommands = (params: Parameters[0]) => handleHarnessSessionCommands({ ...params, - lifecycleOperationsLoader: applicationLifecycleRuntimeFixture, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, }); // These two cases pin the REAL Apple close sequencing around a platform close: the runner stop @@ -59,7 +59,7 @@ test('targeted close preserves the platform-close AppError and still runs later await expect( handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -118,7 +118,7 @@ test('targeted close skips platform dispatch and preserves the error when the re await expect( handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, 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 index 48e95db254..471f63c789 100644 --- 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 @@ -18,7 +18,7 @@ vi.mock('../../../platform-runtime-runtime-hints.ts', () => { return { clearRuntimeHintValues: runtimeHintsModule.clearRuntimeHintValues }; }); -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { handleSessionCommands as handleHarnessSessionCommands, mockBindDeviceRuntime, @@ -26,7 +26,7 @@ import { } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; import { lifecycleRuntimeFacts } from '../../../daemon/__tests__/application-lifecycle-runtime-harness.ts'; import { - applicationLifecycleRuntimeFixture, + composedLifecycleRuntimeFixture, dispatchApplicationLifecycleEffect, } from '../application-lifecycle-runtime-fixture.ts'; @@ -35,7 +35,7 @@ import { const handleSessionCommands = (params: Parameters[0]) => handleHarnessSessionCommands({ ...params, - lifecycleOperationsLoader: applicationLifecycleRuntimeFixture, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, }); import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; @@ -74,7 +74,7 @@ async function close(params: { }) { return await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: closeRequest(params.sessionName, params.positionals ?? [], params.internal), sessionName: params.sessionName, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), 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 index 3c88d70cf2..55d57300c2 100644 --- 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 @@ -1,8 +1,8 @@ import { beforeEach, expect, test } from 'vitest'; -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; -import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; +import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; const { @@ -23,7 +23,7 @@ const { const handleSessionCommands = (params: Parameters[0]) => handleHarnessSessionCommands({ ...params, - lifecycleOperationsLoader: applicationLifecycleRuntimeFixture, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, }); // The second half of this test proves that after the unarmed-authoring rejection, a plain close @@ -51,7 +51,7 @@ test('close --save-script on a never-armed session is rejected before teardown, await expect( handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -87,7 +87,7 @@ test('close --save-script on a never-armed session is rejected before teardown, // teardown genuinely does run: the recorder is signaled and the session deleted. const plainClose = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, 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 index 463bf9fc12..bf1519341f 100644 --- 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 @@ -1,8 +1,8 @@ import { beforeEach, expect, test } from 'vitest'; -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; -import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; +import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; const { @@ -20,7 +20,7 @@ const { const handleSessionCommands = (params: Parameters[0]) => handleHarnessSessionCommands({ ...params, - lifecycleOperationsLoader: applicationLifecycleRuntimeFixture, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, }); // The shutdown-gating rule lives in the real Apple/Android close finalization: a physical device @@ -44,7 +44,7 @@ test('close --shutdown is ignored for non-simulator iOS devices', async () => { const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -84,7 +84,7 @@ test('close --shutdown is ignored for Android devices', async () => { const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, 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 index 5759bde5cd..e378d16267 100644 --- a/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts +++ b/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts @@ -11,10 +11,10 @@ import { assertInvalidArgsMessage, } from '../../../daemon/handlers/__tests__/session-test-harness.ts'; import { handleSessionCommands as handleHarnessSessionCommands } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; -import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; +import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +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 @@ -26,7 +26,7 @@ import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; const handleSessionCommands = (params: Parameters[0]) => handleHarnessSessionCommands({ ...params, - lifecycleOperationsLoader: applicationLifecycleRuntimeFixture, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, }); test('open web URL on iOS device session without active app falls back to Safari', async () => { @@ -51,7 +51,7 @@ test('open web URL on iOS device session without active app falls back to Safari const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -103,7 +103,7 @@ test('open web URL on iOS simulator session without active app falls back to Saf const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -150,7 +150,7 @@ test('open app and URL on existing iOS device session keeps app context', async const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -199,7 +199,7 @@ test('open app on existing macOS session resolves and stores bundle id', async ( const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -233,7 +233,7 @@ test('open rejects --surface on non-macOS devices', async () => { const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: 'ios-surface', @@ -286,7 +286,7 @@ test('open on existing macOS frontmost-app session preserves surface without --s try { const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -339,7 +339,7 @@ test('open app on existing Android session resolves and stores package id', asyn const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -385,7 +385,7 @@ test('open intent target on existing Android session clears stale package contex const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, 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 index f7dd78c3f5..4f7c77248e 100644 --- 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 @@ -54,14 +54,14 @@ vi.mock('@agent-device/host-kit/process', async (importOriginal) => { return { ...actual, readProcessStartTime: vi.fn(() => 'test-process-start') }; }); -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; import { handleSessionCommands as handleHarnessSessionCommands, mockBindDeviceRuntime, } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; import { - applicationLifecycleRuntimeFixture, + composedLifecycleRuntimeFixture, dispatchApplicationLifecycleEffect, } from '../application-lifecycle-runtime-fixture.ts'; import { pendingSurfaceStabilization } from '../../../daemon/deferred-interaction-outcome.ts'; @@ -71,7 +71,7 @@ import { pendingSurfaceStabilization } from '../../../daemon/deferred-interactio const handleSessionCommands = (params: Parameters[0]) => handleHarnessSessionCommands({ ...params, - lifecycleOperationsLoader: applicationLifecycleRuntimeFixture, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, }); import { makeAppleSimulator, @@ -119,7 +119,7 @@ async function openReportingObservation( const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, 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 index f2abae4726..094abf91f1 100644 --- a/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts +++ b/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts @@ -1,9 +1,9 @@ import { beforeEach, expect, test } from 'vitest'; import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; -import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; -import { applicationLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; +import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; const { @@ -27,7 +27,7 @@ const { const handleSessionCommands = (params: Parameters[0]) => handleHarnessSessionCommands({ ...params, - lifecycleOperationsLoader: applicationLifecycleRuntimeFixture, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, platformResourceCleanup, }); @@ -46,7 +46,7 @@ test('close finalizes an active iOS simulator recording before deleting the sess const response = await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -86,7 +86,7 @@ test('close surfaces a recording finalization failure through the cleanup-failur await expect( handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts b/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts index 746d0cd923..4b4c58396f 100644 --- a/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts +++ b/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { afterEach, expect, test } from 'vitest'; import { makeAndroidSession } from '../../daemon/__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../daemon/__tests__/support/store-factory.ts'; @@ -77,13 +77,13 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { createCommandSurface: testCreateCommandSurface, ...input, ...snapshotRuntimeFixture(requestId), - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), }) : dispatchSnapshotDiffViaRuntime({ createCommandSurface: testCreateCommandSurface, ...input, ...snapshotRuntimeFixture(requestId), - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), }); await dispatchEntered.promise; markRequestCanceled(requestId); diff --git a/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts b/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts index b370e15f93..088f5dfe59 100644 --- a/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts +++ b/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { type DeviceBinding, @@ -132,7 +132,7 @@ test.each([ const response = await dispatchSnapshotDiffViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { command: 'diff', positionals: ['snapshot'], @@ -193,7 +193,7 @@ test('preserves initialized, unchanged, and changed diff results through one bou const run = async () => await dispatchSnapshotDiffViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { command: 'diff', positionals: ['snapshot'], @@ -243,7 +243,7 @@ test('a sparse internal diff capture returns no screenshot fallback artifact', a const response = await dispatchSnapshotDiffViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { command: 'diff', positionals: ['snapshot'], token: 't', session: 'diff-runtime' }, sessionName: 'diff-runtime', logPath: '/tmp/diff-runtime.log', diff --git a/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts b/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts index 6a39245301..7572fde6bb 100644 --- a/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts +++ b/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import path from 'node:path'; import { expect, test } from 'vitest'; import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; @@ -59,7 +59,7 @@ async function dispatch( ) { const response = await dispatchSnapshotViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { command: 'snapshot', positionals: [], diff --git a/src/__tests__/daemon-composition/wait-runtime.test.ts b/src/__tests__/daemon-composition/wait-runtime.test.ts index 7044063f0a..d79b494f67 100644 --- a/src/__tests__/daemon-composition/wait-runtime.test.ts +++ b/src/__tests__/daemon-composition/wait-runtime.test.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { WAIT_REASONS, type ReadinessPhase } from '@agent-device/contracts/wait'; import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; @@ -164,7 +164,7 @@ test.each(['rebuild', 'retire'] as const)( const ref = sessionStore.publish(address, makeSession('default', { device: harness.device })); const running = handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: waitRequest(['100']), sessionName: address, logPath: '/tmp/daemon.log', @@ -220,7 +220,7 @@ async function runWait( sessionStore.publish(session.name, session); const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: waitRequest(positionals, flags), sessionName: session.name, logPath: '/tmp/daemon.log', @@ -255,7 +255,7 @@ test('an observing wait binds through the shared selector entry', async () => { const harness = waitRuntimeHarness({ findText: available }); const bound = await resolveBoundSelectorCapture({ - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), command: 'wait', session: undefined, device: harness.device, @@ -476,7 +476,7 @@ test('a read after a natively satisfied text wait captures instead of reusing th const read = await dispatchGetViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { command: 'get', positionals: ['text', 'label="cold.start"'], diff --git a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts index 251c61ae7d..1bea3c7f24 100644 --- a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts +++ b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts @@ -1,7 +1,7 @@ // Real-scope same-device serialization guard for the concurrency torture lane import { - daemonPlatformServicesFixture, - daemonClaimRecoveryGatewayFixture, + composedPlatformServicesFixture, + composedClaimRecoveryGatewayFixture, } from '../../../../src/__tests__/daemon-composition/platform-services-fixture.ts'; // (#1416). The seeded sweep models the mutex GRANT (a seed cannot reproduce // `withKeyedLock`'s native microtask hand-off), so on its own it could stay @@ -69,8 +69,8 @@ export async function measureRealScopeMaxOverlap(concurrency: number): Promise createRequestExecutionScope({ - platformServices: daemonPlatformServicesFixture(), - claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), + platformServices: composedPlatformServicesFixture(), + claimRecoveryGateway: composedClaimRecoveryGatewayFixture(), req: openRequest(`real-${i}`, device.id), sessionStore, leaseRegistry, diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts index 1ace37aee5..6231d558dd 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts @@ -1,4 +1,4 @@ -import { daemonPlatformServicesFixture } from '../../../src/__tests__/daemon-composition/platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from '../../../src/__tests__/daemon-composition/platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { vi } from 'vitest'; @@ -82,7 +82,7 @@ export async function inspectProviderDeploymentAdmission(params: { device: params.device, }); const response = await handleAppDeploymentCommand({ - platformServices: daemonPlatformServicesFixture(), + platformServices: composedPlatformServicesFixture(), req: { token: 'test', session: 'default',