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
10 changes: 7 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseArgs, usage } from './utils/args.ts';
import { parseArgs, toDaemonFlags, usage } from './utils/args.ts';
import { asAppError, AppError } from './utils/errors.ts';
import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
import { readVersion } from './utils/version.ts';
Expand All @@ -10,6 +10,9 @@ import path from 'node:path';

export async function runCli(argv: string[]): Promise<void> {
const parsed = parseArgs(argv);
for (const warning of parsed.warnings) {
process.stderr.write(`Warning: ${warning}\n`);
}

if (parsed.flags.version) {
process.stdout.write(`${readVersion()}\n`);
Expand All @@ -22,6 +25,7 @@ export async function runCli(argv: string[]): Promise<void> {
}

const { command, positionals, flags } = parsed;
const daemonFlags = toDaemonFlags(flags);
const sessionName = flags.session ?? process.env.AGENT_DEVICE_SESSION ?? 'default';
const logTailStopper = flags.verbose && !flags.json ? startDaemonLogTail() : null;
try {
Expand All @@ -34,7 +38,7 @@ export async function runCli(argv: string[]): Promise<void> {
session: sessionName,
command: 'session_list',
positionals: [],
flags: {},
flags: daemonFlags,
});
if (!response.ok) throw new AppError(response.error.code as any, response.error.message);
if (flags.json) printJson({ success: true, data: response.data ?? {} });
Expand All @@ -47,7 +51,7 @@ export async function runCli(argv: string[]): Promise<void> {
session: sessionName,
command: command!,
positionals,
flags,
flags: daemonFlags,
});

if (response.ok) {
Expand Down
4 changes: 4 additions & 0 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@ export function isCommandSupportedOnDevice(command: string, device: DeviceInfo):
const kind = (device.kind ?? 'unknown') as keyof KindMatrix;
return byPlatform[kind] === true;
}

export function listCapabilityCommands(): string[] {
return Object.keys(COMMAND_CAPABILITY_MATRIX).sort();
}
30 changes: 2 additions & 28 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,9 @@ import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
import { setIosSetting } from '../platforms/ios/index.ts';
import type { RawSnapshotNode } from '../utils/snapshot.ts';
import type { CliFlags } from '../utils/command-schema.ts';

export type CommandFlags = {
session?: string;
platform?: 'ios' | 'android';
device?: string;
udid?: string;
serial?: string;
out?: string;
activity?: string;
verbose?: boolean;
snapshotInteractiveOnly?: boolean;
snapshotCompact?: boolean;
snapshotDepth?: number;
snapshotScope?: string;
snapshotRaw?: boolean;
snapshotBackend?: 'ax' | 'xctest';
saveScript?: boolean;
relaunch?: boolean;
noRecord?: boolean;
appsFilter?: 'launchable' | 'user-installed' | 'all';
appsMetadata?: boolean;
count?: number;
intervalMs?: number;
holdMs?: number;
jitterPx?: number;
pauseMs?: number;
pattern?: 'one-way' | 'ping-pong';
replayUpdate?: boolean;
};
export type CommandFlags = Omit<CliFlags, 'json' | 'help' | 'version'>;

export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceInfo> {
const selector = {
Expand Down
22 changes: 22 additions & 0 deletions src/daemon/handlers/__tests__/interaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { unsupportedRefSnapshotFlags } from '../interaction.ts';

test('unsupportedRefSnapshotFlags returns unsupported snapshot flags for @ref flows', () => {
const unsupported = unsupportedRefSnapshotFlags({
snapshotDepth: 2,
snapshotScope: 'Login',
snapshotRaw: true,
snapshotBackend: 'ax',
});
assert.deepEqual(unsupported, ['--depth', '--scope', '--raw', '--backend']);
});

test('unsupportedRefSnapshotFlags returns empty when no ref-unsupported flags are present', () => {
const unsupported = unsupportedRefSnapshotFlags({
platform: 'ios',
session: 'default',
verbose: true,
});
assert.deepEqual(unsupported, []);
});
37 changes: 37 additions & 0 deletions src/daemon/handlers/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export async function handleInteractionCommands(params: {
}
const refInput = req.positionals?.[0] ?? '';
if (refInput.startsWith('@')) {
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('click', req.flags);
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
if (!session.snapshot) {
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
}
Expand Down Expand Up @@ -126,6 +128,8 @@ export async function handleInteractionCommands(params: {
if (command === 'fill') {
const session = sessionStore.get(sessionName);
if (req.positionals?.[0]?.startsWith('@')) {
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('fill', req.flags);
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
if (!session?.snapshot) {
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
}
Expand Down Expand Up @@ -258,6 +262,8 @@ export async function handleInteractionCommands(params: {
}
const refInput = req.positionals?.[1] ?? '';
if (refInput.startsWith('@')) {
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('get', req.flags);
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
if (!session.snapshot) {
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
}
Expand Down Expand Up @@ -511,3 +517,34 @@ async function captureSnapshotForSession(
sessionStore.set(session.name, session);
return session.snapshot;
}

const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [
['snapshotDepth', '--depth'],
['snapshotScope', '--scope'],
['snapshotRaw', '--raw'],
['snapshotBackend', '--backend'],
];

function refSnapshotFlagGuardResponse(
command: 'click' | 'fill' | 'get',
flags: CommandFlags | undefined,
): DaemonResponse | null {
const unsupported = unsupportedRefSnapshotFlags(flags);
if (unsupported.length === 0) return null;
return {
ok: false,
error: {
code: 'INVALID_ARGS',
message: `${command} @ref does not support ${unsupported.join(', ')}.`,
},
};
}

export function unsupportedRefSnapshotFlags(flags: CommandFlags | undefined): string[] {
if (!flags) return [];
const unsupported: string[] = [];
for (const [key, label] of REF_UNSUPPORTED_FLAG_MAP) {
if (flags[key] !== undefined) unsupported.push(label);
}
return unsupported;
}
104 changes: 104 additions & 0 deletions src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs, usage } from '../args.ts';
import { AppError } from '../errors.ts';
import { getCliCommandNames, getSchemaCapabilityKeys } from '../command-schema.ts';
import { listCapabilityCommands } from '../../core/capabilities.ts';

test('parseArgs recognizes --relaunch', () => {
const parsed = parseArgs(['open', 'settings', '--relaunch']);
Expand Down Expand Up @@ -61,6 +64,107 @@ test('parseArgs rejects invalid swipe pattern', () => {

test('usage includes --relaunch flag', () => {
assert.match(usage(), /--relaunch/);
assert.match(usage(), /pinch <scale> \[x\] \[y\]/);
assert.match(usage(), /--metadata/);
});

test('every capability command has a parser schema entry', () => {
const schemaCommands = new Set(getCliCommandNames());
for (const command of listCapabilityCommands()) {
assert.equal(schemaCommands.has(command), true, `Missing schema for command: ${command}`);
}
});

test('schema capability mappings match capability source-of-truth', () => {
assert.deepEqual(getSchemaCapabilityKeys(), listCapabilityCommands());
});

test('compat mode warns and strips unsupported pilot-command flags', () => {
const parsed = parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: false });
assert.equal(parsed.command, 'press');
assert.equal(parsed.flags.snapshotDepth, undefined);
assert.equal(parsed.warnings.length, 1);
assert.match(parsed.warnings[0], /not supported for command press/);
});

test('strict mode rejects unsupported pilot-command flags', () => {
assert.throws(
() => parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: true }),
(error) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message.includes('not supported for command press'),
);
});

test('snapshot command accepts command-specific flags', () => {
const parsed = parseArgs(['snapshot', '-i', '-c', '--depth', '3', '-s', 'Login'], { strictFlags: true });
assert.equal(parsed.command, 'snapshot');
assert.equal(parsed.flags.snapshotInteractiveOnly, true);
assert.equal(parsed.flags.snapshotCompact, true);
assert.equal(parsed.flags.snapshotDepth, 3);
assert.equal(parsed.flags.snapshotScope, 'Login');
});

test('unknown short flags are rejected', () => {
assert.throws(
() => parseArgs(['press', '10', '20', '-x'], { strictFlags: true }),
(error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Unknown flag: -x',
);
});

test('negative numeric positionals are accepted without -- separator', () => {
const typed = parseArgs(['type', '-123'], { strictFlags: true });
assert.equal(typed.command, 'type');
assert.deepEqual(typed.positionals, ['-123']);

const typedMulti = parseArgs(['type', '-123', '-456'], { strictFlags: true });
assert.equal(typedMulti.command, 'type');
assert.deepEqual(typedMulti.positionals, ['-123', '-456']);

const pressed = parseArgs(['press', '-10', '20'], { strictFlags: true });
assert.equal(pressed.command, 'press');
assert.deepEqual(pressed.positionals, ['-10', '20']);
});

test('command-specific flags without command fail in strict mode', () => {
assert.throws(
() => parseArgs(['--depth', '3'], { strictFlags: true }),
(error) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message.includes('requires a command that supports it'),
);
});

test('command-specific flags without command warn and strip in compat mode', () => {
const parsed = parseArgs(['--depth', '3'], { strictFlags: false });
assert.equal(parsed.command, null);
assert.equal(parsed.flags.snapshotDepth, undefined);
assert.equal(parsed.warnings.length, 1);
assert.match(parsed.warnings[0], /requires a command that supports/);
});

test('all commands participate in strict command-flag validation', () => {
assert.throws(
() => parseArgs(['open', 'Settings', '--depth', '1'], { strictFlags: true }),
(error) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message.includes('not supported for command open'),
);
});

test('invalid enum/range errors are deterministic', () => {
assert.throws(
() => parseArgs(['snapshot', '--backend', 'foo'], { strictFlags: true }),
(error) =>
error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid backend: foo',
);
assert.throws(
() => parseArgs(['snapshot', '--depth', '-1'], { strictFlags: true }),
(error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid depth: -1',
);
});

test('usage includes swipe and press series options', () => {
Expand Down
Loading