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/scripts/daemon-edge-residue-scan.mjs b/scripts/daemon-edge-residue-scan.mjs new file mode 100644 index 0000000000..fe96a27118 --- /dev/null +++ b/scripts/daemon-edge-residue-scan.mjs @@ -0,0 +1,90 @@ +// 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?$/, ''); + // 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); + // 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}`); + } + } +} +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); 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..a36337784c --- /dev/null +++ b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts @@ -0,0 +1,200 @@ +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'; +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, + dispatchApplicationLifecycleEffect, +} from '../../daemon/__tests__/application-lifecycle-runtime-fixture.ts'; +import { + applicationLifecycleFixtureInteractor, + awaitFixtureReadiness, +} 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'; + +/** + * 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 composedLifecycleRuntimeFixture( + 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/daemon/device/__tests__/device-ready.test.ts b/src/__tests__/daemon-composition/device/device-ready.test.ts similarity index 86% rename from src/daemon/device/__tests__/device-ready.test.ts rename to src/__tests__/daemon-composition/device/device-ready.test.ts index 4f6e10e119..aaf2776f7c 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 { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => { @@ -28,14 +28,19 @@ 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. -const platformServices = daemonPlatformServicesFixture(); +// This suite pins what the REAL composed readiness port does per device family: it runs in the +// composition lane on the root-composed services fixture, with the platform leaves mocked above. +// The daemon-lane port fixture answers readiness with a canned spy; this suite separately +// pins the readiness cache contract, including its TTL behavior. +const platformServices = composedPlatformServicesFixture(); beforeEach(() => { vi.useFakeTimers(); diff --git a/src/__tests__/daemon-composition/generic-settle.test.ts b/src/__tests__/daemon-composition/generic-settle.test.ts index 735294fd80..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 '../../daemon/__tests__/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 5feb630824..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 '../../daemon/__tests__/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-close-runner-retention.test.ts b/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts new file mode 100644 index 0000000000..08ed51dd7b --- /dev/null +++ b/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts @@ -0,0 +1,213 @@ +import { test, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; +import { LeaseRegistry } from '../../../daemon/lease-registry.ts'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +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 } from '../../../daemon/__tests__/test-device-runtime-gateway.ts'; +import { realLifecycleDeviceRuntimeGateway } from '../lifecycle-runtime-gateway.ts'; +import { + dispatchApplicationLifecycleEffect, + awaitFixtureReadiness, + discoverReadyAndroidEmulators, +} from '../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: realLifecycleDeviceRuntimeGateway, + 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/__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..5997af2372 --- /dev/null +++ b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts @@ -0,0 +1,153 @@ +import { composedPlatformServicesFixture } 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 { + composedLifecycleRuntimeFixture, + 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. 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> { + return { + device, + owner: localRuntimeOwner(device.platform), + facts: await inspectLifecycleRuntimeFacts(device), + operations: await composedLifecycleRuntimeFixture(device), + [Symbol.asyncDispose]: async () => {}, + }; +} + +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); +}); + +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 }; +} + +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: composedPlatformServicesFixture(), + 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-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/handlers/__tests__/session-relaunch-close.test.ts b/src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts similarity index 86% rename from src/daemon/handlers/__tests__/session-relaunch-close.test.ts rename to src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts index 9c624929f1..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', }); @@ -549,56 +551,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 +658,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'; 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..e1409d1c81 --- /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 { 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 +// `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: composedPlatformServicesFixture(), + 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: composedPlatformServicesFixture(), + 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: composedPlatformServicesFixture(), + 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: composedPlatformServicesFixture(), + 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: composedPlatformServicesFixture(), + 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..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 '../../../daemon/__tests__/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 8fc3e5ef25..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 '../../../daemon/__tests__/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/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/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts b/src/__tests__/daemon-composition/interaction/interaction-dispatch-disclosure.test.ts index b1cea82bbf..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 '../../../daemon/__tests__/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 c19b200dea..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 '../../../daemon/__tests__/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 0f5a4f51ef..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 '../../../daemon/__tests__/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 d200ee305c..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 '../../../daemon/__tests__/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 ab22406e52..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 '../../../daemon/__tests__/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 a319bb5bc6..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 '../../daemon/__tests__/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 new file mode 100644 index 0000000000..97446176cd --- /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 { composedLifecycleRuntimeFixture } 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( + composedLifecycleRuntimeFixture, +); + +export const createRealLifecycleDeviceRuntimeGatewaySpies = () => + createLifecycleDeviceRuntimeGatewaySpies(composedLifecycleRuntimeFixture); 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-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(); +}); 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..5cf9783a8a --- /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 composedPlatformServicesFixture( + 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 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 599ee3230e..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 '../../daemon/__tests__/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'; @@ -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'; @@ -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 new file mode 100644 index 0000000000..85b6f72c5e --- /dev/null +++ b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts @@ -0,0 +1,261 @@ +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 { fakeCreateCommandSurface } from '../../daemon/__tests__/support/command-surface-fake.ts'; +import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { + composedClaimRecoveryGatewayFixture, + composedPlatformServicesFixture, +} 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). + +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, +} 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 { + 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: composedPlatformServicesFixture(), + claimRecoveryGateway: composedClaimRecoveryGatewayFixture(), + 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. 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(process.execPath, ['-e', 'setTimeout(() => {}, 120_000)'], { + 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/__tests__/daemon-composition/request-router-recording-health.test.ts b/src/__tests__/daemon-composition/request-router-recording-health.test.ts index e3109cb15e..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 '../../daemon/__tests__/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/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/screenshot-runtime.test.ts b/src/__tests__/daemon-composition/screenshot-runtime.test.ts index 0ee2edd9b1..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 '../../daemon/__tests__/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 c475a8ca2b..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 '../../daemon/__tests__/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 584f87f1b9..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 '../../daemon/__tests__/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 464b4167e7..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 '../../daemon/__tests__/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 new file mode 100644 index 0000000000..62d6d9799c --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-close-error-precedence.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, expect, test } from 'vitest'; +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 { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; + +const { + AppError, + handleSessionCommands: handleHarnessSessionCommands, + makeIosSimulatorRecordingSession, + makeSession, + makeSessionStore, + mkdtempForTestSync, + mockDispatchCommand, + mockReleaseRunnerOnClose, + mockStopIosRunnerSession, + noopInvoke, + path, + resetSessionCloseShutdownMocks, +} = sessionCloseShutdownFixture; + +// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: +// these composition cases pass the REAL composed fixture instead of the daemon's canned default. +const handleSessionCommands = (params: Parameters[0]) => + handleHarnessSessionCommands({ + ...params, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, + }); + +// These two cases pin the REAL Apple close sequencing around a platform close: the runner stop +// that precedes dispatch on a physical device, and error precedence when the close effect itself +// fails. The canned port fixture answers close without runner staging, so they run in the +// composition lane. +beforeEach(resetSessionCloseShutdownMocks); + +test('targeted close preserves the platform-close AppError and still runs later cleanup', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'targeted-close-error-session'; + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { + device: { + platform: 'apple', + id: 'sim-udid-close-error', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }, + }); + sessionStore.publish(sessionName, session); + + const platformCloseError = new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { + reason: 'device_disconnected', + hint: 'Reconnect the device and retry close.', + }); + mockDispatchCommand.mockRejectedValueOnce(platformCloseError); + + await expect( + handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: ['com.example.app'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toBe(platformCloseError); + + // The original AppError code/details/hint are preserved, not collapsed into a + // generic cleanup aggregate. + expect(platformCloseError.code).toBe('DEVICE_UNAVAILABLE'); + expect(platformCloseError.details).toMatchObject({ + reason: 'device_disconnected', + hint: 'Reconnect the device and retry close.', + }); + // A failed close is not recorded as `Closed`. + expect(session.actions.some((action) => action.command === 'close')).toBe(false); + // Subsequent independent cleanup still ran, and the session was still deleted. + expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('targeted close skips platform dispatch and preserves the error when the required pre-close runner stop fails', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'targeted-close-preclose-failure-session'; + // A physical Apple target (non-simulator) must stop its runner before the + // platform close is dispatched — the runner owns the device connection. + const session = { + ...makeSession(sessionName, { + platform: 'apple', + id: 'physical-device-close', + name: 'My iPhone', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.app', + } as unknown as SessionState; + sessionStore.publish(sessionName, session); + + const preCloseError = new AppError('RUNNER_UNAVAILABLE', 'runner stop failed', { + reason: 'runner_stop_failed', + hint: 'Retry once the runner is reachable.', + }); + // Scoped to this test's pre-close stop: this one-shot rejection is consumed there, + // so it cannot leak into later tests when `vi.clearAllMocks()` clears only call + // history — a persistent `mockRejectedValue` would leak into every later test that + // exercises an Apple-platform runner stop. + mockStopIosRunnerSession.mockRejectedValueOnce(preCloseError); + + await expect( + handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: ['com.example.app'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toBe(preCloseError); + + // The platform close must not be dispatched after a required pre-close stop fails. + expect(mockDispatchCommand).not.toHaveBeenCalled(); + // The original failure code/details/hint are preserved, not collapsed into a + // generic cleanup aggregate. + expect(preCloseError.code).toBe('RUNNER_UNAVAILABLE'); + expect(preCloseError.details).toMatchObject({ + reason: 'runner_stop_failed', + hint: 'Retry once the runner is reachable.', + }); + // A skipped close is not recorded as `Closed`. + expect(session.actions.some((action) => action.command === 'close')).toBe(false); + // Later independent cleanup still ran (runner release re-attempted), and the + // session was still deleted. + expect(mockStopIosRunnerSession).toHaveBeenCalledOnce(); + expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-close-runtime-hints.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-close-runtime-hints.test.ts new file mode 100644 index 0000000000..471f63c789 --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-close-runtime-hints.test.ts @@ -0,0 +1,270 @@ +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 { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { + handleSessionCommands as handleHarnessSessionCommands, + mockBindDeviceRuntime, + mockInspectDeviceRuntimeFacts, +} from '../../../daemon/handlers/__tests__/session-command-harness.ts'; +import { lifecycleRuntimeFacts } from '../../../daemon/__tests__/application-lifecycle-runtime-harness.ts'; +import { + composedLifecycleRuntimeFixture, + dispatchApplicationLifecycleEffect, +} from '../application-lifecycle-runtime-fixture.ts'; + +// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: +// these composition cases pass the REAL composed fixture instead of the daemon's canned default. +const handleSessionCommands = (params: Parameters[0]) => + handleHarnessSessionCommands({ + ...params, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, + }); +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; + +const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); +const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); + +function makeSessionStore(): SessionStore { + const root = mkdtempForTestSync('agent-device-session-close-lifecycle-runtime-'); + return new SessionStore(path.join(root, 'sessions')); +} + +function makeSession(name: string, device: SessionState['device']): SessionState { + return { name, device, createdAt: Date.now(), actions: [] }; +} + +function closeRequest( + session: string, + positionals: string[], + internal?: DaemonRequest['internal'], +) { + return { + token: 't', + session, + command: 'close' as const, + positionals, + flags: {}, + ...(internal === undefined ? {} : { internal }), + }; +} + +async function close(params: { + sessionName: string; + sessionStore: SessionStore; + positionals?: string[]; + internal?: DaemonRequest['internal']; +}) { + return await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: closeRequest(params.sessionName, params.positionals ?? [], params.internal), + sessionName: params.sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore: params.sessionStore, + invoke: noopInvoke, + }); +} + +// The load-policy evidence must survive module loading: the mocked factory sets the +// flag when the SUT first imports it, so the FIRST entry into a test may still see a +// false flag only because the SUT imports the runtime-hints operation lazily. If a +// regression made that import eager, the factory would already have run during test +// module loading and this first assertion fails. After that proof, each case resets +// the flag so its own negative assertion measures only its own close. +let eagerEvaluationProofed = false; + +beforeEach(() => { + vi.clearAllMocks(); + if (!eagerEvaluationProofed) { + expect(runtimeHintsModule.evaluated).toBe(false); + eagerEvaluationProofed = true; + } + runtimeHintsModule.evaluated = false; +}); + +test('a close-capable runtime with false runtime-hints facts never loads the runtime-hints operation', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'harmony-close-without-runtime-hints'; + const device = { + platform: 'harmonyos' as const, + id: 'harmony-device-1', + name: 'Harmony device', + kind: 'device' as const, + target: 'mobile' as const, + booted: true, + }; + const session = makeSession(sessionName, device); + session.appBundleId = 'com.example.harmony'; + sessionStore.publish(sessionName, session); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { + const facts = lifecycleRuntimeFacts(candidate); + return { + ...facts, + operations: { + ...facts.operations, + clearRuntimeHints: { + available: false as const, + reason: 'unsupported-platform-leaf' as const, + }, + }, + }; + }); + + const response = await close({ + sessionName, + sessionStore, + positionals: ['com.example.harmony'], + }); + + expect(response?.ok).toBe(true); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); + expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledOnce(); + expect(runtimeHintsModule.evaluated).toBe(false); + expect(runtimeHintsModule.clearRuntimeHintValues).not.toHaveBeenCalled(); +}); + +test('an app-only close with stored hints selects no runtime-hint cleanup sibling', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-app-only-close-stored-runtime-hints'; + const device = { + platform: 'android' as const, + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, + }; + const session = makeSession(sessionName, device); + session.appBundleId = 'com.example.app'; + sessionStore.publish(sessionName, session); + sessionStore.setRuntimeHints(sessionName, { platform: 'android', metroHost: '10.0.2.2' }); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { + const facts = lifecycleRuntimeFacts(candidate); + return { + ...facts, + operations: { + ...facts.operations, + clearRuntimeHints: { + available: false as const, + reason: 'unsupported-provider-mode' as const, + }, + }, + }; + }); + + const response = await close({ + sessionName, + sessionStore, + positionals: ['com.example.app'], + internal: { closeAppOnly: true }, + }); + + expect(response?.ok).toBe(true); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); + expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledOnce(); + expect(runtimeHintsModule.evaluated).toBe(false); + expect(runtimeHintsModule.clearRuntimeHintValues).not.toHaveBeenCalled(); + expect(sessionStore.getRuntimeHints(sessionName)).toEqual({ + platform: 'android', + metroHost: '10.0.2.2', + }); +}); + +test('a close with persisted native hints fails closed when the distinct cleanup fact is false', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-close-stale-runtime-hints'; + const device = { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'ios-device-1', + name: 'iPhone', + kind: 'device' as const, + target: 'mobile' as const, + booted: true, + }; + const session = makeSession(sessionName, device); + session.appBundleId = 'com.example.app'; + sessionStore.publish(sessionName, session); + sessionStore.setRuntimeHints(sessionName, { platform: 'ios', metroHost: '10.0.2.2' }); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { + const facts = lifecycleRuntimeFacts(candidate); + return { + ...facts, + operations: { + ...facts.operations, + clearRuntimeHints: { + available: false as const, + reason: 'unsupported-device-kind' as const, + }, + }, + }; + }); + + const response = await close({ sessionName, sessionStore }); + + expect(response).toMatchObject({ + ok: false, + error: { code: 'UNSUPPORTED_OPERATION', message: 'close is not supported on this device' }, + }); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); + expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(runtimeHintsModule.evaluated).toBe(false); + expect(sessionStore.get(sessionName)).toMatchObject({ name: sessionName }); +}); + +test('a supported Android close clears admitted runtime hints exactly once', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-close-runtime-hints'; + const device = { + platform: 'android' as const, + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, + }; + const session = makeSession(sessionName, device); + session.appBundleId = 'com.example.app'; + sessionStore.publish(sessionName, session); + sessionStore.setRuntimeHints(sessionName, { + platform: 'android', + metroHost: '10.0.2.2', + metroPort: 8081, + }); + + const response = await close({ sessionName, sessionStore }); + + expect(response?.ok).toBe(true); + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledTimes(1); + expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); + expect(runtimeHintsModule.evaluated).toBe(true); + expect(runtimeHintsModule.clearRuntimeHintValues).toHaveBeenCalledOnce(); + expect(runtimeHintsModule.clearRuntimeHintValues).toHaveBeenCalledWith( + expect.objectContaining({ + device, + appId: 'com.example.app', + values: expect.objectContaining({ metroHost: '10.0.2.2', metroPort: '8081' }), + }), + ); +}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-close-save-script.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-close-save-script.test.ts new file mode 100644 index 0000000000..55d57300c2 --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-close-save-script.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, expect, test } from 'vitest'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; +import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; + +const { + handleSessionCommands: handleHarnessSessionCommands, + fs, + makeIosSimulatorRecordingSession, + makeSessionStore, + mockReleaseRunnerOnClose, + mockStopIosRunnerSession, + noopInvoke, + path, + recordingFinishMock, + resetSessionCloseShutdownMocks, +} = sessionCloseShutdownFixture; + +// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: +// these composition cases pass the REAL composed fixture instead of the daemon's canned default. +const handleSessionCommands = (params: Parameters[0]) => + handleHarnessSessionCommands({ + ...params, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, + }); + +// The second half of this test proves that after the unarmed-authoring rejection, a plain close +// finalizes for real (recorder signaled, runner released with retain:false, session deleted) — +// the REAL Apple close finalization. The canned port fixture answers finalize without runner +// release, so the pair runs in the composition lane. +beforeEach(resetSessionCloseShutdownMocks); + +test('close --save-script on a never-armed session is rejected before teardown, with no script written', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-unarmed-close-save-script-session'; + const scriptPath = path.join( + mkdtempForTestSync('agent-device-unarmed-close'), + `agent-device-unarmed-close-${Date.now()}.ad`, + ); + // The fixture must carry real cleanup-bearing state (here: an active recording, like + // `makeIosSimulatorRecordingSession`'s other consumers) so this test can actually prove the + // guard runs *before* `stopBestEffortSessionResources` — not just that the response rejects. + // Without it, moving the guard after teardown would still pass: there would be nothing for + // teardown to observably touch. + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); + const finish = recordingFinishMock(session); + sessionStore.publish(sessionName, session); + + await expect( + handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: { saveScript: scriptPath }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toMatchObject({ + code: 'INVALID_ARGS', + message: expect.stringMatching(/not armed/), + details: expect.objectContaining({ + hint: expect.stringMatching(/open --save-script/), + }), + }); + + // The rejection does not tear down the session — it stays retryable/recoverable. + expect(sessionStore.get(sessionName)).toBeDefined(); + expect(fs.existsSync(scriptPath)).toBe(false); + // No teardown hook ran: the recording is still live (recorder never signaled) and the runner + // was never told to stop. This is the assertion that goes red if the guard moves after + // `stopBestEffortSessionResources` — see the counterfactual in the PR description. + expect(finish).not.toHaveBeenCalled(); + expect(session.screenRecording).toBeDefined(); + expect(mockStopIosRunnerSession).not.toHaveBeenCalled(); + expect(mockReleaseRunnerOnClose).not.toHaveBeenCalled(); + + // A plain close (no --save-script) still closes the same session cleanly afterward, and now + // teardown genuinely does run: the recorder is signaled and the session deleted. + const plainClose = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(plainClose?.ok).toBe(true); + expect(finish).toHaveBeenCalledOnce(); + expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-close-shutdown-platform.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-close-shutdown-platform.test.ts new file mode 100644 index 0000000000..bf1519341f --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-close-shutdown-platform.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, expect, test } from 'vitest'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; +import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; + +const { + handleSessionCommands: handleHarnessSessionCommands, + makeSession, + makeSessionStore, + mockShutdownTargetRuntime, + noopInvoke, + path, + resetSessionCloseShutdownMocks, +} = sessionCloseShutdownFixture; + +// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: +// these composition cases pass the REAL composed fixture instead of the daemon's canned default. +const handleSessionCommands = (params: Parameters[0]) => + handleHarnessSessionCommands({ + ...params, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, + }); + +// The shutdown-gating rule lives in the real Apple/Android close finalization: a physical device +// (or any Android device) ignores `close --shutdown`. The canned port fixture answers finalize +// without platform gating, so these cases run in the composition lane. +beforeEach(resetSessionCloseShutdownMocks); + +test('close --shutdown is ignored for non-simulator iOS devices', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-shutdown-session'; + sessionStore.publish( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'physical-device-1', + name: 'My iPhone', + kind: 'device', + booted: true, + }), + ); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: { shutdown: true }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockShutdownTargetRuntime).not.toHaveBeenCalled(); + expect(sessionStore.get(sessionName)).toBeUndefined(); + if (response && response.ok) { + expect(response.data?.session).toBe(sessionName); + expect(response.data?.shutdown).toBeUndefined(); + } +}); + +test('close --shutdown is ignored for Android devices', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-device-shutdown-session'; + sessionStore.publish( + sessionName, + makeSession(sessionName, { + platform: 'android', + id: 'R5CT123456A', + name: 'Pixel 9', + kind: 'device', + booted: true, + }), + ); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: { shutdown: true }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockShutdownTargetRuntime).not.toHaveBeenCalled(); + expect(sessionStore.get(sessionName)).toBeUndefined(); + if (response && response.ok) { + expect(response.data?.session).toBe(sessionName); + expect(response.data?.shutdown).toBeUndefined(); + } +}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts new file mode 100644 index 0000000000..e378d16267 --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-open-existing.test.ts @@ -0,0 +1,408 @@ +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 as handleHarnessSessionCommands } from '../../../daemon/handlers/__tests__/session-command-harness.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 { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; + +// These open cases pin what the REAL composed open-target and surface rules answer through the +// daemon open flow: the Safari and deep-link fallbacks, iOS and Android target resolution, the +// macOS frontmost surface, and the non-desktop `--surface` refusal. The daemon-lane port fixture +// answers those asks with canned spies (its own siblings pin the daemon's open bookkeeping), so +// the rule-pinning cases run here against the real composed services AND the real composed +// lifecycle. The harness takes the lifecycle-operations loader per call, exactly like the gateway +// factories, so no module-global install or import-order dependency is involved. +const handleSessionCommands = (params: Parameters[0]) => + handleHarnessSessionCommands({ + ...params, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, + }); + +test('open web URL on iOS device session without active app falls back to Safari', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-session'; + sessionStore.publish( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + ); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['https://example.com/path'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.apple.mobilesafari'); + expect(updated?.appName).toBe('https://example.com/path'); + expect(dispatchedContext?.appBundleId).toBe('com.apple.mobilesafari'); +}); + +test('open web URL on iOS simulator session without active app falls back to Safari', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-session'; + sessionStore.publish( + sessionName, + makeSession(sessionName, { + platform: 'apple', + id: 'ios-simulator-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, + }), + ); + + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'ios-simulator-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['https://example.com/path'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.apple.mobilesafari'); + expect(updated?.appName).toBe('https://example.com/path'); + expect(dispatchedContext?.appBundleId).toBe('com.apple.mobilesafari'); +}); + +test('open app and URL on existing iOS device session keeps app context', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-device-session'; + sessionStore.publish(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.previous', + appName: 'Previous App', + }); + + let dispatchedPositionals: string[] | undefined; + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, positionals, _out, context) => { + dispatchedPositionals = positionals; + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['Settings', 'myapp://screen/to'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(response?.ok && response.data?.sessionReused).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.apple.Preferences'); + expect(updated?.appName).toBe('Settings'); + expect(dispatchedPositionals).toEqual(['Settings', 'myapp://screen/to']); + expect(dispatchedContext?.appBundleId).toBe('com.apple.Preferences'); +}); + +test('open app on existing macOS session resolves and stores bundle id', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-session'; + sessionStore.publish(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-mac', + name: 'Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + appBundleId: 'com.example.old', + appName: 'Old App', + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['settings'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('com.apple.systempreferences'); + expect(updated?.appName).toBe('settings'); + expect(dispatchedContext?.appBundleId).toBe('com.apple.systempreferences'); +}); + +test('open rejects --surface on non-macOS devices', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: 'ios-surface', + command: 'open', + positionals: ['Notes'], + flags: { + platform: 'ios', + surface: 'frontmost-app', + }, + }, + sessionName: 'ios-surface', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + assertInvalidArgsMessage(response, 'surface is only supported on macOS and Linux'); +}); + +test('open on existing macOS frontmost-app session preserves surface without --surface flag', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-frontmost-existing'; + sessionStore.publish(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }), + surface: 'frontmost-app', + appBundleId: 'com.apple.TextEdit', + appName: 'TextEdit', + }); + + const prevHelper = process.env.AGENT_DEVICE_MACOS_HELPER_BIN; + process.env.AGENT_DEVICE_MACOS_HELPER_BIN = '/usr/bin/true'; + mockRunCmd.mockResolvedValue({ + stdout: '{"ok":true,"data":{"bundleId":"com.apple.TextEdit","appName":"TextEdit","pid":123}}', + stderr: '', + exitCode: 0, + }); + mockDispatch.mockImplementation(async (_device, _command, positionals) => { + expect(positionals).toEqual([]); + return {}; + }); + + try { + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { + platform: 'macos', + }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + const session = sessionStore.get(sessionName); + expect(session?.surface).toBe('frontmost-app'); + expect(session?.appBundleId).toBe('com.apple.TextEdit'); + expect(session?.appName).toBe('TextEdit'); + if (response && response.ok) { + expect(response.data?.surface).toBe('frontmost-app'); + } + } finally { + if (prevHelper === undefined) delete process.env.AGENT_DEVICE_MACOS_HELPER_BIN; + else process.env.AGENT_DEVICE_MACOS_HELPER_BIN = prevHelper; + } +}); + +test('open app on existing Android session resolves and stores package id', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-session'; + sessionStore.publish(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appName: 'Old App', + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + mockResolveAndroidPackage.mockResolvedValue('org.reactjs.native.example.RNCLI83'); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['RNCLI83'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe('org.reactjs.native.example.RNCLI83'); + expect(updated?.appName).toBe('RNCLI83'); + expect(dispatchedContext?.appBundleId).toBe('org.reactjs.native.example.RNCLI83'); +}); + +test('open intent target on existing Android session clears stale package context', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-session'; + sessionStore.publish(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.old', + appName: 'Old App', + }); + + let dispatchedContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => { + dispatchedContext = context as Record | undefined; + return {}; + }); + mockResolveAndroidPackage.mockResolvedValue(undefined); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['settings'], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const updated = sessionStore.get(sessionName); + expect(updated?.appBundleId).toBe(undefined); + expect(updated?.appName).toBe('settings'); + expect(dispatchedContext?.appBundleId).toBe(undefined); +}); diff --git a/src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts b/src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts new file mode 100644 index 0000000000..4f7c77248e --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-open-post-open-observation.test.ts @@ -0,0 +1,162 @@ +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 { 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 { + composedLifecycleRuntimeFixture, + 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: composedLifecycleRuntimeFixture, + }); +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 the REAL composed runner staging around an open (the post-open prewarm calls +// below) and how the daemon records the settle consequence of an open's observation timing +// (postOpenObservation). The observation verdict itself is a canned per-case answer injected at +// the binding seam, because the composed probe needs a live Simulator bridge; what the REAL Apple +// open reports into its timing for each verdict (observable / unobservable / probe-failed with +// typed failure) is pinned by the platform's own lane in +// `packages/platform-apple/src/lifecycle.test.ts`. +beforeEach(() => { + vi.clearAllMocks(); + mockDispatch.mockImplementation(async () => ({})); +}); + +async function openReportingObservation( + sessionName: string, + postOpenObservation: PostOpenObservation, +) { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAppleSimulator()); + const bindDefault = mockBindDeviceRuntime.getMockImplementation(); + if (!bindDefault) throw new Error('the harness binds a default runtime'); + mockBindDeviceRuntime.mockImplementationOnce(async (device, use, loader) => { + const binding = await bindDefault(device, use, loader); + const operations = binding.operations as ApplicationLifecycleRuntimeOperations; + return { + ...binding, + operations: { + ...binding.operations, + openApplication: async (input: OpenApplicationInput) => { + const outcome = await operations.openApplication(input); + return { ...outcome, timing: { ...outcome.timing, postOpenObservation } }; + }, + }, + }; + }); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['Demo'], + flags: { platform: 'ios' }, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response?.ok).toBe(true); + expect(mockPrewarmRunnerSession).toHaveBeenCalledTimes(1); + expect(mockPrewarmRunnerSession).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'apple', kind: 'simulator' }), + expect.any(Object), + expect.any(AbortSignal), + false, + ); + return sessionStore.get(sessionName); +} + +test('a fresh iOS open that could not observe its app leaves the next capture to settle', async () => { + const session = await openReportingObservation('unobserved-open', 'unobservable'); + + expect(pendingSurfaceStabilization(session)?.action).toBe('open'); +}); + +test('a fresh iOS open whose observation probe failed leaves the next capture to settle', async () => { + const session = await openReportingObservation('probe-failed-open', 'probe-failed'); + + expect(pendingSurfaceStabilization(session)?.action).toBe('open'); +}); + +test('a fresh iOS open that observed its app leaves nothing pending', async () => { + const session = await openReportingObservation('observed-open', 'observable'); + + expect(pendingSurfaceStabilization(session)).toBeUndefined(); +}); diff --git a/src/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..094abf91f1 --- /dev/null +++ b/src/__tests__/daemon-composition/session-lifecycle/session-teardown-resources.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, expect, test } from 'vitest'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { fakeCreateCommandSurface } from '../../../daemon/__tests__/support/command-surface-fake.ts'; +import { composedLifecycleRuntimeFixture } from '../application-lifecycle-runtime-fixture.ts'; +import { sessionCloseShutdownFixture } from '../../../daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts'; + +const { + handleSessionCommands: handleHarnessSessionCommands, + makeIosSimulatorRecordingSession, + makeSessionStore, + mockReleaseRunnerOnClose, + noopInvoke, + path, + recordingCleanupMock, + recordingFinishMock, + resetSessionCloseShutdownMocks, +} = sessionCloseShutdownFixture; + +// The harness takes the lifecycle-operations loader per call, exactly like the gateway factories: +// these composition cases pass the REAL composed fixture instead of the daemon's canned default. +// The resource-cleanup capability is the production-composed one too: what defeats runner +// retention here is its real close policy reading `hasScreenRecording`, observed at the runner +// release below. The daemon-lane cleanup fixture answers retention with a canned false, which +// would let a policy that ignores the recording pass these cases. +const handleSessionCommands = (params: Parameters[0]) => + handleHarnessSessionCommands({ + ...params, + lifecycleOperationsLoader: composedLifecycleRuntimeFixture, + platformResourceCleanup, + }); + +// These cases pin the REAL Apple close finalization around an active recording: the recorder is +// signaled and cleaned before the runner release, and a finalization failure surfaces through the +// cleanup-failure channel without skipping later cleanup. The canned port fixture answers finalize +// without recording or runner staging, so they run in the composition lane. +beforeEach(resetSessionCloseShutdownMocks); + +test('close finalizes an active iOS simulator recording before deleting the session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-active-recording-close-session'; + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); + const finish = recordingFinishMock(session); + sessionStore.publish(sessionName, session); + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + // The recorder was signaled (SIGINT finalizes the simctl mp4), the recording + // was detached, and the session was deleted — no orphaned recordVideo child. + expect(finish).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)).toBeUndefined(); + // An active recording at close time still defeats iOS runner retention even + // though the recording is finalized (and cleared) before the retention step. + expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); + expect(finish.mock.invocationCallOrder[0]).toBeLessThan( + mockReleaseRunnerOnClose.mock.invocationCallOrder[0]!, + ); +}); + +test('close surfaces a recording finalization failure through the cleanup-failure channel', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-recording-close-failure-session'; + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { + recorderExitCode: 1, + }); + const finish = recordingFinishMock(session); + const forceCleanup = recordingCleanupMock(session); + sessionStore.publish(sessionName, session); + + await expect( + handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: composedPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toThrow(/recording: .*failed to stop recording/); + + // Cleanup failure is reported, later cleanup still ran, session still deleted. + expect(finish).toHaveBeenCalledOnce(); + expect(forceCleanup).toHaveBeenCalledOnce(); + expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith(session.device.id, { retain: false }); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); diff --git a/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts b/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts index f4bcd18c48..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 '../../daemon/__tests__/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 0adbfbc7c6..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 '../../daemon/__tests__/platform-services-fixture.ts'; +import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { type DeviceBinding, @@ -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); @@ -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 b9a272a193..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 '../../daemon/__tests__/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 0246853bb4..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 '../../daemon/__tests__/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/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__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index 8e061f1d15..c1702955b5 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -1,20 +1,16 @@ +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 type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import 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,31 +19,20 @@ 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 - * 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); -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 + * policy, and error precedence around this call. + */ export const dispatchApplicationLifecycleEffect = vi.fn( async ( _device: DeviceInfo, @@ -59,9 +44,98 @@ export const dispatchApplicationLifecycleEffect = vi.fn( ); /** - * 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. + * 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, +); +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 }), +); + +/** + * 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: 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, + 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 direct.openApplication(input); + }, +); + +/** + * The `ApplicationLifecycleRuntimeOperations` port as the daemon suites need it: the contract's + * 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, @@ -73,154 +147,38 @@ 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 directResolveOpenTarget = direct.resolveOpenTarget; + return Object.freeze({ + ...direct, + resolveOpenTarget: async (input) => + await resolveOpenTargetFixture(device, directResolveOpenTarget, input), + prepareApplicationOpen: async () => { + await prepareApplicationOpenFixture(device); + }, + openApplication: async (input) => await openApplicationFixture(device, direct, 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__/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__/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__/platform-services-fixture.ts b/src/daemon/__tests__/platform-services-fixture.ts index 0121a9c7a2..62dc9ae641 100644 --- a/src/daemon/__tests__/platform-services-fixture.ts +++ b/src/daemon/__tests__/platform-services-fixture.ts @@ -1,26 +1,104 @@ -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 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 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 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({ ...createDaemonPlatformServices(), ...overrides }); + return Object.freeze({ + appleSessionObservation: Object.freeze({ + observeRunnerSession: observeRunnerSessionFixture, + resolveSoleForegroundApp: resolveSoleForegroundAppFixture, + }), + deviceBootObservation: Object.freeze({ + observeBootTimeMs: observeBootTimeMsFixture, + }), + ensureLocalDeviceReady: ensureLocalDeviceReadyFixture, + resolveRequestedOpenSurface: resolveRequestedOpenSurfaceFixture, + validateOpenRelaunchTarget: validateOpenRelaunchTargetFixture, + resolveSessionAppBundleIdForTarget: resolveSessionAppBundleIdForTargetFixture, + ...overrides, + }); } +/** The canned runner-session observation: no live runner on any device. */ +const observeRunnerSessionFixture = vi.fn(async (_deviceId: string) => undefined); + +/** The canned foreground answer: nothing observable to name a sole foreground app. */ +const resolveSoleForegroundAppFixture = vi.fn( + async (_params: Readonly<{ simulatorSetPath?: string }>) => undefined, +); + +/** + * 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 const observeBootTimeMsFixture = vi.fn( + async (_device: DeviceInfo): Promise => + ({ observed: false, reason: 'unobserved' }) as const, +); + +/** The canned readiness answer: local readiness succeeds; a suite rejects this to fail an open. */ +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. + */ +const resolveRequestedOpenSurfaceFixture = vi.fn( + (params: { + device: DeviceInfo; + surfaceFlag: string | undefined; + openTarget: string | undefined; + existingSurface?: SessionSurface; + }): SessionSurface => params.existingSurface ?? 'app', +); + +/** The canned relaunch answer: every requested relaunch target is acceptable. */ +const validateOpenRelaunchTargetFixture = vi.fn( + async (_params: { + target: string | undefined; + platform: string | undefined; + surface?: SessionSurface; + }) => undefined, +); + +/** + * 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 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__/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-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__/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__/request-router-open-claim.test.ts b/src/daemon/__tests__/request-router-open-claim.test.ts index 65ddd3e71a..86e9a1a6d5 100644 --- a/src/daemon/__tests__/request-router-open-claim.test.ts +++ b/src/daemon/__tests__/request-router-open-claim.test.ts @@ -44,30 +44,18 @@ 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, 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 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'; @@ -75,7 +63,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,12 +88,8 @@ beforeEach(() => { mockEnsureDeviceReady.mockResolvedValue(undefined); mockAwaitFixtureReadiness.mockReset(); mockAwaitFixtureReadiness.mockResolvedValue(undefined); - mockObserveSimulatorBoot.mockReset(); - mockObserveSimulatorBoot.mockImplementation(async () => ({ - observed: false, - reason: 'unobserved', - })); - mockDiscoverReadyAndroidEmulators.mockReset(); + mockObserveBoot.mockReset(); + mockObserveBoot.mockResolvedValue({ observed: false, reason: 'unobserved' }); }); function seedLiveForeignClaim(claimsDir: string, device: DeviceInfo, foreignStateDir: string) { @@ -145,7 +129,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 +140,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 +178,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/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index d8f52d2688..73880f3db2 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, @@ -81,26 +72,11 @@ 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 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, @@ -111,9 +87,7 @@ 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); function createOpenHandler( @@ -140,19 +114,8 @@ beforeEach(() => { mockEnsureDeviceReady.mockResolvedValue(undefined); mockAwaitFixtureReadiness.mockReset(); mockAwaitFixtureReadiness.mockResolvedValue(undefined); - mockDiscoverReadyAndroidEmulators.mockReset(); - mockObserveSimulatorBoot.mockReset(); - mockObserveSimulatorBoot.mockImplementation(async () => ({ - observed: false, - reason: 'unobserved', - })); - mockDiscoverReadyAndroidEmulators.mockImplementation(async (device) => [ - { - ...device, - id: device.id.startsWith('emulator-') ? device.id : `emulator-${device.id}`, - booted: true, - }, - ]); + mockObserveBoot.mockReset(); + mockObserveBoot.mockResolvedValue({ observed: false, reason: 'unobserved' }); }); // fallow-ignore-next-line complexity @@ -704,140 +667,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 @@ -856,10 +685,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) { 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__/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..84665a6fda --- /dev/null +++ b/src/daemon/__tests__/support/unavailable-device-runtime-gateway.ts @@ -0,0 +1,65 @@ +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'; + +/** + * The `DeviceRuntimeGateway` port answering that no owner on this host can bind the device: + * 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, intent }) => { + const owner = + intent.kind === 'exact-owner' ? intent.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..d4b604d099 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -18,17 +18,26 @@ 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 { + 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'; 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'; +import { + unavailableBinding, + unavailableDeviceRuntimeGateway, +} from './support/unavailable-device-runtime-gateway.ts'; const unavailable = Object.freeze({ available: false as const, @@ -50,7 +59,11 @@ function lifecycleFactsForTest(device: DeviceInfo): ApplicationLifecycleOperatio }); } -async function lifecycleBindingForTest(device: DeviceInfo) { +async function lifecycleBindingForTest( + device: DeviceInfo, + loadLifecycleOperations: LifecycleOperationsLoader = applicationLifecycleRuntimeFixture, + signal?: AbortSignal, +) { const lifecycleFacts = lifecycleFactsForTest(device); const base = createUnavailableRuntimeFactsForTest(device, localRuntimeOwner(device.platform)); return { @@ -67,7 +80,9 @@ async function lifecycleBindingForTest(device: DeviceInfo) { }, operations: { ...availableApplicationLifecycleOperations( - await applicationLifecycleRuntimeFixture(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, @@ -81,13 +96,27 @@ 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, - bind: async ({ device }) => await lifecycleBindingForTest(device), + bind: async ({ device, scope }) => + await lifecycleBindingForTest(device, applicationLifecycleRuntimeFixture, scope.signal), + shutdown: async () => {}, + }); + +/** 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, scope }) => + await lifecycleBindingForTest(device, loadLifecycleOperations, scope.signal), shutdown: async () => {}, }); +} /** * A fresh, unfrozen `lifecycleDeviceRuntimeGateway`-equivalent whose `inspectFacts`/`bind` are @@ -96,12 +125,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, request.scope.signal), ); const gateway: DeviceRuntimeGateway = { inspectFacts, @@ -111,22 +144,6 @@ 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 () => {}, - }); - /** * Spies for the gesture surface a router-level test drives. They replace the retired * `dispatchGesturePlan` / `dispatchGestureViewport` module mocks: gestures now reach the platform @@ -190,39 +207,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< RequestRouterDeps, @@ -239,12 +223,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/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 112d3f7119..ee2de6d05f 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -22,7 +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 { + 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'; @@ -63,8 +66,12 @@ 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), +export const mockBindDeviceRuntime = vi.fn( + async ( + device: DeviceInfo, + use, + loadLifecycleOperations: LifecycleOperationsLoader = applicationLifecycleRuntimeFixture, + ) => narrowDeviceBinding(await readinessBinding(device, loadLifecycleOperations), use), ); beforeEach(() => { @@ -92,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', @@ -116,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 () => {}, }); @@ -171,9 +189,10 @@ function operationAvailability(supported: boolean): typeof available | typeof un async function readinessBinding( device: DeviceInfo, + loadLifecycleOperations: LifecycleOperationsLoader, ): 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..6c3891ad91 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,14 +34,14 @@ 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 { - discoverReadyAndroidEmulators, + applyRuntimeHintsFixture, 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'; @@ -70,10 +65,10 @@ 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(applyRuntimeHintValues); +const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); const reconcileOrphanedDeviceClaim = async () => ({ status: 'retained' as const, @@ -133,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 () => @@ -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-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-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-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/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/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..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 @@ -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,14 @@ import { mockInspectDeviceRuntimeFacts, } from '../../../handlers/__tests__/session-command-harness.ts'; import { - applyRuntimeHintValues, - clearRuntimeHintValues, -} from '../../../../platform-runtime-runtime-hints.ts'; + applyRuntimeHintsFixture, + clearRuntimeHintsFixture, + dispatchApplicationLifecycleEffect, + resolveOpenTargetFixture, +} 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,9 +71,10 @@ 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); +const mockResolveOpenTarget = vi.mocked(resolveOpenTargetFixture); beforeEach(() => { vi.clearAllMocks(); @@ -125,7 +101,14 @@ 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 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, + })); mockApplyRuntimeHints.mockImplementation(async ({ appId, values }) => { runtimeApplyCalls.push({ appId, @@ -469,73 +452,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-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 5f721a8f76..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 @@ -18,28 +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('../../../../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 +51,12 @@ import { mockBindDeviceRuntime, mockInspectDeviceRuntimeFacts, } from '../../../handlers/__tests__/session-command-harness.ts'; -import { applyRuntimeHintValues } from '../../../../platform-runtime-runtime-hints.ts'; +import { + applyRuntimeHintsFixture, + dispatchApplicationLifecycleEffect, + resolveOpenTargetFixture, +} 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,13 +68,15 @@ 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); +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 () => { @@ -109,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({ 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', ), diff --git a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts index 567a50a7ce..1bea3c7f24 100644 --- a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts +++ b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts @@ -1,8 +1,8 @@ // Real-scope same-device serialization guard for the concurrency torture lane import { - daemonPlatformServicesFixture, - daemonClaimRecoveryGatewayFixture, -} from '../../../../src/daemon/__tests__/platform-services-fixture.ts'; + 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 // green if the PRODUCTION lock APPLICATION path regressed. This guard closes @@ -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/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'; 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..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/daemon/__tests__/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',