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
|
@@ -1,13 +1,155 @@
|
|
|
1
1
|
import type { DeviceInfo } from '../utils/device.ts';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import { runCmd } from '../utils/exec.ts';
|
|
6
|
+
import { AppError } from '../utils/errors.ts';
|
|
7
|
+
import { resolveTimeoutMs } from '../utils/timeouts.ts';
|
|
8
|
+
import { resolveIosDevicectlHint, IOS_DEVICECTL_DEFAULT_HINT } from '../platforms/ios/devicectl.ts';
|
|
9
|
+
|
|
10
|
+
const IOS_DEVICE_READY_TIMEOUT_MS = resolveTimeoutMs(
|
|
11
|
+
process.env.AGENT_DEVICE_IOS_DEVICE_READY_TIMEOUT_MS,
|
|
12
|
+
15_000,
|
|
13
|
+
1_000,
|
|
14
|
+
);
|
|
15
|
+
const IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS = 3_000;
|
|
2
16
|
|
|
3
17
|
export async function ensureDeviceReady(device: DeviceInfo): Promise<void> {
|
|
4
|
-
if (device.platform === 'ios'
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
18
|
+
if (device.platform === 'ios') {
|
|
19
|
+
if (device.kind === 'simulator') {
|
|
20
|
+
const { ensureBootedSimulator } = await import('../platforms/ios/index.ts');
|
|
21
|
+
await ensureBootedSimulator(device);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (device.kind === 'device') {
|
|
25
|
+
await ensureIosDeviceReady(device.id);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
8
28
|
}
|
|
9
29
|
if (device.platform === 'android') {
|
|
10
30
|
const { waitForAndroidBoot } = await import('../platforms/android/devices.ts');
|
|
11
31
|
await waitForAndroidBoot(device.id);
|
|
12
32
|
}
|
|
13
33
|
}
|
|
34
|
+
|
|
35
|
+
async function ensureIosDeviceReady(deviceId: string): Promise<void> {
|
|
36
|
+
const jsonPath = path.join(
|
|
37
|
+
os.tmpdir(),
|
|
38
|
+
`agent-device-ready-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
|
39
|
+
);
|
|
40
|
+
const timeoutSeconds = Math.max(1, Math.ceil(IOS_DEVICE_READY_TIMEOUT_MS / 1000));
|
|
41
|
+
try {
|
|
42
|
+
const result = await runCmd(
|
|
43
|
+
'xcrun',
|
|
44
|
+
[
|
|
45
|
+
'devicectl',
|
|
46
|
+
'device',
|
|
47
|
+
'info',
|
|
48
|
+
'details',
|
|
49
|
+
'--device',
|
|
50
|
+
deviceId,
|
|
51
|
+
'--json-output',
|
|
52
|
+
jsonPath,
|
|
53
|
+
'--timeout',
|
|
54
|
+
String(timeoutSeconds),
|
|
55
|
+
],
|
|
56
|
+
{
|
|
57
|
+
allowFailure: true,
|
|
58
|
+
timeoutMs: IOS_DEVICE_READY_TIMEOUT_MS + IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
const stdout = String(result.stdout ?? '');
|
|
62
|
+
const stderr = String(result.stderr ?? '');
|
|
63
|
+
const parsed = await readIosReadyPayload(jsonPath);
|
|
64
|
+
if (result.exitCode === 0) {
|
|
65
|
+
if (!parsed.parsed) {
|
|
66
|
+
throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', {
|
|
67
|
+
kind: 'probe_inconclusive',
|
|
68
|
+
deviceId,
|
|
69
|
+
stdout,
|
|
70
|
+
stderr,
|
|
71
|
+
hint: 'CoreDevice returned success but readiness JSON output was missing or invalid. Retry; if it persists restart Xcode and the iOS device.',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const tunnelState = parsed?.tunnelState?.toLowerCase();
|
|
75
|
+
if (tunnelState === 'connecting') {
|
|
76
|
+
throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', {
|
|
77
|
+
kind: 'not_ready',
|
|
78
|
+
deviceId,
|
|
79
|
+
tunnelState,
|
|
80
|
+
hint: 'Device tunnel is still connecting. Keep the device unlocked and connected by cable until it is fully available in Xcode Devices, then retry.',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', {
|
|
86
|
+
kind: 'not_ready',
|
|
87
|
+
deviceId,
|
|
88
|
+
stdout,
|
|
89
|
+
stderr,
|
|
90
|
+
exitCode: result.exitCode,
|
|
91
|
+
tunnelState: parsed?.tunnelState,
|
|
92
|
+
hint: resolveIosReadyHint(stdout, stderr),
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof AppError && error.code === 'COMMAND_FAILED') {
|
|
96
|
+
const kind = typeof error.details?.kind === 'string' ? error.details.kind : '';
|
|
97
|
+
if (kind === 'not_ready') {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
const details = (error.details ?? {}) as {
|
|
101
|
+
stdout?: string;
|
|
102
|
+
stderr?: string;
|
|
103
|
+
timeoutMs?: number;
|
|
104
|
+
};
|
|
105
|
+
const stdout = String(details.stdout ?? '');
|
|
106
|
+
const stderr = String(details.stderr ?? '');
|
|
107
|
+
const timeoutMs = Number(details.timeoutMs ?? IOS_DEVICE_READY_TIMEOUT_MS);
|
|
108
|
+
const timeoutHint = `CoreDevice did not respond within ${timeoutMs}ms. Keep the device unlocked and trusted, then retry; if it persists restart Xcode and the iOS device.`;
|
|
109
|
+
throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', {
|
|
110
|
+
deviceId,
|
|
111
|
+
cause: error.message,
|
|
112
|
+
timeoutMs,
|
|
113
|
+
stdout,
|
|
114
|
+
stderr,
|
|
115
|
+
hint: stdout || stderr ? resolveIosReadyHint(stdout, stderr) : timeoutHint,
|
|
116
|
+
}, error);
|
|
117
|
+
}
|
|
118
|
+
throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', {
|
|
119
|
+
deviceId,
|
|
120
|
+
hint: 'Reconnect the device, keep it unlocked, and retry.',
|
|
121
|
+
}, error instanceof Error ? error : undefined);
|
|
122
|
+
} finally {
|
|
123
|
+
await fs.rm(jsonPath, { force: true }).catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseIosReadyPayload(payload: unknown): { tunnelState?: string } {
|
|
128
|
+
const result = (payload as { result?: unknown } | null | undefined)?.result;
|
|
129
|
+
if (!result || typeof result !== 'object') return {};
|
|
130
|
+
const direct = (result as { connectionProperties?: { tunnelState?: unknown } }).connectionProperties?.tunnelState;
|
|
131
|
+
const nested = (result as { device?: { connectionProperties?: { tunnelState?: unknown } } }).device?.connectionProperties?.tunnelState;
|
|
132
|
+
const tunnelState = typeof direct === 'string' ? direct : typeof nested === 'string' ? nested : undefined;
|
|
133
|
+
return tunnelState ? { tunnelState } : {};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function readIosReadyPayload(jsonPath: string): Promise<{ parsed: boolean; tunnelState?: string }> {
|
|
137
|
+
try {
|
|
138
|
+
const payloadText = await fs.readFile(jsonPath, 'utf8');
|
|
139
|
+
const payload = JSON.parse(payloadText) as unknown;
|
|
140
|
+
const parsed = parseIosReadyPayload(payload);
|
|
141
|
+
return { parsed: true, tunnelState: parsed.tunnelState };
|
|
142
|
+
} catch {
|
|
143
|
+
return { parsed: false };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resolveIosReadyHint(stdout: string, stderr: string): string {
|
|
148
|
+
const devicectlHint = resolveIosDevicectlHint(stdout, stderr);
|
|
149
|
+
if (devicectlHint) return devicectlHint;
|
|
150
|
+
const text = `${stdout}\n${stderr}`.toLowerCase();
|
|
151
|
+
if (text.includes('timed out waiting for all destinations')) {
|
|
152
|
+
return 'Xcode destination did not become available in time. Keep device unlocked and retry.';
|
|
153
|
+
}
|
|
154
|
+
return IOS_DEVICECTL_DEFAULT_HINT;
|
|
155
|
+
}
|
|
@@ -124,6 +124,299 @@ test('boot succeeds for supported device in session', async () => {
|
|
|
124
124
|
}
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
test('boot prefers explicit device selector over active session device', async () => {
|
|
128
|
+
const sessionStore = makeSessionStore();
|
|
129
|
+
const sessionName = 'default';
|
|
130
|
+
sessionStore.set(
|
|
131
|
+
sessionName,
|
|
132
|
+
makeSession(sessionName, {
|
|
133
|
+
platform: 'android',
|
|
134
|
+
id: 'emulator-5554',
|
|
135
|
+
name: 'Pixel Emulator',
|
|
136
|
+
kind: 'emulator',
|
|
137
|
+
booted: true,
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
const selectedDevice: SessionState['device'] = {
|
|
141
|
+
platform: 'ios',
|
|
142
|
+
id: 'sim-2',
|
|
143
|
+
name: 'iPhone 17 Pro',
|
|
144
|
+
kind: 'simulator',
|
|
145
|
+
booted: true,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const ensured: string[] = [];
|
|
149
|
+
const response = await handleSessionCommands({
|
|
150
|
+
req: {
|
|
151
|
+
token: 't',
|
|
152
|
+
session: sessionName,
|
|
153
|
+
command: 'boot',
|
|
154
|
+
positionals: [],
|
|
155
|
+
flags: { platform: 'ios', device: 'iPhone 17 Pro' },
|
|
156
|
+
},
|
|
157
|
+
sessionName,
|
|
158
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
159
|
+
sessionStore,
|
|
160
|
+
invoke: noopInvoke,
|
|
161
|
+
ensureReady: async (device) => {
|
|
162
|
+
ensured.push(device.id);
|
|
163
|
+
},
|
|
164
|
+
resolveTargetDevice: async () => selectedDevice,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
assert.ok(response);
|
|
168
|
+
assert.equal(response?.ok, true);
|
|
169
|
+
assert.deepEqual(ensured, ['sim-2']);
|
|
170
|
+
if (response && response.ok) {
|
|
171
|
+
assert.equal(response.data?.platform, 'ios');
|
|
172
|
+
assert.equal(response.data?.id, 'sim-2');
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('appstate on iOS requires active session on selected device', async () => {
|
|
177
|
+
const sessionStore = makeSessionStore();
|
|
178
|
+
const sessionName = 'default';
|
|
179
|
+
sessionStore.set(
|
|
180
|
+
sessionName,
|
|
181
|
+
{
|
|
182
|
+
...makeSession(sessionName, {
|
|
183
|
+
platform: 'ios',
|
|
184
|
+
id: 'sim-1',
|
|
185
|
+
name: 'iPhone 15',
|
|
186
|
+
kind: 'simulator',
|
|
187
|
+
booted: true,
|
|
188
|
+
}),
|
|
189
|
+
appBundleId: 'com.apple.Preferences',
|
|
190
|
+
appName: 'Settings',
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
const selectedDevice: SessionState['device'] = {
|
|
194
|
+
platform: 'ios',
|
|
195
|
+
id: 'sim-2',
|
|
196
|
+
name: 'iPhone 17 Pro',
|
|
197
|
+
kind: 'simulator',
|
|
198
|
+
booted: true,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const response = await handleSessionCommands({
|
|
202
|
+
req: {
|
|
203
|
+
token: 't',
|
|
204
|
+
session: sessionName,
|
|
205
|
+
command: 'appstate',
|
|
206
|
+
positionals: [],
|
|
207
|
+
flags: { platform: 'ios', device: 'iPhone 17 Pro' },
|
|
208
|
+
},
|
|
209
|
+
sessionName,
|
|
210
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
211
|
+
sessionStore,
|
|
212
|
+
invoke: noopInvoke,
|
|
213
|
+
ensureReady: async () => {},
|
|
214
|
+
resolveTargetDevice: async () => selectedDevice,
|
|
215
|
+
dispatch: async () => {
|
|
216
|
+
throw new Error('snapshot dispatch should not run');
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
assert.ok(response);
|
|
221
|
+
assert.equal(response?.ok, false);
|
|
222
|
+
if (response && !response.ok) {
|
|
223
|
+
assert.equal(response.error.code, 'SESSION_NOT_FOUND');
|
|
224
|
+
assert.match(response.error.message, /requires an active session/i);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('appstate with explicit selector matching session returns session state', async () => {
|
|
229
|
+
const sessionStore = makeSessionStore();
|
|
230
|
+
const sessionName = 'sim';
|
|
231
|
+
sessionStore.set(
|
|
232
|
+
sessionName,
|
|
233
|
+
{
|
|
234
|
+
...makeSession(sessionName, {
|
|
235
|
+
platform: 'ios',
|
|
236
|
+
id: 'sim-1',
|
|
237
|
+
name: 'iPhone 17 Pro',
|
|
238
|
+
kind: 'simulator',
|
|
239
|
+
booted: true,
|
|
240
|
+
}),
|
|
241
|
+
appBundleId: 'com.apple.Maps',
|
|
242
|
+
appName: 'Maps',
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const response = await handleSessionCommands({
|
|
247
|
+
req: {
|
|
248
|
+
token: 't',
|
|
249
|
+
session: sessionName,
|
|
250
|
+
command: 'appstate',
|
|
251
|
+
positionals: [],
|
|
252
|
+
flags: { platform: 'ios', device: 'iPhone 17 Pro' },
|
|
253
|
+
},
|
|
254
|
+
sessionName,
|
|
255
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
256
|
+
sessionStore,
|
|
257
|
+
invoke: noopInvoke,
|
|
258
|
+
ensureReady: async () => {},
|
|
259
|
+
dispatch: async () => {
|
|
260
|
+
throw new Error('snapshot dispatch should not run');
|
|
261
|
+
},
|
|
262
|
+
resolveTargetDevice: async () => {
|
|
263
|
+
throw new Error('resolveTargetDevice should not run');
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
assert.ok(response);
|
|
268
|
+
assert.equal(response?.ok, true);
|
|
269
|
+
if (response && response.ok) {
|
|
270
|
+
assert.equal(response.data?.platform, 'ios');
|
|
271
|
+
assert.equal(response.data?.appName, 'Maps');
|
|
272
|
+
assert.equal(response.data?.appBundleId, 'com.apple.Maps');
|
|
273
|
+
assert.equal(response.data?.source, 'session');
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('appstate returns session appName when bundle id is unavailable', async () => {
|
|
278
|
+
const sessionStore = makeSessionStore();
|
|
279
|
+
const sessionName = 'sim';
|
|
280
|
+
sessionStore.set(
|
|
281
|
+
sessionName,
|
|
282
|
+
{
|
|
283
|
+
...makeSession(sessionName, {
|
|
284
|
+
platform: 'ios',
|
|
285
|
+
id: 'sim-1',
|
|
286
|
+
name: 'iPhone 17 Pro',
|
|
287
|
+
kind: 'simulator',
|
|
288
|
+
booted: true,
|
|
289
|
+
}),
|
|
290
|
+
appName: 'Maps',
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const response = await handleSessionCommands({
|
|
295
|
+
req: {
|
|
296
|
+
token: 't',
|
|
297
|
+
session: sessionName,
|
|
298
|
+
command: 'appstate',
|
|
299
|
+
positionals: [],
|
|
300
|
+
flags: { platform: 'ios', device: 'iPhone 17 Pro' },
|
|
301
|
+
},
|
|
302
|
+
sessionName,
|
|
303
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
304
|
+
sessionStore,
|
|
305
|
+
invoke: noopInvoke,
|
|
306
|
+
ensureReady: async () => {},
|
|
307
|
+
dispatch: async () => {
|
|
308
|
+
throw new Error('snapshot dispatch should not run');
|
|
309
|
+
},
|
|
310
|
+
resolveTargetDevice: async () => {
|
|
311
|
+
throw new Error('resolveTargetDevice should not run');
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
assert.ok(response);
|
|
316
|
+
assert.equal(response?.ok, true);
|
|
317
|
+
if (response && response.ok) {
|
|
318
|
+
assert.equal(response.data?.platform, 'ios');
|
|
319
|
+
assert.equal(response.data?.appName, 'Maps');
|
|
320
|
+
assert.equal(response.data?.appBundleId, undefined);
|
|
321
|
+
assert.equal(response.data?.source, 'session');
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('appstate fails when iOS session has no tracked app', async () => {
|
|
326
|
+
const sessionStore = makeSessionStore();
|
|
327
|
+
const sessionName = 'sim';
|
|
328
|
+
sessionStore.set(
|
|
329
|
+
sessionName,
|
|
330
|
+
makeSession(sessionName, {
|
|
331
|
+
platform: 'ios',
|
|
332
|
+
id: 'sim-1',
|
|
333
|
+
name: 'iPhone 17 Pro',
|
|
334
|
+
kind: 'simulator',
|
|
335
|
+
booted: true,
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const response = await handleSessionCommands({
|
|
340
|
+
req: {
|
|
341
|
+
token: 't',
|
|
342
|
+
session: sessionName,
|
|
343
|
+
command: 'appstate',
|
|
344
|
+
positionals: [],
|
|
345
|
+
flags: { platform: 'ios', device: 'iPhone 17 Pro' },
|
|
346
|
+
},
|
|
347
|
+
sessionName,
|
|
348
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
349
|
+
sessionStore,
|
|
350
|
+
invoke: noopInvoke,
|
|
351
|
+
ensureReady: async () => {},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
assert.ok(response);
|
|
355
|
+
assert.equal(response?.ok, false);
|
|
356
|
+
if (response && !response.ok) {
|
|
357
|
+
assert.equal(response.error.code, 'COMMAND_FAILED');
|
|
358
|
+
assert.match(response.error.message, /no foreground app is tracked/i);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async () => {
|
|
363
|
+
const sessionStore = makeSessionStore();
|
|
364
|
+
const selectedDevice: SessionState['device'] = {
|
|
365
|
+
platform: 'ios',
|
|
366
|
+
id: 'sim-2',
|
|
367
|
+
name: 'iPhone 17 Pro',
|
|
368
|
+
kind: 'simulator',
|
|
369
|
+
booted: true,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const response = await handleSessionCommands({
|
|
373
|
+
req: {
|
|
374
|
+
token: 't',
|
|
375
|
+
session: 'default',
|
|
376
|
+
command: 'appstate',
|
|
377
|
+
positionals: [],
|
|
378
|
+
flags: { platform: 'ios', device: 'iPhone 17 Pro' },
|
|
379
|
+
},
|
|
380
|
+
sessionName: 'default',
|
|
381
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
382
|
+
sessionStore,
|
|
383
|
+
invoke: noopInvoke,
|
|
384
|
+
ensureReady: async () => {},
|
|
385
|
+
resolveTargetDevice: async () => selectedDevice,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
assert.ok(response);
|
|
389
|
+
assert.equal(response?.ok, false);
|
|
390
|
+
if (response && !response.ok) {
|
|
391
|
+
assert.equal(response.error.code, 'SESSION_NOT_FOUND');
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('appstate with explicit missing session returns SESSION_NOT_FOUND', async () => {
|
|
396
|
+
const sessionStore = makeSessionStore();
|
|
397
|
+
const response = await handleSessionCommands({
|
|
398
|
+
req: {
|
|
399
|
+
token: 't',
|
|
400
|
+
session: 'sim',
|
|
401
|
+
command: 'appstate',
|
|
402
|
+
positionals: [],
|
|
403
|
+
flags: { session: 'sim', platform: 'ios', device: 'iPhone 17 Pro' },
|
|
404
|
+
},
|
|
405
|
+
sessionName: 'sim',
|
|
406
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
407
|
+
sessionStore,
|
|
408
|
+
invoke: noopInvoke,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
assert.ok(response);
|
|
412
|
+
assert.equal(response?.ok, false);
|
|
413
|
+
if (response && !response.ok) {
|
|
414
|
+
assert.equal(response.error.code, 'SESSION_NOT_FOUND');
|
|
415
|
+
assert.match(response.error.message, /no active session "sim"/i);
|
|
416
|
+
assert.doesNotMatch(response.error.message, /omit --session/i);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
127
420
|
test('open URL on existing iOS session clears stale app bundle id', async () => {
|
|
128
421
|
const sessionStore = makeSessionStore();
|
|
129
422
|
const sessionName = 'ios-session';
|
|
@@ -170,6 +463,143 @@ test('open URL on existing iOS session clears stale app bundle id', async () =>
|
|
|
170
463
|
assert.equal(dispatchedContext?.appBundleId, undefined);
|
|
171
464
|
});
|
|
172
465
|
|
|
466
|
+
test('open URL on existing iOS device session preserves app bundle id context', async () => {
|
|
467
|
+
const sessionStore = makeSessionStore();
|
|
468
|
+
const sessionName = 'ios-device-session';
|
|
469
|
+
sessionStore.set(
|
|
470
|
+
sessionName,
|
|
471
|
+
{
|
|
472
|
+
...makeSession(sessionName, {
|
|
473
|
+
platform: 'ios',
|
|
474
|
+
id: 'ios-device-1',
|
|
475
|
+
name: 'iPhone Device',
|
|
476
|
+
kind: 'device',
|
|
477
|
+
booted: true,
|
|
478
|
+
}),
|
|
479
|
+
appBundleId: 'com.example.app',
|
|
480
|
+
appName: 'Example App',
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
let dispatchedContext: Record<string, unknown> | undefined;
|
|
485
|
+
const response = await handleSessionCommands({
|
|
486
|
+
req: {
|
|
487
|
+
token: 't',
|
|
488
|
+
session: sessionName,
|
|
489
|
+
command: 'open',
|
|
490
|
+
positionals: ['myapp://item/42'],
|
|
491
|
+
flags: {},
|
|
492
|
+
},
|
|
493
|
+
sessionName,
|
|
494
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
495
|
+
sessionStore,
|
|
496
|
+
invoke: noopInvoke,
|
|
497
|
+
dispatch: async (_device, _command, _positionals, _out, context) => {
|
|
498
|
+
dispatchedContext = context as Record<string, unknown> | undefined;
|
|
499
|
+
return {};
|
|
500
|
+
},
|
|
501
|
+
ensureReady: async () => {},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
assert.ok(response);
|
|
505
|
+
assert.equal(response?.ok, true);
|
|
506
|
+
const updated = sessionStore.get(sessionName);
|
|
507
|
+
assert.equal(updated?.appBundleId, 'com.example.app');
|
|
508
|
+
assert.equal(updated?.appName, 'myapp://item/42');
|
|
509
|
+
assert.equal(dispatchedContext?.appBundleId, 'com.example.app');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('open web URL on iOS device session without active app falls back to Safari', async () => {
|
|
513
|
+
const sessionStore = makeSessionStore();
|
|
514
|
+
const sessionName = 'ios-device-session';
|
|
515
|
+
sessionStore.set(
|
|
516
|
+
sessionName,
|
|
517
|
+
makeSession(sessionName, {
|
|
518
|
+
platform: 'ios',
|
|
519
|
+
id: 'ios-device-1',
|
|
520
|
+
name: 'iPhone Device',
|
|
521
|
+
kind: 'device',
|
|
522
|
+
booted: true,
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
let dispatchedContext: Record<string, unknown> | undefined;
|
|
527
|
+
const response = await handleSessionCommands({
|
|
528
|
+
req: {
|
|
529
|
+
token: 't',
|
|
530
|
+
session: sessionName,
|
|
531
|
+
command: 'open',
|
|
532
|
+
positionals: ['https://example.com/path'],
|
|
533
|
+
flags: {},
|
|
534
|
+
},
|
|
535
|
+
sessionName,
|
|
536
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
537
|
+
sessionStore,
|
|
538
|
+
invoke: noopInvoke,
|
|
539
|
+
dispatch: async (_device, _command, _positionals, _out, context) => {
|
|
540
|
+
dispatchedContext = context as Record<string, unknown> | undefined;
|
|
541
|
+
return {};
|
|
542
|
+
},
|
|
543
|
+
ensureReady: async () => {},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
assert.ok(response);
|
|
547
|
+
assert.equal(response?.ok, true);
|
|
548
|
+
const updated = sessionStore.get(sessionName);
|
|
549
|
+
assert.equal(updated?.appBundleId, 'com.apple.mobilesafari');
|
|
550
|
+
assert.equal(updated?.appName, 'https://example.com/path');
|
|
551
|
+
assert.equal(dispatchedContext?.appBundleId, 'com.apple.mobilesafari');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test('open app and URL on existing iOS device session keeps app context', async () => {
|
|
555
|
+
const sessionStore = makeSessionStore();
|
|
556
|
+
const sessionName = 'ios-device-session';
|
|
557
|
+
sessionStore.set(
|
|
558
|
+
sessionName,
|
|
559
|
+
{
|
|
560
|
+
...makeSession(sessionName, {
|
|
561
|
+
platform: 'ios',
|
|
562
|
+
id: 'ios-device-1',
|
|
563
|
+
name: 'iPhone Device',
|
|
564
|
+
kind: 'device',
|
|
565
|
+
booted: true,
|
|
566
|
+
}),
|
|
567
|
+
appBundleId: 'com.example.previous',
|
|
568
|
+
appName: 'Previous App',
|
|
569
|
+
},
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
let dispatchedPositionals: string[] | undefined;
|
|
573
|
+
let dispatchedContext: Record<string, unknown> | undefined;
|
|
574
|
+
const response = await handleSessionCommands({
|
|
575
|
+
req: {
|
|
576
|
+
token: 't',
|
|
577
|
+
session: sessionName,
|
|
578
|
+
command: 'open',
|
|
579
|
+
positionals: ['Settings', 'myapp://screen/to'],
|
|
580
|
+
flags: {},
|
|
581
|
+
},
|
|
582
|
+
sessionName,
|
|
583
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
584
|
+
sessionStore,
|
|
585
|
+
invoke: noopInvoke,
|
|
586
|
+
dispatch: async (_device, _command, positionals, _out, context) => {
|
|
587
|
+
dispatchedPositionals = positionals;
|
|
588
|
+
dispatchedContext = context as Record<string, unknown> | undefined;
|
|
589
|
+
return {};
|
|
590
|
+
},
|
|
591
|
+
ensureReady: async () => {},
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
assert.ok(response);
|
|
595
|
+
assert.equal(response?.ok, true);
|
|
596
|
+
const updated = sessionStore.get(sessionName);
|
|
597
|
+
assert.equal(updated?.appBundleId, 'com.apple.Preferences');
|
|
598
|
+
assert.equal(updated?.appName, 'Settings');
|
|
599
|
+
assert.deepEqual(dispatchedPositionals, ['Settings', 'myapp://screen/to']);
|
|
600
|
+
assert.equal(dispatchedContext?.appBundleId, 'com.apple.Preferences');
|
|
601
|
+
});
|
|
602
|
+
|
|
173
603
|
test('open app on existing iOS session resolves and stores bundle id', async () => {
|
|
174
604
|
const sessionStore = makeSessionStore();
|
|
175
605
|
const sessionName = 'ios-session';
|
|
@@ -250,6 +680,7 @@ test('open --relaunch closes and reopens active session app', async () => {
|
|
|
250
680
|
calls.push({ command, positionals });
|
|
251
681
|
return {};
|
|
252
682
|
},
|
|
683
|
+
ensureReady: async () => {},
|
|
253
684
|
});
|
|
254
685
|
|
|
255
686
|
assert.ok(response);
|
|
@@ -307,6 +738,52 @@ test('open --relaunch fails without app when no session exists', async () => {
|
|
|
307
738
|
}
|
|
308
739
|
});
|
|
309
740
|
|
|
741
|
+
test('open on in-use device returns DEVICE_IN_USE before readiness checks', async () => {
|
|
742
|
+
const sessionStore = makeSessionStore();
|
|
743
|
+
sessionStore.set(
|
|
744
|
+
'busy-session',
|
|
745
|
+
makeSession('busy-session', {
|
|
746
|
+
platform: 'ios',
|
|
747
|
+
id: 'ios-device-1',
|
|
748
|
+
name: 'iPhone Device',
|
|
749
|
+
kind: 'device',
|
|
750
|
+
booted: true,
|
|
751
|
+
}),
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
let ensureReadyCalls = 0;
|
|
755
|
+
const response = await handleSessionCommands({
|
|
756
|
+
req: {
|
|
757
|
+
token: 't',
|
|
758
|
+
session: 'default',
|
|
759
|
+
command: 'open',
|
|
760
|
+
positionals: ['settings'],
|
|
761
|
+
flags: { platform: 'ios' },
|
|
762
|
+
},
|
|
763
|
+
sessionName: 'default',
|
|
764
|
+
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
|
765
|
+
sessionStore,
|
|
766
|
+
invoke: noopInvoke,
|
|
767
|
+
ensureReady: async () => {
|
|
768
|
+
ensureReadyCalls += 1;
|
|
769
|
+
},
|
|
770
|
+
resolveTargetDevice: async () => ({
|
|
771
|
+
platform: 'ios',
|
|
772
|
+
id: 'ios-device-1',
|
|
773
|
+
name: 'iPhone Device',
|
|
774
|
+
kind: 'device',
|
|
775
|
+
booted: true,
|
|
776
|
+
}),
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
assert.ok(response);
|
|
780
|
+
assert.equal(response?.ok, false);
|
|
781
|
+
if (response && !response.ok) {
|
|
782
|
+
assert.equal(response.error.code, 'DEVICE_IN_USE');
|
|
783
|
+
}
|
|
784
|
+
assert.equal(ensureReadyCalls, 0);
|
|
785
|
+
});
|
|
786
|
+
|
|
310
787
|
test('replay parses open --relaunch flag and replays open with relaunch semantics', async () => {
|
|
311
788
|
const sessionStore = makeSessionStore();
|
|
312
789
|
const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-relaunch-'));
|