visor-ai 0.2.5 → 0.2.6
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 +0 -1
- package/dist/adapters.js +25 -94
- package/dist/appiumLifecycle.js +4 -1
- package/dist/cli.js +163 -193
- package/dist/daemon.js +517 -0
- package/dist/devices.js +123 -0
- package/dist/main.js +5 -0
- package/dist/runner.js +5 -3
- package/dist/validator.js +6 -5
- package/package.json +2 -1
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { DEFAULT_SERVER_URL, getAdapter } from './adapters.js';
|
|
7
|
+
import { DEFAULT_STARTUP_TIMEOUT_SECONDS, startManagedAppium, statusManagedAppium, stopManagedAppium } from './appiumLifecycle.js';
|
|
8
|
+
import { runScenario } from './runner.js';
|
|
9
|
+
import { ensureDir, errorMessage, resolveExecutable, sleep } from './utils.js';
|
|
10
|
+
export class DaemonUnavailableError extends Error {
|
|
11
|
+
constructor(message = 'Visor daemon is not running. Run `visor start` before real-device commands.') {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'DaemonUnavailableError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class DaemonRequestTimeoutError extends Error {
|
|
17
|
+
constructor(requestType, timeoutMs) {
|
|
18
|
+
super(`Timed out after ${timeoutMs}ms waiting for the Visor daemon to finish '${requestType}'. Inspect .visor/daemon/daemon.log and .visor/appium/*.log for the underlying Appium operation.`);
|
|
19
|
+
this.name = 'DaemonRequestTimeoutError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function daemonDir() {
|
|
23
|
+
return ensureDir(path.join(process.cwd(), '.visor', 'daemon'));
|
|
24
|
+
}
|
|
25
|
+
function socketPath() {
|
|
26
|
+
if (process.env.VISOR_DAEMON_SOCKET_PATH) {
|
|
27
|
+
return process.env.VISOR_DAEMON_SOCKET_PATH;
|
|
28
|
+
}
|
|
29
|
+
if (process.platform === 'win32') {
|
|
30
|
+
return '\\\\.\\pipe\\visor-daemon';
|
|
31
|
+
}
|
|
32
|
+
return path.join(daemonDir(), 'visor.sock');
|
|
33
|
+
}
|
|
34
|
+
function metadataPath() {
|
|
35
|
+
return path.join(daemonDir(), 'daemon.json');
|
|
36
|
+
}
|
|
37
|
+
function logPath() {
|
|
38
|
+
return path.join(daemonDir(), 'daemon.log');
|
|
39
|
+
}
|
|
40
|
+
function readMetadata() {
|
|
41
|
+
const filePath = metadataPath();
|
|
42
|
+
if (!fs.existsSync(filePath)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function writeMetadata(meta) {
|
|
53
|
+
fs.writeFileSync(metadataPath(), JSON.stringify(meta, null, 2), 'utf8');
|
|
54
|
+
}
|
|
55
|
+
function cleanupMetadata() {
|
|
56
|
+
const filePath = metadataPath();
|
|
57
|
+
if (fs.existsSync(filePath)) {
|
|
58
|
+
fs.unlinkSync(filePath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function pidExists(pid) {
|
|
62
|
+
if (pid <= 0) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
process.kill(pid, 0);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'EPERM') {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function runtimeKey(runtime) {
|
|
77
|
+
return JSON.stringify({
|
|
78
|
+
platform: runtime.platform,
|
|
79
|
+
server_url: runtime.server_url,
|
|
80
|
+
device: runtime.device ?? '',
|
|
81
|
+
app_id: runtime.app_id ?? '',
|
|
82
|
+
attach_to_running: runtime.attach_to_running
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function parseBoolean(value) {
|
|
86
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').toLowerCase());
|
|
87
|
+
}
|
|
88
|
+
export function isRecoverableSessionCacheError(error) {
|
|
89
|
+
const message = errorMessage(error).toLowerCase();
|
|
90
|
+
return (message.includes('session is either terminated or not started') ||
|
|
91
|
+
message.includes('invalid session id') ||
|
|
92
|
+
message.includes('no such driver') ||
|
|
93
|
+
(message.includes('could not proxy command to the remote server') &&
|
|
94
|
+
message.includes('econnrefused')) ||
|
|
95
|
+
(message.includes('127.0.0.1:8100') && message.includes('econnrefused')));
|
|
96
|
+
}
|
|
97
|
+
async function requestDaemon(request, timeoutMs = 1000) {
|
|
98
|
+
const targetSocket = socketPath();
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const client = net.createConnection(targetSocket);
|
|
101
|
+
let output = '';
|
|
102
|
+
let settled = false;
|
|
103
|
+
function settle(error, response) {
|
|
104
|
+
if (settled) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
settled = true;
|
|
108
|
+
client.destroy();
|
|
109
|
+
if (error) {
|
|
110
|
+
reject(error);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
resolve(response ?? { ok: false, error: 'Empty daemon response' });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
client.setTimeout(timeoutMs);
|
|
117
|
+
client.on('connect', () => {
|
|
118
|
+
client.write(`${JSON.stringify(request)}\n`);
|
|
119
|
+
});
|
|
120
|
+
client.on('data', (chunk) => {
|
|
121
|
+
output += chunk.toString('utf8');
|
|
122
|
+
});
|
|
123
|
+
client.on('end', () => {
|
|
124
|
+
try {
|
|
125
|
+
settle(undefined, JSON.parse(output));
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
settle(error instanceof Error ? error : new Error(String(error)));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
client.on('timeout', () => {
|
|
132
|
+
settle(new DaemonRequestTimeoutError(request.type, timeoutMs));
|
|
133
|
+
});
|
|
134
|
+
client.on('error', () => {
|
|
135
|
+
settle(new DaemonUnavailableError());
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function daemonRequestData(request, timeoutMs = 1000) {
|
|
140
|
+
const response = await requestDaemon(request, timeoutMs);
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
throw new Error(response.error);
|
|
143
|
+
}
|
|
144
|
+
return response.data;
|
|
145
|
+
}
|
|
146
|
+
async function isDaemonReachable() {
|
|
147
|
+
try {
|
|
148
|
+
await daemonRequestData({ type: 'status' }, 500);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function daemonEntryCommand() {
|
|
156
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
157
|
+
const builtMain = path.join(moduleDir, 'main.js');
|
|
158
|
+
if (fs.existsSync(builtMain)) {
|
|
159
|
+
return { command: process.execPath, args: [builtMain] };
|
|
160
|
+
}
|
|
161
|
+
const sourceMain = path.resolve(process.cwd(), 'src', 'main.ts');
|
|
162
|
+
const localTsx = path.resolve(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
|
|
163
|
+
if (fs.existsSync(sourceMain) && fs.existsSync(localTsx)) {
|
|
164
|
+
return { command: localTsx, args: [sourceMain] };
|
|
165
|
+
}
|
|
166
|
+
if (process.argv[1] && fs.existsSync(process.argv[1])) {
|
|
167
|
+
return { command: process.execPath, args: [process.argv[1]] };
|
|
168
|
+
}
|
|
169
|
+
const tsx = resolveExecutable('tsx');
|
|
170
|
+
if (tsx && fs.existsSync(sourceMain)) {
|
|
171
|
+
return { command: tsx, args: [sourceMain] };
|
|
172
|
+
}
|
|
173
|
+
throw new Error('Unable to resolve a Visor CLI entrypoint for the daemon.');
|
|
174
|
+
}
|
|
175
|
+
export async function startVisorDaemon(serverUrl = DEFAULT_SERVER_URL, appiumCmd) {
|
|
176
|
+
const existing = readMetadata();
|
|
177
|
+
if (existing && pidExists(existing.pid) && (await isDaemonReachable())) {
|
|
178
|
+
return {
|
|
179
|
+
serverUrl,
|
|
180
|
+
daemon: {
|
|
181
|
+
running: true,
|
|
182
|
+
alreadyRunning: true,
|
|
183
|
+
pid: existing.pid,
|
|
184
|
+
socketPath: existing.socketPath,
|
|
185
|
+
metadataPath: metadataPath(),
|
|
186
|
+
logPath: logPath()
|
|
187
|
+
},
|
|
188
|
+
appium: await statusManagedAppium(serverUrl)
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (process.platform !== 'win32' && fs.existsSync(socketPath())) {
|
|
192
|
+
fs.unlinkSync(socketPath());
|
|
193
|
+
}
|
|
194
|
+
cleanupMetadata();
|
|
195
|
+
const appiumStatus = await statusManagedAppium(serverUrl);
|
|
196
|
+
if (Boolean(appiumStatus.reachable) && !Boolean(appiumStatus.managed)) {
|
|
197
|
+
throw new Error(`Appium is already reachable at ${serverUrl}, but it is not managed by Visor. Stop the existing Appium process or choose a different --server-url, then run \`visor start\` again.`);
|
|
198
|
+
}
|
|
199
|
+
const appium = await startManagedAppium(serverUrl, appiumCmd, DEFAULT_STARTUP_TIMEOUT_SECONDS);
|
|
200
|
+
const entry = daemonEntryCommand();
|
|
201
|
+
const targetLogPath = logPath();
|
|
202
|
+
ensureDir(path.dirname(targetLogPath));
|
|
203
|
+
const logFd = fs.openSync(targetLogPath, 'a');
|
|
204
|
+
const child = spawn(entry.command, [...entry.args, '__daemon'], {
|
|
205
|
+
detached: process.platform !== 'win32',
|
|
206
|
+
stdio: ['ignore', logFd, logFd],
|
|
207
|
+
env: {
|
|
208
|
+
...process.env,
|
|
209
|
+
VISOR_DAEMON_SERVER_URL: serverUrl,
|
|
210
|
+
VISOR_DAEMON_APPIUM_STARTED: String(Boolean(appium.started)),
|
|
211
|
+
VISOR_DAEMON_SOCKET_PATH: socketPath()
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
fs.closeSync(logFd);
|
|
215
|
+
const meta = {
|
|
216
|
+
pid: child.pid ?? -1,
|
|
217
|
+
serverUrl,
|
|
218
|
+
socketPath: socketPath(),
|
|
219
|
+
appiumStarted: Boolean(appium.started),
|
|
220
|
+
startedAt: Date.now()
|
|
221
|
+
};
|
|
222
|
+
writeMetadata(meta);
|
|
223
|
+
child.unref();
|
|
224
|
+
const deadline = Date.now() + 5000;
|
|
225
|
+
while (Date.now() < deadline) {
|
|
226
|
+
if (await isDaemonReachable()) {
|
|
227
|
+
return {
|
|
228
|
+
serverUrl,
|
|
229
|
+
daemon: {
|
|
230
|
+
running: true,
|
|
231
|
+
alreadyRunning: false,
|
|
232
|
+
pid: meta.pid,
|
|
233
|
+
socketPath: meta.socketPath,
|
|
234
|
+
metadataPath: metadataPath(),
|
|
235
|
+
logPath: targetLogPath
|
|
236
|
+
},
|
|
237
|
+
appium
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (child.exitCode !== null) {
|
|
241
|
+
cleanupMetadata();
|
|
242
|
+
throw new Error(`Visor daemon exited before becoming ready. See log at ${targetLogPath}`);
|
|
243
|
+
}
|
|
244
|
+
await sleep(100);
|
|
245
|
+
}
|
|
246
|
+
cleanupMetadata();
|
|
247
|
+
throw new Error(`Visor daemon did not become ready within 5.0s. See log at ${targetLogPath}`);
|
|
248
|
+
}
|
|
249
|
+
export async function statusVisorDaemon(serverUrl = DEFAULT_SERVER_URL) {
|
|
250
|
+
const meta = readMetadata();
|
|
251
|
+
let daemonData = {};
|
|
252
|
+
let running = false;
|
|
253
|
+
let daemonError;
|
|
254
|
+
const pidPresent = meta ? pidExists(meta.pid) : false;
|
|
255
|
+
try {
|
|
256
|
+
daemonData = await daemonRequestData({ type: 'status' }, 2000);
|
|
257
|
+
running = true;
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
running = false;
|
|
261
|
+
daemonError = errorMessage(error);
|
|
262
|
+
}
|
|
263
|
+
if (meta && !running && !pidPresent) {
|
|
264
|
+
cleanupMetadata();
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
serverUrl,
|
|
268
|
+
daemon: {
|
|
269
|
+
running,
|
|
270
|
+
responsive: running,
|
|
271
|
+
unresponsive: Boolean(meta && pidPresent && !running),
|
|
272
|
+
pid: meta?.pid ?? null,
|
|
273
|
+
socketPath: socketPath(),
|
|
274
|
+
metadataPath: metadataPath(),
|
|
275
|
+
logPath: logPath(),
|
|
276
|
+
appiumStarted: running ? (meta?.appiumStarted ?? false) : false,
|
|
277
|
+
error: daemonError,
|
|
278
|
+
...daemonData
|
|
279
|
+
},
|
|
280
|
+
appium: await statusManagedAppium(serverUrl)
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
export async function stopVisorDaemon(serverUrl = DEFAULT_SERVER_URL, force = false) {
|
|
284
|
+
const meta = readMetadata();
|
|
285
|
+
let daemonResponse = null;
|
|
286
|
+
try {
|
|
287
|
+
daemonResponse = await daemonRequestData({ type: 'stop', force }, 5000);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (!(error instanceof DaemonUnavailableError) && !(error instanceof DaemonRequestTimeoutError)) {
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (meta && pidExists(meta.pid)) {
|
|
295
|
+
const deadline = Date.now() + 3000;
|
|
296
|
+
while (Date.now() < deadline && pidExists(meta.pid)) {
|
|
297
|
+
await sleep(100);
|
|
298
|
+
}
|
|
299
|
+
if (pidExists(meta.pid) && force) {
|
|
300
|
+
process.kill(meta.pid, 'SIGKILL');
|
|
301
|
+
}
|
|
302
|
+
if (pidExists(meta.pid)) {
|
|
303
|
+
return {
|
|
304
|
+
serverUrl,
|
|
305
|
+
stopped: false,
|
|
306
|
+
daemon: {
|
|
307
|
+
running: false,
|
|
308
|
+
unresponsive: true,
|
|
309
|
+
reason: 'daemon_did_not_stop',
|
|
310
|
+
pid: meta.pid,
|
|
311
|
+
socketPath: socketPath(),
|
|
312
|
+
metadataPath: metadataPath(),
|
|
313
|
+
logPath: logPath()
|
|
314
|
+
},
|
|
315
|
+
appium: await statusManagedAppium(serverUrl)
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (meta?.appiumStarted) {
|
|
320
|
+
try {
|
|
321
|
+
await stopManagedAppium(meta.serverUrl, force);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
if (!force) {
|
|
325
|
+
await stopManagedAppium(meta.serverUrl, true);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
cleanupMetadata();
|
|
330
|
+
if (process.platform !== 'win32' && fs.existsSync(socketPath())) {
|
|
331
|
+
fs.unlinkSync(socketPath());
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
serverUrl,
|
|
335
|
+
stopped: Boolean(daemonResponse),
|
|
336
|
+
daemon: daemonResponse ?? {
|
|
337
|
+
running: false,
|
|
338
|
+
reason: 'daemon_not_running',
|
|
339
|
+
socketPath: socketPath(),
|
|
340
|
+
metadataPath: metadataPath(),
|
|
341
|
+
logPath: logPath()
|
|
342
|
+
},
|
|
343
|
+
appium: await statusManagedAppium(serverUrl)
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
export async function runDaemonAction(runtime, command, args) {
|
|
347
|
+
return daemonRequestData({ type: 'action', runtime, command, args }, 300000);
|
|
348
|
+
}
|
|
349
|
+
export async function runDaemonScenario(runtime, scenario, device, timeout, outputDir) {
|
|
350
|
+
return daemonRequestData({ type: 'scenario', runtime, scenario, device, timeout, outputDir }, 600000);
|
|
351
|
+
}
|
|
352
|
+
export async function runDaemonFromEnv() {
|
|
353
|
+
const options = {
|
|
354
|
+
serverUrl: process.env.VISOR_DAEMON_SERVER_URL ?? DEFAULT_SERVER_URL,
|
|
355
|
+
appiumStarted: parseBoolean(process.env.VISOR_DAEMON_APPIUM_STARTED)
|
|
356
|
+
};
|
|
357
|
+
const sessions = new Map();
|
|
358
|
+
let queue = Promise.resolve();
|
|
359
|
+
let activeOperation = null;
|
|
360
|
+
let lastError = null;
|
|
361
|
+
async function closeSessions() {
|
|
362
|
+
const entries = Array.from(sessions.values());
|
|
363
|
+
sessions.clear();
|
|
364
|
+
for (const entry of entries) {
|
|
365
|
+
try {
|
|
366
|
+
await entry.adapter.close();
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// Stopping the daemon should still succeed if Appium already dropped the session.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function sessionFor(runtime) {
|
|
374
|
+
const key = runtimeKey(runtime);
|
|
375
|
+
const existing = sessions.get(key);
|
|
376
|
+
if (existing) {
|
|
377
|
+
return existing.adapter;
|
|
378
|
+
}
|
|
379
|
+
const adapter = await getAdapter(runtime.platform, runtime.server_url, runtime.device, runtime.app_id, runtime.attach_to_running);
|
|
380
|
+
sessions.set(key, {
|
|
381
|
+
adapter,
|
|
382
|
+
runtime,
|
|
383
|
+
createdAt: Date.now()
|
|
384
|
+
});
|
|
385
|
+
return adapter;
|
|
386
|
+
}
|
|
387
|
+
async function discardSession(runtime) {
|
|
388
|
+
const key = runtimeKey(runtime);
|
|
389
|
+
const existing = sessions.get(key);
|
|
390
|
+
if (!existing) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
sessions.delete(key);
|
|
394
|
+
try {
|
|
395
|
+
await existing.adapter.close();
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// The backing WebDriver session is already gone; removing it from the cache is enough.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function runActionWithSessionRetry(runtime, command, args) {
|
|
402
|
+
const adapter = await sessionFor(runtime);
|
|
403
|
+
try {
|
|
404
|
+
return await adapter[command](args);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (!isRecoverableSessionCacheError(error)) {
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
await discardSession(runtime);
|
|
411
|
+
const freshAdapter = await sessionFor(runtime);
|
|
412
|
+
return freshAdapter[command](args);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function enqueue(work) {
|
|
416
|
+
const run = queue.then(work, work).catch((error) => {
|
|
417
|
+
lastError = errorMessage(error);
|
|
418
|
+
throw error;
|
|
419
|
+
});
|
|
420
|
+
queue = run.then(() => undefined, () => undefined);
|
|
421
|
+
return run;
|
|
422
|
+
}
|
|
423
|
+
const server = net.createServer((socket) => {
|
|
424
|
+
let input = '';
|
|
425
|
+
socket.on('data', (chunk) => {
|
|
426
|
+
input += chunk.toString('utf8');
|
|
427
|
+
if (!input.includes('\n')) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
socket.pause();
|
|
431
|
+
void (async () => {
|
|
432
|
+
let response;
|
|
433
|
+
try {
|
|
434
|
+
const request = JSON.parse(input);
|
|
435
|
+
if (request.type === 'status') {
|
|
436
|
+
response = {
|
|
437
|
+
ok: true,
|
|
438
|
+
data: {
|
|
439
|
+
activeOperation,
|
|
440
|
+
lastError,
|
|
441
|
+
sessions: Array.from(sessions.entries()).map(([key, entry]) => ({
|
|
442
|
+
key,
|
|
443
|
+
runtime: entry.runtime,
|
|
444
|
+
createdAt: entry.createdAt
|
|
445
|
+
}))
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
else if (request.type === 'stop') {
|
|
450
|
+
await closeSessions();
|
|
451
|
+
response = {
|
|
452
|
+
ok: true,
|
|
453
|
+
data: {
|
|
454
|
+
running: false,
|
|
455
|
+
sessionsClosed: true,
|
|
456
|
+
appiumStarted: options.appiumStarted
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
socket.end(`${JSON.stringify(response)}\n`, () => {
|
|
460
|
+
server.close(() => {
|
|
461
|
+
process.exit(0);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
else if (request.type === 'action') {
|
|
467
|
+
const data = await enqueue(async () => {
|
|
468
|
+
activeOperation = `${request.command}:${runtimeKey(request.runtime)}`;
|
|
469
|
+
try {
|
|
470
|
+
return await runActionWithSessionRetry(request.runtime, request.command, request.args);
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
activeOperation = null;
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
response = { ok: true, data };
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
const data = await enqueue(async () => {
|
|
480
|
+
activeOperation = `scenario:${runtimeKey(request.runtime)}`;
|
|
481
|
+
try {
|
|
482
|
+
const adapter = await sessionFor(request.runtime);
|
|
483
|
+
return await runScenario(request.scenario, adapter, request.device, request.timeout, request.outputDir, false);
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
activeOperation = null;
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
response = { ok: true, data };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
response = { ok: false, error: errorMessage(error) };
|
|
494
|
+
}
|
|
495
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
496
|
+
})();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
process.on('SIGTERM', () => {
|
|
500
|
+
void closeSessions().finally(() => {
|
|
501
|
+
server.close();
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
if (process.platform !== 'win32' && fs.existsSync(socketPath())) {
|
|
505
|
+
fs.unlinkSync(socketPath());
|
|
506
|
+
}
|
|
507
|
+
await new Promise((resolve, reject) => {
|
|
508
|
+
server.once('error', reject);
|
|
509
|
+
server.listen(socketPath(), () => {
|
|
510
|
+
server.off('error', reject);
|
|
511
|
+
resolve();
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
await new Promise((resolve) => {
|
|
515
|
+
server.once('close', resolve);
|
|
516
|
+
});
|
|
517
|
+
}
|
package/dist/devices.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
export class DeviceSelectionError extends Error {
|
|
6
|
+
devices;
|
|
7
|
+
constructor(message, devices = []) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'DeviceSelectionError';
|
|
10
|
+
this.devices = devices;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function defaultDeviceCommandRunner(command, args) {
|
|
14
|
+
const { stdout } = await execFileAsync(command, args);
|
|
15
|
+
return stdout.toString();
|
|
16
|
+
}
|
|
17
|
+
let deviceCommandRunner = defaultDeviceCommandRunner;
|
|
18
|
+
export function setDeviceCommandRunner(runner) {
|
|
19
|
+
deviceCommandRunner = runner;
|
|
20
|
+
}
|
|
21
|
+
export function resetDeviceCommandRunner() {
|
|
22
|
+
deviceCommandRunner = defaultDeviceCommandRunner;
|
|
23
|
+
}
|
|
24
|
+
export function deviceLabel(device) {
|
|
25
|
+
return `${device.name} (${device.id}, ${device.platform})`;
|
|
26
|
+
}
|
|
27
|
+
export function parseAndroidDevices(output) {
|
|
28
|
+
return output
|
|
29
|
+
.split(/\r?\n/)
|
|
30
|
+
.map((line) => line.trim())
|
|
31
|
+
.filter((line) => line && !line.startsWith('List of devices attached'))
|
|
32
|
+
.flatMap((line) => {
|
|
33
|
+
const [id, state] = line.split(/\s+/);
|
|
34
|
+
if (!id || state !== 'device') {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return [{ platform: 'android', id, name: id }];
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export function parseIosBootedDevices(output) {
|
|
41
|
+
return output
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map((line) => line.trim())
|
|
44
|
+
.flatMap((line) => {
|
|
45
|
+
const match = line.match(/^(.+?)\s+\(([0-9a-fA-F-]{8,})\)\s+\(Booted\)$/);
|
|
46
|
+
if (!match) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return [{ platform: 'ios', id: match[2], name: match[1] }];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async function discoverAndroidDevices() {
|
|
53
|
+
try {
|
|
54
|
+
const stdout = await deviceCommandRunner('adb', ['devices']);
|
|
55
|
+
return parseAndroidDevices(stdout);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function discoverIosDevices() {
|
|
62
|
+
try {
|
|
63
|
+
const stdout = await deviceCommandRunner('xcrun', ['simctl', 'list', 'devices', 'booted']);
|
|
64
|
+
return parseIosBootedDevices(stdout);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function discoverRunningDevices() {
|
|
71
|
+
const [android, ios] = await Promise.all([discoverAndroidDevices(), discoverIosDevices()]);
|
|
72
|
+
return [...android, ...ios];
|
|
73
|
+
}
|
|
74
|
+
function matchesDevice(device, requested) {
|
|
75
|
+
return device.id === requested || device.name === requested;
|
|
76
|
+
}
|
|
77
|
+
function deviceListForError(devices) {
|
|
78
|
+
if (devices.length === 0) {
|
|
79
|
+
return 'No running Android devices or booted iOS simulators were detected.';
|
|
80
|
+
}
|
|
81
|
+
return `Detected devices: ${devices.map(deviceLabel).join('; ')}`;
|
|
82
|
+
}
|
|
83
|
+
async function promptForDevice(devices, io) {
|
|
84
|
+
if (!io.input.isTTY || !io.output.isTTY) {
|
|
85
|
+
throw new DeviceSelectionError(`${deviceListForError(devices)} Specify one with --device.`, devices);
|
|
86
|
+
}
|
|
87
|
+
io.output.write('Multiple running devices detected:\n');
|
|
88
|
+
devices.forEach((device, index) => {
|
|
89
|
+
io.output.write(` ${index + 1}. ${deviceLabel(device)}\n`);
|
|
90
|
+
});
|
|
91
|
+
const readline = createInterface({ input: io.input, output: io.output });
|
|
92
|
+
try {
|
|
93
|
+
const answer = (await readline.question('Select device number: ')).trim();
|
|
94
|
+
const selectedIndex = Number(answer);
|
|
95
|
+
if (!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > devices.length) {
|
|
96
|
+
throw new DeviceSelectionError(`Invalid device selection '${answer}'. Specify one with --device.`, devices);
|
|
97
|
+
}
|
|
98
|
+
return devices[selectedIndex - 1];
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
readline.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export async function resolveRunningDevice(requestedDevice, io = { input: process.stdin, output: process.stderr }) {
|
|
105
|
+
const devices = await discoverRunningDevices();
|
|
106
|
+
if (requestedDevice) {
|
|
107
|
+
const matches = devices.filter((device) => matchesDevice(device, requestedDevice));
|
|
108
|
+
if (matches.length === 1) {
|
|
109
|
+
return matches[0];
|
|
110
|
+
}
|
|
111
|
+
if (matches.length > 1) {
|
|
112
|
+
throw new DeviceSelectionError(`Device '${requestedDevice}' matches multiple running devices. ${deviceListForError(matches)}`, matches);
|
|
113
|
+
}
|
|
114
|
+
throw new DeviceSelectionError(`Device '${requestedDevice}' is not running or could not be detected. ${deviceListForError(devices)}`, devices);
|
|
115
|
+
}
|
|
116
|
+
if (devices.length === 1) {
|
|
117
|
+
return devices[0];
|
|
118
|
+
}
|
|
119
|
+
if (devices.length === 0) {
|
|
120
|
+
throw new DeviceSelectionError('No running Android devices or booted iOS simulators were detected. Start a target device or pass --device for a detected running target.', devices);
|
|
121
|
+
}
|
|
122
|
+
return promptForDevice(devices, io);
|
|
123
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { executeCommand } from './cli.js';
|
|
6
|
+
import { runDaemonFromEnv } from './daemon.js';
|
|
6
7
|
import { makeError } from './errors.js';
|
|
7
8
|
import { makeId, utcNowIso } from './utils.js';
|
|
8
9
|
function isHelpData(value) {
|
|
@@ -13,6 +14,10 @@ function isHelpData(value) {
|
|
|
13
14
|
}
|
|
14
15
|
async function main(argv = process.argv.slice(2)) {
|
|
15
16
|
try {
|
|
17
|
+
if (argv[0] === '__daemon') {
|
|
18
|
+
await runDaemonFromEnv();
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
16
21
|
const result = await executeCommand(argv);
|
|
17
22
|
if (isHelpData(result.response.data)) {
|
|
18
23
|
console.log(result.response.data.usageText);
|
package/dist/runner.js
CHANGED
|
@@ -61,10 +61,10 @@ function signatureSafeDetails(details) {
|
|
|
61
61
|
}
|
|
62
62
|
return safe;
|
|
63
63
|
}
|
|
64
|
-
export async function runScenario(scenario, adapter, device = 'local', timeoutMs, artifactBaseDir) {
|
|
64
|
+
export async function runScenario(scenario, adapter, device = 'local', timeoutMs, artifactBaseDir, closeAdapter = true) {
|
|
65
65
|
const started_at = utcNowIso();
|
|
66
66
|
const run_id = makeId('run');
|
|
67
|
-
const platform =
|
|
67
|
+
const platform = adapter.capability().platform;
|
|
68
68
|
const stepResults = [];
|
|
69
69
|
const artifacts = [];
|
|
70
70
|
let topError;
|
|
@@ -166,7 +166,9 @@ export async function runScenario(scenario, adapter, device = 'local', timeoutMs
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
finally {
|
|
169
|
-
|
|
169
|
+
if (closeAdapter) {
|
|
170
|
+
await adapter.close();
|
|
171
|
+
}
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
export function determinismCheck(signatures) {
|