Skip to content

Commit fceaab9

Browse files
authored
refactor: clarify command architecture (#512)
1 parent 89f3594 commit fceaab9

39 files changed

Lines changed: 2203 additions & 1705 deletions

AGENTS.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ Minimal operating guide for AI coding agents in this repo.
4242

4343
## Routing
4444
- Keep `src/daemon.ts` as a thin router.
45+
- Keep command names and daemon routing groups centralized in `src/command-catalog.ts`; do not re-create command string sets in handlers or request policy modules.
46+
- Keep CLI/client positional grammar in `src/command-codecs.ts` and its `src/command-codecs/*` command-family modules. CLI commands, typed client methods, and daemon interaction adapters should reuse these codecs instead of duplicating selector/ref/positionals parsing.
47+
- Keep `src/daemon/request-router.ts` as request orchestration: auth, diagnostics scope, request admission, locking, handler chain, and fallback dispatch.
48+
- Put request policies in focused request modules:
49+
- tenant/lease/selector/lock admission: `src/daemon/request-admission.ts`
50+
- artifact/error finalization: `src/daemon/request-finalization.ts`
51+
- Android ADB provider scoping: `src/daemon/request-android-adb.ts`
52+
- generic fallback dispatch + action recording: `src/daemon/request-generic-dispatch.ts`
53+
- recording invalidation health: `src/daemon/request-recording-health.ts`
4554
- Put command logic in handler modules:
4655
- session/apps/appstate/open/close/replay/logs: `src/daemon/handlers/session.ts`
4756
- click/fill/get/is: `src/daemon/handlers/interaction.ts`
@@ -96,7 +105,7 @@ A new snapshot/command flag touches up to 7 files in a fixed order. Follow this
96105
3. `src/client-types.ts`: add to `CaptureSnapshotOptions` (or equivalent public options type) **and** `InternalRequestOptions`.
97106
4. `src/client-normalizers.ts`: map the public option name to the internal flag name in `buildFlags`.
98107
5. `src/daemon/context.ts`: add to `DaemonCommandContext` type and `contextFromFlags` function.
99-
6. `src/core/dispatch.ts`: add to the inline context type on `dispatchCommand` and thread it to the platform call.
108+
6. `src/core/dispatch-context.ts`: add to `DispatchContext` when the flag flows into platform dispatch, then thread it through the relevant dispatcher module.
100109
7. `src/cli/commands/<command>.ts`: pass the flag from `flags.*` to the client call.
101110

102111
Command-only flags (like `find --first`) that don't flow to the platform layer only need steps 1 and the handler file.
@@ -217,7 +226,9 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o
217226
- Shared action helpers: `src/daemon/action-utils.ts`
218227
- Snapshot shaping + labels: `src/daemon/snapshot-processing.ts`
219228
- Handler context helpers: `src/daemon/context.ts`, `src/daemon/device-ready.ts`
220-
- Dispatcher + capability map: `src/core/dispatch.ts`, `src/core/capabilities.ts`
229+
- Request routing/policy: `src/daemon/request-router.ts`, `src/daemon/request-admission.ts`, `src/daemon/request-generic-dispatch.ts`
230+
- Dispatcher + capability map: `src/core/dispatch.ts`, `src/core/dispatch-context.ts`, `src/core/dispatch-interactions.ts`, `src/core/capabilities.ts`
231+
- Command catalog + positional codecs: `src/command-catalog.ts`, `src/command-codecs.ts`, `src/command-codecs/*`
221232
- Platform backends: `src/platforms/ios/*`, `ios-runner/*`, `src/platforms/android/*`
222233

223234
## Pull Requests
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { DAEMON_COMMAND_GROUPS, PUBLIC_COMMANDS } from '../command-catalog.ts';
4+
import {
5+
fillCommandCodec,
6+
findCommandCodec,
7+
interactionTargetCodec,
8+
isCommandCodec,
9+
settingsCommandCodec,
10+
waitCommandCodec,
11+
} from '../command-codecs.ts';
12+
import type { CliFlags } from '../utils/command-schema.ts';
13+
14+
const BASE_FLAGS: CliFlags = {
15+
json: false,
16+
help: false,
17+
version: false,
18+
};
19+
20+
test('command catalog owns daemon routing groups', () => {
21+
assert.equal(DAEMON_COMMAND_GROUPS.snapshot.has(PUBLIC_COMMANDS.wait), true);
22+
assert.equal(DAEMON_COMMAND_GROUPS.observability.has(PUBLIC_COMMANDS.logs), true);
23+
assert.equal(DAEMON_COMMAND_GROUPS.replay.has(PUBLIC_COMMANDS.test), true);
24+
});
25+
26+
test('wait codec preserves CLI bare text and client selector forms', () => {
27+
const options = waitCommandCodec.decode(['Continue', '1500'], BASE_FLAGS);
28+
assert.equal(options.text, 'Continue');
29+
assert.equal(options.timeoutMs, 1500);
30+
assert.deepEqual(
31+
waitCommandCodec.encode({
32+
selector: 'id=submit',
33+
timeoutMs: 2000,
34+
}),
35+
['id=submit', '2000'],
36+
);
37+
});
38+
39+
test('interaction and fill codecs share ref, selector, and point grammar', () => {
40+
assert.deepEqual(interactionTargetCodec.decode(['@e3', 'Email']), {
41+
ref: '@e3',
42+
label: 'Email',
43+
});
44+
assert.deepEqual(interactionTargetCodec.encode({ selector: 'id=submit' }), ['id=submit']);
45+
assert.deepEqual(fillCommandCodec.decode(['id=email', 'qa@example.com']), {
46+
kind: 'selector',
47+
target: { selector: 'id=email' },
48+
text: 'qa@example.com',
49+
});
50+
assert.deepEqual(fillCommandCodec.decode(['@e4', 'Email', 'qa@example.com']), {
51+
kind: 'ref',
52+
target: { ref: '@e4', label: 'Email' },
53+
text: 'qa@example.com',
54+
});
55+
assert.deepEqual(fillCommandCodec.decode(['10', '20', 'hello']), {
56+
kind: 'point',
57+
target: { x: 10, y: 20 },
58+
text: 'hello',
59+
});
60+
assert.deepEqual(
61+
fillCommandCodec.encode({
62+
ref: '@e4',
63+
label: 'Email',
64+
text: 'qa@example.com',
65+
}),
66+
['@e4', 'Email', 'qa@example.com'],
67+
);
68+
});
69+
70+
test('find and is codecs round-trip command action positionals', () => {
71+
const findOptions = findCommandCodec.decode(['label', 'Continue', 'wait', '3000'], {
72+
...BASE_FLAGS,
73+
platform: 'ios',
74+
findFirst: true,
75+
});
76+
assert.equal(findOptions.platform, 'ios');
77+
assert.equal(findOptions.locator, 'label');
78+
assert.equal(findOptions.query, 'Continue');
79+
assert.equal(findOptions.action, 'wait');
80+
assert.equal(findOptions.timeoutMs, 3000);
81+
assert.equal(findOptions.first, true);
82+
assert.deepEqual(findCommandCodec.encode(findOptions), ['label', 'Continue', 'wait', '3000']);
83+
84+
const isOptions = isCommandCodec.decode(['text', 'id=title', 'Welcome'], BASE_FLAGS);
85+
assert.equal(isOptions.predicate, 'text');
86+
assert.equal(isOptions.selector, 'id=title');
87+
assert.equal(isOptions.value, 'Welcome');
88+
assert.deepEqual(isCommandCodec.encode(isOptions), ['text', 'id=title', 'Welcome']);
89+
});
90+
91+
test('settings codec owns positional grammar for command and client paths', () => {
92+
const location = settingsCommandCodec.decode(['location', 'set', '37.3349', '-122.009'], {
93+
...BASE_FLAGS,
94+
platform: 'ios',
95+
});
96+
assert.equal(location.platform, 'ios');
97+
assert.equal(location.setting, 'location');
98+
assert.equal(location.state, 'set');
99+
assert.equal(location.latitude, 37.3349);
100+
assert.equal(location.longitude, -122.009);
101+
assert.deepEqual(settingsCommandCodec.encode(location), [
102+
'location',
103+
'set',
104+
'37.3349',
105+
'-122.009',
106+
]);
107+
108+
assert.deepEqual(
109+
settingsCommandCodec.encode({
110+
setting: 'permission',
111+
state: 'grant',
112+
permission: 'camera',
113+
mode: 'limited',
114+
}),
115+
['permission', 'grant', 'camera', 'limited'],
116+
);
117+
});

src/cli/commands/client-command.ts

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,21 @@ import type {
55
ClipboardCommandResult,
66
KeyboardCommandOptions,
77
RotateCommandOptions,
8-
WaitCommandOptions,
98
} from '../../client.ts';
109
import { CLIENT_COMMANDS } from '../../client-command-registry.ts';
1110
import type { CliFlags } from '../../utils/command-schema.ts';
1211
import { AppError } from '../../utils/errors.ts';
13-
import { parseWaitArgs } from '../../daemon/handlers/snapshot.ts';
12+
import { waitCommandCodec } from '../../command-codecs.ts';
1413
import { parseDeviceRotation } from '../../core/device-rotation.ts';
1514
import { buildSelectionOptions, writeCommandMessage, writeCommandOutput } from './shared.ts';
1615
import type { ClientCommandHandlerMap } from './router-types.ts';
1716

1817
export const clientCommandMethodHandlers = {
1918
[CLIENT_COMMANDS.wait]: async ({ positionals, flags, client }) => {
20-
writeCommandMessage(flags, await client.command.wait(readWaitOptions(positionals, flags)));
19+
writeCommandMessage(
20+
flags,
21+
await client.command.wait(waitCommandCodec.decode(positionals, flags)),
22+
);
2123
return true;
2224
},
2325
[CLIENT_COMMANDS.alert]: async ({ positionals, flags, client }) => {
@@ -64,41 +66,6 @@ export const clientCommandMethodHandlers = {
6466
},
6567
} satisfies ClientCommandHandlerMap;
6668

67-
function readWaitOptions(positionals: string[], flags: CliFlags): WaitCommandOptions {
68-
const parsed = parseWaitArgs(positionals);
69-
if (!parsed) {
70-
throw new AppError(
71-
'INVALID_ARGS',
72-
'wait requires <ms>, text <text>, @ref, or <selector> [timeoutMs].',
73-
);
74-
}
75-
76-
const base = {
77-
...buildSelectionOptions(flags),
78-
depth: flags.snapshotDepth,
79-
scope: flags.snapshotScope,
80-
raw: flags.snapshotRaw,
81-
};
82-
83-
if (parsed.kind === 'sleep') return { ...base, durationMs: parsed.durationMs };
84-
if (parsed.kind === 'text') {
85-
if (!parsed.text) throw new AppError('INVALID_ARGS', 'wait requires text.');
86-
return { ...base, text: parsed.text, ...readTimeoutOption(parsed.timeoutMs) };
87-
}
88-
if (parsed.kind === 'ref') {
89-
return { ...base, ref: parsed.rawRef, ...readTimeoutOption(parsed.timeoutMs) };
90-
}
91-
return {
92-
...base,
93-
selector: parsed.selectorExpression,
94-
...readTimeoutOption(parsed.timeoutMs),
95-
};
96-
}
97-
98-
function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } {
99-
return timeoutMs === null ? {} : { timeoutMs };
100-
}
101-
10269
function readAlertOptions(positionals: string[], flags: CliFlags): AlertCommandOptions {
10370
if (positionals.length > 2) {
10471
throw new AppError('INVALID_ARGS', 'alert accepts at most action and timeout arguments.');

0 commit comments

Comments
 (0)