agent-device 0.4.1 → 0.5.0
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 +19 -20
- package/dist/src/797.js +1 -1
- package/dist/src/bin.js +32 -32
- package/dist/src/daemon.js +17 -17
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
- package/package.json +7 -9
- package/skills/agent-device/SKILL.md +22 -19
- package/skills/agent-device/references/permissions.md +10 -17
- package/skills/agent-device/references/session-management.md +3 -1
- package/skills/agent-device/references/snapshot-refs.md +1 -4
- package/dist/bin/axsnapshot +0 -0
- package/ios-runner/AXSnapshot/Package.swift +0 -18
- package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
- package/src/__tests__/cli-help.test.ts +0 -102
- package/src/bin.ts +0 -3
- package/src/cli.ts +0 -289
- package/src/core/__tests__/capabilities.test.ts +0 -74
- package/src/core/__tests__/open-target.test.ts +0 -16
- package/src/core/capabilities.ts +0 -57
- package/src/core/dispatch.ts +0 -360
- package/src/core/open-target.ts +0 -13
- package/src/daemon/__tests__/app-state.test.ts +0 -138
- package/src/daemon/__tests__/is-predicates.test.ts +0 -68
- package/src/daemon/__tests__/selectors.test.ts +0 -261
- package/src/daemon/__tests__/session-routing.test.ts +0 -108
- package/src/daemon/__tests__/session-selector.test.ts +0 -64
- package/src/daemon/__tests__/session-store.test.ts +0 -142
- package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
- package/src/daemon/action-utils.ts +0 -29
- package/src/daemon/app-state.ts +0 -65
- package/src/daemon/context.ts +0 -48
- package/src/daemon/device-ready.ts +0 -13
- package/src/daemon/handlers/__tests__/find.test.ts +0 -99
- package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
- package/src/daemon/handlers/__tests__/session.test.ts +0 -343
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
- package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
- package/src/daemon/handlers/find.ts +0 -324
- package/src/daemon/handlers/interaction.ts +0 -550
- package/src/daemon/handlers/parse-utils.ts +0 -8
- package/src/daemon/handlers/record-trace.ts +0 -154
- package/src/daemon/handlers/session.ts +0 -1032
- package/src/daemon/handlers/snapshot.ts +0 -439
- package/src/daemon/is-predicates.ts +0 -46
- package/src/daemon/selectors.ts +0 -540
- package/src/daemon/session-routing.ts +0 -22
- package/src/daemon/session-selector.ts +0 -39
- package/src/daemon/session-store.ts +0 -298
- package/src/daemon/snapshot-processing.ts +0 -131
- package/src/daemon/types.ts +0 -56
- package/src/daemon-client.ts +0 -172
- package/src/daemon.ts +0 -295
- package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
- package/src/platforms/android/__tests__/index.test.ts +0 -157
- package/src/platforms/android/devices.ts +0 -196
- package/src/platforms/android/index.ts +0 -754
- package/src/platforms/android/ui-hierarchy.ts +0 -312
- package/src/platforms/boot-diagnostics.ts +0 -128
- package/src/platforms/ios/__tests__/index.test.ts +0 -24
- package/src/platforms/ios/__tests__/runner-client.test.ts +0 -113
- package/src/platforms/ios/ax-snapshot.ts +0 -207
- package/src/platforms/ios/devices.ts +0 -87
- package/src/platforms/ios/index.ts +0 -455
- package/src/platforms/ios/runner-client.ts +0 -938
- package/src/utils/__tests__/args.test.ts +0 -221
- package/src/utils/__tests__/daemon-client.test.ts +0 -78
- package/src/utils/__tests__/exec.test.ts +0 -16
- package/src/utils/__tests__/finders.test.ts +0 -34
- package/src/utils/__tests__/keyed-lock.test.ts +0 -55
- package/src/utils/__tests__/process-identity.test.ts +0 -33
- package/src/utils/__tests__/retry.test.ts +0 -44
- package/src/utils/args.ts +0 -234
- package/src/utils/command-schema.ts +0 -629
- package/src/utils/device.ts +0 -84
- package/src/utils/errors.ts +0 -35
- package/src/utils/exec.ts +0 -339
- package/src/utils/finders.ts +0 -101
- package/src/utils/interactive.ts +0 -4
- package/src/utils/interactors.ts +0 -173
- package/src/utils/keyed-lock.ts +0 -14
- package/src/utils/output.ts +0 -204
- package/src/utils/process-identity.ts +0 -100
- package/src/utils/retry.ts +0 -180
- package/src/utils/snapshot.ts +0 -64
- package/src/utils/version.ts +0 -26
package/src/daemon.ts
DELETED
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
import net from 'node:net';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import crypto from 'node:crypto';
|
|
6
|
-
import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
|
|
7
|
-
import { isCommandSupportedOnDevice } from './core/capabilities.ts';
|
|
8
|
-
import { asAppError, AppError } from './utils/errors.ts';
|
|
9
|
-
import { readVersion } from './utils/version.ts';
|
|
10
|
-
import { stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts';
|
|
11
|
-
import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
|
|
12
|
-
import { SessionStore } from './daemon/session-store.ts';
|
|
13
|
-
import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
|
|
14
|
-
import { handleSessionCommands } from './daemon/handlers/session.ts';
|
|
15
|
-
import { handleSnapshotCommands } from './daemon/handlers/snapshot.ts';
|
|
16
|
-
import { handleFindCommands } from './daemon/handlers/find.ts';
|
|
17
|
-
import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
|
|
18
|
-
import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
|
|
19
|
-
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
|
|
20
|
-
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
|
|
21
|
-
import {
|
|
22
|
-
isAgentDeviceDaemonProcess,
|
|
23
|
-
readProcessStartTime,
|
|
24
|
-
} from './utils/process-identity.ts';
|
|
25
|
-
|
|
26
|
-
const baseDir = path.join(os.homedir(), '.agent-device');
|
|
27
|
-
const infoPath = path.join(baseDir, 'daemon.json');
|
|
28
|
-
const lockPath = path.join(baseDir, 'daemon.lock');
|
|
29
|
-
const logPath = path.join(baseDir, 'daemon.log');
|
|
30
|
-
const sessionsDir = path.join(baseDir, 'sessions');
|
|
31
|
-
const sessionStore = new SessionStore(sessionsDir);
|
|
32
|
-
const version = readVersion();
|
|
33
|
-
const token = crypto.randomBytes(24).toString('hex');
|
|
34
|
-
const selectorValidationExemptCommands = new Set(['session_list', 'devices']);
|
|
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
|
-
|
|
45
|
-
function contextFromFlags(
|
|
46
|
-
flags: CommandFlags | undefined,
|
|
47
|
-
appBundleId?: string,
|
|
48
|
-
traceLogPath?: string,
|
|
49
|
-
): DaemonCommandContext {
|
|
50
|
-
return contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
|
|
54
|
-
if (req.token !== token) {
|
|
55
|
-
return { ok: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const command = req.command;
|
|
59
|
-
const sessionName = resolveEffectiveSessionName(req, sessionStore);
|
|
60
|
-
const existingSession = sessionStore.get(sessionName);
|
|
61
|
-
if (existingSession && !selectorValidationExemptCommands.has(command)) {
|
|
62
|
-
assertSessionSelectorMatches(existingSession, req.flags);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const sessionResponse = await handleSessionCommands({
|
|
66
|
-
req,
|
|
67
|
-
sessionName,
|
|
68
|
-
logPath,
|
|
69
|
-
sessionStore,
|
|
70
|
-
invoke: handleRequest,
|
|
71
|
-
});
|
|
72
|
-
if (sessionResponse) return sessionResponse;
|
|
73
|
-
|
|
74
|
-
const snapshotResponse = await handleSnapshotCommands({
|
|
75
|
-
req,
|
|
76
|
-
sessionName,
|
|
77
|
-
logPath,
|
|
78
|
-
sessionStore,
|
|
79
|
-
});
|
|
80
|
-
if (snapshotResponse) return snapshotResponse;
|
|
81
|
-
|
|
82
|
-
const recordTraceResponse = await handleRecordTraceCommands({
|
|
83
|
-
req,
|
|
84
|
-
sessionName,
|
|
85
|
-
sessionStore,
|
|
86
|
-
});
|
|
87
|
-
if (recordTraceResponse) return recordTraceResponse;
|
|
88
|
-
|
|
89
|
-
const findResponse = await handleFindCommands({
|
|
90
|
-
req,
|
|
91
|
-
sessionName,
|
|
92
|
-
logPath,
|
|
93
|
-
sessionStore,
|
|
94
|
-
invoke: handleRequest,
|
|
95
|
-
});
|
|
96
|
-
if (findResponse) return findResponse;
|
|
97
|
-
|
|
98
|
-
const interactionResponse = await handleInteractionCommands({
|
|
99
|
-
req,
|
|
100
|
-
sessionName,
|
|
101
|
-
sessionStore,
|
|
102
|
-
contextFromFlags,
|
|
103
|
-
});
|
|
104
|
-
if (interactionResponse) return interactionResponse;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const session = sessionStore.get(sessionName);
|
|
108
|
-
if (!session) {
|
|
109
|
-
return {
|
|
110
|
-
ok: false,
|
|
111
|
-
error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (!isCommandSupportedOnDevice(command, session.device)) {
|
|
116
|
-
return {
|
|
117
|
-
ok: false,
|
|
118
|
-
error: { code: 'UNSUPPORTED_OPERATION', message: `${command} is not supported on this device` },
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const data = await dispatchCommand(session.device, command, req.positionals ?? [], req.flags?.out, {
|
|
123
|
-
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
|
|
124
|
-
});
|
|
125
|
-
sessionStore.recordAction(session, {
|
|
126
|
-
command,
|
|
127
|
-
positionals: req.positionals ?? [],
|
|
128
|
-
flags: req.flags ?? {},
|
|
129
|
-
result: data ?? {},
|
|
130
|
-
});
|
|
131
|
-
return { ok: true, data: data ?? {} };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function writeInfo(port: number): void {
|
|
135
|
-
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
|
|
136
|
-
fs.writeFileSync(logPath, '');
|
|
137
|
-
fs.writeFileSync(
|
|
138
|
-
infoPath,
|
|
139
|
-
JSON.stringify({ port, token, pid: process.pid, version, processStartTime: daemonProcessStartTime }, null, 2),
|
|
140
|
-
{
|
|
141
|
-
mode: 0o600,
|
|
142
|
-
},
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function removeInfo(): void {
|
|
147
|
-
if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
|
|
148
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
const server = net.createServer((socket) => {
|
|
218
|
-
let buffer = '';
|
|
219
|
-
socket.setEncoding('utf8');
|
|
220
|
-
socket.on('data', async (chunk) => {
|
|
221
|
-
buffer += chunk;
|
|
222
|
-
let idx = buffer.indexOf('\n');
|
|
223
|
-
while (idx !== -1) {
|
|
224
|
-
const line = buffer.slice(0, idx).trim();
|
|
225
|
-
buffer = buffer.slice(idx + 1);
|
|
226
|
-
if (line.length === 0) {
|
|
227
|
-
idx = buffer.indexOf('\n');
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
let response: DaemonResponse;
|
|
231
|
-
try {
|
|
232
|
-
const req = JSON.parse(line) as DaemonRequest;
|
|
233
|
-
response = await handleRequest(req);
|
|
234
|
-
} catch (err) {
|
|
235
|
-
const appErr = asAppError(err);
|
|
236
|
-
response = {
|
|
237
|
-
ok: false,
|
|
238
|
-
error: { code: appErr.code, message: appErr.message, details: appErr.details },
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
socket.write(`${JSON.stringify(response)}\n`);
|
|
242
|
-
idx = buffer.indexOf('\n');
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
server.listen(0, '127.0.0.1', () => {
|
|
248
|
-
const address = server.address();
|
|
249
|
-
if (typeof address === 'object' && address?.port) {
|
|
250
|
-
writeInfo(address.port);
|
|
251
|
-
process.stdout.write(`AGENT_DEVICE_DAEMON_PORT=${address.port}\n`);
|
|
252
|
-
}
|
|
253
|
-
});
|
|
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
|
-
};
|
|
265
|
-
const shutdown = async () => {
|
|
266
|
-
if (shuttingDown) return;
|
|
267
|
-
shuttingDown = true;
|
|
268
|
-
await closeServer();
|
|
269
|
-
const sessionsToStop = sessionStore.toArray();
|
|
270
|
-
for (const session of sessionsToStop) {
|
|
271
|
-
sessionStore.writeSessionLog(session);
|
|
272
|
-
}
|
|
273
|
-
await stopAllIosRunnerSessions();
|
|
274
|
-
removeInfo();
|
|
275
|
-
releaseDaemonLock();
|
|
276
|
-
process.exit(0);
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
process.on('SIGINT', () => {
|
|
280
|
-
void shutdown();
|
|
281
|
-
});
|
|
282
|
-
process.on('SIGTERM', () => {
|
|
283
|
-
void shutdown();
|
|
284
|
-
});
|
|
285
|
-
process.on('SIGHUP', () => {
|
|
286
|
-
void shutdown();
|
|
287
|
-
});
|
|
288
|
-
process.on('uncaughtException', (err) => {
|
|
289
|
-
const appErr = err instanceof AppError ? err : asAppError(err);
|
|
290
|
-
process.stderr.write(`Daemon error: ${appErr.message}\n`);
|
|
291
|
-
void shutdown();
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
start();
|
|
@@ -1,59 +0,0 @@
|
|
|
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,157 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
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';
|
|
7
|
-
import type { DeviceInfo } from '../../../utils/device.ts';
|
|
8
|
-
import { AppError } from '../../../utils/errors.ts';
|
|
9
|
-
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
|
|
10
|
-
|
|
11
|
-
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
|
|
12
|
-
const xml =
|
|
13
|
-
'<hierarchy><node class="android.widget.TextView" text="Hello" content-desc="Greeting" resource-id="com.demo:id/title" bounds="[10,20][110,60]" clickable="true" enabled="true"/></hierarchy>';
|
|
14
|
-
|
|
15
|
-
const result = parseUiHierarchy(xml, 800, { raw: true });
|
|
16
|
-
assert.equal(result.nodes.length, 1);
|
|
17
|
-
assert.equal(result.nodes[0].value, 'Hello');
|
|
18
|
-
assert.equal(result.nodes[0].label, 'Hello');
|
|
19
|
-
assert.equal(result.nodes[0].identifier, 'com.demo:id/title');
|
|
20
|
-
assert.deepEqual(result.nodes[0].rect, { x: 10, y: 20, width: 100, height: 40 });
|
|
21
|
-
assert.equal(result.nodes[0].hittable, true);
|
|
22
|
-
assert.equal(result.nodes[0].enabled, true);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test('parseUiHierarchy reads single-quoted Android node attributes', () => {
|
|
26
|
-
const xml =
|
|
27
|
-
"<hierarchy><node class='android.widget.TextView' text='Hello' content-desc='Greeting' resource-id='com.demo:id/title' bounds='[10,20][110,60]' clickable='true' enabled='true'/></hierarchy>";
|
|
28
|
-
|
|
29
|
-
const result = parseUiHierarchy(xml, 800, { raw: true });
|
|
30
|
-
assert.equal(result.nodes.length, 1);
|
|
31
|
-
assert.equal(result.nodes[0].value, 'Hello');
|
|
32
|
-
assert.equal(result.nodes[0].label, 'Hello');
|
|
33
|
-
assert.equal(result.nodes[0].identifier, 'com.demo:id/title');
|
|
34
|
-
assert.deepEqual(result.nodes[0].rect, { x: 10, y: 20, width: 100, height: 40 });
|
|
35
|
-
assert.equal(result.nodes[0].hittable, true);
|
|
36
|
-
assert.equal(result.nodes[0].enabled, true);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('parseUiHierarchy supports mixed quote styles in one node', () => {
|
|
40
|
-
const xml =
|
|
41
|
-
'<hierarchy><node class="android.widget.TextView" text=\'Hello\' content-desc="Greeting" resource-id=\'com.demo:id/title\' bounds="[10,20][110,60]"/></hierarchy>';
|
|
42
|
-
|
|
43
|
-
const result = parseUiHierarchy(xml, 800, { raw: true });
|
|
44
|
-
assert.equal(result.nodes.length, 1);
|
|
45
|
-
assert.equal(result.nodes[0].value, 'Hello');
|
|
46
|
-
assert.equal(result.nodes[0].label, 'Hello');
|
|
47
|
-
assert.equal(result.nodes[0].identifier, 'com.demo:id/title');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('findBounds supports single and double quoted attributes', () => {
|
|
51
|
-
const xml = [
|
|
52
|
-
'<hierarchy>',
|
|
53
|
-
'<node text="Nothing" content-desc="Irrelevant" bounds="[0,0][10,10]"/>',
|
|
54
|
-
"<node text='Target from single quote' content-desc='Alt single' bounds='[100,200][300,500]'/>",
|
|
55
|
-
'<node text="Target from double quote" content-desc="Alt double" bounds="[50,50][150,250]"/>',
|
|
56
|
-
'</hierarchy>',
|
|
57
|
-
].join('');
|
|
58
|
-
|
|
59
|
-
assert.deepEqual(findBounds(xml, 'single quote'), { x: 200, y: 350 });
|
|
60
|
-
assert.deepEqual(findBounds(xml, 'alt double'), { x: 100, y: 150 });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('parseUiHierarchy ignores attribute-name prefix spoofing', () => {
|
|
64
|
-
const xml =
|
|
65
|
-
"<hierarchy><node class='android.widget.TextView' hint-text='Spoofed' text='Actual' bounds='[10,20][110,60]'/></hierarchy>";
|
|
66
|
-
|
|
67
|
-
const result = parseUiHierarchy(xml, 800, { raw: true });
|
|
68
|
-
assert.equal(result.nodes.length, 1);
|
|
69
|
-
assert.equal(result.nodes[0].value, 'Actual');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('findBounds ignores bounds-like fragments inside other attribute values', () => {
|
|
73
|
-
const xml = [
|
|
74
|
-
'<hierarchy>',
|
|
75
|
-
"<node text='Target' content-desc=\"metadata bounds='[900,900][1000,1000]'\" bounds='[100,200][300,500]'/>",
|
|
76
|
-
'</hierarchy>',
|
|
77
|
-
].join('');
|
|
78
|
-
|
|
79
|
-
assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test('parseAndroidLaunchComponent extracts final resolved component', () => {
|
|
83
|
-
const stdout = [
|
|
84
|
-
'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
|
|
85
|
-
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
86
|
-
].join('\n');
|
|
87
|
-
assert.equal(
|
|
88
|
-
parseAndroidLaunchComponent(stdout),
|
|
89
|
-
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
90
|
-
);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('parseAndroidLaunchComponent returns null when no component is present', () => {
|
|
94
|
-
const stdout = 'No activity found';
|
|
95
|
-
assert.equal(parseAndroidLaunchComponent(stdout), null);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('openAndroidApp rejects activity override for deep link URLs', async () => {
|
|
99
|
-
const device: DeviceInfo = {
|
|
100
|
-
platform: 'android',
|
|
101
|
-
id: 'emulator-5554',
|
|
102
|
-
name: 'Pixel',
|
|
103
|
-
kind: 'emulator',
|
|
104
|
-
booted: true,
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
await assert.rejects(
|
|
108
|
-
() => openAndroidApp(device, ' https://example.com/path ', '.MainActivity'),
|
|
109
|
-
(error: unknown) => {
|
|
110
|
-
assert.equal(error instanceof AppError, true);
|
|
111
|
-
assert.equal((error as AppError).code, 'INVALID_ARGS');
|
|
112
|
-
return true;
|
|
113
|
-
},
|
|
114
|
-
);
|
|
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
|
-
});
|