agent-device 0.3.5 → 0.4.1
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 +47 -14
- package/dist/src/797.js +1 -0
- package/dist/src/bin.js +44 -95
- package/dist/src/daemon.js +18 -17
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
- package/ios-runner/README.md +1 -1
- package/package.json +2 -2
- package/skills/agent-device/SKILL.md +25 -12
- package/skills/agent-device/references/permissions.md +15 -1
- package/skills/agent-device/references/session-management.md +3 -0
- package/skills/agent-device/references/snapshot-refs.md +2 -0
- package/skills/agent-device/references/video-recording.md +2 -0
- package/src/__tests__/cli-help.test.ts +102 -0
- package/src/cli.ts +42 -8
- package/src/core/__tests__/capabilities.test.ts +11 -6
- package/src/core/capabilities.ts +26 -20
- package/src/core/dispatch.ts +109 -31
- package/src/daemon/__tests__/app-state.test.ts +138 -0
- package/src/daemon/__tests__/session-store.test.ts +23 -0
- package/src/daemon/app-state.ts +37 -38
- package/src/daemon/context.ts +12 -0
- package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
- package/src/daemon/handlers/__tests__/session.test.ts +8 -5
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
- package/src/daemon/handlers/interaction.ts +37 -0
- package/src/daemon/handlers/record-trace.ts +1 -1
- package/src/daemon/handlers/session.ts +3 -3
- package/src/daemon/handlers/snapshot.ts +230 -187
- package/src/daemon/session-store.ts +16 -4
- package/src/daemon/types.ts +2 -1
- package/src/daemon-client.ts +42 -13
- package/src/daemon.ts +99 -9
- package/src/platforms/android/__tests__/index.test.ts +46 -1
- package/src/platforms/android/index.ts +23 -0
- package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
- package/src/platforms/ios/devices.ts +40 -18
- package/src/platforms/ios/index.ts +2 -2
- package/src/platforms/ios/runner-client.ts +418 -93
- package/src/utils/__tests__/args.test.ts +208 -1
- package/src/utils/__tests__/daemon-client.test.ts +78 -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 +202 -215
- package/src/utils/command-schema.ts +629 -0
- package/src/utils/interactors.ts +11 -1
- package/src/utils/keyed-lock.ts +14 -0
- package/src/utils/process-identity.ts +100 -0
- package/dist/src/274.js +0 -1
package/src/daemon-client.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { AppError } from './utils/errors.ts';
|
|
|
6
6
|
import type { CommandFlags } from './core/dispatch.ts';
|
|
7
7
|
import { runCmdDetached } from './utils/exec.ts';
|
|
8
8
|
import { findProjectRoot, readVersion } from './utils/version.ts';
|
|
9
|
+
import { stopProcessForTakeover } from './utils/process-identity.ts';
|
|
9
10
|
|
|
10
11
|
export type DaemonRequest = {
|
|
11
12
|
token: string;
|
|
@@ -19,12 +20,20 @@ export type DaemonResponse =
|
|
|
19
20
|
| { ok: true; data?: Record<string, unknown> }
|
|
20
21
|
| { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
|
|
21
22
|
|
|
22
|
-
type DaemonInfo = {
|
|
23
|
+
type DaemonInfo = {
|
|
24
|
+
port: number;
|
|
25
|
+
token: string;
|
|
26
|
+
pid: number;
|
|
27
|
+
version?: string;
|
|
28
|
+
processStartTime?: string;
|
|
29
|
+
};
|
|
23
30
|
|
|
24
31
|
const baseDir = path.join(os.homedir(), '.agent-device');
|
|
25
32
|
const infoPath = path.join(baseDir, 'daemon.json');
|
|
26
|
-
const REQUEST_TIMEOUT_MS =
|
|
33
|
+
const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
|
|
27
34
|
const DAEMON_STARTUP_TIMEOUT_MS = 5000;
|
|
35
|
+
const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
|
|
36
|
+
const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000;
|
|
28
37
|
|
|
29
38
|
export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
|
|
30
39
|
const info = await ensureDaemon();
|
|
@@ -35,8 +44,10 @@ export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<D
|
|
|
35
44
|
async function ensureDaemon(): Promise<DaemonInfo> {
|
|
36
45
|
const existing = readDaemonInfo();
|
|
37
46
|
const localVersion = readVersion();
|
|
38
|
-
|
|
39
|
-
if (existing &&
|
|
47
|
+
const existingReachable = existing ? await canConnect(existing) : false;
|
|
48
|
+
if (existing && existing.version === localVersion && existingReachable) return existing;
|
|
49
|
+
if (existing && (existing.version !== localVersion || !existingReachable)) {
|
|
50
|
+
await stopDaemonProcessForTakeover(existing);
|
|
40
51
|
removeDaemonInfo();
|
|
41
52
|
}
|
|
42
53
|
|
|
@@ -55,19 +66,34 @@ async function ensureDaemon(): Promise<DaemonInfo> {
|
|
|
55
66
|
});
|
|
56
67
|
}
|
|
57
68
|
|
|
69
|
+
async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
|
|
70
|
+
await stopProcessForTakeover(info.pid, {
|
|
71
|
+
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
|
|
72
|
+
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
|
|
73
|
+
expectedStartTime: info.processStartTime,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
function readDaemonInfo(): DaemonInfo | null {
|
|
59
78
|
if (!fs.existsSync(infoPath)) return null;
|
|
60
79
|
try {
|
|
61
80
|
const data = JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo;
|
|
62
81
|
if (!data.port || !data.token) return null;
|
|
63
|
-
return
|
|
82
|
+
return {
|
|
83
|
+
...data,
|
|
84
|
+
pid: Number.isInteger(data.pid) && data.pid > 0 ? data.pid : 0,
|
|
85
|
+
};
|
|
64
86
|
} catch {
|
|
65
87
|
return null;
|
|
66
88
|
}
|
|
67
89
|
}
|
|
68
90
|
|
|
69
91
|
function removeDaemonInfo(): void {
|
|
70
|
-
|
|
92
|
+
try {
|
|
93
|
+
if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
|
|
94
|
+
} catch {
|
|
95
|
+
// Best-effort cleanup only; daemon can still overwrite this file on startup.
|
|
96
|
+
}
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
async function canConnect(info: DaemonInfo): Promise<boolean> {
|
|
@@ -87,11 +113,14 @@ async function startDaemon(): Promise<void> {
|
|
|
87
113
|
const distPath = path.join(root, 'dist', 'src', 'daemon.js');
|
|
88
114
|
const srcPath = path.join(root, 'src', 'daemon.ts');
|
|
89
115
|
|
|
90
|
-
const
|
|
91
|
-
|
|
116
|
+
const hasDist = fs.existsSync(distPath);
|
|
117
|
+
const hasSrc = fs.existsSync(srcPath);
|
|
118
|
+
if (!hasDist && !hasSrc) {
|
|
92
119
|
throw new AppError('COMMAND_FAILED', 'Daemon entry not found', { distPath, srcPath });
|
|
93
120
|
}
|
|
94
|
-
const
|
|
121
|
+
const runningFromSource = process.execArgv.includes('--experimental-strip-types');
|
|
122
|
+
const useSrc = runningFromSource ? hasSrc : !hasDist && hasSrc;
|
|
123
|
+
const args = useSrc ? ['--experimental-strip-types', srcPath] : [distPath];
|
|
95
124
|
|
|
96
125
|
runCmdDetached(process.execPath, args);
|
|
97
126
|
}
|
|
@@ -134,10 +163,10 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
|
|
|
134
163
|
});
|
|
135
164
|
}
|
|
136
165
|
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
if (!raw) return
|
|
166
|
+
export function resolveDaemonRequestTimeoutMs(raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS): number {
|
|
167
|
+
// iOS physical-device runner startup/build can exceed 60s, so use a safer default for daemon RPCs.
|
|
168
|
+
if (!raw) return 180000;
|
|
140
169
|
const parsed = Number(raw);
|
|
141
|
-
if (!Number.isFinite(parsed)) return
|
|
170
|
+
if (!Number.isFinite(parsed)) return 180000;
|
|
142
171
|
return Math.max(1000, Math.floor(parsed));
|
|
143
172
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
|
|
|
7
7
|
import { isCommandSupportedOnDevice } from './core/capabilities.ts';
|
|
8
8
|
import { asAppError, AppError } from './utils/errors.ts';
|
|
9
9
|
import { readVersion } from './utils/version.ts';
|
|
10
|
-
import {
|
|
10
|
+
import { stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts';
|
|
11
11
|
import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
|
|
12
12
|
import { SessionStore } from './daemon/session-store.ts';
|
|
13
13
|
import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
|
|
@@ -18,9 +18,14 @@ import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
|
|
|
18
18
|
import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
|
|
19
19
|
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
|
|
20
20
|
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
|
|
21
|
+
import {
|
|
22
|
+
isAgentDeviceDaemonProcess,
|
|
23
|
+
readProcessStartTime,
|
|
24
|
+
} from './utils/process-identity.ts';
|
|
21
25
|
|
|
22
26
|
const baseDir = path.join(os.homedir(), '.agent-device');
|
|
23
27
|
const infoPath = path.join(baseDir, 'daemon.json');
|
|
28
|
+
const lockPath = path.join(baseDir, 'daemon.lock');
|
|
24
29
|
const logPath = path.join(baseDir, 'daemon.log');
|
|
25
30
|
const sessionsDir = path.join(baseDir, 'sessions');
|
|
26
31
|
const sessionStore = new SessionStore(sessionsDir);
|
|
@@ -28,6 +33,15 @@ const version = readVersion();
|
|
|
28
33
|
const token = crypto.randomBytes(24).toString('hex');
|
|
29
34
|
const selectorValidationExemptCommands = new Set(['session_list', 'devices']);
|
|
30
35
|
|
|
36
|
+
type DaemonLockInfo = {
|
|
37
|
+
pid: number;
|
|
38
|
+
version: string;
|
|
39
|
+
startedAt: number;
|
|
40
|
+
processStartTime?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined;
|
|
44
|
+
|
|
31
45
|
function contextFromFlags(
|
|
32
46
|
flags: CommandFlags | undefined,
|
|
33
47
|
appBundleId?: string,
|
|
@@ -122,7 +136,7 @@ function writeInfo(port: number): void {
|
|
|
122
136
|
fs.writeFileSync(logPath, '');
|
|
123
137
|
fs.writeFileSync(
|
|
124
138
|
infoPath,
|
|
125
|
-
JSON.stringify({ port, token, pid: process.pid, version }, null, 2),
|
|
139
|
+
JSON.stringify({ port, token, pid: process.pid, version, processStartTime: daemonProcessStartTime }, null, 2),
|
|
126
140
|
{
|
|
127
141
|
mode: 0o600,
|
|
128
142
|
},
|
|
@@ -133,7 +147,73 @@ function removeInfo(): void {
|
|
|
133
147
|
if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
|
|
134
148
|
}
|
|
135
149
|
|
|
150
|
+
function readLockInfo(): DaemonLockInfo | null {
|
|
151
|
+
if (!fs.existsSync(lockPath)) return null;
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as DaemonLockInfo;
|
|
154
|
+
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
|
|
155
|
+
return parsed;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function acquireDaemonLock(): boolean {
|
|
162
|
+
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
|
|
163
|
+
const lockData: DaemonLockInfo = {
|
|
164
|
+
pid: process.pid,
|
|
165
|
+
version,
|
|
166
|
+
startedAt: Date.now(),
|
|
167
|
+
processStartTime: daemonProcessStartTime,
|
|
168
|
+
};
|
|
169
|
+
const payload = JSON.stringify(lockData, null, 2);
|
|
170
|
+
|
|
171
|
+
const tryWriteLock = (): boolean => {
|
|
172
|
+
try {
|
|
173
|
+
fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
|
|
174
|
+
return true;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (tryWriteLock()) return true;
|
|
182
|
+
const existing = readLockInfo();
|
|
183
|
+
if (
|
|
184
|
+
existing?.pid
|
|
185
|
+
&& existing.pid !== process.pid
|
|
186
|
+
&& isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime)
|
|
187
|
+
) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
// Best-effort stale-lock cleanup: another process may win the race between unlink and re-create.
|
|
191
|
+
// We rely on the subsequent write with `wx` to enforce single-writer semantics.
|
|
192
|
+
try {
|
|
193
|
+
fs.unlinkSync(lockPath);
|
|
194
|
+
} catch {
|
|
195
|
+
// ignore
|
|
196
|
+
}
|
|
197
|
+
return tryWriteLock();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function releaseDaemonLock(): void {
|
|
201
|
+
const existing = readLockInfo();
|
|
202
|
+
if (existing && existing.pid !== process.pid) return;
|
|
203
|
+
try {
|
|
204
|
+
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
|
|
205
|
+
} catch {
|
|
206
|
+
// ignore
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
136
210
|
function start(): void {
|
|
211
|
+
if (!acquireDaemonLock()) {
|
|
212
|
+
process.stderr.write('Daemon lock is held by another process; exiting.\n');
|
|
213
|
+
process.exit(0);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
137
217
|
const server = net.createServer((socket) => {
|
|
138
218
|
let buffer = '';
|
|
139
219
|
socket.setEncoding('utf8');
|
|
@@ -172,18 +252,28 @@ function start(): void {
|
|
|
172
252
|
}
|
|
173
253
|
});
|
|
174
254
|
|
|
255
|
+
let shuttingDown = false;
|
|
256
|
+
const closeServer = async (): Promise<void> => {
|
|
257
|
+
await new Promise<void>((resolve) => {
|
|
258
|
+
try {
|
|
259
|
+
server.close(() => resolve());
|
|
260
|
+
} catch {
|
|
261
|
+
resolve();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
};
|
|
175
265
|
const shutdown = async () => {
|
|
266
|
+
if (shuttingDown) return;
|
|
267
|
+
shuttingDown = true;
|
|
268
|
+
await closeServer();
|
|
176
269
|
const sessionsToStop = sessionStore.toArray();
|
|
177
270
|
for (const session of sessionsToStop) {
|
|
178
|
-
if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
|
|
179
|
-
await stopIosRunnerSession(session.device.id);
|
|
180
|
-
}
|
|
181
271
|
sessionStore.writeSessionLog(session);
|
|
182
272
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
273
|
+
await stopAllIosRunnerSessions();
|
|
274
|
+
removeInfo();
|
|
275
|
+
releaseDaemonLock();
|
|
276
|
+
process.exit(0);
|
|
187
277
|
};
|
|
188
278
|
|
|
189
279
|
process.on('SIGINT', () => {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { openAndroidApp, parseAndroidLaunchComponent, swipeAndroid } from '../index.ts';
|
|
4
7
|
import type { DeviceInfo } from '../../../utils/device.ts';
|
|
5
8
|
import { AppError } from '../../../utils/errors.ts';
|
|
6
9
|
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
|
|
@@ -110,3 +113,45 @@ test('openAndroidApp rejects activity override for deep link URLs', async () =>
|
|
|
110
113
|
},
|
|
111
114
|
);
|
|
112
115
|
});
|
|
116
|
+
|
|
117
|
+
test('swipeAndroid invokes adb input swipe with duration', async () => {
|
|
118
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-swipe-test-'));
|
|
119
|
+
const adbPath = path.join(tmpDir, 'adb');
|
|
120
|
+
const argsLogPath = path.join(tmpDir, 'args.log');
|
|
121
|
+
await fs.writeFile(
|
|
122
|
+
adbPath,
|
|
123
|
+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
|
|
124
|
+
'utf8',
|
|
125
|
+
);
|
|
126
|
+
await fs.chmod(adbPath, 0o755);
|
|
127
|
+
|
|
128
|
+
const previousPath = process.env.PATH;
|
|
129
|
+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
|
|
130
|
+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
|
|
131
|
+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
|
|
132
|
+
|
|
133
|
+
const device: DeviceInfo = {
|
|
134
|
+
platform: 'android',
|
|
135
|
+
id: 'emulator-5554',
|
|
136
|
+
name: 'Pixel',
|
|
137
|
+
kind: 'emulator',
|
|
138
|
+
booted: true,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await swipeAndroid(device, 10, 20, 30, 40, 250);
|
|
143
|
+
const args = (await fs.readFile(argsLogPath, 'utf8'))
|
|
144
|
+
.trim()
|
|
145
|
+
.split('\n')
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
assert.deepEqual(args, ['-s', 'emulator-5554', 'shell', 'input', 'swipe', '10', '20', '30', '40', '250']);
|
|
148
|
+
} finally {
|
|
149
|
+
process.env.PATH = previousPath;
|
|
150
|
+
if (previousArgsFile === undefined) {
|
|
151
|
+
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
|
|
152
|
+
} else {
|
|
153
|
+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
|
|
154
|
+
}
|
|
155
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
@@ -333,6 +333,29 @@ export async function pressAndroid(device: DeviceInfo, x: number, y: number): Pr
|
|
|
333
333
|
await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
export async function swipeAndroid(
|
|
337
|
+
device: DeviceInfo,
|
|
338
|
+
x1: number,
|
|
339
|
+
y1: number,
|
|
340
|
+
x2: number,
|
|
341
|
+
y2: number,
|
|
342
|
+
durationMs = 250,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
await runCmd(
|
|
345
|
+
'adb',
|
|
346
|
+
adbArgs(device, [
|
|
347
|
+
'shell',
|
|
348
|
+
'input',
|
|
349
|
+
'swipe',
|
|
350
|
+
String(x1),
|
|
351
|
+
String(y1),
|
|
352
|
+
String(x2),
|
|
353
|
+
String(y2),
|
|
354
|
+
String(durationMs),
|
|
355
|
+
]),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
336
359
|
export async function backAndroid(device: DeviceInfo): Promise<void> {
|
|
337
360
|
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '4']));
|
|
338
361
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import type { DeviceInfo } from '../../../utils/device.ts';
|
|
4
|
+
import {
|
|
5
|
+
assertSafeDerivedCleanup,
|
|
6
|
+
resolveRunnerBuildDestination,
|
|
7
|
+
resolveRunnerDestination,
|
|
8
|
+
resolveRunnerMaxConcurrentDestinationsFlag,
|
|
9
|
+
resolveRunnerSigningBuildSettings,
|
|
10
|
+
} from '../runner-client.ts';
|
|
11
|
+
|
|
12
|
+
const iosSimulator: DeviceInfo = {
|
|
13
|
+
platform: 'ios',
|
|
14
|
+
id: 'sim-1',
|
|
15
|
+
name: 'iPhone Simulator',
|
|
16
|
+
kind: 'simulator',
|
|
17
|
+
booted: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const iosDevice: DeviceInfo = {
|
|
21
|
+
platform: 'ios',
|
|
22
|
+
id: '00008110-000E12341234002E',
|
|
23
|
+
name: 'iPhone',
|
|
24
|
+
kind: 'device',
|
|
25
|
+
booted: true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
test('resolveRunnerDestination uses simulator destination for simulators', () => {
|
|
29
|
+
assert.equal(resolveRunnerDestination(iosSimulator), 'platform=iOS Simulator,id=sim-1');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('resolveRunnerDestination uses device destination for physical devices', () => {
|
|
33
|
+
assert.equal(
|
|
34
|
+
resolveRunnerDestination(iosDevice),
|
|
35
|
+
'platform=iOS,id=00008110-000E12341234002E',
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('resolveRunnerBuildDestination uses generic iOS destination for physical devices', () => {
|
|
40
|
+
assert.equal(resolveRunnerBuildDestination(iosDevice), 'generic/platform=iOS');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators', () => {
|
|
44
|
+
assert.equal(
|
|
45
|
+
resolveRunnerMaxConcurrentDestinationsFlag(iosSimulator),
|
|
46
|
+
'-maximum-concurrent-test-simulator-destinations',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('resolveRunnerMaxConcurrentDestinationsFlag uses device flag for physical devices', () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
resolveRunnerMaxConcurrentDestinationsFlag(iosDevice),
|
|
53
|
+
'-maximum-concurrent-test-device-destinations',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('resolveRunnerSigningBuildSettings returns empty args without env overrides', () => {
|
|
58
|
+
assert.deepEqual(resolveRunnerSigningBuildSettings({}), []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('resolveRunnerSigningBuildSettings enables automatic signing for device builds without forcing identity', () => {
|
|
62
|
+
assert.deepEqual(resolveRunnerSigningBuildSettings({}, true), [
|
|
63
|
+
'CODE_SIGN_STYLE=Automatic',
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('resolveRunnerSigningBuildSettings ignores device signing overrides for simulator builds', () => {
|
|
68
|
+
assert.deepEqual(resolveRunnerSigningBuildSettings({
|
|
69
|
+
AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345',
|
|
70
|
+
AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development',
|
|
71
|
+
AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile',
|
|
72
|
+
}, false), []);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('resolveRunnerSigningBuildSettings applies optional overrides when provided', () => {
|
|
76
|
+
const settings = resolveRunnerSigningBuildSettings({
|
|
77
|
+
AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345',
|
|
78
|
+
AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development',
|
|
79
|
+
AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile',
|
|
80
|
+
}, true);
|
|
81
|
+
assert.deepEqual(settings, [
|
|
82
|
+
'CODE_SIGN_STYLE=Automatic',
|
|
83
|
+
'DEVELOPMENT_TEAM=ABCDE12345',
|
|
84
|
+
'CODE_SIGN_IDENTITY=Apple Development',
|
|
85
|
+
'PROVISIONING_PROFILE_SPECIFIER=My Profile',
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('assertSafeDerivedCleanup allows cleaning when no override is set', () => {
|
|
90
|
+
assert.doesNotThrow(() => {
|
|
91
|
+
assertSafeDerivedCleanup('/tmp/derived', {});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('assertSafeDerivedCleanup rejects cleaning override path by default', () => {
|
|
96
|
+
assert.throws(
|
|
97
|
+
() => {
|
|
98
|
+
assertSafeDerivedCleanup('/tmp/custom', {
|
|
99
|
+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: '/tmp/custom',
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
/Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically/,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('assertSafeDerivedCleanup allows cleaning override path with explicit opt-in', () => {
|
|
107
|
+
assert.doesNotThrow(() => {
|
|
108
|
+
assertSafeDerivedCleanup('/tmp/custom', {
|
|
109
|
+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: '/tmp/custom',
|
|
110
|
+
AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN: '1',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
1
4
|
import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
2
5
|
import { AppError } from '../../utils/errors.ts';
|
|
3
6
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
@@ -38,26 +41,45 @@ export async function listIosDevices(): Promise<DeviceInfo[]> {
|
|
|
38
41
|
throw new AppError('COMMAND_FAILED', 'Failed to parse simctl devices JSON', undefined, err);
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
let jsonPath: string | null = null;
|
|
45
|
+
try {
|
|
46
|
+
jsonPath = path.join(
|
|
47
|
+
os.tmpdir(),
|
|
48
|
+
`agent-device-devicectl-${process.pid}-${Date.now()}.json`,
|
|
49
|
+
);
|
|
50
|
+
await runCmd('xcrun', ['devicectl', 'list', 'devices', '--json-output', jsonPath]);
|
|
51
|
+
const jsonText = await fs.readFile(jsonPath, 'utf8');
|
|
52
|
+
const payload = JSON.parse(jsonText) as {
|
|
53
|
+
result?: {
|
|
54
|
+
devices?: Array<{
|
|
55
|
+
identifier?: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
hardwareProperties?: { platform?: string; udid?: string };
|
|
58
|
+
deviceProperties?: { name?: string };
|
|
59
|
+
connectionProperties?: { tunnelState?: string };
|
|
60
|
+
}>;
|
|
47
61
|
};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
};
|
|
63
|
+
for (const device of payload.result?.devices ?? []) {
|
|
64
|
+
const platform = device.hardwareProperties?.platform ?? '';
|
|
65
|
+
if (platform.toLowerCase().includes('ios')) {
|
|
66
|
+
const id = device.hardwareProperties?.udid ?? device.identifier ?? '';
|
|
67
|
+
const name = device.name ?? device.deviceProperties?.name ?? id;
|
|
68
|
+
if (!id) continue;
|
|
69
|
+
devices.push({
|
|
70
|
+
platform: 'ios',
|
|
71
|
+
id,
|
|
72
|
+
name,
|
|
73
|
+
kind: 'device',
|
|
74
|
+
booted: true,
|
|
75
|
+
});
|
|
58
76
|
}
|
|
59
|
-
}
|
|
60
|
-
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore devicectl failures; simulators are still supported.
|
|
80
|
+
} finally {
|
|
81
|
+
if (jsonPath) {
|
|
82
|
+
await fs.rm(jsonPath, { force: true }).catch(() => {});
|
|
61
83
|
}
|
|
62
84
|
}
|
|
63
85
|
|
|
@@ -54,7 +54,7 @@ export async function openIosApp(
|
|
|
54
54
|
const deepLinkTarget = app.trim();
|
|
55
55
|
if (isDeepLinkTarget(deepLinkTarget)) {
|
|
56
56
|
if (device.kind !== 'simulator') {
|
|
57
|
-
throw new AppError('UNSUPPORTED_OPERATION', 'Deep link open is only supported on iOS simulators
|
|
57
|
+
throw new AppError('UNSUPPORTED_OPERATION', 'Deep link open is only supported on iOS simulators');
|
|
58
58
|
}
|
|
59
59
|
await ensureBootedSimulator(device);
|
|
60
60
|
await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
|
|
@@ -249,7 +249,7 @@ function ensureSimulator(device: DeviceInfo, command: string): void {
|
|
|
249
249
|
if (device.kind !== 'simulator') {
|
|
250
250
|
throw new AppError(
|
|
251
251
|
'UNSUPPORTED_OPERATION',
|
|
252
|
-
`${command} is only supported on iOS simulators
|
|
252
|
+
`${command} is only supported on iOS simulators`,
|
|
253
253
|
);
|
|
254
254
|
}
|
|
255
255
|
}
|