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
1 change: 1 addition & 0 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,7 @@ function createStubClient(params: {
command,
devices: {
list: async () => [],
capabilities: unexpectedCommandCall,
boot: unexpectedCommandCall,
shutdown: unexpectedCommandCall,
},
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/test-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export {

export { makeSnapshotState } from './snapshot-builders.ts';

export { makeSessionStore } from './store-factory.ts';

export { withNoColor } from './color.ts';

export {
Expand Down
3 changes: 2 additions & 1 deletion src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ Command shape:
Bootstrap:
agent-device devices --platform ios
agent-device capabilities --platform android
agent-device apps --platform android
agent-device open MyApp --platform ios --device "iPhone 17 Pro"
agent-device open <discovered-app-id> --session checkout --platform android
Expand All @@ -160,7 +161,7 @@ Bootstrap:
agent-device install-from-source --github-actions-artifact org/repo:app-debug --platform android
agent-device open com.example.app --platform android --relaunch
agent-device prepare ios-runner --platform ios --timeout 240000
If app id is unknown, plan devices, apps, then open <discovered-app-id>. Discovery is not enough when the task asks to open/start the app.
If app id is unknown, plan devices, apps, then open <discovered-app-id>. Use capabilities only when a dynamic integration needs the command names supported by the selected target; normal app-driving loops do not need it. Discovery is not enough when the task asks to open/start the app.
Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install.
In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner, health-checks it with a lightweight command, and retries one stuck/non-connecting runner launch before the first snapshot pays that setup cost. It is not a recovery step for "runner already owned by another agent-device daemon"; stop the owning daemon on the Mac with simulator access instead. If the replay/test step starts a separate daemon, stop the prepare daemon before replay/test so the prepared runner does not keep a live lease owned by that daemon.
CI may cache ~/.agent-device/apple-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. Runner build/start output is written to the session's runner.log; daemon.log is for daemon lifecycle/startup issues.
Expand Down
1 change: 1 addition & 0 deletions src/client/client-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function serializeSessionListEntry(session: AgentDeviceSession): Record<s
export function serializeDevice(device: AgentDeviceDevice): Record<string, unknown> {
return {
platform: device.platform,
...(device.appleOs ? { appleOs: device.appleOs } : {}),
id: device.id,
name: device.name,
kind: device.kind,
Expand Down
8 changes: 8 additions & 0 deletions src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ export type AgentDeviceDevice = {
};
};

export type AgentDeviceCapabilitiesResult = {
device: AgentDeviceDevice;
availableCommands: string[];
};

export type AgentDeviceSessionDevice = {
platform: PublicPlatform;
target: DeviceTarget;
Expand Down Expand Up @@ -989,6 +994,9 @@ export type AgentDeviceClient = {
list: (
options?: AgentDeviceRequestOverrides & AgentDeviceSelectionOptions,
) => Promise<AgentDeviceDevice[]>;
capabilities: (
options?: AgentDeviceRequestOverrides & AgentDeviceSelectionOptions,
) => Promise<AgentDeviceCapabilitiesResult>;
boot: (options?: DeviceBootOptions) => Promise<CommandResult<'boot'>>;
shutdown: (options?: DeviceShutdownOptions) => Promise<CommandResult<'shutdown'>>;
};
Expand Down
12 changes: 12 additions & 0 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ export function createAgentDeviceClient(
const devices = Array.isArray(data.devices) ? data.devices : [];
return devices.map(normalizeDevice);
},
capabilities: async (options = {}) => {
const data = await executeCommand<Record<string, unknown>>('capabilities', options);
const availableCommands = Array.isArray(data.availableCommands)
? data.availableCommands.filter(
(command): command is string => typeof command === 'string',
)
: [];
return {
device: normalizeDevice(data.device),
availableCommands,
};
},
boot: async (options = {}) => await executeCommand<CommandResult<'boot'>>('boot', options),
shutdown: async (options = {}) =>
await executeCommand<CommandResult<'shutdown'>>('shutdown', options),
Expand Down
2 changes: 2 additions & 0 deletions src/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const PUBLIC_COMMANDS = {
back: 'back',
batch: 'batch',
boot: 'boot',
capabilities: 'capabilities',
click: 'click',
close: 'close',
clipboard: 'clipboard',
Expand Down Expand Up @@ -127,6 +128,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
PUBLIC_COMMANDS.appState,
PUBLIC_COMMANDS.prepare,
PUBLIC_COMMANDS.batch,
PUBLIC_COMMANDS.capabilities,
PUBLIC_COMMANDS.devices,
PUBLIC_COMMANDS.doctor,
PUBLIC_COMMANDS.gesture,
Expand Down
29 changes: 29 additions & 0 deletions src/commands/management/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { managementCliOutputFormatters } from './output.ts';

const devicesCommandMetadata = defineFieldCommandMetadata('devices', 'List available devices.', {});

const capabilitiesCommandMetadata = defineFieldCommandMetadata(
'capabilities',
'List commands supported by the selected device.',
{},
);

const bootCommandMetadata = defineFieldCommandMetadata(
'boot',
'Boot or prepare a selected device without using CLI positional arguments.',
Expand All @@ -28,6 +34,11 @@ const devicesCommandDefinition = defineExecutableCommand(devicesCommandMetadata,
client.devices.list(input),
);

const capabilitiesCommandDefinition = defineExecutableCommand(
capabilitiesCommandMetadata,
(client, input) => client.devices.capabilities(input),
);

const bootCommandDefinition = defineExecutableCommand(bootCommandMetadata, (client, input) =>
client.devices.boot(input),
);
Expand All @@ -42,6 +53,12 @@ const bootCliSchema = {
allowedFlags: ['headless'],
} as const satisfies CommandSchemaOverride;

const capabilitiesCliSchema = {
summary: 'List supported commands for the selected device',
helpDescription:
'List command names supported by the selected session device or explicit --platform/--device/--udid/--serial target.',
} as const satisfies CommandSchemaOverride;

const shutdownCliSchema = {
summary: 'Shutdown target simulator/emulator',
} as const satisfies CommandSchemaOverride;
Expand All @@ -54,6 +71,7 @@ const bootCliReader: CliReader = (_positionals, flags) => ({
});

const devicesDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.devices);
const capabilitiesDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.capabilities);
const bootDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.boot);
const shutdownDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.shutdown);

Expand All @@ -66,6 +84,16 @@ const devicesCommandFacet = defineCommandFacet({
cliOutputFormatter: managementCliOutputFormatters.devices,
});

const capabilitiesCommandFacet = defineCommandFacet({
name: 'capabilities',
metadata: capabilitiesCommandMetadata,
definition: capabilitiesCommandDefinition,
cliSchema: capabilitiesCliSchema,
cliReader: commonCliReader,
daemonWriter: capabilitiesDaemonWriter,
cliOutputFormatter: managementCliOutputFormatters.capabilities,
});

const bootCommandFacet = defineCommandFacet({
name: 'boot',
metadata: bootCommandMetadata,
Expand All @@ -88,6 +116,7 @@ const shutdownCommandFacet = defineCommandFacet({

export const deviceManagementCommandFacets = [
devicesCommandFacet,
capabilitiesCommandFacet,
bootCommandFacet,
shutdownCommandFacet,
] as const;
16 changes: 16 additions & 0 deletions src/commands/management/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
serializeSessionListEntry,
} from '../../client/client-shared.ts';
import type {
AgentDeviceCapabilitiesResult,
AgentDeviceDevice,
AgentDeviceSession,
AppCloseResult,
Expand Down Expand Up @@ -40,6 +41,20 @@ function devicesCliOutput(result: AgentDeviceDevice[]): CliOutput {
return { data, text: result.map(formatDeviceLine).join('\n') };
}

function capabilitiesCliOutput(result: AgentDeviceCapabilitiesResult): CliOutput {
const data = {
device: serializeDevice(result.device),
availableCommands: result.availableCommands,
};
return {
data,
text: [
`${formatDeviceLine(result.device)} supports ${result.availableCommands.length} commands:`,
result.availableCommands.join(' '),
].join('\n'),
};
}

function appsCliOutput(params: {
result: string[];
appsFilter?: 'user-installed' | 'all';
Expand Down Expand Up @@ -163,6 +178,7 @@ export const managementCliOutputFormatters = {
boot: resultOutput(bootCliOutput),
shutdown: resultOutput(shutdownCliOutput),
devices: resultOutput(devicesCliOutput),
capabilities: resultOutput(capabilitiesCliOutput),
doctor: resultOutput(doctorCliOutput),
apps: ({ input, result }) =>
appsCliOutput({
Expand Down
1 change: 1 addition & 0 deletions src/core/command-descriptor/__tests__/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set<string>([
PUBLIC_COMMANDS.appState,
PUBLIC_COMMANDS.artifacts,
PUBLIC_COMMANDS.batch,
PUBLIC_COMMANDS.capabilities,
PUBLIC_COMMANDS.devices,
PUBLIC_COMMANDS.doctor,
PUBLIC_COMMANDS.gesture,
Expand Down
12 changes: 12 additions & 0 deletions src/core/command-descriptor/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ const RAW_COMMAND_DESCRIPTORS = [
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: true,
},
{
name: PUBLIC_COMMANDS.capabilities,
daemon: {
route: 'session',
sessionKind: 'inventory',
lockPolicySelectorOverride: true,
preferExplicitDeviceOverExistingSession: true,
...REQUEST_EXECUTION_EXEMPT,
},
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: true,
},
{
name: PUBLIC_COMMANDS.doctor,
daemon: {
Expand Down
8 changes: 8 additions & 0 deletions src/daemon/__tests__/daemon-command-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ test('daemon command registry owns specialized handler routes', () => {
test('daemon command registry owns session handler subroutes', () => {
assert.equal(getSessionCommandKind(INTERNAL_COMMANDS.sessionList), 'inventory');
assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.devices), 'inventory');
assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.capabilities), 'inventory');
assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.doctor), 'inventory');
assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.apps), 'inventory');
assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.boot), 'state');
Expand All @@ -54,6 +55,7 @@ test('daemon command registry owns session handler subroutes', () => {
test('daemon command registry preserves request admission traits', () => {
for (const command of [
INTERNAL_COMMANDS.sessionList,
PUBLIC_COMMANDS.capabilities,
PUBLIC_COMMANDS.devices,
PUBLIC_COMMANDS.doctor,
INTERNAL_COMMANDS.releaseMaterializedPaths,
Expand All @@ -67,6 +69,7 @@ test('daemon command registry preserves request admission traits', () => {

for (const command of [
INTERNAL_COMMANDS.sessionList,
PUBLIC_COMMANDS.capabilities,
PUBLIC_COMMANDS.devices,
PUBLIC_COMMANDS.doctor,
INTERNAL_COMMANDS.releaseMaterializedPaths,
Expand Down Expand Up @@ -142,6 +145,7 @@ test('daemon command registry preserves Android modal and lock-policy traits', (

assert.equal(shouldGuardAndroidBlockingDialog(PUBLIC_COMMANDS.get), false);
assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.apps), true);
assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.capabilities), true);
assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.devices), true);
assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.doctor), true);
assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.open), false);
Expand All @@ -152,6 +156,10 @@ test('daemon command registry preserves provider device resolution traits', () =
shouldPreferExplicitDeviceOverExistingSession(makeRequest(PUBLIC_COMMANDS.apps)),
true,
);
assert.equal(
shouldPreferExplicitDeviceOverExistingSession(makeRequest(PUBLIC_COMMANDS.capabilities)),
true,
);
assert.equal(
shouldPreferExplicitDeviceOverExistingSession(makeRequest(PUBLIC_COMMANDS.snapshot)),
false,
Expand Down
83 changes: 83 additions & 0 deletions src/daemon/handlers/__tests__/session-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { test, expect } from 'vitest';
import path from 'node:path';
import os from 'node:os';
import { PUBLIC_COMMANDS } from '../../../command-catalog.ts';
import { makeAndroidSession, makeSessionStore } from '../../../__tests__/test-utils/index.ts';
import { withTargetDeviceResolutionScope } from '../../../core/dispatch-resolve.ts';
import type { DeviceInfo } from '../../../kernel/device.ts';
import { handleSessionCommands } from '../session.ts';

test('capabilities reports supported commands for the selected session device', async () => {
const sessionName = 'android-capabilities';
const sessionStore = makeSessionStore('agent-device-capabilities-');
sessionStore.set(sessionName, makeAndroidSession(sessionName));

const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: PUBLIC_COMMANDS.capabilities,
positionals: [],
flags: {},
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: async () => ({ ok: true, data: {} }),
});

expect(response?.ok).toBe(true);
if (!response?.ok) return;

expect(response.data?.device).toMatchObject({
platform: 'android',
kind: 'emulator',
});
expect(response.data?.availableCommands).toEqual(
expect.arrayContaining(['open', 'screenshot', 'snapshot', 'press', 'fill', 'network', 'perf']),
);
expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.capabilities);
expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.devices);
});

test('capabilities accepts a stopped Android AVD placeholder for explicit platform discovery', async () => {
const stoppedAvd: DeviceInfo = {
platform: 'android',
id: 'Pixel_8_API_35',
name: 'Pixel 8 API 35',
kind: 'emulator',
booted: false,
};
const sessionStore = makeSessionStore('agent-device-capabilities-stopped-avd-');

const response = await withTargetDeviceResolutionScope(
async (request) => (request.platform === 'android' ? [stoppedAvd] : []),
async () =>
await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: PUBLIC_COMMANDS.capabilities,
positionals: [],
flags: { platform: 'android' },
},
sessionName: 'default',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: async () => ({ ok: true, data: {} }),
}),
);

expect(response?.ok).toBe(true);
if (!response?.ok) return;

expect(response.data?.device).toMatchObject({
platform: 'android',
id: 'Pixel_8_API_35',
kind: 'emulator',
booted: false,
});
expect(response.data?.availableCommands).toEqual(
expect.arrayContaining(['open', 'screenshot', 'snapshot', 'press', 'fill']),
);
});
Loading
Loading