Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions packages/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,10 @@
"types": "./src/__tests__/support/application-lifecycle-runtime-fixture.ts",
"default": "./src/__tests__/support/application-lifecycle-runtime-fixture.ts"
},
"./testing/__tests__/application-lifecycle-runtime-harness": {
"types": "./src/__tests__/support/application-lifecycle-runtime-harness.ts",
"default": "./src/__tests__/support/application-lifecycle-runtime-harness.ts"
},
"./testing/__tests__/capture-disclosure-target-activation-fact": {
"types": "./src/core/__tests__/capture-disclosure-target-activation-fact.ts",
"default": "./src/core/__tests__/capture-disclosure-target-activation-fact.ts"
},
"./testing/__tests__/device-selection-stub": {
"types": "./src/__tests__/support/device-selection-stub.ts",
"default": "./src/__tests__/support/device-selection-stub.ts"
},
"./testing/__tests__/human-control-fixtures": {
"types": "./src/__tests__/support/human-control-fixtures.ts",
"default": "./src/__tests__/support/human-control-fixtures.ts"
Expand Down Expand Up @@ -188,10 +180,6 @@
"types": "./src/sessions/handlers/session-appstate.ts",
"default": "./src/sessions/handlers/session-appstate.ts"
},
"./testing/handlers/session-replay-command": {
"types": "./src/sessions/handlers/session-replay-command.ts",
"default": "./src/sessions/handlers/session-replay-command.ts"
},
"./testing/handlers/snapshot": {
"types": "./src/sessions/handlers/snapshot.ts",
"default": "./src/sessions/handlers/snapshot.ts"
Expand Down Expand Up @@ -332,10 +320,6 @@
"types": "./src/core/session-capture-binding.ts",
"default": "./src/core/session-capture-binding.ts"
},
"./testing/session-lifecycle/internal/__tests__/*": {
"types": "./src/sessions/session-lifecycle/internal/__tests__/*.ts",
"default": "./src/sessions/session-lifecycle/internal/__tests__/*.ts"
},
"./testing/session-observability/__tests__/*": {
"types": "./src/sessions/session-observability/__tests__/*.ts",
"default": "./src/sessions/session-observability/__tests__/*.ts"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ const prepareAppleRunnerFixture = vi.fn(
* the fixture owner (a dotted, non-deep-link target is the bundle id; otherwise the session's
* current one), reached through the `directResolve` the fixture passes in. No open-target, surface,
* or platform rule lives here — the real owners' resolution rules (apple/android target identity,
* macOS frontmost surfaces, Safari and deep-link fallbacks) are pinned under
* `src/__tests__/daemon-composition/` (`session-open-target.test.ts` and siblings). A daemon suite
* that needs a different bundle id answers per case through this spy.
* macOS frontmost surfaces, Safari and deep-link fallbacks) are pinned beside the root modules
* (`src/platform-runtime-open-target.test.ts`, `src/platform-runtime-apple-application-tools.test.ts`).
* A daemon suite that needs a different bundle id answers per case through this spy.
*/
export const resolveOpenTargetFixture = vi.fn(
async (
Expand Down
27 changes: 27 additions & 0 deletions packages/daemon/src/__tests__/support/lifecycle-operations-spy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { vi } from 'vitest';
import type { ApplicationLifecycleRuntimeOperations } from '@agent-device/contracts/application-lifecycle-runtime';
import {
applicationLifecycleRuntimeFixture,
type LifecycleOperationsLoader,
} from './application-lifecycle-runtime-fixture.ts';

/**
* The canned lifecycle port with `finalizeApplicationClose` answered by a spy (an empty result by
* default) and any other operation replaceable per case. Daemon close tests read the spy to prove
* the neutral orchestration asked the platform for close finalization, and with which arguments,
* without encoding how a platform performs it.
*/
export function lifecycleOperationsSpy(
overrides: Partial<ApplicationLifecycleRuntimeOperations> = {},
) {
const finalizeApplicationClose = vi.fn<
ApplicationLifecycleRuntimeOperations['finalizeApplicationClose']
>(async () => ({}));
const loader: LifecycleOperationsLoader = async (device, signal, shutdownTarget) =>
Object.freeze({
...(await applicationLifecycleRuntimeFixture(device, signal, shutdownTarget)),
...overrides,
finalizeApplicationClose,
});
return { loader, finalizeApplicationClose };
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const validateOpenRelaunchTargetFixture = vi.fn(
/**
* The canned open-target answer: target resolution changes no identity — the session keeps its
* current app. Suites asserting a resolved bundle id answer per case; the resolution rules live
* in the composition lane (`session-open-target.test.ts` and siblings).
* in `src/platform-runtime-open-target.test.ts`.
*/
export const resolveSessionAppBundleIdForTargetFixture = vi.fn(
async (
Expand Down
84 changes: 84 additions & 0 deletions packages/daemon/src/core/device/device-ready.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';
import {
ANDROID_EMULATOR,
IOS_DEVICE,
IOS_SIMULATOR,
} from '@agent-device/test-kit/device-fixtures';
import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts';
import { DEVICE_READY_CACHE_TTL_MS, ensureDeviceReady } from './device-ready.ts';

const ensureLocalDeviceReady = vi.fn(async (_device: DeviceInfo) => true);
const platformServices = daemonPlatformServicesFixture({ ensureLocalDeviceReady });

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'));
ensureLocalDeviceReady.mockReset();
ensureLocalDeviceReady.mockResolvedValue(true);
});

afterEach(() => {
vi.useRealTimers();
});

test('ensureDeviceReady caches successful simulator readiness checks', async () => {
const device: DeviceInfo = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' };

await ensureDeviceReady(device, platformServices);
await ensureDeviceReady({ ...device }, platformServices);

expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(1);
expect(ensureLocalDeviceReady).toHaveBeenCalledWith(device);
});

test('ensureDeviceReady caches successful iOS physical device readiness checks', async () => {
await ensureDeviceReady(IOS_DEVICE, platformServices);
await ensureDeviceReady(
{ ...IOS_DEVICE, simulatorSetPath: '/ignored-for-physical-device' },
platformServices,
);

expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(1);
});

test('ensureDeviceReady includes simulator set path in the cache key', async () => {
await ensureDeviceReady(
{ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' },
platformServices,
);
await ensureDeviceReady(
{ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-b' },
platformServices,
);

expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(2);
});

test('ensureDeviceReady expires cached readiness checks after the ttl', async () => {
await ensureDeviceReady(ANDROID_EMULATOR, platformServices);
vi.setSystemTime(new Date(Date.now() + DEVICE_READY_CACHE_TTL_MS - 1));
await ensureDeviceReady({ ...ANDROID_EMULATOR }, platformServices);
vi.setSystemTime(new Date(Date.now() + 1));
await ensureDeviceReady({ ...ANDROID_EMULATOR }, platformServices);

expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(2);
});

test('ensureDeviceReady does not cache failed readiness checks', async () => {
ensureLocalDeviceReady.mockRejectedValueOnce(new Error('boot failed'));

await expect(ensureDeviceReady(IOS_SIMULATOR, platformServices)).rejects.toThrow('boot failed');
await ensureDeviceReady(IOS_SIMULATOR, platformServices);

expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(2);
});

test('ensureDeviceReady does not cache a device the platform has no readiness step for', async () => {
ensureLocalDeviceReady.mockResolvedValue(false);

await ensureDeviceReady(ANDROID_EMULATOR, platformServices);
await ensureDeviceReady(ANDROID_EMULATOR, platformServices);

expect(ensureLocalDeviceReady).toHaveBeenCalledTimes(2);
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
type RuntimeFacts,
} from '@agent-device/contracts/platform-runtime';
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
import { IOS_SIMULATOR } from '@agent-device/test-kit/device-fixtures';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve';
import {
ANDROID_EMULATOR,
IOS_SIMULATOR,
MACOS_DEVICE,
} from '@agent-device/test-kit/device-fixtures';
import { makeSessionStore } from '../../../__tests__/support/store-factory.ts';
import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit/runtime-operation-facts';
import type {
Expand Down Expand Up @@ -111,6 +117,193 @@ async function runPrepare(flags: { timeoutMs?: number }): Promise<{
return { handlerTimeoutMs: handlerTimeoutMs!, envelopeMs };
}

type PrepareRunnerResult = Awaited<
ReturnType<NonNullable<PlatformRuntimeOperations['prepareAppleRunner']>>
>;

/**
* Runs `prepare ios-runner` against `device` with no session, answering `prepareAppleRunner`
* through the injected binding. Facts admit the operation only for Apple devices, as the
* platform owners publish it.
*/
async function invokePrepare(params: {
device: DeviceInfo;
flags?: CommandFlags;
positionals?: string[];
prepareAppleRunner?: (input: {
timeoutMs: number;
execution: { requestId?: string; logPath?: string };
}) => Promise<PrepareRunnerResult>;
}) {
const { device } = params;
const sessionStore = makeSessionStore('agent-device-prepare-handler-');
const unavailableFacts = createUnavailableRuntimeFactsForTest(device, localRuntimeOwner('apple'));
const facts: RuntimeFacts<PlatformRuntimeOperations> = {
...unavailableFacts,
operations: {
...unavailableFacts.operations,
...(device.platform === 'apple' ? { prepareAppleRunner: { available: true } } : {}),
},
};
const prepareAppleRunner = vi.fn(
params.prepareAppleRunner ??
(async () => ({ runner: { currentUptimeMs: 42 }, connectMs: 3, healthCheckMs: 3 })),
);
const inspectFacts = vi.fn(async () => facts);
const bindDevice = vi.fn();
const bindDeviceRuntime: BindDeviceRuntime = async (candidate, use) => {
bindDevice();
return narrowDeviceBinding(
{
device: candidate,
owner: localRuntimeOwner('apple'),
facts,
operations: { prepareAppleRunner },
[Symbol.asyncDispose]: async () => {},
},
use,
);
};
vi.mocked(resolveTargetDevice).mockClear();
vi.mocked(resolveTargetDevice).mockResolvedValue(device);
const response = await handlePrepareCommand({
platformServices: daemonPlatformServicesFixture(),
req: {
token: 't',
session: 'prepare-session',
command: 'prepare',
positionals: params.positionals ?? ['ios-runner'],
flags: params.flags ?? { udid: device.id },
meta: { requestId: 'prepare-request' },
},
sessionName: 'prepare-session',
logPath: '/dev/null',
sessionStore,
inspectFacts,
bindDevice: bindDeviceRuntime,
});
return { response, sessionStore, prepareAppleRunner, inspectFacts, bindDevice };
}

test.each([
{ device: IOS_SIMULATOR, platform: 'ios' },
{ device: MACOS_DEVICE, platform: 'macos' },
])(
'prepare ios-runner on $device.name hands the runner the requested budget and reports the device',
async ({ device, platform }) => {
const { response, sessionStore, prepareAppleRunner, inspectFacts, bindDevice } =
await invokePrepare({ device, flags: { udid: device.id, timeoutMs: 240_000 } });

expect(response.ok).toBe(true);
expect(inspectFacts).toHaveBeenCalledTimes(1);
expect(bindDevice).toHaveBeenCalledTimes(1);
expect(prepareAppleRunner).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
timeoutMs: 240_000,
execution: expect.objectContaining({
requestId: 'prepare-request',
logPath: '/dev/null',
}),
}),
);
if (response.ok) {
expect(response.data).toMatchObject({
action: 'ios-runner',
platform,
deviceId: device.id,
deviceName: device.name,
kind: device.kind,
connectMs: 3,
healthCheckMs: 3,
runner: { currentUptimeMs: 42 },
message: `Prepared Apple runner: ${device.name}`,
});
}
expect(sessionStore.get('prepare-session')).toBeUndefined();
},
);

test('prepare ios-runner explains overlapping timing fields with additive parts', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(1_000));
try {
const { response } = await invokePrepare({
device: IOS_SIMULATOR,
prepareAppleRunner: async () => {
vi.setSystemTime(new Date(28_337));
return {
runner: { currentUptimeMs: 42 },
buildMs: 10_642,
connectMs: 12_635,
healthCheckMs: 14_702,
};
},
});

expect(response.ok).toBe(true);
if (!response.ok) return;
const data = response.data as Record<string, any>;
expect(data).toMatchObject({
buildMs: 10_642,
connectMs: 12_635,
healthCheckMs: 14_702,
timing: {
totalMs: expect.any(Number),
additiveParts: {
buildMs: 10_642,
connectAfterBuildMs: 1_993,
healthCheckMs: 14_702,
},
containment: {
connectMs: ['buildMs'],
healthCheckMs: [],
},
},
});
expect(data.durationMs).toBe(27_337);
expect(data.timing.totalMs).toBe(data.durationMs);
expect(String(data.timing.note)).toMatch(/top-level prepare timing fields.*may overlap/i);
const additiveParts = data.timing.additiveParts as Record<string, number>;
const additiveTotalMs = Object.values(additiveParts).reduce((sum, value) => sum + value, 0);
expect(additiveTotalMs).toBeLessThanOrEqual(data.timing.totalMs);
expect(data.buildMs + data.connectMs + data.healthCheckMs).toBeGreaterThan(data.durationMs);
} finally {
vi.useRealTimers();
}
});

test('prepare ios-runner rejects a device whose owner publishes no runner preparation', async () => {
const { response, inspectFacts, bindDevice, prepareAppleRunner } = await invokePrepare({
device: ANDROID_EMULATOR,
flags: { platform: 'android', serial: ANDROID_EMULATOR.id },
});

expect(response.ok).toBe(false);
if (!response.ok) {
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(response.error.message).toBe('prepare is not supported on this device');
}
expect(inspectFacts).toHaveBeenCalledTimes(1);
expect(bindDevice).not.toHaveBeenCalled();
expect(prepareAppleRunner).not.toHaveBeenCalled();
});

test('prepare requires the ios-runner subcommand', async () => {
const { response, bindDevice } = await invokePrepare({
device: IOS_SIMULATOR,
positionals: [],
flags: { platform: 'ios' },
});

expect(response.ok).toBe(false);
if (!response.ok) {
expect(response.error.code).toBe('INVALID_ARGS');
expect(response.error.message).toBe('prepare requires a subcommand: ios-runner');
}
expect(resolveTargetDevice).not.toHaveBeenCalled();
Comment thread
thymikee marked this conversation as resolved.
expect(bindDevice).not.toHaveBeenCalled();
});

test('prepare ios-runner with no --timeout keeps the daemon-result margin under the envelope', async () => {
const { handlerTimeoutMs, envelopeMs } = await runPrepare({});
expect(envelopeMs).toBeDefined();
Expand Down
Loading
Loading