agent-device 0.3.0 → 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/src/274.js +1 -0
- package/dist/src/bin.js +29 -27
- package/dist/src/daemon.js +9 -8
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +4 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +149 -0
- 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/devices.ts +133 -41
- package/src/platforms/boot-diagnostics.ts +67 -0
- package/src/platforms/ios/index.ts +94 -2
- 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
|
@@ -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
|
+
}
|
|
@@ -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};
|