agent-device 0.4.1 → 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 +18 -12
- package/dist/src/bin.js +32 -32
- package/dist/src/daemon.js +18 -14
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +19 -13
- package/skills/agent-device/references/permissions.md +7 -2
- package/skills/agent-device/references/session-management.md +3 -1
- package/src/__tests__/cli-close.test.ts +155 -0
- package/src/cli.ts +32 -16
- 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/device-ready.ts +146 -4
- package/src/daemon/handlers/__tests__/session.test.ts +477 -0
- package/src/daemon/handlers/session.ts +196 -91
- package/src/daemon/session-store.ts +0 -2
- package/src/daemon-client.ts +118 -18
- 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 +72 -16
- package/src/platforms/ios/simulator.ts +164 -0
- package/src/utils/__tests__/args.test.ts +20 -2
- package/src/utils/__tests__/daemon-client.test.ts +21 -4
- package/src/utils/args.ts +6 -1
- package/src/utils/command-schema.ts +7 -14
- package/src/utils/interactors.ts +2 -2
- package/src/utils/timeouts.ts +9 -0
- 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
|
@@ -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
|
|
@@ -167,9 +170,11 @@ agent-device trace stop ./trace.log # Stop and move trace log
|
|
|
167
170
|
|
|
168
171
|
```bash
|
|
169
172
|
agent-device devices
|
|
170
|
-
agent-device apps --platform ios
|
|
171
|
-
agent-device apps --platform
|
|
172
|
-
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)
|
|
173
178
|
agent-device apps --platform android --user-installed
|
|
174
179
|
```
|
|
175
180
|
|
|
@@ -184,15 +189,16 @@ agent-device apps --platform android --user-installed
|
|
|
184
189
|
- Prefer `snapshot -i` to reduce output size.
|
|
185
190
|
- On iOS, `xctest` is the default and does not require Accessibility permission.
|
|
186
191
|
- If XCTest returns 0 nodes (foreground app changed), treat it as an explicit failure and retry the flow/app state.
|
|
187
|
-
- `open <app|url
|
|
188
|
-
- `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.
|
|
189
194
|
- Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
|
|
190
195
|
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
|
|
191
196
|
- Use `--session <name>` for parallel sessions; avoid device contention.
|
|
192
197
|
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
|
|
193
|
-
- iOS
|
|
198
|
+
- On iOS devices, `http(s)://` URLs fall back to Safari automatically; custom scheme URLs require an active app in the session.
|
|
194
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`.
|
|
195
|
-
- 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`.
|
|
196
202
|
- Use `fill` when you want clear-then-type semantics.
|
|
197
203
|
- Use `type` when you want to append/enter text without clearing.
|
|
198
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,7 +14,9 @@ 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.
|
|
19
21
|
- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
|
|
20
22
|
- For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`.
|
|
@@ -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
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -179,9 +179,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
|
|
|
179
179
|
const bundleId = app.bundleId ?? app.package;
|
|
180
180
|
const name = app.name ?? app.label;
|
|
181
181
|
if (name && bundleId) return `${name} (${bundleId})`;
|
|
182
|
-
if (bundleId && typeof app.launchable === 'boolean') {
|
|
183
|
-
return `${bundleId} (launchable=${app.launchable})`;
|
|
184
|
-
}
|
|
185
182
|
if (bundleId) return String(bundleId);
|
|
186
183
|
return JSON.stringify(app);
|
|
187
184
|
}
|
|
@@ -199,7 +196,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
|
|
|
199
196
|
const pkg = (data as any)?.package;
|
|
200
197
|
const activity = (data as any)?.activity;
|
|
201
198
|
if (platform === 'ios') {
|
|
202
|
-
process.stdout.write(`Foreground app: ${appName ?? appBundleId}\n`);
|
|
199
|
+
process.stdout.write(`Foreground app: ${appName ?? appBundleId ?? 'unknown'}\n`);
|
|
203
200
|
if (appBundleId) process.stdout.write(`Bundle: ${appBundleId}\n`);
|
|
204
201
|
if (source) process.stdout.write(`Source: ${source}\n`);
|
|
205
202
|
if (logTailStopper) logTailStopper();
|
|
@@ -220,6 +217,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
|
|
|
220
217
|
throw new AppError(response.error.code as any, response.error.message, response.error.details);
|
|
221
218
|
} catch (err) {
|
|
222
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
|
+
}
|
|
223
227
|
if (flags.json) {
|
|
224
228
|
printJson({
|
|
225
229
|
success: false,
|
|
@@ -229,9 +233,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
|
|
|
229
233
|
printHumanError(appErr);
|
|
230
234
|
if (flags.verbose) {
|
|
231
235
|
try {
|
|
232
|
-
const fs = await import('node:fs');
|
|
233
|
-
const os = await import('node:os');
|
|
234
|
-
const path = await import('node:path');
|
|
235
236
|
const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log');
|
|
236
237
|
if (fs.existsSync(logPath)) {
|
|
237
238
|
const content = fs.readFileSync(logPath, 'utf8');
|
|
@@ -251,6 +252,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
|
|
|
251
252
|
}
|
|
252
253
|
}
|
|
253
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
|
+
|
|
254
262
|
const isDirectRun = pathToFileURL(process.argv[1] ?? '').href === import.meta.url;
|
|
255
263
|
if (isDirectRun) {
|
|
256
264
|
runCli(process.argv.slice(2)).catch((err) => {
|
|
@@ -268,15 +276,23 @@ function startDaemonLogTail(): (() => void) | null {
|
|
|
268
276
|
const interval = setInterval(() => {
|
|
269
277
|
if (stopped) return;
|
|
270
278
|
if (!fs.existsSync(logPath)) return;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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.
|
|
280
296
|
}
|
|
281
297
|
}, 200);
|
|
282
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
|
+
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
IOS_SAFARI_BUNDLE_ID,
|
|
5
|
+
isDeepLinkTarget,
|
|
6
|
+
isWebUrl,
|
|
7
|
+
resolveIosDeviceDeepLinkBundleId,
|
|
8
|
+
} from '../open-target.ts';
|
|
4
9
|
|
|
5
10
|
test('isDeepLinkTarget accepts URL-style deep links', () => {
|
|
6
11
|
assert.equal(isDeepLinkTarget('myapp://home'), true);
|
|
@@ -14,3 +19,37 @@ test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
|
|
|
14
19
|
assert.equal(isDeepLinkTarget('settings'), false);
|
|
15
20
|
assert.equal(isDeepLinkTarget('http:/x'), false);
|
|
16
21
|
});
|
|
22
|
+
|
|
23
|
+
test('isWebUrl accepts http and https URLs', () => {
|
|
24
|
+
assert.equal(isWebUrl('https://example.com'), true);
|
|
25
|
+
assert.equal(isWebUrl('http://example.com/path'), true);
|
|
26
|
+
assert.equal(isWebUrl('https://example.com/path?q=1'), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('isWebUrl rejects custom schemes and non-URLs', () => {
|
|
30
|
+
assert.equal(isWebUrl('myapp://home'), false);
|
|
31
|
+
assert.equal(isWebUrl('tel:123456789'), false);
|
|
32
|
+
assert.equal(isWebUrl('com.example.app'), false);
|
|
33
|
+
assert.equal(isWebUrl('settings'), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('resolveIosDeviceDeepLinkBundleId prefers active app context', () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
resolveIosDeviceDeepLinkBundleId('com.example.app', 'myapp://home'),
|
|
39
|
+
'com.example.app',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('resolveIosDeviceDeepLinkBundleId falls back to Safari for web URLs', () => {
|
|
44
|
+
assert.equal(
|
|
45
|
+
resolveIosDeviceDeepLinkBundleId(undefined, 'https://example.com/path'),
|
|
46
|
+
IOS_SAFARI_BUNDLE_ID,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('resolveIosDeviceDeepLinkBundleId returns undefined for custom scheme without app context', () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
resolveIosDeviceDeepLinkBundleId(undefined, 'myapp://home'),
|
|
53
|
+
undefined,
|
|
54
|
+
);
|
|
55
|
+
});
|
package/src/core/capabilities.ts
CHANGED
|
@@ -17,7 +17,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
|
|
|
17
17
|
alert: { ios: { simulator: true }, android: {} },
|
|
18
18
|
pinch: { ios: { simulator: true }, android: {} },
|
|
19
19
|
'app-switcher': { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
|
20
|
-
apps: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
20
|
+
apps: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
|
21
21
|
back: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
|
22
22
|
boot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
|
23
23
|
click: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
package/src/core/dispatch.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
|
|
|
16
16
|
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
|
|
17
17
|
import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
|
|
18
18
|
import { setIosSetting } from '../platforms/ios/index.ts';
|
|
19
|
+
import { isDeepLinkTarget } from './open-target.ts';
|
|
19
20
|
import type { RawSnapshotNode } from '../utils/snapshot.ts';
|
|
20
21
|
import type { CliFlags } from '../utils/command-schema.ts';
|
|
21
22
|
|
|
@@ -89,10 +90,31 @@ export async function dispatchCommand(
|
|
|
89
90
|
switch (command) {
|
|
90
91
|
case 'open': {
|
|
91
92
|
const app = positionals[0];
|
|
93
|
+
const url = positionals[1];
|
|
94
|
+
if (positionals.length > 2) {
|
|
95
|
+
throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: <app|url> [url]');
|
|
96
|
+
}
|
|
92
97
|
if (!app) {
|
|
93
98
|
await interactor.openDevice();
|
|
94
99
|
return { app: null };
|
|
95
100
|
}
|
|
101
|
+
if (url !== undefined) {
|
|
102
|
+
if (device.platform !== 'ios') {
|
|
103
|
+
throw new AppError('INVALID_ARGS', 'open <app> <url> is supported only on iOS');
|
|
104
|
+
}
|
|
105
|
+
if (isDeepLinkTarget(app)) {
|
|
106
|
+
throw new AppError('INVALID_ARGS', 'open <app> <url> requires an app target as the first argument');
|
|
107
|
+
}
|
|
108
|
+
if (!isDeepLinkTarget(url)) {
|
|
109
|
+
throw new AppError('INVALID_ARGS', 'open <app> <url> requires a valid URL target');
|
|
110
|
+
}
|
|
111
|
+
await interactor.open(app, {
|
|
112
|
+
activity: context?.activity,
|
|
113
|
+
appBundleId: context?.appBundleId,
|
|
114
|
+
url,
|
|
115
|
+
});
|
|
116
|
+
return { app, url };
|
|
117
|
+
}
|
|
96
118
|
await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId });
|
|
97
119
|
return { app };
|
|
98
120
|
}
|
package/src/core/open-target.ts
CHANGED
|
@@ -11,3 +11,17 @@ export function isDeepLinkTarget(input: string): boolean {
|
|
|
11
11
|
}
|
|
12
12
|
return true;
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
export function isWebUrl(input: string): boolean {
|
|
16
|
+
const scheme = input.trim().split(':')[0]?.toLowerCase();
|
|
17
|
+
return scheme === 'http' || scheme === 'https';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const IOS_SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
|
|
21
|
+
|
|
22
|
+
export function resolveIosDeviceDeepLinkBundleId(appBundleId: string | undefined, url: string): string | undefined {
|
|
23
|
+
const bundleId = appBundleId?.trim();
|
|
24
|
+
if (bundleId) return bundleId;
|
|
25
|
+
if (isWebUrl(url)) return IOS_SAFARI_BUNDLE_ID;
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseIosReadyPayload, resolveIosReadyHint } from '../device-ready.ts';
|
|
4
|
+
|
|
5
|
+
test('parseIosReadyPayload reads tunnelState from direct connectionProperties', () => {
|
|
6
|
+
const parsed = parseIosReadyPayload({
|
|
7
|
+
result: {
|
|
8
|
+
connectionProperties: {
|
|
9
|
+
tunnelState: 'connected',
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
assert.equal(parsed.tunnelState, 'connected');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('parseIosReadyPayload reads tunnelState from nested device connectionProperties', () => {
|
|
17
|
+
const parsed = parseIosReadyPayload({
|
|
18
|
+
result: {
|
|
19
|
+
device: {
|
|
20
|
+
connectionProperties: {
|
|
21
|
+
tunnelState: 'connecting',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
assert.equal(parsed.tunnelState, 'connecting');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('parseIosReadyPayload returns empty payload for malformed input', () => {
|
|
30
|
+
assert.deepEqual(parseIosReadyPayload(null), {});
|
|
31
|
+
assert.deepEqual(parseIosReadyPayload({}), {});
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
parseIosReadyPayload({
|
|
34
|
+
result: { connectionProperties: { tunnelState: 123 } },
|
|
35
|
+
}),
|
|
36
|
+
{},
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('resolveIosReadyHint maps known connection errors', () => {
|
|
41
|
+
const connecting = resolveIosReadyHint('', 'Device is busy (Connecting to iPhone)');
|
|
42
|
+
assert.match(connecting, /still connecting/i);
|
|
43
|
+
|
|
44
|
+
const coreDeviceTimeout = resolveIosReadyHint('CoreDeviceService timed out', '');
|
|
45
|
+
assert.match(coreDeviceTimeout, /coredevice service/i);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('resolveIosReadyHint falls back to generic guidance', () => {
|
|
49
|
+
const hint = resolveIosReadyHint('unexpected failure', '');
|
|
50
|
+
assert.match(hint, /unlocked/i);
|
|
51
|
+
assert.match(hint, /xcode/i);
|
|
52
|
+
});
|