agent-device 0.2.6 → 0.3.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 +15 -0
- package/dist/bin/axsnapshot +0 -0
- package/dist/src/274.js +1 -0
- package/dist/src/bin.js +29 -27
- package/dist/src/daemon.js +9 -6
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +4 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +151 -2
- package/package.json +2 -2
- package/src/cli.ts +6 -0
- package/src/daemon-client.ts +1 -24
- package/src/daemon.ts +1 -24
- package/src/platforms/__tests__/boot-diagnostics.test.ts +30 -0
- package/src/platforms/android/__tests__/index.test.ts +74 -0
- package/src/platforms/android/devices.ts +133 -41
- package/src/platforms/android/index.ts +47 -293
- package/src/platforms/android/ui-hierarchy.ts +312 -0
- package/src/platforms/boot-diagnostics.ts +67 -0
- package/src/platforms/ios/index.ts +94 -2
- package/src/platforms/ios/runner-client.ts +115 -55
- package/src/utils/__tests__/retry.test.ts +27 -0
- package/src/utils/args.ts +7 -1
- package/src/utils/retry.ts +73 -13
- package/src/utils/version.ts +26 -0
- package/dist/src/861.js +0 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { asAppError } from '../utils/errors.ts';
|
|
2
|
+
|
|
3
|
+
export type BootFailureReason =
|
|
4
|
+
| 'BOOT_TIMEOUT'
|
|
5
|
+
| 'DEVICE_UNAVAILABLE'
|
|
6
|
+
| 'DEVICE_OFFLINE'
|
|
7
|
+
| 'PERMISSION_DENIED'
|
|
8
|
+
| 'TOOL_MISSING'
|
|
9
|
+
| 'BOOT_COMMAND_FAILED'
|
|
10
|
+
| 'UNKNOWN';
|
|
11
|
+
|
|
12
|
+
export function classifyBootFailure(input: {
|
|
13
|
+
error?: unknown;
|
|
14
|
+
message?: string;
|
|
15
|
+
stdout?: string;
|
|
16
|
+
stderr?: string;
|
|
17
|
+
}): BootFailureReason {
|
|
18
|
+
const appErr = input.error ? asAppError(input.error) : null;
|
|
19
|
+
if (appErr?.code === 'TOOL_MISSING') return 'TOOL_MISSING';
|
|
20
|
+
const details = (appErr?.details ?? {}) as Record<string, unknown>;
|
|
21
|
+
const detailMessage = typeof details.message === 'string' ? details.message : undefined;
|
|
22
|
+
const detailStdout = typeof details.stdout === 'string' ? details.stdout : undefined;
|
|
23
|
+
const detailStderr = typeof details.stderr === 'string' ? details.stderr : undefined;
|
|
24
|
+
const nestedBoot = details.boot && typeof details.boot === 'object'
|
|
25
|
+
? (details.boot as Record<string, unknown>)
|
|
26
|
+
: null;
|
|
27
|
+
const nestedBootstatus = details.bootstatus && typeof details.bootstatus === 'object'
|
|
28
|
+
? (details.bootstatus as Record<string, unknown>)
|
|
29
|
+
: null;
|
|
30
|
+
|
|
31
|
+
const haystack = [
|
|
32
|
+
input.message,
|
|
33
|
+
appErr?.message,
|
|
34
|
+
input.stdout,
|
|
35
|
+
input.stderr,
|
|
36
|
+
detailMessage,
|
|
37
|
+
detailStdout,
|
|
38
|
+
detailStderr,
|
|
39
|
+
typeof nestedBoot?.stdout === 'string' ? nestedBoot.stdout : undefined,
|
|
40
|
+
typeof nestedBoot?.stderr === 'string' ? nestedBoot.stderr : undefined,
|
|
41
|
+
typeof nestedBootstatus?.stdout === 'string' ? nestedBootstatus.stdout : undefined,
|
|
42
|
+
typeof nestedBootstatus?.stderr === 'string' ? nestedBootstatus.stderr : undefined,
|
|
43
|
+
]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join('\n')
|
|
46
|
+
.toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (haystack.includes('timed out') || haystack.includes('timeout')) return 'BOOT_TIMEOUT';
|
|
49
|
+
if (
|
|
50
|
+
haystack.includes('device not found') ||
|
|
51
|
+
haystack.includes('no devices') ||
|
|
52
|
+
haystack.includes('unable to locate device') ||
|
|
53
|
+
haystack.includes('invalid device')
|
|
54
|
+
) {
|
|
55
|
+
return 'DEVICE_UNAVAILABLE';
|
|
56
|
+
}
|
|
57
|
+
if (haystack.includes('offline')) return 'DEVICE_OFFLINE';
|
|
58
|
+
if (
|
|
59
|
+
haystack.includes('permission denied') ||
|
|
60
|
+
haystack.includes('not authorized') ||
|
|
61
|
+
haystack.includes('unauthorized')
|
|
62
|
+
) {
|
|
63
|
+
return 'PERMISSION_DENIED';
|
|
64
|
+
}
|
|
65
|
+
if (appErr?.code === 'COMMAND_FAILED' || haystack.length > 0) return 'BOOT_COMMAND_FAILED';
|
|
66
|
+
return 'UNKNOWN';
|
|
67
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { runCmd } from '../../utils/exec.ts';
|
|
2
|
+
import type { ExecResult } from '../../utils/exec.ts';
|
|
2
3
|
import { AppError } from '../../utils/errors.ts';
|
|
3
4
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
5
|
+
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
|
|
6
|
+
import { classifyBootFailure } from '../boot-diagnostics.ts';
|
|
4
7
|
|
|
5
8
|
const ALIASES: Record<string, string> = {
|
|
6
9
|
settings: 'com.apple.Preferences',
|
|
7
10
|
};
|
|
8
11
|
|
|
12
|
+
const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(process.env.AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS, 120_000, 5_000);
|
|
13
|
+
|
|
9
14
|
export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
|
|
10
15
|
const trimmed = app.trim();
|
|
11
16
|
if (trimmed.includes('.')) return trimmed;
|
|
@@ -207,8 +212,88 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
|
207
212
|
if (device.kind !== 'simulator') return;
|
|
208
213
|
const state = await getSimulatorState(device.id);
|
|
209
214
|
if (state === 'Booted') return;
|
|
210
|
-
|
|
211
|
-
|
|
215
|
+
const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
|
|
216
|
+
let bootResult: ExecResult | undefined;
|
|
217
|
+
let bootStatusResult: ExecResult | undefined;
|
|
218
|
+
try {
|
|
219
|
+
await retryWithPolicy(
|
|
220
|
+
async () => {
|
|
221
|
+
const currentState = await getSimulatorState(device.id);
|
|
222
|
+
if (currentState === 'Booted') return;
|
|
223
|
+
bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], { allowFailure: true });
|
|
224
|
+
const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase();
|
|
225
|
+
const bootAlreadyDone =
|
|
226
|
+
bootOutput.includes('already booted') || bootOutput.includes('current state: booted');
|
|
227
|
+
if (bootResult.exitCode !== 0 && !bootAlreadyDone) {
|
|
228
|
+
throw new AppError('COMMAND_FAILED', 'simctl boot failed', {
|
|
229
|
+
stdout: bootResult.stdout,
|
|
230
|
+
stderr: bootResult.stderr,
|
|
231
|
+
exitCode: bootResult.exitCode,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], {
|
|
235
|
+
allowFailure: true,
|
|
236
|
+
});
|
|
237
|
+
if (bootStatusResult.exitCode !== 0) {
|
|
238
|
+
throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', {
|
|
239
|
+
stdout: bootStatusResult.stdout,
|
|
240
|
+
stderr: bootStatusResult.stderr,
|
|
241
|
+
exitCode: bootStatusResult.exitCode,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const nextState = await getSimulatorState(device.id);
|
|
245
|
+
if (nextState !== 'Booted') {
|
|
246
|
+
throw new AppError('COMMAND_FAILED', 'Simulator is still booting', {
|
|
247
|
+
state: nextState,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
maxAttempts: 3,
|
|
253
|
+
baseDelayMs: 500,
|
|
254
|
+
maxDelayMs: 2000,
|
|
255
|
+
jitter: 0.2,
|
|
256
|
+
shouldRetry: (error) => {
|
|
257
|
+
const reason = classifyBootFailure({
|
|
258
|
+
error,
|
|
259
|
+
stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
|
|
260
|
+
stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
|
|
261
|
+
});
|
|
262
|
+
return reason !== 'PERMISSION_DENIED' && reason !== 'TOOL_MISSING';
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{ deadline },
|
|
266
|
+
);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const bootStdout = bootResult?.stdout;
|
|
269
|
+
const bootStderr = bootResult?.stderr;
|
|
270
|
+
const bootExitCode = bootResult?.exitCode;
|
|
271
|
+
const bootstatusStdout = bootStatusResult?.stdout;
|
|
272
|
+
const bootstatusStderr = bootStatusResult?.stderr;
|
|
273
|
+
const bootstatusExitCode = bootStatusResult?.exitCode;
|
|
274
|
+
const reason = classifyBootFailure({
|
|
275
|
+
error,
|
|
276
|
+
stdout: bootstatusStdout ?? bootStdout,
|
|
277
|
+
stderr: bootstatusStderr ?? bootStderr,
|
|
278
|
+
});
|
|
279
|
+
throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', {
|
|
280
|
+
platform: 'ios',
|
|
281
|
+
deviceId: device.id,
|
|
282
|
+
timeoutMs: IOS_BOOT_TIMEOUT_MS,
|
|
283
|
+
elapsedMs: deadline.elapsedMs(),
|
|
284
|
+
reason,
|
|
285
|
+
boot: bootResult
|
|
286
|
+
? { exitCode: bootExitCode, stdout: bootStdout, stderr: bootStderr }
|
|
287
|
+
: undefined,
|
|
288
|
+
bootstatus: bootStatusResult
|
|
289
|
+
? {
|
|
290
|
+
exitCode: bootstatusExitCode,
|
|
291
|
+
stdout: bootstatusStdout,
|
|
292
|
+
stderr: bootstatusStderr,
|
|
293
|
+
}
|
|
294
|
+
: undefined,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
212
297
|
}
|
|
213
298
|
|
|
214
299
|
async function getSimulatorState(udid: string): Promise<string | null> {
|
|
@@ -229,3 +314,10 @@ async function getSimulatorState(udid: string): Promise<string | null> {
|
|
|
229
314
|
}
|
|
230
315
|
return null;
|
|
231
316
|
}
|
|
317
|
+
|
|
318
|
+
function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
|
|
319
|
+
if (!raw) return fallback;
|
|
320
|
+
const parsed = Number(raw);
|
|
321
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
322
|
+
return Math.max(min, Math.floor(parsed));
|
|
323
|
+
}
|
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { AppError } from '../../utils/errors.ts';
|
|
6
|
-
import { runCmd, runCmdStreaming, type ExecResult } from '../../utils/exec.ts';
|
|
6
|
+
import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
|
|
7
7
|
import { withRetry } from '../../utils/retry.ts';
|
|
8
8
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
9
9
|
import net from 'node:net';
|
|
@@ -46,9 +46,30 @@ export type RunnerSession = {
|
|
|
46
46
|
xctestrunPath: string;
|
|
47
47
|
jsonPath: string;
|
|
48
48
|
testPromise: Promise<ExecResult>;
|
|
49
|
+
child: ExecBackgroundResult['child'];
|
|
50
|
+
ready: boolean;
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
const runnerSessions = new Map<string, RunnerSession>();
|
|
54
|
+
const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
|
|
55
|
+
process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
|
|
56
|
+
120_000,
|
|
57
|
+
5_000,
|
|
58
|
+
);
|
|
59
|
+
const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
|
|
60
|
+
process.env.AGENT_DEVICE_RUNNER_COMMAND_TIMEOUT_MS,
|
|
61
|
+
15_000,
|
|
62
|
+
1_000,
|
|
63
|
+
);
|
|
64
|
+
const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
|
|
65
|
+
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
66
|
+
|
|
67
|
+
function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
|
|
68
|
+
if (!raw) return fallback;
|
|
69
|
+
const parsed = Number(raw);
|
|
70
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
71
|
+
return Math.max(min, Math.floor(parsed));
|
|
72
|
+
}
|
|
52
73
|
|
|
53
74
|
export type RunnerSnapshotNode = {
|
|
54
75
|
index: number;
|
|
@@ -87,29 +108,14 @@ async function executeRunnerCommand(
|
|
|
87
108
|
|
|
88
109
|
try {
|
|
89
110
|
const session = await ensureRunnerSession(device, options);
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (!json.ok) {
|
|
101
|
-
throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
|
|
102
|
-
runner: json,
|
|
103
|
-
xcodebuild: {
|
|
104
|
-
exitCode: 1,
|
|
105
|
-
stdout: '',
|
|
106
|
-
stderr: '',
|
|
107
|
-
},
|
|
108
|
-
logPath: options.logPath,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return json.data ?? {};
|
|
111
|
+
const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS;
|
|
112
|
+
return await executeRunnerCommandWithSession(
|
|
113
|
+
device,
|
|
114
|
+
session,
|
|
115
|
+
command,
|
|
116
|
+
options.logPath,
|
|
117
|
+
timeoutMs,
|
|
118
|
+
);
|
|
113
119
|
} catch (err) {
|
|
114
120
|
const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
|
|
115
121
|
if (
|
|
@@ -119,46 +125,79 @@ async function executeRunnerCommand(
|
|
|
119
125
|
) {
|
|
120
126
|
await stopIosRunnerSession(device.id);
|
|
121
127
|
const session = await ensureRunnerSession(device, options);
|
|
122
|
-
const response = await waitForRunner(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!json.ok) {
|
|
131
|
-
throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
|
|
132
|
-
runner: json,
|
|
133
|
-
xcodebuild: {
|
|
134
|
-
exitCode: 1,
|
|
135
|
-
stdout: '',
|
|
136
|
-
stderr: '',
|
|
137
|
-
},
|
|
138
|
-
logPath: options.logPath,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
return json.data ?? {};
|
|
128
|
+
const response = await waitForRunner(
|
|
129
|
+
session.device,
|
|
130
|
+
session.port,
|
|
131
|
+
command,
|
|
132
|
+
options.logPath,
|
|
133
|
+
RUNNER_STARTUP_TIMEOUT_MS,
|
|
134
|
+
);
|
|
135
|
+
return await parseRunnerResponse(response, session, options.logPath);
|
|
142
136
|
}
|
|
143
137
|
throw err;
|
|
144
138
|
}
|
|
145
139
|
}
|
|
146
140
|
|
|
141
|
+
async function executeRunnerCommandWithSession(
|
|
142
|
+
device: DeviceInfo,
|
|
143
|
+
session: RunnerSession,
|
|
144
|
+
command: RunnerCommand,
|
|
145
|
+
logPath: string | undefined,
|
|
146
|
+
timeoutMs: number,
|
|
147
|
+
): Promise<Record<string, unknown>> {
|
|
148
|
+
const response = await waitForRunner(device, session.port, command, logPath, timeoutMs);
|
|
149
|
+
return await parseRunnerResponse(response, session, logPath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function parseRunnerResponse(
|
|
153
|
+
response: Response,
|
|
154
|
+
session: RunnerSession,
|
|
155
|
+
logPath?: string,
|
|
156
|
+
): Promise<Record<string, unknown>> {
|
|
157
|
+
const text = await response.text();
|
|
158
|
+
let json: any = {};
|
|
159
|
+
try {
|
|
160
|
+
json = JSON.parse(text);
|
|
161
|
+
} catch {
|
|
162
|
+
throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
|
|
163
|
+
}
|
|
164
|
+
if (!json.ok) {
|
|
165
|
+
throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
|
|
166
|
+
runner: json,
|
|
167
|
+
xcodebuild: {
|
|
168
|
+
exitCode: 1,
|
|
169
|
+
stdout: '',
|
|
170
|
+
stderr: '',
|
|
171
|
+
},
|
|
172
|
+
logPath,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
session.ready = true;
|
|
176
|
+
return json.data ?? {};
|
|
177
|
+
}
|
|
178
|
+
|
|
147
179
|
export async function stopIosRunnerSession(deviceId: string): Promise<void> {
|
|
148
180
|
const session = runnerSessions.get(deviceId);
|
|
149
181
|
if (!session) return;
|
|
150
182
|
try {
|
|
151
183
|
await waitForRunner(session.device, session.port, {
|
|
152
184
|
command: 'shutdown',
|
|
153
|
-
} as RunnerCommand);
|
|
185
|
+
} as RunnerCommand, undefined, RUNNER_SHUTDOWN_TIMEOUT_MS);
|
|
154
186
|
} catch {
|
|
155
|
-
//
|
|
187
|
+
// Runner not responsive — send SIGTERM so we don't hang on testPromise
|
|
188
|
+
await killRunnerProcessTree(session.child.pid, 'SIGTERM');
|
|
156
189
|
}
|
|
157
190
|
try {
|
|
158
|
-
|
|
191
|
+
// Bound the wait so we never hang if xcodebuild refuses to exit
|
|
192
|
+
await Promise.race([
|
|
193
|
+
session.testPromise,
|
|
194
|
+
new Promise<void>((resolve) => setTimeout(resolve, RUNNER_STOP_WAIT_TIMEOUT_MS)),
|
|
195
|
+
]);
|
|
159
196
|
} catch {
|
|
160
197
|
// ignore
|
|
161
198
|
}
|
|
199
|
+
// Force-kill if still alive (harmless if already exited)
|
|
200
|
+
await killRunnerProcessTree(session.child.pid, 'SIGKILL');
|
|
162
201
|
cleanupTempFile(session.xctestrunPath);
|
|
163
202
|
cleanupTempFile(session.jsonPath);
|
|
164
203
|
runnerSessions.delete(deviceId);
|
|
@@ -183,7 +222,7 @@ async function ensureRunnerSession(
|
|
|
183
222
|
{ AGENT_DEVICE_RUNNER_PORT: String(port) },
|
|
184
223
|
`session-${device.id}-${port}`,
|
|
185
224
|
);
|
|
186
|
-
const testPromise =
|
|
225
|
+
const { child, wait: testPromise } = runCmdBackground(
|
|
187
226
|
'xcodebuild',
|
|
188
227
|
[
|
|
189
228
|
'test-without-building',
|
|
@@ -201,16 +240,16 @@ async function ensureRunnerSession(
|
|
|
201
240
|
`platform=iOS Simulator,id=${device.id}`,
|
|
202
241
|
],
|
|
203
242
|
{
|
|
204
|
-
onStdoutChunk: (chunk) => {
|
|
205
|
-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
206
|
-
},
|
|
207
|
-
onStderrChunk: (chunk) => {
|
|
208
|
-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
209
|
-
},
|
|
210
243
|
allowFailure: true,
|
|
211
244
|
env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
|
|
212
245
|
},
|
|
213
246
|
);
|
|
247
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
248
|
+
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
249
|
+
});
|
|
250
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
251
|
+
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
252
|
+
});
|
|
214
253
|
|
|
215
254
|
const session: RunnerSession = {
|
|
216
255
|
device,
|
|
@@ -219,11 +258,31 @@ async function ensureRunnerSession(
|
|
|
219
258
|
xctestrunPath,
|
|
220
259
|
jsonPath,
|
|
221
260
|
testPromise,
|
|
261
|
+
child,
|
|
262
|
+
ready: false,
|
|
222
263
|
};
|
|
223
264
|
runnerSessions.set(device.id, session);
|
|
224
265
|
return session;
|
|
225
266
|
}
|
|
226
267
|
|
|
268
|
+
async function killRunnerProcessTree(
|
|
269
|
+
pid: number | undefined,
|
|
270
|
+
signal: 'SIGTERM' | 'SIGKILL',
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
if (!pid || pid <= 0) return;
|
|
273
|
+
try {
|
|
274
|
+
process.kill(pid, signal);
|
|
275
|
+
} catch {
|
|
276
|
+
// ignore
|
|
277
|
+
}
|
|
278
|
+
const pkillSignal = signal === 'SIGTERM' ? 'TERM' : 'KILL';
|
|
279
|
+
try {
|
|
280
|
+
await runCmd('pkill', [`-${pkillSignal}`, '-P', String(pid)], { allowFailure: true });
|
|
281
|
+
} catch {
|
|
282
|
+
// ignore
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
227
286
|
|
|
228
287
|
async function ensureXctestrun(
|
|
229
288
|
udid: string,
|
|
@@ -364,10 +423,11 @@ async function waitForRunner(
|
|
|
364
423
|
port: number,
|
|
365
424
|
command: RunnerCommand,
|
|
366
425
|
logPath?: string,
|
|
426
|
+
timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
|
|
367
427
|
): Promise<Response> {
|
|
368
428
|
const start = Date.now();
|
|
369
429
|
let lastError: unknown = null;
|
|
370
|
-
while (Date.now() - start <
|
|
430
|
+
while (Date.now() - start < timeoutMs) {
|
|
371
431
|
try {
|
|
372
432
|
const response = await fetch(`http://127.0.0.1:${port}/command`, {
|
|
373
433
|
method: 'POST',
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { Deadline, retryWithPolicy } from '../retry.ts';
|
|
4
|
+
|
|
5
|
+
test('Deadline tracks remaining and expiration', async () => {
|
|
6
|
+
const deadline = Deadline.fromTimeoutMs(25);
|
|
7
|
+
assert.equal(deadline.isExpired(), false);
|
|
8
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
9
|
+
assert.equal(deadline.isExpired(), true);
|
|
10
|
+
assert.equal(deadline.remainingMs(), 0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('retryWithPolicy retries until success', async () => {
|
|
14
|
+
let attempts = 0;
|
|
15
|
+
const result = await retryWithPolicy(
|
|
16
|
+
async () => {
|
|
17
|
+
attempts += 1;
|
|
18
|
+
if (attempts < 3) {
|
|
19
|
+
throw new Error('transient');
|
|
20
|
+
}
|
|
21
|
+
return 'ok';
|
|
22
|
+
},
|
|
23
|
+
{ maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 },
|
|
24
|
+
);
|
|
25
|
+
assert.equal(result, 'ok');
|
|
26
|
+
assert.equal(attempts, 3);
|
|
27
|
+
});
|
package/src/utils/args.ts
CHANGED
|
@@ -25,11 +25,12 @@ export type ParsedArgs = {
|
|
|
25
25
|
noRecord?: boolean;
|
|
26
26
|
replayUpdate?: boolean;
|
|
27
27
|
help: boolean;
|
|
28
|
+
version: boolean;
|
|
28
29
|
};
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
export function parseArgs(argv: string[]): ParsedArgs {
|
|
32
|
-
const flags: ParsedArgs['flags'] = { json: false, help: false };
|
|
33
|
+
const flags: ParsedArgs['flags'] = { json: false, help: false, version: false };
|
|
33
34
|
const positionals: string[] = [];
|
|
34
35
|
|
|
35
36
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -42,6 +43,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
42
43
|
flags.help = true;
|
|
43
44
|
continue;
|
|
44
45
|
}
|
|
46
|
+
if (arg === '--version' || arg === '-V') {
|
|
47
|
+
flags.version = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
45
50
|
if (arg === '--verbose' || arg === '-v') {
|
|
46
51
|
flags.verbose = true;
|
|
47
52
|
continue;
|
|
@@ -229,5 +234,6 @@ Flags:
|
|
|
229
234
|
--update, -u Replay: update selectors and rewrite replay file in place
|
|
230
235
|
--user-installed Apps: list user-installed packages (Android only)
|
|
231
236
|
--all Apps: list all packages (Android only)
|
|
237
|
+
--version, -V Print version and exit
|
|
232
238
|
`;
|
|
233
239
|
}
|
package/src/utils/retry.ts
CHANGED
|
@@ -8,6 +8,20 @@ type RetryOptions = {
|
|
|
8
8
|
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
export type RetryPolicy = {
|
|
12
|
+
maxAttempts: number;
|
|
13
|
+
baseDelayMs: number;
|
|
14
|
+
maxDelayMs: number;
|
|
15
|
+
jitter: number;
|
|
16
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RetryAttemptContext = {
|
|
20
|
+
attempt: number;
|
|
21
|
+
maxAttempts: number;
|
|
22
|
+
deadline?: Deadline;
|
|
23
|
+
};
|
|
24
|
+
|
|
11
25
|
const defaultOptions: Required<Pick<RetryOptions, 'attempts' | 'baseDelayMs' | 'maxDelayMs' | 'jitter'>> = {
|
|
12
26
|
attempts: 3,
|
|
13
27
|
baseDelayMs: 200,
|
|
@@ -15,30 +29,76 @@ const defaultOptions: Required<Pick<RetryOptions, 'attempts' | 'baseDelayMs' | '
|
|
|
15
29
|
jitter: 0.2,
|
|
16
30
|
};
|
|
17
31
|
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
32
|
+
export class Deadline {
|
|
33
|
+
private readonly startedAtMs: number;
|
|
34
|
+
private readonly expiresAtMs: number;
|
|
35
|
+
|
|
36
|
+
private constructor(startedAtMs: number, timeoutMs: number) {
|
|
37
|
+
this.startedAtMs = startedAtMs;
|
|
38
|
+
this.expiresAtMs = startedAtMs + Math.max(0, timeoutMs);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static fromTimeoutMs(timeoutMs: number, nowMs = Date.now()): Deadline {
|
|
42
|
+
return new Deadline(nowMs, timeoutMs);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
remainingMs(nowMs = Date.now()): number {
|
|
46
|
+
return Math.max(0, this.expiresAtMs - nowMs);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
elapsedMs(nowMs = Date.now()): number {
|
|
50
|
+
return Math.max(0, nowMs - this.startedAtMs);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
isExpired(nowMs = Date.now()): boolean {
|
|
54
|
+
return this.remainingMs(nowMs) <= 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function retryWithPolicy<T>(
|
|
59
|
+
fn: (context: RetryAttemptContext) => Promise<T>,
|
|
60
|
+
policy: Partial<RetryPolicy> = {},
|
|
61
|
+
options: { deadline?: Deadline } = {},
|
|
21
62
|
): Promise<T> {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
63
|
+
const merged: RetryPolicy = {
|
|
64
|
+
maxAttempts: policy.maxAttempts ?? defaultOptions.attempts,
|
|
65
|
+
baseDelayMs: policy.baseDelayMs ?? defaultOptions.baseDelayMs,
|
|
66
|
+
maxDelayMs: policy.maxDelayMs ?? defaultOptions.maxDelayMs,
|
|
67
|
+
jitter: policy.jitter ?? defaultOptions.jitter,
|
|
68
|
+
shouldRetry: policy.shouldRetry,
|
|
69
|
+
};
|
|
26
70
|
let lastError: unknown;
|
|
27
|
-
for (let attempt = 1; attempt <=
|
|
71
|
+
for (let attempt = 1; attempt <= merged.maxAttempts; attempt += 1) {
|
|
72
|
+
if (options.deadline?.isExpired() && attempt > 1) break;
|
|
28
73
|
try {
|
|
29
|
-
return await fn();
|
|
74
|
+
return await fn({ attempt, maxAttempts: merged.maxAttempts, deadline: options.deadline });
|
|
30
75
|
} catch (err) {
|
|
31
76
|
lastError = err;
|
|
32
|
-
if (attempt >=
|
|
33
|
-
if (
|
|
34
|
-
const delay = computeDelay(baseDelayMs, maxDelayMs, jitter, attempt);
|
|
35
|
-
|
|
77
|
+
if (attempt >= merged.maxAttempts) break;
|
|
78
|
+
if (merged.shouldRetry && !merged.shouldRetry(err, attempt)) break;
|
|
79
|
+
const delay = computeDelay(merged.baseDelayMs, merged.maxDelayMs, merged.jitter, attempt);
|
|
80
|
+
const boundedDelay = options.deadline ? Math.min(delay, options.deadline.remainingMs()) : delay;
|
|
81
|
+
if (boundedDelay <= 0) break;
|
|
82
|
+
await sleep(boundedDelay);
|
|
36
83
|
}
|
|
37
84
|
}
|
|
38
85
|
if (lastError) throw lastError;
|
|
39
86
|
throw new AppError('COMMAND_FAILED', 'retry failed');
|
|
40
87
|
}
|
|
41
88
|
|
|
89
|
+
export async function withRetry<T>(
|
|
90
|
+
fn: () => Promise<T>,
|
|
91
|
+
options: RetryOptions = {},
|
|
92
|
+
): Promise<T> {
|
|
93
|
+
return retryWithPolicy(() => fn(), {
|
|
94
|
+
maxAttempts: options.attempts,
|
|
95
|
+
baseDelayMs: options.baseDelayMs,
|
|
96
|
+
maxDelayMs: options.maxDelayMs,
|
|
97
|
+
jitter: options.jitter,
|
|
98
|
+
shouldRetry: options.shouldRetry,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
42
102
|
function computeDelay(base: number, max: number, jitter: number, attempt: number): number {
|
|
43
103
|
const exp = Math.min(max, base * 2 ** (attempt - 1));
|
|
44
104
|
const jitterAmount = exp * jitter;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
export function readVersion(): string {
|
|
6
|
+
try {
|
|
7
|
+
const root = findProjectRoot();
|
|
8
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
|
9
|
+
version?: string;
|
|
10
|
+
};
|
|
11
|
+
return pkg.version ?? '0.0.0';
|
|
12
|
+
} catch {
|
|
13
|
+
return '0.0.0';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function findProjectRoot(): string {
|
|
18
|
+
const start = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
let current = start;
|
|
20
|
+
for (let i = 0; i < 6; i += 1) {
|
|
21
|
+
const pkgPath = path.join(current, 'package.json');
|
|
22
|
+
if (fs.existsSync(pkgPath)) return current;
|
|
23
|
+
current = path.dirname(current);
|
|
24
|
+
}
|
|
25
|
+
return start;
|
|
26
|
+
}
|
package/dist/src/861.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{spawn as e}from"node:child_process";function t(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}class n extends Error{constructor(e,n,o,r){super(n),t(this,"code",void 0),t(this,"details",void 0),t(this,"cause",void 0),this.code=e,this.details=o,this.cause=r}}function o(e){return e instanceof n?e:e instanceof Error?new n("UNKNOWN",e.message,void 0,e):new n("UNKNOWN","Unknown error",{err:e})}async function r(t,o,d={}){return new Promise((r,i)=>{let s=e(t,o,{cwd:d.cwd,env:d.env,stdio:["pipe","pipe","pipe"]}),u="",a=d.binaryStdout?Buffer.alloc(0):void 0,c="";d.binaryStdout||s.stdout.setEncoding("utf8"),s.stderr.setEncoding("utf8"),void 0!==d.stdin&&s.stdin.write(d.stdin),s.stdin.end(),s.stdout.on("data",e=>{d.binaryStdout?a=Buffer.concat([a??Buffer.alloc(0),Buffer.isBuffer(e)?e:Buffer.from(e)]):u+=e}),s.stderr.on("data",e=>{c+=e}),s.on("error",e=>{"ENOENT"===e.code?i(new n("TOOL_MISSING",`${t} not found in PATH`,{cmd:t},e)):i(new n("COMMAND_FAILED",`Failed to run ${t}`,{cmd:t,args:o},e))}),s.on("close",e=>{let s=e??1;0===s||d.allowFailure?r({stdout:u,stderr:c,exitCode:s,stdoutBuffer:a}):i(new n("COMMAND_FAILED",`${t} exited with code ${s}`,{cmd:t,args:o,stdout:u,stderr:c,exitCode:s}))})})}async function d(e){try{var t;let{shell:n,args:o}=(t=e,"win32"===process.platform?{shell:"cmd.exe",args:["/c","where",t]}:{shell:"bash",args:["-lc",`command -v ${t}`]}),d=await r(n,o,{allowFailure:!0});return 0===d.exitCode&&d.stdout.trim().length>0}catch{return!1}}function i(t,n,o={}){e(t,n,{cwd:o.cwd,env:o.env,stdio:"ignore",detached:!0}).unref()}async function s(t,o,r={}){return new Promise((d,i)=>{let s=e(t,o,{cwd:r.cwd,env:r.env,stdio:["pipe","pipe","pipe"]}),u="",a="",c=r.binaryStdout?Buffer.alloc(0):void 0;r.binaryStdout||s.stdout.setEncoding("utf8"),s.stderr.setEncoding("utf8"),void 0!==r.stdin&&s.stdin.write(r.stdin),s.stdin.end(),s.stdout.on("data",e=>{if(r.binaryStdout){c=Buffer.concat([c??Buffer.alloc(0),Buffer.isBuffer(e)?e:Buffer.from(e)]);return}let t=String(e);u+=t,r.onStdoutChunk?.(t)}),s.stderr.on("data",e=>{let t=String(e);a+=t,r.onStderrChunk?.(t)}),s.on("error",e=>{"ENOENT"===e.code?i(new n("TOOL_MISSING",`${t} not found in PATH`,{cmd:t},e)):i(new n("COMMAND_FAILED",`Failed to run ${t}`,{cmd:t,args:o},e))}),s.on("close",e=>{let s=e??1;0===s||r.allowFailure?d({stdout:u,stderr:a,exitCode:s,stdoutBuffer:c}):i(new n("COMMAND_FAILED",`${t} exited with code ${s}`,{cmd:t,args:o,stdout:u,stderr:a,exitCode:s}))})})}function u(t,o,r={}){let d=e(t,o,{cwd:r.cwd,env:r.env,stdio:["ignore","pipe","pipe"]}),i="",s="";d.stdout.setEncoding("utf8"),d.stderr.setEncoding("utf8"),d.stdout.on("data",e=>{i+=e}),d.stderr.on("data",e=>{s+=e});let a=new Promise((e,u)=>{d.on("error",e=>{"ENOENT"===e.code?u(new n("TOOL_MISSING",`${t} not found in PATH`,{cmd:t},e)):u(new n("COMMAND_FAILED",`Failed to run ${t}`,{cmd:t,args:o},e))}),d.on("close",d=>{let a=d??1;0===a||r.allowFailure?e({stdout:i,stderr:s,exitCode:a}):u(new n("COMMAND_FAILED",`${t} exited with code ${a}`,{cmd:t,args:o,stdout:i,stderr:s,exitCode:a}))})});return{child:d,wait:a}}export{fileURLToPath,pathToFileURL}from"node:url";export{default as node_net}from"node:net";export{default as node_fs,promises}from"node:fs";export{default as node_os}from"node:os";export{default as node_path}from"node:path";export{o as asAppError,n as errors_AppError,r as runCmd,u as runCmdBackground,i as runCmdDetached,s as runCmdStreaming,d as whichCmd};
|