Skip to content
24 changes: 0 additions & 24 deletions packages/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,6 @@
"types": "./src/__tests__/support/selector-capture-fixture.ts",
"default": "./src/__tests__/support/selector-capture-fixture.ts"
},
"./testing/__tests__/session-teardown.fixtures": {
"types": "./src/__tests__/support/session-teardown.fixtures.ts",
"default": "./src/__tests__/support/session-teardown.fixtures.ts"
},
"./testing/__tests__/snapshot-runtime-fixture": {
"types": "./src/__tests__/support/snapshot-runtime-fixture.ts",
"default": "./src/__tests__/support/snapshot-runtime-fixture.ts"
Expand All @@ -112,10 +108,6 @@
"types": "./src/__tests__/support/command-surface-fake.ts",
"default": "./src/__tests__/support/command-surface-fake.ts"
},
"./testing/__tests__/support/device-claim-store": {
"types": "./src/__tests__/support/device-claim-store.ts",
"default": "./src/__tests__/support/device-claim-store.ts"
},
"./testing/__tests__/support/provider-device-admission-fixture": {
"types": "./src/__tests__/support/provider-device-admission-fixture.ts",
"default": "./src/__tests__/support/provider-device-admission-fixture.ts"
Expand Down Expand Up @@ -148,10 +140,6 @@
"types": "./src/execution/app-events.ts",
"default": "./src/execution/app-events.ts"
},
"./testing/application-lifecycle-recovery": {
"types": "./src/server/application-lifecycle-recovery.ts",
"default": "./src/server/application-lifecycle-recovery.ts"
},
"./testing/artifact-tracking": {
"types": "./src/resources/artifact-tracking.ts",
"default": "./src/resources/artifact-tracking.ts"
Expand Down Expand Up @@ -344,18 +332,10 @@
"types": "./src/execution/selector-runtime-backend.ts",
"default": "./src/execution/selector-runtime-backend.ts"
},
"./testing/server/daemon-runtime": {
"types": "./src/server/http/daemon-runtime.ts",
"default": "./src/server/http/daemon-runtime.ts"
},
"./testing/server/http-server": {
"types": "./src/server/http/http-server.ts",
"default": "./src/server/http/http-server.ts"
},
"./testing/server/server-shutdown": {
"types": "./src/server/http/server-shutdown.ts",
"default": "./src/server/http/server-shutdown.ts"
},
"./testing/server/transport": {
"types": "./src/server/http/transport.ts",
"default": "./src/server/http/transport.ts"
Expand Down Expand Up @@ -404,10 +384,6 @@
"types": "./src/core/session-store.ts",
"default": "./src/core/session-store.ts"
},
"./testing/session-teardown-budget": {
"types": "./src/sessions/session-teardown-budget.ts",
"default": "./src/sessions/session-teardown-budget.ts"
},
"./testing/snapshot-capture": {
"types": "./src/core/snapshot-capture.ts",
"default": "./src/core/snapshot-capture.ts"
Expand Down
77 changes: 77 additions & 0 deletions packages/daemon/src/__tests__/support/command-surface-fake.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, test, vi } from 'vitest';
import type { AgentDeviceRuntimeConfig } from '@agent-device/contracts/runtime-contract';
import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot';
import { forwardingCreateCommandSurface } from './command-surface-fake.ts';

const node = { index: 0, ref: 'e1', type: 'Button', label: 'Go' } as SnapshotNode;

function configReturning(
captured: Record<string, unknown>,
existing?: { snapshot: SnapshotState },
) {
const set = vi.fn(async () => {});
const config = {
backend: { captureSnapshot: vi.fn(async () => captured) },
sessions: {
get: vi.fn(async () => (existing ? { name: 'default', ...existing } : undefined)),
set,
},
} as unknown as AgentDeviceRuntimeConfig;
return { config, set };
}

test('the forwarding surface returns and stores the snapshot tree the daemon backend produced', async () => {
const snapshot: SnapshotState = { nodes: [node], createdAt: 1, backend: 'android' };
const { config, set } = configReturning({ snapshot });
const surface = await forwardingCreateCommandSurface(config);

const result = await surface.capture.snapshot({ session: 'default' });

expect(result.nodes).toEqual([node]);
expect(set).toHaveBeenCalledWith(expect.objectContaining({ name: 'default', snapshot }));
});

test('the forwarding surface falls back to a flat node list from a backend that returns no snapshot', async () => {
const { config, set } = configReturning({ nodes: [node] });
const surface = await forwardingCreateCommandSurface(config);

const result = await surface.capture.snapshot({ session: 'default' });

expect(result.nodes).toEqual([node]);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({ snapshot: expect.objectContaining({ nodes: [node] }) }),
);
});

test('the forwarding surface diff stores the observed tree and reports whether it initialized the baseline', async () => {
const snapshot: SnapshotState = { nodes: [node], createdAt: 1, backend: 'android' };
const first = configReturning({ snapshot });
const initialized = await (
await forwardingCreateCommandSurface(first.config)
).capture.diffSnapshot({ session: 'default' });

expect(initialized.baselineInitialized).toBe(true);
expect(first.set).toHaveBeenCalledWith(expect.objectContaining({ snapshot }));

const second = configReturning({ snapshot }, { snapshot: { ...snapshot, createdAt: 0 } });
const compared = await (
await forwardingCreateCommandSurface(second.config)
).capture.diffSnapshot({ session: 'default' });

expect(compared.baselineInitialized).toBe(false);
expect(second.set).toHaveBeenCalledWith(expect.objectContaining({ snapshot }));
});

test('the forwarding surface hands the backend the capture options of both snapshot and diff', async () => {
const snapshot: SnapshotState = { nodes: [node], createdAt: 1, backend: 'android' };
const { config } = configReturning({ snapshot });
const surface = await forwardingCreateCommandSurface(config);
const options = { session: 'default', interactiveOnly: true, depth: 3, scope: '@e1', raw: true };

await surface.capture.snapshot(options);
await surface.capture.diffSnapshot(options);

const expected = { interactiveOnly: true, depth: 3, scope: '@e1', raw: true };
expect(config.backend.captureSnapshot).toHaveBeenNthCalledWith(1, expect.anything(), expected);
expect(config.backend.captureSnapshot).toHaveBeenNthCalledWith(2, expect.anything(), expected);
});
57 changes: 57 additions & 0 deletions packages/daemon/src/__tests__/support/command-surface-fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { vi } from 'vitest';
import type {
CreateDaemonCommandSurface,
DaemonCommandSurface,
DaemonSnapshotOptions,
} from '@agent-device/contracts/daemon-command-surface';

/**
Expand Down Expand Up @@ -63,3 +64,59 @@ const fakeCommandSurface: DaemonCommandSurface = {

/** The port value the daemon-policy suites inject; see {@link fakeCommandSurface}. */
export const fakeCreateCommandSurface: CreateDaemonCommandSurface = async () => fakeCommandSurface;

/**
* A command surface whose snapshot and diff captures do what the real command family does at the
* two seams the daemon owns: they reach the runtime config's backend, and they write the session
* back through the config's session store. Suites that pin what the daemon does around a capture
* (shutdown, cancellation, session lifetime, ref-frame state) need both, because the daemon's
* refusals and frame rules land on those seams. Everything else the real command family does with
* the result stays out of the daemon suites: a snapshot returns the backend's tree, and a diff
* returns an empty comparison whose only informative field is whether it initialized the baseline.
*/
export const forwardingCreateCommandSurface: CreateDaemonCommandSurface = async (config) => {
const captureIntoSession = async (options: DaemonSnapshotOptions) => {
const name = options.session ?? 'default';
const session = await config.sessions?.get(name);
const captured = await config.backend.captureSnapshot?.(
{ session: name, requestId: options.requestId, signal: options.signal ?? config.signal },
{
interactiveOnly: options.interactiveOnly,
depth: options.depth,
scope: options.scope,
raw: options.raw,
},
);
const stored = captured?.snapshot ?? {
nodes: captured?.nodes ?? [],
createdAt: Date.now(),
backend: 'android' as const,
};
await config.sessions?.set({
...(session ?? { name }),
name: session?.name ?? name,
snapshot: stored,
});
return { nodes: stored.nodes as never, hadBaseline: session?.snapshot !== undefined };
};
return {
...fakeCommandSurface,
signal: config.signal,
capture: {
...fakeCommandSurface.capture,
snapshot: async (options) => {
const { nodes } = await captureIntoSession(options);
return { nodes };
},
diffSnapshot: async (options) => {
const { hadBaseline } = await captureIntoSession(options);
return {
mode: 'snapshot',
baselineInitialized: !hadBaseline,
summary: { additions: 0, removals: 0, unchanged: 0 },
lines: [],
};
},
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { ApplicationLifecycleResourceLifecycle } from '@agent-device/contracts/application-lifecycle-runtime';
import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures';
import { createOwnedProcessRecordStore } from '@agent-device/host-kit/process';
import { vi } from 'vitest';
import type {
ComposeDaemonRuntimeDeps,
DaemonRuntimeDeps,
DaemonRuntimeDepsInput,
} from '../../server/http/daemon-runtime.ts';
import type { PlatformOwnerLifecycle } from '../../core/platform-owner-lifecycle.ts';
import { clearAndroidObservationFixture } from './android-observation-fixture.ts';
import { fakeCreateCommandSurface } from './command-surface-fake.ts';
import { hostDiagnosticsFixture } from './host-diagnostics-fixture.ts';
import { platformResourceCleanupFixture } from './platform-resource-cleanup-fixture.ts';
import {
daemonClaimRecoveryGatewayFixture,
daemonPlatformServicesFixture,
} from './platform-services-fixture.ts';
import { providerCredentialsFixture } from './provider-credentials-fixture.ts';
import { lifecycleDeviceRuntimeGateway } from './test-device-runtime-gateway.ts';

/** The durable lifecycle phases the runtime runs at startup and shutdown, as spies. */
export function applicationLifecycleResourceFixture(
overrides: Partial<ApplicationLifecycleResourceLifecycle> = {},
): ApplicationLifecycleResourceLifecycle {
return Object.freeze({
recoverStartupResources: vi.fn(async () => {}),
detachForDaemonShutdown: vi.fn(async () => {}),
finalizeDaemonShutdown: vi.fn(async () => {}),
...overrides,
});
}

/** Every member observable as a spy, which is how the runtime suites read the owner back. */
export function platformOwnerLifecycleFixture(
overrides: Partial<PlatformOwnerLifecycle> = {},
): PlatformOwnerLifecycle {
return Object.freeze({
configureForDaemonLock: vi.fn(async () => {}),
clearDaemonLockConfiguration: vi.fn(async () => {}),
recoverLegacyAppLogMarkers: vi.fn(async () => ({ recovered: [], retained: [] })),
cleanupManagedWebOrphans: vi.fn(async () => {}),
resetAndroidSnapshotHelper: vi.fn(async () => {}),
...overrides,
});
}

/**
* The `composeDeps` factory a daemon-runtime suite hands `startDaemonRuntime`: every capability the
* root composition would supply is an inert port, so a suite exercises the server's own lifecycle,
* idle, lease, and shutdown decisions. A suite that cares about one capability replaces just that
* member, receiving the paths and session callbacks the server owns.
*/
export function daemonRuntimeDepsFixture(
overrides:
| Partial<DaemonRuntimeDeps>
| ((input: DaemonRuntimeDepsInput) => Partial<DaemonRuntimeDeps>) = {},
): ComposeDaemonRuntimeDeps {
return async (input) => {
const resolved = typeof overrides === 'function' ? overrides(input) : overrides;
return Object.freeze({
ownedProcessRecords: createOwnedProcessRecordStore({
stateDir: input.baseDir,
sessionsDir: input.sessionsDir,
resolveSessionDir: input.resolveSessionDir,
}),
hostDiagnostics: hostDiagnosticsFixture(),
ownerLifecycle: platformOwnerLifecycleFixture(),
providerDeviceRuntimes: [],
skippedProviderRuntimes: [],
providerRuntimeIds: [],
recoverableProviderIds: [],
deviceRuntimeGateway: Object.freeze({
...lifecycleDeviceRuntimeGateway,
applicationLifecycle: applicationLifecycleResourceFixture(),
}),
platformResourceCleanup: platformResourceCleanupFixture(),
claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(),
interactorResolution: Object.freeze({
resolve: async () => {
throw new Error('daemon runtime fixture: no interactor is composed');
},
}),
providerDeviceAdmission: Object.freeze({ isActive: () => false }),
cleanupManagedWebOrphans: async () => {},
requestRouter: Object.freeze({
deviceInventoryGateways: createTestDeviceInventoryGateways(),
platformServices: daemonPlatformServicesFixture(),
androidObservation: clearAndroidObservationFixture,
providerCredentials: providerCredentialsFixture(input.env, input.baseDir),
createCommandSurface: fakeCreateCommandSurface,
}),
...resolved,
});
};
}
68 changes: 68 additions & 0 deletions packages/daemon/src/server/http/daemon-runtime-artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect, test } from 'vitest';
import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir';
import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts';
import {
cleanupDownloadableArtifact,
trackDownloadableArtifact,
} from '../../resources/artifact-tracking.ts';
import { startDaemonRuntime } from './daemon-runtime.ts';

async function downloadTrackedArtifact(env: Record<string, string>, awaitConsumption: boolean) {
const stateDir = mkdtempForTestSync('agent-device-daemon-runtime-artifacts-');
const artifactPath = path.join(stateDir, 'runtime-artifact.txt');
fs.writeFileSync(artifactPath, 'runtime-artifact');
const artifactId = trackDownloadableArtifact({
artifactPath,
artifactType: 'runtime-artifact',
fileName: 'runtime-artifact.txt',
});
const runtime = await startDaemonRuntime({
env: {
...process.env,
AGENT_DEVICE_STATE_DIR: stateDir,
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
...env,
},
exit: () => {},
registerProcessHandlers: false,
stderr: { write: () => {} },
stdout: { write: () => {} },
composeDeps: daemonRuntimeDepsFixture(),
});
try {
expect(runtime).not.toBeNull();
const response = await fetch(
`http://127.0.0.1:${runtime?.httpPort}/artifacts/${encodeURIComponent(artifactId)}`,
{ headers: { authorization: `Bearer ${runtime?.token}`, connection: 'close' } },
);
const body = await response.text();
if (awaitConsumption) {
for (let attempt = 0; attempt < 20 && fs.existsSync(artifactPath); attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
return { status: response.status, body, onDisk: fs.existsSync(artifactPath) };
} finally {
await runtime?.shutdown();
cleanupDownloadableArtifact(artifactId);
}
}

test('AGENT_DEVICE_RETAIN_ARTIFACTS keeps a downloaded artifact on disk', async () => {
const download = await downloadTrackedArtifact({ AGENT_DEVICE_RETAIN_ARTIFACTS: '1' }, false);

expect(download.status).toBe(200);
expect(download.body).toBe('runtime-artifact');
expect(download.onDisk).toBe(true);
});

test('a downloaded artifact is consumed when the daemon was not told to retain artifacts', async () => {
const download = await downloadTrackedArtifact({ AGENT_DEVICE_RETAIN_ARTIFACTS: '' }, true);

expect(download.status).toBe(200);
expect(download.body).toBe('runtime-artifact');
expect(download.onDisk).toBe(false);
});
Loading
Loading