Skip to content
Closed
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
6 changes: 0 additions & 6 deletions fallow-baselines/health.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,6 @@
"count": 1
}
},
"src/daemon/handlers/session-state.ts": {
"complexity_critical": {
"count": 2
}
},
"src/daemon/handlers/session-test-artifacts.ts": {
"complexity_moderate": {
"count": 1
Expand Down Expand Up @@ -197,7 +192,6 @@
"packages/provision-kit/src/boot-diagnostics.ts:complexity",
"src/compat/maestro/support.ts:high impact",
"src/daemon/session-routing.ts:high impact",
"src/daemon/handlers/session-state.ts:complexity",
"src/commands/cli-grammar/common.ts:high impact",
"src/snapshot/snapshot-presentation/tree.ts:high impact",
"packages/kernel/src/success-text.ts:high impact",
Expand Down
5 changes: 5 additions & 0 deletions scripts/layering/daemon-layer-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@
"src/daemon/handlers/session-app-deployment.ts",
"src/daemon/handlers/session-app-source-deployment.ts",
"src/daemon/handlers/session-batch.ts",
"src/daemon/handlers/session-appstate-apple-session.ts",
"src/daemon/handlers/session-appstate-device-runtime.ts",
"src/daemon/handlers/session-appstate.ts",
"src/daemon/handlers/session-boot.ts",
"src/daemon/handlers/session-command-input.ts",
"src/daemon/handlers/session-doctor-app.ts",
"src/daemon/handlers/session-doctor-device.ts",
Expand All @@ -206,6 +210,7 @@
"src/daemon/handlers/session-runtime-command.ts",
"src/daemon/handlers/session-runtime-port-reverse.ts",
"src/daemon/handlers/session-script-publication.ts",
"src/daemon/handlers/session-shutdown.ts",
"src/daemon/handlers/session-state.ts",
"src/daemon/handlers/session.ts",
"src/daemon/handlers/snapshot.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { expect, test } from 'vitest';
import { shutdownTargetUse } from '@agent-device/contracts/platform-runtime-operations';
import { commandDescriptors } from '@agent-device/command-registry/registry';

const sessionStateSource = readFileSync(
new URL('../daemon/handlers/session-state.ts', import.meta.url),
const sessionShutdownSource = readFileSync(
new URL('../daemon/handlers/session-shutdown.ts', import.meta.url),
'utf8',
);
const sessionCloseSource = readFileSync(
Expand Down Expand Up @@ -42,9 +42,7 @@ test('shutdown descriptor declares one canonical runtime operation', () => {
});

test('canonical shutdown and close parity share one concrete teardown mechanic', () => {
const shutdownRoute = sessionStateSource.slice(
sessionStateSource.indexOf("if (req.command === 'shutdown')"),
);
const shutdownRoute = sessionShutdownSource;
expect(shutdownRoute.match(/inspectFacts\(device\)/g)).toHaveLength(1);
expect(shutdownRoute.match(/bindDevice\(device, shutdownTargetUse\)/g)).toHaveLength(1);
expect(shutdownRoute).not.toContain('operations.ensureReady');
Expand Down
296 changes: 296 additions & 0 deletions src/daemon/handlers/__tests__/session-appstate-device-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts';
import { test, expect, vi } from 'vitest';

import { handleSessionStateCommands } from '../session-state.ts';
import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts';
import { withTestDeviceInventory } from '../../../__tests__/test-utils/device-inventory-gateways.ts';
import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts';
import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime';
import {
type DeviceBinding,
type RuntimeFacts,
localRuntimeOwner,
narrowDeviceBinding,
} from '@agent-device/contracts/platform-runtime';
import {
type PlatformRuntimeOperations,
appStateUse,
} from '@agent-device/contracts/platform-runtime-operations';
import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable';
import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device';
import type { BindDeviceRuntime } from '../../request-runtime-binding.ts';

test('appstate rejects web before Android app-state backend dispatch', async () => {
const device = {
platform: 'web' as const,
id: 'agent-browser-chrome',
name: 'Agent Browser Chrome',
kind: 'device' as const,
target: 'desktop' as const,
booted: true,
};
let bound = false;
const bindDevice: BindDeviceRuntime = async () => {
bound = true;
throw new Error('unsupported appstate must not bind');
};
const response = await withTestDeviceInventory(
{
local: async () => [device],
},
async () =>
await handleSessionStateCommands({
platformServices: daemonPlatformServicesFixture(),
req: {
token: 't',
session: 'default',
command: 'appstate',
positionals: [],
flags: { platform: 'web' },
},
sessionName: 'default',
sessionStore: makeSessionStore(),
bindDevice,
inspectFacts: async () =>
createUnavailablePlatformRuntimeFacts(device, localRuntimeOwner('web'), {
appLog: { available: false, reason: 'unsupported-platform-leaf' },
appState: { available: false, reason: 'unsupported-platform-leaf' },
network: { available: false, reason: 'unsupported-platform-leaf' },
screenshot: { available: false, reason: 'unsupported-platform-leaf' },
viewport: { available: false, reason: 'unsupported-platform-leaf' },
focus: { available: false, reason: 'unsupported-platform-leaf' },
gesture: { available: false, reason: 'unsupported-platform-leaf' },
scroll: { available: false, reason: 'unsupported-platform-leaf' },
typeText: { available: false, reason: 'unsupported-platform-leaf' },
touch: { available: false, reason: 'unsupported-platform-leaf' },
elementText: { available: false, reason: 'unsupported-platform-leaf' },
back: { available: false, reason: 'unsupported-platform-leaf' },
orientation: { available: false, reason: 'unsupported-platform-leaf' },
tvRemote: { available: false, reason: 'unsupported-platform-leaf' },
keyboard: { available: false, reason: 'unsupported-platform-leaf' },
clipboard: { available: false, reason: 'unsupported-platform-leaf' },
systemButton: { available: false, reason: 'unsupported-platform-leaf' },
fold: { available: false, reason: 'unsupported-platform-leaf' },
triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' },
settings: { available: false, reason: 'unsupported-platform-leaf' },
readAlert: { available: false, reason: 'unsupported-platform-leaf' },
awaitAlert: { available: false, reason: 'unsupported-platform-leaf' },
acceptAlert: { available: false, reason: 'unsupported-platform-leaf' },
dismissAlert: { available: false, reason: 'unsupported-platform-leaf' },
audioProbeCapture: { available: false, reason: 'unsupported-platform-leaf' },
audioProbeQuery: { available: false, reason: 'unsupported-platform-leaf' },
readiness: { available: false, reason: 'unsupported-platform-leaf' },
lifecycle: applicationLifecycleOperationFacts({
resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' },
prepareApplicationOpen: { available: false, reason: 'unsupported-platform-leaf' },
openApplication: { available: false, reason: 'unsupported-platform-leaf' },
applyRuntimeHints: { available: false, reason: 'unsupported-platform-leaf' },
clearRuntimeHints: { available: false, reason: 'unsupported-platform-leaf' },
closeApplication: { available: false, reason: 'unsupported-platform-leaf' },
finalizeApplicationClose: { available: false, reason: 'unsupported-platform-leaf' },
prepareAppleRunner: { available: false, reason: 'unsupported-platform-leaf' },
configureProviderPortReverse: {
available: false,
reason: 'unsupported-platform-leaf',
},
}),
}),
}),
);

expect(response).toBeTruthy();
expect(response?.ok).toBe(false);
if (response && !response.ok) {
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(response.error.message).toMatch(/appstate is not supported on web/i);
}
expect(bound).toBe(false);
});

test('appstate rejects a missing readiness fact even when appState is available', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'android-device-1',
name: 'Android Device',
kind: 'device',
target: 'mobile',
booted: true,
};
const facts: RuntimeFacts<PlatformRuntimeOperations> = {
device: { ...deviceShape(device), providerMode: 'local' },
operations: {
appLogInspect: { available: false, reason: 'owner-capability-missing' },
appLogDoctor: { available: false, reason: 'owner-capability-missing' },
appLogStart: { available: false, reason: 'owner-capability-missing' },
appLogReattach: { available: false, reason: 'owner-capability-missing' },
appLogCleanup: { available: false, reason: 'owner-capability-missing' },
appState: { available: true },
listApps: { available: false, reason: 'owner-capability-missing' },
networkDump: { available: false, reason: 'owner-capability-missing' },
screenRecordingStart: { available: false, reason: 'owner-capability-missing' },
screenRecordingReattach: { available: false, reason: 'owner-capability-missing' },
screenRecordingCleanup: { available: false, reason: 'owner-capability-missing' },
ensureReady: {
available: false,
reason: 'unsupported-device-kind',
hint: 'readiness is unavailable for this device kind',
},
bootTarget: { available: false, reason: 'unsupported-device-kind' },
bootTargetHeadless: { available: false, reason: 'unsupported-device-kind' },
...unavailableDeploymentSnapshotAndShutdownOperationFacts,
...applicationLifecycleOperationFacts({
resolveOpenTarget: { available: false, reason: 'unsupported-device-kind' },
prepareApplicationOpen: { available: false, reason: 'unsupported-device-kind' },
openApplication: { available: false, reason: 'unsupported-device-kind' },
applyRuntimeHints: { available: false, reason: 'unsupported-device-kind' },
clearRuntimeHints: { available: false, reason: 'unsupported-device-kind' },
closeApplication: { available: false, reason: 'unsupported-device-kind' },
finalizeApplicationClose: { available: false, reason: 'unsupported-device-kind' },
prepareAppleRunner: { available: false, reason: 'unsupported-device-kind' },
configureProviderPortReverse: { available: false, reason: 'unsupported-device-kind' },
}),
},
};
const inspectFacts = vi.fn(async () => facts);
let bound = false;
const bindDevice: BindDeviceRuntime = async () => {
bound = true;
throw new Error('missing readiness must not bind');
};
const response = await withTestDeviceInventory(
{ local: async () => [device] },
async () =>
await handleSessionStateCommands({
platformServices: daemonPlatformServicesFixture(),
req: {
token: 't',
session: 'default',
command: 'appstate',
positionals: [],
flags: { platform: 'android', device: device.name },
},
sessionName: 'default',
sessionStore: makeSessionStore(),
inspectFacts,
bindDevice,
}),
);

expect(response).toBeTruthy();
expect(response?.ok).toBe(false);
if (response && !response.ok) {
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(response.error.hint).toMatch(/readiness is unavailable/i);
}
expect(inspectFacts).toHaveBeenCalledOnce();
expect(bound).toBe(false);
});
test('sessionless Android appstate inspects once, binds once, and preserves operation order', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
target: 'mobile',
booted: true,
};
const unavailable = { available: false, reason: 'owner-capability-missing' } as const;
const available = { available: true } as const;
const facts: RuntimeFacts<PlatformRuntimeOperations> = {
device: { ...deviceShape(device), providerMode: 'local' },
operations: {
appLogInspect: unavailable,
appLogDoctor: unavailable,
appLogStart: unavailable,
appLogReattach: unavailable,
appLogCleanup: unavailable,
appState: available,
listApps: unavailable,
networkDump: unavailable,
screenRecordingStart: unavailable,
screenRecordingReattach: unavailable,
screenRecordingCleanup: unavailable,
ensureReady: available,
bootTarget: available,
bootTargetHeadless: unavailable,
...unavailableDeploymentSnapshotAndShutdownOperationFacts,
...applicationLifecycleOperationFacts({
resolveOpenTarget: unavailable,
prepareApplicationOpen: unavailable,
openApplication: unavailable,
applyRuntimeHints: unavailable,
clearRuntimeHints: unavailable,
closeApplication: unavailable,
finalizeApplicationClose: unavailable,
prepareAppleRunner: unavailable,
configureProviderPortReverse: unavailable,
}),
},
};
const events: string[] = [];
const binding: DeviceBinding<PlatformRuntimeOperations> = {
device,
owner: localRuntimeOwner('android'),
facts,
operations: {
ensureReady: async (input) => {
events.push(`ensureReady:${JSON.stringify(input)}`);
return { ...device, booted: true };
},
appState: async () => {
events.push('appState');
return { package: 'com.example.app', activity: '.MainActivity' };
},
},
[Symbol.asyncDispose]: async () => undefined,
};
const inspectFacts = vi.fn(async () => {
events.push('inspectFacts');
return facts;
});
const bindDevice: BindDeviceRuntime = async (selected, use) => {
events.push('bindDevice');
expect(selected).toEqual(device);
expect(use).toBe(appStateUse);
return narrowDeviceBinding(binding, use);
};

const response = await withTestDeviceInventory(
{ local: async () => [device] },
async () =>
await handleSessionStateCommands({
platformServices: daemonPlatformServicesFixture(),
req: {
token: 't',
session: 'default',
command: 'appstate',
positionals: [],
flags: {
platform: 'android',
device: 'Pixel Emulator',
androidDeviceAllowlist: 'emulator-5555,emulator-5554',
},
},
sessionName: 'default',
sessionStore: makeSessionStore(),
inspectFacts,
bindDevice,
}),
);

expect(response).toEqual({
ok: true,
data: {
platform: 'android',
package: 'com.example.app',
activity: '.MainActivity',
},
});
expect(inspectFacts).toHaveBeenCalledOnce();
expect(events).toEqual([
'inspectFacts',
'bindDevice',
'ensureReady:{"androidSerialAllowlist":["emulator-5554","emulator-5555"]}',
'appState',
]);
});
23 changes: 23 additions & 0 deletions src/daemon/handlers/__tests__/session-appstate-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device';
import type { SessionState } from '../../session-state.ts';
import type { DaemonRequest } from '../../daemon-request.ts';
import { handleSessionCommands, mockInspectDeviceRuntimeFacts } from './session-command-harness.ts';
import { handleSessionStateCommands } from '../session-state.ts';
import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts';
import { AppError } from '@agent-device/kernel/errors';
import type {
Expand Down Expand Up @@ -452,3 +453,25 @@ test('appstate on iOS keeps the session answer, with no state, when the runner c
expect(response.data).not.toHaveProperty('state');
}
});

test('appstate returns missing-session error for explicit session flag', async () => {
const response = await handleSessionStateCommands({
platformServices: daemonPlatformServicesFixture(),
req: {
token: 't',
session: 'named',
command: 'appstate',
positionals: [],
flags: { platform: 'ios', session: 'named' },
},
sessionName: 'named',
sessionStore: makeSessionStore(),
});

expect(response).toBeTruthy();
expect(response?.ok).toBe(false);
if (response && !response.ok) {
expect(response.error.code).toBe('SESSION_NOT_FOUND');
expect(response.error.message).toMatch(/Run open with --session named first/i);
}
});
Loading
Loading