agent-device 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -12
- package/dist/src/797.js +1 -0
- package/dist/src/bin.js +40 -29
- package/dist/src/daemon.js +21 -17
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
- package/package.json +2 -2
- package/skills/agent-device/SKILL.md +23 -14
- package/skills/agent-device/references/permissions.md +7 -2
- package/skills/agent-device/references/session-management.md +5 -1
- package/src/__tests__/cli-close.test.ts +155 -0
- package/src/__tests__/cli-help.test.ts +102 -0
- package/src/cli.ts +68 -22
- package/src/core/__tests__/capabilities.test.ts +2 -1
- package/src/core/__tests__/dispatch-open.test.ts +25 -0
- package/src/core/__tests__/open-target.test.ts +40 -1
- package/src/core/capabilities.ts +1 -1
- package/src/core/dispatch.ts +22 -0
- package/src/core/open-target.ts +14 -0
- package/src/daemon/__tests__/device-ready.test.ts +52 -0
- package/src/daemon/__tests__/session-store.test.ts +23 -0
- package/src/daemon/device-ready.ts +146 -4
- package/src/daemon/handlers/__tests__/session.test.ts +477 -0
- package/src/daemon/handlers/session.ts +198 -93
- package/src/daemon/handlers/snapshot.ts +210 -185
- package/src/daemon/session-store.ts +16 -6
- package/src/daemon/types.ts +2 -1
- package/src/daemon-client.ts +138 -17
- package/src/daemon.ts +99 -9
- package/src/platforms/android/__tests__/index.test.ts +118 -1
- package/src/platforms/android/index.ts +77 -47
- package/src/platforms/ios/__tests__/index.test.ts +292 -4
- package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
- package/src/platforms/ios/apps.ts +358 -0
- package/src/platforms/ios/config.ts +28 -0
- package/src/platforms/ios/devicectl.ts +134 -0
- package/src/platforms/ios/devices.ts +15 -2
- package/src/platforms/ios/index.ts +20 -455
- package/src/platforms/ios/runner-client.ts +171 -69
- package/src/platforms/ios/simulator.ts +164 -0
- package/src/utils/__tests__/args.test.ts +66 -2
- package/src/utils/__tests__/daemon-client.test.ts +95 -0
- package/src/utils/__tests__/keyed-lock.test.ts +55 -0
- package/src/utils/__tests__/process-identity.test.ts +33 -0
- package/src/utils/args.ts +37 -1
- package/src/utils/command-schema.ts +58 -27
- package/src/utils/interactors.ts +2 -2
- package/src/utils/keyed-lock.ts +14 -0
- package/src/utils/process-identity.ts +100 -0
- package/src/utils/timeouts.ts +9 -0
- package/dist/src/274.js +0 -1
- package/src/daemon/__tests__/app-state.test.ts +0 -138
- package/src/daemon/app-state.ts +0 -65
|
@@ -216,14 +216,20 @@ final class RunnerTests: XCTestCase {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
private func executeOnMain(command: Command) throws -> Response {
|
|
219
|
-
let
|
|
220
|
-
|
|
219
|
+
let normalizedBundleId = command.appBundleId?
|
|
220
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
221
|
+
let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
|
|
222
|
+
if let bundleId = requestedBundleId, currentBundleId != bundleId {
|
|
221
223
|
let target = XCUIApplication(bundleIdentifier: bundleId)
|
|
222
224
|
NSLog("AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d", bundleId, target.state.rawValue)
|
|
223
225
|
// activate avoids terminating and relaunching the target app
|
|
224
226
|
target.activate()
|
|
225
227
|
currentApp = target
|
|
226
228
|
currentBundleId = bundleId
|
|
229
|
+
} else if requestedBundleId == nil {
|
|
230
|
+
// Do not reuse stale bundle targets when the caller does not explicitly request one.
|
|
231
|
+
currentApp = nil
|
|
232
|
+
currentBundleId = nil
|
|
227
233
|
}
|
|
228
234
|
let activeApp = currentApp ?? app
|
|
229
235
|
_ = activeApp.waitForExistence(timeout: 5)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-device",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Callstack",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"prepack": "pnpm build:node && pnpm build:axsnapshot",
|
|
27
27
|
"typecheck": "tsc -p tsconfig.json",
|
|
28
28
|
"test": "node --test",
|
|
29
|
-
"test:unit": "node --test src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts",
|
|
29
|
+
"test:unit": "node --test src/__tests__/*.test.ts src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts",
|
|
30
30
|
"test:smoke": "node --test test/integration/smoke-*.test.ts",
|
|
31
31
|
"test:integration": "node --test test/integration/*.test.ts"
|
|
32
32
|
},
|
|
@@ -27,7 +27,7 @@ npx -y agent-device
|
|
|
27
27
|
|
|
28
28
|
## Core workflow
|
|
29
29
|
|
|
30
|
-
1. Open app or deep link: `open [app|url]` (`open` handles target selection + boot/activation in the normal flow)
|
|
30
|
+
1. Open app or deep link: `open [app|url] [url]` (`open` handles target selection + boot/activation in the normal flow)
|
|
31
31
|
2. Snapshot: `snapshot` to get refs from accessibility tree
|
|
32
32
|
3. Interact using refs (`click @ref`, `fill @ref "text"`)
|
|
33
33
|
4. Re-snapshot after navigation/UI changes
|
|
@@ -39,13 +39,14 @@ npx -y agent-device
|
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
41
|
agent-device boot # Ensure target is booted/ready without opening app
|
|
42
|
-
agent-device boot --platform ios # Boot iOS
|
|
42
|
+
agent-device boot --platform ios # Boot iOS target
|
|
43
43
|
agent-device boot --platform android # Boot Android emulator/device target
|
|
44
|
-
agent-device open [app|url]
|
|
44
|
+
agent-device open [app|url] [url] # Boot device/simulator; optionally launch app or deep link URL
|
|
45
45
|
agent-device open [app] --relaunch # Terminate app process first, then launch (fresh runtime)
|
|
46
46
|
agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
|
|
47
47
|
agent-device open "myapp://home" --platform android # Android deep link
|
|
48
|
-
agent-device open "https://example.com" --platform ios # iOS
|
|
48
|
+
agent-device open "https://example.com" --platform ios # iOS deep link (opens in browser)
|
|
49
|
+
agent-device open MyApp "myapp://screen/to" --platform ios # iOS deep link in app context
|
|
49
50
|
agent-device close [app] # Close app or just end session
|
|
50
51
|
agent-device reinstall <app> <path> # Uninstall + install app in one command
|
|
51
52
|
agent-device session list # List active sessions
|
|
@@ -101,10 +102,12 @@ iOS settings helpers are simulator-only.
|
|
|
101
102
|
|
|
102
103
|
```bash
|
|
103
104
|
agent-device appstate
|
|
104
|
-
agent-device apps --metadata --platform ios
|
|
105
|
-
agent-device apps --metadata --platform android
|
|
106
105
|
```
|
|
107
106
|
|
|
107
|
+
- Android: `appstate` reports live foreground package/activity.
|
|
108
|
+
- iOS: `appstate` is session-scoped and reports the app tracked by the active session on the target device.
|
|
109
|
+
- For iOS `appstate`, ensure a matching session exists (for example `open --session <name> --platform ios --device "<name>" <app>`).
|
|
110
|
+
|
|
108
111
|
### Interactions (use @refs from snapshot)
|
|
109
112
|
|
|
110
113
|
```bash
|
|
@@ -143,13 +146,16 @@ agent-device screenshot out.png
|
|
|
143
146
|
|
|
144
147
|
```bash
|
|
145
148
|
agent-device open App --relaunch # Fresh app process restart in the current session
|
|
146
|
-
agent-device open App --save-script # Save session script (.ad) on close
|
|
149
|
+
agent-device open App --save-script # Save session script (.ad) on close (default path)
|
|
150
|
+
agent-device open App --save-script ./workflows/app-flow.ad # Save to custom file path
|
|
147
151
|
agent-device replay ./session.ad # Run deterministic replay from .ad script
|
|
148
152
|
agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place
|
|
149
153
|
```
|
|
150
154
|
|
|
151
155
|
`replay` reads `.ad` recordings.
|
|
152
156
|
`--relaunch` controls launch semantics; `--save-script` controls recording. Combine only when both are needed.
|
|
157
|
+
`--save-script` path is a file path; parent directories are created automatically.
|
|
158
|
+
For ambiguous bare values, use `--save-script=workflow.ad` or `./workflow.ad`.
|
|
153
159
|
|
|
154
160
|
### Trace logs (AX/XCTest)
|
|
155
161
|
|
|
@@ -164,9 +170,11 @@ agent-device trace stop ./trace.log # Stop and move trace log
|
|
|
164
170
|
|
|
165
171
|
```bash
|
|
166
172
|
agent-device devices
|
|
167
|
-
agent-device apps --platform ios
|
|
168
|
-
agent-device apps --platform
|
|
169
|
-
agent-device apps --platform
|
|
173
|
+
agent-device apps --platform ios # iOS simulator + iOS device, includes default/system apps
|
|
174
|
+
agent-device apps --platform ios --all # explicit include-all (same as default)
|
|
175
|
+
agent-device apps --platform ios --user-installed
|
|
176
|
+
agent-device apps --platform android # includes default/system apps
|
|
177
|
+
agent-device apps --platform android --all # explicit include-all (same as default)
|
|
170
178
|
agent-device apps --platform android --user-installed
|
|
171
179
|
```
|
|
172
180
|
|
|
@@ -181,15 +189,16 @@ agent-device apps --platform android --user-installed
|
|
|
181
189
|
- Prefer `snapshot -i` to reduce output size.
|
|
182
190
|
- On iOS, `xctest` is the default and does not require Accessibility permission.
|
|
183
191
|
- If XCTest returns 0 nodes (foreground app changed), treat it as an explicit failure and retry the flow/app state.
|
|
184
|
-
- `open <app|url
|
|
185
|
-
- `open <app>` updates session app bundle context;
|
|
192
|
+
- `open <app|url> [url]` can be used within an existing session to switch apps or open deep links.
|
|
193
|
+
- `open <app>` updates session app bundle context; `open <app> <url>` opens a deep link on iOS.
|
|
186
194
|
- Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
|
|
187
195
|
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
|
|
188
196
|
- Use `--session <name>` for parallel sessions; avoid device contention.
|
|
189
197
|
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
|
|
190
|
-
- iOS
|
|
198
|
+
- On iOS devices, `http(s)://` URLs fall back to Safari automatically; custom scheme URLs require an active app in the session.
|
|
191
199
|
- iOS physical-device runner requires Xcode signing/provisioning; optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.
|
|
192
|
-
- For
|
|
200
|
+
- Default daemon request timeout is `45000`ms. For slow physical-device setup/build, increase `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `120000`).
|
|
201
|
+
- For daemon startup troubleshooting, follow stale metadata hints for `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock`.
|
|
193
202
|
- Use `fill` when you want clear-then-type semantics.
|
|
194
203
|
- Use `type` when you want to append/enter text without clearing.
|
|
195
204
|
- On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.
|
|
@@ -24,9 +24,14 @@ Use Automatic Signing in Xcode, or provide optional overrides:
|
|
|
24
24
|
- `AGENT_DEVICE_IOS_SIGNING_IDENTITY`
|
|
25
25
|
- `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`
|
|
26
26
|
|
|
27
|
-
If
|
|
27
|
+
If setup/build takes long, increase:
|
|
28
28
|
|
|
29
|
-
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `
|
|
29
|
+
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (default `45000`, for example `120000`)
|
|
30
|
+
|
|
31
|
+
If daemon startup fails with stale metadata hints, clean stale files and retry:
|
|
32
|
+
|
|
33
|
+
- `~/.agent-device/daemon.json`
|
|
34
|
+
- `~/.agent-device/daemon.lock`
|
|
30
35
|
|
|
31
36
|
## Simulator troubleshooting
|
|
32
37
|
|
|
@@ -14,8 +14,12 @@ Sessions isolate device context. A device can only be held by one session at a t
|
|
|
14
14
|
- Name sessions semantically.
|
|
15
15
|
- Close sessions when done.
|
|
16
16
|
- Use separate sessions for parallel work.
|
|
17
|
-
- In iOS sessions, use `open <app
|
|
17
|
+
- In iOS sessions, use `open <app>`. `open <url>` opens deep links; on devices `http(s)://` opens Safari when no app is active, and custom schemes require an active app in the session.
|
|
18
|
+
- In iOS sessions, `open <app> <url>` opens a deep link.
|
|
19
|
+
- On iOS, `appstate` is session-scoped and requires a matching active session on the target device.
|
|
18
20
|
- For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
|
|
21
|
+
- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
|
|
22
|
+
- For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`.
|
|
19
23
|
- For deterministic replay scripts, prefer selector-based actions and assertions.
|
|
20
24
|
- Use `replay -u` to update selector drift during maintenance.
|
|
21
25
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { runCli } from '../cli.ts';
|
|
4
|
+
import { AppError } from '../utils/errors.ts';
|
|
5
|
+
import type { DaemonResponse } from '../daemon-client.ts';
|
|
6
|
+
|
|
7
|
+
class ExitSignal extends Error {
|
|
8
|
+
public readonly code: number;
|
|
9
|
+
|
|
10
|
+
constructor(code: number) {
|
|
11
|
+
super(`EXIT_${code}`);
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type RunResult = {
|
|
17
|
+
code: number | null;
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
daemonCalls: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async function runCliCapture(argv: string[]): Promise<RunResult> {
|
|
24
|
+
let daemonCalls = 0;
|
|
25
|
+
let stdout = '';
|
|
26
|
+
let stderr = '';
|
|
27
|
+
let code: number | null = null;
|
|
28
|
+
|
|
29
|
+
const originalExit = process.exit;
|
|
30
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
31
|
+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
32
|
+
|
|
33
|
+
(process as any).exit = ((nextCode?: number) => {
|
|
34
|
+
throw new ExitSignal(nextCode ?? 0);
|
|
35
|
+
}) as typeof process.exit;
|
|
36
|
+
(process.stdout as any).write = ((chunk: unknown) => {
|
|
37
|
+
stdout += String(chunk);
|
|
38
|
+
return true;
|
|
39
|
+
}) as typeof process.stdout.write;
|
|
40
|
+
(process.stderr as any).write = ((chunk: unknown) => {
|
|
41
|
+
stderr += String(chunk);
|
|
42
|
+
return true;
|
|
43
|
+
}) as typeof process.stderr.write;
|
|
44
|
+
|
|
45
|
+
const sendToDaemon = async (): Promise<DaemonResponse> => {
|
|
46
|
+
daemonCalls += 1;
|
|
47
|
+
throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
|
|
48
|
+
infoPath: '/tmp/daemon.json',
|
|
49
|
+
hint: 'stale daemon info',
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await runCli(argv, { sendToDaemon });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof ExitSignal) code = error.code;
|
|
57
|
+
else throw error;
|
|
58
|
+
} finally {
|
|
59
|
+
process.exit = originalExit;
|
|
60
|
+
process.stdout.write = originalStdoutWrite;
|
|
61
|
+
process.stderr.write = originalStderrWrite;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { code, stdout, stderr, daemonCalls };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function runCliCaptureWithErrorDetails(
|
|
68
|
+
argv: string[],
|
|
69
|
+
details: Record<string, unknown>,
|
|
70
|
+
message = 'Failed to start daemon',
|
|
71
|
+
): Promise<RunResult> {
|
|
72
|
+
let daemonCalls = 0;
|
|
73
|
+
let stdout = '';
|
|
74
|
+
let stderr = '';
|
|
75
|
+
let code: number | null = null;
|
|
76
|
+
|
|
77
|
+
const originalExit = process.exit;
|
|
78
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
79
|
+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
80
|
+
|
|
81
|
+
(process as any).exit = ((nextCode?: number) => {
|
|
82
|
+
throw new ExitSignal(nextCode ?? 0);
|
|
83
|
+
}) as typeof process.exit;
|
|
84
|
+
(process.stdout as any).write = ((chunk: unknown) => {
|
|
85
|
+
stdout += String(chunk);
|
|
86
|
+
return true;
|
|
87
|
+
}) as typeof process.stdout.write;
|
|
88
|
+
(process.stderr as any).write = ((chunk: unknown) => {
|
|
89
|
+
stderr += String(chunk);
|
|
90
|
+
return true;
|
|
91
|
+
}) as typeof process.stderr.write;
|
|
92
|
+
|
|
93
|
+
const sendToDaemon = async (): Promise<DaemonResponse> => {
|
|
94
|
+
daemonCalls += 1;
|
|
95
|
+
throw new AppError('COMMAND_FAILED', message, details);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await runCli(argv, { sendToDaemon });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof ExitSignal) code = error.code;
|
|
102
|
+
else throw error;
|
|
103
|
+
} finally {
|
|
104
|
+
process.exit = originalExit;
|
|
105
|
+
process.stdout.write = originalStdoutWrite;
|
|
106
|
+
process.stderr.write = originalStderrWrite;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { code, stdout, stderr, daemonCalls };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('close treats daemon startup failure as no-op', async () => {
|
|
113
|
+
const result = await runCliCapture(['close']);
|
|
114
|
+
assert.equal(result.code, null);
|
|
115
|
+
assert.equal(result.daemonCalls, 1);
|
|
116
|
+
assert.equal(result.stdout, '');
|
|
117
|
+
assert.equal(result.stderr, '');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('close --json treats daemon startup failure as no-op success', async () => {
|
|
121
|
+
const result = await runCliCapture(['close', '--json']);
|
|
122
|
+
assert.equal(result.code, null);
|
|
123
|
+
assert.equal(result.daemonCalls, 1);
|
|
124
|
+
const payload = JSON.parse(result.stdout);
|
|
125
|
+
assert.equal(payload.success, true);
|
|
126
|
+
assert.equal(payload.data.closed, 'session');
|
|
127
|
+
assert.equal(payload.data.source, 'no-daemon');
|
|
128
|
+
assert.equal(result.stderr, '');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('close treats lock-only daemon startup failure as no-op', async () => {
|
|
132
|
+
const result = await runCliCaptureWithErrorDetails(['close'], {
|
|
133
|
+
lockPath: '/tmp/daemon.lock',
|
|
134
|
+
hint: 'stale daemon lock',
|
|
135
|
+
});
|
|
136
|
+
assert.equal(result.code, null);
|
|
137
|
+
assert.equal(result.daemonCalls, 1);
|
|
138
|
+
assert.equal(result.stdout, '');
|
|
139
|
+
assert.equal(result.stderr, '');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('close treats structured daemon startup failure as no-op without relying on message text', async () => {
|
|
143
|
+
const result = await runCliCaptureWithErrorDetails(
|
|
144
|
+
['close'],
|
|
145
|
+
{
|
|
146
|
+
kind: 'daemon_startup_failed',
|
|
147
|
+
lockPath: '/tmp/daemon.lock',
|
|
148
|
+
},
|
|
149
|
+
'daemon bootstrap failed',
|
|
150
|
+
);
|
|
151
|
+
assert.equal(result.code, null);
|
|
152
|
+
assert.equal(result.daemonCalls, 1);
|
|
153
|
+
assert.equal(result.stdout, '');
|
|
154
|
+
assert.equal(result.stderr, '');
|
|
155
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { runCli } from '../cli.ts';
|
|
4
|
+
import type { DaemonResponse } from '../daemon-client.ts';
|
|
5
|
+
|
|
6
|
+
class ExitSignal extends Error {
|
|
7
|
+
public readonly code: number;
|
|
8
|
+
|
|
9
|
+
constructor(code: number) {
|
|
10
|
+
super(`EXIT_${code}`);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type RunResult = {
|
|
16
|
+
code: number | null;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
daemonCalls: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function runCliCapture(argv: string[]): Promise<RunResult> {
|
|
23
|
+
let daemonCalls = 0;
|
|
24
|
+
let stdout = '';
|
|
25
|
+
let stderr = '';
|
|
26
|
+
let code: number | null = null;
|
|
27
|
+
|
|
28
|
+
const originalExit = process.exit;
|
|
29
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
30
|
+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
31
|
+
|
|
32
|
+
(process as any).exit = ((nextCode?: number) => {
|
|
33
|
+
throw new ExitSignal(nextCode ?? 0);
|
|
34
|
+
}) as typeof process.exit;
|
|
35
|
+
(process.stdout as any).write = ((chunk: unknown) => {
|
|
36
|
+
stdout += String(chunk);
|
|
37
|
+
return true;
|
|
38
|
+
}) as typeof process.stdout.write;
|
|
39
|
+
(process.stderr as any).write = ((chunk: unknown) => {
|
|
40
|
+
stderr += String(chunk);
|
|
41
|
+
return true;
|
|
42
|
+
}) as typeof process.stderr.write;
|
|
43
|
+
|
|
44
|
+
const sendToDaemon = async (): Promise<DaemonResponse> => {
|
|
45
|
+
daemonCalls += 1;
|
|
46
|
+
return { ok: true, data: {} };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await runCli(argv, { sendToDaemon });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof ExitSignal) code = error.code;
|
|
53
|
+
else throw error;
|
|
54
|
+
} finally {
|
|
55
|
+
process.exit = originalExit;
|
|
56
|
+
process.stdout.write = originalStdoutWrite;
|
|
57
|
+
process.stderr.write = originalStderrWrite;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { code, stdout, stderr, daemonCalls };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('help appstate prints command help and skips daemon dispatch', async () => {
|
|
64
|
+
const result = await runCliCapture(['help', 'appstate']);
|
|
65
|
+
assert.equal(result.code, 0);
|
|
66
|
+
assert.equal(result.daemonCalls, 0);
|
|
67
|
+
assert.match(result.stdout, /Show foreground app\/activity/);
|
|
68
|
+
assert.doesNotMatch(result.stdout, /Command flags:/);
|
|
69
|
+
assert.match(result.stdout, /Global flags:/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('appstate --help prints command help and skips daemon dispatch', async () => {
|
|
73
|
+
const result = await runCliCapture(['appstate', '--help']);
|
|
74
|
+
assert.equal(result.code, 0);
|
|
75
|
+
assert.equal(result.daemonCalls, 0);
|
|
76
|
+
assert.match(result.stdout, /Usage:\n agent-device appstate/);
|
|
77
|
+
assert.match(result.stdout, /Global flags:/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('help unknown command prints error plus global usage and skips daemon dispatch', async () => {
|
|
81
|
+
const result = await runCliCapture(['help', 'not-a-command']);
|
|
82
|
+
assert.equal(result.code, 1);
|
|
83
|
+
assert.equal(result.daemonCalls, 0);
|
|
84
|
+
assert.match(result.stderr, /Error \(INVALID_ARGS\): Unknown command: not-a-command/);
|
|
85
|
+
assert.match(result.stdout, /Commands:/);
|
|
86
|
+
assert.match(result.stdout, /Flags:/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('unknown command --help prints error plus global usage and skips daemon dispatch', async () => {
|
|
90
|
+
const result = await runCliCapture(['not-a-command', '--help']);
|
|
91
|
+
assert.equal(result.code, 1);
|
|
92
|
+
assert.equal(result.daemonCalls, 0);
|
|
93
|
+
assert.match(result.stderr, /Error \(INVALID_ARGS\): Unknown command: not-a-command/);
|
|
94
|
+
assert.match(result.stdout, /Commands:/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('help rejects multiple positional commands and skips daemon dispatch', async () => {
|
|
98
|
+
const result = await runCliCapture(['help', 'appstate', 'extra']);
|
|
99
|
+
assert.equal(result.code, 1);
|
|
100
|
+
assert.equal(result.daemonCalls, 0);
|
|
101
|
+
assert.match(result.stderr, /Error \(INVALID_ARGS\): help accepts at most one command/);
|
|
102
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseArgs, toDaemonFlags, usage } from './utils/args.ts';
|
|
1
|
+
import { parseArgs, toDaemonFlags, usage, usageForCommand } from './utils/args.ts';
|
|
2
2
|
import { asAppError, AppError } from './utils/errors.ts';
|
|
3
3
|
import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
|
|
4
4
|
import { readVersion } from './utils/version.ts';
|
|
@@ -8,7 +8,15 @@ import fs from 'node:fs';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
type CliDeps = {
|
|
12
|
+
sendToDaemon: typeof sendToDaemon;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CLI_DEPS: CliDeps = {
|
|
16
|
+
sendToDaemon,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise<void> {
|
|
12
20
|
const parsed = parseArgs(argv);
|
|
13
21
|
for (const warning of parsed.warnings) {
|
|
14
22
|
process.stderr.write(`Warning: ${warning}\n`);
|
|
@@ -19,9 +27,31 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
19
27
|
process.exit(0);
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
const isHelpAlias = parsed.command === 'help';
|
|
31
|
+
const isHelpFlag = parsed.flags.help;
|
|
32
|
+
if (isHelpAlias || isHelpFlag) {
|
|
33
|
+
if (isHelpAlias && parsed.positionals.length > 1) {
|
|
34
|
+
printHumanError(new AppError('INVALID_ARGS', 'help accepts at most one command.'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const helpTarget = isHelpAlias ? parsed.positionals[0] : parsed.command;
|
|
38
|
+
if (!helpTarget) {
|
|
39
|
+
process.stdout.write(`${usage()}\n`);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
const commandHelp = usageForCommand(helpTarget);
|
|
43
|
+
if (commandHelp) {
|
|
44
|
+
process.stdout.write(commandHelp);
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
printHumanError(new AppError('INVALID_ARGS', `Unknown command: ${helpTarget}`));
|
|
48
|
+
process.stdout.write(`${usage()}\n`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!parsed.command) {
|
|
23
53
|
process.stdout.write(`${usage()}\n`);
|
|
24
|
-
process.exit(
|
|
54
|
+
process.exit(1);
|
|
25
55
|
}
|
|
26
56
|
|
|
27
57
|
const { command, positionals, flags } = parsed;
|
|
@@ -34,7 +64,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
34
64
|
if (sub !== 'list') {
|
|
35
65
|
throw new AppError('INVALID_ARGS', 'session only supports list');
|
|
36
66
|
}
|
|
37
|
-
const response = await sendToDaemon({
|
|
67
|
+
const response = await deps.sendToDaemon({
|
|
38
68
|
session: sessionName,
|
|
39
69
|
command: 'session_list',
|
|
40
70
|
positionals: [],
|
|
@@ -47,7 +77,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
47
77
|
return;
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
const response = await sendToDaemon({
|
|
80
|
+
const response = await deps.sendToDaemon({
|
|
51
81
|
session: sessionName,
|
|
52
82
|
command: command!,
|
|
53
83
|
positionals,
|
|
@@ -149,9 +179,6 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
149
179
|
const bundleId = app.bundleId ?? app.package;
|
|
150
180
|
const name = app.name ?? app.label;
|
|
151
181
|
if (name && bundleId) return `${name} (${bundleId})`;
|
|
152
|
-
if (bundleId && typeof app.launchable === 'boolean') {
|
|
153
|
-
return `${bundleId} (launchable=${app.launchable})`;
|
|
154
|
-
}
|
|
155
182
|
if (bundleId) return String(bundleId);
|
|
156
183
|
return JSON.stringify(app);
|
|
157
184
|
}
|
|
@@ -169,7 +196,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
169
196
|
const pkg = (data as any)?.package;
|
|
170
197
|
const activity = (data as any)?.activity;
|
|
171
198
|
if (platform === 'ios') {
|
|
172
|
-
process.stdout.write(`Foreground app: ${appName ?? appBundleId}\n`);
|
|
199
|
+
process.stdout.write(`Foreground app: ${appName ?? appBundleId ?? 'unknown'}\n`);
|
|
173
200
|
if (appBundleId) process.stdout.write(`Bundle: ${appBundleId}\n`);
|
|
174
201
|
if (source) process.stdout.write(`Source: ${source}\n`);
|
|
175
202
|
if (logTailStopper) logTailStopper();
|
|
@@ -190,6 +217,13 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
190
217
|
throw new AppError(response.error.code as any, response.error.message, response.error.details);
|
|
191
218
|
} catch (err) {
|
|
192
219
|
const appErr = asAppError(err);
|
|
220
|
+
if (command === 'close' && isDaemonStartupFailure(appErr)) {
|
|
221
|
+
if (flags.json) {
|
|
222
|
+
printJson({ success: true, data: { closed: 'session', source: 'no-daemon' } });
|
|
223
|
+
}
|
|
224
|
+
if (logTailStopper) logTailStopper();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
193
227
|
if (flags.json) {
|
|
194
228
|
printJson({
|
|
195
229
|
success: false,
|
|
@@ -199,9 +233,6 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
199
233
|
printHumanError(appErr);
|
|
200
234
|
if (flags.verbose) {
|
|
201
235
|
try {
|
|
202
|
-
const fs = await import('node:fs');
|
|
203
|
-
const os = await import('node:os');
|
|
204
|
-
const path = await import('node:path');
|
|
205
236
|
const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log');
|
|
206
237
|
if (fs.existsSync(logPath)) {
|
|
207
238
|
const content = fs.readFileSync(logPath, 'utf8');
|
|
@@ -221,6 +252,13 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
221
252
|
}
|
|
222
253
|
}
|
|
223
254
|
|
|
255
|
+
function isDaemonStartupFailure(error: AppError): boolean {
|
|
256
|
+
if (error.code !== 'COMMAND_FAILED') return false;
|
|
257
|
+
if (error.details?.kind === 'daemon_startup_failed') return true;
|
|
258
|
+
if (!error.message.toLowerCase().includes('failed to start daemon')) return false;
|
|
259
|
+
return typeof error.details?.infoPath === 'string' || typeof error.details?.lockPath === 'string';
|
|
260
|
+
}
|
|
261
|
+
|
|
224
262
|
const isDirectRun = pathToFileURL(process.argv[1] ?? '').href === import.meta.url;
|
|
225
263
|
if (isDirectRun) {
|
|
226
264
|
runCli(process.argv.slice(2)).catch((err) => {
|
|
@@ -238,15 +276,23 @@ function startDaemonLogTail(): (() => void) | null {
|
|
|
238
276
|
const interval = setInterval(() => {
|
|
239
277
|
if (stopped) return;
|
|
240
278
|
if (!fs.existsSync(logPath)) return;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
279
|
+
try {
|
|
280
|
+
const stats = fs.statSync(logPath);
|
|
281
|
+
if (stats.size < offset) offset = 0;
|
|
282
|
+
if (stats.size <= offset) return;
|
|
283
|
+
const fd = fs.openSync(logPath, 'r');
|
|
284
|
+
try {
|
|
285
|
+
const buffer = Buffer.alloc(stats.size - offset);
|
|
286
|
+
fs.readSync(fd, buffer, 0, buffer.length, offset);
|
|
287
|
+
offset = stats.size;
|
|
288
|
+
if (buffer.length > 0) {
|
|
289
|
+
process.stdout.write(buffer.toString('utf8'));
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
fs.closeSync(fd);
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// Best-effort tailing should not crash CLI flow.
|
|
250
296
|
}
|
|
251
297
|
}, 200);
|
|
252
298
|
return () => {
|
|
@@ -33,7 +33,7 @@ test('iOS simulator-only commands reject iOS devices and Android', () => {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
test('simulator-only iOS commands with Android support reject iOS devices', () => {
|
|
36
|
-
for (const cmd of ['
|
|
36
|
+
for (const cmd of ['reinstall', 'record', 'settings', 'swipe']) {
|
|
37
37
|
assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
|
|
38
38
|
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
|
|
39
39
|
assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
|
|
@@ -43,6 +43,7 @@ test('simulator-only iOS commands with Android support reject iOS devices', () =
|
|
|
43
43
|
test('core commands support iOS simulator, iOS device, and Android', () => {
|
|
44
44
|
for (const cmd of [
|
|
45
45
|
'app-switcher',
|
|
46
|
+
'apps',
|
|
46
47
|
'back',
|
|
47
48
|
'boot',
|
|
48
49
|
'click',
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { dispatchCommand } from '../dispatch.ts';
|
|
4
|
+
import { AppError } from '../../utils/errors.ts';
|
|
5
|
+
import type { DeviceInfo } from '../../utils/device.ts';
|
|
6
|
+
|
|
7
|
+
test('dispatch open rejects URL as first argument when second URL is provided', async () => {
|
|
8
|
+
const device: DeviceInfo = {
|
|
9
|
+
platform: 'ios',
|
|
10
|
+
id: 'sim-1',
|
|
11
|
+
name: 'iPhone 15',
|
|
12
|
+
kind: 'simulator',
|
|
13
|
+
booted: true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
await assert.rejects(
|
|
17
|
+
() => dispatchCommand(device, 'open', ['myapp://first', 'myapp://second']),
|
|
18
|
+
(error: unknown) => {
|
|
19
|
+
assert.equal(error instanceof AppError, true);
|
|
20
|
+
assert.equal((error as AppError).code, 'INVALID_ARGS');
|
|
21
|
+
assert.match((error as AppError).message, /requires an app target as the first argument/i);
|
|
22
|
+
return true;
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
});
|