diff --git a/packages/daemon/package.json b/packages/daemon/package.json index a26ec86a75..8914f37b98 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -100,10 +100,6 @@ "types": "./src/__tests__/support/selector-capture-fixture.ts", "default": "./src/__tests__/support/selector-capture-fixture.ts" }, - "./testing/__tests__/session-teardown.fixtures": { - "types": "./src/__tests__/support/session-teardown.fixtures.ts", - "default": "./src/__tests__/support/session-teardown.fixtures.ts" - }, "./testing/__tests__/snapshot-runtime-fixture": { "types": "./src/__tests__/support/snapshot-runtime-fixture.ts", "default": "./src/__tests__/support/snapshot-runtime-fixture.ts" @@ -112,10 +108,6 @@ "types": "./src/__tests__/support/command-surface-fake.ts", "default": "./src/__tests__/support/command-surface-fake.ts" }, - "./testing/__tests__/support/device-claim-store": { - "types": "./src/__tests__/support/device-claim-store.ts", - "default": "./src/__tests__/support/device-claim-store.ts" - }, "./testing/__tests__/support/provider-device-admission-fixture": { "types": "./src/__tests__/support/provider-device-admission-fixture.ts", "default": "./src/__tests__/support/provider-device-admission-fixture.ts" @@ -148,10 +140,6 @@ "types": "./src/execution/app-events.ts", "default": "./src/execution/app-events.ts" }, - "./testing/application-lifecycle-recovery": { - "types": "./src/server/application-lifecycle-recovery.ts", - "default": "./src/server/application-lifecycle-recovery.ts" - }, "./testing/artifact-tracking": { "types": "./src/resources/artifact-tracking.ts", "default": "./src/resources/artifact-tracking.ts" @@ -344,18 +332,10 @@ "types": "./src/execution/selector-runtime-backend.ts", "default": "./src/execution/selector-runtime-backend.ts" }, - "./testing/server/daemon-runtime": { - "types": "./src/server/http/daemon-runtime.ts", - "default": "./src/server/http/daemon-runtime.ts" - }, "./testing/server/http-server": { "types": "./src/server/http/http-server.ts", "default": "./src/server/http/http-server.ts" }, - "./testing/server/server-shutdown": { - "types": "./src/server/http/server-shutdown.ts", - "default": "./src/server/http/server-shutdown.ts" - }, "./testing/server/transport": { "types": "./src/server/http/transport.ts", "default": "./src/server/http/transport.ts" @@ -404,10 +384,6 @@ "types": "./src/core/session-store.ts", "default": "./src/core/session-store.ts" }, - "./testing/session-teardown-budget": { - "types": "./src/sessions/session-teardown-budget.ts", - "default": "./src/sessions/session-teardown-budget.ts" - }, "./testing/snapshot-capture": { "types": "./src/core/snapshot-capture.ts", "default": "./src/core/snapshot-capture.ts" diff --git a/packages/daemon/src/__tests__/support/command-surface-fake.test.ts b/packages/daemon/src/__tests__/support/command-surface-fake.test.ts new file mode 100644 index 0000000000..6f2c3e29b3 --- /dev/null +++ b/packages/daemon/src/__tests__/support/command-surface-fake.test.ts @@ -0,0 +1,77 @@ +import { expect, test, vi } from 'vitest'; +import type { AgentDeviceRuntimeConfig } from '@agent-device/contracts/runtime-contract'; +import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; +import { forwardingCreateCommandSurface } from './command-surface-fake.ts'; + +const node = { index: 0, ref: 'e1', type: 'Button', label: 'Go' } as SnapshotNode; + +function configReturning( + captured: Record, + existing?: { snapshot: SnapshotState }, +) { + const set = vi.fn(async () => {}); + const config = { + backend: { captureSnapshot: vi.fn(async () => captured) }, + sessions: { + get: vi.fn(async () => (existing ? { name: 'default', ...existing } : undefined)), + set, + }, + } as unknown as AgentDeviceRuntimeConfig; + return { config, set }; +} + +test('the forwarding surface returns and stores the snapshot tree the daemon backend produced', async () => { + const snapshot: SnapshotState = { nodes: [node], createdAt: 1, backend: 'android' }; + const { config, set } = configReturning({ snapshot }); + const surface = await forwardingCreateCommandSurface(config); + + const result = await surface.capture.snapshot({ session: 'default' }); + + expect(result.nodes).toEqual([node]); + expect(set).toHaveBeenCalledWith(expect.objectContaining({ name: 'default', snapshot })); +}); + +test('the forwarding surface falls back to a flat node list from a backend that returns no snapshot', async () => { + const { config, set } = configReturning({ nodes: [node] }); + const surface = await forwardingCreateCommandSurface(config); + + const result = await surface.capture.snapshot({ session: 'default' }); + + expect(result.nodes).toEqual([node]); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ snapshot: expect.objectContaining({ nodes: [node] }) }), + ); +}); + +test('the forwarding surface diff stores the observed tree and reports whether it initialized the baseline', async () => { + const snapshot: SnapshotState = { nodes: [node], createdAt: 1, backend: 'android' }; + const first = configReturning({ snapshot }); + const initialized = await ( + await forwardingCreateCommandSurface(first.config) + ).capture.diffSnapshot({ session: 'default' }); + + expect(initialized.baselineInitialized).toBe(true); + expect(first.set).toHaveBeenCalledWith(expect.objectContaining({ snapshot })); + + const second = configReturning({ snapshot }, { snapshot: { ...snapshot, createdAt: 0 } }); + const compared = await ( + await forwardingCreateCommandSurface(second.config) + ).capture.diffSnapshot({ session: 'default' }); + + expect(compared.baselineInitialized).toBe(false); + expect(second.set).toHaveBeenCalledWith(expect.objectContaining({ snapshot })); +}); + +test('the forwarding surface hands the backend the capture options of both snapshot and diff', async () => { + const snapshot: SnapshotState = { nodes: [node], createdAt: 1, backend: 'android' }; + const { config } = configReturning({ snapshot }); + const surface = await forwardingCreateCommandSurface(config); + const options = { session: 'default', interactiveOnly: true, depth: 3, scope: '@e1', raw: true }; + + await surface.capture.snapshot(options); + await surface.capture.diffSnapshot(options); + + const expected = { interactiveOnly: true, depth: 3, scope: '@e1', raw: true }; + expect(config.backend.captureSnapshot).toHaveBeenNthCalledWith(1, expect.anything(), expected); + expect(config.backend.captureSnapshot).toHaveBeenNthCalledWith(2, expect.anything(), expected); +}); diff --git a/packages/daemon/src/__tests__/support/command-surface-fake.ts b/packages/daemon/src/__tests__/support/command-surface-fake.ts index 8531652b1e..0deb0a5c68 100644 --- a/packages/daemon/src/__tests__/support/command-surface-fake.ts +++ b/packages/daemon/src/__tests__/support/command-surface-fake.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest'; import type { CreateDaemonCommandSurface, DaemonCommandSurface, + DaemonSnapshotOptions, } from '@agent-device/contracts/daemon-command-surface'; /** @@ -63,3 +64,59 @@ const fakeCommandSurface: DaemonCommandSurface = { /** The port value the daemon-policy suites inject; see {@link fakeCommandSurface}. */ export const fakeCreateCommandSurface: CreateDaemonCommandSurface = async () => fakeCommandSurface; + +/** + * A command surface whose snapshot and diff captures do what the real command family does at the + * two seams the daemon owns: they reach the runtime config's backend, and they write the session + * back through the config's session store. Suites that pin what the daemon does around a capture + * (shutdown, cancellation, session lifetime, ref-frame state) need both, because the daemon's + * refusals and frame rules land on those seams. Everything else the real command family does with + * the result stays out of the daemon suites: a snapshot returns the backend's tree, and a diff + * returns an empty comparison whose only informative field is whether it initialized the baseline. + */ +export const forwardingCreateCommandSurface: CreateDaemonCommandSurface = async (config) => { + const captureIntoSession = async (options: DaemonSnapshotOptions) => { + const name = options.session ?? 'default'; + const session = await config.sessions?.get(name); + const captured = await config.backend.captureSnapshot?.( + { session: name, requestId: options.requestId, signal: options.signal ?? config.signal }, + { + interactiveOnly: options.interactiveOnly, + depth: options.depth, + scope: options.scope, + raw: options.raw, + }, + ); + const stored = captured?.snapshot ?? { + nodes: captured?.nodes ?? [], + createdAt: Date.now(), + backend: 'android' as const, + }; + await config.sessions?.set({ + ...(session ?? { name }), + name: session?.name ?? name, + snapshot: stored, + }); + return { nodes: stored.nodes as never, hadBaseline: session?.snapshot !== undefined }; + }; + return { + ...fakeCommandSurface, + signal: config.signal, + capture: { + ...fakeCommandSurface.capture, + snapshot: async (options) => { + const { nodes } = await captureIntoSession(options); + return { nodes }; + }, + diffSnapshot: async (options) => { + const { hadBaseline } = await captureIntoSession(options); + return { + mode: 'snapshot', + baselineInitialized: !hadBaseline, + summary: { additions: 0, removals: 0, unchanged: 0 }, + lines: [], + }; + }, + }, + }; +}; diff --git a/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts b/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts new file mode 100644 index 0000000000..3767db910c --- /dev/null +++ b/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts @@ -0,0 +1,96 @@ +import type { ApplicationLifecycleResourceLifecycle } from '@agent-device/contracts/application-lifecycle-runtime'; +import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { createOwnedProcessRecordStore } from '@agent-device/host-kit/process'; +import { vi } from 'vitest'; +import type { + ComposeDaemonRuntimeDeps, + DaemonRuntimeDeps, + DaemonRuntimeDepsInput, +} from '../../server/http/daemon-runtime.ts'; +import type { PlatformOwnerLifecycle } from '../../core/platform-owner-lifecycle.ts'; +import { clearAndroidObservationFixture } from './android-observation-fixture.ts'; +import { fakeCreateCommandSurface } from './command-surface-fake.ts'; +import { hostDiagnosticsFixture } from './host-diagnostics-fixture.ts'; +import { platformResourceCleanupFixture } from './platform-resource-cleanup-fixture.ts'; +import { + daemonClaimRecoveryGatewayFixture, + daemonPlatformServicesFixture, +} from './platform-services-fixture.ts'; +import { providerCredentialsFixture } from './provider-credentials-fixture.ts'; +import { lifecycleDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; + +/** The durable lifecycle phases the runtime runs at startup and shutdown, as spies. */ +export function applicationLifecycleResourceFixture( + overrides: Partial = {}, +): ApplicationLifecycleResourceLifecycle { + return Object.freeze({ + recoverStartupResources: vi.fn(async () => {}), + detachForDaemonShutdown: vi.fn(async () => {}), + finalizeDaemonShutdown: vi.fn(async () => {}), + ...overrides, + }); +} + +/** Every member observable as a spy, which is how the runtime suites read the owner back. */ +export function platformOwnerLifecycleFixture( + overrides: Partial = {}, +): PlatformOwnerLifecycle { + return Object.freeze({ + configureForDaemonLock: vi.fn(async () => {}), + clearDaemonLockConfiguration: vi.fn(async () => {}), + recoverLegacyAppLogMarkers: vi.fn(async () => ({ recovered: [], retained: [] })), + cleanupManagedWebOrphans: vi.fn(async () => {}), + resetAndroidSnapshotHelper: vi.fn(async () => {}), + ...overrides, + }); +} + +/** + * The `composeDeps` factory a daemon-runtime suite hands `startDaemonRuntime`: every capability the + * root composition would supply is an inert port, so a suite exercises the server's own lifecycle, + * idle, lease, and shutdown decisions. A suite that cares about one capability replaces just that + * member, receiving the paths and session callbacks the server owns. + */ +export function daemonRuntimeDepsFixture( + overrides: + | Partial + | ((input: DaemonRuntimeDepsInput) => Partial) = {}, +): ComposeDaemonRuntimeDeps { + return async (input) => { + const resolved = typeof overrides === 'function' ? overrides(input) : overrides; + return Object.freeze({ + ownedProcessRecords: createOwnedProcessRecordStore({ + stateDir: input.baseDir, + sessionsDir: input.sessionsDir, + resolveSessionDir: input.resolveSessionDir, + }), + hostDiagnostics: hostDiagnosticsFixture(), + ownerLifecycle: platformOwnerLifecycleFixture(), + providerDeviceRuntimes: [], + skippedProviderRuntimes: [], + providerRuntimeIds: [], + recoverableProviderIds: [], + deviceRuntimeGateway: Object.freeze({ + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture(), + }), + platformResourceCleanup: platformResourceCleanupFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), + interactorResolution: Object.freeze({ + resolve: async () => { + throw new Error('daemon runtime fixture: no interactor is composed'); + }, + }), + providerDeviceAdmission: Object.freeze({ isActive: () => false }), + cleanupManagedWebOrphans: async () => {}, + requestRouter: Object.freeze({ + deviceInventoryGateways: createTestDeviceInventoryGateways(), + platformServices: daemonPlatformServicesFixture(), + androidObservation: clearAndroidObservationFixture, + providerCredentials: providerCredentialsFixture(input.env, input.baseDir), + createCommandSurface: fakeCreateCommandSurface, + }), + ...resolved, + }); + }; +} diff --git a/packages/daemon/src/server/http/daemon-runtime-artifacts.test.ts b/packages/daemon/src/server/http/daemon-runtime-artifacts.test.ts new file mode 100644 index 0000000000..0af002bf4a --- /dev/null +++ b/packages/daemon/src/server/http/daemon-runtime-artifacts.test.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { + cleanupDownloadableArtifact, + trackDownloadableArtifact, +} from '../../resources/artifact-tracking.ts'; +import { startDaemonRuntime } from './daemon-runtime.ts'; + +async function downloadTrackedArtifact(env: Record, awaitConsumption: boolean) { + const stateDir = mkdtempForTestSync('agent-device-daemon-runtime-artifacts-'); + const artifactPath = path.join(stateDir, 'runtime-artifact.txt'); + fs.writeFileSync(artifactPath, 'runtime-artifact'); + const artifactId = trackDownloadableArtifact({ + artifactPath, + artifactType: 'runtime-artifact', + fileName: 'runtime-artifact.txt', + }); + const runtime = await startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + ...env, + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps: daemonRuntimeDepsFixture(), + }); + try { + expect(runtime).not.toBeNull(); + const response = await fetch( + `http://127.0.0.1:${runtime?.httpPort}/artifacts/${encodeURIComponent(artifactId)}`, + { headers: { authorization: `Bearer ${runtime?.token}`, connection: 'close' } }, + ); + const body = await response.text(); + if (awaitConsumption) { + for (let attempt = 0; attempt < 20 && fs.existsSync(artifactPath); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + return { status: response.status, body, onDisk: fs.existsSync(artifactPath) }; + } finally { + await runtime?.shutdown(); + cleanupDownloadableArtifact(artifactId); + } +} + +test('AGENT_DEVICE_RETAIN_ARTIFACTS keeps a downloaded artifact on disk', async () => { + const download = await downloadTrackedArtifact({ AGENT_DEVICE_RETAIN_ARTIFACTS: '1' }, false); + + expect(download.status).toBe(200); + expect(download.body).toBe('runtime-artifact'); + expect(download.onDisk).toBe(true); +}); + +test('a downloaded artifact is consumed when the daemon was not told to retain artifacts', async () => { + const download = await downloadTrackedArtifact({ AGENT_DEVICE_RETAIN_ARTIFACTS: '' }, true); + + expect(download.status).toBe(200); + expect(download.body).toBe('runtime-artifact'); + expect(download.onDisk).toBe(false); +}); diff --git a/packages/daemon/src/server/http/daemon-runtime-composition.test.ts b/packages/daemon/src/server/http/daemon-runtime-composition.test.ts new file mode 100644 index 0000000000..0e4e12892a --- /dev/null +++ b/packages/daemon/src/server/http/daemon-runtime-composition.test.ts @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import { + DAEMON_STARTUP_EXIT_CODES, + tryAcquireDaemonRegistration, +} from '@agent-device/daemon-contracts/daemon-registration-owner'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { IOS_SIMULATOR, WEB_DESKTOP_DEVICE } from '@agent-device/test-kit/device-fixtures'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { + installInteractorResolution, + interactorResolution, +} from '../../core/interactor-resolution.ts'; +import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { startDaemonRuntime, type DaemonRuntimeDepsInput } from './daemon-runtime.ts'; + +// The server builds its own store, so capture that instance: the proof is about the store the +// server hands to the composition callbacks, not one the test fabricates. +const storeProbe = vi.hoisted(() => ({ + store: undefined as import('../../core/session-store.ts').SessionStore | undefined, +})); +vi.mock('../../core/session-store.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SessionStore: class extends actual.SessionStore { + constructor(...args: ConstructorParameters) { + super(...args); + storeProbe.store = this; + } + }, + }; +}); + +test('daemon startup installs the interactor resolution the composition supplied', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-interactor-composition-'); + const composed = Object.freeze({ resolve: vi.fn(async () => ({}) as never) }); + const previousResolution = interactorResolution(); + let runtime: Awaited> = null; + try { + runtime = await startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps: daemonRuntimeDepsFixture({ interactorResolution: composed }), + }); + expect(runtime).not.toBeNull(); + + expect(interactorResolution()).toBe(composed); + await interactorResolution().resolve(IOS_SIMULATOR, {}); + expect(composed.resolve).toHaveBeenCalledOnce(); + } finally { + await runtime?.shutdown(); + installInteractorResolution(previousResolution); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('a daemon attempt losing the lock shuts down every constructed provider', async () => { + const stateDir = mkdtempForTestSync('daemon-held-lock-'); + const held = await tryAcquireDaemonRegistration(resolveDaemonPaths(stateDir)); + if (held.status !== 'acquired') throw new Error('registration fixture refused'); + const shutdown = vi.fn(() => { + throw new Error('cleanup failed'); + }); + const otherShutdown = vi.fn(async () => {}); + const exit = vi.fn(); + const runtime = await startDaemonRuntime({ + env: { AGENT_DEVICE_STATE_DIR: stateDir }, + exit, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps: daemonRuntimeDepsFixture({ + providerDeviceRuntimes: [shutdown, otherShutdown].map( + (stop, index) => + ({ + provider: `fixture-${index}`, + shutdown: stop, + leaseLifecycle: {}, + }) as unknown as ProviderDeviceRuntime, + ), + }), + }); + expect(runtime).toBeNull(); + expect(shutdown).toHaveBeenCalledOnce(); + expect(otherShutdown).toHaveBeenCalledOnce(); + expect(exit).toHaveBeenCalledWith(DAEMON_STARTUP_EXIT_CODES.busy); + await held.owner.finish(); +}); + +/** + * The one-assembly guarantee (#3431 review): the server owns the paths, the ADR 0029 policy load, + * the session store, and the `assertShutdownAllowed` derivation, and passes them to the + * composition factory. A server that dropped the derivation, loaded no policy, or wired the session + * callbacks to a different store fails here. + */ +test('the server derives the policy gate and session callbacks and hands them to the composition', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-assembly-'); + const policyPath = path.join(stateDir, 'policy.json'); + fs.writeFileSync( + policyPath, + JSON.stringify({ version: 1, capabilities: { deny: ['device-shutdown'] } }), + ); + const paths = resolveDaemonPaths(stateDir); + const exits: number[] = []; + let composed: DaemonRuntimeDepsInput | undefined; + const fixtureDeps = daemonRuntimeDepsFixture(); + let runtime: Awaited> = null; + try { + runtime = await startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_POLICY: policyPath, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: (code) => exits.push(code), + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps: async (input) => { + composed = input; + return await fixtureDeps(input); + }, + }); + assert.notEqual(runtime, null, 'the assembly must boot'); + assert.deepEqual(exits, []); + assert.ok(composed, 'the server must call the factory'); + // The server resolved the paths and loaded the operator's policy before composing anything. + assert.equal(composed.baseDir, paths.baseDir); + assert.equal(composed.sessionsDir, paths.sessionsDir); + // The shutdown gate the composition threads into the runtime gateway refuses the denied capability. + assert.throws( + () => composed?.assertShutdownAllowed?.(), + (error: unknown) => + (error as { details?: { reason?: string; rule?: string } }).details?.reason === + 'DAEMON_POLICY_DENIED' && + (error as { details?: { rule?: string } }).details?.rule === 'capability', + ); + // The web-session callback reads the one store the server built, so the deps closures and the + // server can never disagree about which sessions are open. + const store = storeProbe.store; + assert.ok(store, 'the server must construct the store the callbacks read'); + store.publish('web-assembly', { + name: 'web-assembly', + device: WEB_DESKTOP_DEVICE, + createdAt: Date.now(), + actions: [], + }); + assert.deepEqual([...composed.openWebSessionNames()], ['web-assembly']); + assert.equal( + composed.resolveSessionDir('web-assembly'), + store.resolveSessionDir('web-assembly'), + ); + assert.deepEqual(composed.resolveSessionArtifacts('web-assembly'), { + outputPath: store.resolveAppLogPath('web-assembly'), + pidPath: store.resolveAppLogPidPath('web-assembly'), + }); + await runtime?.shutdown(); + runtime = null; + assert.deepEqual(exits, [0]); + } finally { + await runtime?.shutdown(); + } +}); + +/** + * A policy the operator names but that cannot be read stops startup through the same server, + * before any runtime is composed: the typed stderr and exit are the server's, not the entry's. + */ +test('an unreadable policy stops the assembly before the factory is called', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-assembly-bad-policy-'); + const exits: number[] = []; + const stderr: string[] = []; + let factoryCalls = 0; + let runtime: Awaited> = null; + try { + runtime = await startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_POLICY: path.join(stateDir, 'missing-policy.json'), + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: (code) => exits.push(code), + stderr: { write: (chunk) => stderr.push(chunk) }, + stdout: { write: () => {} }, + registerProcessHandlers: false, + composeDeps: async (input) => { + factoryCalls += 1; + return await daemonRuntimeDepsFixture()(input); + }, + }); + assert.equal(runtime, null); + assert.deepEqual(exits, [1]); + assert.equal(factoryCalls, 0, 'no runtime may be built under an unreadable policy'); + assert.match(stderr.join(''), /Daemon error: .*/); + } finally { + await runtime?.shutdown(); + } +}); diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-idle-reap.test.ts b/packages/daemon/src/server/http/daemon-runtime-idle-reap.test.ts similarity index 86% rename from src/__tests__/daemon-composition/server/daemon-runtime-idle-reap.test.ts rename to packages/daemon/src/server/http/daemon-runtime-idle-reap.test.ts index 30444447e7..d0ca20cb30 100644 --- a/src/__tests__/daemon-composition/server/daemon-runtime-idle-reap.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-idle-reap.test.ts @@ -3,11 +3,10 @@ import fs from 'node:fs'; import { afterEach, test, vi } from 'vitest'; const leaseProbe = vi.hoisted(() => ({ - registries: [] as import('@agent-device/daemon/testing/lease-registry').LeaseRegistry[], + registries: [] as import('../../resources/lease-registry.ts').LeaseRegistry[], })); -vi.mock('@agent-device/daemon/testing/lease-registry', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('../../resources/lease-registry.ts', async (importOriginal) => { + const actual = await importOriginal(); class RecordedLeaseRegistry extends actual.LeaseRegistry { constructor(...args: ConstructorParameters) { super(...args); @@ -18,7 +17,8 @@ vi.mock('@agent-device/daemon/testing/lease-registry', async (importOriginal) => }); import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { startDaemonRuntimeFixture } from '../daemon-runtime-fixture.ts'; +import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { startDaemonRuntime } from './daemon-runtime.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; afterEach(() => { @@ -37,7 +37,8 @@ test('daemon runtime self-reaps after the idle window when nothing ever uses it' }); try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture(), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -76,7 +77,8 @@ test('daemon runtime never self-reaps when AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS i let exitCode: number | undefined; try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture(), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -113,7 +115,8 @@ test('only a retained lease keeps the daemon runtime from self-reaping', async ( }); try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture(), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-lease-expiry.test.ts b/packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts similarity index 80% rename from src/__tests__/daemon-composition/server/daemon-runtime-lease-expiry.test.ts rename to packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts index dfad85193b..d441b60302 100644 --- a/src/__tests__/daemon-composition/server/daemon-runtime-lease-expiry.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts @@ -3,13 +3,12 @@ import fs from 'node:fs'; import { afterEach, test, vi } from 'vitest'; const leaseProbe = vi.hoisted(() => ({ - registries: [] as import('@agent-device/daemon/testing/lease-registry').LeaseRegistry[], + registries: [] as import('../../resources/lease-registry.ts').LeaseRegistry[], ended: [] as Array<[string, string]>, shutdownSteps: [] as string[], })); -vi.mock('@agent-device/daemon/testing/lease-registry', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('../../resources/lease-registry.ts', async (importOriginal) => { + const actual = await importOriginal(); class RecordedLeaseRegistry extends actual.LeaseRegistry { constructor(...args: ConstructorParameters) { super(...args); @@ -18,9 +17,8 @@ vi.mock('@agent-device/daemon/testing/lease-registry', async (importOriginal) => } return { ...actual, LeaseRegistry: RecordedLeaseRegistry }; }); -vi.mock('@agent-device/daemon/testing/request-execution-scope', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('../request-execution-scope.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, createLeaseSessionEnd: () => ({ @@ -37,8 +35,9 @@ vi.mock('@agent-device/daemon/testing/request-execution-scope', async (importOri }; }); -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { startDaemonRuntimeFixture } from '../daemon-runtime-fixture.ts'; +import { SessionStore } from '../../core/session-store.ts'; +import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { startDaemonRuntime } from './daemon-runtime.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; afterEach(() => { @@ -53,7 +52,8 @@ test('an expired lease ends its session without any further request', async () = vi.useFakeTimers(); const stateDir = mkdtempForTestSync('agent-device-daemon-lease-expiry-'); try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture(), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -94,7 +94,8 @@ test('shutdown cancels and drains lease ends before it tears sessions down', asy ); const stateDir = mkdtempForTestSync('agent-device-daemon-lease-shutdown-'); try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture(), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-lifecycle-shutdown.test.ts b/packages/daemon/src/server/http/daemon-runtime-lifecycle-shutdown.test.ts similarity index 59% rename from src/__tests__/daemon-composition/server/daemon-runtime-lifecycle-shutdown.test.ts rename to packages/daemon/src/server/http/daemon-runtime-lifecycle-shutdown.test.ts index c8d32e471a..a281a832c6 100644 --- a/src/__tests__/daemon-composition/server/daemon-runtime-lifecycle-shutdown.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-lifecycle-shutdown.test.ts @@ -3,61 +3,19 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; const lifecycleEvents = vi.hoisted(() => [] as string[]); -vi.mock('../../../platform-runtime.ts', () => ({ - androidObservation: {}, - createRequestPlatformProviders: () => ({ - run: async (_context: unknown, task: () => Promise) => await task(), - }), - createPlatformRuntimeGateway: () => ({ - applicationLifecycle: { - recoverStartupResources: async () => {}, - detachForDaemonShutdown: async () => { - lifecycleEvents.push('detach'); - // The real diagnostics module, unmocked: what this records is whether a diagnostic raised by - // the handoff reaches disk at all, which only the shutdown's own scope can decide (#2681). - const { emitDiagnostic } = await import('@agent-device/host-kit/diagnostics'); - emitDiagnostic({ - level: 'debug', - phase: 'detach_scope_probe', - data: { lane: 'physical_coredevice' }, - }); - }, - finalizeDaemonShutdown: async () => { - lifecycleEvents.push('finalize'); - }, - }, - inspectFacts: async () => { - throw new Error('unused'); - }, - bind: async () => { - throw new Error('unused'); - }, - shutdown: async () => { - lifecycleEvents.push('gateway-shutdown'); - }, - }), - createPlatformDeviceInventoryGateways: () => ({}), -})); - -vi.mock('../../../provider-device-runtimes.ts', () => ({ - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), -})); - const shutdownProbe = vi.hoisted(() => ({ - store: undefined as import('@agent-device/daemon/testing/session-store').SessionStore | undefined, + store: undefined as import('../../core/session-store.ts').SessionStore | undefined, drain: undefined as (() => Promise) | undefined, finalize: vi.fn(async () => {}), dispatch: undefined as import('@agent-device/daemon').DaemonInvokeFn | undefined, closeTimeout: undefined as number | undefined, })); -vi.mock('@agent-device/daemon/testing/session-store', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('../../core/session-store.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, SessionStore: class extends actual.SessionStore { @@ -68,9 +26,8 @@ vi.mock('@agent-device/daemon/testing/session-store', async (importOriginal) => }, }; }); -vi.mock('@agent-device/daemon/testing/server/server-shutdown', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('./server-shutdown.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, closeDaemonServers: async (...args: Parameters) => { @@ -79,9 +36,8 @@ vi.mock('@agent-device/daemon/testing/server/server-shutdown', async (importOrig }, }; }); -vi.mock('@agent-device/daemon/testing/request-router', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('../request-router.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, createRequestHandler: (...args: Parameters) => { @@ -91,20 +47,17 @@ vi.mock('@agent-device/daemon/testing/request-router', async (importOriginal) => }, }; }); -vi.mock('@agent-device/daemon/testing/application-lifecycle-recovery', async (importOriginal) => ({ - ...(await importOriginal< - typeof import('@agent-device/daemon/testing/application-lifecycle-recovery') - >()), +vi.mock('../application-lifecycle-recovery.ts', async (importOriginal) => ({ + ...(await importOriginal()), finalizeDaemonSessionApplicationLifecycle: shutdownProbe.finalize, })); const leaseProbe = vi.hoisted(() => ({ - registries: [] as import('@agent-device/daemon/testing/lease-registry').LeaseRegistry[], + registries: [] as import('../../resources/lease-registry.ts').LeaseRegistry[], released: [] as import('@agent-device/contracts/device').DeviceLease[], })); -vi.mock('@agent-device/daemon/testing/lease-registry', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('../../resources/lease-registry.ts', async (importOriginal) => { + const actual = await importOriginal(); class RecordedLeaseRegistry extends actual.LeaseRegistry { constructor(...args: ConstructorParameters) { super(...args); @@ -114,9 +67,8 @@ vi.mock('@agent-device/daemon/testing/lease-registry', async (importOriginal) => return { ...actual, LeaseRegistry: RecordedLeaseRegistry }; }); -vi.mock('@agent-device/daemon/testing/provider-lease-expiry', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('../provider-lease-expiry.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, createExpiredProviderLeaseReleaser: ( @@ -139,13 +91,15 @@ import { ANDROID_EMULATOR } from '@agent-device/test-kit/device-fixtures'; import { isolatedDeviceClaimStores, retainOrphanedDeviceClaims, -} from '@agent-device/daemon/testing/__tests__/support/device-claim-store'; -import { sendRequest } from '../../../daemon-client/daemon-client-transport.ts'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { teardownDaemonSessionForShutdown } from '@agent-device/daemon/testing/server/daemon-runtime'; -import { startDaemonRuntimeFixture } from '../daemon-runtime-fixture.ts'; -import { makeIosSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; +} from '../../__tests__/support/device-claim-store.ts'; +import { + applicationLifecycleResourceFixture, + daemonRuntimeDepsFixture, +} from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { lifecycleDeviceRuntimeGateway } from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { startDaemonRuntime, teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; +import { makeIosSession } from '../../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; afterEach(() => { lifecycleEvents.length = 0; @@ -162,7 +116,32 @@ test('daemon shutdown detaches before session teardown and force-finalizes only const exits: number[] = []; const startupErrors: string[] = []; try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture({ + deviceRuntimeGateway: Object.freeze({ + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture({ + detachForDaemonShutdown: async () => { + lifecycleEvents.push('detach'); + // The real diagnostics module, unmocked: what this records is whether a diagnostic + // raised by the handoff reaches disk at all, which only the shutdown's own scope can + // decide (#2681). + const { emitDiagnostic } = await import('@agent-device/host-kit/diagnostics'); + emitDiagnostic({ + level: 'debug', + phase: 'detach_scope_probe', + data: { lane: 'physical_coredevice' }, + }); + }, + finalizeDaemonShutdown: async () => { + lifecycleEvents.push('finalize'); + }, + }), + shutdown: async () => { + lifecycleEvents.push('gateway-shutdown'); + }, + }), + }), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -190,7 +169,32 @@ test('a SIGTERM shutdown gives the handoff a diagnostics scope to write its reas // including "why did this runner get killed instead of handed off" — disappears with the daemon. const stateDir = mkdtempForTestSync('agent-device-daemon-detach-diagnostics-'); try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture({ + deviceRuntimeGateway: Object.freeze({ + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture({ + detachForDaemonShutdown: async () => { + lifecycleEvents.push('detach'); + // The real diagnostics module, unmocked: what this records is whether a diagnostic + // raised by the handoff reaches disk at all, which only the shutdown's own scope can + // decide (#2681). + const { emitDiagnostic } = await import('@agent-device/host-kit/diagnostics'); + emitDiagnostic({ + level: 'debug', + phase: 'detach_scope_probe', + data: { lane: 'physical_coredevice' }, + }); + }, + finalizeDaemonShutdown: async () => { + lifecycleEvents.push('finalize'); + }, + }), + shutdown: async () => { + lifecycleEvents.push('gateway-shutdown'); + }, + }), + }), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -218,7 +222,32 @@ const claimStores = isolatedDeviceClaimStores('daemon-drain-admission-'); test('shutdown includes drain publications, releases their claims and refuses post-snapshot publication', async () => { const { stateDir } = claimStores(); - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture({ + deviceRuntimeGateway: Object.freeze({ + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture({ + detachForDaemonShutdown: async () => { + lifecycleEvents.push('detach'); + // The real diagnostics module, unmocked: what this records is whether a diagnostic + // raised by the handoff reaches disk at all, which only the shutdown's own scope can + // decide (#2681). + const { emitDiagnostic } = await import('@agent-device/host-kit/diagnostics'); + emitDiagnostic({ + level: 'debug', + phase: 'detach_scope_probe', + data: { lane: 'physical_coredevice' }, + }); + }, + finalizeDaemonShutdown: async () => { + lifecycleEvents.push('finalize'); + }, + }), + shutdown: async () => { + lifecycleEvents.push('gateway-shutdown'); + }, + }), + }), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -272,7 +301,32 @@ test('shutdown includes drain publications, releases their claims and refuses po test('shutdown joins dispatch completion after force-closing the client before taking its snapshot', async () => { const { stateDir } = claimStores(); - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture({ + deviceRuntimeGateway: Object.freeze({ + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture({ + detachForDaemonShutdown: async () => { + lifecycleEvents.push('detach'); + // The real diagnostics module, unmocked: what this records is whether a diagnostic + // raised by the handoff reaches disk at all, which only the shutdown's own scope can + // decide (#2681). + const { emitDiagnostic } = await import('@agent-device/host-kit/diagnostics'); + emitDiagnostic({ + level: 'debug', + phase: 'detach_scope_probe', + data: { lane: 'physical_coredevice' }, + }); + }, + finalizeDaemonShutdown: async () => { + lifecycleEvents.push('finalize'); + }, + }), + shutdown: async () => { + lifecycleEvents.push('gateway-shutdown'); + }, + }), + }), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -326,19 +380,21 @@ test('shutdown joins dispatch completion after force-closing the client before t } return { ok: true }; }; - request = sendRequest( - { httpPort: runtime!.httpPort, pid: process.pid, token: runtime!.token }, - { - token: runtime!.token, - session: 'dispatching', - command: 'open', - positionals: [], - flags: {}, + request = fetch(`http://127.0.0.1:${runtime!.httpPort}/rpc`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${runtime!.token}`, }, - 'http', - resolveDaemonPaths(stateDir), - 10_000, - ).catch((error: unknown) => error); + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'dispatching-open', + method: 'agent_device.command', + params: { session: 'dispatching', command: 'open', positionals: [], flags: {} }, + }), + }) + .then(async (response) => await response.text()) + .catch((error: unknown) => error); await started; shutdownProbe.closeTimeout = 1; shutdownProbe.drain = async () => { @@ -385,7 +441,7 @@ test.each([false, true])( sessionStore, stderr: { write: () => {} }, finalizeApplicationLifecycle, - platformCleanup: platformResourceCleanup, + platformCleanup: platformResourceCleanupFixture(), }); expect(finalizeApplicationLifecycle).toHaveBeenCalledWith( @@ -411,7 +467,32 @@ test.each([false, true])( test('daemon shutdown releases a session lease whose session teardown rejects', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-session-lease-shutdown-'); - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture({ + deviceRuntimeGateway: Object.freeze({ + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture({ + detachForDaemonShutdown: async () => { + lifecycleEvents.push('detach'); + // The real diagnostics module, unmocked: what this records is whether a diagnostic + // raised by the handoff reaches disk at all, which only the shutdown's own scope can + // decide (#2681). + const { emitDiagnostic } = await import('@agent-device/host-kit/diagnostics'); + emitDiagnostic({ + level: 'debug', + phase: 'detach_scope_probe', + data: { lane: 'physical_coredevice' }, + }); + }, + finalizeDaemonShutdown: async () => { + lifecycleEvents.push('finalize'); + }, + }), + shutdown: async () => { + lifecycleEvents.push('gateway-shutdown'); + }, + }), + }), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -445,7 +526,32 @@ test('daemon shutdown releases a session lease whose session teardown rejects', test('daemon shutdown releases a retainOnClose lease that no session holds', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-retained-lease-shutdown-'); try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture({ + deviceRuntimeGateway: Object.freeze({ + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture({ + detachForDaemonShutdown: async () => { + lifecycleEvents.push('detach'); + // The real diagnostics module, unmocked: what this records is whether a diagnostic + // raised by the handoff reaches disk at all, which only the shutdown's own scope can + // decide (#2681). + const { emitDiagnostic } = await import('@agent-device/host-kit/diagnostics'); + emitDiagnostic({ + level: 'debug', + phase: 'detach_scope_probe', + data: { lane: 'physical_coredevice' }, + }); + }, + finalizeDaemonShutdown: async () => { + lifecycleEvents.push('finalize'); + }, + }), + shutdown: async () => { + lifecycleEvents.push('gateway-shutdown'); + }, + }), + }), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-metadata-ownership.test.ts b/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts similarity index 81% rename from src/__tests__/daemon-composition/server/daemon-runtime-metadata-ownership.test.ts rename to packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts index ab9927a8b1..1ee505f405 100644 --- a/src/__tests__/daemon-composition/server/daemon-runtime-metadata-ownership.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts @@ -10,54 +10,13 @@ const providerComposition = vi.hoisted(() => ({ skipped: [] as Array<{ provider: string; error: Error & { code: string } }>, })); -vi.mock('../../../platform-runtime.ts', () => ({ - androidObservation: {}, - createRequestPlatformProviders: () => ({ - run: async (_context: unknown, task: () => Promise) => await task(), - }), - createPlatformRuntimeGateway: () => ({ - applicationLifecycle: { - recoverStartupResources: async () => {}, - detachForDaemonShutdown: async () => {}, - finalizeDaemonShutdown: async () => {}, - }, - inspectFacts: async () => { - throw new Error('unused'); - }, - bind: async () => { - throw new Error('unused'); - }, - shutdown: async () => { - lifecycleEvents.push('gateway-shutdown'); - }, - }), - createPlatformDeviceInventoryGateways: () => ({}), -})); - -vi.mock('../../../provider-device-runtimes.ts', () => ({ - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDaemonProviderRuntimeComposition: async () => ({ - runtimes: [], - platformModules: [], - skipped: providerComposition.skipped, - }), -})); - -// The post-lock, pre-publication step the runtime awaits first. Making it throw lands the runtime in -// the startup-failure branch that also removes `daemon.json`, which is otherwise unreachable from a -// test because every later step needs a real device toolchain. -vi.mock('../../../platform-runtime-daemon-lifecycle.ts', () => ({ - platformDaemonLifecycleOwners: { - configureForDaemonLock: async () => { - if (startupFailure.active) throw new Error('startup step failed'); - }, - recoverLegacyAppLogMarkers: async () => ({ recovered: [], retained: [] }), - clearDaemonLockConfiguration: async () => {}, - resetAndroidSnapshotHelper: async () => {}, - }, -})); - -import { startDaemonRuntimeFixture } from '../daemon-runtime-fixture.ts'; +import { + applicationLifecycleResourceFixture, + daemonRuntimeDepsFixture, + platformOwnerLifecycleFixture, +} from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { lifecycleDeviceRuntimeGateway } from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { startDaemonRuntime } from './daemon-runtime.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; type DaemonPaths = ReturnType; @@ -67,7 +26,25 @@ type DaemonPaths = ReturnType; const SUCCESSOR_PID = 999_999_999; function startRuntime(stateDir: string, exit: (code: number) => void) { - return startDaemonRuntimeFixture({ + return startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture(() => ({ + // The post-lock, pre-publication step the runtime awaits first. Making it throw lands the + // runtime in the startup-failure branch that also removes `daemon.json`, which is otherwise + // unreachable from a test because every later step needs a real device toolchain. + ownerLifecycle: platformOwnerLifecycleFixture({ + configureForDaemonLock: async () => { + if (startupFailure.active) throw new Error('startup step failed'); + }, + }), + deviceRuntimeGateway: Object.freeze({ + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture(), + shutdown: async () => { + lifecycleEvents.push('gateway-shutdown'); + }, + }), + skippedProviderRuntimes: providerComposition.skipped, + })), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -255,10 +232,7 @@ test('a successor published during this daemon lifetime is recorded at detection test('the loss watch is armed only after this daemon publishes its own record', () => { // Arming before publication would report a predecessor's record as a takeover of something this // daemon never owned. Ordering is the invariant, the same way the startup-ordering guards read it. - const source = fs.readFileSync( - new URL('../../../../packages/daemon/src/server/http/daemon-runtime.ts', import.meta.url), - 'utf8', - ); + const source = fs.readFileSync(new URL('./daemon-runtime.ts', import.meta.url), 'utf8'); const published = source.indexOf('publishDaemonInfo(socketPort, httpPort);'); const armed = source.indexOf('stopMetadataLossWatch = armDaemonMetadataLossWatch('); @@ -271,10 +245,7 @@ test('both exits tear the watch down before they touch daemon.json', () => { // deleted. The startup-failure half of this is unreachable from a test — everything after // publication needs a real toolchain — so the invariant is read off the source, the same way the // arming order above is. - const source = fs.readFileSync( - new URL('../../../../packages/daemon/src/server/http/daemon-runtime.ts', import.meta.url), - 'utf8', - ); + const source = fs.readFileSync(new URL('./daemon-runtime.ts', import.meta.url), 'utf8'); const stops = [...source.matchAll(/stopMetadataLossWatch\(\);/g)].map((m) => m.index); const finishes = [...source.matchAll(/await finishDaemonRegistration\(/g)].map((m) => m.index); expect(stops).toHaveLength(2); diff --git a/packages/daemon/src/server/http/daemon-runtime-owner-lifecycle.test.ts b/packages/daemon/src/server/http/daemon-runtime-owner-lifecycle.test.ts new file mode 100644 index 0000000000..56c0dbeb19 --- /dev/null +++ b/packages/daemon/src/server/http/daemon-runtime-owner-lifecycle.test.ts @@ -0,0 +1,97 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { + daemonRuntimeDepsFixture, + platformOwnerLifecycleFixture, +} from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import type { PlatformOwnerLifecycle } from '../../core/platform-owner-lifecycle.ts'; +import { startDaemonRuntime } from './daemon-runtime.ts'; + +type LoggedEvent = { phase: string; data?: Record }; + +function loggedEvents(logPath: string): LoggedEvent[] { + return fs + .readFileSync(logPath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as LoggedEvent) + .filter((event) => event.phase.startsWith('platform_owner_')); +} + +function startDaemon( + stateDir: string, + configureForDaemonLock: PlatformOwnerLifecycle['configureForDaemonLock'], +) { + return startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps: daemonRuntimeDepsFixture({ + ownerLifecycle: platformOwnerLifecycleFixture({ configureForDaemonLock }), + }), + }); +} + +test('daemon startup configures its platform owners before it publishes readiness', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-owner-lifecycle-'); + const infoPath = path.join(stateDir, 'daemon.json'); + const infoPublishedAtConfigure: boolean[] = []; + + const runtime = await startDaemon(stateDir, async (input) => { + infoPublishedAtConfigure.push(fs.existsSync(infoPath)); + input.onDiagnostic({ + phase: 'platform_owner_link_removed', + resourcePath: '/owner/resource', + data: { linkTarget: '/owner/scoped' }, + }); + }); + try { + expect(runtime).not.toBeNull(); + expect(infoPublishedAtConfigure).toEqual([false]); + expect(fs.existsSync(infoPath)).toBe(true); + expect(loggedEvents(path.join(stateDir, 'daemon.log'))).toEqual([ + expect.objectContaining({ + phase: 'platform_owner_link_removed', + data: expect.objectContaining({ + resourcePath: '/owner/resource', + linkTarget: '/owner/scoped', + }), + }), + ]); + } finally { + await runtime?.shutdown(); + } +}); + +test('a platform owner diagnostic about a refused restore is logged and the daemon still starts', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-owner-lifecycle-refused-'); + + const runtime = await startDaemon(stateDir, async (input) => { + input.onDiagnostic({ + phase: 'platform_owner_restore_failed', + resourcePath: '/owner/resource', + data: {}, + }); + }); + try { + expect(runtime).not.toBeNull(); + expect(fs.existsSync(path.join(stateDir, 'daemon.json'))).toBe(true); + expect(loggedEvents(path.join(stateDir, 'daemon.log'))).toEqual([ + expect.objectContaining({ + phase: 'platform_owner_restore_failed', + data: expect.objectContaining({ resourcePath: '/owner/resource' }), + }), + ]); + } finally { + await runtime?.shutdown(); + } +}); diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-recording-reaper.test.ts b/packages/daemon/src/server/http/daemon-runtime-recording-reaper.test.ts similarity index 64% rename from src/__tests__/daemon-composition/server/daemon-runtime-recording-reaper.test.ts rename to packages/daemon/src/server/http/daemon-runtime-recording-reaper.test.ts index 9ed3cfecd8..6b06f0036c 100644 --- a/src/__tests__/daemon-composition/server/daemon-runtime-recording-reaper.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-recording-reaper.test.ts @@ -15,34 +15,8 @@ vi.mock('@agent-device/host-kit/process', async (importOriginal) => { }; }); -vi.mock('../../../platform-runtime.ts', () => ({ - androidObservation: {}, - createRequestPlatformProviders: () => ({ - run: async (_context: unknown, task: () => Promise) => await task(), - }), - createPlatformRuntimeGateway: () => ({ - applicationLifecycle: { - recoverStartupResources: async () => {}, - detachForDaemonShutdown: async () => {}, - finalizeDaemonShutdown: async () => {}, - }, - inspectFacts: async () => { - throw new Error('unused'); - }, - bind: async () => { - throw new Error('unused'); - }, - shutdown: async () => {}, - }), - createPlatformDeviceInventoryGateways: () => ({}), -})); - -vi.mock('../../../provider-device-runtimes.ts', () => ({ - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), -})); - -import { startDaemonRuntimeFixture } from '../daemon-runtime-fixture.ts'; +import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { startDaemonRuntime } from './daemon-runtime.ts'; afterEach(() => { reapCalls.length = 0; @@ -51,7 +25,8 @@ afterEach(() => { test('daemon startup reaps orphaned simctl recorders with a graceful finalize window', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-recording-reaper-'); try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture(), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-recording-teardown.test.ts b/packages/daemon/src/server/http/daemon-runtime-recording-teardown.test.ts similarity index 86% rename from src/__tests__/daemon-composition/server/daemon-runtime-recording-teardown.test.ts rename to packages/daemon/src/server/http/daemon-runtime-recording-teardown.test.ts index 9af7481163..e2b86d9025 100644 --- a/src/__tests__/daemon-composition/server/daemon-runtime-recording-teardown.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-recording-teardown.test.ts @@ -2,10 +2,10 @@ import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import type { ScreenRecordingCompletion } from '@agent-device/contracts/screen-recording-runtime'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { makeRecordingSession } from '@agent-device/daemon/testing/__tests__/session-teardown.fixtures'; -import { teardownDaemonSessionForShutdown } from '@agent-device/daemon/testing/server/daemon-runtime'; +import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; +import { SessionStore } from '../../core/session-store.ts'; +import { makeRecordingSession } from '../../__tests__/support/session-teardown.fixtures.ts'; +import { teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; afterEach(() => { vi.useRealTimers(); @@ -39,7 +39,7 @@ test('daemon shutdown awaits durable recording finalization inside its extended ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, - platformCleanup: platformResourceCleanup, + platformCleanup: platformResourceCleanupFixture(), }); await vi.advanceTimersByTimeAsync(10_000); await teardown; @@ -77,7 +77,7 @@ test('daemon shutdown resolves durable recording resources through the effective ref: sessionStore.lookup(effectiveSessionName)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, - platformCleanup: platformResourceCleanup, + platformCleanup: platformResourceCleanupFixture(), }); expect(stderrChunks.join('')).not.toMatch(/resource record is missing/); diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-rpc-flow-failure.test.ts b/packages/daemon/src/server/http/daemon-runtime-rpc-flow-failure.test.ts similarity index 93% rename from src/__tests__/daemon-composition/server/daemon-runtime-rpc-flow-failure.test.ts rename to packages/daemon/src/server/http/daemon-runtime-rpc-flow-failure.test.ts index 8b55069f65..0a821cfd65 100644 --- a/src/__tests__/daemon-composition/server/daemon-runtime-rpc-flow-failure.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-rpc-flow-failure.test.ts @@ -14,9 +14,8 @@ const requestProbe = vi.hoisted(() => ({ failuresRemaining: 0, })); -vi.mock('@agent-device/daemon/testing/request-router', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('../request-router.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, createRequestHandler: (...args: Parameters) => { @@ -32,12 +31,8 @@ vi.mock('@agent-device/daemon/testing/request-router', async (importOriginal) => }; }); -vi.mock('../../../provider-device-runtimes.ts', () => ({ - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), -})); - -import { startDaemonRuntimeFixture } from '../daemon-runtime-fixture.ts'; +import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { startDaemonRuntime } from './daemon-runtime.ts'; afterEach(() => { requestProbe.failuresRemaining = 0; @@ -100,9 +95,10 @@ async function driveFlowFailure(options: { stderrThrows?: boolean }): Promise> = null; + let runtime: Awaited> = null; try { - runtime = await startDaemonRuntimeFixture({ + runtime = await startDaemonRuntime({ + composeDeps: daemonRuntimeDepsFixture(), env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-snapshot-shutdown.test.ts b/packages/daemon/src/server/http/daemon-runtime-snapshot-shutdown.test.ts similarity index 73% rename from src/__tests__/daemon-composition/server/daemon-runtime-snapshot-shutdown.test.ts rename to packages/daemon/src/server/http/daemon-runtime-snapshot-shutdown.test.ts index e51c4db8ac..dc78578bc5 100644 --- a/src/__tests__/daemon-composition/server/daemon-runtime-snapshot-shutdown.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-snapshot-shutdown.test.ts @@ -5,30 +5,21 @@ import { registerRequestAbort, } from '@agent-device/host-kit/request'; import { ANDROID_EMULATOR } from '@agent-device/test-kit/device-fixtures'; -import { makeAndroidSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { snapshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; -import { DAEMON_SESSION_TEARDOWN_TIMEOUT_MS } from '@agent-device/daemon/testing/session-teardown-budget'; -import { dispatchSnapshotViaRuntime } from '@agent-device/daemon/testing/snapshot-runtime'; -import { teardownDaemonSessionForShutdown } from '@agent-device/daemon/testing/server/daemon-runtime'; -import { testCreateCommandSurface } from '../command-surface-fixture.ts'; +import { makeAndroidSession } from '../../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { snapshotRuntimeFixture } from '../../__tests__/support/snapshot-runtime-fixture.ts'; +import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; +import { DAEMON_SESSION_TEARDOWN_TIMEOUT_MS } from '../../sessions/session-teardown-budget.ts'; +import { dispatchSnapshotViaRuntime } from '../../sessions/snapshot-runtime.ts'; +import { teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; +import { forwardingCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; vi.mock('@agent-device/device-selection/dispatch-resolve', () => ({ resolveTargetDevice: vi.fn(async () => ANDROID_EMULATOR), })); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); -vi.mock('../../../platform-runtime-resource-cleanup.ts', () => ({ - platformResourceCleanup: { - stopSnapshotHelper: vi.fn(), - closeManagedBrowser: vi.fn(async () => {}), - cleanupSessionlessExecutionHost: vi.fn(async () => {}), - retainExecutionHostAfterClose: vi.fn(() => false), - }, -})); + +const platformResourceCleanup = platformResourceCleanupFixture(); function deferred() { let resolve!: () => void; @@ -67,7 +58,7 @@ for (const initialSession of ['published', 'draft'] as const) { await releaseCleanup.promise; }); const running = dispatchSnapshotViaRuntime({ - createCommandSurface: testCreateCommandSurface, + createCommandSurface: forwardingCreateCommandSurface, req: { command: 'snapshot', positionals: [], diff --git a/packages/daemon/src/server/http/daemon-runtime-web-close-teardown.test.ts b/packages/daemon/src/server/http/daemon-runtime-web-close-teardown.test.ts new file mode 100644 index 0000000000..81fb21800b --- /dev/null +++ b/packages/daemon/src/server/http/daemon-runtime-web-close-teardown.test.ts @@ -0,0 +1,121 @@ +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { WEB_DESKTOP_DEVICE } from '@agent-device/test-kit/device-fixtures'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; +import { makeWebSession } from '../../__tests__/support/session-teardown.fixtures.ts'; +import { SessionStore } from '../../core/session-store.ts'; +import { teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +function openStore(prefix: string): SessionStore { + return new SessionStore(path.join(mkdtempForTestSync(prefix), 'sessions')); +} + +// The agent-browser CLI call a web close makes has its own 30s internal timeout, well past the 5s +// base teardown budget every other resource shares; without the extended budget, a close that +// takes longer than 5s (closing ~15 Chrome processes under load is plausible) would be abandoned +// by the daemon's own race before the platform ever got to finish it. +test('daemon shutdown awaits a slow web close inside its extended budget', async () => { + vi.useFakeTimers(); + const sessionStore = openStore('agent-device-shutdown-web-close-slow-'); + const session = makeWebSession('shutdown-slow-web-session'); + sessionStore.publish(session.name, session); + const platformCleanup = platformResourceCleanupFixture({ + closeManagedBrowser: async () => await new Promise((resolve) => setTimeout(resolve, 20_000)), + }); + const stderrChunks: string[] = []; + + const teardown = teardownDaemonSessionForShutdown({ + ref: sessionStore.lookup(session.name)!, + sessionStore, + stderr: { write: (chunk) => stderrChunks.push(chunk) }, + platformCleanup, + }); + await vi.advanceTimersByTimeAsync(20_000); + await teardown; + + expect(stderrChunks.join('')).not.toMatch(/timed out/); + expect(sessionStore.get(session.name)).toBeUndefined(); +}); + +// #1868: SIGTERM (or any other daemon-shutdown teardown) must tell the platform to close its +// browser fleet right away instead of leaving it for the browser's own multi-minute idle timer. +test('daemon shutdown asks the platform to close an open web session', async () => { + const sessionStore = openStore('agent-device-shutdown-web-close-'); + const session = makeWebSession('shutdown-web-session'); + sessionStore.publish(session.name, session); + const platformCleanup = platformResourceCleanupFixture(); + const stderrChunks: string[] = []; + + await teardownDaemonSessionForShutdown({ + ref: sessionStore.lookup(session.name)!, + sessionStore, + stderr: { write: (chunk) => stderrChunks.push(chunk) }, + platformCleanup, + }); + + expect(stderrChunks.join('')).toBe(''); + expect(sessionStore.get(session.name)).toBeUndefined(); + expect(platformCleanup.closeManagedBrowser).toHaveBeenCalledOnce(); + expect(platformCleanup.closeManagedBrowser).toHaveBeenCalledWith({ + device: session.device, + sessionName: session.name, + stateDir: sessionStore.resolveDaemonStateDir(), + openSessionNames: expect.any(Function), + }); +}); + +test('daemon shutdown reports a web close failure on stderr instead of losing it silently', async () => { + const sessionStore = openStore('agent-device-shutdown-web-close-failure-'); + const session = makeWebSession('shutdown-web-close-failure-session'); + sessionStore.publish(session.name, session); + const platformCleanup = platformResourceCleanupFixture({ + closeManagedBrowser: async () => { + throw new Error('no active browser session'); + }, + }); + const stderrChunks: string[] = []; + + await teardownDaemonSessionForShutdown({ + ref: sessionStore.lookup(session.name)!, + sessionStore, + stderr: { write: (chunk) => stderrChunks.push(chunk) }, + platformCleanup, + }); + + // Best-effort: the failure is surfaced, but it never blocks the session from being torn down. + expect(stderrChunks.join('')).toMatch(/web_browser.*no active browser session/); + expect(sessionStore.get(session.name)).toBeUndefined(); +}); + +// The platform filter lives in the composed adapter, not in the neutral teardown: the daemon asks +// the port for every session and the adapter decides which devices have a browser. +test('daemon session teardown asks the platform port about a non-web session too', async () => { + const sessionStore = openStore('agent-device-non-web-teardown-'); + const device = { ...WEB_DESKTOP_DEVICE, platform: 'android' as const, id: 'emulator-5554' }; + sessionStore.publish('android-non-web-teardown-session', { + name: 'android-non-web-teardown-session', + device, + createdAt: Date.now(), + actions: [], + }); + const platformCleanup = platformResourceCleanupFixture(); + const stderrChunks: string[] = []; + + await teardownDaemonSessionForShutdown({ + ref: sessionStore.lookup('android-non-web-teardown-session')!, + sessionStore, + stderr: { write: (chunk) => stderrChunks.push(chunk) }, + platformCleanup, + }); + + expect(platformCleanup.closeManagedBrowser).toHaveBeenCalledWith( + expect.objectContaining({ device }), + ); + expect(stderrChunks.join('')).toBe(''); +}); diff --git a/packages/daemon/src/sessions/handlers/__tests__/snapshot.test.ts b/packages/daemon/src/sessions/handlers/__tests__/snapshot.test.ts index 9289edb0e4..eefaf72ea5 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/snapshot.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/snapshot.test.ts @@ -110,7 +110,7 @@ test('parseWaitArgs parses selector expression with timeout', () => { // #1800: this used to fall back to a literal-text wait for "foo=bar" — a caller typo (or a // straight-up unrecognized selector key) that can only ever time out, never match. It is now a // typed rejection instead. Full coverage of the rejection shapes lives in -// src/__tests__/wait-positionals.test.ts, beside the parser's other root-owned coverage. +// packages/command-registry/src/wait-positionals.test.ts, beside the parser. test('parseWaitArgs rejects an unrecognized selector-like key=value token instead of reading it as text', () => { const result = parseWaitArgs(['foo=bar', '5000']); assert.ok(result); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts index 4bff9ab897..c0e19f311c 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts @@ -222,7 +222,7 @@ test('daemon session teardown closes an open web session immediately, not on age // session's browser right away, the same way an explicit `session close` does, instead of // leaving the Chrome processes to agent-browser's own multi-minute idle timer. That the close // reaches the agent-browser process is real mechanics, pinned root-owned in - // daemon-composition/server/daemon-runtime-web-close-teardown. + // src/platform-runtime-resource-cleanup.test.ts. expect(platformCleanup.closeManagedBrowser).toHaveBeenCalledWith({ device: WEB_DESKTOP_DEVICE, sessionName, diff --git a/src/__tests__/daemon-composition-assembly.test.ts b/src/__tests__/daemon-composition-assembly.test.ts deleted file mode 100644 index 8acef2f597..0000000000 --- a/src/__tests__/daemon-composition-assembly.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { test, vi } from 'vitest'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { WEB_DESKTOP_DEVICE } from '@agent-device/test-kit/device-fixtures'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { createDaemonRuntimeDeps } from '../daemon-composition.ts'; -import { startDaemonRuntimeFixture } from './daemon-composition/daemon-runtime-fixture.ts'; -import type { DaemonRuntimeDepsInput } from '@agent-device/daemon'; - -// The server builds its own store, so capture that instance the same way the shutdown tests do: -// the proof is about the store the server hands to the composition callbacks, not one the test -// fabricates. -const storeProbe = vi.hoisted(() => ({ - store: undefined as import('@agent-device/daemon/testing/session-store').SessionStore | undefined, -})); -vi.mock('@agent-device/daemon/testing/session-store', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - SessionStore: class extends actual.SessionStore { - constructor(...args: ConstructorParameters) { - super(...args); - storeProbe.store = this; - } - }, - }; -}); - -/** - * The one-assembly guarantee (#3431 review): the server owns the paths, the ADR 0029 policy load, - * the session store, and the `assertShutdownAllowed` derivation, and passes them to the root's - * factory. This boots the real `startDaemonRuntime` with the real `createDaemonRuntimeDeps` — - * wrapped only to capture what the server derived — so a server that dropped the derivation, - * loaded no policy, or wired the session callbacks to a different store fails here. - */ -test('the server derives the policy gate and session callbacks and hands them to the root factory', async () => { - const stateDir = mkdtempForTestSync('agent-device-daemon-assembly-'); - const policyPath = path.join(stateDir, 'policy.json'); - fs.writeFileSync( - policyPath, - JSON.stringify({ version: 1, capabilities: { deny: ['device-shutdown'] } }), - ); - const paths = resolveDaemonPaths(stateDir); - const exits: number[] = []; - let composed: DaemonRuntimeDepsInput | undefined; - const composeDeps = async (input: DaemonRuntimeDepsInput) => { - composed = input; - return await createDaemonRuntimeDeps(input); - }; - let runtime: Awaited> = null; - try { - runtime = await startDaemonRuntimeFixture({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_POLICY: policyPath, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, - exit: (code) => exits.push(code), - registerProcessHandlers: false, - stderr: { write: () => {} }, - stdout: { write: () => {} }, - composeDeps, - }); - assert.notEqual(runtime, null, 'the real assembly must boot'); - assert.deepEqual(exits, []); - assert.ok(composed, 'the server must call the factory'); - // The server resolved the paths and loaded the operator's policy before composing anything. - assert.equal(composed.baseDir, paths.baseDir); - assert.equal(composed.sessionsDir, paths.sessionsDir); - // The shutdown gate the root threads into the runtime gateway refuses the denied capability. - assert.throws( - () => composed?.assertShutdownAllowed?.(), - (error: unknown) => - (error as { details?: { reason?: string; rule?: string } }).details?.reason === - 'DAEMON_POLICY_DENIED' && - (error as { details?: { rule?: string } }).details?.rule === 'capability', - ); - // The web-session callback reads the one store the server built, so the deps closures and the - // server can never disagree about which sessions are open. - const store = storeProbe.store; - assert.ok(store, 'the server must construct the store the callbacks read'); - store.publish('web-assembly', { - name: 'web-assembly', - device: WEB_DESKTOP_DEVICE, - createdAt: Date.now(), - actions: [], - }); - assert.deepEqual([...composed.openWebSessionNames()], ['web-assembly']); - assert.equal( - composed.resolveSessionDir('web-assembly'), - store.resolveSessionDir('web-assembly'), - ); - assert.deepEqual(composed.resolveSessionArtifacts('web-assembly'), { - outputPath: store.resolveAppLogPath('web-assembly'), - pidPath: store.resolveAppLogPidPath('web-assembly'), - }); - await runtime?.shutdown(); - runtime = null; - assert.deepEqual(exits, [0]); - } finally { - await runtime?.shutdown(); - } -}); - -/** - * A policy the operator names but that cannot be read stops startup through the same server, - * before any runtime is composed: the typed stderr and exit are the server's, not the entry's. - */ -test('an unreadable policy stops the assembly before the factory is called', async () => { - const stateDir = mkdtempForTestSync('agent-device-daemon-assembly-bad-policy-'); - const exits: number[] = []; - const stderr: string[] = []; - let factoryCalls = 0; - let runtime: Awaited> = null; - try { - runtime = await startDaemonRuntimeFixture({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_POLICY: path.join(stateDir, 'missing-policy.json'), - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, - exit: (code) => exits.push(code), - stderr: { write: (chunk) => stderr.push(chunk) }, - stdout: { write: () => {} }, - registerProcessHandlers: false, - composeDeps: async (input) => { - factoryCalls += 1; - return await createDaemonRuntimeDeps(input); - }, - }); - assert.equal(runtime, null); - assert.deepEqual(exits, [1]); - assert.equal(factoryCalls, 0, 'no runtime may be built under an unreadable policy'); - assert.match(stderr.join(''), /Daemon error: .*/); - } finally { - await runtime?.shutdown(); - } -}); diff --git a/src/__tests__/daemon-composition/daemon-runtime-fixture.ts b/src/__tests__/daemon-composition/daemon-runtime-fixture.ts deleted file mode 100644 index 7c3431d896..0000000000 --- a/src/__tests__/daemon-composition/daemon-runtime-fixture.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createDaemonRuntimeDeps } from '../../daemon-composition.ts'; -import { - startDaemonRuntime, - type ComposeDaemonRuntimeDeps, - type DaemonRuntimeController, -} from '@agent-device/daemon'; - -type WritableOutput = { write: (chunk: string) => unknown }; - -/** - * The runtime a test boots through the one assembly the process root uses: `startDaemonRuntime` - * with the real composition factory from `src/daemon-composition.ts`, so the paths, the policy - * load, the session store, and the `assertShutdownAllowed` derivation under test are exactly the - * production ones. A test replaces a composed capability the same way it always has — a - * `vi.mock` of the root module the composition imports — or passes its own `composeDeps` factory - * when it needs to fake the composed half outright. - */ -export async function startDaemonRuntimeFixture(options: { - env: NodeJS.ProcessEnv; - exit: (code: number) => void; - stderr: WritableOutput; - stdout?: WritableOutput; - registerProcessHandlers?: boolean; - composeDeps?: ComposeDaemonRuntimeDeps; -}): Promise { - return await startDaemonRuntime({ - env: options.env, - exit: options.exit, - stderr: options.stderr, - stdout: options.stdout, - registerProcessHandlers: options.registerProcessHandlers, - composeDeps: options.composeDeps ?? createDaemonRuntimeDeps, - }); -} diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-interactor-composition.test.ts b/src/__tests__/daemon-composition/server/daemon-runtime-interactor-composition.test.ts deleted file mode 100644 index 3682e93845..0000000000 --- a/src/__tests__/daemon-composition/server/daemon-runtime-interactor-composition.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import fs from 'node:fs'; -import { afterEach, expect, test, vi } from 'vitest'; -import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; -import type { Interactor } from '@agent-device/contracts/interactor-types'; -import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtime.ts'; -import { IOS_SIMULATOR } from '@agent-device/test-kit/device-fixtures'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { createDaemonProviderRuntimeComposition } from '../../../provider-device-runtimes.ts'; -import { providerCredentialFingerprint } from '../../../provider-credential-fingerprint.ts'; -import { - DAEMON_STARTUP_EXIT_CODES, - tryAcquireDaemonRegistration, -} from '@agent-device/daemon-contracts/daemon-registration-owner'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { interactorResolution } from '@agent-device/daemon/testing/interactor-resolution'; - -vi.mock('../../../platform-runtime.ts', () => ({ - androidObservation: {}, - createRequestPlatformProviders: () => ({ - run: async (_context: unknown, task: () => Promise) => await task(), - }), - createPlatformRuntimeGateway: () => ({ - applicationLifecycle: { - recoverStartupResources: async () => {}, - detachForDaemonShutdown: async () => {}, - finalizeDaemonShutdown: async () => {}, - }, - inspectFacts: async () => { - throw new Error('unused'); - }, - bind: async () => { - throw new Error('unused'); - }, - shutdown: async () => {}, - }), - createPlatformDeviceInventoryGateways: () => ({}), -})); - -vi.mock('../../../provider-device-runtimes.ts', () => ({ - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDaemonProviderRuntimeComposition: vi.fn(async () => ({ - runtimes: [], - platformModules: [], - })), -})); - -import { startDaemonRuntimeFixture } from '../daemon-runtime-fixture.ts'; - -afterEach(() => { - setActiveProviderDeviceRuntimes([]); - vi.restoreAllMocks(); -}); - -/** - * The harnesses that boot their own request handler compose the seam themselves, so this is the - * only check that the process root actually installs it. - */ -test('daemon startup composes the interactor resolution the daemon resolves through', async () => { - const stateDir = mkdtempForTestSync('agent-device-daemon-interactor-composition-'); - const providerInteractor = { snapshot: async () => ({ nodes: [] }) } as unknown as Interactor; - try { - const runtime = await startDaemonRuntimeFixture({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, - exit: () => {}, - registerProcessHandlers: false, - stderr: { write: () => {} }, - stdout: { write: () => {} }, - }); - expect(runtime).not.toBeNull(); - - setActiveProviderDeviceRuntimes([ - { - provider: 'fixture', - ownsDevice: () => true, - getInteractor: () => providerInteractor, - } as unknown as ProviderDeviceRuntime, - ]); - await expect(interactorResolution().resolve(IOS_SIMULATOR, {})).resolves.toBe( - providerInteractor, - ); - - await runtime?.shutdown(); - } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -test('daemon startup compares lease credentials with its own startup environment', async () => { - const stateDir = mkdtempForTestSync('agent-device-daemon-credential-composition-'); - const daemonEnv = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; - const runtime = await startDaemonRuntimeFixture({ - env: { - ...process.env, - ...daemonEnv, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, - exit: () => {}, - registerProcessHandlers: false, - stderr: { write: () => {} }, - stdout: { write: () => {} }, - }); - try { - const { httpPort, token } = JSON.parse( - fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8'), - ) as { httpPort: number; token: string }; - const allocate = async (env: Record) => - await ( - await fetch(`http://127.0.0.1:${httpPort}/rpc`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'agent_device.lease.allocate', - params: { - token, - tenantId: 'tenant', - runId: 'run', - leaseProvider: 'browserstack', - providerCredentialFingerprint: providerCredentialFingerprint('browserstack', env), - }, - }), - }) - ).text(); - - expect(await allocate({ ...daemonEnv, BROWSERSTACK_ACCESS_KEY: 'key-2' })).toContain( - 'provider-credentials-changed', - ); - expect(await allocate(daemonEnv)).not.toContain('provider-credentials-changed'); - } finally { - await runtime?.shutdown(); - } -}); - -test('a daemon attempt losing the lock shuts down every constructed provider', async () => { - const stateDir = mkdtempForTestSync('daemon-held-lock-'); - const held = await tryAcquireDaemonRegistration(resolveDaemonPaths(stateDir)); - if (held.status !== 'acquired') throw new Error('registration fixture refused'); - const shutdown = vi.fn(() => { - throw new Error('cleanup failed'); - }); - const otherShutdown = vi.fn(async () => {}); - vi.mocked(createDaemonProviderRuntimeComposition).mockResolvedValueOnce({ - runtimes: [shutdown, otherShutdown].map( - (stop, index) => - ({ - provider: `fixture-${index}`, - shutdown: stop, - leaseLifecycle: {}, - }) as unknown as ProviderDeviceRuntime, - ), - platformModules: [], - }); - const exit = vi.fn(); - const runtime = await startDaemonRuntimeFixture({ - env: { AGENT_DEVICE_STATE_DIR: stateDir }, - exit, - registerProcessHandlers: false, - stderr: { write: () => {} }, - stdout: { write: () => {} }, - }); - expect(runtime).toBeNull(); - expect(shutdown).toHaveBeenCalledOnce(); - expect(otherShutdown).toHaveBeenCalledOnce(); - expect(exit).toHaveBeenCalledWith(DAEMON_STARTUP_EXIT_CODES.busy); - await held.owner.finish(); -}); diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-web-close-teardown.test.ts b/src/__tests__/daemon-composition/server/daemon-runtime-web-close-teardown.test.ts deleted file mode 100644 index b8894e1c06..0000000000 --- a/src/__tests__/daemon-composition/server/daemon-runtime-web-close-teardown.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; - -vi.mock('@agent-device/host-kit/command', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, runCmd: vi.fn() }; -}); - -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { installFakeManagedAgentBrowser } from '@agent-device/test-kit/web-managed-agent-browser'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; -import { runCmd } from '@agent-device/host-kit/command'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { makeWebSession } from '@agent-device/daemon/testing/__tests__/session-teardown.fixtures'; -import { teardownDaemonSessionForShutdown } from '@agent-device/daemon/testing/server/daemon-runtime'; - -const mockRunCmd = vi.mocked(runCmd); - -afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); -}); - -// The agent-browser CLI call this step makes has its own 30s internal timeout -// (AGENT_BROWSER_TIMEOUT_MS in agent-browser-provider.ts), well past the 5s base teardown -// budget every other resource shares; without the extended budget above, a close that takes -// longer than 5s (closing ~15 Chrome processes under load is plausible) would be abandoned by -// the daemon's own race before agent-browser ever got to finish it. -test('daemon shutdown awaits a slow web close inside its extended budget', async () => { - vi.useFakeTimers(); - const root = mkdtempForTestSync('agent-device-shutdown-web-close-slow-'); - await installFakeManagedAgentBrowser(root); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const session = makeWebSession('shutdown-slow-web-session'); - sessionStore.publish(session.name, session); - mockRunCmd.mockImplementation( - async () => - await new Promise((resolve) => { - setTimeout( - () => - resolve({ - stdout: JSON.stringify({ success: true, data: {} }), - stderr: '', - exitCode: 0, - }), - 20_000, - ); - }), - ); - const stderrChunks: string[] = []; - - const teardown = teardownDaemonSessionForShutdown({ - ref: sessionStore.lookup(session.name)!, - sessionStore, - stderr: { write: (chunk) => stderrChunks.push(chunk) }, - platformCleanup: platformResourceCleanup, - }); - await vi.dynamicImportSettled(); - await vi.advanceTimersByTimeAsync(20_000); - await teardown; - - expect(stderrChunks.join('')).not.toMatch(/timed out/); - expect(sessionStore.get(session.name)).toBeUndefined(); -}); - -// #1868: SIGTERM (or any other daemon-shutdown teardown) must tell agent-browser to close its -// fleet right away instead of leaving the ~15 Chrome processes for agent-browser's own multi- -// minute idle timer. This is the daemon-shutdown-entry-point counterpart to -// daemon-runtime-recording-teardown.test.ts's coverage of the #1325 recording step. -test('daemon shutdown closes an open web session immediately, without waiting for close', async () => { - const root = mkdtempForTestSync('agent-device-shutdown-web-close-'); - await installFakeManagedAgentBrowser(root); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const session = makeWebSession('shutdown-web-session'); - // Teardown runs while the session it is tearing down is still in the store (session deletion - // happens after), which is also what keeps agent-browser's provider-startup orphan sweep from - // treating this session's own browser as an orphan and scanning real host processes for it. - sessionStore.publish(session.name, session); - mockRunCmd.mockResolvedValue({ - stdout: JSON.stringify({ success: true, data: {} }), - stderr: '', - exitCode: 0, - }); - const stderrChunks: string[] = []; - - await teardownDaemonSessionForShutdown({ - ref: sessionStore.lookup(session.name)!, - sessionStore, - stderr: { write: (chunk) => stderrChunks.push(chunk) }, - platformCleanup: platformResourceCleanup, - }); - - expect(stderrChunks.join('')).toBe(''); - expect(sessionStore.get(session.name)).toBeUndefined(); - const closeCall = mockRunCmd.mock.calls.find(([, args]) => (args as string[]).includes('close')); - // `node close ...`: the managed backend never runs through its `.bin` shim (#2022). - expect(closeCall?.[0]).toBe(process.execPath); - expect((closeCall?.[1] as string[] | undefined)?.slice(1)).toEqual([ - 'close', - '--json', - '--session', - session.name, - ]); -}); - -test('daemon shutdown reports a web close failure on stderr instead of losing it silently', async () => { - const root = mkdtempForTestSync('agent-device-shutdown-web-close-failure-'); - await installFakeManagedAgentBrowser(root); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const session = makeWebSession('shutdown-web-close-failure-session'); - sessionStore.publish(session.name, session); - mockRunCmd.mockResolvedValue({ - stdout: JSON.stringify({ success: false, error: 'no active browser session' }), - stderr: '', - exitCode: 1, - }); - const stderrChunks: string[] = []; - - await teardownDaemonSessionForShutdown({ - ref: sessionStore.lookup(session.name)!, - sessionStore, - stderr: { write: (chunk) => stderrChunks.push(chunk) }, - platformCleanup: platformResourceCleanup, - }); - - // Best-effort: the failure is surfaced, but it never blocks the session from being torn down. - expect(stderrChunks.join('')).toMatch(/web_browser/); - expect(sessionStore.get(session.name)).toBeUndefined(); -}); - -// The platform filter lives in the composed adapter, not in the neutral teardown: the daemon -// asks the port for every session, and only a web device reaches the agent-browser process. -test('daemon session teardown never dispatches a web close for a non-web session', async () => { - const root = mkdtempForTestSync('agent-device-non-web-teardown-'); - // With the fake installed, a close the adapter should never ask for reaches the - // agent-browser process seam (mockRunCmd) and fails below instead of passing silently. - await installFakeManagedAgentBrowser(root); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.publish('android-non-web-teardown-session', { - name: 'android-non-web-teardown-session', - device: { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }, - createdAt: Date.now(), - actions: [], - }); - const stderrChunks: string[] = []; - - await teardownDaemonSessionForShutdown({ - ref: sessionStore.lookup('android-non-web-teardown-session')!, - sessionStore, - stderr: { write: (chunk) => stderrChunks.push(chunk) }, - platformCleanup: platformResourceCleanup, - }); - - // The snapshot-helper leg is a real-mechanics no-op for a device with no registered helper - // session; a mistaken web close would surface on the process seam and on stderr below. - expect(mockRunCmd).not.toHaveBeenCalled(); - expect(stderrChunks.join('')).toBe(''); -}); diff --git a/src/__tests__/daemon-composition/server/daemon-runtime-xctest-device-set.test.ts b/src/__tests__/daemon-composition/server/daemon-runtime-xctest-device-set.test.ts deleted file mode 100644 index 9fb15c389e..0000000000 --- a/src/__tests__/daemon-composition/server/daemon-runtime-xctest-device-set.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { withMockedPlatform } from '@agent-device/test-kit/host-execution'; - -const legacyRedirect = vi.hoisted(() => ({ - xctestDeviceSetPath: '', - infoPublishedAtRestore: [] as boolean[], - infoPath: '', -})); - -vi.mock('@agent-device/platform-apple/runner-owner', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - restoreLegacyXctestDeviceSetRedirect: async ( - onDiagnostic: Parameters[0], - ) => { - legacyRedirect.infoPublishedAtRestore.push(fs.existsSync(legacyRedirect.infoPath)); - await actual.restoreLegacyXctestDeviceSetRedirect( - onDiagnostic, - legacyRedirect.xctestDeviceSetPath, - ); - }, - }; -}); - -vi.mock('../../../platform-runtime.ts', () => ({ - androidObservation: {}, - createRequestPlatformProviders: () => ({ - run: async (_context: unknown, task: () => Promise) => await task(), - }), - createPlatformRuntimeGateway: () => ({ - applicationLifecycle: { - recoverStartupResources: async () => {}, - detachForDaemonShutdown: async () => {}, - finalizeDaemonShutdown: async () => {}, - }, - inspectFacts: async () => { - throw new Error('unused'); - }, - bind: async () => { - throw new Error('unused'); - }, - shutdown: async () => {}, - }), - createPlatformDeviceInventoryGateways: () => ({}), -})); - -vi.mock('../../../provider-device-runtimes.ts', () => ({ - DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), -})); - -import { startDaemonRuntimeFixture } from '../daemon-runtime-fixture.ts'; - -const roots: string[] = []; - -afterEach(() => { - legacyRedirect.infoPublishedAtRestore.length = 0; - for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); -}); - -function loggedEvents(logPath: string): Array<{ phase: string; data?: Record }> { - return fs - .readFileSync(logPath, 'utf8') - .trim() - .split('\n') - .map((line) => JSON.parse(line) as { phase: string; data?: Record }) - .filter((event) => event.phase.startsWith('ios_runner_legacy_xctest_device_set_')); -} - -function startDarwinDaemon(stateDir: string) { - return withMockedPlatform('darwin', () => - startDaemonRuntimeFixture({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, - exit: () => {}, - registerProcessHandlers: false, - stderr: { write: () => {} }, - stdout: { write: () => {} }, - }), - ); -} - -function makeLegacyRedirect(): { stateDir: string; developer: string; scopedSetPath: string } { - const root = mkdtempForTestSync('agent-device-daemon-xctest-device-set-'); - roots.push(root); - const stateDir = path.join(root, 'state'); - const developer = path.join(root, 'Library', 'Developer'); - const scopedSetPath = path.join(root, 'tenant-set'); - fs.mkdirSync(path.join(scopedSetPath, 'SCOPED-UDID'), { recursive: true }); - fs.mkdirSync(developer, { recursive: true }); - legacyRedirect.xctestDeviceSetPath = path.join(developer, 'XCTestDevices'); - legacyRedirect.infoPath = path.join(stateDir, 'daemon.json'); - fs.symlinkSync(scopedSetPath, legacyRedirect.xctestDeviceSetPath, 'dir'); - return { stateDir, developer, scopedSetPath }; -} - -test('macOS daemon startup puts back a redirected XCTestDevices before it publishes readiness', async () => { - const { stateDir, developer, scopedSetPath } = makeLegacyRedirect(); - fs.mkdirSync(path.join(developer, 'XCTestDevices.agent-device-backup', 'HOST-UDID'), { - recursive: true, - }); - - const runtime = await startDarwinDaemon(stateDir); - try { - expect(runtime).not.toBeNull(); - expect(legacyRedirect.infoPublishedAtRestore).toEqual([false]); - expect(fs.lstatSync(legacyRedirect.xctestDeviceSetPath).isSymbolicLink()).toBe(false); - expect(fs.existsSync(path.join(legacyRedirect.xctestDeviceSetPath, 'HOST-UDID'))).toBe(true); - expect(fs.existsSync(path.join(scopedSetPath, 'SCOPED-UDID'))).toBe(true); - expect(loggedEvents(path.join(stateDir, 'daemon.log'))).toEqual([ - expect.objectContaining({ - phase: 'ios_runner_legacy_xctest_device_set_link_removed', - data: expect.objectContaining({ - resourcePath: legacyRedirect.xctestDeviceSetPath, - linkTarget: scopedSetPath, - }), - }), - expect.objectContaining({ phase: 'ios_runner_legacy_xctest_device_set_backup_restored' }), - ]); - } finally { - await runtime?.shutdown(); - } -}); - -test.skipIf(process.getuid?.() === 0)( - 'a restore the host refuses is logged and the daemon still starts', - async () => { - const { stateDir, developer } = makeLegacyRedirect(); - fs.chmodSync(developer, 0o555); - - try { - const runtime = await startDarwinDaemon(stateDir); - try { - expect(runtime).not.toBeNull(); - expect(fs.existsSync(legacyRedirect.infoPath)).toBe(true); - expect(fs.lstatSync(legacyRedirect.xctestDeviceSetPath).isSymbolicLink()).toBe(true); - expect(loggedEvents(path.join(stateDir, 'daemon.log'))).toEqual([ - expect.objectContaining({ - phase: 'ios_runner_legacy_xctest_device_set_restore_failed', - data: expect.objectContaining({ resourcePath: legacyRedirect.xctestDeviceSetPath }), - }), - ]); - } finally { - await runtime?.shutdown(); - } - } finally { - fs.chmodSync(developer, 0o755); - } - }, -); diff --git a/src/daemon-composition-credentials.test.ts b/src/daemon-composition-credentials.test.ts new file mode 100644 index 0000000000..af74299a5b --- /dev/null +++ b/src/daemon-composition-credentials.test.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import { expect, test, vi } from 'vitest'; +import { startDaemonRuntime } from '@agent-device/daemon'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { createDaemonRuntimeDeps } from './daemon-composition.ts'; +import { providerCredentialFingerprint } from './provider-credential-fingerprint.ts'; + +vi.mock('./platform-runtime.ts', () => ({ + androidObservation: {}, + createRequestPlatformProviders: () => ({ + run: async (_context: unknown, task: () => Promise) => await task(), + }), + createPlatformRuntimeGateway: () => ({ + applicationLifecycle: { + recoverStartupResources: async () => {}, + detachForDaemonShutdown: async () => {}, + finalizeDaemonShutdown: async () => {}, + }, + inspectFacts: async () => { + throw new Error('unused'); + }, + bind: async () => { + throw new Error('unused'); + }, + shutdown: async () => {}, + }), + createPlatformDeviceInventoryGateways: () => ({}), +})); + +vi.mock('./provider-device-runtimes.ts', () => ({ + DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], + createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), +})); + +test('daemon startup compares lease credentials with its own startup environment', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-credential-composition-'); + const daemonEnv = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; + const runtime = await startDaemonRuntime({ + env: { + ...process.env, + ...daemonEnv, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps: createDaemonRuntimeDeps, + }); + try { + const { httpPort, token } = JSON.parse( + fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8'), + ) as { httpPort: number; token: string }; + const allocate = async (env: Record) => + await ( + await fetch(`http://127.0.0.1:${httpPort}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'agent_device.lease.allocate', + params: { + token, + tenantId: 'tenant', + runId: 'run', + leaseProvider: 'browserstack', + providerCredentialFingerprint: providerCredentialFingerprint('browserstack', env), + }, + }), + }) + ).text(); + + expect(await allocate({ ...daemonEnv, BROWSERSTACK_ACCESS_KEY: 'key-2' })).toContain( + 'provider-credentials-changed', + ); + expect(await allocate(daemonEnv)).not.toContain('provider-credentials-changed'); + } finally { + await runtime?.shutdown(); + } +}); diff --git a/src/__tests__/daemon-composition-web-cleanup.test.ts b/src/daemon-composition-web-cleanup.test.ts similarity index 71% rename from src/__tests__/daemon-composition-web-cleanup.test.ts rename to src/daemon-composition-web-cleanup.test.ts index cf0e8955c9..6d06925d78 100644 --- a/src/__tests__/daemon-composition-web-cleanup.test.ts +++ b/src/daemon-composition-web-cleanup.test.ts @@ -1,6 +1,5 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import path from 'node:path'; import { beforeEach, test, vi } from 'vitest'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { installFakeManagedAgentBrowser } from '@agent-device/test-kit/web-managed-agent-browser'; @@ -17,10 +16,7 @@ vi.mock('@agent-device/platform-web', async (importOriginal) => { }; }); -import { WEB_DESKTOP_DEVICE } from '@agent-device/test-kit/device-fixtures'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { openWebSessionNames } from '@agent-device/daemon/testing/web-session-names'; -import { cleanupWebBrowserOrphansForDaemonStartup } from '../daemon-composition.ts'; +import { cleanupWebBrowserOrphansForDaemonStartup } from './daemon-composition.ts'; const mockCleanupManagedAgentBrowserOrphans = vi.mocked(cleanupManagedAgentBrowserOrphansMock); @@ -32,17 +28,9 @@ test('daemon-startup web cleanup passes open web sessions to the reaper', async const stateDir = mkdtempForTestSync('agent-device-web-daemon-cleanup-'); try { await installFakeManagedAgentBrowser(stateDir); - const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); - sessionStore.publish('web-session', { - name: 'web-session', - device: WEB_DESKTOP_DEVICE, - createdAt: Date.now(), - actions: [], - }); - await cleanupWebBrowserOrphansForDaemonStartup({ stateDir, - openWebSessionNames: openWebSessionNames(sessionStore), + openWebSessionNames: ['web-session'], }); assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls.length, 1); @@ -58,11 +46,9 @@ test('daemon-startup web cleanup passes open web sessions to the reaper', async test('daemon-startup web cleanup does not run when the managed backend is absent', async () => { const stateDir = mkdtempForTestSync('agent-device-web-daemon-cleanup-'); try { - const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); - await cleanupWebBrowserOrphansForDaemonStartup({ stateDir, - openWebSessionNames: openWebSessionNames(sessionStore), + openWebSessionNames: [], }); assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls.length, 0); diff --git a/src/daemon-composition.test.ts b/src/daemon-composition.test.ts new file mode 100644 index 0000000000..87e350e18e --- /dev/null +++ b/src/daemon-composition.test.ts @@ -0,0 +1,66 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import { IOS_SIMULATOR } from '@agent-device/test-kit/device-fixtures'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { createDaemonRuntimeDeps } from './daemon-composition.ts'; +import { setActiveProviderDeviceRuntimes } from './provider-device-runtime.ts'; + +vi.mock('./platform-runtime.ts', () => ({ + androidObservation: {}, + createRequestPlatformProviders: () => ({ + run: async (_context: unknown, task: () => Promise) => await task(), + }), + createPlatformRuntimeGateway: () => ({ + applicationLifecycle: { + recoverStartupResources: async () => {}, + detachForDaemonShutdown: async () => {}, + finalizeDaemonShutdown: async () => {}, + }, + inspectFacts: async () => { + throw new Error('unused'); + }, + bind: async () => { + throw new Error('unused'); + }, + shutdown: async () => {}, + }), + createPlatformDeviceInventoryGateways: () => ({}), +})); + +vi.mock('./provider-device-runtimes.ts', () => ({ + DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], + createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), +})); + +afterEach(() => { + setActiveProviderDeviceRuntimes([]); +}); + +test('the composed interactor resolution resolves the interactor of the provider that owns the device', async () => { + const baseDir = mkdtempForTestSync('agent-device-daemon-composition-interactor-'); + const providerInteractor = { snapshot: async () => ({ nodes: [] }) } as unknown as Interactor; + const deps = await createDaemonRuntimeDeps({ + env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir }, + baseDir, + sessionsDir: `${baseDir}/sessions`, + assertShutdownAllowed: undefined, + resolveSessionDir: (sessionId) => `${baseDir}/sessions/${sessionId}`, + resolveSessionArtifacts: () => { + throw new Error('unused'); + }, + openWebSessionNames: () => [], + }); + + setActiveProviderDeviceRuntimes([ + { + provider: 'fixture', + ownsDevice: () => true, + getInteractor: () => providerInteractor, + } as unknown as ProviderDeviceRuntime, + ]); + + await expect(deps.interactorResolution.resolve(IOS_SIMULATOR, {})).resolves.toBe( + providerInteractor, + ); +}); diff --git a/src/__tests__/daemon-entrypoint.test.ts b/src/daemon-entrypoint.test.ts similarity index 92% rename from src/__tests__/daemon-entrypoint.test.ts rename to src/daemon-entrypoint.test.ts index 326f5164e3..74528a9daf 100644 --- a/src/__tests__/daemon-entrypoint.test.ts +++ b/src/daemon-entrypoint.test.ts @@ -6,12 +6,8 @@ import http from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { startDaemonRuntimeFixture } from './daemon-composition/daemon-runtime-fixture.ts'; -import type { DaemonRuntimeController } from '@agent-device/daemon'; -import { - cleanupDownloadableArtifact, - trackDownloadableArtifact, -} from '@agent-device/daemon/testing/artifact-tracking'; +import { startDaemonRuntime, type DaemonRuntimeController } from '@agent-device/daemon'; +import { createDaemonRuntimeDeps } from './daemon-composition.ts'; import { runCmdBackground } from '@agent-device/host-kit/command'; import { createOwnedProcessRecordStore, @@ -79,24 +75,17 @@ function waitForStdoutLine( test('daemon runtime starts HTTP transport in-process and shuts down cleanly', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-runtime-'); const paths = resolveDaemonPaths(stateDir); - const artifactPath = path.join(stateDir, 'runtime-artifact.txt'); - fs.writeFileSync(artifactPath, 'runtime-artifact'); - const artifactId = trackDownloadableArtifact({ - artifactPath, - artifactType: 'runtime-artifact', - fileName: 'runtime-artifact.txt', - }); const stdout: string[] = []; const stderr: string[] = []; let exitCode: number | undefined; try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: createDaemonRuntimeDeps, env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_RETAIN_ARTIFACTS: '1', }, exit: (code) => { exitCode = code; @@ -119,26 +108,12 @@ test('daemon runtime starts HTTP transport in-process and shuts down cleanly', a assert.ok(fs.existsSync(paths.lockPath), 'daemon lock should be held while runtime is active'); await waitForHttpOk(`http://127.0.0.1:${runtime?.httpPort}/health`, 2_000); - const artifactResponse = await fetch( - `http://127.0.0.1:${runtime?.httpPort}/artifacts/${encodeURIComponent(artifactId)}`, - { - headers: { - authorization: `Bearer ${runtime?.token}`, - connection: 'close', - }, - }, - ); - assert.equal(artifactResponse.status, 200); - assert.equal(await artifactResponse.text(), 'runtime-artifact'); - assert.equal(fs.existsSync(artifactPath), true); - await runtime?.shutdown(); assert.equal(exitCode, 0); assert.equal(fs.existsSync(paths.infoPath), false); assert.equal(fs.existsSync(paths.lockPath), false); } finally { - cleanupDownloadableArtifact(artifactId); fs.rmSync(stateDir, { recursive: true, force: true }); } }); @@ -150,7 +125,8 @@ test('daemon runtime publishes dual transport metadata', async () => { let exitCode: number | undefined; try { - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: createDaemonRuntimeDeps, env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -200,7 +176,8 @@ test('daemon rejects unowned cloud artifacts over RPC', async () => { let runtime: DaemonRuntimeController | null = null; try { - runtime = await startDaemonRuntimeFixture({ + runtime = await startDaemonRuntime({ + composeDeps: createDaemonRuntimeDeps, env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -330,7 +307,8 @@ test('daemon runtime records startup device-claim reconciliation in daemon.log', }), ); - const runtime = await startDaemonRuntimeFixture({ + const runtime = await startDaemonRuntime({ + composeDeps: createDaemonRuntimeDeps, env: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir, @@ -455,7 +433,8 @@ test('startup sweep settles a foreign dead owner without touching a live same-na }), ); - runtime = await startDaemonRuntimeFixture({ + runtime = await startDaemonRuntime({ + composeDeps: createDaemonRuntimeDeps, env: { ...process.env, AGENT_DEVICE_STATE_DIR: daemonStateDir, diff --git a/src/platform-runtime-daemon-lifecycle.test.ts b/src/platform-runtime-daemon-lifecycle.test.ts new file mode 100644 index 0000000000..d979d2daf5 --- /dev/null +++ b/src/platform-runtime-daemon-lifecycle.test.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { withMockedPlatform } from '@agent-device/test-kit/host-execution'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; + +const legacyRedirect = vi.hoisted(() => ({ xctestDeviceSetPath: '' })); + +vi.mock('@agent-device/platform-apple/runner-owner', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + restoreLegacyXctestDeviceSetRedirect: async ( + onDiagnostic: Parameters[0], + ) => + await actual.restoreLegacyXctestDeviceSetRedirect( + onDiagnostic, + legacyRedirect.xctestDeviceSetPath, + ), + }; +}); + +import { platformDaemonLifecycleOwners } from './platform-runtime-daemon-lifecycle.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await platformDaemonLifecycleOwners.clearDaemonLockConfiguration(); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function makeLegacyRedirect(): { stateDir: string; developer: string; scopedSetPath: string } { + const root = mkdtempForTestSync('agent-device-owner-lifecycle-xctest-device-set-'); + roots.push(root); + const stateDir = path.join(root, 'state'); + const developer = path.join(root, 'Library', 'Developer'); + const scopedSetPath = path.join(root, 'tenant-set'); + fs.mkdirSync(path.join(scopedSetPath, 'SCOPED-UDID'), { recursive: true }); + fs.mkdirSync(developer, { recursive: true }); + legacyRedirect.xctestDeviceSetPath = path.join(developer, 'XCTestDevices'); + fs.symlinkSync(scopedSetPath, legacyRedirect.xctestDeviceSetPath, 'dir'); + return { stateDir, developer, scopedSetPath }; +} + +async function configureOnDarwin(stateDir: string): Promise { + const diagnostics: ResourceDiagnostic[] = []; + await withMockedPlatform('darwin', () => + platformDaemonLifecycleOwners.configureForDaemonLock({ + stateDir, + hasDeviceClaimAuthority: () => false, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }), + ); + return diagnostics; +} + +test('configuring for the daemon lock on macOS puts back a redirected XCTestDevices', async () => { + const { stateDir, developer, scopedSetPath } = makeLegacyRedirect(); + fs.mkdirSync(path.join(developer, 'XCTestDevices.agent-device-backup', 'HOST-UDID'), { + recursive: true, + }); + + const diagnostics = await configureOnDarwin(stateDir); + + expect(fs.lstatSync(legacyRedirect.xctestDeviceSetPath).isSymbolicLink()).toBe(false); + expect(fs.existsSync(path.join(legacyRedirect.xctestDeviceSetPath, 'HOST-UDID'))).toBe(true); + expect(fs.existsSync(path.join(scopedSetPath, 'SCOPED-UDID'))).toBe(true); + expect(diagnostics).toEqual([ + expect.objectContaining({ + phase: 'ios_runner_legacy_xctest_device_set_link_removed', + resourcePath: legacyRedirect.xctestDeviceSetPath, + data: expect.objectContaining({ linkTarget: scopedSetPath }), + }), + expect.objectContaining({ phase: 'ios_runner_legacy_xctest_device_set_backup_restored' }), + ]); +}); + +test.skipIf(process.getuid?.() === 0)( + 'a restore the host refuses is reported as a diagnostic and does not fail configuration', + async () => { + const { stateDir, developer } = makeLegacyRedirect(); + fs.chmodSync(developer, 0o555); + + try { + const diagnostics = await configureOnDarwin(stateDir); + + expect(fs.lstatSync(legacyRedirect.xctestDeviceSetPath).isSymbolicLink()).toBe(true); + expect(diagnostics).toEqual([ + expect.objectContaining({ + phase: 'ios_runner_legacy_xctest_device_set_restore_failed', + resourcePath: legacyRedirect.xctestDeviceSetPath, + }), + ]); + } finally { + fs.chmodSync(developer, 0o755); + } + }, +); diff --git a/src/platform-runtime-resource-cleanup.test.ts b/src/platform-runtime-resource-cleanup.test.ts new file mode 100644 index 0000000000..2f8891f6b6 --- /dev/null +++ b/src/platform-runtime-resource-cleanup.test.ts @@ -0,0 +1,66 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn() }; +}); + +import { runCmd } from '@agent-device/host-kit/command'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { ANDROID_EMULATOR, WEB_DESKTOP_DEVICE } from '@agent-device/test-kit/device-fixtures'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { installFakeManagedAgentBrowser } from '@agent-device/test-kit/web-managed-agent-browser'; +import { platformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; + +const mockRunCmd = vi.mocked(runCmd); + +afterEach(() => { + vi.clearAllMocks(); +}); + +async function closeBrowser(device: DeviceInfo, sessionName: string): Promise { + const stateDir = mkdtempForTestSync('agent-device-resource-cleanup-web-close-'); + await installFakeManagedAgentBrowser(stateDir); + await platformResourceCleanup.closeManagedBrowser({ + device, + sessionName, + stateDir, + openSessionNames: () => [sessionName], + }); +} + +test('closing a web session tells agent-browser to close right away', async () => { + mockRunCmd.mockResolvedValue({ + stdout: JSON.stringify({ success: true, data: {} }), + stderr: '', + exitCode: 0, + }); + + await closeBrowser(WEB_DESKTOP_DEVICE, 'web-session'); + + const closeCall = mockRunCmd.mock.calls.find(([, args]) => (args as string[]).includes('close')); + // `node close ...`: the managed backend never runs through its `.bin` shim (#2022). + expect(closeCall?.[0]).toBe(process.execPath); + expect((closeCall?.[1] as string[] | undefined)?.slice(1)).toEqual([ + 'close', + '--json', + '--session', + 'web-session', + ]); +}); + +test('a refused web close rejects so the caller can report it', async () => { + mockRunCmd.mockResolvedValue({ + stdout: JSON.stringify({ success: false, error: 'no active browser session' }), + stderr: '', + exitCode: 1, + }); + + await expect(closeBrowser(WEB_DESKTOP_DEVICE, 'web-session')).rejects.toThrow(); +}); + +test('closing a non-web session never reaches the agent-browser process', async () => { + await closeBrowser(ANDROID_EMULATOR, 'android-session'); + + expect(mockRunCmd).not.toHaveBeenCalled(); +});