agent-device 0.3.0 → 0.3.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 +26 -2
- package/dist/src/274.js +1 -0
- package/dist/src/bin.js +27 -22
- package/dist/src/daemon.js +15 -10
- 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/skills/agent-device/SKILL.md +8 -1
- package/src/cli.ts +13 -0
- package/src/core/__tests__/capabilities.test.ts +2 -0
- package/src/core/capabilities.ts +2 -0
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +5 -0
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +219 -0
- package/src/daemon/handlers/__tests__/session.test.ts +122 -0
- package/src/daemon/handlers/find.ts +23 -3
- package/src/daemon/handlers/session.ts +175 -10
- package/src/daemon-client.ts +1 -24
- package/src/daemon.ts +1 -24
- package/src/platforms/__tests__/boot-diagnostics.test.ts +59 -0
- package/src/platforms/android/__tests__/index.test.ts +17 -0
- package/src/platforms/android/devices.ts +167 -42
- package/src/platforms/android/index.ts +101 -14
- package/src/platforms/boot-diagnostics.ts +128 -0
- package/src/platforms/ios/index.ts +161 -2
- package/src/platforms/ios/runner-client.ts +19 -1
- package/src/utils/__tests__/exec.test.ts +16 -0
- package/src/utils/__tests__/finders.test.ts +34 -0
- package/src/utils/__tests__/retry.test.ts +44 -0
- package/src/utils/args.ts +9 -1
- package/src/utils/exec.ts +39 -0
- package/src/utils/finders.ts +27 -9
- package/src/utils/retry.ts +143 -13
- package/src/utils/version.ts +26 -0
- package/dist/src/861.js +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
|
|
2
|
-
import {
|
|
2
|
+
import { findBestMatchesByLocator, type FindLocator } from '../../utils/finders.ts';
|
|
3
3
|
import { attachRefs, centerOfRect, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
|
|
4
4
|
import { AppError } from '../../utils/errors.ts';
|
|
5
5
|
import type { DaemonRequest, DaemonResponse } from '../types.ts';
|
|
@@ -97,7 +97,7 @@ export async function handleFindCommands(params: {
|
|
|
97
97
|
const start = Date.now();
|
|
98
98
|
while (Date.now() - start < timeout) {
|
|
99
99
|
const { nodes } = await fetchNodes();
|
|
100
|
-
const match =
|
|
100
|
+
const match = findBestMatchesByLocator(nodes, locator, query, { requireRect: false }).matches[0];
|
|
101
101
|
if (match) {
|
|
102
102
|
if (session) {
|
|
103
103
|
sessionStore.recordAction(session, {
|
|
@@ -114,7 +114,27 @@ export async function handleFindCommands(params: {
|
|
|
114
114
|
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find wait timed out' } };
|
|
115
115
|
}
|
|
116
116
|
const { nodes } = await fetchNodes();
|
|
117
|
-
const
|
|
117
|
+
const bestMatches = findBestMatchesByLocator(nodes, locator, query, { requireRect: requiresRect });
|
|
118
|
+
if (requiresRect && bestMatches.matches.length > 1) {
|
|
119
|
+
const candidates = bestMatches.matches.slice(0, 8).map((candidate) => {
|
|
120
|
+
const label = extractNodeText(candidate) || candidate.label || candidate.identifier || candidate.type || '';
|
|
121
|
+
return `@${candidate.ref}${label ? `(${label})` : ''}`;
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: {
|
|
126
|
+
code: 'AMBIGUOUS_MATCH',
|
|
127
|
+
message: `find matched ${bestMatches.matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`,
|
|
128
|
+
details: {
|
|
129
|
+
locator,
|
|
130
|
+
query,
|
|
131
|
+
matches: bestMatches.matches.length,
|
|
132
|
+
candidates,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const node = bestMatches.matches[0] ?? null;
|
|
118
138
|
if (!node) {
|
|
119
139
|
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'find did not match any element' } };
|
|
120
140
|
}
|
|
@@ -14,6 +14,39 @@ import { pruneGroupNodes } from '../snapshot-processing.ts';
|
|
|
14
14
|
import { buildSelectorChainForNode, parseSelectorChain, resolveSelectorChain, splitSelectorFromArgs } from '../selectors.ts';
|
|
15
15
|
import { inferFillText, uniqueStrings } from '../action-utils.ts';
|
|
16
16
|
|
|
17
|
+
type ReinstallOps = {
|
|
18
|
+
ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>;
|
|
19
|
+
android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean {
|
|
23
|
+
return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function resolveCommandDevice(params: {
|
|
27
|
+
session: SessionState | undefined;
|
|
28
|
+
flags: DaemonRequest['flags'] | undefined;
|
|
29
|
+
ensureReadyFn: typeof ensureDeviceReady;
|
|
30
|
+
ensureReady?: boolean;
|
|
31
|
+
}): Promise<DeviceInfo> {
|
|
32
|
+
const device = params.session?.device ?? (await resolveTargetDevice(params.flags ?? {}));
|
|
33
|
+
if (params.ensureReady !== false) {
|
|
34
|
+
await params.ensureReadyFn(device);
|
|
35
|
+
}
|
|
36
|
+
return device;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const defaultReinstallOps: ReinstallOps = {
|
|
40
|
+
ios: async (device, app, appPath) => {
|
|
41
|
+
const { reinstallIosApp } = await import('../../platforms/ios/index.ts');
|
|
42
|
+
return await reinstallIosApp(device, app, appPath);
|
|
43
|
+
},
|
|
44
|
+
android: async (device, app, appPath) => {
|
|
45
|
+
const { reinstallAndroidApp } = await import('../../platforms/android/index.ts');
|
|
46
|
+
return await reinstallAndroidApp(device, app, appPath);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
17
50
|
export async function handleSessionCommands(params: {
|
|
18
51
|
req: DaemonRequest;
|
|
19
52
|
sessionName: string;
|
|
@@ -21,9 +54,21 @@ export async function handleSessionCommands(params: {
|
|
|
21
54
|
sessionStore: SessionStore;
|
|
22
55
|
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
|
|
23
56
|
dispatch?: typeof dispatchCommand;
|
|
57
|
+
ensureReady?: typeof ensureDeviceReady;
|
|
58
|
+
reinstallOps?: ReinstallOps;
|
|
24
59
|
}): Promise<DaemonResponse | null> {
|
|
25
|
-
const {
|
|
60
|
+
const {
|
|
61
|
+
req,
|
|
62
|
+
sessionName,
|
|
63
|
+
logPath,
|
|
64
|
+
sessionStore,
|
|
65
|
+
invoke,
|
|
66
|
+
dispatch: dispatchOverride,
|
|
67
|
+
ensureReady: ensureReadyOverride,
|
|
68
|
+
reinstallOps = defaultReinstallOps,
|
|
69
|
+
} = params;
|
|
26
70
|
const dispatch = dispatchOverride ?? dispatchCommand;
|
|
71
|
+
const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
|
|
27
72
|
const command = req.command;
|
|
28
73
|
|
|
29
74
|
if (command === 'session_list') {
|
|
@@ -72,7 +117,7 @@ export async function handleSessionCommands(params: {
|
|
|
72
117
|
if (command === 'apps') {
|
|
73
118
|
const session = sessionStore.get(sessionName);
|
|
74
119
|
const flags = req.flags ?? {};
|
|
75
|
-
if (!session && !flags
|
|
120
|
+
if (!session && !hasExplicitDeviceSelector(flags)) {
|
|
76
121
|
return {
|
|
77
122
|
ok: false,
|
|
78
123
|
error: {
|
|
@@ -81,8 +126,7 @@ export async function handleSessionCommands(params: {
|
|
|
81
126
|
},
|
|
82
127
|
};
|
|
83
128
|
}
|
|
84
|
-
const device = session
|
|
85
|
-
await ensureDeviceReady(device);
|
|
129
|
+
const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
|
|
86
130
|
if (!isCommandSupportedOnDevice('apps', device)) {
|
|
87
131
|
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
|
|
88
132
|
}
|
|
@@ -106,11 +150,39 @@ export async function handleSessionCommands(params: {
|
|
|
106
150
|
return { ok: true, data: { apps } };
|
|
107
151
|
}
|
|
108
152
|
|
|
109
|
-
if (command === '
|
|
153
|
+
if (command === 'boot') {
|
|
110
154
|
const session = sessionStore.get(sessionName);
|
|
111
155
|
const flags = req.flags ?? {};
|
|
156
|
+
if (!session && !hasExplicitDeviceSelector(flags)) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
error: {
|
|
160
|
+
code: 'INVALID_ARGS',
|
|
161
|
+
message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
112
165
|
const device = session?.device ?? (await resolveTargetDevice(flags));
|
|
113
|
-
|
|
166
|
+
if (!isCommandSupportedOnDevice('boot', device)) {
|
|
167
|
+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
|
|
168
|
+
}
|
|
169
|
+
await ensureReady(device);
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
data: {
|
|
173
|
+
platform: device.platform,
|
|
174
|
+
device: device.name,
|
|
175
|
+
id: device.id,
|
|
176
|
+
kind: device.kind,
|
|
177
|
+
booted: true,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (command === 'appstate') {
|
|
183
|
+
const session = sessionStore.get(sessionName);
|
|
184
|
+
const flags = req.flags ?? {};
|
|
185
|
+
const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
|
|
114
186
|
if (device.platform === 'ios') {
|
|
115
187
|
if (session?.appBundleId) {
|
|
116
188
|
return {
|
|
@@ -151,6 +223,62 @@ export async function handleSessionCommands(params: {
|
|
|
151
223
|
};
|
|
152
224
|
}
|
|
153
225
|
|
|
226
|
+
if (command === 'reinstall') {
|
|
227
|
+
const session = sessionStore.get(sessionName);
|
|
228
|
+
const flags = req.flags ?? {};
|
|
229
|
+
if (!session && !hasExplicitDeviceSelector(flags)) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: {
|
|
233
|
+
code: 'INVALID_ARGS',
|
|
234
|
+
message: 'reinstall requires an active session or an explicit device selector (e.g. --platform ios).',
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const app = req.positionals?.[0]?.trim();
|
|
239
|
+
const appPathInput = req.positionals?.[1]?.trim();
|
|
240
|
+
if (!app || !appPathInput) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
error: { code: 'INVALID_ARGS', message: 'reinstall requires: reinstall <app> <path-to-app-binary>' },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const appPath = SessionStore.expandHome(appPathInput);
|
|
247
|
+
if (!fs.existsSync(appPath)) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: false });
|
|
254
|
+
if (!isCommandSupportedOnDevice('reinstall', device)) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
error: { code: 'UNSUPPORTED_OPERATION', message: 'reinstall is not supported on this device' },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
let reinstallData:
|
|
261
|
+
| { platform: 'ios'; appId: string; bundleId: string }
|
|
262
|
+
| { platform: 'android'; appId: string; package: string };
|
|
263
|
+
if (device.platform === 'ios') {
|
|
264
|
+
const iosResult = await reinstallOps.ios(device, app, appPath);
|
|
265
|
+
reinstallData = { platform: 'ios', appId: iosResult.bundleId, bundleId: iosResult.bundleId };
|
|
266
|
+
} else {
|
|
267
|
+
const androidResult = await reinstallOps.android(device, app, appPath);
|
|
268
|
+
reinstallData = { platform: 'android', appId: androidResult.package, package: androidResult.package };
|
|
269
|
+
}
|
|
270
|
+
const result = { app, appPath, ...reinstallData };
|
|
271
|
+
if (session) {
|
|
272
|
+
sessionStore.recordAction(session, {
|
|
273
|
+
command,
|
|
274
|
+
positionals: req.positionals ?? [],
|
|
275
|
+
flags: req.flags ?? {},
|
|
276
|
+
result,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return { ok: true, data: result };
|
|
280
|
+
}
|
|
281
|
+
|
|
154
282
|
if (command === 'open') {
|
|
155
283
|
if (sessionStore.has(sessionName)) {
|
|
156
284
|
const session = sessionStore.get(sessionName);
|
|
@@ -193,7 +321,6 @@ export async function handleSessionCommands(params: {
|
|
|
193
321
|
return { ok: true, data: { session: sessionName, appName, appBundleId } };
|
|
194
322
|
}
|
|
195
323
|
const device = await resolveTargetDevice(req.flags ?? {});
|
|
196
|
-
await ensureDeviceReady(device);
|
|
197
324
|
const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
|
|
198
325
|
if (inUse) {
|
|
199
326
|
return {
|
|
@@ -269,7 +396,9 @@ export async function handleSessionCommands(params: {
|
|
|
269
396
|
flags: action.flags ?? {},
|
|
270
397
|
});
|
|
271
398
|
if (response.ok) continue;
|
|
272
|
-
if (!shouldUpdate)
|
|
399
|
+
if (!shouldUpdate) {
|
|
400
|
+
return withReplayFailureContext(response, action, index, resolved);
|
|
401
|
+
}
|
|
273
402
|
const nextAction = await healReplayAction({
|
|
274
403
|
action,
|
|
275
404
|
sessionName,
|
|
@@ -278,7 +407,7 @@ export async function handleSessionCommands(params: {
|
|
|
278
407
|
dispatch,
|
|
279
408
|
});
|
|
280
409
|
if (!nextAction) {
|
|
281
|
-
return response;
|
|
410
|
+
return withReplayFailureContext(response, action, index, resolved);
|
|
282
411
|
}
|
|
283
412
|
actions[index] = nextAction;
|
|
284
413
|
response = await invoke({
|
|
@@ -289,7 +418,7 @@ export async function handleSessionCommands(params: {
|
|
|
289
418
|
flags: nextAction.flags ?? {},
|
|
290
419
|
});
|
|
291
420
|
if (!response.ok) {
|
|
292
|
-
return response;
|
|
421
|
+
return withReplayFailureContext(response, nextAction, index, resolved);
|
|
293
422
|
}
|
|
294
423
|
healed += 1;
|
|
295
424
|
}
|
|
@@ -334,6 +463,42 @@ export async function handleSessionCommands(params: {
|
|
|
334
463
|
return null;
|
|
335
464
|
}
|
|
336
465
|
|
|
466
|
+
function withReplayFailureContext(
|
|
467
|
+
response: DaemonResponse,
|
|
468
|
+
action: SessionAction,
|
|
469
|
+
index: number,
|
|
470
|
+
replayPath: string,
|
|
471
|
+
): DaemonResponse {
|
|
472
|
+
if (response.ok) return response;
|
|
473
|
+
const step = index + 1;
|
|
474
|
+
const summary = formatReplayActionSummary(action);
|
|
475
|
+
const details = {
|
|
476
|
+
...(response.error.details ?? {}),
|
|
477
|
+
replayPath,
|
|
478
|
+
step,
|
|
479
|
+
action: action.command,
|
|
480
|
+
positionals: action.positionals ?? [],
|
|
481
|
+
};
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
error: {
|
|
485
|
+
code: response.error.code,
|
|
486
|
+
message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
|
|
487
|
+
details,
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function formatReplayActionSummary(action: SessionAction): string {
|
|
493
|
+
const values = (action.positionals ?? []).map((value) => {
|
|
494
|
+
const trimmed = value.trim();
|
|
495
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
496
|
+
if (trimmed.startsWith('@')) return trimmed;
|
|
497
|
+
return JSON.stringify(trimmed);
|
|
498
|
+
});
|
|
499
|
+
return [action.command, ...values].join(' ');
|
|
500
|
+
}
|
|
501
|
+
|
|
337
502
|
async function healReplayAction(params: {
|
|
338
503
|
action: SessionAction;
|
|
339
504
|
sessionName: string;
|
package/src/daemon-client.ts
CHANGED
|
@@ -2,10 +2,10 @@ import net from 'node:net';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
5
|
import { AppError } from './utils/errors.ts';
|
|
7
6
|
import type { CommandFlags } from './core/dispatch.ts';
|
|
8
7
|
import { runCmdDetached } from './utils/exec.ts';
|
|
8
|
+
import { findProjectRoot, readVersion } from './utils/version.ts';
|
|
9
9
|
|
|
10
10
|
export type DaemonRequest = {
|
|
11
11
|
token: string;
|
|
@@ -134,18 +134,6 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
|
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
function readVersion(): string {
|
|
138
|
-
try {
|
|
139
|
-
const root = findProjectRoot();
|
|
140
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
|
141
|
-
version?: string;
|
|
142
|
-
};
|
|
143
|
-
return pkg.version ?? '0.0.0';
|
|
144
|
-
} catch {
|
|
145
|
-
return '0.0.0';
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
137
|
function resolveRequestTimeoutMs(): number {
|
|
150
138
|
const raw = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS;
|
|
151
139
|
if (!raw) return 60000;
|
|
@@ -153,14 +141,3 @@ function resolveRequestTimeoutMs(): number {
|
|
|
153
141
|
if (!Number.isFinite(parsed)) return 60000;
|
|
154
142
|
return Math.max(1000, Math.floor(parsed));
|
|
155
143
|
}
|
|
156
|
-
|
|
157
|
-
function findProjectRoot(): string {
|
|
158
|
-
const start = path.dirname(fileURLToPath(import.meta.url));
|
|
159
|
-
let current = start;
|
|
160
|
-
for (let i = 0; i < 6; i += 1) {
|
|
161
|
-
const pkgPath = path.join(current, 'package.json');
|
|
162
|
-
if (fs.existsSync(pkgPath)) return current;
|
|
163
|
-
current = path.dirname(current);
|
|
164
|
-
}
|
|
165
|
-
return start;
|
|
166
|
-
}
|
package/src/daemon.ts
CHANGED
|
@@ -3,10 +3,10 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
6
|
import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
|
|
8
7
|
import { isCommandSupportedOnDevice } from './core/capabilities.ts';
|
|
9
8
|
import { asAppError, AppError } from './utils/errors.ts';
|
|
9
|
+
import { readVersion } from './utils/version.ts';
|
|
10
10
|
import { stopIosRunnerSession } 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';
|
|
@@ -203,26 +203,3 @@ function start(): void {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
start();
|
|
206
|
-
|
|
207
|
-
function readVersion(): string {
|
|
208
|
-
try {
|
|
209
|
-
const root = findProjectRoot();
|
|
210
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
|
211
|
-
version?: string;
|
|
212
|
-
};
|
|
213
|
-
return pkg.version ?? '0.0.0';
|
|
214
|
-
} catch {
|
|
215
|
-
return '0.0.0';
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function findProjectRoot(): string {
|
|
220
|
-
const start = path.dirname(fileURLToPath(import.meta.url));
|
|
221
|
-
let current = start;
|
|
222
|
-
for (let i = 0; i < 6; i += 1) {
|
|
223
|
-
const pkgPath = path.join(current, 'package.json');
|
|
224
|
-
if (fs.existsSync(pkgPath)) return current;
|
|
225
|
-
current = path.dirname(current);
|
|
226
|
-
}
|
|
227
|
-
return start;
|
|
228
|
-
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
4
|
+
import { AppError } from '../../utils/errors.ts';
|
|
5
|
+
|
|
6
|
+
test('classifyBootFailure maps timeout errors', () => {
|
|
7
|
+
const reason = classifyBootFailure({
|
|
8
|
+
message: 'bootstatus timed out after 120s',
|
|
9
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
10
|
+
});
|
|
11
|
+
assert.equal(reason, 'IOS_BOOT_TIMEOUT');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('classifyBootFailure maps adb offline errors', () => {
|
|
15
|
+
const reason = classifyBootFailure({
|
|
16
|
+
stderr: 'error: device offline',
|
|
17
|
+
context: { platform: 'android', phase: 'transport' },
|
|
18
|
+
});
|
|
19
|
+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('classifyBootFailure maps tool missing from AppError code (android)', () => {
|
|
23
|
+
const reason = classifyBootFailure({
|
|
24
|
+
error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
|
|
25
|
+
context: { platform: 'android', phase: 'transport' },
|
|
26
|
+
});
|
|
27
|
+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('classifyBootFailure maps tool missing from AppError code (ios)', () => {
|
|
31
|
+
const reason = classifyBootFailure({
|
|
32
|
+
error: new AppError('TOOL_MISSING', 'xcrun not found in PATH'),
|
|
33
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
34
|
+
});
|
|
35
|
+
assert.equal(reason, 'IOS_TOOL_MISSING');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('classifyBootFailure reads stderr from AppError details', () => {
|
|
39
|
+
const reason = classifyBootFailure({
|
|
40
|
+
error: new AppError('COMMAND_FAILED', 'adb failed', {
|
|
41
|
+
stderr: 'error: device unauthorized',
|
|
42
|
+
}),
|
|
43
|
+
context: { platform: 'android', phase: 'transport' },
|
|
44
|
+
});
|
|
45
|
+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('bootFailureHint returns actionable guidance', () => {
|
|
49
|
+
const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
|
|
50
|
+
assert.equal(hint.includes('xcodebuild logs'), true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('connect phase does not classify non-timeout errors as connect timeout', () => {
|
|
54
|
+
const reason = classifyBootFailure({
|
|
55
|
+
message: 'Runner returned malformed JSON payload',
|
|
56
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
57
|
+
});
|
|
58
|
+
assert.equal(reason, 'BOOT_COMMAND_FAILED');
|
|
59
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseAndroidLaunchComponent } from '../index.ts';
|
|
3
4
|
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
|
|
4
5
|
|
|
5
6
|
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
|
|
@@ -72,3 +73,19 @@ test('findBounds ignores bounds-like fragments inside other attribute values', (
|
|
|
72
73
|
|
|
73
74
|
assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
test('parseAndroidLaunchComponent extracts final resolved component', () => {
|
|
78
|
+
const stdout = [
|
|
79
|
+
'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
|
|
80
|
+
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
81
|
+
].join('\n');
|
|
82
|
+
assert.equal(
|
|
83
|
+
parseAndroidLaunchComponent(stdout),
|
|
84
|
+
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('parseAndroidLaunchComponent returns null when no component is present', () => {
|
|
89
|
+
const stdout = 'No activity found';
|
|
90
|
+
assert.equal(parseAndroidLaunchComponent(stdout), null);
|
|
91
|
+
});
|