diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 8914f37b98..cb05d40f66 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -108,10 +108,6 @@ "types": "./src/__tests__/support/command-surface-fake.ts", "default": "./src/__tests__/support/command-surface-fake.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" - }, "./testing/__tests__/support/screen-recording-live-handle": { "types": "./src/__tests__/support/screen-recording-live-handle.ts", "default": "./src/__tests__/support/screen-recording-live-handle.ts" @@ -192,18 +188,6 @@ "types": "./src/sessions/handlers/session-appstate.ts", "default": "./src/sessions/handlers/session-appstate.ts" }, - "./testing/handlers/session-doctor": { - "types": "./src/sessions/handlers/session-doctor.ts", - "default": "./src/sessions/handlers/session-doctor.ts" - }, - "./testing/handlers/session-doctor-app": { - "types": "./src/sessions/handlers/session-doctor-app.ts", - "default": "./src/sessions/handlers/session-doctor-app.ts" - }, - "./testing/handlers/session-doctor-metro": { - "types": "./src/sessions/handlers/session-doctor-metro.ts", - "default": "./src/sessions/handlers/session-doctor-metro.ts" - }, "./testing/handlers/session-replay-command": { "types": "./src/sessions/handlers/session-replay-command.ts", "default": "./src/sessions/handlers/session-replay-command.ts" @@ -348,10 +332,6 @@ "types": "./src/core/session-capture-binding.ts", "default": "./src/core/session-capture-binding.ts" }, - "./testing/session-lifecycle/index": { - "types": "./src/sessions/session-lifecycle/index.ts", - "default": "./src/sessions/session-lifecycle/index.ts" - }, "./testing/session-lifecycle/internal/__tests__/*": { "types": "./src/sessions/session-lifecycle/internal/__tests__/*.ts", "default": "./src/sessions/session-lifecycle/internal/__tests__/*.ts" @@ -384,10 +364,6 @@ "types": "./src/core/session-store.ts", "default": "./src/core/session-store.ts" }, - "./testing/snapshot-capture": { - "types": "./src/core/snapshot-capture.ts", - "default": "./src/core/snapshot-capture.ts" - }, "./testing/snapshot-diff-runtime": { "types": "./src/sessions/snapshot-diff-runtime.ts", "default": "./src/sessions/snapshot-diff-runtime.ts" @@ -404,10 +380,6 @@ "types": "./src/sessions/snapshot-runtime.ts", "default": "./src/sessions/snapshot-runtime.ts" }, - "./testing/snapshot-session": { - "types": "./src/core/snapshot-session.ts", - "default": "./src/core/snapshot-session.ts" - }, "./testing/wait-runtime": { "types": "./src/execution/wait-runtime.ts", "default": "./src/execution/wait-runtime.ts" diff --git a/packages/daemon/src/__tests__/support/provider-credentials-fixture.ts b/packages/daemon/src/__tests__/support/provider-credentials-fixture.ts index d3e7adae0e..773e8b85ce 100644 --- a/packages/daemon/src/__tests__/support/provider-credentials-fixture.ts +++ b/packages/daemon/src/__tests__/support/provider-credentials-fixture.ts @@ -7,7 +7,7 @@ import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/p * passthrough, never fingerprint content. So the fake builds a deterministic opaque key from * the sorted defined pairs rather than reproducing the real reader's digest format (the real * provider/manifest-aware selection and digest semantics are pinned root-owned in - * request-router-provider-credentials and lease-provider-credentials). Distinct environments + * request-router-provider-credentials and provider-credential-fingerprint). Distinct environments * produce distinct keys and an empty environment produces `undefined` — the whole contract the * daemon suites observe. */ diff --git a/packages/daemon/src/core/__tests__/snapshot-capture.test.ts b/packages/daemon/src/core/__tests__/snapshot-capture.test.ts index d45e5bb42e..b513a1faac 100644 --- a/packages/daemon/src/core/__tests__/snapshot-capture.test.ts +++ b/packages/daemon/src/core/__tests__/snapshot-capture.test.ts @@ -16,6 +16,7 @@ import { androidDevice, androidTextRows, inboxBaselineNodes, + inboxRow, makeAndroidFreshnessSession, } from '../../__tests__/support/handlers/snapshot-handler.fixtures.ts'; import { pendingSurfaceStabilization } from '../deferred-interaction-outcome.ts'; @@ -154,3 +155,69 @@ test('captureSnapshot composes post-gesture stabilization with Android freshness expect(session.androidSnapshotFreshness).toBeUndefined(); expect(pendingSurfaceStabilization(session)).toBeUndefined(); }); + +test('captureSnapshot retries a stale Android tree after recent navigation until the tree changes', async () => { + resetLegacySnapshotCapture(captureSnapshotWithInteractor); + const session = makeAndroidFreshnessSession( + 'android-stale-retry', + 'press', + inboxBaselineNodes(24), + ); + legacyDispatchCapture + .mockResolvedValueOnce( + androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), + ) + .mockResolvedValueOnce( + androidCapture( + [ + { index: 0, depth: 0, type: 'android.widget.TextView', label: 'Create document' }, + { index: 1, depth: 0, type: 'android.widget.Button', label: 'Submit', hittable: true }, + ], + { rawNodeCount: 2, maxDepth: 1 }, + ), + ); + + const result = await captureSnapshot({ + device: androidDevice, + session, + flags: { snapshotInteractiveOnly: true }, + logPath: '/tmp/daemon.log', + }); + + expect(result.snapshot.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Create document' })]), + ); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); + expect(session.androidSnapshotFreshness).toBeUndefined(); +}); + +test('Android ref refresh mode does not retry narrow snapshots as sharp drops', async () => { + resetLegacySnapshotCapture(captureSnapshotWithInteractor); + const session = makeAndroidFreshnessSession( + 'android-ref-refresh-no-sharp-drop', + 'press', + buildNodes(androidTextRows(50, (row) => `Previous row ${row}`)), + ); + legacyDispatchCapture.mockResolvedValue( + androidCapture( + Array.from({ length: 8 }, (_, index) => ({ + index, + depth: 0, + type: 'android.widget.TextView', + })), + { rawNodeCount: 8, maxDepth: 1 }, + ), + ); + + const result = await captureSnapshot({ + device: androidDevice, + session, + flags: { snapshotInteractiveOnly: true }, + logPath: '/tmp/daemon.log', + androidFreshnessMode: 'ref-refresh', + }); + + expect(result.freshness).toBeUndefined(); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(1); + expect(session.androidSnapshotFreshness).toBeUndefined(); +}); diff --git a/packages/daemon/src/core/snapshot-session.test.ts b/packages/daemon/src/core/snapshot-session.test.ts new file mode 100644 index 0000000000..3815bb7d4f --- /dev/null +++ b/packages/daemon/src/core/snapshot-session.test.ts @@ -0,0 +1,83 @@ +import { afterEach, expect, test } from 'vitest'; +import { IOS_SIMULATOR } from '@agent-device/test-kit/device-fixtures'; +import { withSessionlessRunnerCleanup } from './snapshot-session.ts'; +import { installProviderDeviceAdmission } from './provider-device-admission.ts'; +import { platformResourceCleanupFixture } from '../__tests__/support/platform-resource-cleanup-fixture.ts'; +import { makeSession } from '../__tests__/support/session-factories.ts'; + +const returnOk = async () => 'ok'; + +afterEach(() => { + installProviderDeviceAdmission({ isActive: () => false }); +}); + +test('sessionless cleanup releases the local execution host after the task', async () => { + const events: string[] = []; + const platformCleanup = platformResourceCleanupFixture({ + cleanupSessionlessExecutionHost: async () => { + events.push('cleanup'); + }, + }); + + const result = await withSessionlessRunnerCleanup( + undefined, + IOS_SIMULATOR, + async () => { + events.push('task'); + return 'ok'; + }, + platformCleanup, + ); + + expect(result).toBe('ok'); + expect(events).toEqual(['task', 'cleanup']); + expect(platformCleanup.cleanupSessionlessExecutionHost).toHaveBeenCalledWith(IOS_SIMULATOR); +}); + +test('sessionless cleanup runs when the task fails and keeps the failure', async () => { + const platformCleanup = platformResourceCleanupFixture(); + const failure = new Error('capture failed'); + + await expect( + withSessionlessRunnerCleanup( + undefined, + IOS_SIMULATOR, + async () => { + throw failure; + }, + platformCleanup, + ), + ).rejects.toBe(failure); + expect(platformCleanup.cleanupSessionlessExecutionHost).toHaveBeenCalledWith(IOS_SIMULATOR); +}); + +test('sessionless cleanup leaves a provider-owned device to its provider', async () => { + const device = { ...IOS_SIMULATOR, id: 'limrun:ios:lease-a' }; + installProviderDeviceAdmission({ isActive: (candidate) => candidate.id === device.id }); + const platformCleanup = platformResourceCleanupFixture(); + + const result = await withSessionlessRunnerCleanup(undefined, device, returnOk, platformCleanup); + + expect(result).toBe('ok'); + expect(platformCleanup.cleanupSessionlessExecutionHost).not.toHaveBeenCalled(); +}); + +test('a session keeps its execution host past the task', async () => { + const platformCleanup = platformResourceCleanupFixture(); + + const result = await withSessionlessRunnerCleanup( + makeSession('open-session', { device: IOS_SIMULATOR }), + IOS_SIMULATOR, + returnOk, + platformCleanup, + ); + + expect(result).toBe('ok'); + expect(platformCleanup.cleanupSessionlessExecutionHost).not.toHaveBeenCalled(); +}); + +test('sessionless cleanup refuses to run without an injected platform cleanup', async () => { + await expect(withSessionlessRunnerCleanup(undefined, IOS_SIMULATOR, returnOk)).rejects.toThrow( + 'Platform resource cleanup was not injected', + ); +}); diff --git a/packages/daemon/src/execution/wait-current-surface.test.ts b/packages/daemon/src/execution/wait-current-surface.test.ts index a92c8604cd..c2669822ca 100644 --- a/packages/daemon/src/execution/wait-current-surface.test.ts +++ b/packages/daemon/src/execution/wait-current-surface.test.ts @@ -17,7 +17,7 @@ const req = { * not on the `snapshot-capture` module — and "no capture started" is provable as "the binding * was never invoked". */ -function boundCapture(nodes: { index: number; depth: number; type: string; label?: string }[]) { +function boundCapture(nodes: readonly Record[]) { return vi.fn(async () => ({ nodes, backend: 'android' })) as unknown as BoundSelectorCapture & ReturnType; } @@ -97,6 +97,102 @@ test('an absent-target wait describes the current surface through its own reques }); }); +test('the surface summary prefers content labels over chrome and identifier noise', async () => { + const capture = boundCapture([ + { + index: 0, + depth: 0, + type: 'XCUIElementTypeApplication', + label: 'Expo Go', + rect: { x: 0, y: 0, width: 393, height: 852 }, + }, + { + index: 1, + depth: 1, + type: 'XCUIElementTypeImage', + label: 'gearshape.fill', + rect: { x: 12, y: 54, width: 24, height: 24 }, + }, + { + index: 2, + depth: 1, + type: 'XCUIElementTypeOther', + label: 'Tab Bar', + rect: { x: 0, y: 760, width: 393, height: 92 }, + }, + { + index: 3, + depth: 1, + type: 'XCUIElementTypeStaticText', + label: 'Confirm catalog refresh', + rect: { x: 48, y: 280, width: 297, height: 36 }, + }, + { + index: 4, + depth: 1, + type: 'XCUIElementTypeButton', + label: 'Keep browsing', + rect: { x: 48, y: 360, width: 297, height: 48 }, + }, + { + index: 5, + depth: 1, + type: 'XCUIElementTypeButton', + identifier: 'host.exp.exponent:id/reload_button', + rect: { x: 260, y: 54, width: 48, height: 48 }, + }, + ]); + + const result = await maybeWaitTimeoutSurfaceResponse( + { req, session: undefined, device: ANDROID_EMULATOR, capture }, + { + ok: false as const, + error: { + code: 'COMMAND_FAILED' as const, + message: 'wait timed out for text: Impossible success text', + details: { reason: 'wait_target_absent' }, + }, + }, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toBe( + 'wait timed out for text: Impossible success text. Current surface: Confirm catalog refresh, Keep browsing.', + ); + expect(result.error.details?.currentSurface).toEqual({ + labels: [ + 'Confirm catalog refresh', + 'Keep browsing', + 'host.exp.exponent:id/reload_button', + 'Expo Go', + 'gearshape.fill', + 'Tab Bar', + ], + buttons: ['Keep browsing', 'host.exp.exponent:id/reload_button'], + }); +}); + +test('a capture-stalled wait failure is returned untouched without inspecting the surface', async () => { + const capture = boundCapture([]); + const response = { + ok: false as const, + error: { + code: 'COMMAND_FAILED' as const, + message: 'wait timed out for text: Receipt uploaded', + details: { reason: 'wait_capture_stalled', retriable: true, readableCaptures: 0 }, + }, + }; + + const result = await maybeWaitTimeoutSurfaceResponse( + { req, session: undefined, device: ANDROID_EMULATOR, capture }, + response, + ); + + expect(result).toBe(response); + expect(capture).not.toHaveBeenCalled(); +}); + /** * The decoration capture is a real capture: when it is the command's own foreground repair, the * timed-out wait that reads this surface owes the disclosure, and the proof has to be recorded where diff --git a/src/__tests__/daemon-composition/handlers/wait-landmark-recording.test.ts b/packages/daemon/src/execution/wait-runtime-landmark.test.ts similarity index 53% rename from src/__tests__/daemon-composition/handlers/wait-landmark-recording.test.ts rename to packages/daemon/src/execution/wait-runtime-landmark.test.ts index 1129c476cc..947825260b 100644 --- a/src/__tests__/daemon-composition/handlers/wait-landmark-recording.test.ts +++ b/packages/daemon/src/execution/wait-runtime-landmark.test.ts @@ -1,76 +1,82 @@ /** * #1349 (ADR 0012 decision 3 extension): the wait dispatch seam. * - * Record time: a selector wait that succeeds while the session is recording - * attaches landmark-mode `target-v1` evidence to the recorded action, and the - * resolution payload (`node`/`preActionNodes`) reaches neither the public - * response nor session history. + * Record time: a selector wait that succeeds while the session is recording attaches + * landmark-mode `target-v1` evidence to the recorded action, and the resolution payload + * (`node`/`preActionNodes`) reaches neither the public response nor session history. * - * Replay time: `internal.replayLandmarkGuard` threads the recorded landmark - * into the real polling loop — success requires an identity-carrying match, - * and a timeout with only impostor matches surfaces the - * `WAIT_LANDMARK_MISMATCH_REASON` refusal. + * Replay time: `internal.replayLandmarkGuard` is handed to the wait as its recorded landmark, and + * the refusal the wait reports for impostor-only matches reaches the response with its details. + * The polling loop that applies the guard belongs to the command family + * (`runtime/wait-selector.test.ts`). */ -import { test, expect, vi, beforeEach } from 'vitest'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { dispatchWaitViaRuntime } from '@agent-device/daemon/testing/wait-runtime'; -import type { DaemonRequest } from '@agent-device/daemon'; +import { test, expect, beforeEach } from 'vitest'; +import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; import { WAIT_LANDMARK_MISMATCH_REASON } from '@agent-device/contracts/replay'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import { snapshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; +import { AppError } from '@agent-device/kernel/errors'; +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import { fakeCreateCommandSurface } from '../__tests__/support/command-surface-fake.ts'; +import { snapshotRuntimeFixture } from '../__tests__/support/snapshot-runtime-fixture.ts'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; +import { legacyDispatchCapture } from '../__tests__/support/legacy-snapshot-capture-fixture.ts'; import { makeAndroidSession, authoringPublication, -} from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { testCreateCommandSurface } from '../command-surface-fixture.ts'; +} from '../__tests__/support/session-factories.ts'; +import type { DaemonRequest } from '../core/daemon-request.ts'; +import { dispatchWaitViaRuntime } from './wait-runtime.ts'; -vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - resolveTargetDevice: vi.fn(actual.resolveTargetDevice), - }; -}); - -vi.mock('@agent-device/daemon/testing/snapshot-interactor-capture', async () => { - const fixture = - await import('@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'); - return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; -}); +type WaitOptions = Parameters< + Awaited>['selectors']['wait'] +>[0]; -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); +const waitCalls: WaitOptions[] = []; +let waitOutcome: () => Promise>; -function screenSnapshot(parentLabel: string) { +const waitCreateCommandSurface: CreateDaemonCommandSurface = async (config) => { + const surface = await fakeCreateCommandSurface(config); return { - backend: 'android', - nodes: [ - { - index: 0, - depth: 0, - type: 'FrameLayout', - label: parentLabel, - rect: { x: 0, y: 0, width: 390, height: 844 }, - }, - { - index: 1, - depth: 1, - parentIndex: 0, - type: 'TextView', - label: 'Screen X', - rect: { x: 0, y: 100, width: 390, height: 40 }, + ...surface, + selectors: { + ...surface.selectors, + wait: async (options) => { + waitCalls.push(options); + return (await waitOutcome()) as never; }, - ], + }, }; -} +}; + +const detailScreenNodes: SnapshotNode[] = [ + { + ref: 'e1', + index: 0, + depth: 0, + type: 'FrameLayout', + label: 'Detail Screen', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + ref: 'e2', + index: 1, + depth: 1, + parentIndex: 0, + type: 'TextView', + label: 'Screen X', + rect: { x: 0, y: 100, width: 390, height: 40 }, + }, +]; beforeEach(() => { + waitCalls.length = 0; legacyDispatchCapture.mockReset(); - legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { - return command === 'snapshot' ? screenSnapshot('Detail Screen') : {}; + waitOutcome = async () => ({ + kind: 'selector', + selector: 'label="Screen X"', + waitedMs: 0, + node: detailScreenNodes[1], + preActionNodes: detailScreenNodes, }); }); @@ -93,7 +99,7 @@ async function runWait(options: { recording?: boolean; req?: DaemonRequest } = { ); sessionStore.publish('default', session); const response = await dispatchWaitViaRuntime({ - createCommandSurface: testCreateCommandSurface, + createCommandSurface: waitCreateCommandSurface, req: options.req ?? waitReq(), sessionName: 'default', logPath: '/tmp/test.log', @@ -144,18 +150,35 @@ function recordedLandmark(): TargetAnnotationV1 { }; } -test('a replayed wait with a landmark guard succeeds when a match carries the recorded identity', async () => { +test('a replayed wait hands its recorded landmark to the selector wait', async () => { + const landmark = recordedLandmark(); + const { response } = await runWait({ - req: waitReq({ internal: { replayLandmarkGuard: recordedLandmark() } }), + req: waitReq({ internal: { replayLandmarkGuard: landmark } }), }); expect(response.ok).toBe(true); + expect(waitCalls).toHaveLength(1); + expect(waitCalls[0]?.target).toMatchObject({ + kind: 'selector', + selector: 'label="Screen X"', + recordedLandmark: landmark, + }); }); -test('a replayed wait with a landmark guard refuses at the deadline when only impostors matched', async () => { - legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { - return command === 'snapshot' ? screenSnapshot('List Screen') : {}; - }); +test('a live wait hands the selector wait no recorded landmark', async () => { + await runWait(); + + expect(waitCalls[0]?.target).not.toHaveProperty('recordedLandmark'); +}); + +test('a landmark refusal from the wait reaches the response with its details', async () => { + waitOutcome = async () => { + throw new AppError('COMMAND_FAILED', 'wait found only impostors of the recorded landmark', { + reason: WAIT_LANDMARK_MISMATCH_REASON, + matchCount: 1, + }); + }; const { response } = await runWait({ req: waitReq({ @@ -182,5 +205,6 @@ test('a selector-shaped positional list that fails to parse as a selector is rej if (response.ok) return; expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).toContain('label="Open"'); + expect(waitCalls).toEqual([]); expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts index 15217b5043..12d131bef3 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts @@ -305,6 +305,41 @@ test('a canceled attempt lets the next attempt of the same suite open the device ); }); +test('cancellation after local device setup retains the device claim for recovery', async () => { + const { store, stateDir } = setup(); + const requestId = 'claim-canceled-after-dispatch'; + mockResolveTargetDevice.mockResolvedValue(android); + mockDispatch.mockResolvedValue(undefined); + markRequestCanceled(requestId); + + try { + const response = await handleOpenCommand({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + command: 'open', + token: 'test', + session: 'claim-canceled-after-dispatch', + positionals: ['Demo'], + flags: { platform: 'android' }, + meta: { requestId }, + }, + sessionName: 'claim-canceled-after-dispatch', + logPath: path.join(stateDir, 'daemon.log'), + sessionStore: store, + }); + + assert.equal(response.ok, false); + assert.equal(mockDispatch.mock.calls.length, 1); + const retainedClaim = inspectDeviceClaims({ serial: android.id })[0]; + assert.equal(retainedClaim?.classification, 'live'); + assert.equal(typeof retainedClaim?.claim?.abandonedAtMs, 'number'); + assert.equal(store.get('claim-canceled-after-dispatch'), undefined); + } finally { + clearRequestCanceled(requestId); + } +}); + test('provider-owned open creates no host-local device claim from its selected owner', async () => { const { store, stateDir } = setup(); mockResolveTargetDevice.mockResolvedValue(android); diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-relaunch-guards.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-relaunch-guards.test.ts index ab16543ccd..75aa6df9d0 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-relaunch-guards.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-relaunch-guards.test.ts @@ -1,5 +1,5 @@ import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; -import { test, expect } from 'vitest'; +import { test, expect, vi } from 'vitest'; import * as path from 'node:path'; import { mockResolveTargetDevice, @@ -7,11 +7,13 @@ import { makeSessionStore, makeSession, noopInvoke, + assertInvalidArgsMessage, } from '../../../__tests__/support/handlers/session-test-harness.ts'; import { handleSessionCommands } from '../../../__tests__/support/handlers/session-command-harness.ts'; import { makeTestScreenRecordingResource } from '../../../__tests__/support/screen-recording-live-handle.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; test('open --relaunch fails without app when no session exists', async () => { const sessionStore = makeSessionStore(); @@ -144,3 +146,92 @@ test('open on device owned by recording session returns recording recovery hint' } expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); }); + +const RELAUNCH_REFUSAL = 'relaunch target refused by the platform'; + +async function openRelaunch(params: { + sessionStore: ReturnType; + validateOpenRelaunchTarget: DaemonPlatformServices['validateOpenRelaunchTarget']; + positionals: string[]; + flags: Record; +}) { + return await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture({ + validateOpenRelaunchTarget: params.validateOpenRelaunchTarget, + }), + req: { + token: 't', + session: 'default', + command: 'open', + positionals: params.positionals, + flags: { relaunch: true, ...params.flags }, + }, + sessionName: 'default', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore: params.sessionStore, + invoke: noopInvoke, + }); +} + +test('open --relaunch answers a platform relaunch refusal as INVALID_ARGS with its message', async () => { + const validateOpenRelaunchTarget = vi.fn(async () => RELAUNCH_REFUSAL); + + const response = await openRelaunch({ + sessionStore: makeSessionStore(), + validateOpenRelaunchTarget, + positionals: ['https://example.com/path'], + flags: {}, + }); + + assertInvalidArgsMessage(response, RELAUNCH_REFUSAL); + expect(validateOpenRelaunchTarget).toHaveBeenCalledWith({ + target: 'https://example.com/path', + platform: undefined, + }); +}); + +test('open --relaunch asks the platform about the requested target before resolving a new device', async () => { + const validateOpenRelaunchTarget = vi.fn(async () => RELAUNCH_REFUSAL); + + const response = await openRelaunch({ + sessionStore: makeSessionStore(), + validateOpenRelaunchTarget, + positionals: ['/tmp/app-debug.apk'], + flags: { platform: 'android' }, + }); + + assertInvalidArgsMessage(response, RELAUNCH_REFUSAL); + expect(validateOpenRelaunchTarget).toHaveBeenCalledWith({ + target: '/tmp/app-debug.apk', + platform: 'android', + }); + expect(mockResolveTargetDevice).not.toHaveBeenCalled(); +}); + +test('open --relaunch asks the platform about the active session device', async () => { + const sessionStore = makeSessionStore(); + const session = makeSession('default', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }); + session.appName = 'com.example.app'; + session.appBundleId = 'com.example.app'; + sessionStore.publish('default', session); + const validateOpenRelaunchTarget = vi.fn(async () => RELAUNCH_REFUSAL); + + const response = await openRelaunch({ + sessionStore, + validateOpenRelaunchTarget, + positionals: ['/tmp/app-debug.apk'], + flags: { platform: 'android' }, + }); + + assertInvalidArgsMessage(response, RELAUNCH_REFUSAL); + expect(validateOpenRelaunchTarget).toHaveBeenCalledWith( + expect.objectContaining({ target: '/tmp/app-debug.apk', platform: 'android' }), + ); +}); diff --git a/packages/daemon/src/sessions/handlers/lease-provider-credentials.test.ts b/packages/daemon/src/sessions/handlers/lease-provider-credentials.test.ts new file mode 100644 index 0000000000..14f129aa42 --- /dev/null +++ b/packages/daemon/src/sessions/handlers/lease-provider-credentials.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; +import type { AppError } from '@agent-device/kernel/errors'; +import { handleLeaseCommands } from './lease.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import type { DaemonRequest } from '../../core/daemon-request.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { providerCredentialsFixture } from '../../__tests__/support/provider-credentials-fixture.ts'; + +const STATE_DIR = '/tmp/agent device state'; +const PROVIDERS = ['limrun', 'browserstack', 'testmu'] as const; + +type LeaseBackend = 'ios-instance' | 'android-instance'; + +function allocateRequest( + leaseProvider: string, + providerCredentialFingerprint: string | undefined, + leaseBackend: LeaseBackend = 'ios-instance', +): DaemonRequest { + return { + token: 'test-token', + session: 'lease-ttl-test', + command: 'lease_allocate', + positionals: [], + flags: {}, + meta: { + tenantId: 'tenant-a', + runId: 'run-a', + clientId: 'client-a', + leaseBackend, + leaseProvider, + providerCredentialFingerprint, + }, + }; +} + +async function allocateWithDaemonCredentials( + req: DaemonRequest, + providerCredentials: DaemonProviderCredentials, +): Promise<{ allocations: number; error?: AppError }> { + const registry = new LeaseRegistry(); + let allocations = 0; + try { + await handleLeaseCommands({ + req, + sessionName: req.session, + sessionStore: makeSessionStore('agent-device-provider-credentials-'), + leaseRegistry: registry, + providerCredentials, + leaseLifecycleProvider: { + allocate: async () => { + allocations += 1; + return {}; + }, + }, + }); + } catch (error) { + assert.deepEqual(registry.listActiveLeases(), []); + return { allocations, error: error as AppError }; + } + return { allocations }; +} + +function shellFingerprint(env: Record): string | undefined { + return providerCredentialsFixture(env).fingerprint('any'); +} + +test.for(PROVIDERS)( + 'a daemon holding other %s credentials than the shell refuses before allocation', + async (provider) => { + const outcome = await allocateWithDaemonCredentials( + allocateRequest(provider, shellFingerprint({ KEY: 'key-2' })), + providerCredentialsFixture({ KEY: 'key-1' }, STATE_DIR), + ); + + assert.equal(outcome.allocations, 0); + assert.equal(outcome.error?.code, 'INVALID_ARGS'); + assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); + assert.equal(outcome.error?.details?.provider, provider); + assert.match( + String(outcome.error?.details?.hint), + /agent-device daemon stop --state-dir '\/tmp\/agent device state'/, + ); + }, +); + +test.for(PROVIDERS)( + 'a daemon holding the %s credentials of the shell allocates', + async (provider) => { + const env = { KEY: 'key-1' }; + const outcome = await allocateWithDaemonCredentials( + allocateRequest(provider, shellFingerprint(env)), + providerCredentialsFixture(env, STATE_DIR), + ); + + assert.equal(outcome.error, undefined); + assert.equal(outcome.allocations, 1); + }, +); + +test.for(PROVIDERS)( + 'a daemon holding %s credentials allocates for a shell with none', + async (provider) => { + const outcome = await allocateWithDaemonCredentials( + allocateRequest(provider, shellFingerprint({})), + providerCredentialsFixture({ KEY: 'key-1' }, STATE_DIR), + ); + + assert.equal(outcome.error, undefined); + assert.equal(outcome.allocations, 1); + }, +); + +test.for(PROVIDERS)( + 'a daemon started without %s credentials refuses a shell that has them', + async (provider) => { + const outcome = await allocateWithDaemonCredentials( + allocateRequest(provider, shellFingerprint({ KEY: 'key-1' })), + providerCredentialsFixture({}, STATE_DIR), + ); + + assert.equal(outcome.allocations, 0); + assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); + assert.match( + String(outcome.error?.message), + new RegExp(`started without the ${provider} credentials`), + ); + }, +); + +test('a lease compares the daemon fingerprint of the leased backend only', async () => { + const daemonFingerprints: Record = { + 'ios-instance': 'ios-digest', + 'android-instance': 'android-digest', + }; + const requested: Array<[string, string | undefined]> = []; + const providerCredentials: DaemonProviderCredentials = { + stateDir: STATE_DIR, + fingerprint: (provider, leaseBackend) => { + requested.push([provider, leaseBackend]); + return leaseBackend ? daemonFingerprints[leaseBackend] : undefined; + }, + }; + const allocate = async (leaseBackend: LeaseBackend, shellDigest: string) => + await allocateWithDaemonCredentials( + allocateRequest('limrun', shellDigest, leaseBackend), + providerCredentials, + ); + + assert.equal((await allocate('ios-instance', 'ios-digest')).error, undefined); + assert.equal( + (await allocate('android-instance', 'ios-digest')).error?.details?.reason, + 'provider-credentials-changed', + ); + assert.deepEqual(requested, [ + ['limrun', 'ios-instance'], + ['limrun', 'android-instance'], + ]); +}); diff --git a/packages/daemon/src/sessions/handlers/session-doctor-warmup.test.ts b/packages/daemon/src/sessions/handlers/session-doctor-warmup.test.ts new file mode 100644 index 0000000000..0757554ceb --- /dev/null +++ b/packages/daemon/src/sessions/handlers/session-doctor-warmup.test.ts @@ -0,0 +1,174 @@ +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DoctorCheck } from '@agent-device/contracts/observability'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { handleDoctorCommand } from './session-doctor.ts'; +import { hostDiagnosticsFixture } from '../../__tests__/support/host-diagnostics-fixture.ts'; +import { installProviderDeviceAdmission } from '../../core/provider-device-admission.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; + +vi.mock('./session-doctor-app.ts', () => ({ + appendAppChecks: vi.fn(async () => {}), +})); +vi.mock('./session-doctor-metro.ts', () => ({ + probeMetro: vi.fn(async () => ({ id: 'metro', status: 'pass', summary: 'mocked' })), +})); + +const IOS_SIMULATOR: DeviceInfo = { + platform: 'apple', + id: 'doctor-sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + target: 'mobile', + booted: true, +}; + +const warmupCheck = vi.fn['warmupCheck']>>(); +const hostDiagnostics = hostDiagnosticsFixture({ warmupCheck }); + +beforeEach(() => { + warmupCheck.mockReset(); + warmupCheck.mockResolvedValue(undefined); + installProviderDeviceAdmission({ isActive: () => false }); +}); + +async function runDoctorWithSessionDevice(device: DeviceInfo): Promise { + const sessionStore = makeSessionStore('agent-device-doctor-warmup-'); + sessionStore.publish('doctor-session', { + name: 'doctor-session', + createdAt: Date.now(), + device, + actions: [], + }); + return await withTestDeviceInventory( + {}, + async () => + await handleDoctorCommand({ + req: { + token: 't', + session: 'doctor-session', + command: 'doctor', + positionals: [], + flags: { session: 'doctor-session' }, + }, + sessionName: 'doctor-session', + sessionStore, + hostDiagnostics, + }), + ); +} + +async function runSessionlessDoctor( + source: 'host' | 'provider', + flags: { targetApp?: string } = {}, +): Promise { + const inventory = + source === 'host' + ? { local: async () => [IOS_SIMULATOR] } + : { + provider: { + discover: async () => ({ kind: 'inventory' as const, devices: [IOS_SIMULATOR] }), + }, + }; + return await withTestDeviceInventory( + inventory, + async () => + await handleDoctorCommand({ + req: { token: 't', session: 'default', command: 'doctor', positionals: [], flags }, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-doctor-warmup-'), + hostDiagnostics, + }), + ); +} + +function readCheck(response: DaemonResponse | null, id: string): Record | null { + if (!response?.ok) return null; + const checks = (response.data as { checks?: Array> }).checks ?? []; + return checks.find((check) => check.id === id) ?? null; +} + +const RUNNER_CACHE_CHECKS: DoctorCheck[] = [ + { + id: 'ios-runner-cache', + status: 'pass', + summary: 'iOS runner build started in the background', + }, + { id: 'ios-runner-cache', status: 'pass', summary: 'iOS runner artifact cached' }, +]; + +test.each(RUNNER_CACHE_CHECKS)( + 'doctor reports the warmup check the host returned ($summary)', + async (check) => { + warmupCheck.mockResolvedValue(check); + + const response = await runDoctorWithSessionDevice(IOS_SIMULATOR); + + expect(response?.ok).toBe(true); + expect(warmupCheck).toHaveBeenCalledTimes(1); + expect(readCheck(response, 'ios-runner-cache')).toMatchObject({ + status: check.status, + summary: check.summary, + }); + }, +); + +test('doctor offers every session device to the host warmup and adds no line when it declines', async () => { + const device: DeviceInfo = { ...IOS_SIMULATOR, id: 'doctor-device-1', kind: 'device' }; + + const response = await runDoctorWithSessionDevice(device); + + expect(response?.ok).toBe(true); + expect(warmupCheck).toHaveBeenCalledTimes(1); + expect(warmupCheck).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'device' }), + expect.any(Object), + ); + expect(readCheck(response, 'ios-runner-cache')).toBeNull(); +}); + +test('doctor hands the warmup the active provider ownership of the device', async () => { + const seen: boolean[] = []; + warmupCheck.mockImplementation(async (device, context) => { + seen.push(context.isProviderDevice(device)); + return undefined; + }); + + await runDoctorWithSessionDevice(IOS_SIMULATOR); + installProviderDeviceAdmission({ isActive: () => true }); + const response = await runDoctorWithSessionDevice(IOS_SIMULATOR); + + expect(response?.ok).toBe(true); + expect(seen).toEqual([false, true]); + expect(readCheck(response, 'ios-runner-cache')).toBeNull(); +}); + +const SESSIONLESS_WARMUP_CANDIDATES = [ + { name: 'an inventory simulator', flags: {} }, + { name: 'the --app device', flags: { targetApp: 'com.example.demo' } }, +]; + +test.each(SESSIONLESS_WARMUP_CANDIDATES)( + 'doctor warms the runner cache for $name this host discovered', + async ({ flags }) => { + const response = await runSessionlessDoctor('host', flags); + + expect(response?.ok).toBe(true); + expect(warmupCheck).toHaveBeenCalledTimes(1); + expect(warmupCheck).toHaveBeenCalledWith( + expect.objectContaining({ id: IOS_SIMULATOR.id }), + expect.any(Object), + ); + }, +); + +test.each(SESSIONLESS_WARMUP_CANDIDATES)( + 'doctor starts no runner warmup for $name a provider reported', + async ({ flags }) => { + const response = await runSessionlessDoctor('provider', flags); + + expect(response?.ok).toBe(true); + expect(warmupCheck).not.toHaveBeenCalled(); + }, +); diff --git a/packages/daemon/src/sessions/handlers/snapshot-handler-freshness.test.ts b/packages/daemon/src/sessions/handlers/snapshot-handler-freshness.test.ts new file mode 100644 index 0000000000..eb700961ca --- /dev/null +++ b/packages/daemon/src/sessions/handlers/snapshot-handler-freshness.test.ts @@ -0,0 +1,175 @@ +import fs from 'node:fs'; +import { test, expect, beforeEach } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { forwardingCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; +import { SessionStore } from '../../core/session-store.ts'; +import { + fixtureScreenshotCaptures, + resetSnapshotRuntimeFixture, +} from '../../__tests__/support/snapshot-runtime-fixture.ts'; +import { + androidCapture, + androidDevice, + androidTextRows, + handleSnapshotCommands, + inboxBaselineNodes, + inboxRow, + makeAndroidFreshnessSession, + makeSession, + makeSessionStore, + snapshotRequest, +} from '../../__tests__/support/handlers/snapshot-handler.fixtures.ts'; + +beforeEach(() => { + resetSnapshotRuntimeFixture(); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); +}); + +function makeAndroidTimeoutEvidenceSession(sessionName: string): SessionStore { + const sessionStore = makeSessionStore(); + const session = makeSession(sessionName, androidDevice); + session.snapshot = { + nodes: [ + { + ref: 'e1', + index: 0, + depth: 0, + type: 'android.widget.Button', + label: 'Continue', + hittable: true, + rect: { x: 20, y: 40, width: 120, height: 48 }, + }, + ], + createdAt: Date.now(), + backend: 'android', + }; + sessionStore.publish(sessionName, session); + return sessionStore; +} + +function mockAndroidTimeoutEvidenceDispatch(): void { + legacyDispatchCapture.mockImplementation(async (_device, command) => { + if (command === 'snapshot') throw androidSnapshotTimeoutError(); + return {}; + }); +} + +function androidSnapshotTimeoutError(): AppError { + return new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper timed out while waiting for the UI to become idle.', + { + cmd: 'adb', + args: ['shell', 'am', 'instrument'], + androidCaptureFailureReason: 'accessibility-timeout', + hint: 'Android accessibility snapshots can be blocked by busy or continuously changing app UI. Use screenshot as visual truth after this timeout.', + }, + ); +} + +function expectAndroidTimeoutEvidence( + response: Awaited>, +) { + if (!response) throw new Error('Expected snapshot response'); + if (response.ok) throw new Error('Expected snapshot timeout failure'); + expect(response.error.message).toMatch(/snapshot helper timed out/i); + expect(response.error.hint).toMatch(/Use screenshot as visual truth/i); + assertAndroidTimeoutEvidencePayload(response.error.details?.androidSnapshotTimeoutScreenshot); +} + +function assertAndroidTimeoutEvidencePayload(evidence: unknown) { + if (!evidence || typeof evidence !== 'object') { + throw new Error('Expected Android snapshot timeout screenshot evidence'); + } + const record = evidence as Record; + expect(record.path).toEqual(expect.stringContaining('snapshot-timeout-overlay-refs.png')); + expect(fs.existsSync(record.path as string)).toBe(true); + expect(record.overlayRefsAnnotated).toBe(true); + expect(record.overlayRefs).toEqual([expect.objectContaining({ ref: 'e1', label: 'Continue' })]); +} + +test('snapshot timeout captures Android screenshot evidence with overlay refs', async () => { + const sessionName = 'android-timeout-evidence'; + const sessionStore = makeAndroidTimeoutEvidenceSession(sessionName); + mockAndroidTimeoutEvidenceDispatch(); + const response = await handleSnapshotCommands({ + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: snapshotRequest(sessionName, 'snapshot'), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + expectAndroidTimeoutEvidence(response); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot']); + expect(fixtureScreenshotCaptures.at(-1)?.options).toMatchObject({ stabilize: false }); +}); + +test('snapshot automatically retries stale Android trees after recent navigation', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-stale-retries-to-fresh'; + const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); + sessionStore.publish(sessionName, session); + + legacyDispatchCapture + .mockResolvedValueOnce( + androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), + ) + .mockResolvedValueOnce( + androidCapture( + [ + { index: 0, depth: 0, type: 'android.widget.TextView', label: 'Create document' }, + { index: 1, depth: 0, type: 'android.widget.Button', label: 'Submit', hittable: true }, + ], + { rawNodeCount: 2, maxDepth: 1 }, + ), + ); + + const response = await handleSnapshotCommands({ + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); + expect(sessionStore.get(sessionName)?.androidSnapshotFreshness).toBeUndefined(); +}); + +test.each([ + { route: 'snapshot', name: 'snapshot' }, + { route: 'diff', name: 'diff snapshot' }, +] as const)( + '$name gives up on a stale Android tree after three automatic retries', + async ({ route }) => { + const sessionStore = makeSessionStore(); + const sessionName = `android-stale-after-press-${route}`; + const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); + sessionStore.publish(sessionName, session); + + legacyDispatchCapture.mockResolvedValue( + androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), + ); + + const response = await handleSnapshotCommands({ + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: snapshotRequest(sessionName, route, { + ...(route === 'diff' ? { positionals: ['snapshot'] } : {}), + flags: { snapshotInteractiveOnly: true }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(4); + }, +); diff --git a/packages/daemon/src/sessions/handlers/snapshot-handler-wait.test.ts b/packages/daemon/src/sessions/handlers/snapshot-handler-wait.test.ts new file mode 100644 index 0000000000..470b5918a3 --- /dev/null +++ b/packages/daemon/src/sessions/handlers/snapshot-handler-wait.test.ts @@ -0,0 +1,267 @@ +import { test, expect, beforeEach } from 'vitest'; +import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; +import { AppError } from '@agent-device/kernel/errors'; +import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; +import { forwardingCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { + resetSnapshotRuntimeFixture, + snapshotRuntimeFixture, +} from '../../__tests__/support/snapshot-runtime-fixture.ts'; +import type { SessionState } from '../../core/session-state.ts'; +import { + androidCapture, + androidDevice, + androidTextRows, + batteryCapture, + handleSnapshotCommands, + inboxBaselineNodes, + inboxRow, + locationRequiredCapture, + makeAndroidFreshnessSession, + makeSession, + makeSessionStore, + snapshotRequest, +} from '../../__tests__/support/handlers/snapshot-handler.fixtures.ts'; + +beforeEach(() => { + resetSnapshotRuntimeFixture(); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); +}); + +type WaitProbe = ( + backend: Parameters[0]['backend'], + context: { session: string; signal?: AbortSignal }, +) => Promise; + +/** + * A command surface whose `wait` is whatever the probe does with the daemon's selector backend: + * the polling loop is the command family's, so these suites observe what the daemon contributes + * around it — the capture the backend performs and the failure it decorates. + */ +function waitSurface(probe: WaitProbe): CreateDaemonCommandSurface { + return async (config) => { + const surface = await forwardingCreateCommandSurface(config); + return { + ...surface, + selectors: { + ...surface.selectors, + wait: async (options) => { + await probe(config.backend, { + session: options.session ?? 'default', + signal: config.signal, + }); + return { waitedMs: 0, text: 'matched' } as never; + }, + }, + }; + }; +} + +function timedOutWaitSurface( + message: string, + details: Record, +): CreateDaemonCommandSurface { + return waitSurface(async () => { + throw new AppError('COMMAND_FAILED', message, details); + }); +} + +async function captureOnce(backend: Parameters[0], session: string) { + await backend.captureSnapshot?.({ session }, { interactiveOnly: true }); +} + +async function runWaitCommand(params: { + sessionName: string; + session: SessionState; + createCommandSurface: CreateDaemonCommandSurface; + positionals: string[]; +}) { + const sessionStore = makeSessionStore(); + sessionStore.publish(params.sessionName, params.session); + const response = await handleSnapshotCommands({ + createCommandSurface: params.createCommandSurface, + req: snapshotRequest(params.sessionName, 'wait', { positionals: params.positionals }), + sessionName: params.sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + ...snapshotRuntimeFixture(), + }); + return { response, sessionStore }; +} + +const locationPermissionNodes = [ + { + index: 0, + depth: 0, + type: 'android.widget.FrameLayout', + label: 'Location permission', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'android.widget.TextView', + label: 'Allow location access?', + rect: { x: 24, y: 210, width: 342, height: 40 }, + }, + { + index: 2, + depth: 1, + parentIndex: 0, + type: 'android.widget.Button', + label: 'Not now', + rect: { x: 24, y: 320, width: 140, height: 48 }, + hittable: true, + }, + { + index: 3, + depth: 1, + parentIndex: 0, + type: 'android.widget.Button', + label: 'Continue', + rect: { x: 180, y: 320, width: 160, height: 48 }, + hittable: true, + }, +]; + +test('wait text on Android captures through the freshness-aware daemon capture', async () => { + const sessionName = 'android-wait-freshness'; + legacyDispatchCapture + .mockResolvedValueOnce( + androidCapture(androidTextRows(18, inboxRow), { rawNodeCount: 18, maxDepth: 1 }), + ) + .mockResolvedValueOnce( + androidCapture( + [ + { index: 0, depth: 0, type: 'android.widget.TextView', label: 'Create document' }, + { index: 1, depth: 0, type: 'android.widget.TextView', label: 'Done' }, + ], + { rawNodeCount: 2, maxDepth: 1 }, + ), + ); + + const { response, sessionStore } = await runWaitCommand({ + sessionName, + session: makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(18)), + createCommandSurface: waitSurface(async (backend) => await captureOnce(backend, sessionName)), + positionals: ['Create document', '500'], + }); + + expect(response?.ok).toBe(true); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); + expect(sessionStore.get(sessionName)?.snapshot?.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Create document' })]), + ); +}); + +test('wait bypasses a fresh matching session snapshot and captures the device', async () => { + const sessionName = 'android-wait-fresh-capture'; + const session = makeSession(sessionName, androidDevice); + session.snapshot = { + createdAt: Date.now(), + presentationKey: buildSnapshotPresentationKey({}), + nodes: [{ ref: 'e1', index: 0, type: 'android.widget.TextView', label: 'Ready' }], + }; + legacyDispatchCapture.mockResolvedValue({ + nodes: [{ index: 0, type: 'android.widget.TextView', label: 'Ready' }], + }); + + const { response } = await runWaitCommand({ + sessionName, + session, + createCommandSurface: waitSurface(async (backend) => await captureOnce(backend, sessionName)), + positionals: ['label="Ready"', '5000'], + }); + + expect(response?.ok).toBe(true); + expect(legacyDispatchCapture).toHaveBeenCalledWith( + expect.anything(), + 'snapshot', + [], + undefined, + expect.anything(), + ); +}); + +test('a timed-out wait names the compact current surface with its labels and buttons', async () => { + const sessionName = 'android-wait-timeout-surface'; + legacyDispatchCapture.mockResolvedValue({ + nodes: locationPermissionNodes, + truncated: false, + backend: 'android', + analysis: { rawNodeCount: 4, maxDepth: 1 }, + }); + + const { response } = await runWaitCommand({ + sessionName, + session: makeSession(sessionName, androidDevice), + createCommandSurface: timedOutWaitSurface('wait timed out for text: Receipt uploaded', { + reason: 'wait_target_absent', + }), + positionals: ['Receipt uploaded', '50'], + }); + + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.message).toBe( + 'wait timed out for text: Receipt uploaded. Current surface: Location permission, Allow location access?, Not now, Continue.', + ); + expect(response.error.details?.currentSurface).toEqual({ + labels: ['Location permission', 'Allow location access?', 'Not now', 'Continue'], + buttons: ['Not now', 'Continue'], + }); + } +}); + +test('a timed-out selector wait names the current surface the same way', async () => { + const sessionName = 'android-wait-selector-timeout-surface'; + legacyDispatchCapture.mockResolvedValue(locationRequiredCapture()); + + const { response } = await runWaitCommand({ + sessionName, + session: makeSession(sessionName, androidDevice), + createCommandSurface: timedOutWaitSurface('wait timed out for selector: id=receipt-uploaded', { + reason: 'wait_target_absent', + }), + positionals: ['id=receipt-uploaded', '50'], + }); + + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.message).toBe( + 'wait timed out for selector: id=receipt-uploaded. Current surface: Location required, Dismiss.', + ); + expect(response.error.details?.currentSurface).toEqual({ + labels: ['Location required', 'Dismiss'], + buttons: ['Dismiss'], + }); + } +}); + +test('a wait that never read a capture leaves its failure undecorated and captures nothing', async () => { + const sessionName = 'android-wait-timeout-surface-fails'; + legacyDispatchCapture.mockResolvedValue(batteryCapture()); + + const { response } = await runWaitCommand({ + sessionName, + session: makeSession(sessionName, androidDevice), + createCommandSurface: timedOutWaitSurface('wait timed out for text: Receipt uploaded', { + reason: 'wait_capture_stalled', + retriable: true, + readableCaptures: 0, + }), + positionals: ['Receipt uploaded', '0'], + }); + + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.message).toBe('wait timed out for text: Receipt uploaded'); + expect(response.error.details?.reason).toBe('wait_capture_stalled'); + expect(response.error.details?.retriable).toBe(true); + expect(response.error.details?.readableCaptures).toBe(0); + } + expect(legacyDispatchCapture).not.toHaveBeenCalled(); +}); diff --git a/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts b/packages/daemon/src/sessions/handlers/snapshot-handler.test.ts similarity index 78% rename from src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts rename to packages/daemon/src/sessions/handlers/snapshot-handler.test.ts index 2be3f3812e..d098ece77f 100644 --- a/src/__tests__/daemon-composition/handlers/snapshot-handler.test.ts +++ b/packages/daemon/src/sessions/handlers/snapshot-handler.test.ts @@ -1,20 +1,11 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; import { test, expect, vi, afterEach, beforeEach } from 'vitest'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { resetGetRuntimeFixture } from '@agent-device/daemon/testing/__tests__/interaction-get-runtime-fixture'; -import { resetSnapshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import { - isActiveProviderDevice, - setActiveProviderDeviceRuntimes, -} from '../../../provider-device-runtime.ts'; -import { installProviderDeviceAdmission } from '@agent-device/daemon/testing/provider-device-admission'; -import { testCreateCommandSurface } from '../command-surface-fixture.ts'; - -// The daemon reads provider ownership through its own typed admission seam; production -// installs it from root composition, and these tests compose it the same way. -installProviderDeviceAdmission({ isActive: isActiveProviderDevice }); -import type { SessionState } from '@agent-device/daemon/testing/session-state'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { resetSnapshotRuntimeFixture } from '../../__tests__/support/snapshot-runtime-fixture.ts'; +import { forwardingCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; +import { providerDeviceAdmissionFixture } from '../../__tests__/support/provider-device-admission-fixture.ts'; +import type { SessionState } from '../../core/session-state.ts'; import { androidCapture, androidDevice, @@ -26,50 +17,30 @@ import { makeSessionStore, providerIosDevice, snapshotRequest, -} from '@agent-device/daemon/testing/handlers/__tests__/snapshot-handler.fixtures'; - -vi.mock('@agent-device/daemon/testing/snapshot-interactor-capture', async () => { - const fixture = - await import('@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'); - return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; -}); -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn(async () => ({})) }; -}); +} from '../../__tests__/support/handlers/snapshot-handler.fixtures.ts'; // The real implementation shells out to simctl to probe for a hint-worthy // unambiguous environment; that live-probe logic is covered by // ios-app-session-hint.test.ts. Stubbed here so this suite stays hermetic and // fast — defaults to "no enrichment", matching the current-behavior fallback. -vi.mock('@agent-device/daemon/testing/ios-app-session-hint', () => ({ +vi.mock('../../core/ios-app-session-hint.ts', () => ({ buildIosOpenCommandHint: vi.fn(async () => undefined), })); -import { runAppleRunnerCommand } from '@agent-device/platform-apple/runner/operations'; -import { buildIosOpenCommandHint } from '@agent-device/daemon/testing/ios-app-session-hint'; -import { - expireRefFrame, - refFrame, - refFrameState, - refFrameTree, -} from '@agent-device/daemon/testing/ref-frame'; +import { buildIosOpenCommandHint } from '../../core/ios-app-session-hint.ts'; +import { expireRefFrame, refFrame, refFrameState, refFrameTree } from '../../core/ref-frame.ts'; -const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); +const providerAdmission = providerDeviceAdmissionFixture(); afterEach(() => { - setActiveProviderDeviceRuntimes([]); + providerAdmission.setActive([]); }); beforeEach(() => { resetSnapshotRuntimeFixture(); legacyDispatchCapture.mockReset(); legacyDispatchCapture.mockResolvedValue({}); - resetGetRuntimeFixture(); - mockRunnerCommand.mockReset(); - mockRunnerCommand.mockResolvedValue({}); mockBuildIosOpenCommandHint.mockReset(); mockBuildIosOpenCommandHint.mockResolvedValue(undefined); }); @@ -89,8 +60,8 @@ test('snapshot rejects @ref scope without existing session snapshot', async () = ); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotScope: '@e1' } }), sessionName, logPath: '/tmp/daemon.log', @@ -112,8 +83,8 @@ test('snapshot on iOS rejects sessions without a tracked app', async () => { const runtime = countingSnapshotRuntime(); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -142,9 +113,9 @@ test('snapshot on iOS without a tracked app carries the detected open command as const sessionName = 'ios-sim-no-app-hinted'; sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); - const platformServices = composedPlatformServicesFixture(); + const platformServices = daemonPlatformServicesFixture(); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, + createCommandSurface: forwardingCreateCommandSurface, platformServices, req: snapshotRequest(sessionName, 'snapshot'), sessionName, @@ -171,7 +142,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-cloud-no-app'; sessionStore.publish(sessionName, makeSession(sessionName, providerIosDevice)); - setActiveProviderDeviceRuntimes([makeProviderRuntimeOwning(providerIosDevice)]); + providerAdmission.setActive([makeProviderRuntimeOwning(providerIosDevice)]); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'XCUIElementTypeButton', label: 'Sign in' }], truncated: false, @@ -180,8 +151,8 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { const runtime = countingSnapshotRuntime(); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -205,8 +176,8 @@ test('diff on local iOS still requires a tracked app', async () => { sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), sessionName, logPath: '/tmp/daemon.log', @@ -235,8 +206,8 @@ test('snapshot on iOS runs when the session tracks an app', async () => { }); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -272,8 +243,8 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy }); const snapshotResponse = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -286,8 +257,8 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); const diffResponse = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), sessionName, logPath: '/tmp/daemon.log', @@ -307,8 +278,8 @@ async function runVersionedRefsCommand(params: { command: 'snapshot' | 'diff'; }): Promise | undefined> { const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(params.sessionName, params.command, { positionals: params.command === 'diff' ? ['snapshot'] : [], }), @@ -394,8 +365,8 @@ test('daemon-private snapshot observation advances capture state without publish }); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { internal: { observationOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -410,7 +381,7 @@ test('daemon-private snapshot observation advances capture state without publish }); }); -test('snapshot surfaces filtered-to-zero Android guidance for interactive snapshots', async () => { +test('snapshot hands the interactive-only and depth flags to the device capture', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-empty-interactive'; sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); @@ -418,8 +389,8 @@ test('snapshot surfaces filtered-to-zero Android guidance for interactive snapsh legacyDispatchCapture.mockResolvedValue(androidCapture([], { rawNodeCount: 42, maxDepth: 8 })); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true, snapshotDepth: 3 }, }), @@ -429,19 +400,20 @@ test('snapshot surfaces filtered-to-zero Android guidance for interactive snapsh }); expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.warnings).toEqual([ - expect.stringContaining('Interactive snapshot is empty after filtering 42 raw Android nodes'), - 'Interactive output is empty at depth 3; retry without -d.', - ]); - } + expect(legacyDispatchCapture).toHaveBeenCalledWith( + androidDevice, + 'snapshot', + [], + undefined, + expect.objectContaining({ snapshotInteractiveOnly: true, snapshotDepth: 3 }), + ); }); test('diff rejects unsupported kind', async () => { const sessionStore = makeSessionStore(); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest('default', 'diff', { positionals: ['unknown'] }), sessionName: 'default', logPath: '/tmp/daemon.log', @@ -459,8 +431,8 @@ test('diff rejects unsupported kind', async () => { test('diff screenshot is not handled daemon-side (client-backed command)', async () => { const sessionStore = makeSessionStore(); const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: forwardingCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest('default', 'diff', { positionals: ['screenshot'] }), sessionName: 'default', logPath: '/tmp/daemon.log', diff --git a/src/__tests__/daemon-composition/handlers/snapshot-scoped-refs.test.ts b/packages/daemon/src/sessions/handlers/snapshot-scoped-refs.test.ts similarity index 89% rename from src/__tests__/daemon-composition/handlers/snapshot-scoped-refs.test.ts rename to packages/daemon/src/sessions/handlers/snapshot-scoped-refs.test.ts index 996500da12..dcaec98a63 100644 --- a/src/__tests__/daemon-composition/handlers/snapshot-scoped-refs.test.ts +++ b/packages/daemon/src/sessions/handlers/snapshot-scoped-refs.test.ts @@ -1,12 +1,12 @@ import { beforeEach, expect, test } from 'vitest'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { handleSnapshotCommands as handleProductionSnapshotCommands } from '@agent-device/daemon/testing/handlers/snapshot'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { handleSnapshotCommands as handleProductionSnapshotCommands } from './snapshot.ts'; import type { RawSnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; -import { makeAndroidSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { expireRefFrame } from '@agent-device/daemon/testing/ref-frame'; -import { snapshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import { testCreateCommandSurface } from '../command-surface-fixture.ts'; +import { makeAndroidSession } from '../../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { expireRefFrame } from '../../core/ref-frame.ts'; +import { snapshotRuntimeFixture } from '../../__tests__/support/snapshot-runtime-fixture.ts'; +import { forwardingCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; const ANDROID_SCRIPT_ERROR = 'Unable to load script. Make sure you are running Metro.'; @@ -98,7 +98,7 @@ function requestScopedSnapshot( ) { const runtime = snapshotRuntimeFixture(); return handleProductionSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, + createCommandSurface: forwardingCreateCommandSurface, req: { token: 't', session: sessionName, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-close-lifecycle-teardown.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-close-lifecycle-teardown.test.ts new file mode 100644 index 0000000000..193dfa9c8d --- /dev/null +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-close-lifecycle-teardown.test.ts @@ -0,0 +1,278 @@ +import path from 'node:path'; +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { CloseApplicationFinalizationInput } from '@agent-device/contracts/application-lifecycle-runtime'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { SessionStore } from '../../../core/session-store.ts'; +import { LeaseRegistry } from '../../../resources/lease-registry.ts'; +import type { DaemonRequest } from '../../../core/daemon-request.ts'; +import type { SessionState } from '../../../core/session-state.ts'; +import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; +import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +import { platformResourceCleanupFixture } from '../../../__tests__/support/platform-resource-cleanup-fixture.ts'; +import { dispatchApplicationLifecycleEffect } from '../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; +import { + handleSessionCommands, + mockBindDeviceRuntime, +} from '../../../__tests__/support/handlers/session-command-harness.ts'; + +const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); +const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); + +const IOS_SIMULATOR: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function makeSessionStore(): SessionStore { + const root = mkdtempForTestSync('agent-device-close-teardown-'); + return new SessionStore(path.join(root, 'sessions')); +} + +function publishSession( + sessionStore: SessionStore, + name: string, + device: DeviceInfo, + overrides: Partial = {}, +): SessionState { + const session: SessionState = { + name, + device, + createdAt: Date.now(), + actions: [], + appName: 'com.example.app', + ...overrides, + }; + sessionStore.publish(name, session); + return session; +} + +async function closeSession(params: { + sessionStore: SessionStore; + sessionName: string; + retainExecutionHost: boolean; + positionals?: string[]; + flags?: Record; + internal?: DaemonRequest['internal']; + leaseRegistry?: LeaseRegistry; + events?: string[]; +}) { + const platformResourceCleanup = platformResourceCleanupFixture({ + retainExecutionHostAfterClose: () => params.retainExecutionHost, + }); + const finalizations: CloseApplicationFinalizationInput[] = []; + const bindDevice: Parameters[0]['bindDevice'] = async ( + device, + use, + ) => { + const binding = await mockBindDeviceRuntime(device, use); + return { + ...binding, + operations: { + ...binding.operations, + finalizeApplicationClose: async (input: CloseApplicationFinalizationInput) => { + finalizations.push(input); + params.events?.push(`finalize:${input.retainRunner ? 'retain' : 'stop'}`); + return {}; + }, + }, + }; + }; + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + platformResourceCleanup, + req: { + token: 't', + session: params.sessionName, + command: 'close', + positionals: params.positionals ?? [], + flags: params.flags ?? {}, + ...(params.internal === undefined ? {} : { internal: params.internal }), + }, + ...(params.leaseRegistry === undefined ? {} : { leaseRegistry: params.leaseRegistry }), + sessionName: params.sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore: params.sessionStore, + invoke: noopInvoke, + bindDevice, + }); + return { response, finalizations, platformResourceCleanup }; +} + +test.each([ + { name: 'an iOS simulator', device: IOS_SIMULATOR }, + { + name: 'an iOS simulator in a scoped simulator set', + device: { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulator-set' }, + }, +])( + 'close on $name finalizes with the runner retention the platform decided', + async ({ device }) => { + for (const retainExecutionHost of [true, false]) { + const sessionStore = makeSessionStore(); + const sessionName = `retention-${retainExecutionHost}`; + publishSession(sessionStore, sessionName, device); + + const { response, finalizations, platformResourceCleanup } = await closeSession({ + sessionStore, + sessionName, + retainExecutionHost, + }); + + expect(response?.ok).toBe(true); + expect(platformResourceCleanup.retainExecutionHostAfterClose).toHaveBeenCalledWith({ + device, + shutdownRequested: false, + hasScreenRecording: false, + hasLease: false, + }); + expect(finalizations).toEqual([ + expect.objectContaining({ retainRunner: retainExecutionHost, shutdownTarget: false }), + ]); + expect(sessionStore.get(sessionName)).toBeUndefined(); + } + }, +); + +test('close terminates the app before finalizing with the platform retention decision', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-close-session'; + publishSession(sessionStore, sessionName, IOS_SIMULATOR); + const events: string[] = []; + mockDispatch.mockImplementation(async (_device, command, positionals) => { + events.push(`${command}:${(positionals ?? []).join(' ')}`); + }); + + const { response } = await closeSession({ + sessionStore, + sessionName, + retainExecutionHost: true, + positionals: ['com.example.app'], + events, + }); + + expect(response?.ok).toBe(true); + expect(events).toEqual(['close:com.example.app', 'finalize:retain']); +}); + +test('close hands the session app and surface to the platform finalization', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'macos-session'; + publishSession( + sessionStore, + sessionName, + { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }, + { appBundleId: 'com.apple.systempreferences', appName: 'System Settings' }, + ); + + const { response, finalizations } = await closeSession({ + sessionStore, + sessionName, + retainExecutionHost: false, + }); + + expect(response?.ok).toBe(true); + expect(finalizations).toEqual([ + expect.objectContaining({ appBundleId: 'com.apple.systempreferences', surface: 'app' }), + ]); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('close of a leased session tells the platform that a lease holds the device', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-leased-simulator-session'; + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-simulator', + deviceKey: 'ios:sim-1', + clientId: 'client-a', + }); + publishSession(sessionStore, sessionName, IOS_SIMULATOR, { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + expiresAt: lease.expiresAt, + }, + }); + + const { response, finalizations, platformResourceCleanup } = await closeSession({ + sessionStore, + sessionName, + retainExecutionHost: false, + leaseRegistry, + }); + + expect(response?.ok).toBe(true); + expect(platformResourceCleanup.retainExecutionHostAfterClose).toHaveBeenCalledWith( + expect.objectContaining({ hasLease: true }), + ); + expect(finalizations).toEqual([expect.objectContaining({ retainRunner: false })]); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('an app-only close terminates the app without finalizing or ending the session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-app-only-close'; + publishSession(sessionStore, sessionName, IOS_SIMULATOR, { appBundleId: 'com.example.app' }); + + const { response, finalizations } = await closeSession({ + sessionStore, + sessionName, + retainExecutionHost: true, + positionals: ['com.example.app'], + internal: { closeAppOnly: true }, + }); + + expect(response?.ok).toBe(true); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sim-1' }), + 'close', + ['com.example.app'], + undefined, + expect.any(Object), + ); + expect(finalizations).toEqual([]); + expect(sessionStore.get(sessionName)).toBeDefined(); +}); + +test('close --shutdown tells the platform that shutdown was requested', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'shutdown-session'; + publishSession(sessionStore, sessionName, IOS_SIMULATOR); + + const { finalizations, platformResourceCleanup } = await closeSession({ + sessionStore, + sessionName, + retainExecutionHost: false, + flags: { shutdown: true }, + }); + + expect(platformResourceCleanup.retainExecutionHostAfterClose).toHaveBeenCalledWith( + expect.objectContaining({ shutdownRequested: true }), + ); + expect(finalizations).toEqual([expect.objectContaining({ shutdownTarget: true })]); +}); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts new file mode 100644 index 0000000000..c523d6d967 --- /dev/null +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts @@ -0,0 +1,256 @@ +import path from 'node:path'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { OpenApplicationInput } from '@agent-device/contracts/application-lifecycle-runtime'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { SessionStore } from '../../../core/session-store.ts'; +import type { DaemonRequest } from '../../../core/daemon-request.ts'; +import type { SessionState } from '../../../core/session-state.ts'; +import { refFrameState } from '../../../core/ref-frame.ts'; +import { installProviderDeviceAdmission } from '../../../core/provider-device-admission.ts'; +import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; +import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +import { + handleSessionCommands, + mockBindDeviceRuntime, +} from '../../../__tests__/support/handlers/session-command-harness.ts'; + +const mockResolveTargetDevice = vi.hoisted(() => vi.fn()); + +vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { + const actual = + await importOriginal(); + const { selectionFromResolveTargetDevice } = + await import('../../../__tests__/support/device-selection-stub.ts'); + return { + ...actual, + resolveTargetDevice: mockResolveTargetDevice, + resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(mockResolveTargetDevice)), + }; +}); + +const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); + +const ANDROID_EMULATOR: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, +}; + +const IOS_DEVICE: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'ios-device-1', + name: 'My iPhone', + kind: 'device', + booted: true, +}; + +const IOS_SIMULATOR: DeviceInfo = { + ...IOS_DEVICE, + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', +}; + +beforeEach(() => { + mockResolveTargetDevice.mockReset(); +}); + +afterEach(() => { + installProviderDeviceAdmission({ isActive: () => false }); +}); + +function makeSessionStore(): SessionStore { + const root = mkdtempForTestSync('agent-device-open-relaunch-'); + return new SessionStore(path.join(root, 'sessions')); +} + +function publishSession( + sessionStore: SessionStore, + name: string, + device: DeviceInfo, + overrides: Partial = {}, +): SessionState { + const session: SessionState = { + name, + device, + createdAt: Date.now(), + actions: [], + appName: 'com.example.app', + ...overrides, + }; + sessionStore.publish(name, session); + return session; +} + +async function openRelaunch(params: { + sessionStore: SessionStore; + sessionName: string; + positionals?: string[]; + flags?: Record; + onOpenApplication?: (input: OpenApplicationInput) => Promise | void; +}) { + const opened: OpenApplicationInput[] = []; + const boundDevices: DeviceInfo[] = []; + const bindDevice: Parameters[0]['bindDevice'] = async ( + device, + use, + ) => { + boundDevices.push(device); + const binding = await mockBindDeviceRuntime(device, use); + return { + ...binding, + operations: { + ...binding.operations, + openApplication: async (input: OpenApplicationInput) => { + opened.push(input); + await params.onOpenApplication?.(input); + return { appBundleId: input.appBundleId, timing: {} }; + }, + }, + }; + }; + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: params.sessionName, + command: 'open', + positionals: params.positionals ?? [], + flags: { relaunch: true, ...params.flags }, + }, + sessionName: params.sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore: params.sessionStore, + invoke: noopInvoke, + bindDevice, + }); + return { response, opened, boundDevices }; +} + +test('open --relaunch asks the lifecycle to relaunch the active session app', async () => { + const sessionStore = makeSessionStore(); + publishSession(sessionStore, 'android-session', ANDROID_EMULATOR); + + const { response, opened } = await openRelaunch({ + sessionStore, + sessionName: 'android-session', + }); + + expect(response?.ok).toBe(true); + expect(opened).toEqual([ + expect.objectContaining({ + relaunch: true, + hasExistingSession: true, + positionals: ['com.example.app'], + }), + ]); +}); + +test('open --relaunch leaves the old frame expired when the lifecycle fails (ADR 0014)', async () => { + const sessionStore = makeSessionStore(); + const session = publishSession(sessionStore, 'android-session', ANDROID_EMULATOR, { + snapshotGeneration: 400, + }); + expect(refFrameState(session)).toBe('active'); + let frameWhenLifecycleRan: string | undefined; + + const response = await openRelaunch({ + sessionStore, + sessionName: 'android-session', + onOpenApplication: () => { + frameWhenLifecycleRan = refFrameState(sessionStore.get('android-session')!); + throw new Error('adb: close timed out after dispatch'); + }, + }) + .then((result) => result.response) + .catch(() => null); + + expect(response?.ok ?? false).toBe(false); + expect(frameWhenLifecycleRan).toBe('expired'); + expect(refFrameState(sessionStore.get('android-session')!)).toBe('expired'); +}); + +test('open --relaunch does not let an ambient provider claim suppress the relaunch', async () => { + const sessionStore = makeSessionStore(); + installProviderDeviceAdmission({ isActive: (device) => device.id === ANDROID_EMULATOR.id }); + mockResolveTargetDevice.mockResolvedValue(ANDROID_EMULATOR); + + const { response, opened, boundDevices } = await openRelaunch({ + sessionStore, + sessionName: 'provider-android-session', + positionals: ['com.example.app'], + flags: { platform: 'android' }, + }); + + expect(response?.ok).toBe(true); + expect(boundDevices).toEqual([ANDROID_EMULATOR]); + expect(opened).toEqual([expect.objectContaining({ relaunch: true, hasExistingSession: false })]); +}); + +test('open --relaunch without a session relaunches the requested app on the resolved device', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(IOS_DEVICE); + + const { response, opened, boundDevices } = await openRelaunch({ + sessionStore, + sessionName: 'ios-new-session', + positionals: ['com.example.app'], + flags: { platform: 'ios' }, + }); + + expect(response?.ok).toBe(true); + expect(boundDevices).toEqual([IOS_DEVICE]); + expect(opened).toEqual([ + expect.objectContaining({ + relaunch: true, + hasExistingSession: false, + positionals: ['com.example.app'], + }), + ]); +}); + +test('open --relaunch hands the lifecycle both positionals', async () => { + const sessionStore = makeSessionStore(); + publishSession(sessionStore, 'ios-url-session', IOS_SIMULATOR); + mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR); + + const { response, opened } = await openRelaunch({ + sessionStore, + sessionName: 'ios-url-session', + positionals: ['com.example.app', 'https://example.com/deal'], + }); + + expect(response?.ok).toBe(true); + expect(opened).toEqual([ + expect.objectContaining({ + relaunch: true, + positionals: ['com.example.app', 'https://example.com/deal'], + }), + ]); +}); + +test('open --relaunch --clear-app-state hands the lifecycle the clear-state execution', async () => { + const sessionStore = makeSessionStore(); + publishSession(sessionStore, 'ios-clear-state-session', IOS_SIMULATOR); + mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR); + + const { response, opened } = await openRelaunch({ + sessionStore, + sessionName: 'ios-clear-state-session', + flags: { clearAppState: true }, + }); + + expect(response?.ok).toBe(true); + expect(opened).toEqual([ + expect.objectContaining({ + relaunch: true, + execution: expect.objectContaining({ clearAppState: true }), + }), + ]); +}); diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index ed37e49762..48aee7335f 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -109,7 +109,9 @@ function createLifecycle( }, applyRuntimeHints: async () => {}, clearRuntimeHints: async () => {}, - activateTestIme: async () => {}, + activateTestIme: async () => { + calls.push('activate-test-ime'); + }, restoreTestIme: params.restoreTestIme ?? (async () => {}), recoverTestImeStartup: async () => {}, hasTestImeRecoveryEvidence: async () => false, @@ -205,6 +207,14 @@ test('preserves a runtime launch URL duration after the admitted Android follow- expect(outcome.timing.launchUrlDurationMs).toBe(20); }); +test('an Android open activates the test IME after the launch dispatch when requested', async () => { + const { calls, lifecycle } = createLifecycle(); + + await lifecycle.openApplication(openInput({ enableTestIme: true })); + + expect(calls.slice(0, 2)).toEqual(['open:com.example.app', 'activate-test-ime']); +}); + test('an Android app open returns only after the launched app observation settles', async () => { let finishCapture: () => void = () => {}; const { calls, lifecycle } = createLifecycle({ diff --git a/packages/platform-apple/src/lifecycle-relaunch-close.test.ts b/packages/platform-apple/src/lifecycle-relaunch-close.test.ts new file mode 100644 index 0000000000..e8b792feea --- /dev/null +++ b/packages/platform-apple/src/lifecycle-relaunch-close.test.ts @@ -0,0 +1,217 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { bindAppleApplicationLifecycle } from './lifecycle.ts'; +import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; +import { device, openInput, simulator } from './lifecycle.fixtures.ts'; + +const MACOS_DEVICE: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +type OpenCall = Readonly<{ app: string; options?: Record }>; + +/** Completes after the caller has had a chance to run ahead, so only an awaited step is recorded first. */ +const settleLater = async () => await new Promise((resolve) => setTimeout(resolve, 0)); + +function lifecycleHost(params: { + events: string[]; + hasLiveRunnerSession?: boolean; + prewarmRunnerSession?: () => Promise; +}) { + const openCalls: OpenCall[] = []; + const interactor = { + setSetting: vi.fn(async () => {}), + close: vi.fn(async (app: string) => { + params.events.push(`close:${app}`); + }), + open: vi.fn(async (app: string, options?: Record) => { + openCalls.push({ app, options }); + params.events.push(options?.url ? `open:${app} ${String(options.url)}` : `open:${app}`); + }), + } as unknown as Interactor; + const baseHost = platformRuntimeHostFixture(); + const appleApplications = { + ...baseHost.appleApplications, + stopRunnerSession: vi.fn(async (deviceId: string) => { + await settleLater(); + params.events.push(`stop-runner:${deviceId}`); + }), + prewarmRunnerSession: vi.fn( + params.prewarmRunnerSession ?? + (async () => { + params.events.push('prewarm'); + }), + ), + notifyRunnerAppRelaunched: vi.fn(async () => { + params.events.push('reset-runner-target'); + }), + hasLiveRunnerSession: vi.fn(async () => params.hasLiveRunnerSession ?? false), + releaseRunnerOnClose: vi.fn(async (deviceId: string, options: { retain: boolean }) => { + await settleLater(); + params.events.push(`release-runner:${deviceId}:${options.retain ? 'retain' : 'stop'}`); + }), + dismissCloseAlerts: vi.fn(async () => { + params.events.push('dismiss-alerts'); + }), + }; + const host = { + ...baseHost, + clock: { ...baseHost.clock, sleep: async () => {} }, + localInteractors: { resolve: async () => interactor }, + appleApplications, + } as unknown as PlatformRuntimeHost; + return { host, openCalls, appleApplications }; +} + +function bind(host: PlatformRuntimeHost, selected: DeviceInfo) { + return bindAppleApplicationLifecycle({ + host, + device: selected, + signal: new AbortController().signal, + }); +} + +test('a Simulator relaunch collapses into one open that terminates the running app', async () => { + const events: string[] = []; + const { host, openCalls, appleApplications } = lifecycleHost({ + events, + hasLiveRunnerSession: true, + }); + + await bind(host, simulator).openApplication(openInput()); + + expect(events).toEqual(['prewarm', 'open:com.example.app', 'reset-runner-target']); + expect(openCalls[0]?.options?.terminateRunningApp).toBe(true); + expect(appleApplications.stopRunnerSession).not.toHaveBeenCalled(); +}); + +test('a Simulator relaunch with a launch URL delegates termination to the URL open', async () => { + const events: string[] = []; + const { host, openCalls } = lifecycleHost({ events }); + + await bind(host, simulator).openApplication({ + ...openInput(), + positionals: ['com.example.app', 'https://example.com/deal'], + }); + + expect(events.filter((event) => event.startsWith('open:') || event.startsWith('close:'))).toEqual( + ['open:com.example.app https://example.com/deal'], + ); + expect(openCalls[0]?.options?.terminateRunningApp).toBe(true); +}); + +test('a Simulator relaunch that clears app state closes before it opens', async () => { + const events: string[] = []; + const { host, openCalls } = lifecycleHost({ events }); + + await bind(host, simulator).openApplication({ + ...openInput(), + execution: { clearAppState: true }, + }); + + expect(events.filter((event) => event.startsWith('open:') || event.startsWith('close:'))).toEqual( + ['close:com.example.app', 'open:com.example.app'], + ); + expect(openCalls[0]?.options?.terminateRunningApp).toBeUndefined(); +}); + +test('a physical iOS relaunch waits for the runner prewarm and reports its timing', async () => { + vi.useFakeTimers({ now: 1_000 }); + const events: string[] = []; + const { host, appleApplications } = lifecycleHost({ + events, + prewarmRunnerSession: () => + new Promise((resolve) => { + events.push('prewarm-start'); + setTimeout(() => { + events.push('prewarm-finish'); + resolve(); + }, 250); + }), + }); + + const opening = bind(host, device).openApplication(openInput()); + await vi.advanceTimersByTimeAsync(250); + const outcome = await opening; + + expect(events).toEqual([ + 'close:com.example.app', + 'open:com.example.app', + 'prewarm-start', + 'prewarm-finish', + 'reset-runner-target', + ]); + expect(appleApplications.stopRunnerSession).not.toHaveBeenCalled(); + expect(outcome.timing).toMatchObject({ + runnerPrewarmKind: 'session', + runnerPrewarmScheduled: true, + runnerPrewarmWaited: true, + runnerPrewarmDurationMs: 250, + }); +}); + +test('a physical iOS relaunch without an existing session closes then opens the target app', async () => { + const events: string[] = []; + const { host, appleApplications } = lifecycleHost({ events }); + + await bind(host, device).openApplication({ ...openInput(), hasExistingSession: false }); + + expect(events).toEqual([ + 'close:com.example.app', + 'open:com.example.app', + 'prewarm', + 'reset-runner-target', + ]); + expect(appleApplications.stopRunnerSession).not.toHaveBeenCalled(); +}); + +test.each([ + { name: 'a physical iOS device', selected: device, stopsRunner: true }, + { name: 'a macOS host', selected: MACOS_DEVICE, stopsRunner: true }, + { name: 'an iOS Simulator', selected: simulator, stopsRunner: false }, +])( + 'closing an app on $name stops the runner first: $stopsRunner', + async ({ selected, stopsRunner }) => { + const events: string[] = []; + const { host } = lifecycleHost({ events }); + + await bind(host, selected).closeApplication({ + appBundleId: 'com.example.app', + positionals: ['com.example.app'], + surface: 'app', + execution: {}, + }); + + expect(events).toEqual( + stopsRunner + ? [`stop-runner:${selected.id}`, 'close:com.example.app'] + : ['close:com.example.app'], + ); + }, +); + +test('close finalization releases the runner before it dismisses close alerts', async () => { + const events: string[] = []; + const { host } = lifecycleHost({ events }); + + await bind(host, MACOS_DEVICE).finalizeApplicationClose({ + appBundleId: 'com.apple.systempreferences', + surface: 'app', + retainRunner: false, + stateDir: '/tmp', + }); + + expect(events).toEqual(['release-runner:host-macos-local:stop', 'dismiss-alerts']); +}); diff --git a/src/__tests__/daemon-composition/handlers/lease-provider-credentials.test.ts b/src/__tests__/daemon-composition/handlers/lease-provider-credentials.test.ts deleted file mode 100644 index 401815d005..0000000000 --- a/src/__tests__/daemon-composition/handlers/lease-provider-credentials.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { test } from 'vitest'; -import { handleLeaseCommands } from '@agent-device/daemon/testing/handlers/lease'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import type { DaemonRequest } from '@agent-device/daemon'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { AppError } from '@agent-device/kernel/errors'; -import { - providerCredentialFingerprint, - readDaemonProviderCredentials, -} from '../../../provider-credential-fingerprint.ts'; -import { pluginHome, selectPluginManifest } from '@agent-device/test-kit/plugin.fixtures'; - -/** - * Lease refusal compares the client's credential fingerprint against the digest the daemon - * computes from its own startup environment through the real root fingerprint reader and the - * installed-plugin manifest registry. Those digests and manifest lookups are the composition - * this suite pins, so it lives root-owned; lease mechanics stay in the daemon suite. - */ - -function allocateRequest(): DaemonRequest { - return { - token: 'test-token', - session: 'lease-ttl-test', - command: 'lease_allocate', - positionals: [], - flags: {}, - meta: { - tenantId: 'tenant-a', - runId: 'run-a', - clientId: 'client-a', - leaseBackend: 'android-instance', - leaseProvider: 'cloud', - }, - }; -} - -const LIMRUN_ATTACH_ENV = { - LIMRUN_API_KEY: 'lim-key', - LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', - LIM_IOS_INSTANCE_TOKEN: 'ios-token', -}; -const BROWSERSTACK_ENV = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; - -function providerAllocateRequest( - leaseProvider: string, - providerCredentialFingerprint: string | undefined, - leaseBackend: 'ios-instance' | 'android-instance' = 'ios-instance', -): DaemonRequest { - const request = allocateRequest(); - return { - ...request, - meta: { - ...request.meta, - leaseProvider, - leaseBackend, - providerCredentialFingerprint, - }, - }; -} - -async function allocateWithDaemonEnv( - req: DaemonRequest, - daemonEnv: Record, -): Promise<{ allocations: number; error?: AppError }> { - const registry = new LeaseRegistry(); - let allocations = 0; - try { - await handleLeaseCommands({ - req, - sessionName: req.session, - sessionStore: makeSessionStore('agent-device-provider-credentials-'), - leaseRegistry: registry, - providerCredentials: readDaemonProviderCredentials(daemonEnv, '/tmp/agent device state'), - leaseLifecycleProvider: { - allocate: async () => { - allocations += 1; - return {}; - }, - }, - }); - } catch (error) { - assert.deepEqual(registry.listActiveLeases(), []); - return { allocations, error: error as AppError }; - } - return { allocations }; -} - -test('a daemon started with only LIMRUN_API_KEY refuses a shell with instance variables before allocation', async () => { - const outcome = await allocateWithDaemonEnv( - providerAllocateRequest('limrun', providerCredentialFingerprint('limrun', LIMRUN_ATTACH_ENV)), - { LIMRUN_API_KEY: 'lim-key' }, - ); - - assert.equal(outcome.allocations, 0); - assert.equal(outcome.error?.code, 'INVALID_ARGS'); - assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); - assert.equal(outcome.error?.details?.provider, 'limrun'); - assert.match( - String(outcome.error?.details?.hint), - /agent-device daemon stop --state-dir '\/tmp\/agent device state'/, - ); -}); - -test('a Limrun lease compares only the leased platform instance variables', async () => { - const daemonEnv = { - ...LIMRUN_ATTACH_ENV, - LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', - LIM_ANDROID_INSTANCE_TOKEN: 'android-token', - LIM_ANDROID_INSTANCE_ADB_URL: 'wss://region.limrun.example/v1/android_x/adb', - }; - const shellEnv = { ...daemonEnv, LIM_ANDROID_INSTANCE_TOKEN: 'android-token-2' }; - const allocate = async (leaseBackend: 'ios-instance' | 'android-instance') => - await allocateWithDaemonEnv( - providerAllocateRequest( - 'limrun', - providerCredentialFingerprint('limrun', shellEnv, leaseBackend), - leaseBackend, - ), - daemonEnv, - ); - - assert.equal((await allocate('ios-instance')).error, undefined); - assert.equal( - (await allocate('android-instance')).error?.details?.reason, - 'provider-credentials-changed', - ); -}); - -test('a daemon holding rotated BrowserStack keys refuses before allocation', async () => { - const outcome = await allocateWithDaemonEnv( - providerAllocateRequest( - 'browserstack', - providerCredentialFingerprint('browserstack', { - ...BROWSERSTACK_ENV, - BROWSERSTACK_ACCESS_KEY: 'key-2', - }), - ), - BROWSERSTACK_ENV, - ); - - assert.equal(outcome.allocations, 0); - assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); - assert.equal(outcome.error?.details?.provider, 'browserstack'); -}); - -test.for([ - ['limrun', LIMRUN_ATTACH_ENV], - ['browserstack', BROWSERSTACK_ENV], -] as const)( - 'a daemon holding the %s credentials of the shell allocates', - async ([provider, env]) => { - const outcome = await allocateWithDaemonEnv( - providerAllocateRequest(provider, providerCredentialFingerprint(provider, env)), - env, - ); - - assert.equal(outcome.error, undefined); - assert.equal(outcome.allocations, 1); - }, -); - -test('a daemon holding credentials allocates for a shell with none', async () => { - const outcome = await allocateWithDaemonEnv( - providerAllocateRequest('limrun', providerCredentialFingerprint('limrun', {})), - { LIMRUN_API_KEY: 'lim-key' }, - ); - - assert.equal(outcome.error, undefined); - assert.equal(outcome.allocations, 1); -}); - -test('a daemon started without BrowserStack credentials refuses a shell that has them', async () => { - const outcome = await allocateWithDaemonEnv( - providerAllocateRequest( - 'browserstack', - providerCredentialFingerprint('browserstack', BROWSERSTACK_ENV), - ), - {}, - ); - - assert.equal(outcome.allocations, 0); - assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); - assert.match(String(outcome.error?.message), /started without the browserstack credentials/); -}); - -function testMuPluginEnv(): Record { - const manifest = path.resolve( - import.meta.dirname, - '../../../../packages/provider-testmu/package.json', - ); - const { home, env } = pluginHome(); - selectPluginManifest(home, JSON.parse(fs.readFileSync(manifest, 'utf8'))); - return env; -} - -test('a daemon holding other TestMu AI credentials refuses before allocation', async () => { - const home = testMuPluginEnv(); - const daemonEnv = { ...home, LT_USERNAME: 'user', LT_ACCESS_KEY: 'key-1' }; - const outcome = await allocateWithDaemonEnv( - providerAllocateRequest( - 'testmu', - providerCredentialFingerprint('testmu', { ...daemonEnv, LT_ACCESS_KEY: 'key-2' }), - ), - daemonEnv, - ); - - assert.equal(outcome.allocations, 0); - assert.equal(outcome.error?.code, 'INVALID_ARGS'); - assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); - assert.equal(outcome.error?.details?.provider, 'testmu'); -}); - -test('a daemon holding the TestMu AI credentials of the shell allocates', async () => { - const env = { ...testMuPluginEnv(), LT_USERNAME: 'user', LT_ACCESS_KEY: 'key-1' }; - const outcome = await allocateWithDaemonEnv( - providerAllocateRequest('testmu', providerCredentialFingerprint('testmu', env)), - env, - ); - - assert.equal(outcome.error, undefined); - assert.equal(outcome.allocations, 1); -}); - -test('a daemon started without TestMu AI credentials refuses a shell that has them', async () => { - const home = testMuPluginEnv(); - const outcome = await allocateWithDaemonEnv( - providerAllocateRequest( - 'testmu', - providerCredentialFingerprint('testmu', { - ...home, - LT_USERNAME: 'user', - LT_ACCESS_KEY: 'key-1', - }), - ), - home, - ); - - assert.equal(outcome.allocations, 0); - assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); - assert.match(String(outcome.error?.message), /started without the testmu credentials/); -}); diff --git a/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts b/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts deleted file mode 100644 index 727bc0aa4a..0000000000 --- a/src/__tests__/daemon-composition/handlers/session-close-runner-retention.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { test, expect, vi, beforeEach } from 'vitest'; -import * as path from 'node:path'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; -import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { makeSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { getResolveTargetDeviceMock } from '@agent-device/daemon/testing/__tests__/request-router-dispatch-mocks'; -import type { DaemonRequest } from '@agent-device/daemon'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; - -// The runner-retention decision is the real `PlatformResourceCleanup` rule (a booted simulator -// keeps its runner host across a plain close), so these close-policy cases run the composed -// cleanup, the way the daemon-composition lane composes it. - -vi.mock('node:timers/promises', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, setTimeout: vi.fn(async () => undefined) }; -}); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - prepareIosRunner: vi.fn(async () => ({ - runner: { currentUptimeMs: 42 }, - connectMs: 3, - healthCheckMs: 3, - })), - prewarmIosRunnerSession: vi.fn(), - notifyIosRunnerAppRelaunched: vi.fn(async () => {}), - hasLiveIosRunnerSession: vi.fn(() => true), - stopIosRunnerSession: vi.fn(async () => {}), - releaseIosRunnerOnClose: vi.fn(async () => {}), - }; -}); -vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - resolveIosApp: vi.fn(async (_device, app: string) => app), - resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), - }; -}); -vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); - -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { realLifecycleDeviceRuntimeGateway } from '../lifecycle-runtime-gateway.ts'; -import { - dispatchApplicationLifecycleEffect, - awaitFixtureReadiness, - discoverReadyAndroidEmulators, -} from '../application-lifecycle-runtime-fixture.ts'; -import { - stopIosRunnerSession, - releaseIosRunnerOnClose, -} from '@agent-device/platform-apple/runner/operations'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; - -const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); -const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); -const mockStopIosRunner = vi.mocked(stopIosRunnerSession); -const mockReleaseRunnerOnClose = vi.mocked(releaseIosRunnerOnClose); - -beforeEach(() => { - vi.useRealTimers(); - vi.mocked(getResolveTargetDeviceMock()).mockReset(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue(undefined); - mockAwaitFixtureReadiness.mockReset(); - mockAwaitFixtureReadiness.mockResolvedValue(undefined); - mockDiscoverReadyAndroidEmulators.mockReset(); - mockDiscoverReadyAndroidEmulators.mockImplementation(async (device) => [ - { - ...device, - id: device.id.startsWith('emulator-') ? device.id : `emulator-${device.id}`, - booted: true, - }, - ]); - mockStopIosRunner.mockReset(); - mockStopIosRunner.mockResolvedValue(undefined); - mockReleaseRunnerOnClose.mockReset(); -}); - -function createHandler(sessionStore: ReturnType) { - return createRequestHandler({ - createCommandSurface: fakeCreateCommandSurface, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - token: 'test-token', - sessionStore, - leaseRegistry: new LeaseRegistry(), - deviceRuntimeGateway: realLifecycleDeviceRuntimeGateway, - deviceInventoryGateways: createTestDeviceInventoryGateways(), - platformResourceCleanup, - trackDownloadableArtifact: () => 'artifact-id', - }); -} - -let requestSeq = 0; - -function sessionRequest( - session: string, - command: 'close', - options: { - positionals?: string[]; - flags?: Record; - } = {}, -): DaemonRequest { - requestSeq += 1; - return { - token: 'test-token', - session, - command, - positionals: options.positionals ?? [], - flags: options.flags ?? {}, - meta: { requestId: `req-close-retention-${requestSeq}` }, - }; -} - -test('close on iOS simulator session retains runner and deletes the session', async () => { - const sessionStore = makeSessionStore('agent-device-close-retention-'); - const sessionName = 'ios-simulator-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - appName: 'com.example.app', - }), - ); - - const response = await createHandler(sessionStore)(sessionRequest(sessionName, 'close')); - - expect(response.ok).toBe(true); - expect(mockStopIosRunner).not.toHaveBeenCalled(); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: true }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('close on iOS simulator with scoped simulator set retains runner and deletes the session', async () => { - const sessionStore = makeSessionStore('agent-device-close-retention-'); - const sessionName = 'ios-scoped-simulator-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - simulatorSetPath: '/tmp/tenant-a/simulator-set', - }, - appName: 'com.example.app', - }), - ); - - const response = await createHandler(sessionStore)(sessionRequest(sessionName, 'close')); - - expect(response.ok).toBe(true); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: true }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('close on iOS simulator retains runner while terminating app', async () => { - const sessionStore = makeSessionStore('agent-device-close-retention-'); - const sessionName = 'ios-simulator-close-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - appName: 'com.example.app', - }), - ); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - mockReleaseRunnerOnClose.mockImplementation(async (_deviceId, options) => { - calls.push(`release-runner:${options.retain ? 'retain' : 'stop'}`); - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${(positionals ?? []).join(' ')}`); - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'close', { positionals: ['com.example.app'] }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual(['close:com.example.app', 'release-runner:retain']); -}); diff --git a/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts b/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts deleted file mode 100644 index 816e87ff6f..0000000000 --- a/src/__tests__/daemon-composition/handlers/session-device-claims-cancellation.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { afterEach, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; - -vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { - const actual = - await importOriginal(); - const { selectionFromResolveTargetDevice } = - await import('@agent-device/daemon/testing/__tests__/device-selection-stub'); - const resolveTargetDevice = vi.fn(); - return { - ...actual, - resolveTargetDevice, - resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(resolveTargetDevice)), - }; -}); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); -vi.mock('@agent-device/platform-android/mechanics', () => ({ - activateAndroidTestIme: vi.fn(async () => ({ activated: false })), - restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })), - stopAndroidSnapshotHelperSessionForDevice: vi.fn(async () => {}), - resolveAndroidPackageForOpen: vi.fn(async () => undefined), - inferAndroidPackageAfterOpen: vi.fn( - async (_device, _target, currentAppBundleId) => currentAppBundleId, - ), -})); -vi.mock('@agent-device/host-kit/process', async (importOriginal) => - (await import('@agent-device/test-kit/host-process-mock')).pinOwnProcessStartTime(importOriginal), -); - -import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; -import { ensureDeviceReady } from '@agent-device/daemon/testing/device/device-ready'; -import { - activateAndroidTestIme, - resolveAndroidPackageForOpen, -} from '@agent-device/platform-android/mechanics'; -import { clearRequestCanceled, markRequestCanceled } from '@agent-device/host-kit/request'; -import { inspectDeviceClaims } from '@agent-device/daemon-contracts/device-claims'; -import { - localRuntimeOwner, - narrowDeviceBinding, - type DeviceBinding, -} from '@agent-device/contracts/platform-runtime'; -import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import type { BindDeviceRuntime } from '@agent-device/daemon/testing/request-runtime-binding'; -import { - composedLifecycleRuntimeFixture, - dispatchApplicationLifecycleEffect, -} from '../application-lifecycle-runtime-fixture.ts'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { handleSessionOpenCommands as handleProductionOpenCommand } from '@agent-device/daemon/testing/session-lifecycle/index'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { inspectLifecycleRuntimeFacts } from '@agent-device/daemon/testing/__tests__/application-lifecycle-runtime-harness'; - -const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); -const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); -const reconcileOrphanedDeviceClaim = async () => ({ - status: 'retained' as const, - reason: 'test-no-recovery', -}); - -// The post-open test-IME activation is the real Android lifecycle's sequencing step after the -// open dispatch — the daemon-lane canned binding performs no IME leg, so this claim-retention -// case runs in the composition lane on the real composed lifecycle. The gateway seam's bind -// takes a full request context, which this suite's open call owns; the binding shape here -// mirrors it at the (device, use) seam the open command passes. -async function realLifecycleBinding( - device: DeviceInfo, -): Promise> { - return { - device, - owner: localRuntimeOwner(device.platform), - facts: await inspectLifecycleRuntimeFacts(device), - operations: await composedLifecycleRuntimeFixture(device), - [Symbol.asyncDispose]: async () => {}, - }; -} - -const bindRealLifecycleRuntime: BindDeviceRuntime = async (device, use) => - narrowDeviceBinding(await realLifecycleBinding(device), use); - -let previousClaimsDir: string | undefined; - -afterEach(() => { - // The claims-dir override is this suite's scope: restore it so later tests in - // the same worker never inspect or write claims under this case's temp root. - if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; - else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; - previousClaimsDir = undefined; - vi.clearAllMocks(); - mockEnsureDeviceReady.mockResolvedValue(undefined); - mockResolveAndroidPackage.mockResolvedValue(undefined); -}); - -function setup(): { store: SessionStore; stateDir: string } { - const stateDir = mkdtempForTestSync('agent-device-session-device-claim-'); - const claimsDir = path.join(stateDir, 'claims'); - previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; - process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; - return { store: new SessionStore(path.join(stateDir, 'sessions')), stateDir }; -} - -const android: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -}; - -test('cancellation after local device setup retains the device claim for recovery', async () => { - const { store, stateDir } = setup(); - const requestId = 'claim-canceled-after-dispatch'; - mockResolveTargetDevice.mockResolvedValue(android); - mockDispatch.mockResolvedValue(undefined); - markRequestCanceled(requestId); - - try { - const response = await handleProductionOpenCommand({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - command: 'open', - token: 'test', - session: 'claim-canceled-after-dispatch', - positionals: ['Demo'], - flags: { platform: 'android' }, - meta: { requestId }, - }, - sessionName: 'claim-canceled-after-dispatch', - logPath: path.join(stateDir, 'daemon.log'), - sessionStore: store, - inspectFacts: inspectLifecycleRuntimeFacts, - bindDevice: bindRealLifecycleRuntime, - reconcileOrphanedDeviceClaim, - }); - - assert.equal(response.ok, false); - assert.equal(mockDispatch.mock.calls.length, 1); - assert.equal(vi.mocked(activateAndroidTestIme).mock.calls.length, 1); - assert.equal(inspectDeviceClaims({ serial: android.id })[0]?.classification, 'live'); - assert.equal(store.get('claim-canceled-after-dispatch'), undefined); - } finally { - clearRequestCanceled(requestId); - } -}); diff --git a/src/__tests__/daemon-composition/handlers/session-doctor-warmup.test.ts b/src/__tests__/daemon-composition/handlers/session-doctor-warmup.test.ts deleted file mode 100644 index 52e754dd51..0000000000 --- a/src/__tests__/daemon-composition/handlers/session-doctor-warmup.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { beforeEach, expect, test, vi } from 'vitest'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { isActiveProviderDevice } from '@agent-device/daemon/testing/provider-device-admission'; -import { handleDoctorCommand } from '@agent-device/daemon/testing/handlers/session-doctor'; -import { createHostDiagnostics } from '../../../platform-runtime-host-diagnostics.ts'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; - -const { mockAppleRunnerWarmupCheck } = vi.hoisted(() => ({ - mockAppleRunnerWarmupCheck: vi.fn(), -})); - -vi.mock('@agent-device/platform-apple/doctor', async (importOriginal) => ({ - ...(await importOriginal()), - appleRunnerWarmupCheck: mockAppleRunnerWarmupCheck, -})); -vi.mock('@agent-device/daemon/testing/handlers/session-doctor-app', () => ({ - appendAppChecks: vi.fn(async () => {}), -})); -vi.mock('@agent-device/daemon/testing/handlers/session-doctor-metro', () => ({ - probeMetro: vi.fn(async () => ({ id: 'metro', status: 'pass', summary: 'mocked' })), -})); -vi.mock('@agent-device/daemon/testing/provider-device-admission', () => ({ - isActiveProviderDevice: vi.fn(() => false), -})); - -const mockIsActiveProviderDevice = vi.mocked(isActiveProviderDevice); - -async function withMockedPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { - const original = process.platform; - Object.defineProperty(process, 'platform', { value: platform, configurable: true }); - try { - return await fn(); - } finally { - Object.defineProperty(process, 'platform', { value: original, configurable: true }); - } -} - -const IOS_SIMULATOR: DeviceInfo = { - platform: 'apple', - id: 'doctor-sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - target: 'mobile', - booted: true, -}; - -beforeEach(() => { - mockAppleRunnerWarmupCheck.mockReset(); - mockAppleRunnerWarmupCheck.mockResolvedValue(undefined); - mockIsActiveProviderDevice.mockReset(); - mockIsActiveProviderDevice.mockReturnValue(false); -}); - -async function runDoctorWithSessionDevice(device: DeviceInfo): Promise { - const sessionStore = makeSessionStore('agent-device-doctor-warmup-'); - sessionStore.publish('doctor-session', { - name: 'doctor-session', - createdAt: Date.now(), - device, - actions: [], - }); - return await withTestDeviceInventory( - {}, - async () => - await handleDoctorCommand({ - req: { - token: 't', - session: 'doctor-session', - command: 'doctor', - positionals: [], - flags: { session: 'doctor-session' }, - }, - sessionName: 'doctor-session', - sessionStore, - hostDiagnostics: createHostDiagnostics(), - }), - ); -} - -async function runSessionlessDoctor( - source: 'host' | 'provider', - flags: { targetApp?: string } = {}, -): Promise { - const inventory = - source === 'host' - ? { local: async () => [IOS_SIMULATOR] } - : { - provider: { - discover: async () => ({ kind: 'inventory' as const, devices: [IOS_SIMULATOR] }), - }, - }; - return await withTestDeviceInventory( - inventory, - async () => - await handleDoctorCommand({ - req: { token: 't', session: 'default', command: 'doctor', positionals: [], flags }, - sessionName: 'default', - sessionStore: makeSessionStore('agent-device-doctor-warmup-'), - hostDiagnostics: createHostDiagnostics(), - }), - ); -} - -function readCheck(response: DaemonResponse | null, id: string): Record | null { - if (!response?.ok) return null; - const checks = (response.data as { checks?: Array> }).checks ?? []; - return checks.find((check) => check.id === id) ?? null; -} - -test('doctor warms the iOS runner cache in the background when the artifact is missing', async () => { - mockAppleRunnerWarmupCheck.mockResolvedValue({ - id: 'ios-runner-cache', - status: 'pass', - summary: 'iOS runner build started in the background', - }); - const response = await withMockedPlatform('darwin', () => - runDoctorWithSessionDevice(IOS_SIMULATOR), - ); - - expect(response?.ok).toBe(true); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledTimes(1); - const check = readCheck(response, 'ios-runner-cache'); - expect(check?.status).toBe('pass'); - expect(String(check?.summary)).toMatch(/background/i); -}); - -test('doctor reports a cached iOS runner artifact without rebuilding', async () => { - mockAppleRunnerWarmupCheck.mockResolvedValue({ - id: 'ios-runner-cache', - status: 'pass', - summary: 'iOS runner artifact cached', - }); - - const response = await withMockedPlatform('darwin', () => - runDoctorWithSessionDevice(IOS_SIMULATOR), - ); - - expect(response?.ok).toBe(true); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledTimes(1); - const check = readCheck(response, 'ios-runner-cache'); - expect(String(check?.summary)).toMatch(/cached/i); -}); - -test('doctor skips the runner warmup for non-simulator devices', async () => { - const response = await withMockedPlatform('darwin', () => - runDoctorWithSessionDevice({ - ...IOS_SIMULATOR, - id: 'doctor-device-1', - kind: 'device', - }), - ); - - expect(response?.ok).toBe(true); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledTimes(1); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'device' }), - expect.any(Object), - ); - expect(readCheck(response, 'ios-runner-cache')).toBeNull(); -}); - -test('doctor skips the runner warmup on non-macOS hosts', async () => { - const response = await withMockedPlatform('linux', () => - runDoctorWithSessionDevice(IOS_SIMULATOR), - ); - - expect(response?.ok).toBe(true); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledTimes(1); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledWith(IOS_SIMULATOR, expect.any(Object)); - expect(readCheck(response, 'ios-runner-cache')).toBeNull(); -}); - -test('doctor skips the runner warmup for provider-backed devices', async () => { - mockIsActiveProviderDevice.mockReturnValue(true); - - const response = await withMockedPlatform('darwin', () => - runDoctorWithSessionDevice(IOS_SIMULATOR), - ); - - expect(response?.ok).toBe(true); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledTimes(1); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledWith(IOS_SIMULATOR, expect.any(Object)); - expect(readCheck(response, 'ios-runner-cache')).toBeNull(); -}); - -const SESSIONLESS_WARMUP_CANDIDATES = [ - { name: 'an inventory simulator', flags: {} }, - { name: 'the --app device', flags: { targetApp: 'com.example.demo' } }, -]; - -test.each(SESSIONLESS_WARMUP_CANDIDATES)( - 'doctor warms the runner cache for $name this host discovered', - async ({ flags }) => { - const response = await runSessionlessDoctor('host', flags); - - expect(response?.ok).toBe(true); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledTimes(1); - expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledWith( - expect.objectContaining({ id: IOS_SIMULATOR.id }), - expect.any(Object), - ); - }, -); - -test.each(SESSIONLESS_WARMUP_CANDIDATES)( - 'doctor starts no runner warmup for $name a provider reported', - async ({ flags }) => { - const response = await runSessionlessDoctor('provider', flags); - - expect(response?.ok).toBe(true); - expect(mockAppleRunnerWarmupCheck).not.toHaveBeenCalled(); - }, -); diff --git a/src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts b/src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts deleted file mode 100644 index d44fae6348..0000000000 --- a/src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts +++ /dev/null @@ -1,753 +0,0 @@ -import { test, expect, vi, beforeEach } from 'vitest'; -import * as path from 'node:path'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { providerDeviceAdmissionFixture } from '@agent-device/daemon/testing/__tests__/support/provider-device-admission-fixture'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; - -// The daemon reads provider ownership through its own typed admission seam; these suites -// install the seam over runtime fakes, the way root composition installs the real one. -const providerAdmission = providerDeviceAdmissionFixture(); -import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { makeSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { getResolveTargetDeviceMock } from '@agent-device/daemon/testing/__tests__/request-router-dispatch-mocks'; -import type { DaemonRequest } from '@agent-device/daemon'; - -vi.mock('node:timers/promises', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, setTimeout: vi.fn(async () => undefined) }; -}); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - prepareIosRunner: vi.fn(async () => ({ - runner: { currentUptimeMs: 42 }, - connectMs: 3, - healthCheckMs: 3, - })), - prewarmAppleRunnerCache: vi.fn(), - prewarmIosRunnerSession: vi.fn(), - notifyIosRunnerAppRelaunched: vi.fn(async () => {}), - // A retained Simulator runner survives the relaunch, so its cached target is reset. - hasLiveIosRunnerSession: vi.fn(() => true), - stopIosRunnerSession: vi.fn(async () => {}), - releaseIosRunnerOnClose: vi.fn(async () => {}), - }; -}); -vi.mock('@agent-device/platform-apple/macos', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) }; -}); -vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - resolveIosApp: vi.fn(async (_device, app: string) => app), - resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), - }; -}); -vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { - const actual = await importOriginal(); - // Every Android target these tests open is an installed package, which resolves to itself. - return { - ...actual, - resolveAndroidPackageForOpen: vi.fn(async (_device: unknown, target?: string) => target), - }; -}); - -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { realLifecycleDeviceRuntimeGateway } from '../lifecycle-runtime-gateway.ts'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; -import { - dispatchApplicationLifecycleEffect, - awaitFixtureReadiness, - discoverReadyAndroidEmulators, -} from '../application-lifecycle-runtime-fixture.ts'; -import { ensureDeviceReady } from '@agent-device/daemon/testing/device/device-ready'; -import { - prewarmIosRunnerSession, - notifyIosRunnerAppRelaunched, - stopIosRunnerSession, - releaseIosRunnerOnClose, -} from '@agent-device/platform-apple/runner/operations'; -import { runMacOsAlertAction } from '@agent-device/platform-apple/macos'; -import { refFrameState } from '@agent-device/daemon/testing/ref-frame'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; - -const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); -const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); -const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); -const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); -const mockNotifyIosRunnerAppRelaunched = vi.mocked(notifyIosRunnerAppRelaunched); -const mockStopIosRunner = vi.mocked(stopIosRunnerSession); -const mockReleaseRunnerOnClose = vi.mocked(releaseIosRunnerOnClose); -const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); - -beforeEach(() => { - vi.useRealTimers(); - mockResolveTargetDevice.mockReset(); - mockEnsureDeviceReady.mockReset(); - mockEnsureDeviceReady.mockResolvedValue(undefined); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue(undefined); - mockAwaitFixtureReadiness.mockReset(); - mockAwaitFixtureReadiness.mockResolvedValue(undefined); - mockDiscoverReadyAndroidEmulators.mockReset(); - mockDiscoverReadyAndroidEmulators.mockImplementation(async (device) => [ - { - ...device, - id: device.id.startsWith('emulator-') ? device.id : `emulator-${device.id}`, - booted: true, - }, - ]); - mockPrewarmIosRunnerSession.mockReset(); - mockNotifyIosRunnerAppRelaunched.mockReset(); - mockNotifyIosRunnerAppRelaunched.mockResolvedValue(undefined); - mockStopIosRunner.mockReset(); - mockStopIosRunner.mockResolvedValue(undefined); - mockReleaseRunnerOnClose.mockReset(); - mockDismissMacOsAlert.mockReset(); - mockDismissMacOsAlert.mockResolvedValue({} as any); -}); - -function createHandler( - sessionStore: ReturnType, - leaseRegistry: LeaseRegistry = new LeaseRegistry(), -) { - return createRequestHandler({ - createCommandSurface: fakeCreateCommandSurface, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - token: 'test-token', - sessionStore, - leaseRegistry, - deviceRuntimeGateway: realLifecycleDeviceRuntimeGateway, - platformResourceCleanup, - deviceInventoryGateways: createTestDeviceInventoryGateways(), - trackDownloadableArtifact: () => 'artifact-id', - }); -} - -let requestSeq = 0; - -function sessionRequest( - session: string, - command: 'open' | 'close', - options: { - positionals?: string[]; - flags?: Record; - internal?: DaemonRequest['internal']; - } = {}, -): DaemonRequest { - requestSeq += 1; - return { - token: 'test-token', - session, - command, - positionals: options.positionals ?? [], - flags: options.flags ?? {}, - internal: options.internal, - meta: { requestId: `req-${command}-${requestSeq}` }, - }; -} - -test('open --relaunch closes and reopens active session app', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'android-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }, - appName: 'com.example.app', - }), - ); - - const calls: Array<{ command: string; positionals: string[] }> = []; - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push({ command, positionals: positionals ?? [] }); - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { flags: { relaunch: true } }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual([ - { command: 'close', positionals: ['com.example.app'] }, - { command: 'open', positionals: ['com.example.app'] }, - ]); -}); - -test('open --relaunch leaves the old frame expired when the close dispatch fails after dispatch (ADR 0014)', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'android-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, - }, - appName: 'com.example.app', - snapshotGeneration: 400, - }), - ); - // A freshly issued frame is active before the relaunch. - expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); - - // The relaunch close dispatches and then fails/times out AFTER the app may - // already have been torn down. - mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'close') throw new Error('adb: close timed out after dispatch'); - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { flags: { relaunch: true } }), - ).catch(() => null); - - // Whether the failure surfaced as an error response or a throw, the existing - // session's frame was expired BEFORE the close dispatch and stays expired — a - // post-dispatch close failure never restores it (there is no rollback). - expect(response?.ok ?? false).toBe(false); - expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); -}); - -test('open --relaunch does not let an ambient provider claim suppress a local pre-close', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'provider-android-session'; - const device = { - platform: 'android' as const, - id: 'provider-android-1', - name: 'Provider Android', - kind: 'emulator' as const, - booted: true, - }; - providerAdmission.setActive([ - { - provider: 'fake-provider', - leaseLifecycle: {}, - deviceInventoryProvider: async () => [device], - ownsDevice: (candidate) => candidate.id === device.id, - getInteractor: () => undefined, - shutdown: async () => {}, - }, - ]); - mockResolveTargetDevice.mockResolvedValue(device); - - const calls: Array<{ command: string; positionals: string[] }> = []; - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push({ command, positionals: positionals ?? [] }); - return {}; - }); - - try { - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { - positionals: ['com.example.app'], - flags: { relaunch: true, platform: 'android' }, - }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual([ - { command: 'close', positionals: ['com.example.app'] }, - { command: 'open', positionals: ['com.example.app'] }, - ]); - } finally { - providerAdmission.setActive([]); - } -}); - -test('open --relaunch on physical iOS retains runner through close/open', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-session'; - const device = { - platform: 'apple' as const, - appleOs: 'ios' as const, - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device' as const, - booted: true, - }; - sessionStore.publish( - sessionName, - makeSession(sessionName, { device, appName: 'com.example.app' }), - ); - - const calls: string[] = []; - mockResolveTargetDevice.mockResolvedValue(device); - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - mockNotifyIosRunnerAppRelaunched.mockImplementation(async () => { - calls.push('reset-runner-target'); - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${(positionals ?? []).join(' ')}`); - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { flags: { relaunch: true } }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual(['close:com.example.app', 'open:com.example.app', 'reset-runner-target']); - expect(mockStopIosRunner).not.toHaveBeenCalled(); -}); - -test('open --relaunch on iOS simulator collapses into one terminate-running open dispatch', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-simulator-session'; - const device = { - platform: 'apple' as const, - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator' as const, - booted: true, - }; - sessionStore.publish( - sessionName, - makeSession(sessionName, { device, appName: 'com.example.app' }), - ); - - const calls: string[] = []; - mockResolveTargetDevice.mockResolvedValue(device); - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - let openContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { - calls.push(`${command}:${(positionals ?? []).join(' ')}`); - if (command === 'open') openContext = context as Record; - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { flags: { relaunch: true } }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual(['open:com.example.app']); - expect(openContext?.terminateRunningApp).toBe(true); - expect(mockNotifyIosRunnerAppRelaunched).toHaveBeenCalledWith( - expect.objectContaining({ id: 'sim-1' }), - expect.any(Object), - ); -}); - -test('open --relaunch on iOS simulator delegates termination to the URL open', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-simulator-url-relaunch-session'; - const device = { - platform: 'apple' as const, - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator' as const, - booted: true, - }; - sessionStore.publish( - sessionName, - makeSession(sessionName, { device, appName: 'com.example.app' }), - ); - - const calls: string[] = []; - mockResolveTargetDevice.mockResolvedValue(device); - let openContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { - calls.push(`${command}:${(positionals ?? []).join(' ')}`); - if (command === 'open') openContext = context as Record; - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { - positionals: ['com.example.app', 'https://example.com/deal'], - flags: { relaunch: true }, - }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual(['open:com.example.app https://example.com/deal']); - expect(openContext?.terminateRunningApp).toBe(true); -}); - -test('open --relaunch --clear-app-state on iOS simulator keeps close-first ordering', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-simulator-clear-state-session'; - const device = { - platform: 'apple' as const, - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator' as const, - booted: true, - }; - sessionStore.publish( - sessionName, - makeSession(sessionName, { device, appName: 'com.example.app' }), - ); - - const calls: string[] = []; - mockResolveTargetDevice.mockResolvedValue(device); - let openContext: Record | undefined; - mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { - calls.push(`${command}:${(positionals ?? []).join(' ')}`); - if (command === 'open') openContext = context as Record; - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { flags: { relaunch: true, clearAppState: true } }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual(['close:com.example.app', 'open:com.example.app']); - expect(openContext?.terminateRunningApp).toBeUndefined(); -}); - -test('open --relaunch includes timing and waits for iOS runner prewarm after opening app', async () => { - vi.useFakeTimers({ now: 1_000 }); - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-timing-session'; - const events: string[] = []; - const device = { - platform: 'apple' as const, - appleOs: 'ios' as const, - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device' as const, - booted: true, - }; - sessionStore.publish( - sessionName, - makeSession(sessionName, { device, appName: 'Example', appBundleId: 'com.example.app' }), - ); - - mockPrewarmIosRunnerSession.mockImplementation( - () => - new Promise((resolve) => { - events.push('prewarm-start'); - setTimeout(() => { - events.push('prewarm-finish'); - resolve(); - }, 250); - }), - ); - mockStopIosRunner.mockImplementation(async () => { - events.push('stop-runner'); - }); - mockDispatch.mockImplementation(async (_device, command) => { - events.push(`dispatch:${command}`); - return {}; - }); - - const responsePromise = createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { flags: { relaunch: true } }), - ); - - await vi.advanceTimersByTimeAsync(250); - const response = await responsePromise; - - expect(response.ok).toBe(true); - expect(events).toEqual(['dispatch:close', 'dispatch:open', 'prewarm-start', 'prewarm-finish']); - expect(mockStopIosRunner).not.toHaveBeenCalled(); - expect((response as any).data?.timing).toMatchObject({ - runnerPrewarmKind: 'session', - runnerPrewarmScheduled: true, - runnerPrewarmWaited: true, - runnerPrewarmDurationMs: 250, - }); - expect((response as any).data?.timing?.totalDurationMs).toBeGreaterThanOrEqual(250); -}); - -test('open --relaunch on iOS without existing session closes then opens target app', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-new-session'; - mockResolveTargetDevice.mockResolvedValue({ - platform: 'apple', - appleOs: 'ios', - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - mockNotifyIosRunnerAppRelaunched.mockImplementation(async () => { - calls.push('reset-runner-target'); - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${(positionals ?? []).join(' ')}`); - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'open', { - positionals: ['com.example.app'], - flags: { relaunch: true, platform: 'ios' }, - }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual(['close:com.example.app', 'open:com.example.app', 'reset-runner-target']); - expect(mockStopIosRunner).not.toHaveBeenCalled(); -}); - -test('close on macOS session stops runner and dismisses automation alert before delete', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'macos-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }, - appBundleId: 'com.apple.systempreferences', - appName: 'System Settings', - }), - ); - - const calls: string[] = []; - mockReleaseRunnerOnClose.mockImplementation(async (deviceId, options) => { - calls.push(`release-runner:${deviceId}:${options.retain ? 'retain' : 'stop'}`); - }); - mockDismissMacOsAlert.mockImplementation(async (action, options) => { - calls.push( - `dismiss-alert:${action}:${(options as any)?.bundleId ?? (options as any)?.surface ?? 'frontmost'}`, - ); - return {}; - }); - - const response = await createHandler(sessionStore)(sessionRequest(sessionName, 'close')); - - expect(response.ok).toBe(true); - expect(calls).toEqual([ - 'release-runner:host-macos-local:stop', - 'dismiss-alert:dismiss:com.apple.systempreferences', - ]); - expect(sessionStore.get(sessionName)).toBe(undefined); -}); - -test('close on leased iOS simulator session stops runner before deleting session', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-leased-simulator-session'; - const leaseRegistry = new LeaseRegistry(); - const lease = leaseRegistry.allocateLease({ - tenantId: 'tenant-a', - runId: 'run-1', - leaseBackend: 'ios-simulator', - deviceKey: 'ios:sim-1', - clientId: 'client-a', - }); - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - appName: 'com.example.app', - lease: { - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - leaseBackend: lease.backend, - deviceKey: lease.deviceKey, - clientId: lease.clientId, - expiresAt: lease.expiresAt, - }, - }), - ); - - const response = await createHandler( - sessionStore, - leaseRegistry, - )(sessionRequest(sessionName, 'close')); - - expect(response.ok).toBe(true); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: false }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('close --shutdown on iOS simulator stops runner before deleting session', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-simulator-shutdown-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - appName: 'com.example.app', - }), - ); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'close', { flags: { shutdown: true } }), - ); - - expect(response.ok).toBe(true); - expect(mockReleaseRunnerOnClose).toHaveBeenCalledWith('sim-1', { retain: false }); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('close on iOS stops runner before app close dispatch and performs final idempotent stop', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-close-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }, - appName: 'com.example.app', - }), - ); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async () => { - calls.push('stop-runner'); - }); - mockReleaseRunnerOnClose.mockImplementation(async (_deviceId, options) => { - calls.push(`release-runner:${options.retain ? 'retain' : 'stop'}`); - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${(positionals ?? []).join(' ')}`); - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'close', { positionals: ['com.example.app'] }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'release-runner:stop']); -}); - -test('app-only close terminates an iOS simulator app without ending its session', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'ios-simulator-app-only-close'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }, - appBundleId: 'com.example.app', - appName: 'Example App', - }), - ); - - mockDispatch.mockImplementation(async () => ({})); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'close', { - positionals: ['com.example.app'], - internal: { closeAppOnly: true }, - }), - ); - - expect(response.ok).toBe(true); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ id: 'sim-1' }), - 'close', - ['com.example.app'], - undefined, - expect.any(Object), - ); - expect(mockStopIosRunner).not.toHaveBeenCalled(); - expect(sessionStore.get(sessionName)).toBeDefined(); -}); - -test('close on macOS stops runner before app close dispatch and dismisses automation alert', async () => { - const sessionStore = makeSessionStore('agent-device-relaunch-close-'); - const sessionName = 'macos-close-session'; - sessionStore.publish( - sessionName, - makeSession(sessionName, { - device: { - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }, - appBundleId: 'com.apple.systempreferences', - appName: 'System Settings', - }), - ); - - const calls: string[] = []; - mockStopIosRunner.mockImplementation(async (deviceId) => { - calls.push(`stop-runner:${deviceId}`); - }); - mockReleaseRunnerOnClose.mockImplementation(async (deviceId, options) => { - calls.push(`release-runner:${deviceId}:${options.retain ? 'retain' : 'stop'}`); - }); - mockDismissMacOsAlert.mockImplementation(async (action, options) => { - calls.push( - `dismiss-alert:${action}:${(options as any)?.bundleId ?? (options as any)?.surface ?? 'frontmost'}`, - ); - return {}; - }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { - calls.push(`${command}:${(positionals ?? []).join(' ')}`); - return {}; - }); - - const response = await createHandler(sessionStore)( - sessionRequest(sessionName, 'close', { positionals: ['System Settings'] }), - ); - - expect(response.ok).toBe(true); - expect(calls).toEqual([ - 'stop-runner:host-macos-local', - 'close:System Settings', - 'release-runner:host-macos-local:stop', - 'dismiss-alert:dismiss:com.apple.systempreferences', - ]); -}); diff --git a/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts b/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts deleted file mode 100644 index 364cf50529..0000000000 --- a/src/__tests__/daemon-composition/handlers/session-relaunch-guards.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { test, expect } from 'vitest'; -import * as path from 'node:path'; -import { - mockResolveTargetDevice, - makeSessionStore, - makeSession, - noopInvoke, - assertInvalidArgsMessage, -} from '@agent-device/daemon/testing/handlers/__tests__/session-test-harness'; -import { handleSessionCommands } from '@agent-device/daemon/testing/handlers/__tests__/session-command-harness'; -import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; - -// These relaunch cases pin the REAL open-target validation rules (the URL-target refusal and the -// Android installed-package classification with its exact message) reached through the daemon's -// `open --relaunch` flow. The daemon-lane port fixture answers `validateOpenRelaunchTarget` with a -// canned accept, so the rule-pinning cases run here on the real composed services. - -test('open --relaunch rejects URL targets', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['https://example.com/path'], - flags: { relaunch: true }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/does not support URL targets/i); - } -}); - -test('open --relaunch rejects Android app binary paths', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects bare Android app binary filenames', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects Android app binary paths for active sessions', async () => { - const sessionStore = makeSessionStore(); - const session = makeSession('default', { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }); - session.appName = 'com.example.app'; - session.appBundleId = 'com.example.app'; - sessionStore.publish('default', session); - - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); -}); - -test('open --relaunch rejects Android app binary paths before resolving a new device', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: 'default', - command: 'open', - positionals: ['/tmp/app-debug.apk'], - flags: { relaunch: true, platform: 'android' }, - }, - sessionName: 'default', - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - assertInvalidArgsMessage( - response, - 'Android runtime hints require an installed package name, not "/tmp/app-debug.apk". Install or reinstall the app first, then relaunch by package.', - ); - expect(mockResolveTargetDevice).not.toHaveBeenCalled(); -}); diff --git a/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts b/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts deleted file mode 100644 index 4c0f166d30..0000000000 --- a/src/__tests__/daemon-composition/handlers/snapshot-handler-freshness.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { composedPlatformServicesFixture } from '../platform-services-fixture.ts'; -import { test, expect, vi, afterEach, beforeEach } from 'vitest'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { resetGetRuntimeFixture } from '@agent-device/daemon/testing/__tests__/interaction-get-runtime-fixture'; -import fs from 'node:fs'; -import { captureSnapshot } from '@agent-device/daemon/testing/snapshot-capture'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { - isActiveProviderDevice, - setActiveProviderDeviceRuntimes, -} from '../../../provider-device-runtime.ts'; -import { installProviderDeviceAdmission } from '@agent-device/daemon/testing/provider-device-admission'; -import { testCreateCommandSurface } from '../command-surface-fixture.ts'; - -// The daemon reads provider ownership through its own typed admission seam; production -// installs it from root composition, and these tests compose it the same way. -installProviderDeviceAdmission({ isActive: isActiveProviderDevice }); -import { AppError } from '@agent-device/kernel/errors'; -import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; -import { snapshotCliOutput } from '../../../commands/capture/output.ts'; -import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; -import { buildNodes } from '@agent-device/test-kit/snapshot-builders'; -import { - fixtureScreenshotCaptures, - resetSnapshotRuntimeFixture, -} from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import { - androidCapture, - androidDevice, - androidTextRows, - handleSnapshotCommands, - inboxBaselineNodes, - inboxRow, - iosSimulatorDevice, - makeAndroidFreshnessSession, - makeSession, - makeSessionStore, - snapshotRequest, -} from '@agent-device/daemon/testing/handlers/__tests__/snapshot-handler.fixtures'; - -vi.mock('@agent-device/daemon/testing/snapshot-interactor-capture', async () => { - const fixture = - await import('@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'); - return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; -}); -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn(async () => ({})) }; -}); - -// The real implementation shells out to simctl to probe for a hint-worthy -// unambiguous environment; that live-probe logic is covered by -// ios-app-session-hint.test.ts. Stubbed here so this suite stays hermetic and -// fast — defaults to "no enrichment", matching the current-behavior fallback. -vi.mock('@agent-device/daemon/testing/ios-app-session-hint', () => ({ - buildIosOpenCommandHint: vi.fn(async () => undefined), -})); - -import { runAppleRunnerCommand } from '@agent-device/platform-apple/runner/operations'; -import { buildIosOpenCommandHint } from '@agent-device/daemon/testing/ios-app-session-hint'; - -const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); -const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); - -afterEach(() => { - setActiveProviderDeviceRuntimes([]); -}); - -beforeEach(() => { - resetSnapshotRuntimeFixture(); - legacyDispatchCapture.mockReset(); - legacyDispatchCapture.mockResolvedValue({}); - resetGetRuntimeFixture(); - mockRunnerCommand.mockReset(); - mockRunnerCommand.mockResolvedValue({}); - mockBuildIosOpenCommandHint.mockReset(); - mockBuildIosOpenCommandHint.mockResolvedValue(undefined); -}); - -function makeAndroidTimeoutEvidenceSession(sessionName: string): SessionStore { - const sessionStore = makeSessionStore(); - const session = makeSession(sessionName, androidDevice); - session.snapshot = { - nodes: [ - { - ref: 'e1', - index: 0, - depth: 0, - type: 'android.widget.Button', - label: 'Continue', - hittable: true, - rect: { x: 20, y: 40, width: 120, height: 48 }, - }, - ], - createdAt: Date.now(), - backend: 'android', - }; - sessionStore.publish(sessionName, session); - return sessionStore; -} - -function mockAndroidTimeoutEvidenceDispatch(): void { - legacyDispatchCapture.mockImplementation(async (_device, command) => { - if (command === 'snapshot') throw androidSnapshotTimeoutError(); - return {}; - }); -} - -function androidSnapshotTimeoutError(): AppError { - return new AppError( - 'COMMAND_FAILED', - 'Android snapshot helper timed out while waiting for the UI to become idle.', - { - cmd: 'adb', - args: ['shell', 'am', 'instrument'], - androidCaptureFailureReason: 'accessibility-timeout', - hint: 'Android accessibility snapshots can be blocked by busy or continuously changing app UI. Use screenshot as visual truth after this timeout.', - }, - ); -} - -function expectAndroidTimeoutEvidence( - response: Awaited>, -) { - if (!response) throw new Error('Expected snapshot response'); - if (response.ok) throw new Error('Expected snapshot timeout failure'); - expect(response.error.message).toMatch(/snapshot helper timed out/i); - expect(response.error.hint).toMatch(/Use screenshot as visual truth/i); - assertAndroidTimeoutEvidencePayload(response.error.details?.androidSnapshotTimeoutScreenshot); -} - -function assertAndroidTimeoutEvidencePayload(evidence: unknown) { - if (!evidence || typeof evidence !== 'object') { - throw new Error('Expected Android snapshot timeout screenshot evidence'); - } - const record = evidence as Record; - expect(record.path).toEqual(expect.stringContaining('snapshot-timeout-overlay-refs.png')); - expect(fs.existsSync(record.path as string)).toBe(true); - expect(record.overlayRefsAnnotated).toBe(true); - expect(record.overlayRefs).toEqual([expect.objectContaining({ ref: 'e1', label: 'Continue' })]); -} - -test('snapshot annotations survive a deferred post-gesture capture into CLI JSON', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-interaction-annotation-bundle'; - const session = makeSession(sessionName, androidDevice); - const baselineNodes = [ - { - ref: 'e1', - index: 0, - depth: 0, - type: 'android.widget.Button', - label: 'Open albums', - hittable: true, - rect: { x: 20, y: 120, width: 160, height: 48 }, - }, - ]; - const changedNodes = [ - { - index: 0, - depth: 0, - type: 'android.widget.TextView', - label: 'Albums', - rect: { x: 32, y: 240, width: 180, height: 52 }, - }, - ]; - const snapshotQuality = { state: 'healthy', backend: 'tree' }; - session.snapshot = { nodes: baselineNodes, createdAt: Date.now(), backend: 'android' }; - session.deferredSurface = { - kind: 'stabilize', - action: 'swipe', - positionals: [], - markedAt: Date.now(), - }; - sessionStore.publish(sessionName, session); - - legacyDispatchCapture.mockResolvedValue({ - nodes: changedNodes, - truncated: false, - backend: 'android', - analysis: { rawNodeCount: 1, maxDepth: 0 }, - quality: snapshotQuality, - warnings: ['backend warning from interaction capture'], - }); - - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'snapshot'), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(true); - if (!response?.ok) return; - - expect(response.data?.snapshotQuality).toEqual(snapshotQuality); - expect(response.data?.warnings).toEqual(['backend warning from interaction capture']); - - const cliOutput = await snapshotCliOutput({ - result: response.data as unknown as CaptureSnapshotResult, - }); - expect(cliOutput.jsonData).toMatchObject({ - nodes: [expect.objectContaining({ label: 'Albums' })], - truncated: false, - snapshotQuality, - warnings: ['backend warning from interaction capture'], - }); - expect(cliOutput.jsonData).not.toHaveProperty('analysis'); - expect(cliOutput.jsonData).not.toHaveProperty('freshness'); -}); - -test('snapshot timeout captures Android screenshot evidence with overlay refs', async () => { - const sessionName = 'android-timeout-evidence'; - const sessionStore = makeAndroidTimeoutEvidenceSession(sessionName); - mockAndroidTimeoutEvidenceDispatch(); - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'snapshot'), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - expectAndroidTimeoutEvidence(response); - expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot']); - expect(fixtureScreenshotCaptures.at(-1)?.options).toMatchObject({ stabilize: false }); -}); - -test('snapshot warns when recent snapshot node count collapses sharply', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-stale-collapse'; - const session = makeSession(sessionName, androidDevice); - session.snapshot = { - nodes: buildNodes(androidTextRows(50, (row) => `Row ${row}`)), - createdAt: Date.now(), - backend: 'android', - }; - sessionStore.publish(sessionName, session); - - legacyDispatchCapture.mockResolvedValue( - androidCapture( - androidTextRows(8, (row) => `Next ${row}`), - { rawNodeCount: 8, maxDepth: 1 }, - ), - ); - - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'snapshot'), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.warnings).toEqual([ - expect.stringContaining('Recent snapshots dropped sharply in node count'), - ]); - } -}); - -test('snapshot does not warn on expected node drop across presentation modes', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-presentation-drop'; - const session = makeSession(sessionName, iosSimulatorDevice); - session.appBundleId = 'com.example.app'; - session.snapshot = { - nodes: Array.from({ length: 50 }, (_, index) => ({ - ref: `e${index + 1}`, - index, - depth: 0, - type: 'StaticText', - label: `Row ${index + 1}`, - })), - createdAt: Date.now(), - backend: 'xctest', - presentationKey: buildSnapshotPresentationKey({ interactiveOnly: false }), - }; - sessionStore.publish(sessionName, session); - - legacyDispatchCapture.mockResolvedValue({ - nodes: Array.from({ length: 8 }, (_, index) => ({ - index, - depth: 0, - type: 'Button', - label: `Action ${index + 1}`, - hittable: true, - })), - truncated: false, - backend: 'xctest', - }); - - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.warnings ?? []).toEqual( - expect.not.arrayContaining([ - expect.stringContaining('Recent snapshots dropped sharply in node count'), - ]), - ); - } -}); - -test('snapshot automatically retries stale Android trees after recent navigation', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-stale-retries-to-fresh'; - const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); - sessionStore.publish(sessionName, session); - - legacyDispatchCapture - .mockResolvedValueOnce( - androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), - ) - .mockResolvedValueOnce( - androidCapture( - [ - { index: 0, depth: 0, type: 'android.widget.TextView', label: 'Create document' }, - { index: 1, depth: 0, type: 'android.widget.Button', label: 'Submit', hittable: true }, - ], - { rawNodeCount: 2, maxDepth: 1 }, - ), - ); - - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.warnings).toBeUndefined(); - expect(response.data?.nodes).toEqual( - expect.arrayContaining([expect.objectContaining({ label: 'Create document' })]), - ); - } - expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); - expect(sessionStore.get(sessionName)?.androidSnapshotFreshness).toBeUndefined(); -}); - -test('snapshot warns when Android freshness retries still return the previous route', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-stale-after-press'; - const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); - sessionStore.publish(sessionName, session); - - legacyDispatchCapture.mockResolvedValue( - androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), - ); - - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.warnings).toEqual([ - expect.stringContaining( - 'Recent press was followed by a nearly identical snapshot after 3 automatic retries', - ), - ]); - } - expect(legacyDispatchCapture).toHaveBeenCalledTimes(4); -}); - -test('snapshot response includes normalized visibility metadata', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-visibility'; - sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); - - legacyDispatchCapture.mockResolvedValue({ - nodes: [ - { - index: 0, - depth: 0, - type: 'android.widget.ScrollView', - label: 'Messages', - rect: { x: 0, y: 100, width: 390, height: 500 }, - hiddenContentBelow: true, - }, - { - index: 1, - depth: 1, - parentIndex: 0, - type: 'android.widget.Button', - label: 'Visible message', - rect: { x: 0, y: 140, width: 390, height: 48 }, - hittable: true, - }, - ], - truncated: false, - backend: 'android', - analysis: { rawNodeCount: 2, maxDepth: 1 }, - }); - - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.visibility).toEqual({ - partial: true, - visibleNodeCount: 2, - totalNodeCount: 2, - reasons: ['scroll-hidden-below'], - }); - } -}); - -test('diff snapshot carries stale-tree warnings for recent Android presses', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-diff-stale-after-press'; - const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); - sessionStore.publish(sessionName, session); - - legacyDispatchCapture.mockResolvedValue( - androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), - ); - - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'diff', { - positionals: ['snapshot'], - flags: { snapshotInteractiveOnly: true }, - }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.warnings).toEqual([ - expect.stringContaining( - 'Recent press was followed by a nearly identical snapshot after 3 automatic retries', - ), - ]); - } - expect(legacyDispatchCapture).toHaveBeenCalledTimes(4); -}); - -test('Android ref refresh mode does not retry narrow snapshots as sharp drops', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-ref-refresh-no-sharp-drop'; - const session = makeAndroidFreshnessSession( - sessionName, - 'press', - buildNodes(androidTextRows(50, (row) => `Previous row ${row}`)), - ); - sessionStore.publish(sessionName, session); - - legacyDispatchCapture.mockResolvedValue( - androidCapture( - Array.from({ length: 8 }, (_, index) => ({ - index, - depth: 0, - type: 'android.widget.TextView', - })), - { rawNodeCount: 8, maxDepth: 1 }, - ), - ); - - const result = await captureSnapshot({ - device: androidDevice, - session, - flags: { snapshotInteractiveOnly: true }, - logPath: '/tmp/daemon.log', - androidFreshnessMode: 'ref-refresh', - }); - - expect(result.freshness).toBeUndefined(); - expect(legacyDispatchCapture).toHaveBeenCalledTimes(1); - expect(session.androidSnapshotFreshness).toBeUndefined(); -}); diff --git a/src/__tests__/daemon-composition/handlers/snapshot-handler-wait.test.ts b/src/__tests__/daemon-composition/handlers/snapshot-handler-wait.test.ts deleted file mode 100644 index 3275a825bb..0000000000 --- a/src/__tests__/daemon-composition/handlers/snapshot-handler-wait.test.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { test, expect, vi, afterEach, beforeEach } from 'vitest'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { resetGetRuntimeFixture } from '@agent-device/daemon/testing/__tests__/interaction-get-runtime-fixture'; -import { - isActiveProviderDevice, - setActiveProviderDeviceRuntimes, -} from '../../../provider-device-runtime.ts'; -import { installProviderDeviceAdmission } from '@agent-device/daemon/testing/provider-device-admission'; -import { testCreateCommandSurface } from '../command-surface-fixture.ts'; - -// The daemon reads provider ownership through its own typed admission seam; production -// installs it from root composition, and these tests compose it the same way. -installProviderDeviceAdmission({ isActive: isActiveProviderDevice }); -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; -import { - resetSnapshotRuntimeFixture, - snapshotRuntimeFixture, -} from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import { - androidCapture, - androidDevice, - androidTextRows, - batteryCapture, - handleSnapshotCommands, - inboxBaselineNodes, - inboxRow, - iosSimulatorDevice, - locationRequiredCapture, - makeAndroidFreshnessSession, - makeSession, - makeSessionStore, - snapshotRequest, -} from '@agent-device/daemon/testing/handlers/__tests__/snapshot-handler.fixtures'; - -vi.mock('@agent-device/daemon/testing/snapshot-interactor-capture', async () => { - const fixture = - await import('@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'); - return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; -}); -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn(async () => ({})) }; -}); - -// The real implementation shells out to simctl to probe for a hint-worthy -// unambiguous environment; that live-probe logic is covered by -// ios-app-session-hint.test.ts. Stubbed here so this suite stays hermetic and -// fast — defaults to "no enrichment", matching the current-behavior fallback. -vi.mock('@agent-device/daemon/testing/ios-app-session-hint', () => ({ - buildIosOpenCommandHint: vi.fn(async () => undefined), -})); - -import { runAppleRunnerCommand } from '@agent-device/platform-apple/runner/operations'; -import { buildIosOpenCommandHint } from '@agent-device/daemon/testing/ios-app-session-hint'; - -const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); -const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); - -// An Apple wait runs inside an opened app: that bundle id is XCUITest's attach target, and -// without one the plan asks for the without-active-app row local Apple refuses. -const appAttach = (d: SessionState['device']): Partial => - d.platform === 'apple' ? { appBundleId: 'com.example.app' } : {}; - -afterEach(() => { - setActiveProviderDeviceRuntimes([]); -}); - -beforeEach(() => { - resetSnapshotRuntimeFixture(); - legacyDispatchCapture.mockReset(); - legacyDispatchCapture.mockResolvedValue({}); - resetGetRuntimeFixture(); - mockRunnerCommand.mockReset(); - mockRunnerCommand.mockResolvedValue({}); - mockBuildIosOpenCommandHint.mockReset(); - mockBuildIosOpenCommandHint.mockResolvedValue(undefined); -}); - -async function runWaitCommand( - sessionName: string, - device: SessionState['device'], - positionals: string[], -) { - const sessionStore = makeSessionStore(); - sessionStore.publish(sessionName, makeSession(sessionName, device, appAttach(device))); - return await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - req: snapshotRequest(sessionName, 'wait', { positionals }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - ...snapshotRuntimeFixture(), - }); -} - -const locationPermissionNodes = [ - { - index: 0, - depth: 0, - type: 'android.widget.FrameLayout', - label: 'Location permission', - rect: { x: 0, y: 0, width: 390, height: 844 }, - }, - { - index: 1, - depth: 1, - parentIndex: 0, - type: 'android.widget.TextView', - label: 'Allow location access?', - rect: { x: 24, y: 210, width: 342, height: 40 }, - }, - { - index: 2, - depth: 1, - parentIndex: 0, - type: 'android.widget.Button', - label: 'Not now', - rect: { x: 24, y: 320, width: 140, height: 48 }, - hittable: true, - }, - { - index: 3, - depth: 1, - parentIndex: 0, - type: 'android.widget.Button', - label: 'Continue', - rect: { x: 180, y: 320, width: 160, height: 48 }, - hittable: true, - }, -]; - -const iosSurfaceSummaryNodes = [ - { - index: 0, - depth: 0, - type: 'XCUIElementTypeApplication', - label: 'Expo Go', - rect: { x: 0, y: 0, width: 393, height: 852 }, - }, - { - index: 1, - depth: 1, - type: 'XCUIElementTypeImage', - label: 'gearshape.fill', - rect: { x: 12, y: 54, width: 24, height: 24 }, - }, - { - index: 2, - depth: 1, - type: 'XCUIElementTypeOther', - label: 'Tab Bar', - rect: { x: 0, y: 760, width: 393, height: 92 }, - }, - { - index: 3, - depth: 1, - type: 'XCUIElementTypeStaticText', - label: 'Confirm catalog refresh', - rect: { x: 48, y: 280, width: 297, height: 36 }, - }, - { - index: 4, - depth: 1, - type: 'XCUIElementTypeButton', - label: 'Keep browsing', - rect: { x: 48, y: 360, width: 297, height: 48 }, - }, - { - index: 5, - depth: 1, - type: 'XCUIElementTypeButton', - identifier: 'host.exp.exponent:id/reload_button', - rect: { x: 260, y: 54, width: 48, height: 48 }, - }, -]; - -test('wait text on Android uses freshness-aware capture instead of one-shot snapshot polling', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-wait-freshness'; - const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(18)); - sessionStore.publish(sessionName, session); - - legacyDispatchCapture - .mockResolvedValueOnce( - androidCapture(androidTextRows(18, inboxRow), { rawNodeCount: 18, maxDepth: 1 }), - ) - .mockResolvedValueOnce( - androidCapture( - [ - { index: 0, depth: 0, type: 'android.widget.TextView', label: 'Create document' }, - { index: 1, depth: 0, type: 'android.widget.TextView', label: 'Done' }, - ], - { rawNodeCount: 2, maxDepth: 1 }, - ), - ); - - // The wait budget includes Android's 250 ms freshness retry delay. - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - req: snapshotRequest(sessionName, 'wait', { positionals: ['Create document', '500'] }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - ...snapshotRuntimeFixture(), - }); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.text).toBe('Create document'); - } - expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); - expect(sessionStore.get(sessionName)?.snapshot?.nodes).toEqual( - expect.arrayContaining([expect.objectContaining({ label: 'Create document' })]), - ); -}); - -test('wait text timeout includes compact current-surface labels and buttons', async () => { - const sessionName = 'android-wait-timeout-surface'; - legacyDispatchCapture.mockResolvedValue({ - nodes: locationPermissionNodes, - truncated: false, - backend: 'android', - analysis: { rawNodeCount: 4, maxDepth: 1 }, - }); - - const response = await runWaitCommand(sessionName, androidDevice, ['Receipt uploaded', '50']); - - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.message).toBe( - 'wait timed out for text: Receipt uploaded. Current surface: Location permission, Allow location access?, Not now, Continue.', - ); - expect(response.error.details?.currentSurface).toEqual({ - labels: ['Location permission', 'Allow location access?', 'Not now', 'Continue'], - buttons: ['Not now', 'Continue'], - }); - } -}); - -test('wait selector timeout includes compact current-surface details', async () => { - const sessionName = 'android-wait-selector-timeout-surface'; - legacyDispatchCapture.mockResolvedValue(locationRequiredCapture()); - - const response = await runWaitCommand(sessionName, androidDevice, ['id=receipt-uploaded', '50']); - - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.message).toBe( - 'wait timed out for selector: id=receipt-uploaded. Current surface: Location required, Dismiss.', - ); - expect(response.error.details?.currentSurface).toEqual({ - labels: ['Location required', 'Dismiss'], - buttons: ['Dismiss'], - }); - } -}); - -test('wait selector polling skips hidden-content hint derivation on every poll (#1270)', async () => { - // The #1270 repro: `wait 'label="Battery"' 8000` on Android. A presence-only wait never - // consumes scroll hints, so every per-poll snapshot capture must disable hint derivation — - // otherwise a pathological `dumpsys activity top` call is charged against the wait budget. - const sessionName = 'android-wait-selector-skips-hints'; - legacyDispatchCapture - .mockResolvedValueOnce(locationRequiredCapture()) - .mockResolvedValueOnce(batteryCapture()); - - const response = await runWaitCommand(sessionName, androidDevice, ['label="Battery"', '8000']); - - expect(response?.ok).toBe(true); - const snapshotCalls = legacyDispatchCapture.mock.calls.filter( - ([, command]) => command === 'snapshot', - ); - expect(snapshotCalls.length).toBe(2); - for (const call of snapshotCalls) { - const context = call[4] as { snapshotIncludeHiddenContentHints?: boolean } | undefined; - expect(context?.snapshotIncludeHiddenContentHints).toBe(false); - } -}); - -test('wait text polling skips hidden-content hint derivation on every poll (#1270)', async () => { - const sessionName = 'android-wait-text-skips-hints'; - legacyDispatchCapture - .mockResolvedValueOnce(locationRequiredCapture()) - .mockResolvedValueOnce(batteryCapture()); - - const response = await runWaitCommand(sessionName, androidDevice, ['Battery', '8000']); - - expect(response?.ok).toBe(true); - const snapshotCalls = legacyDispatchCapture.mock.calls.filter( - ([, command]) => command === 'snapshot', - ); - expect(snapshotCalls.length).toBe(2); - for (const call of snapshotCalls) { - const context = call[4] as { snapshotIncludeHiddenContentHints?: boolean } | undefined; - expect(context?.snapshotIncludeHiddenContentHints).toBe(false); - } -}); - -test('wait timeout summary prefers content labels over chrome and identifier noise', async () => { - const sessionName = 'ios-wait-timeout-surface-summary'; - mockRunnerCommand.mockResolvedValue({ found: false }); - legacyDispatchCapture.mockResolvedValue({ - nodes: iosSurfaceSummaryNodes, - truncated: false, - backend: 'xctest', - }); - - const response = await runWaitCommand(sessionName, iosSimulatorDevice, [ - 'Impossible success text', - '50', - ]); - - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.message).toBe( - 'wait timed out for text: Impossible success text. Current surface: Confirm catalog refresh, Keep browsing.', - ); - expect(response.error.details?.currentSurface).toEqual({ - labels: [ - 'Confirm catalog refresh', - 'Keep browsing', - 'host.exp.exponent:id/reload_button', - 'Expo Go', - 'gearshape.fill', - 'Tab Bar', - ], - buttons: ['Keep browsing', 'host.exp.exponent:id/reload_button'], - }); - } -}); - -test('wait timeout without readable capture does not inspect the current surface', async () => { - const sessionName = 'android-wait-timeout-surface-fails'; - legacyDispatchCapture.mockRejectedValue(new Error('snapshot unavailable')); - - const response = await runWaitCommand(sessionName, androidDevice, ['Receipt uploaded', '0']); - - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.message).toBe('wait timed out for text: Receipt uploaded'); - expect(response.error.details?.reason).toBe('wait_capture_stalled'); - expect(response.error.details?.retriable).toBe(true); - expect(response.error.details?.readableCaptures).toBe(0); - } - expect(legacyDispatchCapture).not.toHaveBeenCalled(); -}); - -test('wait selector bypasses a fresh matching session snapshot', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-wait-fresh-capture'; - const session = makeSession(sessionName, androidDevice); - session.snapshot = { - createdAt: Date.now(), - presentationKey: buildSnapshotPresentationKey({}), - nodes: [ - { - ref: 'e1', - index: 0, - type: 'android.widget.TextView', - label: 'Ready', - }, - ], - }; - sessionStore.publish(sessionName, session); - legacyDispatchCapture.mockResolvedValue({ - nodes: [ - { - index: 0, - type: 'android.widget.TextView', - label: 'Ready', - }, - ], - }); - - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - req: snapshotRequest(sessionName, 'wait', { positionals: ['label="Ready"', '5000'] }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - ...snapshotRuntimeFixture(), - }); - - expect(response?.ok).toBe(true); - expect(legacyDispatchCapture).toHaveBeenCalledWith( - expect.anything(), - 'snapshot', - [], - undefined, - expect.anything(), - ); -}); diff --git a/src/__tests__/daemon-composition/handlers/snapshot-session-cleanup.test.ts b/src/__tests__/daemon-composition/handlers/snapshot-session-cleanup.test.ts deleted file mode 100644 index fb688700bc..0000000000 --- a/src/__tests__/daemon-composition/handlers/snapshot-session-cleanup.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, beforeEach, expect, test, vi } from 'vitest'; - -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => ({ - ...(await importOriginal()), - stopIosRunnerSession: vi.fn(async () => {}), -})); -vi.mock('@agent-device/platform-apple/app-lifecycle', async (importOriginal) => ({ - ...(await importOriginal()), - closeIosApp: vi.fn(async () => {}), -})); - -import { withSessionlessRunnerCleanup } from '@agent-device/daemon/testing/snapshot-session'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; -import { closeIosApp } from '@agent-device/platform-apple/app-lifecycle'; -import { stopIosRunnerSession } from '@agent-device/platform-apple/runner/operations'; -import { IOS_SIMULATOR } from '@agent-device/test-kit/device-fixtures'; -import { - isActiveProviderDevice, - setActiveProviderDeviceRuntimes, -} from '../../../provider-device-runtime.ts'; -import { installProviderDeviceAdmission } from '@agent-device/daemon/testing/provider-device-admission'; - -// The daemon reads provider ownership through its own typed admission seam; production -// installs it from root composition, and these tests compose it the same way. -installProviderDeviceAdmission({ isActive: isActiveProviderDevice }); -import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; - -const mockStopIosRunnerSession = vi.mocked(stopIosRunnerSession); -const mockCloseIosApp = vi.mocked(closeIosApp); -const returnOk = async () => 'ok'; - -beforeEach(() => { - mockStopIosRunnerSession.mockReset().mockResolvedValue(); - mockCloseIosApp.mockReset().mockResolvedValue(); -}); - -afterEach(() => { - setActiveProviderDeviceRuntimes([]); -}); - -test('sessionless iOS runner cleanup stops the runner host app', async () => { - const result = await withSessionlessRunnerCleanup( - undefined, - IOS_SIMULATOR, - returnOk, - platformResourceCleanup, - ); - expect(result).toBe('ok'); - expect(mockStopIosRunnerSession).toHaveBeenCalledWith(IOS_SIMULATOR.id); - expect(mockCloseIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, 'com.callstack.agentdevice.runner'); -}); - -test('sessionless iOS runner host close is best effort', async () => { - mockCloseIosApp.mockRejectedValueOnce(new Error('terminate failed')); - const result = await withSessionlessRunnerCleanup( - undefined, - IOS_SIMULATOR, - returnOk, - platformResourceCleanup, - ); - expect(result).toBe('ok'); - expect(mockStopIosRunnerSession).toHaveBeenCalledWith(IOS_SIMULATOR.id); - expect(mockCloseIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, 'com.callstack.agentdevice.runner'); -}); - -test('sessionless cleanup leaves a provider-owned device to its provider', async () => { - const device = { ...IOS_SIMULATOR, id: 'limrun:ios:lease-a' }; - const runtime: ProviderDeviceRuntime = { - provider: 'limrun', - leaseLifecycle: {}, - deviceInventoryProvider: async () => [device], - ownsDevice: (candidate) => candidate.id === device.id, - getInteractor: () => undefined, - shutdown: async () => {}, - }; - setActiveProviderDeviceRuntimes([runtime]); - - const result = await withSessionlessRunnerCleanup( - undefined, - device, - returnOk, - platformResourceCleanup, - ); - - expect(result).toBe('ok'); - expect(mockStopIosRunnerSession).not.toHaveBeenCalled(); - expect(mockCloseIosApp).not.toHaveBeenCalled(); -}); diff --git a/src/commands/capture/output.test.ts b/src/commands/capture/output.test.ts index ae3dc494ca..64d3be9278 100644 --- a/src/commands/capture/output.test.ts +++ b/src/commands/capture/output.test.ts @@ -123,3 +123,29 @@ test('a repaired capture renders the disclosure and keeps the structured fact', otherActiveApplicationPid: 4562, }); }); + +test('JSON output keeps snapshot quality and warnings and drops capture analysis and freshness', async () => { + const snapshotQuality = { state: 'healthy', backend: 'tree' }; + const output = await snapshotCliOutput({ + result: { + nodes: attachRefs([{ index: 0, depth: 0, type: 'android.widget.TextView', label: 'Albums' }]), + truncated: false, + identifiers: { session: 'qa' }, + snapshotQuality, + warnings: ['backend warning from interaction capture'], + analysis: { rawNodeCount: 1, maxDepth: 0 }, + freshness: { action: 'swipe', retryCount: 0, staleAfterRetries: false }, + } as unknown as CaptureSnapshotResult, + }); + + assert.deepEqual( + (output.jsonData as { snapshotQuality?: unknown }).snapshotQuality, + snapshotQuality, + ); + assert.deepEqual((output.jsonData as { warnings?: unknown }).warnings, [ + 'backend warning from interaction capture', + ]); + const jsonData = output.jsonData as Record; + assert.equal('analysis' in jsonData, false); + assert.equal('freshness' in jsonData, false); +}); diff --git a/src/commands/capture/runtime/snapshot-freshness.test.ts b/src/commands/capture/runtime/snapshot-freshness.test.ts new file mode 100644 index 0000000000..6ad4b97e95 --- /dev/null +++ b/src/commands/capture/runtime/snapshot-freshness.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { AgentDeviceBackend, BackendSnapshotResult } from '@agent-device/contracts/backend'; +import { localCommandPolicy } from '@agent-device/contracts/command-policy'; +import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; +import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { createAgentDevice } from '../../../runtime.ts'; + +function snapshotDevice( + result: BackendSnapshotResult, + session?: { name: string; snapshot: ReturnType }, +) { + const backend: AgentDeviceBackend = { + platform: 'android', + captureSnapshot: async () => result, + }; + return createAgentDevice({ + backend, + artifacts: createLocalArtifactAdapter(), + sessions: { get: () => session, set: () => {} }, + policy: localCommandPolicy(), + clock: { now: () => 1_500, sleep: async () => {} }, + }); +} + +const STUCK_ROUTE_WARNING = + 'Recent press was followed by a nearly identical snapshot after 3 automatic retries. If you expected navigation or submit, the tree may still be stale. Use screenshot as visual truth, wait briefly, then re-snapshot once.'; + +const ANDROID_ROWS = Array.from({ length: 24 }, (_, index) => ({ + ref: `e${index + 1}`, + index, + depth: 0, + type: 'android.widget.TextView', + label: `Inbox row ${index + 1}`, +})); + +test('runtime snapshot warns when Android freshness retries still return the previous route', async () => { + const device = snapshotDevice({ + nodes: ANDROID_ROWS, + truncated: false, + backend: 'android', + freshness: { + action: 'press', + retryCount: 3, + staleAfterRetries: true, + reason: 'stuck-route', + }, + }); + + const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true }); + + assert.deepEqual(result.warnings, [STUCK_ROUTE_WARNING]); +}); + +test('runtime diff snapshot carries the stale-route warning for recent Android presses', async () => { + const baseline = makeSnapshotState(ANDROID_ROWS, { backend: 'android' }); + baseline.createdAt = 1_000; + const device = snapshotDevice( + { + nodes: ANDROID_ROWS, + truncated: false, + backend: 'android', + freshness: { + action: 'press', + retryCount: 3, + staleAfterRetries: true, + reason: 'stuck-route', + }, + }, + { name: 'default', snapshot: baseline }, + ); + + const result = await device.capture.diffSnapshot({ session: 'default', interactiveOnly: true }); + + assert.deepEqual(result.warnings, [STUCK_ROUTE_WARNING]); +}); + +test('runtime snapshot raises no stale-route warning once the freshness retries succeeded', async () => { + const device = snapshotDevice({ + nodes: ANDROID_ROWS, + truncated: false, + backend: 'android', + freshness: { action: 'press', retryCount: 1, staleAfterRetries: false }, + }); + + const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true }); + + assert.equal(result.warnings, undefined); +}); + +test.each([ + { name: 'does not warn on an expected node drop across presentation modes', interactive: false }, + { name: 'warns on a sharp node drop within one presentation mode', interactive: true }, +])('runtime snapshot $name', async ({ interactive }) => { + const previous = makeSnapshotState( + Array.from({ length: 50 }, (_, index) => ({ + index, + depth: 0, + type: 'android.widget.TextView', + label: `Row ${index + 1}`, + })), + { backend: 'android' }, + ); + previous.createdAt = 1_000; + previous.presentationKey = buildSnapshotPresentationKey({ interactiveOnly: interactive }); + const device = snapshotDevice( + { + nodes: Array.from({ length: 8 }, (_, index) => ({ + ref: `e${index + 1}`, + index, + depth: 0, + type: 'android.widget.Button', + label: `Action ${index + 1}`, + hittable: true, + })), + truncated: false, + backend: 'android', + }, + { name: 'default', snapshot: previous }, + ); + + const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true }); + + assert.equal( + result.warnings?.some((warning) => /Recent snapshots dropped sharply/.test(warning)) ?? false, + interactive, + ); +}); + +test('runtime snapshot reports normalized visibility metadata for scroll-hidden content', async () => { + const device = snapshotDevice({ + nodes: [ + { + ref: 'e1', + index: 0, + depth: 0, + type: 'android.widget.ScrollView', + label: 'Messages', + rect: { x: 0, y: 100, width: 390, height: 500 }, + hiddenContentBelow: true, + }, + { + ref: 'e2', + index: 1, + depth: 1, + parentIndex: 0, + type: 'android.widget.Button', + label: 'Visible message', + rect: { x: 0, y: 140, width: 390, height: 48 }, + hittable: true, + }, + ], + truncated: false, + backend: 'android', + analysis: { rawNodeCount: 2, maxDepth: 1 }, + }); + + const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true }); + + assert.deepEqual(result.visibility, { + partial: true, + visibleNodeCount: 2, + totalNodeCount: 2, + reasons: ['scroll-hidden-below'], + }); +}); diff --git a/src/commands/interaction/runtime/wait-capture-options.test.ts b/src/commands/interaction/runtime/wait-capture-options.test.ts new file mode 100644 index 0000000000..ecb634b290 --- /dev/null +++ b/src/commands/interaction/runtime/wait-capture-options.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { AgentDeviceBackend, BackendSnapshotOptions } from '@agent-device/contracts/backend'; +import { localCommandPolicy } from '@agent-device/contracts/command-policy'; +import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { createAgentDevice } from '../../../runtime.ts'; +import { createFakeClock } from './__tests__/test-utils/index.ts'; + +const LOCATION_REQUIRED = makeSnapshotState( + [ + { index: 0, depth: 0, type: 'android.widget.TextView', label: 'Location required' }, + { index: 1, depth: 0, type: 'android.widget.Button', label: 'Dismiss' }, + ], + { backend: 'android' }, +); + +const BATTERY = makeSnapshotState( + [{ index: 0, depth: 0, type: 'android.widget.TextView', label: 'Battery' }], + { backend: 'android' }, +); + +function pollingDevice(captureOptions: Array) { + const captures = [LOCATION_REQUIRED, BATTERY]; + const backend: AgentDeviceBackend = { + platform: 'android', + captureSnapshot: async (_context, options) => { + captureOptions.push(options); + return { snapshot: captures[Math.min(captureOptions.length, captures.length) - 1] }; + }, + }; + return createAgentDevice({ + backend, + artifacts: createLocalArtifactAdapter(), + sessions: { get: () => undefined, set: () => {} }, + policy: localCommandPolicy(), + clock: createFakeClock(), + }); +} + +test.each([ + { name: 'text', target: { kind: 'text', text: 'Battery', timeoutMs: 8000 } }, + { name: 'selector', target: { kind: 'selector', selector: 'label="Battery"', timeoutMs: 8000 } }, +] as const)( + 'a $name wait skips hidden-content hint derivation on every poll (#1270)', + async ({ target }) => { + const captureOptions: Array = []; + const device = pollingDevice(captureOptions); + + await device.selectors.wait({ session: 'default', target }); + + assert.equal(captureOptions.length, 2); + for (const options of captureOptions) { + assert.equal(options?.includeHiddenContentHints, false); + } + }, +); diff --git a/src/__tests__/daemon-composition/handlers/snapshot-alert-android-occlusion.test.ts b/src/core/interactors/android-alert-occlusion.test.ts similarity index 97% rename from src/__tests__/daemon-composition/handlers/snapshot-alert-android-occlusion.test.ts rename to src/core/interactors/android-alert-occlusion.test.ts index 84c8e6e23e..e69826dce9 100644 --- a/src/__tests__/daemon-composition/handlers/snapshot-alert-android-occlusion.test.ts +++ b/src/core/interactors/android-alert-occlusion.test.ts @@ -12,7 +12,7 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { import { pressAndroid, snapshotAndroid } from '@agent-device/platform-android/mechanics'; import { ANDROID_EMULATOR } from '@agent-device/test-kit/device-fixtures'; -import { createAndroidInteractor } from '../../../core/interactors/android.ts'; +import { createAndroidInteractor } from './android.ts'; import { makeAndroidSnapshotCapture } from '@agent-device/platform-android/snapshot-capture-fixtures'; // R59 moved the alert legs onto the Android interactor, which is where the occlusion reading diff --git a/src/platform-runtime-apple-application-tools-close-alerts.test.ts b/src/platform-runtime-apple-application-tools-close-alerts.test.ts new file mode 100644 index 0000000000..794bcaa948 --- /dev/null +++ b/src/platform-runtime-apple-application-tools-close-alerts.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { IOS_SIMULATOR } from '@agent-device/test-kit/device-fixtures'; +import { createAppleApplicationTools } from './platform-runtime-apple-application-tools.ts'; + +const runMacOsAlertAction = vi.hoisted(() => vi.fn()); + +vi.mock('@agent-device/platform-apple/macos', async (importOriginal) => ({ + ...(await importOriginal()), + runMacOsAlertAction, +})); + +const MACOS_DEVICE: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, +}; + +beforeEach(() => { + runMacOsAlertAction.mockReset(); + runMacOsAlertAction.mockResolvedValue({}); +}); + +const finalization = { retainRunner: false, stateDir: '/tmp' } as const; + +test('close dismisses the automation alert of the closed macOS app', async () => { + await createAppleApplicationTools().dismissCloseAlerts(MACOS_DEVICE, { + ...finalization, + surface: 'app', + appBundleId: 'com.apple.systempreferences', + }); + + expect(runMacOsAlertAction).toHaveBeenCalledWith('dismiss', { + bundleId: 'com.apple.systempreferences', + }); +}); + +test('close of the frontmost-app surface dismisses the frontmost automation alert', async () => { + await createAppleApplicationTools().dismissCloseAlerts(MACOS_DEVICE, { + ...finalization, + surface: 'frontmost-app', + appBundleId: 'com.apple.systempreferences', + }); + + expect(runMacOsAlertAction).toHaveBeenCalledWith('dismiss', { surface: 'frontmost-app' }); +}); + +test('close without a macOS app bundle dismisses with no target', async () => { + await createAppleApplicationTools().dismissCloseAlerts(MACOS_DEVICE, { + ...finalization, + surface: 'app', + }); + + expect(runMacOsAlertAction).toHaveBeenCalledWith('dismiss', {}); +}); + +test('close on a non-macOS device dismisses nothing', async () => { + await createAppleApplicationTools().dismissCloseAlerts(IOS_SIMULATOR, { + ...finalization, + surface: 'app', + appBundleId: 'com.example.app', + }); + + expect(runMacOsAlertAction).not.toHaveBeenCalled(); +}); diff --git a/src/platform-runtime-host-diagnostics.test.ts b/src/platform-runtime-host-diagnostics.test.ts new file mode 100644 index 0000000000..d25091b76c --- /dev/null +++ b/src/platform-runtime-host-diagnostics.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { HostDiagnosticsContext } from '@agent-device/contracts/host-diagnostics'; +import { ANDROID_EMULATOR, IOS_SIMULATOR } from '@agent-device/test-kit/device-fixtures'; +import { createHostDiagnostics } from './platform-runtime-host-diagnostics.ts'; + +const { mockAppleRunnerWarmupCheck } = vi.hoisted(() => ({ + mockAppleRunnerWarmupCheck: vi.fn(), +})); + +vi.mock('@agent-device/platform-apple/doctor', async (importOriginal) => ({ + ...(await importOriginal()), + appleRunnerWarmupCheck: mockAppleRunnerWarmupCheck, +})); + +const context: HostDiagnosticsContext = Object.freeze({ + stateDir: '/tmp/state', + metroPort: 8081, + shouldProbeMetro: false, + isProviderDevice: () => false, + emitProgress: vi.fn(), + listLocalDeviceInventory: async () => [], + shouldPropagateInventoryProbeError: () => false, + transportOverrides: Object.freeze({}), +}); + +beforeEach(() => { + mockAppleRunnerWarmupCheck.mockReset(); +}); + +test('warmup hands an Apple device to the Apple runner warmup and returns its check', async () => { + const check = { + id: 'ios-runner-cache', + status: 'pass', + summary: 'iOS runner build started in the background', + }; + mockAppleRunnerWarmupCheck.mockResolvedValue(check); + + const result = await createHostDiagnostics().warmupCheck(IOS_SIMULATOR, context); + + expect(result).toEqual(check); + expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledWith(IOS_SIMULATOR, context); +}); + +test('warmup runs no probe for a non-Apple device', async () => { + expect(await createHostDiagnostics().warmupCheck(ANDROID_EMULATOR, context)).toBeUndefined(); + expect(mockAppleRunnerWarmupCheck).not.toHaveBeenCalled(); +}); diff --git a/src/platform-runtime-open-target-relaunch.test.ts b/src/platform-runtime-open-target-relaunch.test.ts new file mode 100644 index 0000000000..d30d1ce97a --- /dev/null +++ b/src/platform-runtime-open-target-relaunch.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'vitest'; +import { validateOpenRelaunchTarget } from './platform-runtime-open-target.ts'; + +const INSTALLED_PACKAGE_REQUIRED = (target: string) => + `Android runtime hints require an installed package name, not "${target}". Install or reinstall the app first, then relaunch by package.`; + +test('open --relaunch refuses URL targets', async () => { + expect( + await validateOpenRelaunchTarget({ target: 'https://example.com/path', platform: undefined }), + ).toMatch(/does not support URL targets/i); +}); + +test.each(['/tmp/app-debug.apk', 'app-debug.apk'])( + 'open --relaunch refuses the Android app binary target %s', + async (target) => { + expect(await validateOpenRelaunchTarget({ target, platform: 'android' })).toBe( + INSTALLED_PACKAGE_REQUIRED(target), + ); + }, +); + +test('open --relaunch accepts an installed Android package name', async () => { + expect( + await validateOpenRelaunchTarget({ target: 'com.example.app', platform: 'android' }), + ).toBeUndefined(); +}); diff --git a/src/platform-runtime-resource-cleanup-retention.test.ts b/src/platform-runtime-resource-cleanup-retention.test.ts new file mode 100644 index 0000000000..5d6a188d06 --- /dev/null +++ b/src/platform-runtime-resource-cleanup-retention.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + ANDROID_EMULATOR, + IOS_DEVICE, + IOS_SIMULATOR, +} from '@agent-device/test-kit/device-fixtures'; +import { platformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; + +const PLAIN_CLOSE = { shutdownRequested: false, hasScreenRecording: false, hasLease: false }; + +function retains(device: DeviceInfo, overrides: Partial = {}): boolean { + return platformResourceCleanup.retainExecutionHostAfterClose({ + device, + ...PLAIN_CLOSE, + ...overrides, + }); +} + +test('a plain close keeps the runner host of an iOS simulator', () => { + expect(retains(IOS_SIMULATOR)).toBe(true); + expect(retains({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulator-set' })).toBe(true); +}); + +test.each([ + { name: 'a shutdown request', overrides: { shutdownRequested: true } }, + { name: 'a live screen recording', overrides: { hasScreenRecording: true } }, + { name: 'a lease', overrides: { hasLease: true } }, +])('$name releases the runner host of an iOS simulator', ({ overrides }) => { + expect(retains(IOS_SIMULATOR, overrides)).toBe(false); +}); + +test('a physical iOS device and a non-Apple device keep no runner host', () => { + expect(retains(IOS_DEVICE)).toBe(false); + expect(retains(ANDROID_EMULATOR)).toBe(false); +}); diff --git a/src/platform-runtime-resource-cleanup-sessionless-host.test.ts b/src/platform-runtime-resource-cleanup-sessionless-host.test.ts new file mode 100644 index 0000000000..842dbf7f0f --- /dev/null +++ b/src/platform-runtime-resource-cleanup-sessionless-host.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { IOS_SIMULATOR, ANDROID_EMULATOR } from '@agent-device/test-kit/device-fixtures'; +import { platformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; + +const { RUNNER_BUNDLE_ID, closeIosApp, stopIosRunnerSession } = vi.hoisted(() => ({ + RUNNER_BUNDLE_ID: 'com.example.test-runner', + closeIosApp: vi.fn(async () => {}), + stopIosRunnerSession: vi.fn(async () => {}), +})); + +vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => ({ + ...(await importOriginal()), + stopIosRunnerSession, + resolveRunnerAppBundleId: () => RUNNER_BUNDLE_ID, +})); +vi.mock('@agent-device/platform-apple/app-lifecycle', async (importOriginal) => ({ + ...(await importOriginal()), + closeIosApp, +})); + +beforeEach(() => { + closeIosApp.mockReset().mockResolvedValue(); + stopIosRunnerSession.mockReset().mockResolvedValue(); +}); + +test('sessionless iOS runner cleanup stops the runner host app', async () => { + await platformResourceCleanup.cleanupSessionlessExecutionHost(IOS_SIMULATOR); + + expect(stopIosRunnerSession).toHaveBeenCalledWith(IOS_SIMULATOR.id); + expect(closeIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, RUNNER_BUNDLE_ID); + expect(stopIosRunnerSession.mock.invocationCallOrder[0]!).toBeLessThan( + closeIosApp.mock.invocationCallOrder[0]!, + ); +}); + +test('sessionless iOS runner host close is best effort', async () => { + closeIosApp.mockRejectedValueOnce(new Error('terminate failed')); + + await expect( + platformResourceCleanup.cleanupSessionlessExecutionHost(IOS_SIMULATOR), + ).resolves.toBeUndefined(); + + expect(stopIosRunnerSession).toHaveBeenCalledWith(IOS_SIMULATOR.id); + expect(closeIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, RUNNER_BUNDLE_ID); +}); + +test('sessionless cleanup touches no runner host for a non-Apple device', async () => { + await platformResourceCleanup.cleanupSessionlessExecutionHost(ANDROID_EMULATOR); + + expect(stopIosRunnerSession).not.toHaveBeenCalled(); + expect(closeIosApp).not.toHaveBeenCalled(); +}); diff --git a/src/provider-credential-fingerprint.test.ts b/src/provider-credential-fingerprint.test.ts index 3b7415fcac..ef145944f3 100644 --- a/src/provider-credential-fingerprint.test.ts +++ b/src/provider-credential-fingerprint.test.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { expect, test } from 'vitest'; import { providerCredentialFingerprint, @@ -152,3 +154,25 @@ test('a plugin declaration never adds a fingerprint to a bundled provider', () = }; expect(providerCredentialFingerprint('aws-device-farm', env)).toBe(undefined); }); + +function testMuPluginEnv(): Record { + const manifest = path.resolve(import.meta.dirname, '../packages/provider-testmu/package.json'); + const { home, env } = pluginHome(); + selectPluginManifest(home, JSON.parse(fs.readFileSync(manifest, 'utf8'))); + return env; +} + +test('the bundled TestMu AI plugin manifest fingerprints its LT credentials', () => { + const home = testMuPluginEnv(); + const env = { ...home, LT_USERNAME: 'user', LT_ACCESS_KEY: 'key-1' }; + const fingerprint = providerCredentialFingerprint('testmu', env); + + expect(fingerprint).toMatch(/^v1:[0-9a-f]{16}$/); + expect(providerCredentialFingerprint('testmu', { ...env })).toBe(fingerprint); + expect(providerCredentialFingerprint('testmu', { ...env, LT_ACCESS_KEY: 'key-2' })).not.toBe( + fingerprint, + ); + expect(providerCredentialFingerprint('testmu', home)).toBe(undefined); + expect(readDaemonProviderCredentials(env, '/state').fingerprint('testmu')).toBe(fingerprint); + expect(readDaemonProviderCredentials(home, '/state').fingerprint('testmu')).toBe(undefined); +}); diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index 28ac621948..888fff4428 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -632,9 +632,9 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'session inventory proves the app lease is removed', ), macos: macos.contract( - 'src/__tests__/daemon-composition/handlers/session-relaunch-close.test.ts', - 'close on macOS session stops runner and dismisses automation alert before delete', - 'macOS close stops the runner, dismisses automation state, and deletes the session', + 'packages/platform-apple/src/lifecycle-relaunch-close.test.ts', + 'close finalization releases the runner before it dismisses close alerts', + 'macOS close stops the runner and then dismisses automation state; the daemon close tests own the session deletion', ), tvos: tvos.contract( TVOS_REMOTE_EVIDENCE.path,