@veolab/discoverylab 1.3.1 → 1.3.3
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/chunk-7EDIUVIO.js +4304 -0
- package/dist/{chunk-4L76GPRC.js → chunk-AHVBE25Y.js} +23 -17
- package/dist/chunk-HGWEHWKJ.js +94 -0
- package/dist/{chunk-VRM42PML.js → chunk-LXSWDEXV.js} +276 -56
- package/dist/{chunk-N6JJ2RGV.js → chunk-ZLHIHMSL.js} +1 -1
- package/dist/cli.js +26 -26
- package/dist/{esvp-GSISVXLC.js → esvp-KVOWYW6G.js} +2 -1
- package/dist/{esvp-mobile-GC7MAGMI.js → esvp-mobile-GZ5EMYPG.js} +3 -2
- package/dist/index.d.ts +13 -17
- package/dist/index.html +149 -29
- package/dist/index.js +6 -6
- package/dist/{server-FO3UVUZU.js → server-T5X6GGOO.js} +5 -5
- package/dist/templates/bundle/bundle.js +8 -4
- package/dist/templates/bundle/public/mockup-android-galaxy.png +0 -0
- package/dist/{tools-OCRMOQ4U.js → tools-YGM5HRIB.js} +4 -4
- package/package.json +2 -2
- package/dist/chunk-3QRQEDWR.js +0 -1690
- package/dist/chunk-GAKEFJ5T.js +0 -481
|
@@ -0,0 +1,4304 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APP_VERSION
|
|
3
|
+
} from "./chunk-6EGBXRDK.js";
|
|
4
|
+
import {
|
|
5
|
+
DATA_DIR,
|
|
6
|
+
PROJECTS_DIR
|
|
7
|
+
} from "./chunk-VVIOB362.js";
|
|
8
|
+
|
|
9
|
+
// src/core/integrations/esvp-local-runtime.ts
|
|
10
|
+
import { execFile as execFile2 } from "child_process";
|
|
11
|
+
import { createHash as createHash3 } from "crypto";
|
|
12
|
+
import { mkdir as mkdir2, readFile as readFile2, readdir, stat, writeFile as writeFile2 } from "fs/promises";
|
|
13
|
+
import { basename, extname, join as join4, resolve, sep } from "path";
|
|
14
|
+
import { promisify as promisify3 } from "util";
|
|
15
|
+
|
|
16
|
+
// src/core/integrations/esvp-local-device.ts
|
|
17
|
+
import { execFile } from "child_process";
|
|
18
|
+
import { createHash as createHash2 } from "crypto";
|
|
19
|
+
import { existsSync as existsSync3 } from "fs";
|
|
20
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
21
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
22
|
+
import { promisify as promisify2 } from "util";
|
|
23
|
+
|
|
24
|
+
// src/core/android/adb.ts
|
|
25
|
+
import { execSync } from "child_process";
|
|
26
|
+
import { existsSync } from "fs";
|
|
27
|
+
import { homedir } from "os";
|
|
28
|
+
import { join } from "path";
|
|
29
|
+
var ADB_COMMAND_CACHE_SUCCESS_TTL_MS = 5 * 60 * 1e3;
|
|
30
|
+
var ADB_COMMAND_CACHE_FAILURE_TTL_MS = 5 * 1e3;
|
|
31
|
+
var adbCommandCache = null;
|
|
32
|
+
function quoteCommand(cmd) {
|
|
33
|
+
return cmd.includes(" ") ? `"${cmd}"` : cmd;
|
|
34
|
+
}
|
|
35
|
+
function shellQuoteArg(value) {
|
|
36
|
+
const str = String(value ?? "");
|
|
37
|
+
if (!str) return "''";
|
|
38
|
+
return `'${str.replace(/'/g, `'"'"'`)}'`;
|
|
39
|
+
}
|
|
40
|
+
function findAndroidSdkPath() {
|
|
41
|
+
const envPaths = [
|
|
42
|
+
process.env.ANDROID_HOME,
|
|
43
|
+
process.env.ANDROID_SDK_ROOT,
|
|
44
|
+
process.env.ANDROID_SDK
|
|
45
|
+
].filter((value) => typeof value === "string" && value.trim().length > 0);
|
|
46
|
+
for (const envPath of envPaths) {
|
|
47
|
+
if (existsSync(join(envPath, "platform-tools", "adb"))) {
|
|
48
|
+
return envPath;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const home = homedir();
|
|
52
|
+
const commonPaths = [
|
|
53
|
+
join(home, "Library", "Android", "sdk"),
|
|
54
|
+
join(home, "Android", "Sdk"),
|
|
55
|
+
"/opt/android-sdk",
|
|
56
|
+
"/usr/local/android-sdk"
|
|
57
|
+
];
|
|
58
|
+
for (const sdkPath of commonPaths) {
|
|
59
|
+
if (existsSync(join(sdkPath, "platform-tools", "adb"))) {
|
|
60
|
+
return sdkPath;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function getAdbPath() {
|
|
66
|
+
const sdkPath = findAndroidSdkPath();
|
|
67
|
+
if (!sdkPath) return null;
|
|
68
|
+
const adbPath = join(sdkPath, "platform-tools", "adb");
|
|
69
|
+
return existsSync(adbPath) ? adbPath : null;
|
|
70
|
+
}
|
|
71
|
+
function getEmulatorPath() {
|
|
72
|
+
const sdkPath = findAndroidSdkPath();
|
|
73
|
+
if (!sdkPath) return null;
|
|
74
|
+
const emulatorPath = join(sdkPath, "emulator", "emulator");
|
|
75
|
+
return existsSync(emulatorPath) ? emulatorPath : null;
|
|
76
|
+
}
|
|
77
|
+
function getAdbCommand(options) {
|
|
78
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (!forceRefresh && adbCommandCache) {
|
|
81
|
+
const ttlMs = adbCommandCache.value ? ADB_COMMAND_CACHE_SUCCESS_TTL_MS : ADB_COMMAND_CACHE_FAILURE_TTL_MS;
|
|
82
|
+
if (now - adbCommandCache.checkedAt < ttlMs) {
|
|
83
|
+
return adbCommandCache.value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const candidates = [
|
|
87
|
+
getAdbPath(),
|
|
88
|
+
"/opt/homebrew/bin/adb",
|
|
89
|
+
"/usr/local/bin/adb",
|
|
90
|
+
"adb"
|
|
91
|
+
].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
|
|
92
|
+
for (const candidate of candidates) {
|
|
93
|
+
try {
|
|
94
|
+
if (candidate === "adb") {
|
|
95
|
+
execSync("which adb", { stdio: "pipe", timeout: 2e3 });
|
|
96
|
+
} else if (!existsSync(candidate)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
adbCommandCache = { value: candidate, checkedAt: Date.now() };
|
|
100
|
+
return candidate;
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
adbCommandCache = { value: null, checkedAt: Date.now() };
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function normalizeAndroidDeviceToken(value) {
|
|
108
|
+
return value.trim().replace(/^android:/i, "").replace(/\s+/g, "_").toLowerCase();
|
|
109
|
+
}
|
|
110
|
+
function listConnectedAndroidDevices(adbCommand = getAdbCommand()) {
|
|
111
|
+
if (!adbCommand) return [];
|
|
112
|
+
try {
|
|
113
|
+
const adbOutput = execSync(`${quoteCommand(adbCommand)} devices -l`, {
|
|
114
|
+
encoding: "utf8",
|
|
115
|
+
timeout: 4e3
|
|
116
|
+
});
|
|
117
|
+
const lines = adbOutput.split("\n").slice(1);
|
|
118
|
+
const devices = [];
|
|
119
|
+
for (const rawLine of lines) {
|
|
120
|
+
const line = rawLine.trim();
|
|
121
|
+
if (!line) continue;
|
|
122
|
+
const parts = line.split(/\s+/);
|
|
123
|
+
const serial = parts[0];
|
|
124
|
+
const state = parts[1];
|
|
125
|
+
if (!serial || !state) continue;
|
|
126
|
+
const modelMatch = line.match(/model:(\S+)/);
|
|
127
|
+
const deviceMatch = line.match(/device:(\S+)/);
|
|
128
|
+
const deviceInfo = {
|
|
129
|
+
serial,
|
|
130
|
+
state,
|
|
131
|
+
model: modelMatch?.[1],
|
|
132
|
+
device: deviceMatch?.[1]
|
|
133
|
+
};
|
|
134
|
+
if (serial.startsWith("emulator-") && state === "device") {
|
|
135
|
+
try {
|
|
136
|
+
const avdNameOutput = execSync(
|
|
137
|
+
`${quoteCommand(adbCommand)} -s ${shellQuoteArg(serial)} emu avd name`,
|
|
138
|
+
{
|
|
139
|
+
encoding: "utf8",
|
|
140
|
+
timeout: 1500
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
const avdName = avdNameOutput.split("\n").map((value) => value.trim()).find((value) => value && value !== "OK");
|
|
144
|
+
if (avdName) {
|
|
145
|
+
deviceInfo.avdName = avdName;
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
devices.push(deviceInfo);
|
|
151
|
+
}
|
|
152
|
+
return devices;
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function resolveAndroidDeviceSerial(deviceId, adbCommand = getAdbCommand()) {
|
|
158
|
+
const connectedDevices = listConnectedAndroidDevices(adbCommand);
|
|
159
|
+
const onlineDevices = connectedDevices.filter((device) => device.state === "device");
|
|
160
|
+
if (onlineDevices.length === 0) return null;
|
|
161
|
+
const requested = typeof deviceId === "string" ? deviceId.trim() : "";
|
|
162
|
+
if (!requested) {
|
|
163
|
+
return onlineDevices[0]?.serial || null;
|
|
164
|
+
}
|
|
165
|
+
const requestedToken = normalizeAndroidDeviceToken(requested);
|
|
166
|
+
for (const device of onlineDevices) {
|
|
167
|
+
const candidates = [device.serial, device.avdName, device.model, device.device].filter((value) => Boolean(value)).map((value) => normalizeAndroidDeviceToken(value));
|
|
168
|
+
if (candidates.includes(requestedToken)) {
|
|
169
|
+
return device.serial;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/core/testing/maestro.ts
|
|
176
|
+
import { exec, execSync as execSync2, spawn } from "child_process";
|
|
177
|
+
import { promisify } from "util";
|
|
178
|
+
import * as fs from "fs";
|
|
179
|
+
import * as path from "path";
|
|
180
|
+
import * as os from "os";
|
|
181
|
+
import { createHash } from "crypto";
|
|
182
|
+
import { EventEmitter } from "events";
|
|
183
|
+
var execAsync = promisify(exec);
|
|
184
|
+
var MAESTRO_COMMAND_CACHE_SUCCESS_TTL_MS = 5 * 60 * 1e3;
|
|
185
|
+
var MAESTRO_COMMAND_CACHE_FAILURE_TTL_MS = 5 * 1e3;
|
|
186
|
+
var MAESTRO_DEVICE_CACHE_TTL_MS = 4 * 1e3;
|
|
187
|
+
var maestroCommandCache = null;
|
|
188
|
+
var maestroCommandResolveInFlight = null;
|
|
189
|
+
var maestroDeviceListCache = null;
|
|
190
|
+
var maestroDeviceListInFlight = null;
|
|
191
|
+
function quoteCommand2(cmd) {
|
|
192
|
+
return cmd.includes(" ") ? `"${cmd}"` : cmd;
|
|
193
|
+
}
|
|
194
|
+
function shellQuoteArg2(value) {
|
|
195
|
+
const str = String(value ?? "");
|
|
196
|
+
if (!str) return "''";
|
|
197
|
+
return `'${str.replace(/'/g, `'"'"'`)}'`;
|
|
198
|
+
}
|
|
199
|
+
function getAdbCommandOrThrow() {
|
|
200
|
+
const adbCommand = getAdbCommand();
|
|
201
|
+
if (!adbCommand) {
|
|
202
|
+
throw new Error("ADB not found. Set ANDROID_HOME/ANDROID_SDK_ROOT or add adb to PATH.");
|
|
203
|
+
}
|
|
204
|
+
return adbCommand;
|
|
205
|
+
}
|
|
206
|
+
function getMaestroCommandCandidates() {
|
|
207
|
+
const candidates = [];
|
|
208
|
+
const maestroHomePath = path.join(os.homedir(), ".maestro", "bin", "maestro");
|
|
209
|
+
if (fs.existsSync(maestroHomePath)) {
|
|
210
|
+
candidates.push(maestroHomePath);
|
|
211
|
+
}
|
|
212
|
+
const brewPaths = ["/opt/homebrew/bin/maestro", "/usr/local/bin/maestro"];
|
|
213
|
+
for (const brewPath of brewPaths) {
|
|
214
|
+
if (fs.existsSync(brewPath)) {
|
|
215
|
+
candidates.push(brewPath);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
candidates.push("maestro");
|
|
219
|
+
return candidates;
|
|
220
|
+
}
|
|
221
|
+
function getMaestroCommandCacheTtlMs(value) {
|
|
222
|
+
return value ? MAESTRO_COMMAND_CACHE_SUCCESS_TTL_MS : MAESTRO_COMMAND_CACHE_FAILURE_TTL_MS;
|
|
223
|
+
}
|
|
224
|
+
async function resolveMaestroCommand(options) {
|
|
225
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
if (!forceRefresh && maestroCommandCache) {
|
|
228
|
+
const ttlMs = getMaestroCommandCacheTtlMs(maestroCommandCache.value);
|
|
229
|
+
if (now - maestroCommandCache.checkedAt < ttlMs) {
|
|
230
|
+
return maestroCommandCache.value;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!forceRefresh && maestroCommandResolveInFlight) {
|
|
234
|
+
return maestroCommandResolveInFlight;
|
|
235
|
+
}
|
|
236
|
+
maestroCommandResolveInFlight = (async () => {
|
|
237
|
+
for (const candidate of getMaestroCommandCandidates()) {
|
|
238
|
+
try {
|
|
239
|
+
const cmd = quoteCommand2(candidate);
|
|
240
|
+
await execAsync(`${cmd} --version`);
|
|
241
|
+
maestroCommandCache = { value: candidate, checkedAt: Date.now() };
|
|
242
|
+
return candidate;
|
|
243
|
+
} catch {
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
maestroCommandCache = { value: null, checkedAt: Date.now() };
|
|
247
|
+
return null;
|
|
248
|
+
})();
|
|
249
|
+
try {
|
|
250
|
+
return await maestroCommandResolveInFlight;
|
|
251
|
+
} finally {
|
|
252
|
+
maestroCommandResolveInFlight = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
var PENDING_SESSION_FILE = path.join(PROJECTS_DIR, ".maestro-pending-session.json");
|
|
256
|
+
async function isMaestroInstalled() {
|
|
257
|
+
try {
|
|
258
|
+
return !!await resolveMaestroCommand();
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function isIdbInstalled() {
|
|
264
|
+
try {
|
|
265
|
+
await execAsync("idb --version", { timeout: 3e3 });
|
|
266
|
+
return true;
|
|
267
|
+
} catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async function tapViaIdb(deviceId, x, y) {
|
|
272
|
+
try {
|
|
273
|
+
await execAsync(`idb ui tap ${Math.round(x)} ${Math.round(y)} --udid ${deviceId}`, {
|
|
274
|
+
timeout: 5e3
|
|
275
|
+
});
|
|
276
|
+
return true;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error("[idb tap failed]", error instanceof Error ? error.message : error);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function killZombieMaestroProcesses() {
|
|
283
|
+
try {
|
|
284
|
+
await execAsync('pkill -f "maestro test" || true', { timeout: 3e3 });
|
|
285
|
+
await execAsync('pkill -f "maestro record" || true', { timeout: 3e3 });
|
|
286
|
+
console.log("[MaestroCleanup] Killed zombie maestro processes");
|
|
287
|
+
} catch {
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function getMaestroVersion() {
|
|
291
|
+
try {
|
|
292
|
+
const command = await resolveMaestroCommand();
|
|
293
|
+
if (!command) return null;
|
|
294
|
+
const cmd = quoteCommand2(command);
|
|
295
|
+
const { stdout } = await execAsync(`${cmd} --version`);
|
|
296
|
+
return stdout.trim();
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function listMaestroDevices(options) {
|
|
302
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
303
|
+
const ttlMs = Math.max(0, Number(options?.ttlMs ?? MAESTRO_DEVICE_CACHE_TTL_MS));
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
if (!forceRefresh && maestroDeviceListCache && now - maestroDeviceListCache.fetchedAt < ttlMs) {
|
|
306
|
+
return maestroDeviceListCache.devices;
|
|
307
|
+
}
|
|
308
|
+
if (!forceRefresh && maestroDeviceListInFlight) {
|
|
309
|
+
return maestroDeviceListInFlight;
|
|
310
|
+
}
|
|
311
|
+
maestroDeviceListInFlight = (async () => {
|
|
312
|
+
const devices = [];
|
|
313
|
+
try {
|
|
314
|
+
const { stdout: iosOutput } = await execAsync("xcrun simctl list devices -j");
|
|
315
|
+
const iosData = JSON.parse(iosOutput);
|
|
316
|
+
for (const [runtime, deviceList] of Object.entries(iosData.devices || {})) {
|
|
317
|
+
if (!Array.isArray(deviceList)) continue;
|
|
318
|
+
for (const device of deviceList) {
|
|
319
|
+
if (device.state === "Booted") {
|
|
320
|
+
devices.push({
|
|
321
|
+
id: device.udid,
|
|
322
|
+
name: device.name,
|
|
323
|
+
platform: "ios",
|
|
324
|
+
status: "connected"
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const adbCommand = getAdbCommand();
|
|
333
|
+
if (adbCommand) {
|
|
334
|
+
const { stdout: androidOutput } = await execAsync(`${quoteCommand2(adbCommand)} devices -l`);
|
|
335
|
+
const lines = androidOutput.split("\n").slice(1);
|
|
336
|
+
for (const line of lines) {
|
|
337
|
+
const match = line.match(/^(\S+)\s+device\s+(.*)$/);
|
|
338
|
+
if (match) {
|
|
339
|
+
const [, id, info] = match;
|
|
340
|
+
const modelMatch = info.match(/model:(\S+)/);
|
|
341
|
+
devices.push({
|
|
342
|
+
id,
|
|
343
|
+
name: modelMatch ? modelMatch[1] : id,
|
|
344
|
+
platform: "android",
|
|
345
|
+
status: "connected"
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
}
|
|
352
|
+
maestroDeviceListCache = { devices, fetchedAt: Date.now() };
|
|
353
|
+
return devices;
|
|
354
|
+
})();
|
|
355
|
+
try {
|
|
356
|
+
return await maestroDeviceListInFlight;
|
|
357
|
+
} finally {
|
|
358
|
+
maestroDeviceListInFlight = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function generateMaestroFlow(flow) {
|
|
362
|
+
const lines = [];
|
|
363
|
+
lines.push(`appId: ${flow.appId}`);
|
|
364
|
+
lines.push("");
|
|
365
|
+
if (flow.name) {
|
|
366
|
+
lines.push(`name: ${flow.name}`);
|
|
367
|
+
lines.push("");
|
|
368
|
+
}
|
|
369
|
+
if (flow.env && Object.keys(flow.env).length > 0) {
|
|
370
|
+
lines.push("env:");
|
|
371
|
+
for (const [key, value] of Object.entries(flow.env)) {
|
|
372
|
+
lines.push(` ${key}: "${value}"`);
|
|
373
|
+
}
|
|
374
|
+
lines.push("");
|
|
375
|
+
}
|
|
376
|
+
if (flow.onFlowStart && flow.onFlowStart.length > 0) {
|
|
377
|
+
lines.push("onFlowStart:");
|
|
378
|
+
for (const step of flow.onFlowStart) {
|
|
379
|
+
lines.push(formatFlowStep(step, 2));
|
|
380
|
+
}
|
|
381
|
+
lines.push("");
|
|
382
|
+
}
|
|
383
|
+
if (flow.onFlowComplete && flow.onFlowComplete.length > 0) {
|
|
384
|
+
lines.push("onFlowComplete:");
|
|
385
|
+
for (const step of flow.onFlowComplete) {
|
|
386
|
+
lines.push(formatFlowStep(step, 2));
|
|
387
|
+
}
|
|
388
|
+
lines.push("");
|
|
389
|
+
}
|
|
390
|
+
lines.push("---");
|
|
391
|
+
for (const step of flow.steps) {
|
|
392
|
+
lines.push(formatFlowStep(step, 0));
|
|
393
|
+
}
|
|
394
|
+
return lines.join("\n");
|
|
395
|
+
}
|
|
396
|
+
function formatFlowStep(step, indent) {
|
|
397
|
+
const prefix = " ".repeat(indent);
|
|
398
|
+
const { action, params } = step;
|
|
399
|
+
if (!params || Object.keys(params).length === 0) {
|
|
400
|
+
return `${prefix}- ${action}`;
|
|
401
|
+
}
|
|
402
|
+
if (Object.keys(params).length === 1) {
|
|
403
|
+
const [key, value] = Object.entries(params)[0];
|
|
404
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
405
|
+
return `${prefix}- ${action}:
|
|
406
|
+
${prefix} ${key}: ${formatValue(value)}`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const lines = [`${prefix}- ${action}:`];
|
|
410
|
+
for (const [key, value] of Object.entries(params)) {
|
|
411
|
+
lines.push(`${prefix} ${key}: ${formatValue(value)}`);
|
|
412
|
+
}
|
|
413
|
+
return lines.join("\n");
|
|
414
|
+
}
|
|
415
|
+
function formatValue(value) {
|
|
416
|
+
if (typeof value === "string") {
|
|
417
|
+
return value.includes("\n") || value.includes(":") ? `"${value}"` : value;
|
|
418
|
+
}
|
|
419
|
+
return String(value);
|
|
420
|
+
}
|
|
421
|
+
var MaestroActions = {
|
|
422
|
+
// App lifecycle
|
|
423
|
+
launchApp: (appId) => ({ action: "launchApp", params: appId ? { appId } : void 0 }),
|
|
424
|
+
stopApp: (appId) => ({ action: "stopApp", params: appId ? { appId } : void 0 }),
|
|
425
|
+
clearState: (appId) => ({ action: "clearState", params: appId ? { appId } : void 0 }),
|
|
426
|
+
clearKeychain: () => ({ action: "clearKeychain" }),
|
|
427
|
+
// Navigation
|
|
428
|
+
tapOn: (text) => ({ action: "tapOn", params: { text } }),
|
|
429
|
+
tapOnId: (id) => ({ action: "tapOn", params: { id } }),
|
|
430
|
+
tapOnPoint: (x, y) => ({ action: "tapOn", params: { point: `${x},${y}` } }),
|
|
431
|
+
doubleTapOn: (text) => ({ action: "doubleTapOn", params: { text } }),
|
|
432
|
+
longPressOn: (text) => ({ action: "longPressOn", params: { text } }),
|
|
433
|
+
// Input
|
|
434
|
+
inputText: (text) => ({ action: "inputText", params: { text } }),
|
|
435
|
+
inputRandomText: () => ({ action: "inputRandomText" }),
|
|
436
|
+
inputRandomNumber: () => ({ action: "inputRandomNumber" }),
|
|
437
|
+
inputRandomEmail: () => ({ action: "inputRandomEmail" }),
|
|
438
|
+
eraseText: (chars) => ({ action: "eraseText", params: chars ? { charactersToErase: chars } : void 0 }),
|
|
439
|
+
// Gestures
|
|
440
|
+
scroll: () => ({ action: "scroll" }),
|
|
441
|
+
scrollDown: () => ({ action: "scrollUntilVisible", params: { direction: "DOWN" } }),
|
|
442
|
+
scrollUp: () => ({ action: "scrollUntilVisible", params: { direction: "UP" } }),
|
|
443
|
+
swipeLeft: () => ({ action: "swipe", params: { direction: "LEFT" } }),
|
|
444
|
+
swipeRight: () => ({ action: "swipe", params: { direction: "RIGHT" } }),
|
|
445
|
+
swipeDown: () => ({ action: "swipe", params: { direction: "DOWN" } }),
|
|
446
|
+
swipeUp: () => ({ action: "swipe", params: { direction: "UP" } }),
|
|
447
|
+
// Assertions
|
|
448
|
+
assertVisible: (text) => ({ action: "assertVisible", params: { text } }),
|
|
449
|
+
assertNotVisible: (text) => ({ action: "assertNotVisible", params: { text } }),
|
|
450
|
+
assertTrue: (condition) => ({ action: "assertTrue", params: { condition } }),
|
|
451
|
+
// Wait
|
|
452
|
+
waitForAnimationToEnd: (timeout) => ({
|
|
453
|
+
action: "waitForAnimationToEnd",
|
|
454
|
+
params: timeout ? { timeout } : void 0
|
|
455
|
+
}),
|
|
456
|
+
wait: (ms) => ({ action: "extendedWaitUntil", params: { timeout: ms } }),
|
|
457
|
+
// Screenshots
|
|
458
|
+
takeScreenshot: (path2) => ({ action: "takeScreenshot", params: { path: path2 } }),
|
|
459
|
+
// Keyboard
|
|
460
|
+
hideKeyboard: () => ({ action: "hideKeyboard" }),
|
|
461
|
+
pressKey: (key) => ({ action: "pressKey", params: { key } }),
|
|
462
|
+
back: () => ({ action: "back" }),
|
|
463
|
+
// Conditional
|
|
464
|
+
runFlow: (flowPath) => ({ action: "runFlow", params: { file: flowPath } }),
|
|
465
|
+
runScript: (script) => ({ action: "runScript", params: { script } }),
|
|
466
|
+
// Device
|
|
467
|
+
openLink: (url) => ({ action: "openLink", params: { link: url } }),
|
|
468
|
+
setLocation: (lat, lon) => ({ action: "setLocation", params: { latitude: lat, longitude: lon } }),
|
|
469
|
+
travel: (steps) => ({ action: "travel", params: { points: steps } })
|
|
470
|
+
};
|
|
471
|
+
async function runMaestroTest(options) {
|
|
472
|
+
const installed = await isMaestroInstalled();
|
|
473
|
+
if (!installed) {
|
|
474
|
+
return {
|
|
475
|
+
success: false,
|
|
476
|
+
error: 'Maestro CLI is not installed. Install with: curl -Ls "https://get.maestro.mobile.dev" | bash'
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const {
|
|
480
|
+
flowPath,
|
|
481
|
+
appId,
|
|
482
|
+
device,
|
|
483
|
+
env = {},
|
|
484
|
+
timeout = 3e5,
|
|
485
|
+
// 5 minutes default
|
|
486
|
+
captureVideo = false,
|
|
487
|
+
captureScreenshots = false,
|
|
488
|
+
outputDir = path.join(PROJECTS_DIR, "maestro-output", Date.now().toString())
|
|
489
|
+
} = options;
|
|
490
|
+
const command = await resolveMaestroCommand();
|
|
491
|
+
if (!command) {
|
|
492
|
+
return {
|
|
493
|
+
success: false,
|
|
494
|
+
error: 'Maestro CLI is not installed or not runnable. Install with: curl -Ls "https://get.maestro.mobile.dev" | bash'
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (!fs.existsSync(flowPath)) {
|
|
498
|
+
return { success: false, error: `Flow file not found: ${flowPath}` };
|
|
499
|
+
}
|
|
500
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
501
|
+
const startTime = Date.now();
|
|
502
|
+
const args = ["test", flowPath];
|
|
503
|
+
const maestroCmd = quoteCommand2(command);
|
|
504
|
+
if (device) {
|
|
505
|
+
args.push("--device", device);
|
|
506
|
+
}
|
|
507
|
+
for (const [key, value] of Object.entries(env)) {
|
|
508
|
+
args.push("-e", `${key}=${value}`);
|
|
509
|
+
}
|
|
510
|
+
args.push("--format", "junit");
|
|
511
|
+
args.push("--output", path.join(outputDir, "report.xml"));
|
|
512
|
+
try {
|
|
513
|
+
const shellArgs = args.map(shellQuoteArg2).join(" ");
|
|
514
|
+
const { stdout, stderr } = await execAsync(`${maestroCmd} ${shellArgs}`, {
|
|
515
|
+
timeout,
|
|
516
|
+
env: { ...process.env, ...env }
|
|
517
|
+
});
|
|
518
|
+
const duration = Date.now() - startTime;
|
|
519
|
+
const screenshots = [];
|
|
520
|
+
const videoPath = captureVideo ? path.join(outputDir, "recording.mp4") : void 0;
|
|
521
|
+
if (captureScreenshots) {
|
|
522
|
+
const files = await fs.promises.readdir(outputDir);
|
|
523
|
+
for (const file of files) {
|
|
524
|
+
if (file.endsWith(".png")) {
|
|
525
|
+
screenshots.push(path.join(outputDir, file));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
success: true,
|
|
531
|
+
duration,
|
|
532
|
+
flowPath,
|
|
533
|
+
output: stdout + stderr,
|
|
534
|
+
screenshots,
|
|
535
|
+
video: videoPath
|
|
536
|
+
};
|
|
537
|
+
} catch (error) {
|
|
538
|
+
const duration = Date.now() - startTime;
|
|
539
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
540
|
+
const errLike = error;
|
|
541
|
+
const stdout = typeof errLike?.stdout === "string" ? errLike.stdout : Buffer.isBuffer(errLike?.stdout) ? errLike.stdout.toString("utf8") : "";
|
|
542
|
+
const stderr = typeof errLike?.stderr === "string" ? errLike.stderr : Buffer.isBuffer(errLike?.stderr) ? errLike.stderr.toString("utf8") : "";
|
|
543
|
+
const detailedOutput = [stderr?.trim(), stdout?.trim(), message].filter(Boolean).join("\n\n").slice(-16e3);
|
|
544
|
+
return {
|
|
545
|
+
success: false,
|
|
546
|
+
error: stderr?.trim() || message,
|
|
547
|
+
duration,
|
|
548
|
+
flowPath,
|
|
549
|
+
output: detailedOutput || message
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
async function runMaestroWithCapture(options, onProgress) {
|
|
554
|
+
const {
|
|
555
|
+
flowPath,
|
|
556
|
+
device,
|
|
557
|
+
outputDir = path.join(PROJECTS_DIR, "maestro-output", Date.now().toString())
|
|
558
|
+
} = options;
|
|
559
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
560
|
+
const videoPath = path.join(outputDir, "recording.mp4");
|
|
561
|
+
let recordingProcess = null;
|
|
562
|
+
try {
|
|
563
|
+
onProgress?.("Starting screen recording...");
|
|
564
|
+
if (device) {
|
|
565
|
+
const devices = await listMaestroDevices();
|
|
566
|
+
const targetDevice = devices.find((d) => d.id === device || d.name === device);
|
|
567
|
+
if (targetDevice?.platform === "ios") {
|
|
568
|
+
recordingProcess = spawn("xcrun", ["simctl", "io", device, "recordVideo", videoPath]);
|
|
569
|
+
} else if (targetDevice?.platform === "android") {
|
|
570
|
+
const adbCommand = getAdbCommandOrThrow();
|
|
571
|
+
recordingProcess = spawn(adbCommand, ["-s", device, "shell", "screenrecord", "/sdcard/recording.mp4"]);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
onProgress?.("Running Maestro test...");
|
|
575
|
+
const result = await runMaestroTest({ ...options, outputDir });
|
|
576
|
+
onProgress?.("Stopping screen recording...");
|
|
577
|
+
if (recordingProcess) {
|
|
578
|
+
recordingProcess.kill("SIGINT");
|
|
579
|
+
await new Promise((resolve2) => setTimeout(resolve2, 2e3));
|
|
580
|
+
const devices = await listMaestroDevices();
|
|
581
|
+
const targetDevice = devices.find((d) => d.id === device || d.name === device);
|
|
582
|
+
if (targetDevice?.platform === "android") {
|
|
583
|
+
const adbCommand = getAdbCommandOrThrow();
|
|
584
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(device)} pull /sdcard/recording.mp4 "${videoPath}"`);
|
|
585
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(device)} shell rm /sdcard/recording.mp4`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
...result,
|
|
590
|
+
video: fs.existsSync(videoPath) ? videoPath : void 0
|
|
591
|
+
};
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (recordingProcess) {
|
|
594
|
+
recordingProcess.kill("SIGKILL");
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
success: false,
|
|
598
|
+
error: error instanceof Error ? error.message : String(error),
|
|
599
|
+
flowPath
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async function startMaestroStudio(appId) {
|
|
604
|
+
const installed = await isMaestroInstalled();
|
|
605
|
+
if (!installed) {
|
|
606
|
+
return {
|
|
607
|
+
success: false,
|
|
608
|
+
error: "Maestro CLI is not installed"
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
const args = ["studio"];
|
|
613
|
+
if (appId) {
|
|
614
|
+
args.push(appId);
|
|
615
|
+
}
|
|
616
|
+
const maestroCmd = await resolveMaestroCommand();
|
|
617
|
+
if (!maestroCmd) {
|
|
618
|
+
return { success: false, error: "Maestro CLI is not installed or not runnable" };
|
|
619
|
+
}
|
|
620
|
+
spawn(maestroCmd, args, {
|
|
621
|
+
detached: true,
|
|
622
|
+
stdio: "ignore"
|
|
623
|
+
}).unref();
|
|
624
|
+
return { success: true };
|
|
625
|
+
} catch (error) {
|
|
626
|
+
return {
|
|
627
|
+
success: false,
|
|
628
|
+
error: error instanceof Error ? error.message : String(error)
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function createLoginFlow(appId, usernameField, passwordField, loginButton, successIndicator) {
|
|
633
|
+
return {
|
|
634
|
+
appId,
|
|
635
|
+
name: "Login Flow",
|
|
636
|
+
steps: [
|
|
637
|
+
MaestroActions.launchApp(appId),
|
|
638
|
+
MaestroActions.waitForAnimationToEnd(),
|
|
639
|
+
MaestroActions.tapOn(usernameField),
|
|
640
|
+
MaestroActions.inputText("${USERNAME}"),
|
|
641
|
+
MaestroActions.tapOn(passwordField),
|
|
642
|
+
MaestroActions.inputText("${PASSWORD}"),
|
|
643
|
+
MaestroActions.hideKeyboard(),
|
|
644
|
+
MaestroActions.tapOn(loginButton),
|
|
645
|
+
MaestroActions.waitForAnimationToEnd(5e3),
|
|
646
|
+
MaestroActions.assertVisible(successIndicator)
|
|
647
|
+
]
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function createOnboardingFlow(appId, screens) {
|
|
651
|
+
const steps = [
|
|
652
|
+
MaestroActions.launchApp(appId),
|
|
653
|
+
MaestroActions.waitForAnimationToEnd()
|
|
654
|
+
];
|
|
655
|
+
for (const screen of screens) {
|
|
656
|
+
if (screen.waitFor) {
|
|
657
|
+
steps.push(MaestroActions.assertVisible(screen.waitFor));
|
|
658
|
+
}
|
|
659
|
+
steps.push(MaestroActions.tapOn(screen.nextButton));
|
|
660
|
+
steps.push(MaestroActions.waitForAnimationToEnd());
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
appId,
|
|
664
|
+
name: "Onboarding Flow",
|
|
665
|
+
steps
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
function createNavigationTestFlow(appId, tabs) {
|
|
669
|
+
const steps = [
|
|
670
|
+
MaestroActions.launchApp(appId),
|
|
671
|
+
MaestroActions.waitForAnimationToEnd()
|
|
672
|
+
];
|
|
673
|
+
for (const tab of tabs) {
|
|
674
|
+
steps.push(MaestroActions.tapOn(tab));
|
|
675
|
+
steps.push(MaestroActions.waitForAnimationToEnd());
|
|
676
|
+
steps.push(MaestroActions.takeScreenshot(`${tab.replace(/\s+/g, "_")}.png`));
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
appId,
|
|
680
|
+
name: "Navigation Test",
|
|
681
|
+
steps
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function parseMaestroActionsFromYaml(yamlContent) {
|
|
685
|
+
const actions = [];
|
|
686
|
+
const lines = yamlContent.split("\n");
|
|
687
|
+
let currentAction = null;
|
|
688
|
+
let actionIndex = 0;
|
|
689
|
+
const nextId = () => `action_${String(++actionIndex).padStart(3, "0")}`;
|
|
690
|
+
const stripInlineComment = (value) => {
|
|
691
|
+
let inSingle = false;
|
|
692
|
+
let inDouble = false;
|
|
693
|
+
let escaped = false;
|
|
694
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
695
|
+
const char = value[index];
|
|
696
|
+
if (escaped) {
|
|
697
|
+
escaped = false;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (char === "\\") {
|
|
701
|
+
escaped = true;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (char === '"' && !inSingle) {
|
|
705
|
+
inDouble = !inDouble;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (char === "'" && !inDouble) {
|
|
709
|
+
inSingle = !inSingle;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (char === "#" && !inSingle && !inDouble) {
|
|
713
|
+
return value.slice(0, index).trimEnd();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return value;
|
|
717
|
+
};
|
|
718
|
+
const cleanValue = (value) => stripInlineComment(value).trim().replace(/^["']|["']$/g, "");
|
|
719
|
+
const pushAction = (action) => {
|
|
720
|
+
actions.push({
|
|
721
|
+
id: action.id || nextId(),
|
|
722
|
+
type: action.type || "tap",
|
|
723
|
+
timestamp: action.timestamp || Date.now(),
|
|
724
|
+
description: action.description || action.type || "Action",
|
|
725
|
+
x: action.x,
|
|
726
|
+
y: action.y,
|
|
727
|
+
endX: action.endX,
|
|
728
|
+
endY: action.endY,
|
|
729
|
+
text: action.text,
|
|
730
|
+
direction: action.direction,
|
|
731
|
+
seconds: action.seconds,
|
|
732
|
+
appId: action.appId,
|
|
733
|
+
duration: action.duration,
|
|
734
|
+
screenshotPath: action.screenshotPath
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
for (const line of lines) {
|
|
738
|
+
const trimmed = line.trim();
|
|
739
|
+
if (trimmed.startsWith("#") || trimmed === "" || trimmed === "---") {
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (trimmed.startsWith("appId:")) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (trimmed.startsWith("- tapOn:")) {
|
|
746
|
+
const inlineValue = cleanValue(trimmed.replace("- tapOn:", ""));
|
|
747
|
+
if (inlineValue) {
|
|
748
|
+
pushAction({
|
|
749
|
+
type: "tap",
|
|
750
|
+
description: `Tap on "${inlineValue}"`,
|
|
751
|
+
text: inlineValue
|
|
752
|
+
});
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
currentAction = { type: "tap", description: "Tap" };
|
|
756
|
+
} else if (trimmed.startsWith("- tap:")) {
|
|
757
|
+
currentAction = { type: "tap", description: "Tap" };
|
|
758
|
+
} else if (trimmed.startsWith("- swipe:")) {
|
|
759
|
+
currentAction = { type: "swipe", description: "Swipe" };
|
|
760
|
+
} else if (trimmed.startsWith("- scroll:")) {
|
|
761
|
+
currentAction = { type: "scroll", description: "Scroll" };
|
|
762
|
+
} else if (trimmed.startsWith("- scrollUntilVisible:")) {
|
|
763
|
+
currentAction = { type: "scroll", description: "Scroll" };
|
|
764
|
+
} else if (trimmed.startsWith("- inputText:")) {
|
|
765
|
+
const text = cleanValue(trimmed.replace("- inputText:", ""));
|
|
766
|
+
pushAction({
|
|
767
|
+
type: "input",
|
|
768
|
+
description: `Input: ${text.slice(0, 20)}${text.length > 20 ? "..." : ""}`,
|
|
769
|
+
text
|
|
770
|
+
});
|
|
771
|
+
continue;
|
|
772
|
+
} else if (trimmed.startsWith("- launchApp:")) {
|
|
773
|
+
const appId = cleanValue(trimmed.replace("- launchApp:", ""));
|
|
774
|
+
pushAction({
|
|
775
|
+
type: "launch",
|
|
776
|
+
description: `Launch: ${appId}`,
|
|
777
|
+
text: appId,
|
|
778
|
+
appId
|
|
779
|
+
});
|
|
780
|
+
continue;
|
|
781
|
+
} else if (trimmed === "- launchApp") {
|
|
782
|
+
pushAction({
|
|
783
|
+
type: "launch",
|
|
784
|
+
description: "Launch app"
|
|
785
|
+
});
|
|
786
|
+
continue;
|
|
787
|
+
} else if (trimmed.startsWith("- assertVisible:")) {
|
|
788
|
+
const inlineValue = cleanValue(trimmed.replace("- assertVisible:", ""));
|
|
789
|
+
if (inlineValue) {
|
|
790
|
+
pushAction({
|
|
791
|
+
type: "assert",
|
|
792
|
+
description: `Assert visible "${inlineValue}"`,
|
|
793
|
+
text: inlineValue
|
|
794
|
+
});
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
currentAction = { type: "assert", description: "Assert visible" };
|
|
798
|
+
} else if (trimmed.startsWith("- extendedWaitUntil:")) {
|
|
799
|
+
currentAction = { type: "wait", description: "Wait" };
|
|
800
|
+
} else if (trimmed.startsWith("- pressKey:")) {
|
|
801
|
+
const key = cleanValue(trimmed.replace("- pressKey:", "")).toLowerCase();
|
|
802
|
+
if (key === "back") {
|
|
803
|
+
pushAction({ type: "back", description: "Back button" });
|
|
804
|
+
} else if (key === "home") {
|
|
805
|
+
pushAction({ type: "home", description: "Home button" });
|
|
806
|
+
} else {
|
|
807
|
+
pushAction({
|
|
808
|
+
type: "pressKey",
|
|
809
|
+
description: `Press: ${key}`,
|
|
810
|
+
text: key
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
continue;
|
|
814
|
+
} else if (trimmed.startsWith("- back")) {
|
|
815
|
+
pushAction({ type: "back", description: "Back button" });
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (currentAction && trimmed.startsWith("point:")) {
|
|
819
|
+
const point = cleanValue(trimmed.replace("point:", ""));
|
|
820
|
+
const [x, y] = point.split(",").map((n) => parseInt(n.trim()));
|
|
821
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
822
|
+
currentAction.x = x;
|
|
823
|
+
currentAction.y = y;
|
|
824
|
+
currentAction.description = `Tap at (${x}, ${y})`;
|
|
825
|
+
pushAction(currentAction);
|
|
826
|
+
currentAction = null;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (currentAction && trimmed.startsWith("text:")) {
|
|
831
|
+
const text = cleanValue(trimmed.replace("text:", ""));
|
|
832
|
+
currentAction.text = text;
|
|
833
|
+
if (currentAction.type === "assert") {
|
|
834
|
+
currentAction.description = `Assert visible "${text}"`;
|
|
835
|
+
} else {
|
|
836
|
+
currentAction.description = `Tap on "${text}"`;
|
|
837
|
+
}
|
|
838
|
+
pushAction(currentAction);
|
|
839
|
+
currentAction = null;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (currentAction && trimmed.startsWith("id:")) {
|
|
843
|
+
const id = cleanValue(trimmed.replace("id:", ""));
|
|
844
|
+
currentAction.text = id;
|
|
845
|
+
currentAction.description = `Tap on id: ${id}`;
|
|
846
|
+
pushAction(currentAction);
|
|
847
|
+
currentAction = null;
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
if (currentAction && (currentAction.type === "swipe" || currentAction.type === "scroll") && trimmed.startsWith("direction:")) {
|
|
851
|
+
const direction = cleanValue(trimmed.replace("direction:", "")).toLowerCase();
|
|
852
|
+
currentAction.direction = direction;
|
|
853
|
+
currentAction.description = currentAction.type === "swipe" ? `Swipe ${direction}` : `Scroll ${direction}`;
|
|
854
|
+
pushAction(currentAction);
|
|
855
|
+
currentAction = null;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (currentAction && currentAction.type === "wait" && trimmed.startsWith("timeout:")) {
|
|
859
|
+
const timeoutMs = parseInt(cleanValue(trimmed.replace("timeout:", "")), 10);
|
|
860
|
+
const seconds = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.max(1, Math.round(timeoutMs / 1e3)) : 3;
|
|
861
|
+
currentAction.seconds = seconds;
|
|
862
|
+
currentAction.description = `Wait ${seconds}s`;
|
|
863
|
+
pushAction(currentAction);
|
|
864
|
+
currentAction = null;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (currentAction && currentAction.type === "swipe") {
|
|
868
|
+
if (trimmed.startsWith("start:")) {
|
|
869
|
+
const point = cleanValue(trimmed.replace("start:", ""));
|
|
870
|
+
const [x, y] = point.split(",").map((n) => parseInt(n.trim()));
|
|
871
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
872
|
+
currentAction.x = x;
|
|
873
|
+
currentAction.y = y;
|
|
874
|
+
}
|
|
875
|
+
} else if (trimmed.startsWith("end:")) {
|
|
876
|
+
const point = cleanValue(trimmed.replace("end:", ""));
|
|
877
|
+
const [x, y] = point.split(",").map((n) => parseInt(n.trim()));
|
|
878
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
879
|
+
currentAction.endX = x;
|
|
880
|
+
currentAction.endY = y;
|
|
881
|
+
currentAction.description = `Swipe from (${currentAction.x}, ${currentAction.y}) to (${x}, ${y})`;
|
|
882
|
+
pushAction(currentAction);
|
|
883
|
+
currentAction = null;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return actions;
|
|
889
|
+
}
|
|
890
|
+
var MaestroRecorder = class extends EventEmitter {
|
|
891
|
+
session = null;
|
|
892
|
+
adbProcess = null;
|
|
893
|
+
videoProcess = null;
|
|
894
|
+
maestroRecordProcess = null;
|
|
895
|
+
// Native maestro record process
|
|
896
|
+
actionCounter = 0;
|
|
897
|
+
screenshotInterval = null;
|
|
898
|
+
useNativeMaestroRecord = true;
|
|
899
|
+
// Use native maestro record for better accuracy
|
|
900
|
+
legacyCaptureStarted = false;
|
|
901
|
+
lastMaestroRecordErrorLine = null;
|
|
902
|
+
baseScreenshotIntervalMs = 2e3;
|
|
903
|
+
maxScreenshotIntervalMs = 12e3;
|
|
904
|
+
currentScreenshotIntervalMs = this.baseScreenshotIntervalMs;
|
|
905
|
+
nextScreenshotAt = 0;
|
|
906
|
+
lastScreenshotHash = null;
|
|
907
|
+
unchangedScreenshotCount = 0;
|
|
908
|
+
screenshotBackoffThreshold = 3;
|
|
909
|
+
constructor() {
|
|
910
|
+
super();
|
|
911
|
+
this.loadPendingSession();
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Check if maestro is available in system PATH
|
|
915
|
+
*/
|
|
916
|
+
isMaestroInPath() {
|
|
917
|
+
try {
|
|
918
|
+
execSync2("which maestro", { encoding: "utf-8", timeout: 2e3 });
|
|
919
|
+
return true;
|
|
920
|
+
} catch {
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
canRunMaestro(cmd) {
|
|
925
|
+
try {
|
|
926
|
+
execSync2(`"${cmd}" --version`, { encoding: "utf-8", timeout: 3e3 });
|
|
927
|
+
return true;
|
|
928
|
+
} catch {
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
startLegacyCapture(deviceId, platform) {
|
|
933
|
+
if (platform === "android") {
|
|
934
|
+
if (!this.adbProcess) {
|
|
935
|
+
this.startAndroidEventCapture(deviceId);
|
|
936
|
+
}
|
|
937
|
+
} else {
|
|
938
|
+
this.startIOSEventCapture(deviceId);
|
|
939
|
+
}
|
|
940
|
+
this.legacyCaptureStarted = true;
|
|
941
|
+
}
|
|
942
|
+
fallbackToLegacyCapture(reason) {
|
|
943
|
+
if (!this.session || this.session.status !== "recording") return;
|
|
944
|
+
if (this.legacyCaptureStarted) return;
|
|
945
|
+
console.log(`[MaestroRecorder] Native record stopped (${reason}). Falling back to manual capture.`);
|
|
946
|
+
this.session.captureMode = "manual";
|
|
947
|
+
const stderrSuffix = this.lastMaestroRecordErrorLine ? ` | ${this.lastMaestroRecordErrorLine}` : "";
|
|
948
|
+
this.session.captureModeReason = `maestro record falhou (${reason})${stderrSuffix}`;
|
|
949
|
+
this.startLegacyCapture(this.session.deviceId, this.session.platform);
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Save session state to disk for recovery after server restart
|
|
953
|
+
*/
|
|
954
|
+
savePendingSession() {
|
|
955
|
+
if (!this.session) return;
|
|
956
|
+
try {
|
|
957
|
+
fs.writeFileSync(PENDING_SESSION_FILE, JSON.stringify(this.session, null, 2));
|
|
958
|
+
} catch (error) {
|
|
959
|
+
console.error("[MaestroRecorder] Failed to save pending session:", error);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Load pending session from disk (after server restart)
|
|
964
|
+
*/
|
|
965
|
+
loadPendingSession() {
|
|
966
|
+
try {
|
|
967
|
+
if (fs.existsSync(PENDING_SESSION_FILE)) {
|
|
968
|
+
const data = fs.readFileSync(PENDING_SESSION_FILE, "utf8");
|
|
969
|
+
const savedSession = JSON.parse(data);
|
|
970
|
+
if (savedSession.status === "recording") {
|
|
971
|
+
console.log("[MaestroRecorder] Restoring pending session from disk:", savedSession.id);
|
|
972
|
+
this.session = savedSession;
|
|
973
|
+
this.actionCounter = savedSession.actions.length;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
} catch (error) {
|
|
977
|
+
console.error("[MaestroRecorder] Failed to load pending session:", error);
|
|
978
|
+
try {
|
|
979
|
+
fs.unlinkSync(PENDING_SESSION_FILE);
|
|
980
|
+
} catch {
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Clear pending session file (after successful stop)
|
|
986
|
+
*/
|
|
987
|
+
clearPendingSession() {
|
|
988
|
+
try {
|
|
989
|
+
if (fs.existsSync(PENDING_SESSION_FILE)) {
|
|
990
|
+
fs.unlinkSync(PENDING_SESSION_FILE);
|
|
991
|
+
}
|
|
992
|
+
} catch (error) {
|
|
993
|
+
console.error("[MaestroRecorder] Failed to clear pending session:", error);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Persist session metadata to session.json (available during recording)
|
|
998
|
+
*/
|
|
999
|
+
async writeSessionMetadata() {
|
|
1000
|
+
if (!this.session) return;
|
|
1001
|
+
try {
|
|
1002
|
+
const metadataPath = path.join(this.session.screenshotsDir, "..", "session.json");
|
|
1003
|
+
await fs.promises.writeFile(metadataPath, JSON.stringify(this.session, null, 2));
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
console.error("[MaestroRecorder] Failed to write session metadata:", error);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Start recording a mobile session
|
|
1010
|
+
*/
|
|
1011
|
+
async startRecording(name, deviceId, deviceName, platform, appId, options = {}) {
|
|
1012
|
+
if (this.session?.status === "recording") {
|
|
1013
|
+
const hasActiveProcesses = !!(this.videoProcess || this.maestroRecordProcess || this.adbProcess);
|
|
1014
|
+
if (hasActiveProcesses) {
|
|
1015
|
+
throw new Error("Recording already in progress");
|
|
1016
|
+
}
|
|
1017
|
+
console.log("[MaestroRecorder] Stale recording session detected. Clearing before starting a new one.");
|
|
1018
|
+
this.session = null;
|
|
1019
|
+
if (this.screenshotInterval) {
|
|
1020
|
+
clearInterval(this.screenshotInterval);
|
|
1021
|
+
this.screenshotInterval = null;
|
|
1022
|
+
}
|
|
1023
|
+
this.clearPendingSession();
|
|
1024
|
+
}
|
|
1025
|
+
const sessionId = `maestro_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1026
|
+
const baseDir = path.join(PROJECTS_DIR, "maestro-recordings", sessionId);
|
|
1027
|
+
const screenshotsDir = path.join(baseDir, "screenshots");
|
|
1028
|
+
await fs.promises.mkdir(screenshotsDir, { recursive: true });
|
|
1029
|
+
this.session = {
|
|
1030
|
+
id: sessionId,
|
|
1031
|
+
name,
|
|
1032
|
+
startedAt: Date.now(),
|
|
1033
|
+
appId,
|
|
1034
|
+
deviceId,
|
|
1035
|
+
deviceName,
|
|
1036
|
+
platform,
|
|
1037
|
+
actions: [],
|
|
1038
|
+
screenshotsDir,
|
|
1039
|
+
status: "recording"
|
|
1040
|
+
};
|
|
1041
|
+
this.actionCounter = 0;
|
|
1042
|
+
try {
|
|
1043
|
+
const adbCommand = platform === "android" ? getAdbCommandOrThrow() : null;
|
|
1044
|
+
const flowPath = path.join(baseDir, "test.yaml");
|
|
1045
|
+
this.session.flowPath = flowPath;
|
|
1046
|
+
const videoPath = path.join(baseDir, "recording.mp4");
|
|
1047
|
+
this.session.videoPath = videoPath;
|
|
1048
|
+
const maestroHomePath = path.join(os.homedir(), ".maestro", "bin", "maestro");
|
|
1049
|
+
const maestroPath = fs.existsSync(maestroHomePath) ? maestroHomePath : "maestro";
|
|
1050
|
+
const maestroExists = fs.existsSync(maestroHomePath) || this.isMaestroInPath();
|
|
1051
|
+
const maestroRunnable = maestroExists && this.canRunMaestro(maestroPath);
|
|
1052
|
+
const preferNativeRecord = options.preferNativeRecord !== false;
|
|
1053
|
+
const useNativeRecord = preferNativeRecord && this.useNativeMaestroRecord && maestroRunnable;
|
|
1054
|
+
if (!maestroRunnable && this.useNativeMaestroRecord) {
|
|
1055
|
+
console.log("[MaestroRecorder] \u26A0\uFE0F Maestro CLI not available or not runnable. Falling back to screenshot-based capture.");
|
|
1056
|
+
console.log('[MaestroRecorder] Ensure Java is installed or reinstall Maestro: curl -Ls "https://get.maestro.mobile.dev" | bash');
|
|
1057
|
+
this.session.captureModeReason = "Maestro CLI indispon\xEDvel/n\xE3o execut\xE1vel (verifique Java e reinstale o Maestro).";
|
|
1058
|
+
}
|
|
1059
|
+
if (!preferNativeRecord) {
|
|
1060
|
+
console.log("[MaestroRecorder] Native record disabled for this session (manual capture mode).");
|
|
1061
|
+
this.session.captureModeReason = "Native Maestro record desabilitado para esta sess\xE3o.";
|
|
1062
|
+
}
|
|
1063
|
+
let nativeRecordStarted = false;
|
|
1064
|
+
this.legacyCaptureStarted = false;
|
|
1065
|
+
if (useNativeRecord) {
|
|
1066
|
+
console.log("[MaestroRecorder] Starting native maestro record...");
|
|
1067
|
+
this.lastMaestroRecordErrorLine = null;
|
|
1068
|
+
try {
|
|
1069
|
+
const recordArgs = ["record", flowPath, "--device", deviceId];
|
|
1070
|
+
this.maestroRecordProcess = spawn(maestroPath, recordArgs, {
|
|
1071
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1072
|
+
env: { ...process.env }
|
|
1073
|
+
});
|
|
1074
|
+
nativeRecordStarted = true;
|
|
1075
|
+
this.maestroRecordProcess.stdout?.on("data", (data) => {
|
|
1076
|
+
console.log("[MaestroRecord]", data.toString().trim());
|
|
1077
|
+
});
|
|
1078
|
+
this.maestroRecordProcess.stderr?.on("data", (data) => {
|
|
1079
|
+
const text = data.toString().trim();
|
|
1080
|
+
console.log("[MaestroRecord ERROR]", text);
|
|
1081
|
+
const lastLine = text.split("\n").map((line) => line.trim()).filter(Boolean).pop();
|
|
1082
|
+
if (lastLine) {
|
|
1083
|
+
this.lastMaestroRecordErrorLine = lastLine;
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
this.maestroRecordProcess.on("error", (err) => {
|
|
1087
|
+
console.log("[MaestroRecord] Spawn error:", err.message);
|
|
1088
|
+
this.fallbackToLegacyCapture("spawn error");
|
|
1089
|
+
this.maestroRecordProcess = null;
|
|
1090
|
+
});
|
|
1091
|
+
this.maestroRecordProcess.on("close", (code) => {
|
|
1092
|
+
console.log(`[MaestroRecord] Process exited with code ${code}`);
|
|
1093
|
+
this.fallbackToLegacyCapture(`exit code ${code}`);
|
|
1094
|
+
this.maestroRecordProcess = null;
|
|
1095
|
+
});
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
console.log("[MaestroRecorder] Failed to start native record, using legacy capture");
|
|
1098
|
+
this.maestroRecordProcess = null;
|
|
1099
|
+
nativeRecordStarted = false;
|
|
1100
|
+
this.session.captureModeReason = err instanceof Error ? `Falha ao iniciar maestro record: ${err.message}` : "Falha ao iniciar maestro record";
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
this.session.captureMode = nativeRecordStarted ? "native" : "manual";
|
|
1104
|
+
if (nativeRecordStarted) {
|
|
1105
|
+
this.session.captureModeReason = void 0;
|
|
1106
|
+
}
|
|
1107
|
+
if (!nativeRecordStarted && this.session.flowPath) {
|
|
1108
|
+
try {
|
|
1109
|
+
const initialYaml = this.generateFlowYaml();
|
|
1110
|
+
await fs.promises.writeFile(this.session.flowPath, initialYaml);
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
console.error("[MaestroRecorder] Failed to write initial flow YAML:", error);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
await this.writeSessionMetadata();
|
|
1116
|
+
if (nativeRecordStarted) {
|
|
1117
|
+
if (platform === "ios") {
|
|
1118
|
+
this.videoProcess = spawn("xcrun", ["simctl", "io", deviceId, "recordVideo", videoPath]);
|
|
1119
|
+
} else {
|
|
1120
|
+
const tempVideoPath = "/sdcard/maestro-recording.mp4";
|
|
1121
|
+
this.videoProcess = spawn(adbCommand, ["-s", deviceId, "shell", "screenrecord", "--time-limit", "180", tempVideoPath]);
|
|
1122
|
+
}
|
|
1123
|
+
} else {
|
|
1124
|
+
if (useNativeRecord) {
|
|
1125
|
+
console.log("[MaestroRecorder] Native record failed to start, using legacy capture");
|
|
1126
|
+
}
|
|
1127
|
+
if (platform === "android") {
|
|
1128
|
+
const tempVideoPath = "/sdcard/maestro-recording.mp4";
|
|
1129
|
+
this.videoProcess = spawn(adbCommand, ["-s", deviceId, "shell", "screenrecord", "--time-limit", "180", tempVideoPath]);
|
|
1130
|
+
this.startLegacyCapture(deviceId, platform);
|
|
1131
|
+
} else {
|
|
1132
|
+
this.videoProcess = spawn("xcrun", ["simctl", "io", deviceId, "recordVideo", videoPath]);
|
|
1133
|
+
this.startLegacyCapture(deviceId, platform);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
this.currentScreenshotIntervalMs = this.baseScreenshotIntervalMs;
|
|
1137
|
+
this.nextScreenshotAt = 0;
|
|
1138
|
+
this.lastScreenshotHash = null;
|
|
1139
|
+
this.unchangedScreenshotCount = 0;
|
|
1140
|
+
this.screenshotInterval = setInterval(() => {
|
|
1141
|
+
this.captureScreenshot(void 0, { reason: "periodic" });
|
|
1142
|
+
}, 2e3);
|
|
1143
|
+
this.savePendingSession();
|
|
1144
|
+
this.emit("status", "recording");
|
|
1145
|
+
return this.session;
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
this.session.status = "error";
|
|
1148
|
+
this.clearPendingSession();
|
|
1149
|
+
this.emit("error", error);
|
|
1150
|
+
throw error;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Capture Android touch events via ADB
|
|
1155
|
+
*/
|
|
1156
|
+
startAndroidEventCapture(deviceId) {
|
|
1157
|
+
const adbCommand = getAdbCommand();
|
|
1158
|
+
if (!adbCommand) {
|
|
1159
|
+
console.error("[MaestroRecorder] ADB not found. Android event capture is unavailable.");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
exec(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(deviceId)} shell wm size`, (err, stdout) => {
|
|
1163
|
+
if (err) return;
|
|
1164
|
+
const match = stdout.match(/(\d+)x(\d+)/);
|
|
1165
|
+
const screenWidth = match ? parseInt(match[1]) : 1080;
|
|
1166
|
+
const screenHeight = match ? parseInt(match[2]) : 1920;
|
|
1167
|
+
this.adbProcess = spawn(adbCommand, ["-s", deviceId, "shell", "getevent", "-lt"]);
|
|
1168
|
+
let touchStartTime = 0;
|
|
1169
|
+
let touchStartX = 0;
|
|
1170
|
+
let touchStartY = 0;
|
|
1171
|
+
let currentX = 0;
|
|
1172
|
+
let currentY = 0;
|
|
1173
|
+
let isTracking = false;
|
|
1174
|
+
this.adbProcess.stdout?.on("data", (data) => {
|
|
1175
|
+
const lines = data.toString().split("\n");
|
|
1176
|
+
for (const line of lines) {
|
|
1177
|
+
if (line.includes("ABS_MT_POSITION_X")) {
|
|
1178
|
+
const match2 = line.match(/ABS_MT_POSITION_X\s+([0-9a-f]+)/i);
|
|
1179
|
+
if (match2) {
|
|
1180
|
+
currentX = Math.round(parseInt(match2[1], 16) / 32767 * screenWidth);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (line.includes("ABS_MT_POSITION_Y")) {
|
|
1184
|
+
const match2 = line.match(/ABS_MT_POSITION_Y\s+([0-9a-f]+)/i);
|
|
1185
|
+
if (match2) {
|
|
1186
|
+
currentY = Math.round(parseInt(match2[1], 16) / 32767 * screenHeight);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (line.includes("BTN_TOUCH") && line.includes("DOWN")) {
|
|
1190
|
+
touchStartTime = Date.now();
|
|
1191
|
+
touchStartX = currentX;
|
|
1192
|
+
touchStartY = currentY;
|
|
1193
|
+
isTracking = true;
|
|
1194
|
+
}
|
|
1195
|
+
if (line.includes("BTN_TOUCH") && line.includes("UP") && isTracking) {
|
|
1196
|
+
const duration = Date.now() - touchStartTime;
|
|
1197
|
+
const deltaX = Math.abs(currentX - touchStartX);
|
|
1198
|
+
const deltaY = Math.abs(currentY - touchStartY);
|
|
1199
|
+
if (deltaX > 50 || deltaY > 50) {
|
|
1200
|
+
this.recordAction({
|
|
1201
|
+
type: "swipe",
|
|
1202
|
+
x: touchStartX,
|
|
1203
|
+
y: touchStartY,
|
|
1204
|
+
endX: currentX,
|
|
1205
|
+
endY: currentY,
|
|
1206
|
+
duration,
|
|
1207
|
+
description: `Swipe from (${touchStartX}, ${touchStartY}) to (${currentX}, ${currentY})`
|
|
1208
|
+
});
|
|
1209
|
+
} else if (duration > 500) {
|
|
1210
|
+
this.recordAction({
|
|
1211
|
+
type: "longPress",
|
|
1212
|
+
x: touchStartX,
|
|
1213
|
+
y: touchStartY,
|
|
1214
|
+
duration,
|
|
1215
|
+
description: `Long press at (${touchStartX}, ${touchStartY})`
|
|
1216
|
+
});
|
|
1217
|
+
} else {
|
|
1218
|
+
this.recordAction({
|
|
1219
|
+
type: "tap",
|
|
1220
|
+
x: touchStartX,
|
|
1221
|
+
y: touchStartY,
|
|
1222
|
+
description: `Tap at (${touchStartX}, ${touchStartY})`
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
isTracking = false;
|
|
1226
|
+
}
|
|
1227
|
+
if (line.includes("KEY_BACK") && line.includes("DOWN")) {
|
|
1228
|
+
this.recordAction({
|
|
1229
|
+
type: "back",
|
|
1230
|
+
description: "Press back button"
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
if (line.includes("KEY_HOME") && line.includes("DOWN")) {
|
|
1234
|
+
this.recordAction({
|
|
1235
|
+
type: "home",
|
|
1236
|
+
description: "Press home button"
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Capture iOS events (limited - mainly screenshots)
|
|
1245
|
+
*/
|
|
1246
|
+
startIOSEventCapture(deviceId) {
|
|
1247
|
+
console.log("[MaestroRecorder] iOS event capture started (screenshot-based)");
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Record an action
|
|
1251
|
+
*/
|
|
1252
|
+
async recordAction(actionData) {
|
|
1253
|
+
if (!this.session || this.session.status !== "recording") return;
|
|
1254
|
+
this.actionCounter++;
|
|
1255
|
+
const actionId = `action_${this.actionCounter.toString().padStart(3, "0")}`;
|
|
1256
|
+
const screenshotPath = await this.captureScreenshot(actionId, { force: true, reason: "action" });
|
|
1257
|
+
const action = {
|
|
1258
|
+
id: actionId,
|
|
1259
|
+
type: actionData.type || "tap",
|
|
1260
|
+
timestamp: Date.now(),
|
|
1261
|
+
x: actionData.x,
|
|
1262
|
+
y: actionData.y,
|
|
1263
|
+
endX: actionData.endX,
|
|
1264
|
+
endY: actionData.endY,
|
|
1265
|
+
text: actionData.text,
|
|
1266
|
+
direction: actionData.direction,
|
|
1267
|
+
seconds: actionData.seconds,
|
|
1268
|
+
appId: actionData.appId,
|
|
1269
|
+
duration: actionData.duration,
|
|
1270
|
+
description: actionData.description || actionData.type || "Action",
|
|
1271
|
+
screenshotPath
|
|
1272
|
+
};
|
|
1273
|
+
this.session.actions.push(action);
|
|
1274
|
+
this.emit("action", action);
|
|
1275
|
+
console.log("[MaestroRecorder] Action captured:", action.type, action.description);
|
|
1276
|
+
if (action.type === "launch") {
|
|
1277
|
+
const launchAppId = action.appId || action.text;
|
|
1278
|
+
if (launchAppId && !this.session.appId) {
|
|
1279
|
+
this.session.appId = launchAppId;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const shouldWriteFlow = this.session.captureMode !== "native" || !this.maestroRecordProcess;
|
|
1283
|
+
if (shouldWriteFlow && this.session.flowPath) {
|
|
1284
|
+
try {
|
|
1285
|
+
const flowYaml = this.generateFlowYaml();
|
|
1286
|
+
await fs.promises.writeFile(this.session.flowPath, flowYaml);
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
console.error("[MaestroRecorder] Failed to update flow YAML during recording:", error);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
await this.writeSessionMetadata();
|
|
1292
|
+
this.savePendingSession();
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Capture a screenshot
|
|
1296
|
+
*/
|
|
1297
|
+
async captureScreenshot(name, options = {}) {
|
|
1298
|
+
if (!this.session) return void 0;
|
|
1299
|
+
const now = Date.now();
|
|
1300
|
+
if (!options.force && now < this.nextScreenshotAt) {
|
|
1301
|
+
return void 0;
|
|
1302
|
+
}
|
|
1303
|
+
const screenshotName = name ? `${name}.png` : `screenshot_${Date.now()}.png`;
|
|
1304
|
+
const screenshotPath = path.join(this.session.screenshotsDir, screenshotName);
|
|
1305
|
+
try {
|
|
1306
|
+
if (this.session.platform === "android") {
|
|
1307
|
+
const adbCommand = getAdbCommandOrThrow();
|
|
1308
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(this.session.deviceId)} exec-out screencap -p > "${screenshotPath}"`);
|
|
1309
|
+
} else {
|
|
1310
|
+
await execAsync(`xcrun simctl io ${this.session.deviceId} screenshot "${screenshotPath}"`);
|
|
1311
|
+
}
|
|
1312
|
+
const screenshotBuffer = await fs.promises.readFile(screenshotPath);
|
|
1313
|
+
const screenshotHash = createHash("sha1").update(screenshotBuffer).digest("hex");
|
|
1314
|
+
if (!options.force && this.lastScreenshotHash === screenshotHash) {
|
|
1315
|
+
this.unchangedScreenshotCount += 1;
|
|
1316
|
+
await fs.promises.unlink(screenshotPath);
|
|
1317
|
+
if (this.unchangedScreenshotCount >= this.screenshotBackoffThreshold) {
|
|
1318
|
+
this.currentScreenshotIntervalMs = Math.min(
|
|
1319
|
+
this.currentScreenshotIntervalMs * 2,
|
|
1320
|
+
this.maxScreenshotIntervalMs
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
this.nextScreenshotAt = now + this.currentScreenshotIntervalMs;
|
|
1324
|
+
return void 0;
|
|
1325
|
+
}
|
|
1326
|
+
this.lastScreenshotHash = screenshotHash;
|
|
1327
|
+
this.unchangedScreenshotCount = 0;
|
|
1328
|
+
this.currentScreenshotIntervalMs = this.baseScreenshotIntervalMs;
|
|
1329
|
+
this.nextScreenshotAt = now + this.currentScreenshotIntervalMs;
|
|
1330
|
+
this.emit("screenshot", screenshotPath);
|
|
1331
|
+
return screenshotPath;
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
console.error("Screenshot failed:", error);
|
|
1334
|
+
return void 0;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Add a manual action (for iOS or user-initiated)
|
|
1339
|
+
*/
|
|
1340
|
+
addManualAction(type, description, params) {
|
|
1341
|
+
return this.recordAction({
|
|
1342
|
+
type,
|
|
1343
|
+
description,
|
|
1344
|
+
...params
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Stop recording and generate outputs
|
|
1349
|
+
*/
|
|
1350
|
+
async stopRecording() {
|
|
1351
|
+
if (!this.session) throw new Error("No recording session");
|
|
1352
|
+
this.session.status = "stopped";
|
|
1353
|
+
this.session.endedAt = Date.now();
|
|
1354
|
+
await killZombieMaestroProcesses();
|
|
1355
|
+
if (this.adbProcess) {
|
|
1356
|
+
this.adbProcess.kill();
|
|
1357
|
+
this.adbProcess = null;
|
|
1358
|
+
}
|
|
1359
|
+
if (this.screenshotInterval) {
|
|
1360
|
+
clearInterval(this.screenshotInterval);
|
|
1361
|
+
this.screenshotInterval = null;
|
|
1362
|
+
}
|
|
1363
|
+
if (this.maestroRecordProcess) {
|
|
1364
|
+
console.log("[MaestroRecorder] Stopping native maestro record...");
|
|
1365
|
+
this.maestroRecordProcess.kill("SIGINT");
|
|
1366
|
+
await new Promise((resolve2) => {
|
|
1367
|
+
const timeout = setTimeout(() => {
|
|
1368
|
+
console.log("[MaestroRecorder] Timeout waiting for maestro record, forcing kill");
|
|
1369
|
+
this.maestroRecordProcess?.kill("SIGKILL");
|
|
1370
|
+
resolve2();
|
|
1371
|
+
}, 5e3);
|
|
1372
|
+
this.maestroRecordProcess?.on("close", () => {
|
|
1373
|
+
clearTimeout(timeout);
|
|
1374
|
+
resolve2();
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
this.maestroRecordProcess = null;
|
|
1378
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1e3));
|
|
1379
|
+
const recordedActions = this.session.actions;
|
|
1380
|
+
let parsedActions = [];
|
|
1381
|
+
if (this.session.flowPath && fs.existsSync(this.session.flowPath)) {
|
|
1382
|
+
console.log("[MaestroRecorder] Reading generated YAML from:", this.session.flowPath);
|
|
1383
|
+
try {
|
|
1384
|
+
const yamlContent = await fs.promises.readFile(this.session.flowPath, "utf-8");
|
|
1385
|
+
parsedActions = this.parseActionsFromYaml(yamlContent);
|
|
1386
|
+
console.log(`[MaestroRecorder] Parsed ${parsedActions.length} actions from YAML`);
|
|
1387
|
+
this.session.actions = this.mergeParsedActionsWithRecorded(parsedActions, recordedActions);
|
|
1388
|
+
} catch (e) {
|
|
1389
|
+
console.error("[MaestroRecorder] Failed to read/parse YAML:", e);
|
|
1390
|
+
}
|
|
1391
|
+
} else {
|
|
1392
|
+
console.log("[MaestroRecorder] No YAML file found at:", this.session.flowPath);
|
|
1393
|
+
}
|
|
1394
|
+
const flowPath = this.session.flowPath || path.join(this.session.screenshotsDir, "..", "test.yaml");
|
|
1395
|
+
if ((parsedActions.length === 0 || !fs.existsSync(flowPath)) && recordedActions.length > 0) {
|
|
1396
|
+
const flowYaml = this.generateFlowYaml();
|
|
1397
|
+
await fs.promises.writeFile(flowPath, flowYaml);
|
|
1398
|
+
this.session.flowPath = flowPath;
|
|
1399
|
+
this.session.actions = recordedActions;
|
|
1400
|
+
console.log("[MaestroRecorder] Fallback YAML generated from recorded actions");
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
if (this.videoProcess) {
|
|
1404
|
+
this.videoProcess.kill("SIGINT");
|
|
1405
|
+
await new Promise((resolve2) => setTimeout(resolve2, 2e3));
|
|
1406
|
+
if (this.session.platform === "android" && this.session.videoPath) {
|
|
1407
|
+
try {
|
|
1408
|
+
const adbCommand = getAdbCommandOrThrow();
|
|
1409
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(this.session.deviceId)} pull /sdcard/maestro-recording.mp4 "${this.session.videoPath}"`);
|
|
1410
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(this.session.deviceId)} shell rm /sdcard/maestro-recording.mp4`);
|
|
1411
|
+
} catch (e) {
|
|
1412
|
+
console.error("Failed to pull video:", e);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
this.videoProcess = null;
|
|
1416
|
+
}
|
|
1417
|
+
if (!this.session.flowPath || !fs.existsSync(this.session.flowPath)) {
|
|
1418
|
+
if (this.session.actions.length > 0) {
|
|
1419
|
+
const flowYaml = this.generateFlowYaml();
|
|
1420
|
+
const fallbackFlowPath = path.join(this.session.screenshotsDir, "..", "test.yaml");
|
|
1421
|
+
await fs.promises.writeFile(fallbackFlowPath, flowYaml);
|
|
1422
|
+
this.session.flowPath = fallbackFlowPath;
|
|
1423
|
+
console.log("[MaestroRecorder] Generated flow YAML from recorded actions");
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
if (this.session.actions.length === 0 && this.session.flowPath && fs.existsSync(this.session.flowPath)) {
|
|
1427
|
+
try {
|
|
1428
|
+
const fallbackYaml = await fs.promises.readFile(this.session.flowPath, "utf-8");
|
|
1429
|
+
const parsedActions = parseMaestroActionsFromYaml(fallbackYaml);
|
|
1430
|
+
if (parsedActions.length > 0) {
|
|
1431
|
+
this.session.actions = parsedActions;
|
|
1432
|
+
console.log(`[MaestroRecorder] Rehydrated ${parsedActions.length} actions from YAML for manual capture`);
|
|
1433
|
+
}
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
console.error("[MaestroRecorder] Failed to rehydrate actions from YAML:", error);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
const metadataPath = path.join(this.session.screenshotsDir, "..", "session.json");
|
|
1439
|
+
await fs.promises.writeFile(metadataPath, JSON.stringify(this.session, null, 2));
|
|
1440
|
+
this.clearPendingSession();
|
|
1441
|
+
this.emit("status", "stopped");
|
|
1442
|
+
this.emit("stopped", this.session);
|
|
1443
|
+
const result = this.session;
|
|
1444
|
+
this.session = null;
|
|
1445
|
+
return result;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Parse actions from Maestro YAML content
|
|
1449
|
+
*/
|
|
1450
|
+
parseActionsFromYaml(yamlContent) {
|
|
1451
|
+
const parsedActions = parseMaestroActionsFromYaml(yamlContent);
|
|
1452
|
+
const appIdMatch = yamlContent.match(/^\s*appId:\s*([^\n#]+)/m);
|
|
1453
|
+
const parsedAppId = appIdMatch?.[1]?.trim().replace(/^["']|["']$/g, "") || "";
|
|
1454
|
+
if (this.session && parsedAppId && !this.session.appId) {
|
|
1455
|
+
this.session.appId = parsedAppId;
|
|
1456
|
+
}
|
|
1457
|
+
return parsedActions;
|
|
1458
|
+
}
|
|
1459
|
+
mergeParsedActionsWithRecorded(parsedActions, recordedActions) {
|
|
1460
|
+
if (parsedActions.length === 0) return recordedActions;
|
|
1461
|
+
if (recordedActions.length === 0) return parsedActions;
|
|
1462
|
+
const recordedWithScreens = recordedActions.filter((action) => action.screenshotPath);
|
|
1463
|
+
if (recordedWithScreens.length === 0) return parsedActions;
|
|
1464
|
+
const shouldMapScreenshot = (action) => action.type === "tap" || action.type === "swipe" || action.type === "scroll" || action.type === "longPress" || action.type === "back" || action.type === "home" || action.type === "assert" || action.type === "wait";
|
|
1465
|
+
let recordedIndex = 0;
|
|
1466
|
+
return parsedActions.map((action) => {
|
|
1467
|
+
if (!shouldMapScreenshot(action) || recordedIndex >= recordedWithScreens.length) {
|
|
1468
|
+
return action;
|
|
1469
|
+
}
|
|
1470
|
+
const recorded = recordedWithScreens[recordedIndex++];
|
|
1471
|
+
return {
|
|
1472
|
+
...recorded,
|
|
1473
|
+
...action,
|
|
1474
|
+
id: recorded.id || action.id,
|
|
1475
|
+
screenshotPath: recorded.screenshotPath,
|
|
1476
|
+
x: action.x ?? recorded.x,
|
|
1477
|
+
y: action.y ?? recorded.y,
|
|
1478
|
+
endX: action.endX ?? recorded.endX,
|
|
1479
|
+
endY: action.endY ?? recorded.endY,
|
|
1480
|
+
text: action.text ?? recorded.text,
|
|
1481
|
+
direction: action.direction ?? recorded.direction,
|
|
1482
|
+
seconds: action.seconds ?? recorded.seconds,
|
|
1483
|
+
appId: action.appId ?? recorded.appId,
|
|
1484
|
+
duration: action.duration ?? recorded.duration,
|
|
1485
|
+
timestamp: recorded.timestamp || action.timestamp,
|
|
1486
|
+
description: action.description || recorded.description,
|
|
1487
|
+
type: action.type
|
|
1488
|
+
};
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Generate Maestro YAML from recorded actions
|
|
1493
|
+
*/
|
|
1494
|
+
generateFlowYaml() {
|
|
1495
|
+
if (!this.session) return "";
|
|
1496
|
+
const escapeYaml = (value) => value.replace(/"/g, '\\"');
|
|
1497
|
+
const lines = [
|
|
1498
|
+
`# Maestro Flow: ${this.session.name}`,
|
|
1499
|
+
`# Recorded: ${new Date(this.session.startedAt).toISOString()}`,
|
|
1500
|
+
`# Generated by DiscoveryLab`,
|
|
1501
|
+
``
|
|
1502
|
+
];
|
|
1503
|
+
if (this.session.appId) {
|
|
1504
|
+
lines.push(`appId: ${this.session.appId}`);
|
|
1505
|
+
lines.push(``);
|
|
1506
|
+
}
|
|
1507
|
+
lines.push(`---`);
|
|
1508
|
+
lines.push(``);
|
|
1509
|
+
for (const action of this.session.actions) {
|
|
1510
|
+
lines.push(`# ${action.description}`);
|
|
1511
|
+
switch (action.type) {
|
|
1512
|
+
case "tap":
|
|
1513
|
+
if (action.text) {
|
|
1514
|
+
lines.push(`- tapOn:`);
|
|
1515
|
+
lines.push(` text: "${escapeYaml(action.text)}"`);
|
|
1516
|
+
} else if (action.x !== void 0 && action.y !== void 0) {
|
|
1517
|
+
lines.push(`- tapOn:`);
|
|
1518
|
+
lines.push(` point: "${action.x},${action.y}"`);
|
|
1519
|
+
}
|
|
1520
|
+
break;
|
|
1521
|
+
case "swipe":
|
|
1522
|
+
if (action.x !== void 0 && action.y !== void 0 && action.endX !== void 0 && action.endY !== void 0) {
|
|
1523
|
+
lines.push(`- swipe:`);
|
|
1524
|
+
lines.push(` start: "${action.x},${action.y}"`);
|
|
1525
|
+
lines.push(` end: "${action.endX},${action.endY}"`);
|
|
1526
|
+
if (action.duration) {
|
|
1527
|
+
lines.push(` duration: ${action.duration}`);
|
|
1528
|
+
}
|
|
1529
|
+
} else if (action.direction) {
|
|
1530
|
+
lines.push(`- swipe:`);
|
|
1531
|
+
lines.push(` direction: "${action.direction.toUpperCase()}"`);
|
|
1532
|
+
}
|
|
1533
|
+
break;
|
|
1534
|
+
case "longPress":
|
|
1535
|
+
if (action.x !== void 0 && action.y !== void 0) {
|
|
1536
|
+
lines.push(`- longPressOn:`);
|
|
1537
|
+
lines.push(` point: "${action.x},${action.y}"`);
|
|
1538
|
+
}
|
|
1539
|
+
break;
|
|
1540
|
+
case "input":
|
|
1541
|
+
if (action.text) {
|
|
1542
|
+
lines.push(`- inputText: "${escapeYaml(action.text)}"`);
|
|
1543
|
+
}
|
|
1544
|
+
break;
|
|
1545
|
+
case "back":
|
|
1546
|
+
lines.push(`- pressKey: back`);
|
|
1547
|
+
break;
|
|
1548
|
+
case "home":
|
|
1549
|
+
lines.push(`- pressKey: home`);
|
|
1550
|
+
break;
|
|
1551
|
+
case "scroll":
|
|
1552
|
+
if (action.direction && action.direction.toLowerCase() === "down") {
|
|
1553
|
+
lines.push(`- scrollUntilVisible:`);
|
|
1554
|
+
lines.push(` element: ".*"`);
|
|
1555
|
+
lines.push(` direction: "DOWN"`);
|
|
1556
|
+
} else {
|
|
1557
|
+
lines.push(`- scroll`);
|
|
1558
|
+
}
|
|
1559
|
+
break;
|
|
1560
|
+
case "launch": {
|
|
1561
|
+
const launchAppId = action.appId || action.text;
|
|
1562
|
+
if (launchAppId) {
|
|
1563
|
+
lines.push(`- launchApp: "${escapeYaml(launchAppId)}"`);
|
|
1564
|
+
if (!this.session.appId) {
|
|
1565
|
+
this.session.appId = launchAppId;
|
|
1566
|
+
}
|
|
1567
|
+
} else {
|
|
1568
|
+
lines.push(`- launchApp`);
|
|
1569
|
+
}
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
case "assert":
|
|
1573
|
+
if (action.text) {
|
|
1574
|
+
lines.push(`- assertVisible:`);
|
|
1575
|
+
lines.push(` text: "${escapeYaml(action.text)}"`);
|
|
1576
|
+
}
|
|
1577
|
+
break;
|
|
1578
|
+
case "wait": {
|
|
1579
|
+
const seconds = action.seconds && action.seconds > 0 ? Math.round(action.seconds) : 3;
|
|
1580
|
+
lines.push(`- extendedWaitUntil:`);
|
|
1581
|
+
lines.push(` visible: ".*"`);
|
|
1582
|
+
lines.push(` timeout: ${seconds * 1e3}`);
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
default:
|
|
1586
|
+
lines.push(`# Unknown action: ${action.type}`);
|
|
1587
|
+
}
|
|
1588
|
+
lines.push(``);
|
|
1589
|
+
}
|
|
1590
|
+
return lines.join("\n");
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Get current session
|
|
1594
|
+
*/
|
|
1595
|
+
getSession() {
|
|
1596
|
+
return this.session;
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Check if recording is active
|
|
1600
|
+
*/
|
|
1601
|
+
isRecording() {
|
|
1602
|
+
return this.session?.status === "recording";
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
var maestroRecorderInstance = null;
|
|
1606
|
+
function getMaestroRecorder() {
|
|
1607
|
+
if (!maestroRecorderInstance) {
|
|
1608
|
+
maestroRecorderInstance = new MaestroRecorder();
|
|
1609
|
+
}
|
|
1610
|
+
return maestroRecorderInstance;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// src/core/integrations/esvp-local-device.ts
|
|
1614
|
+
var execFileAsync = promisify2(execFile);
|
|
1615
|
+
async function listDevicesForExecutor(executor) {
|
|
1616
|
+
if (executor === "fake") return [];
|
|
1617
|
+
const devices = await listMaestroDevices();
|
|
1618
|
+
if (executor === "adb") {
|
|
1619
|
+
return devices.filter((device) => device.platform === "android");
|
|
1620
|
+
}
|
|
1621
|
+
return devices.filter((device) => device.platform === "ios");
|
|
1622
|
+
}
|
|
1623
|
+
async function resolveDefaultDeviceId(executor) {
|
|
1624
|
+
const devices = await listDevicesForExecutor(executor);
|
|
1625
|
+
return devices[0]?.id || null;
|
|
1626
|
+
}
|
|
1627
|
+
async function runDeviceActionFlow(input) {
|
|
1628
|
+
const appId = resolveFlowAppId(input.actions, input.meta, input.preflightConfig);
|
|
1629
|
+
if (!appId) {
|
|
1630
|
+
throw new Error("ESVP local requires an appId in session meta/preflight or a launch action before running device-backed actions.");
|
|
1631
|
+
}
|
|
1632
|
+
const flowDir = join3(input.runDir, "maestro");
|
|
1633
|
+
const checkpointsDir = join3(input.runDir, "checkpoints");
|
|
1634
|
+
await mkdir(flowDir, { recursive: true });
|
|
1635
|
+
await mkdir(checkpointsDir, { recursive: true });
|
|
1636
|
+
const flowPath = join3(flowDir, `session-${Date.now()}.yaml`);
|
|
1637
|
+
const checkpointSpecs = [];
|
|
1638
|
+
const steps = [];
|
|
1639
|
+
steps.push(...buildPreflightSteps(input.preflightConfig, appId));
|
|
1640
|
+
for (const [index, action] of input.actions.entries()) {
|
|
1641
|
+
steps.push(...translateActionToMaestroSteps(action, appId));
|
|
1642
|
+
if (action.checkpointAfter) {
|
|
1643
|
+
const label = action.checkpointLabel || `${action.name}:${index + 1}`;
|
|
1644
|
+
const relativePath = `checkpoints/${String(index + 1).padStart(3, "0")}-${slugify(label)}.png`;
|
|
1645
|
+
const absPath = join3(input.runDir, relativePath);
|
|
1646
|
+
steps.push(MaestroActions.takeScreenshot(absPath));
|
|
1647
|
+
checkpointSpecs.push({
|
|
1648
|
+
actionIndex: index,
|
|
1649
|
+
label,
|
|
1650
|
+
relativePath,
|
|
1651
|
+
absPath
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
const flow = {
|
|
1656
|
+
appId,
|
|
1657
|
+
name: `ESVP ${input.sessionId}`,
|
|
1658
|
+
steps
|
|
1659
|
+
};
|
|
1660
|
+
await writeFile(flowPath, generateMaestroFlow(flow), "utf8");
|
|
1661
|
+
const result = await runMaestroTest({
|
|
1662
|
+
flowPath,
|
|
1663
|
+
device: input.deviceId,
|
|
1664
|
+
outputDir: join3(flowDir, `output-${Date.now()}`),
|
|
1665
|
+
timeout: 3e5
|
|
1666
|
+
});
|
|
1667
|
+
const checkpoints = [];
|
|
1668
|
+
for (const spec of checkpointSpecs) {
|
|
1669
|
+
if (!existsSync3(spec.absPath)) break;
|
|
1670
|
+
const contents = await readFile(spec.absPath);
|
|
1671
|
+
checkpoints.push({
|
|
1672
|
+
...spec,
|
|
1673
|
+
sha256: createHash2("sha256").update(contents).digest("hex"),
|
|
1674
|
+
bytes: contents.length
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
return {
|
|
1678
|
+
success: result.success,
|
|
1679
|
+
error: result.success ? null : result.error || result.output || "Maestro run failed",
|
|
1680
|
+
output: typeof result.output === "string" ? result.output : result.error || null,
|
|
1681
|
+
flowPath,
|
|
1682
|
+
checkpoints,
|
|
1683
|
+
executedActionCount: result.success ? input.actions.length : Math.min(input.actions.length, checkpoints.length),
|
|
1684
|
+
appId
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
async function configureDeviceNetwork(executor, context, profile = {}) {
|
|
1688
|
+
if (executor === "fake") {
|
|
1689
|
+
context.networkProfile = profile;
|
|
1690
|
+
return {
|
|
1691
|
+
supported: true,
|
|
1692
|
+
applied: true,
|
|
1693
|
+
applied_features: ["simulated"],
|
|
1694
|
+
unsupported_features: [],
|
|
1695
|
+
warnings: [],
|
|
1696
|
+
capabilities: networkCapabilitiesForExecutor(executor),
|
|
1697
|
+
profile
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
if (executor === "adb") {
|
|
1701
|
+
const applied2 = [];
|
|
1702
|
+
const unsupported = [];
|
|
1703
|
+
const warnings2 = [];
|
|
1704
|
+
if (profile.connectivity === "offline") {
|
|
1705
|
+
await setAndroidConnectivity(context, "offline");
|
|
1706
|
+
applied2.push("offline");
|
|
1707
|
+
} else if (profile.connectivity === "online" || profile.connectivity === "reset") {
|
|
1708
|
+
await setAndroidConnectivity(context, "online");
|
|
1709
|
+
applied2.push(String(profile.connectivity));
|
|
1710
|
+
}
|
|
1711
|
+
if (profile.proxy?.host && Number.isFinite(profile.proxy?.port) && profile.proxy.port > 0) {
|
|
1712
|
+
await setAndroidHttpProxy(context, profile.proxy);
|
|
1713
|
+
applied2.push("proxy");
|
|
1714
|
+
} else if (profile.proxy === null && profile.connectivity === "reset") {
|
|
1715
|
+
await clearAndroidHttpProxy(context);
|
|
1716
|
+
applied2.push("proxy_cleared");
|
|
1717
|
+
}
|
|
1718
|
+
if (profile.faults?.delay_ms != null) unsupported.push("faults.delay_ms");
|
|
1719
|
+
if (profile.faults?.timeout === true) unsupported.push("faults.timeout");
|
|
1720
|
+
if (profile.faults?.offline_partial === true) unsupported.push("faults.offline_partial");
|
|
1721
|
+
if (profile.faults?.status_code != null) unsupported.push("faults.status_code");
|
|
1722
|
+
if (profile.faults?.body_patch != null) unsupported.push("faults.body_patch");
|
|
1723
|
+
if (profile.capture?.enabled === true && profile.capture?.mode !== "esvp-managed-proxy") {
|
|
1724
|
+
warnings2.push("capture.enabled was recorded, but external proxy traces still need to be attached via /network/trace");
|
|
1725
|
+
}
|
|
1726
|
+
context.networkProfile = profile;
|
|
1727
|
+
return {
|
|
1728
|
+
supported: true,
|
|
1729
|
+
applied: applied2.length > 0,
|
|
1730
|
+
applied_features: applied2,
|
|
1731
|
+
unsupported_features: unsupported,
|
|
1732
|
+
warnings: warnings2,
|
|
1733
|
+
capabilities: networkCapabilitiesForExecutor(executor),
|
|
1734
|
+
profile
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
const applied = [];
|
|
1738
|
+
const warnings = [];
|
|
1739
|
+
if (profile.proxy?.host && Number.isFinite(profile.proxy?.port) && profile.proxy.port > 0) {
|
|
1740
|
+
await setMacHostHttpProxy(context, profile.proxy);
|
|
1741
|
+
applied.push("proxy");
|
|
1742
|
+
warnings.push("proxy was applied via macOS networksetup on the host, which temporarily affects host traffic during the session");
|
|
1743
|
+
} else if (profile.proxy === null) {
|
|
1744
|
+
await clearMacHostHttpProxy(context);
|
|
1745
|
+
applied.push("proxy_cleared");
|
|
1746
|
+
}
|
|
1747
|
+
context.networkProfile = profile;
|
|
1748
|
+
return {
|
|
1749
|
+
supported: true,
|
|
1750
|
+
applied: applied.length > 0,
|
|
1751
|
+
applied_features: applied,
|
|
1752
|
+
unsupported_features: [],
|
|
1753
|
+
warnings,
|
|
1754
|
+
capabilities: networkCapabilitiesForExecutor(executor),
|
|
1755
|
+
profile
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
async function clearDeviceNetwork(executor, context) {
|
|
1759
|
+
if (executor === "adb") {
|
|
1760
|
+
await clearAndroidHttpProxy(context);
|
|
1761
|
+
await setAndroidConnectivity(context, "online");
|
|
1762
|
+
} else if (executor === "ios-sim" || executor === "maestro-ios") {
|
|
1763
|
+
await clearMacHostHttpProxy(context);
|
|
1764
|
+
}
|
|
1765
|
+
context.networkProfile = null;
|
|
1766
|
+
return {
|
|
1767
|
+
supported: true,
|
|
1768
|
+
cleared: true,
|
|
1769
|
+
capabilities: networkCapabilitiesForExecutor(executor),
|
|
1770
|
+
warnings: []
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
async function captureDeviceCheckpoint(input) {
|
|
1774
|
+
await mkdir(dirname2(input.targetPath), { recursive: true });
|
|
1775
|
+
if (input.executor === "adb") {
|
|
1776
|
+
const adbCommand = getAdbCommand();
|
|
1777
|
+
if (!adbCommand) {
|
|
1778
|
+
return { success: false, error: "ADB not found. Set ANDROID_HOME/ANDROID_SDK_ROOT or add adb to PATH." };
|
|
1779
|
+
}
|
|
1780
|
+
const tempPath = "/sdcard/esvp-checkpoint.png";
|
|
1781
|
+
try {
|
|
1782
|
+
await execFileAsync(adbCommand, ["-s", input.deviceId, "shell", "screencap", "-p", tempPath], { timeout: 1e4 });
|
|
1783
|
+
await execFileAsync(adbCommand, ["-s", input.deviceId, "pull", tempPath, input.targetPath], { timeout: 1e4 });
|
|
1784
|
+
await execFileAsync(adbCommand, ["-s", input.deviceId, "shell", "rm", tempPath], { timeout: 5e3 }).catch(() => void 0);
|
|
1785
|
+
return { success: true };
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
try {
|
|
1791
|
+
await execFileAsync("xcrun", ["simctl", "io", input.deviceId, "screenshot", input.targetPath], { timeout: 1e4 });
|
|
1792
|
+
return { success: true };
|
|
1793
|
+
} catch (error) {
|
|
1794
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
async function collectExecutorDebugArtifact(input) {
|
|
1798
|
+
if (input.executor === "adb" && input.context.deviceId) {
|
|
1799
|
+
const adbCommand = getAdbCommand();
|
|
1800
|
+
if (adbCommand) {
|
|
1801
|
+
try {
|
|
1802
|
+
const { stdout, stderr } = await execFileAsync(adbCommand, ["-s", input.context.deviceId, "logcat", "-d"], {
|
|
1803
|
+
timeout: 1e4,
|
|
1804
|
+
maxBuffer: 4 * 1024 * 1024
|
|
1805
|
+
});
|
|
1806
|
+
const content = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
1807
|
+
if (content) {
|
|
1808
|
+
return {
|
|
1809
|
+
kind: "logcat",
|
|
1810
|
+
content,
|
|
1811
|
+
extension: "log"
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
} catch {
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
const output = typeof input.context.lastRunOutput === "string" ? input.context.lastRunOutput.trim() : "";
|
|
1819
|
+
if (!output) return null;
|
|
1820
|
+
return {
|
|
1821
|
+
kind: "debug_asset",
|
|
1822
|
+
content: output,
|
|
1823
|
+
extension: "txt"
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
function networkCapabilitiesForExecutor(executor) {
|
|
1827
|
+
if (executor === "adb") {
|
|
1828
|
+
return {
|
|
1829
|
+
proxy: true,
|
|
1830
|
+
connectivity: true,
|
|
1831
|
+
delay: false,
|
|
1832
|
+
loss: false,
|
|
1833
|
+
timeout: false,
|
|
1834
|
+
offline_partial: false,
|
|
1835
|
+
status_code: false,
|
|
1836
|
+
body_patch: false,
|
|
1837
|
+
trace_attach: true,
|
|
1838
|
+
capture: true
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
if (executor === "ios-sim" || executor === "maestro-ios") {
|
|
1842
|
+
return {
|
|
1843
|
+
proxy: true,
|
|
1844
|
+
connectivity: false,
|
|
1845
|
+
delay: false,
|
|
1846
|
+
loss: false,
|
|
1847
|
+
timeout: false,
|
|
1848
|
+
offline_partial: false,
|
|
1849
|
+
status_code: false,
|
|
1850
|
+
body_patch: false,
|
|
1851
|
+
trace_attach: true,
|
|
1852
|
+
capture: true
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
return {
|
|
1856
|
+
proxy: true,
|
|
1857
|
+
connectivity: true,
|
|
1858
|
+
delay: true,
|
|
1859
|
+
loss: false,
|
|
1860
|
+
timeout: true,
|
|
1861
|
+
offline_partial: true,
|
|
1862
|
+
status_code: true,
|
|
1863
|
+
body_patch: true,
|
|
1864
|
+
trace_attach: true,
|
|
1865
|
+
capture: true
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
function buildPreflightSteps(config, appId) {
|
|
1869
|
+
if (!config || typeof config !== "object") return [];
|
|
1870
|
+
const steps = [];
|
|
1871
|
+
const rules = Array.isArray(config.rules) ? config.rules : [];
|
|
1872
|
+
for (const rule of rules) {
|
|
1873
|
+
const kind = String(rule?.kind || "").trim().toLowerCase();
|
|
1874
|
+
if (kind === "clear_data") {
|
|
1875
|
+
steps.push(MaestroActions.clearState(String(config.appId || appId)));
|
|
1876
|
+
continue;
|
|
1877
|
+
}
|
|
1878
|
+
if (kind === "wait_for_stable") {
|
|
1879
|
+
steps.push(MaestroActions.waitForAnimationToEnd(1e3));
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
return steps;
|
|
1884
|
+
}
|
|
1885
|
+
function resolveFlowAppId(actions, meta, preflightConfig) {
|
|
1886
|
+
const fromMeta = typeof meta?.appId === "string" ? meta.appId.trim() : typeof meta?.app_id === "string" ? String(meta.app_id).trim() : "";
|
|
1887
|
+
if (fromMeta) return fromMeta;
|
|
1888
|
+
const fromPreflight = typeof preflightConfig?.appId === "string" ? preflightConfig.appId.trim() : "";
|
|
1889
|
+
if (fromPreflight) return fromPreflight;
|
|
1890
|
+
for (const action of actions) {
|
|
1891
|
+
if (action.name !== "launch") continue;
|
|
1892
|
+
const appId = typeof action.args?.appId === "string" ? action.args.appId.trim() : "";
|
|
1893
|
+
if (appId) return appId;
|
|
1894
|
+
}
|
|
1895
|
+
return "";
|
|
1896
|
+
}
|
|
1897
|
+
function translateActionToMaestroSteps(action, appId) {
|
|
1898
|
+
const args = action.args || {};
|
|
1899
|
+
switch (action.name) {
|
|
1900
|
+
case "launch": {
|
|
1901
|
+
const launchAppId = typeof args.appId === "string" && args.appId.trim() ? args.appId.trim() : appId;
|
|
1902
|
+
return [MaestroActions.launchApp(launchAppId)];
|
|
1903
|
+
}
|
|
1904
|
+
case "tap": {
|
|
1905
|
+
if (typeof args.selector === "string" && args.selector.trim()) {
|
|
1906
|
+
return [MaestroActions.tapOn(args.selector.trim())];
|
|
1907
|
+
}
|
|
1908
|
+
if (typeof args.text === "string" && args.text.trim()) {
|
|
1909
|
+
return [MaestroActions.tapOn(args.text.trim())];
|
|
1910
|
+
}
|
|
1911
|
+
if (typeof args.x === "number" && typeof args.y === "number") {
|
|
1912
|
+
return [MaestroActions.tapOnPoint(Math.round(args.x), Math.round(args.y))];
|
|
1913
|
+
}
|
|
1914
|
+
throw new Error("ESVP tap action requires selector/text or x/y.");
|
|
1915
|
+
}
|
|
1916
|
+
case "type": {
|
|
1917
|
+
const text = typeof args.text === "string" ? args.text : "";
|
|
1918
|
+
if (!text) throw new Error("ESVP type action requires args.text.");
|
|
1919
|
+
return [MaestroActions.inputText(text)];
|
|
1920
|
+
}
|
|
1921
|
+
case "back":
|
|
1922
|
+
return [MaestroActions.back()];
|
|
1923
|
+
case "home":
|
|
1924
|
+
return [MaestroActions.pressKey("HOME")];
|
|
1925
|
+
case "keyevent": {
|
|
1926
|
+
const key = typeof args.key === "string" ? args.key : "";
|
|
1927
|
+
if (!key) throw new Error("ESVP keyevent action requires args.key.");
|
|
1928
|
+
return [MaestroActions.pressKey(key)];
|
|
1929
|
+
}
|
|
1930
|
+
case "wait": {
|
|
1931
|
+
const ms = Number(args.ms);
|
|
1932
|
+
return [MaestroActions.wait(Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : 1e3)];
|
|
1933
|
+
}
|
|
1934
|
+
case "swipe": {
|
|
1935
|
+
const direction = resolveSwipeDirection(args);
|
|
1936
|
+
switch (direction) {
|
|
1937
|
+
case "left":
|
|
1938
|
+
return [MaestroActions.swipeLeft()];
|
|
1939
|
+
case "right":
|
|
1940
|
+
return [MaestroActions.swipeRight()];
|
|
1941
|
+
case "up":
|
|
1942
|
+
return [MaestroActions.swipeUp()];
|
|
1943
|
+
case "down":
|
|
1944
|
+
return [MaestroActions.swipeDown()];
|
|
1945
|
+
default:
|
|
1946
|
+
throw new Error("ESVP swipe action requires a direction or coordinates that can be resolved to a direction.");
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
default:
|
|
1950
|
+
throw new Error(`ESVP action "${action.name}" is not supported by the local AppLab executor.`);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
function resolveSwipeDirection(args) {
|
|
1954
|
+
if (typeof args.direction === "string" && args.direction.trim()) {
|
|
1955
|
+
const normalized = args.direction.trim().toLowerCase();
|
|
1956
|
+
if (normalized === "left" || normalized === "right" || normalized === "up" || normalized === "down") {
|
|
1957
|
+
return normalized;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
if (typeof args.x1 === "number" && typeof args.y1 === "number" && typeof args.x2 === "number" && typeof args.y2 === "number") {
|
|
1961
|
+
const deltaX = args.x2 - args.x1;
|
|
1962
|
+
const deltaY = args.y2 - args.y1;
|
|
1963
|
+
if (Math.abs(deltaX) >= Math.abs(deltaY)) {
|
|
1964
|
+
return deltaX >= 0 ? "right" : "left";
|
|
1965
|
+
}
|
|
1966
|
+
return deltaY >= 0 ? "down" : "up";
|
|
1967
|
+
}
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
async function setAndroidHttpProxy(context, proxy) {
|
|
1971
|
+
const adbCommand = getAdbCommand();
|
|
1972
|
+
if (!adbCommand) {
|
|
1973
|
+
throw new Error("ADB not found. Set ANDROID_HOME/ANDROID_SDK_ROOT or add adb to PATH.");
|
|
1974
|
+
}
|
|
1975
|
+
if (!context.deviceId) {
|
|
1976
|
+
throw new Error("Android proxy configuration requires a deviceId.");
|
|
1977
|
+
}
|
|
1978
|
+
const host = String(proxy.host || "").trim();
|
|
1979
|
+
const port = Number(proxy.port);
|
|
1980
|
+
if (!host || !Number.isFinite(port) || port <= 0) {
|
|
1981
|
+
throw new Error("Invalid Android proxy host/port.");
|
|
1982
|
+
}
|
|
1983
|
+
const endpoint = `${host}:${Math.round(port)}`;
|
|
1984
|
+
await execFileAsync(adbCommand, ["-s", context.deviceId, "shell", "settings", "put", "global", "http_proxy", endpoint], { timeout: 7e3 });
|
|
1985
|
+
await execFileAsync(adbCommand, ["-s", context.deviceId, "shell", "settings", "put", "global", "global_http_proxy_host", host], { timeout: 7e3 });
|
|
1986
|
+
await execFileAsync(adbCommand, ["-s", context.deviceId, "shell", "settings", "put", "global", "global_http_proxy_port", String(Math.round(port))], { timeout: 7e3 });
|
|
1987
|
+
const bypass = Array.isArray(proxy.bypass) ? proxy.bypass.filter(Boolean).join(",") : "";
|
|
1988
|
+
if (bypass) {
|
|
1989
|
+
await execFileAsync(adbCommand, ["-s", context.deviceId, "shell", "settings", "put", "global", "global_http_proxy_exclusion_list", bypass], { timeout: 7e3 });
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
async function clearAndroidHttpProxy(context) {
|
|
1993
|
+
const adbCommand = getAdbCommand();
|
|
1994
|
+
if (!adbCommand || !context.deviceId) return;
|
|
1995
|
+
const commands = [
|
|
1996
|
+
["-s", context.deviceId, "shell", "settings", "put", "global", "http_proxy", ":0"],
|
|
1997
|
+
["-s", context.deviceId, "shell", "settings", "delete", "global", "global_http_proxy_host"],
|
|
1998
|
+
["-s", context.deviceId, "shell", "settings", "delete", "global", "global_http_proxy_port"],
|
|
1999
|
+
["-s", context.deviceId, "shell", "settings", "delete", "global", "global_http_proxy_exclusion_list"]
|
|
2000
|
+
];
|
|
2001
|
+
for (const command of commands) {
|
|
2002
|
+
await execFileAsync(adbCommand, command, { timeout: 7e3 }).catch(() => void 0);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
async function setAndroidConnectivity(context, mode) {
|
|
2006
|
+
const adbCommand = getAdbCommand();
|
|
2007
|
+
if (!adbCommand || !context.deviceId) return;
|
|
2008
|
+
if (mode === "offline") {
|
|
2009
|
+
await execFileAsync(adbCommand, ["-s", context.deviceId, "shell", "svc", "wifi", "disable"], { timeout: 7e3 }).catch(() => void 0);
|
|
2010
|
+
await execFileAsync(adbCommand, ["-s", context.deviceId, "shell", "svc", "data", "disable"], { timeout: 7e3 }).catch(() => void 0);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
await execFileAsync(adbCommand, ["-s", context.deviceId, "shell", "svc", "wifi", "enable"], { timeout: 7e3 }).catch(() => void 0);
|
|
2014
|
+
await execFileAsync(adbCommand, ["-s", context.deviceId, "shell", "svc", "data", "enable"], { timeout: 7e3 }).catch(() => void 0);
|
|
2015
|
+
}
|
|
2016
|
+
async function setMacHostHttpProxy(context, proxy) {
|
|
2017
|
+
const host = String(proxy.host || "").trim();
|
|
2018
|
+
const port = Number(proxy.port);
|
|
2019
|
+
if (!host || !Number.isFinite(port) || port <= 0) {
|
|
2020
|
+
throw new Error("Invalid macOS proxy host/port.");
|
|
2021
|
+
}
|
|
2022
|
+
const iface = await detectActiveNetworkInterface();
|
|
2023
|
+
const saved = await saveCurrentProxySettings(iface);
|
|
2024
|
+
context._macosProxyBackup = saved;
|
|
2025
|
+
context._macosProxyInterface = iface;
|
|
2026
|
+
const portStr = String(Math.round(port));
|
|
2027
|
+
await execFileAsync("networksetup", ["-setwebproxy", iface, host, portStr], { timeout: 5e3 });
|
|
2028
|
+
await execFileAsync("networksetup", ["-setsecurewebproxy", iface, host, portStr], { timeout: 5e3 });
|
|
2029
|
+
const { stdout } = await execFileAsync("networksetup", ["-getwebproxy", iface], { timeout: 5e3 });
|
|
2030
|
+
const verify = String(stdout || "");
|
|
2031
|
+
if (!verify.includes("Enabled: Yes") || !verify.includes(host)) {
|
|
2032
|
+
throw new Error(`Failed to verify host proxy on macOS network service ${iface}.`);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
async function clearMacHostHttpProxy(context) {
|
|
2036
|
+
const iface = context._macosProxyInterface;
|
|
2037
|
+
const saved = context._macosProxyBackup;
|
|
2038
|
+
if (!iface) return;
|
|
2039
|
+
if (saved?.http?.enabled) {
|
|
2040
|
+
await execFileAsync("networksetup", ["-setwebproxy", iface, saved.http.server || "", saved.http.port || "0"], { timeout: 5e3 }).catch(() => void 0);
|
|
2041
|
+
} else {
|
|
2042
|
+
await execFileAsync("networksetup", ["-setwebproxystate", iface, "off"], { timeout: 5e3 }).catch(() => void 0);
|
|
2043
|
+
}
|
|
2044
|
+
if (saved?.https?.enabled) {
|
|
2045
|
+
await execFileAsync("networksetup", ["-setsecurewebproxy", iface, saved.https.server || "", saved.https.port || "0"], { timeout: 5e3 }).catch(() => void 0);
|
|
2046
|
+
} else {
|
|
2047
|
+
await execFileAsync("networksetup", ["-setsecurewebproxystate", iface, "off"], { timeout: 5e3 }).catch(() => void 0);
|
|
2048
|
+
}
|
|
2049
|
+
delete context._macosProxyBackup;
|
|
2050
|
+
delete context._macosProxyInterface;
|
|
2051
|
+
}
|
|
2052
|
+
async function detectActiveNetworkInterface() {
|
|
2053
|
+
const { stdout } = await execFileAsync("networksetup", ["-listallnetworkservices"], { timeout: 5e3 });
|
|
2054
|
+
const services = String(stdout || "").split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("*") && !line.startsWith("An asterisk"));
|
|
2055
|
+
const preferred = ["Wi-Fi", "Ethernet", "USB 10/100/1000 LAN", "Thunderbolt Ethernet"];
|
|
2056
|
+
for (const service of [...preferred, ...services.filter((candidate) => !preferred.includes(candidate))]) {
|
|
2057
|
+
if (!services.includes(service)) continue;
|
|
2058
|
+
try {
|
|
2059
|
+
const { stdout: info } = await execFileAsync("networksetup", ["-getinfo", service], { timeout: 5e3 });
|
|
2060
|
+
if (/IP address:\s*\d+\.\d+/.test(String(info || ""))) {
|
|
2061
|
+
return service;
|
|
2062
|
+
}
|
|
2063
|
+
} catch {
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
throw new Error("No active macOS network service was found via networksetup.");
|
|
2067
|
+
}
|
|
2068
|
+
async function saveCurrentProxySettings(iface) {
|
|
2069
|
+
const parseProxyOutput = (stdout) => {
|
|
2070
|
+
const lines = String(stdout || "").split("\n");
|
|
2071
|
+
const get = (key) => {
|
|
2072
|
+
const line = lines.find((candidate) => candidate.toLowerCase().startsWith(`${key.toLowerCase()}:`));
|
|
2073
|
+
return line ? line.split(":").slice(1).join(":").trim() : "";
|
|
2074
|
+
};
|
|
2075
|
+
return {
|
|
2076
|
+
enabled: get("Enabled") === "Yes",
|
|
2077
|
+
server: get("Server") || "",
|
|
2078
|
+
port: get("Port") || "0"
|
|
2079
|
+
};
|
|
2080
|
+
};
|
|
2081
|
+
const { stdout: httpOut } = await execFileAsync("networksetup", ["-getwebproxy", iface], { timeout: 5e3 });
|
|
2082
|
+
const { stdout: httpsOut } = await execFileAsync("networksetup", ["-getsecurewebproxy", iface], { timeout: 5e3 });
|
|
2083
|
+
return {
|
|
2084
|
+
http: parseProxyOutput(String(httpOut || "")),
|
|
2085
|
+
https: parseProxyOutput(String(httpsOut || ""))
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
function slugify(value) {
|
|
2089
|
+
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "checkpoint";
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// src/core/integrations/esvp-managed-proxy.ts
|
|
2093
|
+
import http from "http";
|
|
2094
|
+
import net from "net";
|
|
2095
|
+
function nowIso() {
|
|
2096
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
2097
|
+
}
|
|
2098
|
+
function randomId(prefix) {
|
|
2099
|
+
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
|
2100
|
+
}
|
|
2101
|
+
var ESVPManagedProxyManager = class {
|
|
2102
|
+
bindHost;
|
|
2103
|
+
defaultAdvertiseHost;
|
|
2104
|
+
proxies = /* @__PURE__ */ new Map();
|
|
2105
|
+
defaultBodyCaptureBytes;
|
|
2106
|
+
constructor(options = {}) {
|
|
2107
|
+
this.bindHost = String(options.bindHost || "127.0.0.1");
|
|
2108
|
+
this.defaultAdvertiseHost = normalizeOptionalString(options.advertiseHost);
|
|
2109
|
+
this.defaultBodyCaptureBytes = clampInt(options.maxBodyCaptureBytes, 2048, 131072, 16384);
|
|
2110
|
+
}
|
|
2111
|
+
shouldManageProfile(profile = {}) {
|
|
2112
|
+
const captureMode = String(profile.capture?.mode || "").trim().toLowerCase();
|
|
2113
|
+
return captureMode === "esvp-managed-proxy" || captureMode === "esvp-proxy" || (profile.capture?.enabled === true || hasAdvancedFaults(profile)) && !hasExplicitProxy(profile);
|
|
2114
|
+
}
|
|
2115
|
+
async configureSessionProxy(input) {
|
|
2116
|
+
const existing = this.proxies.get(input.sessionId) || null;
|
|
2117
|
+
if (existing) {
|
|
2118
|
+
existing.profile = input.profile || {};
|
|
2119
|
+
return {
|
|
2120
|
+
managed: true,
|
|
2121
|
+
proxy: existing.publicState(),
|
|
2122
|
+
capabilities: managedProxyCapabilities(),
|
|
2123
|
+
effectiveProfile: {
|
|
2124
|
+
...input.profile || {},
|
|
2125
|
+
proxy: {
|
|
2126
|
+
...normalizeProxyShape(input.profile?.proxy),
|
|
2127
|
+
host: existing.advertiseHost,
|
|
2128
|
+
port: existing.port,
|
|
2129
|
+
protocol: "http"
|
|
2130
|
+
},
|
|
2131
|
+
capture: {
|
|
2132
|
+
...input.profile?.capture || {},
|
|
2133
|
+
enabled: true,
|
|
2134
|
+
mode: "esvp-managed-proxy"
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
const proxy = new ManagedSessionProxy({
|
|
2140
|
+
sessionId: input.sessionId,
|
|
2141
|
+
profile: input.profile || {},
|
|
2142
|
+
bindHost: normalizeOptionalString(input.profile?.proxy?.bind_host) || this.bindHost,
|
|
2143
|
+
advertiseHost: normalizeOptionalString(input.profile?.proxy?.advertise_host) || inferAdvertiseHost(input.profile, input.session) || this.defaultAdvertiseHost || inferSessionAdvertiseHost(input.session) || "127.0.0.1",
|
|
2144
|
+
maxBodyCaptureBytes: this.defaultBodyCaptureBytes
|
|
2145
|
+
});
|
|
2146
|
+
await proxy.start();
|
|
2147
|
+
this.proxies.set(input.sessionId, proxy);
|
|
2148
|
+
return {
|
|
2149
|
+
managed: true,
|
|
2150
|
+
proxy: proxy.publicState(),
|
|
2151
|
+
capabilities: managedProxyCapabilities(),
|
|
2152
|
+
effectiveProfile: {
|
|
2153
|
+
...input.profile || {},
|
|
2154
|
+
proxy: {
|
|
2155
|
+
...normalizeProxyShape(input.profile?.proxy),
|
|
2156
|
+
host: proxy.advertiseHost,
|
|
2157
|
+
port: proxy.port,
|
|
2158
|
+
protocol: "http"
|
|
2159
|
+
},
|
|
2160
|
+
capture: {
|
|
2161
|
+
...input.profile?.capture || {},
|
|
2162
|
+
enabled: true,
|
|
2163
|
+
mode: "esvp-managed-proxy"
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
getSessionProxy(sessionId) {
|
|
2169
|
+
return this.proxies.get(sessionId)?.publicState() || null;
|
|
2170
|
+
}
|
|
2171
|
+
async releaseSessionProxy(sessionId, options = {}) {
|
|
2172
|
+
const proxy = this.proxies.get(sessionId);
|
|
2173
|
+
if (!proxy) {
|
|
2174
|
+
return {
|
|
2175
|
+
managed: false,
|
|
2176
|
+
traces: [],
|
|
2177
|
+
proxy: null
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
this.proxies.delete(sessionId);
|
|
2181
|
+
const traces = proxy.snapshotTraceEntries({
|
|
2182
|
+
reason: options.reason || "released"
|
|
2183
|
+
});
|
|
2184
|
+
await proxy.stop();
|
|
2185
|
+
return {
|
|
2186
|
+
managed: true,
|
|
2187
|
+
traces,
|
|
2188
|
+
proxy: proxy.publicState({ active: false })
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
2192
|
+
var ManagedSessionProxy = class {
|
|
2193
|
+
id = randomId("proxy");
|
|
2194
|
+
sessionId;
|
|
2195
|
+
profile;
|
|
2196
|
+
bindHost;
|
|
2197
|
+
advertiseHost;
|
|
2198
|
+
maxBodyCaptureBytes;
|
|
2199
|
+
entries = [];
|
|
2200
|
+
server = null;
|
|
2201
|
+
startedAt = null;
|
|
2202
|
+
port = null;
|
|
2203
|
+
seq = 0;
|
|
2204
|
+
constructor(options) {
|
|
2205
|
+
this.sessionId = String(options.sessionId);
|
|
2206
|
+
this.profile = options.profile || {};
|
|
2207
|
+
this.bindHost = String(options.bindHost || "127.0.0.1");
|
|
2208
|
+
this.advertiseHost = String(options.advertiseHost || this.bindHost);
|
|
2209
|
+
this.maxBodyCaptureBytes = clampInt(options.maxBodyCaptureBytes, 2048, 131072, 16384);
|
|
2210
|
+
}
|
|
2211
|
+
async start() {
|
|
2212
|
+
if (this.server) return;
|
|
2213
|
+
this.server = http.createServer();
|
|
2214
|
+
this.server.on("request", (req, res) => {
|
|
2215
|
+
void this.handleHttpRequest(req, res).catch((error) => {
|
|
2216
|
+
if (!res.headersSent) {
|
|
2217
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
2218
|
+
}
|
|
2219
|
+
res.end(`ESVP proxy error: ${error instanceof Error ? error.message : String(error)}`);
|
|
2220
|
+
});
|
|
2221
|
+
});
|
|
2222
|
+
this.server.on("connect", (req, clientSocket, head) => {
|
|
2223
|
+
void this.handleConnect(req, clientSocket, head).catch(() => {
|
|
2224
|
+
try {
|
|
2225
|
+
clientSocket.destroy();
|
|
2226
|
+
} catch {
|
|
2227
|
+
}
|
|
2228
|
+
});
|
|
2229
|
+
});
|
|
2230
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
2231
|
+
const onError = (error) => {
|
|
2232
|
+
this.server?.off("listening", onListening);
|
|
2233
|
+
rejectListen(error);
|
|
2234
|
+
};
|
|
2235
|
+
const onListening = () => {
|
|
2236
|
+
this.server?.off("error", onError);
|
|
2237
|
+
resolveListen();
|
|
2238
|
+
};
|
|
2239
|
+
this.server?.once("error", onError);
|
|
2240
|
+
this.server?.once("listening", onListening);
|
|
2241
|
+
this.server?.listen(0, this.bindHost);
|
|
2242
|
+
});
|
|
2243
|
+
const address = this.server.address();
|
|
2244
|
+
this.port = typeof address === "object" && address && "port" in address ? Number(address.port) : null;
|
|
2245
|
+
this.startedAt = nowIso();
|
|
2246
|
+
this.server.unref();
|
|
2247
|
+
}
|
|
2248
|
+
async stop() {
|
|
2249
|
+
if (!this.server) return;
|
|
2250
|
+
const server = this.server;
|
|
2251
|
+
this.server = null;
|
|
2252
|
+
await new Promise((resolveClose) => {
|
|
2253
|
+
server.close(() => resolveClose());
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
publicState(options = {}) {
|
|
2257
|
+
return {
|
|
2258
|
+
id: this.id,
|
|
2259
|
+
active: options.active === false ? false : Boolean(this.server),
|
|
2260
|
+
bind_host: this.bindHost,
|
|
2261
|
+
host: this.advertiseHost,
|
|
2262
|
+
port: this.port,
|
|
2263
|
+
url: this.port ? `http://${this.advertiseHost}:${this.port}` : null,
|
|
2264
|
+
started_at: this.startedAt,
|
|
2265
|
+
entry_count: this.entries.length,
|
|
2266
|
+
capture_mode: "esvp-managed-proxy"
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
snapshotTraceEntries(options = {}) {
|
|
2270
|
+
if (!this.entries.length) return [];
|
|
2271
|
+
return [
|
|
2272
|
+
{
|
|
2273
|
+
trace_kind: "http_trace",
|
|
2274
|
+
label: options.reason ? `managed-proxy-${slugify2(options.reason)}` : "managed-proxy",
|
|
2275
|
+
format: "json",
|
|
2276
|
+
source: "esvp-managed-proxy",
|
|
2277
|
+
payload: {
|
|
2278
|
+
session_id: this.sessionId,
|
|
2279
|
+
proxy_id: this.id,
|
|
2280
|
+
generated_at: nowIso(),
|
|
2281
|
+
entries: this.entries.map((entry) => JSON.parse(JSON.stringify(entry)))
|
|
2282
|
+
},
|
|
2283
|
+
artifactMeta: {
|
|
2284
|
+
capture_mode: "esvp-managed-proxy",
|
|
2285
|
+
proxy_id: this.id,
|
|
2286
|
+
entry_count: this.entries.length
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
];
|
|
2290
|
+
}
|
|
2291
|
+
async handleHttpRequest(clientReq, clientRes) {
|
|
2292
|
+
const entry = this.createBaseEntry({
|
|
2293
|
+
kind: "request",
|
|
2294
|
+
method: clientReq.method || "GET",
|
|
2295
|
+
url: inferProxyRequestUrl(clientReq)
|
|
2296
|
+
});
|
|
2297
|
+
const body = await readStreamBuffer(clientReq, this.maxBodyCaptureBytes);
|
|
2298
|
+
entry.request = {
|
|
2299
|
+
url: entry.url,
|
|
2300
|
+
method: entry.method,
|
|
2301
|
+
headers: redactHeaders(clientReq.headers),
|
|
2302
|
+
startedAt: entry.startedAt,
|
|
2303
|
+
bodyPreview: previewBody(body),
|
|
2304
|
+
bodyBytes: body.length
|
|
2305
|
+
};
|
|
2306
|
+
const fault = await maybeApplyFaults({
|
|
2307
|
+
profile: this.profile,
|
|
2308
|
+
entry,
|
|
2309
|
+
respondHttp: async ({ statusCode, headers, bodyText, delayMs = 0 }) => {
|
|
2310
|
+
if (delayMs > 0) await sleep(delayMs);
|
|
2311
|
+
clientRes.writeHead(statusCode, headers);
|
|
2312
|
+
clientRes.end(bodyText);
|
|
2313
|
+
},
|
|
2314
|
+
abortHttp: async ({ delayMs = 0, errorText = "ESVP_PROXY_ABORT" }) => {
|
|
2315
|
+
if (delayMs > 0) await sleep(delayMs);
|
|
2316
|
+
clientReq.destroy(new Error(errorText));
|
|
2317
|
+
clientRes.destroy(new Error(errorText));
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
if (fault.handled) {
|
|
2321
|
+
entry.finishedAt = Date.now();
|
|
2322
|
+
entry.durationMs = Math.max(0, entry.finishedAt - entry.startedAt);
|
|
2323
|
+
this.entries.push(entry);
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
const targetUrl = new URL(entry.url);
|
|
2327
|
+
const upstreamReq = http.request(
|
|
2328
|
+
{
|
|
2329
|
+
protocol: targetUrl.protocol,
|
|
2330
|
+
hostname: targetUrl.hostname,
|
|
2331
|
+
port: targetUrl.port || 80,
|
|
2332
|
+
method: entry.method,
|
|
2333
|
+
path: `${targetUrl.pathname}${targetUrl.search}`,
|
|
2334
|
+
headers: filterHopByHopHeaders(clientReq.headers)
|
|
2335
|
+
},
|
|
2336
|
+
async (upstreamRes) => {
|
|
2337
|
+
const responseBody = await readStreamBuffer(upstreamRes, this.maxBodyCaptureBytes);
|
|
2338
|
+
entry.status = upstreamRes.statusCode || null;
|
|
2339
|
+
entry.ok = typeof upstreamRes.statusCode === "number" ? upstreamRes.statusCode < 400 : null;
|
|
2340
|
+
entry.response = {
|
|
2341
|
+
status: upstreamRes.statusCode || null,
|
|
2342
|
+
headers: redactHeaders(upstreamRes.headers),
|
|
2343
|
+
durationMs: Math.max(0, Date.now() - entry.startedAt),
|
|
2344
|
+
size: responseBody.length,
|
|
2345
|
+
contentType: headerValue(upstreamRes.headers["content-type"]),
|
|
2346
|
+
bodyPreview: previewBody(responseBody)
|
|
2347
|
+
};
|
|
2348
|
+
entry.finishedAt = Date.now();
|
|
2349
|
+
entry.durationMs = Math.max(0, entry.finishedAt - entry.startedAt);
|
|
2350
|
+
clientRes.writeHead(upstreamRes.statusCode || 502, filterHopByHopHeaders(upstreamRes.headers));
|
|
2351
|
+
clientRes.end(responseBody);
|
|
2352
|
+
this.entries.push(entry);
|
|
2353
|
+
}
|
|
2354
|
+
);
|
|
2355
|
+
upstreamReq.on("error", (error) => {
|
|
2356
|
+
entry.finishedAt = Date.now();
|
|
2357
|
+
entry.durationMs = Math.max(0, entry.finishedAt - entry.startedAt);
|
|
2358
|
+
entry.failureText = error instanceof Error ? error.message : String(error);
|
|
2359
|
+
entry.response = {
|
|
2360
|
+
error: entry.failureText
|
|
2361
|
+
};
|
|
2362
|
+
if (!clientRes.headersSent) {
|
|
2363
|
+
clientRes.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
2364
|
+
}
|
|
2365
|
+
clientRes.end(`ESVP upstream error: ${entry.failureText}`);
|
|
2366
|
+
this.entries.push(entry);
|
|
2367
|
+
});
|
|
2368
|
+
if (body.length) upstreamReq.write(body);
|
|
2369
|
+
upstreamReq.end();
|
|
2370
|
+
}
|
|
2371
|
+
async handleConnect(req, clientSocket, head) {
|
|
2372
|
+
const authority = String(req.url || "");
|
|
2373
|
+
const [hostname, portRaw] = authority.split(":");
|
|
2374
|
+
const port = Number(portRaw || 443);
|
|
2375
|
+
const entry = this.createBaseEntry({
|
|
2376
|
+
kind: "connect",
|
|
2377
|
+
method: "CONNECT",
|
|
2378
|
+
url: `https://${authority}`
|
|
2379
|
+
});
|
|
2380
|
+
entry.request = {
|
|
2381
|
+
url: entry.url,
|
|
2382
|
+
method: "CONNECT",
|
|
2383
|
+
headers: redactHeaders(req.headers),
|
|
2384
|
+
startedAt: entry.startedAt
|
|
2385
|
+
};
|
|
2386
|
+
const fault = await maybeApplyFaults({
|
|
2387
|
+
profile: this.profile,
|
|
2388
|
+
entry,
|
|
2389
|
+
respondConnect: async ({ delayMs = 0, statusCode = 502, message = "Bad Gateway" }) => {
|
|
2390
|
+
if (delayMs > 0) await sleep(delayMs);
|
|
2391
|
+
clientSocket.write(`HTTP/1.1 ${statusCode} ${message}\r
|
|
2392
|
+
Connection: close\r
|
|
2393
|
+
\r
|
|
2394
|
+
`);
|
|
2395
|
+
clientSocket.destroy();
|
|
2396
|
+
},
|
|
2397
|
+
abortConnect: async ({ delayMs = 0 }) => {
|
|
2398
|
+
if (delayMs > 0) await sleep(delayMs);
|
|
2399
|
+
clientSocket.destroy();
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
if (fault.handled) {
|
|
2403
|
+
entry.finishedAt = Date.now();
|
|
2404
|
+
entry.durationMs = Math.max(0, entry.finishedAt - entry.startedAt);
|
|
2405
|
+
this.entries.push(entry);
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
const upstreamSocket = net.connect(port, hostname, () => {
|
|
2409
|
+
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
2410
|
+
if (head && head.length > 0) {
|
|
2411
|
+
upstreamSocket.write(head);
|
|
2412
|
+
}
|
|
2413
|
+
upstreamSocket.pipe(clientSocket);
|
|
2414
|
+
clientSocket.pipe(upstreamSocket);
|
|
2415
|
+
entry.status = 200;
|
|
2416
|
+
entry.ok = true;
|
|
2417
|
+
entry.response = {
|
|
2418
|
+
status: 200
|
|
2419
|
+
};
|
|
2420
|
+
});
|
|
2421
|
+
const finalize = (error = null) => {
|
|
2422
|
+
if (entry.finishedAt) return;
|
|
2423
|
+
entry.finishedAt = Date.now();
|
|
2424
|
+
entry.durationMs = Math.max(0, entry.finishedAt - entry.startedAt);
|
|
2425
|
+
if (error) {
|
|
2426
|
+
entry.failureText = error instanceof Error ? error.message : String(error);
|
|
2427
|
+
entry.response = {
|
|
2428
|
+
...entry.response || {},
|
|
2429
|
+
error: entry.failureText
|
|
2430
|
+
};
|
|
2431
|
+
}
|
|
2432
|
+
this.entries.push(entry);
|
|
2433
|
+
};
|
|
2434
|
+
upstreamSocket.on("error", (error) => {
|
|
2435
|
+
finalize(error);
|
|
2436
|
+
try {
|
|
2437
|
+
clientSocket.destroy(error);
|
|
2438
|
+
} catch {
|
|
2439
|
+
}
|
|
2440
|
+
});
|
|
2441
|
+
clientSocket.on("error", (error) => finalize(error));
|
|
2442
|
+
upstreamSocket.on("close", () => finalize());
|
|
2443
|
+
clientSocket.on("close", () => finalize());
|
|
2444
|
+
}
|
|
2445
|
+
createBaseEntry(input) {
|
|
2446
|
+
this.seq += 1;
|
|
2447
|
+
return {
|
|
2448
|
+
id: `${this.id}-${String(this.seq).padStart(4, "0")}`,
|
|
2449
|
+
kind: input.kind,
|
|
2450
|
+
resourceType: input.kind === "connect" ? "connect_tunnel" : "request",
|
|
2451
|
+
method: String(input.method || "GET").toUpperCase(),
|
|
2452
|
+
url: input.url,
|
|
2453
|
+
startedAt: Date.now(),
|
|
2454
|
+
sessionId: this.sessionId,
|
|
2455
|
+
proxyId: this.id
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
};
|
|
2459
|
+
async function maybeApplyFaults(input) {
|
|
2460
|
+
const faults = input.profile?.faults || {};
|
|
2461
|
+
const delayMs = clampInt(faults.delay_ms, 0, 6e4, 0);
|
|
2462
|
+
if (faults.offline_partial === true && shouldFailOfflinePartial(input.entry)) {
|
|
2463
|
+
input.entry.failureText = "ESVP_PROXY_OFFLINE_PARTIAL";
|
|
2464
|
+
if (input.entry.method === "CONNECT") {
|
|
2465
|
+
await input.abortConnect?.({ delayMs });
|
|
2466
|
+
} else {
|
|
2467
|
+
await input.abortHttp?.({ delayMs, errorText: "ESVP_PROXY_OFFLINE_PARTIAL" });
|
|
2468
|
+
}
|
|
2469
|
+
return { handled: true };
|
|
2470
|
+
}
|
|
2471
|
+
if (faults.timeout === true) {
|
|
2472
|
+
input.entry.failureText = "ESVP_PROXY_TIMEOUT";
|
|
2473
|
+
if (input.entry.method === "CONNECT") {
|
|
2474
|
+
await input.abortConnect?.({ delayMs: Math.max(delayMs, 15e3) });
|
|
2475
|
+
} else {
|
|
2476
|
+
await input.abortHttp?.({ delayMs: Math.max(delayMs, 15e3), errorText: "ESVP_PROXY_TIMEOUT" });
|
|
2477
|
+
}
|
|
2478
|
+
return { handled: true };
|
|
2479
|
+
}
|
|
2480
|
+
if (input.entry.method !== "CONNECT" && Number.isFinite(faults.status_code) && faults.status_code > 0) {
|
|
2481
|
+
const statusCode = Math.max(100, Math.min(599, Number(faults.status_code)));
|
|
2482
|
+
const bodyText = faults.body_patch != null ? typeof faults.body_patch === "string" ? faults.body_patch : JSON.stringify(faults.body_patch, null, 2) : "";
|
|
2483
|
+
input.entry.status = statusCode;
|
|
2484
|
+
input.entry.ok = statusCode < 400;
|
|
2485
|
+
input.entry.response = {
|
|
2486
|
+
status: statusCode,
|
|
2487
|
+
headers: {
|
|
2488
|
+
"content-type": typeof faults.body_patch === "object" ? "application/json; charset=utf-8" : "text/plain; charset=utf-8",
|
|
2489
|
+
"x-esvp-fault": "status_code"
|
|
2490
|
+
},
|
|
2491
|
+
durationMs: delayMs,
|
|
2492
|
+
size: Buffer.byteLength(bodyText),
|
|
2493
|
+
bodyPreview: previewBody(Buffer.from(bodyText, "utf8"))
|
|
2494
|
+
};
|
|
2495
|
+
await input.respondHttp?.({
|
|
2496
|
+
statusCode,
|
|
2497
|
+
delayMs,
|
|
2498
|
+
headers: input.entry.response.headers,
|
|
2499
|
+
bodyText
|
|
2500
|
+
});
|
|
2501
|
+
return { handled: true };
|
|
2502
|
+
}
|
|
2503
|
+
if (delayMs > 0) {
|
|
2504
|
+
await sleep(delayMs);
|
|
2505
|
+
}
|
|
2506
|
+
return { handled: false };
|
|
2507
|
+
}
|
|
2508
|
+
function shouldFailOfflinePartial(entry) {
|
|
2509
|
+
const idNum = Number(String(entry.id || "").split("-").pop());
|
|
2510
|
+
return Number.isFinite(idNum) ? idNum % 2 === 0 : false;
|
|
2511
|
+
}
|
|
2512
|
+
function inferProxyRequestUrl(req) {
|
|
2513
|
+
const raw = String(req.url || "");
|
|
2514
|
+
if (/^https?:\/\//i.test(raw)) return raw;
|
|
2515
|
+
const host = headerValue(req.headers.host) || "127.0.0.1";
|
|
2516
|
+
return `http://${host}${raw.startsWith("/") ? raw : `/${raw}`}`;
|
|
2517
|
+
}
|
|
2518
|
+
function filterHopByHopHeaders(headers = {}) {
|
|
2519
|
+
const result = {};
|
|
2520
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
2521
|
+
const lower = String(key).toLowerCase();
|
|
2522
|
+
if (lower === "proxy-connection" || lower === "connection" || lower === "keep-alive" || lower === "transfer-encoding" || lower === "te" || lower === "trailer" || lower === "upgrade" || lower === "proxy-authorization") {
|
|
2523
|
+
continue;
|
|
2524
|
+
}
|
|
2525
|
+
result[key] = value;
|
|
2526
|
+
}
|
|
2527
|
+
return result;
|
|
2528
|
+
}
|
|
2529
|
+
function redactHeaders(headers = {}) {
|
|
2530
|
+
const output = {};
|
|
2531
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
2532
|
+
const lower = String(key).toLowerCase();
|
|
2533
|
+
if (lower === "authorization" || lower === "proxy-authorization" || lower === "cookie" || lower === "set-cookie") {
|
|
2534
|
+
output[key] = "[redacted]";
|
|
2535
|
+
continue;
|
|
2536
|
+
}
|
|
2537
|
+
output[key] = Array.isArray(value) ? value.join(", ") : value != null ? String(value) : null;
|
|
2538
|
+
}
|
|
2539
|
+
return output;
|
|
2540
|
+
}
|
|
2541
|
+
function headerValue(value) {
|
|
2542
|
+
if (Array.isArray(value)) return value[0] || null;
|
|
2543
|
+
return value != null ? String(value) : null;
|
|
2544
|
+
}
|
|
2545
|
+
async function readStreamBuffer(stream, limitBytes) {
|
|
2546
|
+
const chunks = [];
|
|
2547
|
+
let total = 0;
|
|
2548
|
+
for await (const chunk of stream) {
|
|
2549
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2550
|
+
total += buffer.length;
|
|
2551
|
+
if (total <= limitBytes) {
|
|
2552
|
+
chunks.push(buffer);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
return Buffer.concat(chunks);
|
|
2556
|
+
}
|
|
2557
|
+
function previewBody(buffer) {
|
|
2558
|
+
if (!buffer || buffer.length === 0) return null;
|
|
2559
|
+
return buffer.toString("utf8").slice(0, 1024);
|
|
2560
|
+
}
|
|
2561
|
+
function hasExplicitProxy(profile = {}) {
|
|
2562
|
+
return Boolean(profile?.proxy?.host) && Number.isFinite(profile?.proxy?.port) && Number(profile.proxy.port) > 0;
|
|
2563
|
+
}
|
|
2564
|
+
function hasAdvancedFaults(profile = {}) {
|
|
2565
|
+
const faults = profile?.faults || {};
|
|
2566
|
+
return Boolean(
|
|
2567
|
+
faults.delay_ms != null || faults.timeout === true || faults.offline_partial === true || faults.status_code != null || faults.body_patch != null
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
function normalizeProxyShape(proxy = null) {
|
|
2571
|
+
if (!proxy || typeof proxy !== "object") {
|
|
2572
|
+
return {
|
|
2573
|
+
host: null,
|
|
2574
|
+
port: null,
|
|
2575
|
+
protocol: "http",
|
|
2576
|
+
bypass: []
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
return {
|
|
2580
|
+
host: normalizeOptionalString(proxy.host),
|
|
2581
|
+
port: Number.isFinite(proxy.port) ? Number(proxy.port) : null,
|
|
2582
|
+
protocol: normalizeOptionalString(proxy.protocol) || "http",
|
|
2583
|
+
bypass: Array.isArray(proxy.bypass) ? proxy.bypass.map((value) => String(value)) : []
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
function inferAdvertiseHost(profile = {}, session) {
|
|
2587
|
+
return normalizeOptionalString(profile?.proxy?.advertise_host) || inferSessionAdvertiseHost(session);
|
|
2588
|
+
}
|
|
2589
|
+
function inferSessionAdvertiseHost(session) {
|
|
2590
|
+
const deviceId = String(session?.context?.deviceId || "");
|
|
2591
|
+
if (deviceId.startsWith("emulator-")) return "10.0.2.2";
|
|
2592
|
+
return null;
|
|
2593
|
+
}
|
|
2594
|
+
function normalizeOptionalString(value) {
|
|
2595
|
+
if (typeof value !== "string") return null;
|
|
2596
|
+
const trimmed = value.trim();
|
|
2597
|
+
return trimmed || null;
|
|
2598
|
+
}
|
|
2599
|
+
function clampInt(value, min, max, fallback) {
|
|
2600
|
+
const num = Number(value);
|
|
2601
|
+
if (!Number.isFinite(num)) return fallback;
|
|
2602
|
+
return Math.max(min, Math.min(max, Math.round(num)));
|
|
2603
|
+
}
|
|
2604
|
+
function slugify2(value) {
|
|
2605
|
+
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "trace";
|
|
2606
|
+
}
|
|
2607
|
+
function sleep(ms) {
|
|
2608
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
2609
|
+
}
|
|
2610
|
+
function managedProxyCapabilities() {
|
|
2611
|
+
return {
|
|
2612
|
+
proxy: true,
|
|
2613
|
+
connectivity: false,
|
|
2614
|
+
delay: true,
|
|
2615
|
+
loss: false,
|
|
2616
|
+
timeout: true,
|
|
2617
|
+
offline_partial: true,
|
|
2618
|
+
status_code: true,
|
|
2619
|
+
body_patch: true,
|
|
2620
|
+
trace_attach: true,
|
|
2621
|
+
capture: true,
|
|
2622
|
+
mode: "esvp-managed-proxy"
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
// src/core/integrations/esvp-local-runtime.ts
|
|
2627
|
+
var execFileAsync2 = promisify3(execFile2);
|
|
2628
|
+
var LOCAL_ESVP_SERVER_URL = "applab://local-esvp";
|
|
2629
|
+
var LOCAL_RUNTIME_ROOT = join4(DATA_DIR, "esvp-local");
|
|
2630
|
+
var LOCAL_RUNS_ROOT = join4(LOCAL_RUNTIME_ROOT, "runs");
|
|
2631
|
+
var runtimePromise = null;
|
|
2632
|
+
var cleanupRegistered = false;
|
|
2633
|
+
async function getAppLabESVPLocalRuntime() {
|
|
2634
|
+
if (!runtimePromise) {
|
|
2635
|
+
runtimePromise = (async () => {
|
|
2636
|
+
const runtime = new AppLabESVPLocalRuntime();
|
|
2637
|
+
await runtime.init();
|
|
2638
|
+
runtime.registerCleanupHooks();
|
|
2639
|
+
return runtime;
|
|
2640
|
+
})().catch((error) => {
|
|
2641
|
+
runtimePromise = null;
|
|
2642
|
+
throw error;
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
return runtimePromise;
|
|
2646
|
+
}
|
|
2647
|
+
var AppLabESVPLocalRuntime = class {
|
|
2648
|
+
rootDir = LOCAL_RUNS_ROOT;
|
|
2649
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2650
|
+
managedProxyManager = new ESVPManagedProxyManager();
|
|
2651
|
+
async init() {
|
|
2652
|
+
await mkdir2(this.rootDir, { recursive: true });
|
|
2653
|
+
await this.loadPersistedSessions();
|
|
2654
|
+
}
|
|
2655
|
+
registerCleanupHooks() {
|
|
2656
|
+
if (cleanupRegistered) return;
|
|
2657
|
+
cleanupRegistered = true;
|
|
2658
|
+
const cleanup = async () => {
|
|
2659
|
+
await this.cleanupTransientSystemState().catch(() => void 0);
|
|
2660
|
+
};
|
|
2661
|
+
process.once("beforeExit", () => {
|
|
2662
|
+
void cleanup();
|
|
2663
|
+
});
|
|
2664
|
+
process.once("SIGINT", () => {
|
|
2665
|
+
void cleanup().finally(() => process.exit(0));
|
|
2666
|
+
});
|
|
2667
|
+
process.once("SIGTERM", () => {
|
|
2668
|
+
void cleanup().finally(() => process.exit(0));
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
async getHealth() {
|
|
2672
|
+
return {
|
|
2673
|
+
ok: true,
|
|
2674
|
+
service: "applab-esvp-local",
|
|
2675
|
+
version: APP_VERSION,
|
|
2676
|
+
auth: { enabled: false },
|
|
2677
|
+
limits: {
|
|
2678
|
+
max_body_bytes: 10 * 1024 * 1024
|
|
2679
|
+
},
|
|
2680
|
+
managed_proxy: {
|
|
2681
|
+
bind_host: "127.0.0.1",
|
|
2682
|
+
advertise_host: null
|
|
2683
|
+
}
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
listSessions() {
|
|
2687
|
+
return [...this.sessions.values()].map((session) => this.publicSession(session)).sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || "")));
|
|
2688
|
+
}
|
|
2689
|
+
async createSession(input) {
|
|
2690
|
+
const executorName = normalizeExecutor(input.executor);
|
|
2691
|
+
const sessionId = input.sessionId || randomId2("sess");
|
|
2692
|
+
if (this.sessions.has(sessionId)) {
|
|
2693
|
+
throw new Error(`session_id already exists: ${sessionId}`);
|
|
2694
|
+
}
|
|
2695
|
+
const runDir = resolve(this.rootDir, sessionId);
|
|
2696
|
+
await mkdir2(runDir, { recursive: true });
|
|
2697
|
+
const resolvedDeviceId = input.deviceId || await resolveDefaultDeviceId(executorName);
|
|
2698
|
+
const meta = sanitizeMeta({
|
|
2699
|
+
...input.meta || {},
|
|
2700
|
+
...input.crash_clip ? { crash_clip: input.crash_clip } : {}
|
|
2701
|
+
});
|
|
2702
|
+
const transcript = [
|
|
2703
|
+
{
|
|
2704
|
+
t: 0,
|
|
2705
|
+
type: "session_started",
|
|
2706
|
+
session_id: sessionId,
|
|
2707
|
+
target: targetForExecutor(executorName),
|
|
2708
|
+
meta: {
|
|
2709
|
+
executor: executorName,
|
|
2710
|
+
...meta,
|
|
2711
|
+
...resolvedDeviceId ? { deviceId: resolvedDeviceId } : {}
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
];
|
|
2715
|
+
const session = {
|
|
2716
|
+
id: sessionId,
|
|
2717
|
+
executorName,
|
|
2718
|
+
context: {
|
|
2719
|
+
deviceId: resolvedDeviceId || null,
|
|
2720
|
+
target: targetForExecutor(executorName),
|
|
2721
|
+
runDir,
|
|
2722
|
+
appId: resolveMetaAppId(meta),
|
|
2723
|
+
preflightConfig: null,
|
|
2724
|
+
networkProfile: null,
|
|
2725
|
+
lastRunOutput: null,
|
|
2726
|
+
lastFlowPath: null,
|
|
2727
|
+
lastRunFailed: false
|
|
2728
|
+
},
|
|
2729
|
+
createdAt: nowIso2(),
|
|
2730
|
+
updatedAt: nowIso2(),
|
|
2731
|
+
status: "running",
|
|
2732
|
+
error: null,
|
|
2733
|
+
recovered: false,
|
|
2734
|
+
meta,
|
|
2735
|
+
runDir,
|
|
2736
|
+
transcriptPath: resolve(runDir, "transcript.jsonl"),
|
|
2737
|
+
transcriptPathRelative: relativeRunsPath(this.rootDir, runDir, "transcript.jsonl"),
|
|
2738
|
+
manifestPath: resolve(runDir, "session.json"),
|
|
2739
|
+
manifestPathRelative: relativeRunsPath(this.rootDir, runDir, "session.json"),
|
|
2740
|
+
transcript,
|
|
2741
|
+
actionCount: 0,
|
|
2742
|
+
checkpointCount: 0,
|
|
2743
|
+
artifactCount: 0,
|
|
2744
|
+
network: createInitialNetworkState(executorName)
|
|
2745
|
+
};
|
|
2746
|
+
this.sessions.set(sessionId, session);
|
|
2747
|
+
await this.persistSessionState(session);
|
|
2748
|
+
return this.publicSession(session);
|
|
2749
|
+
}
|
|
2750
|
+
getSession(sessionId) {
|
|
2751
|
+
return this.publicSession(this.requireSession(sessionId));
|
|
2752
|
+
}
|
|
2753
|
+
getTranscript(sessionId) {
|
|
2754
|
+
const session = this.requireSession(sessionId);
|
|
2755
|
+
return {
|
|
2756
|
+
session_id: session.id,
|
|
2757
|
+
status: session.status,
|
|
2758
|
+
transcript_path: session.transcriptPathRelative,
|
|
2759
|
+
events: session.transcript
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
listArtifacts(sessionId) {
|
|
2763
|
+
const session = this.requireSession(sessionId);
|
|
2764
|
+
return session.transcript.filter((event) => event.type === "artifact").map((event) => ({
|
|
2765
|
+
t: event.t,
|
|
2766
|
+
kind: event.kind,
|
|
2767
|
+
path: event.path,
|
|
2768
|
+
sha256: event.sha256,
|
|
2769
|
+
bytes: event.bytes,
|
|
2770
|
+
meta: event.meta || null,
|
|
2771
|
+
abs_path: resolve(session.runDir, event.path)
|
|
2772
|
+
}));
|
|
2773
|
+
}
|
|
2774
|
+
async getArtifactContent(sessionId, artifactPath) {
|
|
2775
|
+
const resolved = this.resolveSessionArtifactPath(sessionId, artifactPath);
|
|
2776
|
+
const ext = extname(resolved.absPath).toLowerCase();
|
|
2777
|
+
const data = await readFile2(resolved.absPath);
|
|
2778
|
+
if (ext === ".json" || ext === ".har" || ext.endsWith(".json")) {
|
|
2779
|
+
try {
|
|
2780
|
+
return JSON.parse(data.toString("utf8"));
|
|
2781
|
+
} catch {
|
|
2782
|
+
return data.toString("utf8");
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
if (ext === ".txt" || ext === ".log" || ext === ".jsonl" || ext === ".xml" || ext === ".yaml" || ext === ".yml") {
|
|
2786
|
+
return data.toString("utf8");
|
|
2787
|
+
}
|
|
2788
|
+
return {
|
|
2789
|
+
encoding: "base64",
|
|
2790
|
+
bytes: data.length,
|
|
2791
|
+
content_base64: data.toString("base64"),
|
|
2792
|
+
path: resolved.path
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
async runPreflight(sessionId, preflightConfig) {
|
|
2796
|
+
const session = this.requireMutableSession(sessionId);
|
|
2797
|
+
if (!preflightConfig || typeof preflightConfig !== "object") {
|
|
2798
|
+
throw new Error("Invalid preflight config");
|
|
2799
|
+
}
|
|
2800
|
+
session.context.preflightConfig = sanitizeMeta(preflightConfig);
|
|
2801
|
+
const appId = typeof preflightConfig.appId === "string" ? preflightConfig.appId.trim() : "";
|
|
2802
|
+
if (appId) {
|
|
2803
|
+
session.context.appId = appId;
|
|
2804
|
+
session.meta = {
|
|
2805
|
+
...session.meta,
|
|
2806
|
+
appId,
|
|
2807
|
+
app_id: appId
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
const results = Array.isArray(preflightConfig.rules) ? preflightConfig.rules.map((rule) => ({
|
|
2811
|
+
kind: String(rule?.kind || "unknown"),
|
|
2812
|
+
status: "applied",
|
|
2813
|
+
optional: rule?.optional === true
|
|
2814
|
+
})) : [];
|
|
2815
|
+
const event = {
|
|
2816
|
+
t: lastEventTime(session.transcript) + 1,
|
|
2817
|
+
type: "preflight",
|
|
2818
|
+
policy: preflightConfig.policy || "default",
|
|
2819
|
+
appId: appId || session.context.appId || null,
|
|
2820
|
+
results
|
|
2821
|
+
};
|
|
2822
|
+
appendSessionEvents(session, [event]);
|
|
2823
|
+
await this.persistSessionState(session);
|
|
2824
|
+
return {
|
|
2825
|
+
session: this.publicSession(session),
|
|
2826
|
+
appended_events: [event],
|
|
2827
|
+
results
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
async runActions(sessionId, actions, options = {}) {
|
|
2831
|
+
const session = this.requireMutableSession(sessionId);
|
|
2832
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
2833
|
+
throw new Error("actions must be a non-empty array");
|
|
2834
|
+
}
|
|
2835
|
+
const normalizedActions = actions.map((action) => ({
|
|
2836
|
+
name: String(action?.name || ""),
|
|
2837
|
+
args: action?.args && typeof action.args === "object" ? action.args : {},
|
|
2838
|
+
checkpointAfter: typeof action?.checkpointAfter === "boolean" ? action.checkpointAfter : options.checkpointAfterEach === true,
|
|
2839
|
+
checkpointLabel: typeof action?.checkpointLabel === "string" ? action.checkpointLabel : void 0
|
|
2840
|
+
}));
|
|
2841
|
+
if (session.executorName === "fake") {
|
|
2842
|
+
return this.runFakeActions(session, normalizedActions, options);
|
|
2843
|
+
}
|
|
2844
|
+
const deviceId = session.context.deviceId || await resolveDefaultDeviceId(session.executorName);
|
|
2845
|
+
if (!deviceId) {
|
|
2846
|
+
throw new Error(`No connected device was found for executor ${session.executorName}.`);
|
|
2847
|
+
}
|
|
2848
|
+
session.context.deviceId = deviceId;
|
|
2849
|
+
const result = await runDeviceActionFlow({
|
|
2850
|
+
executor: session.executorName,
|
|
2851
|
+
deviceId,
|
|
2852
|
+
runDir: session.runDir,
|
|
2853
|
+
sessionId: session.id,
|
|
2854
|
+
meta: session.meta,
|
|
2855
|
+
preflightConfig: session.context.preflightConfig || null,
|
|
2856
|
+
actions: normalizedActions
|
|
2857
|
+
});
|
|
2858
|
+
session.context.lastRunOutput = result.output;
|
|
2859
|
+
session.context.lastFlowPath = result.flowPath;
|
|
2860
|
+
session.context.lastRunFailed = !result.success;
|
|
2861
|
+
session.context.appId = result.appId;
|
|
2862
|
+
session.meta = {
|
|
2863
|
+
...session.meta,
|
|
2864
|
+
...result.appId ? { appId: result.appId, app_id: result.appId } : {}
|
|
2865
|
+
};
|
|
2866
|
+
const appended = [];
|
|
2867
|
+
let eventClock = lastEventTime(session.transcript);
|
|
2868
|
+
for (let index = 0; index < result.executedActionCount; index += 1) {
|
|
2869
|
+
const action = normalizedActions[index];
|
|
2870
|
+
eventClock += 1;
|
|
2871
|
+
const actionEvent = {
|
|
2872
|
+
t: eventClock,
|
|
2873
|
+
type: "action",
|
|
2874
|
+
name: action.name,
|
|
2875
|
+
args: sanitizeMeta(action.args || {})
|
|
2876
|
+
};
|
|
2877
|
+
appended.push(actionEvent);
|
|
2878
|
+
const checkpoint = result.checkpoints.find((candidate) => candidate.actionIndex === index) || null;
|
|
2879
|
+
if (checkpoint) {
|
|
2880
|
+
const artifactEvent = await this.persistBinaryArtifact(session, {
|
|
2881
|
+
t: eventClock + 1,
|
|
2882
|
+
kind: "screenshot",
|
|
2883
|
+
path: checkpoint.relativePath,
|
|
2884
|
+
absPath: checkpoint.absPath,
|
|
2885
|
+
bytes: checkpoint.bytes,
|
|
2886
|
+
sha256: checkpoint.sha256,
|
|
2887
|
+
meta: {
|
|
2888
|
+
label: checkpoint.label,
|
|
2889
|
+
source: "applab-esvp-local"
|
|
2890
|
+
}
|
|
2891
|
+
});
|
|
2892
|
+
eventClock += 1;
|
|
2893
|
+
appended.push(artifactEvent);
|
|
2894
|
+
const checkpointEvent = {
|
|
2895
|
+
t: eventClock + 1,
|
|
2896
|
+
type: "checkpoint",
|
|
2897
|
+
label: checkpoint.label,
|
|
2898
|
+
screen_hash: checkpoint.sha256,
|
|
2899
|
+
ui_hash: checkpoint.sha256,
|
|
2900
|
+
state_hash: null
|
|
2901
|
+
};
|
|
2902
|
+
eventClock += 1;
|
|
2903
|
+
appended.push(checkpointEvent);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
appendSessionEvents(session, appended);
|
|
2907
|
+
if (!result.success) {
|
|
2908
|
+
const debugArtifact = await collectExecutorDebugArtifact({
|
|
2909
|
+
executor: session.executorName,
|
|
2910
|
+
context: session.context
|
|
2911
|
+
});
|
|
2912
|
+
const failureEvents = [];
|
|
2913
|
+
if (debugArtifact) {
|
|
2914
|
+
const artifact = await this.persistTextArtifact(session, {
|
|
2915
|
+
t: lastEventTime(session.transcript) + 1,
|
|
2916
|
+
kind: debugArtifact.kind,
|
|
2917
|
+
path: `logs/${String(Date.now())}-${debugArtifact.kind}.${debugArtifact.extension}`,
|
|
2918
|
+
content: debugArtifact.content,
|
|
2919
|
+
meta: {
|
|
2920
|
+
source: "applab-esvp-local"
|
|
2921
|
+
}
|
|
2922
|
+
});
|
|
2923
|
+
failureEvents.push(artifact);
|
|
2924
|
+
}
|
|
2925
|
+
session.status = "failed";
|
|
2926
|
+
session.error = result.error || "ESVP local device run failed";
|
|
2927
|
+
const finishEvent = {
|
|
2928
|
+
t: lastEventTime(session.transcript) + failureEvents.length + 1,
|
|
2929
|
+
type: "session_finished",
|
|
2930
|
+
status: "failed",
|
|
2931
|
+
error: session.error
|
|
2932
|
+
};
|
|
2933
|
+
appendSessionEvents(session, [...failureEvents, finishEvent]);
|
|
2934
|
+
await this.persistSessionState(session);
|
|
2935
|
+
return {
|
|
2936
|
+
session: this.publicSession(session),
|
|
2937
|
+
appended_events: [...appended, ...failureEvents, finishEvent],
|
|
2938
|
+
failed: true
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
if (options.finish === true) {
|
|
2942
|
+
const finishResult = await this.finishSessionInternal(session, options);
|
|
2943
|
+
return {
|
|
2944
|
+
session: this.publicSession(session),
|
|
2945
|
+
appended_events: [...appended, ...finishResult.events],
|
|
2946
|
+
failed: false
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
await this.persistSessionState(session);
|
|
2950
|
+
return {
|
|
2951
|
+
session: this.publicSession(session),
|
|
2952
|
+
appended_events: appended,
|
|
2953
|
+
failed: false
|
|
2954
|
+
};
|
|
2955
|
+
}
|
|
2956
|
+
async finishSession(sessionId, options = {}) {
|
|
2957
|
+
const session = this.requireSession(sessionId);
|
|
2958
|
+
if (session.status === "finished" || session.status === "failed") {
|
|
2959
|
+
return this.publicSession(session);
|
|
2960
|
+
}
|
|
2961
|
+
this.assertSessionMutable(session);
|
|
2962
|
+
await this.finishSessionInternal(session, options);
|
|
2963
|
+
return this.publicSession(session);
|
|
2964
|
+
}
|
|
2965
|
+
async captureCheckpoint(sessionId, label) {
|
|
2966
|
+
const session = this.requireMutableSession(sessionId);
|
|
2967
|
+
const checkpointLabel = normalizeCheckpointLabelInput(label) || `checkpoint:${session.checkpointCount + 1}`;
|
|
2968
|
+
if (session.executorName === "fake") {
|
|
2969
|
+
const hash = createHash3("sha256").update(`${session.id}:${checkpointLabel}:${session.checkpointCount + 1}`).digest("hex");
|
|
2970
|
+
const event = {
|
|
2971
|
+
t: lastEventTime(session.transcript) + 1,
|
|
2972
|
+
type: "checkpoint",
|
|
2973
|
+
label: checkpointLabel,
|
|
2974
|
+
screen_hash: hash,
|
|
2975
|
+
ui_hash: hash,
|
|
2976
|
+
state_hash: null
|
|
2977
|
+
};
|
|
2978
|
+
appendSessionEvents(session, [event]);
|
|
2979
|
+
await this.persistSessionState(session);
|
|
2980
|
+
return event;
|
|
2981
|
+
}
|
|
2982
|
+
const deviceId = session.context.deviceId || await resolveDefaultDeviceId(session.executorName);
|
|
2983
|
+
if (!deviceId) {
|
|
2984
|
+
throw new Error(`No connected device was found for executor ${session.executorName}.`);
|
|
2985
|
+
}
|
|
2986
|
+
session.context.deviceId = deviceId;
|
|
2987
|
+
const relativePath = `checkpoints/${String(session.checkpointCount + 1).padStart(3, "0")}-${slugify3(checkpointLabel)}.png`;
|
|
2988
|
+
const absPath = join4(session.runDir, relativePath);
|
|
2989
|
+
const capture = await captureDeviceCheckpoint({
|
|
2990
|
+
executor: session.executorName,
|
|
2991
|
+
deviceId,
|
|
2992
|
+
targetPath: absPath
|
|
2993
|
+
});
|
|
2994
|
+
if (!capture.success) {
|
|
2995
|
+
throw new Error(capture.error || "Failed to capture local ESVP checkpoint screenshot");
|
|
2996
|
+
}
|
|
2997
|
+
const contents = await readFile2(absPath);
|
|
2998
|
+
const sha256 = createHash3("sha256").update(contents).digest("hex");
|
|
2999
|
+
const t = lastEventTime(session.transcript) + 1;
|
|
3000
|
+
const artifactEvent = await this.persistBinaryArtifact(session, {
|
|
3001
|
+
t,
|
|
3002
|
+
kind: "screenshot",
|
|
3003
|
+
path: relativePath,
|
|
3004
|
+
absPath,
|
|
3005
|
+
bytes: contents.length,
|
|
3006
|
+
sha256,
|
|
3007
|
+
meta: {
|
|
3008
|
+
label: checkpointLabel,
|
|
3009
|
+
source: "applab-esvp-local"
|
|
3010
|
+
}
|
|
3011
|
+
});
|
|
3012
|
+
const checkpointEvent = {
|
|
3013
|
+
t: t + 1,
|
|
3014
|
+
type: "checkpoint",
|
|
3015
|
+
label: checkpointLabel,
|
|
3016
|
+
screen_hash: sha256,
|
|
3017
|
+
ui_hash: sha256,
|
|
3018
|
+
state_hash: null
|
|
3019
|
+
};
|
|
3020
|
+
appendSessionEvents(session, [artifactEvent, checkpointEvent]);
|
|
3021
|
+
await this.persistSessionState(session);
|
|
3022
|
+
return checkpointEvent;
|
|
3023
|
+
}
|
|
3024
|
+
getSessionNetwork(sessionId) {
|
|
3025
|
+
const session = this.requireSession(sessionId);
|
|
3026
|
+
return {
|
|
3027
|
+
session: this.publicSession(session),
|
|
3028
|
+
network: this.buildPublicNetwork(session)
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
async configureSessionNetwork(sessionId, input = {}) {
|
|
3032
|
+
const session = this.requireMutableSession(sessionId);
|
|
3033
|
+
const profile = normalizeNetworkProfileInput(input);
|
|
3034
|
+
const previousProxyArtifacts = await this.releaseManagedProxyArtifacts(session, {
|
|
3035
|
+
reason: "reconfigure"
|
|
3036
|
+
});
|
|
3037
|
+
const managedProxyResult = await this.prepareManagedProxy(session, profile);
|
|
3038
|
+
const effectiveProfile = managedProxyResult?.effectiveProfile || profile;
|
|
3039
|
+
const result = await configureDeviceNetwork(session.executorName, session.context, effectiveProfile);
|
|
3040
|
+
const actionEvent = {
|
|
3041
|
+
t: lastEventTime(session.transcript) + 1,
|
|
3042
|
+
type: "action",
|
|
3043
|
+
name: "network.configure",
|
|
3044
|
+
args: profile
|
|
3045
|
+
};
|
|
3046
|
+
const artifactEvent = await this.persistJsonArtifact(session, {
|
|
3047
|
+
t: actionEvent.t + 1 + previousProxyArtifacts.length,
|
|
3048
|
+
kind: "network_profile",
|
|
3049
|
+
path: buildNetworkArtifactPath(session, "network_profile", {
|
|
3050
|
+
label: profile.label || "network-profile",
|
|
3051
|
+
format: "json"
|
|
3052
|
+
}),
|
|
3053
|
+
payload: {
|
|
3054
|
+
profile,
|
|
3055
|
+
effective_profile: effectiveProfile,
|
|
3056
|
+
managed_proxy: managedProxyResult?.proxy || null,
|
|
3057
|
+
result
|
|
3058
|
+
},
|
|
3059
|
+
meta: {
|
|
3060
|
+
label: profile.label || "network-profile",
|
|
3061
|
+
profile_name: profile.profile || null
|
|
3062
|
+
}
|
|
3063
|
+
});
|
|
3064
|
+
appendSessionEvents(session, [actionEvent, ...previousProxyArtifacts, artifactEvent]);
|
|
3065
|
+
session.network = {
|
|
3066
|
+
...session.network,
|
|
3067
|
+
supported: true,
|
|
3068
|
+
capabilities: mergeNetworkCapabilities(result.capabilities, managedProxyResult?.capabilities, session.network?.capabilities),
|
|
3069
|
+
active_profile: profile,
|
|
3070
|
+
effective_profile: effectiveProfile,
|
|
3071
|
+
managed_proxy: managedProxyResult?.proxy || null,
|
|
3072
|
+
configured_at: nowIso2(),
|
|
3073
|
+
last_result: sanitizeNetworkResult({
|
|
3074
|
+
...result,
|
|
3075
|
+
managed_proxy: managedProxyResult?.proxy || null
|
|
3076
|
+
}),
|
|
3077
|
+
last_error: result?.error || null
|
|
3078
|
+
};
|
|
3079
|
+
await this.persistSessionState(session);
|
|
3080
|
+
return {
|
|
3081
|
+
session: this.publicSession(session),
|
|
3082
|
+
network: this.buildPublicNetwork(session),
|
|
3083
|
+
applied: result,
|
|
3084
|
+
profile_artifact: summarizeArtifactEvent(artifactEvent)
|
|
3085
|
+
};
|
|
3086
|
+
}
|
|
3087
|
+
async clearSessionNetwork(sessionId, input = {}) {
|
|
3088
|
+
const session = this.requireMutableSession(sessionId);
|
|
3089
|
+
const result = await clearDeviceNetwork(session.executorName, session.context);
|
|
3090
|
+
const proxyArtifacts = await this.releaseManagedProxyArtifacts(session, {
|
|
3091
|
+
reason: "network-clear"
|
|
3092
|
+
});
|
|
3093
|
+
const actionEvent = {
|
|
3094
|
+
t: lastEventTime(session.transcript) + 1,
|
|
3095
|
+
type: "action",
|
|
3096
|
+
name: "network.clear",
|
|
3097
|
+
args: sanitizeMeta(input || {})
|
|
3098
|
+
};
|
|
3099
|
+
const artifactEvent = await this.persistJsonArtifact(session, {
|
|
3100
|
+
t: actionEvent.t + 1 + proxyArtifacts.length,
|
|
3101
|
+
kind: "network_profile",
|
|
3102
|
+
path: buildNetworkArtifactPath(session, "network_profile", {
|
|
3103
|
+
label: "network-clear",
|
|
3104
|
+
format: "json"
|
|
3105
|
+
}),
|
|
3106
|
+
payload: {
|
|
3107
|
+
cleared: true,
|
|
3108
|
+
result
|
|
3109
|
+
},
|
|
3110
|
+
meta: {
|
|
3111
|
+
cleared: true
|
|
3112
|
+
}
|
|
3113
|
+
});
|
|
3114
|
+
appendSessionEvents(session, [actionEvent, ...proxyArtifacts, artifactEvent]);
|
|
3115
|
+
session.network = {
|
|
3116
|
+
...session.network,
|
|
3117
|
+
active_profile: null,
|
|
3118
|
+
effective_profile: null,
|
|
3119
|
+
managed_proxy: null,
|
|
3120
|
+
cleared_at: nowIso2(),
|
|
3121
|
+
last_result: sanitizeNetworkResult(result),
|
|
3122
|
+
last_error: null
|
|
3123
|
+
};
|
|
3124
|
+
await this.persistSessionState(session);
|
|
3125
|
+
return {
|
|
3126
|
+
session: this.publicSession(session),
|
|
3127
|
+
network: this.buildPublicNetwork(session),
|
|
3128
|
+
cleared: result,
|
|
3129
|
+
profile_artifact: summarizeArtifactEvent(artifactEvent)
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
async attachSessionNetworkTrace(sessionId, input = {}) {
|
|
3133
|
+
const session = this.requireSession(sessionId);
|
|
3134
|
+
const trace = normalizeNetworkTraceInput(input);
|
|
3135
|
+
const artifactEvent = await this.persistArtifactPayload(session, {
|
|
3136
|
+
t: hasFinished(session.transcript) ? lastEventTimeBeforeFinish(session.transcript) : lastEventTime(session.transcript) + 1,
|
|
3137
|
+
kind: "network_trace",
|
|
3138
|
+
path: buildNetworkArtifactPath(session, "network_trace", {
|
|
3139
|
+
label: trace.label || trace.trace_kind,
|
|
3140
|
+
format: trace.format,
|
|
3141
|
+
traceKind: trace.trace_kind
|
|
3142
|
+
}),
|
|
3143
|
+
payload: trace.payload,
|
|
3144
|
+
format: trace.format,
|
|
3145
|
+
meta: {
|
|
3146
|
+
...trace.label ? { label: trace.label } : {},
|
|
3147
|
+
trace_kind: trace.trace_kind,
|
|
3148
|
+
source: trace.source,
|
|
3149
|
+
request_id: trace.request_id,
|
|
3150
|
+
url: trace.url,
|
|
3151
|
+
method: trace.method,
|
|
3152
|
+
status_code: trace.status_code
|
|
3153
|
+
}
|
|
3154
|
+
});
|
|
3155
|
+
if (hasFinished(session.transcript)) {
|
|
3156
|
+
insertEventsBeforeSessionFinished(session.transcript, [artifactEvent]);
|
|
3157
|
+
recountEvents(session, [artifactEvent]);
|
|
3158
|
+
session.updatedAt = nowIso2();
|
|
3159
|
+
} else {
|
|
3160
|
+
appendSessionEvents(session, [artifactEvent]);
|
|
3161
|
+
}
|
|
3162
|
+
const traceKinds = new Set(session.network?.trace_kinds || []);
|
|
3163
|
+
traceKinds.add(trace.trace_kind);
|
|
3164
|
+
session.network = {
|
|
3165
|
+
...session.network,
|
|
3166
|
+
trace_count: Number(session.network?.trace_count || 0) + 1,
|
|
3167
|
+
trace_kinds: [...traceKinds]
|
|
3168
|
+
};
|
|
3169
|
+
await this.persistSessionState(session);
|
|
3170
|
+
return {
|
|
3171
|
+
session: this.publicSession(session),
|
|
3172
|
+
network: this.buildPublicNetwork(session),
|
|
3173
|
+
attached_trace: summarizeArtifactEvent(artifactEvent)
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
validateSessionReplay(sessionId) {
|
|
3177
|
+
const session = this.requireSession(sessionId);
|
|
3178
|
+
if (!hasFinished(session.transcript)) {
|
|
3179
|
+
return {
|
|
3180
|
+
ok: false,
|
|
3181
|
+
supported: false,
|
|
3182
|
+
reason: "session must be finished before replay validation",
|
|
3183
|
+
session: this.publicSession(session)
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
const extracted = extractReplayActionsFromTranscript(session.transcript);
|
|
3187
|
+
if (!extracted.actions.length) {
|
|
3188
|
+
return {
|
|
3189
|
+
ok: false,
|
|
3190
|
+
supported: false,
|
|
3191
|
+
reason: "transcript does not contain replayable actions",
|
|
3192
|
+
session: this.publicSession(session)
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
return {
|
|
3196
|
+
ok: true,
|
|
3197
|
+
supported: true,
|
|
3198
|
+
reason: null,
|
|
3199
|
+
session: this.publicSession(session),
|
|
3200
|
+
extraction: extracted
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
3203
|
+
analyzeReplayConsistency(sessionId) {
|
|
3204
|
+
const session = this.requireSession(sessionId);
|
|
3205
|
+
const transcript = session.transcript || [];
|
|
3206
|
+
const actions = transcript.filter((event) => event.type === "action");
|
|
3207
|
+
const checkpoints = transcript.filter((event) => event.type === "checkpoint");
|
|
3208
|
+
const artifacts = transcript.filter((event) => event.type === "artifact");
|
|
3209
|
+
const failure = transcript.find((event) => event.type === "session_finished" && event.status === "failed") || null;
|
|
3210
|
+
const tapActions = actions.filter((action) => action.name === "tap");
|
|
3211
|
+
const selectorTapCount = tapActions.filter((action) => action.args && typeof action.args.selector === "string").length;
|
|
3212
|
+
const coordinateTapCount = tapActions.filter((action) => action.args && typeof action.args.x === "number" && typeof action.args.y === "number").length;
|
|
3213
|
+
const waitCount = actions.filter((action) => action.name === "wait").length;
|
|
3214
|
+
const waitForCount = actions.filter((action) => action.name === "waitFor").length;
|
|
3215
|
+
const artifactsByKind = artifacts.reduce((acc, event) => {
|
|
3216
|
+
acc[event.kind] = (acc[event.kind] || 0) + 1;
|
|
3217
|
+
return acc;
|
|
3218
|
+
}, {});
|
|
3219
|
+
const recommendations = [];
|
|
3220
|
+
let score = 100;
|
|
3221
|
+
if (actions.length === 0) {
|
|
3222
|
+
score -= 40;
|
|
3223
|
+
recommendations.push("record actions in the transcript to strengthen replay quality");
|
|
3224
|
+
}
|
|
3225
|
+
if (checkpoints.length === 0) {
|
|
3226
|
+
score -= 25;
|
|
3227
|
+
recommendations.push("add regular checkpoints for replay comparison");
|
|
3228
|
+
}
|
|
3229
|
+
if (coordinateTapCount > 0) {
|
|
3230
|
+
score -= Math.min(20, coordinateTapCount * 4);
|
|
3231
|
+
recommendations.push("prefer selectors instead of fixed coordinates");
|
|
3232
|
+
}
|
|
3233
|
+
if (waitCount > waitForCount && waitCount >= 2) {
|
|
3234
|
+
score -= Math.min(15, waitCount * 2);
|
|
3235
|
+
recommendations.push("replace fixed waits with waitFor(selector) style waits when possible");
|
|
3236
|
+
}
|
|
3237
|
+
if (!artifactsByKind.screenshot) {
|
|
3238
|
+
score -= 10;
|
|
3239
|
+
recommendations.push("capture screenshots at critical checkpoints");
|
|
3240
|
+
}
|
|
3241
|
+
if (failure && session.executorName === "adb" && !artifactsByKind.logcat) {
|
|
3242
|
+
score -= 15;
|
|
3243
|
+
recommendations.push("capture logcat on Android failures");
|
|
3244
|
+
}
|
|
3245
|
+
if (failure && (session.executorName === "ios-sim" || session.executorName === "maestro-ios") && !artifactsByKind.debug_asset) {
|
|
3246
|
+
score -= 10;
|
|
3247
|
+
recommendations.push("persist Maestro debug output on iOS failures");
|
|
3248
|
+
}
|
|
3249
|
+
score = Math.max(0, Math.min(100, score));
|
|
3250
|
+
const verdict = score >= 85 ? "strong" : score >= 65 ? "moderate" : "weak";
|
|
3251
|
+
return {
|
|
3252
|
+
session: this.publicSession(session),
|
|
3253
|
+
replay_consistency: {
|
|
3254
|
+
version: 1,
|
|
3255
|
+
objective: "replay_consistency",
|
|
3256
|
+
score,
|
|
3257
|
+
verdict,
|
|
3258
|
+
deterministic_validation: this.validateSessionReplay(sessionId),
|
|
3259
|
+
metrics: {
|
|
3260
|
+
action_count: actions.length,
|
|
3261
|
+
checkpoint_count: checkpoints.length,
|
|
3262
|
+
artifact_count: artifacts.length,
|
|
3263
|
+
tap_selector_count: selectorTapCount,
|
|
3264
|
+
tap_coordinate_count: coordinateTapCount,
|
|
3265
|
+
wait_count: waitCount,
|
|
3266
|
+
wait_for_count: waitForCount,
|
|
3267
|
+
artifacts_by_kind: artifactsByKind
|
|
3268
|
+
},
|
|
3269
|
+
recommendations: [...new Set(recommendations)]
|
|
3270
|
+
}
|
|
3271
|
+
};
|
|
3272
|
+
}
|
|
3273
|
+
async replaySessionToNewSession(sessionId, options = {}) {
|
|
3274
|
+
const original = this.requireSession(sessionId);
|
|
3275
|
+
if (!hasFinished(original.transcript)) {
|
|
3276
|
+
throw new Error("finish the original session before replaying it");
|
|
3277
|
+
}
|
|
3278
|
+
const extracted = extractReplayActionsFromTranscript(original.transcript);
|
|
3279
|
+
if (!extracted.actions.length) {
|
|
3280
|
+
throw new Error("transcript does not contain replayable actions");
|
|
3281
|
+
}
|
|
3282
|
+
const replayExecutor = normalizeExecutor(options.executor || original.executorName || "fake");
|
|
3283
|
+
const replaySession = await this.createSession({
|
|
3284
|
+
executor: replayExecutor,
|
|
3285
|
+
deviceId: options.deviceId || original.context.deviceId || void 0,
|
|
3286
|
+
meta: {
|
|
3287
|
+
source: "replay-run",
|
|
3288
|
+
replay_of: original.id,
|
|
3289
|
+
...resolveMetaAppId(original.meta) ? { appId: resolveMetaAppId(original.meta), app_id: resolveMetaAppId(original.meta) } : {},
|
|
3290
|
+
...options.meta ? sanitizeMeta(options.meta) : {}
|
|
3291
|
+
}
|
|
3292
|
+
});
|
|
3293
|
+
const replaySessionId = String(replaySession.id || "");
|
|
3294
|
+
if (!replaySessionId) {
|
|
3295
|
+
throw new Error("Failed to create replay session");
|
|
3296
|
+
}
|
|
3297
|
+
if (original.context.preflightConfig) {
|
|
3298
|
+
await this.runPreflight(replaySessionId, original.context.preflightConfig).catch(() => void 0);
|
|
3299
|
+
}
|
|
3300
|
+
const run = await this.runActions(replaySessionId, extracted.actions, {
|
|
3301
|
+
finish: true,
|
|
3302
|
+
captureLogcat: options.captureLogcat !== false,
|
|
3303
|
+
checkpointAfterEach: false
|
|
3304
|
+
});
|
|
3305
|
+
const replayTranscriptPayload = this.getTranscript(replaySessionId);
|
|
3306
|
+
const comparison = compareCheckpointSequences({
|
|
3307
|
+
expectedTranscript: original.transcript,
|
|
3308
|
+
actualTranscript: replayTranscriptPayload.events,
|
|
3309
|
+
strategy: comparisonStrategyForExecutors(original.executorName, replayExecutor)
|
|
3310
|
+
});
|
|
3311
|
+
return {
|
|
3312
|
+
original_session: this.publicSession(original),
|
|
3313
|
+
replay_session: this.getSession(replaySessionId),
|
|
3314
|
+
extraction: extracted,
|
|
3315
|
+
run_summary: {
|
|
3316
|
+
failed: run.failed,
|
|
3317
|
+
appended_events: Array.isArray(run.appended_events) ? run.appended_events.length : 0,
|
|
3318
|
+
session: run.session
|
|
3319
|
+
},
|
|
3320
|
+
checkpoint_comparison: comparison
|
|
3321
|
+
};
|
|
3322
|
+
}
|
|
3323
|
+
async cleanupTransientSystemState() {
|
|
3324
|
+
for (const session of this.sessions.values()) {
|
|
3325
|
+
if (session.status !== "running") continue;
|
|
3326
|
+
await clearDeviceNetwork(session.executorName, session.context).catch(() => void 0);
|
|
3327
|
+
await this.managedProxyManager.releaseSessionProxy(session.id, { reason: "process-exit" }).catch(() => void 0);
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
async runFakeActions(session, actions, options) {
|
|
3331
|
+
const appended = [];
|
|
3332
|
+
for (const action of actions) {
|
|
3333
|
+
const actionEvent = {
|
|
3334
|
+
t: lastEventTime(session.transcript) + appended.length + 1,
|
|
3335
|
+
type: "action",
|
|
3336
|
+
name: action.name,
|
|
3337
|
+
args: sanitizeMeta(action.args || {})
|
|
3338
|
+
};
|
|
3339
|
+
appended.push(actionEvent);
|
|
3340
|
+
if (action.checkpointAfter) {
|
|
3341
|
+
const hash = createHash3("sha256").update(`${session.id}:${action.name}:${JSON.stringify(action.args || {})}:${action.checkpointLabel || ""}`).digest("hex");
|
|
3342
|
+
appended.push({
|
|
3343
|
+
t: actionEvent.t + 1,
|
|
3344
|
+
type: "checkpoint",
|
|
3345
|
+
label: action.checkpointLabel || null,
|
|
3346
|
+
screen_hash: hash,
|
|
3347
|
+
ui_hash: hash,
|
|
3348
|
+
state_hash: hash
|
|
3349
|
+
});
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
appendSessionEvents(session, appended);
|
|
3353
|
+
if (options.finish === true) {
|
|
3354
|
+
const finishResult = await this.finishSessionInternal(session, {});
|
|
3355
|
+
return {
|
|
3356
|
+
session: this.publicSession(session),
|
|
3357
|
+
appended_events: [...appended, ...finishResult.events],
|
|
3358
|
+
failed: false
|
|
3359
|
+
};
|
|
3360
|
+
}
|
|
3361
|
+
await this.persistSessionState(session);
|
|
3362
|
+
return {
|
|
3363
|
+
session: this.publicSession(session),
|
|
3364
|
+
appended_events: appended,
|
|
3365
|
+
failed: false
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
buildPublicNetwork(session) {
|
|
3369
|
+
return {
|
|
3370
|
+
...session.network || {},
|
|
3371
|
+
capabilities: session.network?.capabilities || networkCapabilitiesForExecutor(session.executorName)
|
|
3372
|
+
};
|
|
3373
|
+
}
|
|
3374
|
+
publicSession(session) {
|
|
3375
|
+
const lastEvent = session.transcript[session.transcript.length - 1];
|
|
3376
|
+
return {
|
|
3377
|
+
id: session.id,
|
|
3378
|
+
executor: session.executorName,
|
|
3379
|
+
status: session.status,
|
|
3380
|
+
error: session.error,
|
|
3381
|
+
recovered: session.recovered === true,
|
|
3382
|
+
mutable: session.status === "running",
|
|
3383
|
+
created_at: session.createdAt,
|
|
3384
|
+
updated_at: session.updatedAt,
|
|
3385
|
+
target: session.context?.target || null,
|
|
3386
|
+
device_id: session.context?.deviceId || null,
|
|
3387
|
+
meta: session.meta || {},
|
|
3388
|
+
run_dir: session.runDir,
|
|
3389
|
+
transcript_path: session.transcriptPath,
|
|
3390
|
+
transcript_path_relative: session.transcriptPathRelative,
|
|
3391
|
+
manifest_path: session.manifestPath,
|
|
3392
|
+
manifest_path_relative: session.manifestPathRelative,
|
|
3393
|
+
action_count: session.actionCount,
|
|
3394
|
+
checkpoint_count: session.checkpointCount,
|
|
3395
|
+
artifact_count: session.artifactCount,
|
|
3396
|
+
event_count: session.transcript.length,
|
|
3397
|
+
last_event_type: lastEvent?.type || null,
|
|
3398
|
+
last_event_t: lastEvent?.t ?? 0,
|
|
3399
|
+
network: this.buildPublicNetwork(session)
|
|
3400
|
+
};
|
|
3401
|
+
}
|
|
3402
|
+
requireSession(sessionId) {
|
|
3403
|
+
const session = this.sessions.get(sessionId);
|
|
3404
|
+
if (!session) {
|
|
3405
|
+
throw new Error(`session not found: ${sessionId}`);
|
|
3406
|
+
}
|
|
3407
|
+
return session;
|
|
3408
|
+
}
|
|
3409
|
+
requireMutableSession(sessionId) {
|
|
3410
|
+
const session = this.requireSession(sessionId);
|
|
3411
|
+
this.assertSessionMutable(session);
|
|
3412
|
+
return session;
|
|
3413
|
+
}
|
|
3414
|
+
assertSessionMutable(session) {
|
|
3415
|
+
if (session.status !== "running") {
|
|
3416
|
+
throw new Error(`session ${session.id} is not running (status=${session.status})`);
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
async finishSessionInternal(session, options = {}) {
|
|
3420
|
+
const finishEvents = [];
|
|
3421
|
+
const proxyArtifacts = await this.releaseManagedProxyArtifacts(session, {
|
|
3422
|
+
reason: "session-finish"
|
|
3423
|
+
});
|
|
3424
|
+
if (proxyArtifacts.length) {
|
|
3425
|
+
appendSessionEvents(session, proxyArtifacts);
|
|
3426
|
+
finishEvents.push(...proxyArtifacts);
|
|
3427
|
+
}
|
|
3428
|
+
if (options.captureLogcat !== false) {
|
|
3429
|
+
const debugArtifact = await collectExecutorDebugArtifact({
|
|
3430
|
+
executor: session.executorName,
|
|
3431
|
+
context: session.context
|
|
3432
|
+
});
|
|
3433
|
+
if (debugArtifact) {
|
|
3434
|
+
const artifact = await this.persistTextArtifact(session, {
|
|
3435
|
+
t: lastEventTime(session.transcript) + 1,
|
|
3436
|
+
kind: debugArtifact.kind,
|
|
3437
|
+
path: `logs/${String(Date.now())}-${debugArtifact.kind}.${debugArtifact.extension}`,
|
|
3438
|
+
content: debugArtifact.content,
|
|
3439
|
+
meta: {
|
|
3440
|
+
source: "applab-esvp-local"
|
|
3441
|
+
}
|
|
3442
|
+
});
|
|
3443
|
+
appendSessionEvents(session, [artifact]);
|
|
3444
|
+
finishEvents.push(artifact);
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
session.status = "finished";
|
|
3448
|
+
session.error = null;
|
|
3449
|
+
session.network = {
|
|
3450
|
+
...session.network,
|
|
3451
|
+
managed_proxy: null
|
|
3452
|
+
};
|
|
3453
|
+
const sessionFinishedEvent = {
|
|
3454
|
+
t: lastEventTime(session.transcript) + 1,
|
|
3455
|
+
type: "session_finished",
|
|
3456
|
+
status: "ok"
|
|
3457
|
+
};
|
|
3458
|
+
appendSessionEvents(session, [sessionFinishedEvent]);
|
|
3459
|
+
finishEvents.push(sessionFinishedEvent);
|
|
3460
|
+
await this.persistSessionState(session);
|
|
3461
|
+
return {
|
|
3462
|
+
session: this.publicSession(session),
|
|
3463
|
+
events: finishEvents
|
|
3464
|
+
};
|
|
3465
|
+
}
|
|
3466
|
+
async persistSessionState(session) {
|
|
3467
|
+
await writeTranscriptFile(session.transcriptPath, session.transcript);
|
|
3468
|
+
const manifest = {
|
|
3469
|
+
session: this.publicSession(session),
|
|
3470
|
+
meta: session.meta || {},
|
|
3471
|
+
persisted_at: nowIso2(),
|
|
3472
|
+
context: {
|
|
3473
|
+
deviceId: session.context.deviceId || null,
|
|
3474
|
+
appId: session.context.appId || null,
|
|
3475
|
+
preflightConfig: session.context.preflightConfig || null
|
|
3476
|
+
}
|
|
3477
|
+
};
|
|
3478
|
+
await writeFile2(session.manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
3479
|
+
`, "utf8");
|
|
3480
|
+
}
|
|
3481
|
+
async prepareManagedProxy(session, profile) {
|
|
3482
|
+
if (!this.managedProxyManager.shouldManageProfile(profile)) {
|
|
3483
|
+
return null;
|
|
3484
|
+
}
|
|
3485
|
+
return this.managedProxyManager.configureSessionProxy({
|
|
3486
|
+
sessionId: session.id,
|
|
3487
|
+
session,
|
|
3488
|
+
profile
|
|
3489
|
+
});
|
|
3490
|
+
}
|
|
3491
|
+
async releaseManagedProxyArtifacts(session, options = {}) {
|
|
3492
|
+
const release = await this.managedProxyManager.releaseSessionProxy(session.id, options);
|
|
3493
|
+
if (!release.managed || !Array.isArray(release.traces) || release.traces.length === 0) {
|
|
3494
|
+
return [];
|
|
3495
|
+
}
|
|
3496
|
+
const events = [];
|
|
3497
|
+
for (const trace of release.traces) {
|
|
3498
|
+
const artifactEvent = await this.persistArtifactPayload(session, {
|
|
3499
|
+
t: hasFinished(session.transcript) ? lastEventTimeBeforeFinish(session.transcript) : lastEventTime(session.transcript) + 1 + events.length,
|
|
3500
|
+
kind: "network_trace",
|
|
3501
|
+
path: buildNetworkArtifactPath(session, "network_trace", {
|
|
3502
|
+
label: trace.label || trace.trace_kind,
|
|
3503
|
+
format: trace.format,
|
|
3504
|
+
traceKind: trace.trace_kind
|
|
3505
|
+
}),
|
|
3506
|
+
payload: trace.payload,
|
|
3507
|
+
format: trace.format,
|
|
3508
|
+
meta: {
|
|
3509
|
+
...trace.artifactMeta || {},
|
|
3510
|
+
source: trace.source || "esvp-managed-proxy",
|
|
3511
|
+
trace_kind: trace.trace_kind || "http_trace"
|
|
3512
|
+
}
|
|
3513
|
+
});
|
|
3514
|
+
events.push(artifactEvent);
|
|
3515
|
+
}
|
|
3516
|
+
if (events.length) {
|
|
3517
|
+
const traceKinds = new Set(session.network?.trace_kinds || []);
|
|
3518
|
+
for (const trace of release.traces) {
|
|
3519
|
+
if (trace.trace_kind) traceKinds.add(String(trace.trace_kind));
|
|
3520
|
+
}
|
|
3521
|
+
session.network = {
|
|
3522
|
+
...session.network,
|
|
3523
|
+
trace_count: Number(session.network?.trace_count || 0) + events.length,
|
|
3524
|
+
trace_kinds: [...traceKinds],
|
|
3525
|
+
managed_proxy: null
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
return events;
|
|
3529
|
+
}
|
|
3530
|
+
async loadPersistedSessions() {
|
|
3531
|
+
const entries = await readdir(this.rootDir, { withFileTypes: true }).catch(() => []);
|
|
3532
|
+
for (const entry of entries) {
|
|
3533
|
+
if (!entry.isDirectory()) continue;
|
|
3534
|
+
const runDir = resolve(this.rootDir, entry.name);
|
|
3535
|
+
const transcriptPath = resolve(runDir, "transcript.jsonl");
|
|
3536
|
+
const transcriptInfo = await stat(transcriptPath).catch(() => null);
|
|
3537
|
+
if (!transcriptInfo?.isFile()) continue;
|
|
3538
|
+
try {
|
|
3539
|
+
const transcript = await readTranscriptFile(transcriptPath);
|
|
3540
|
+
const manifest = await readJsonFile(resolve(runDir, "session.json"));
|
|
3541
|
+
const session = this.recoverSessionFromDisk({
|
|
3542
|
+
runDir,
|
|
3543
|
+
transcriptPath,
|
|
3544
|
+
transcript,
|
|
3545
|
+
manifest,
|
|
3546
|
+
transcriptInfo
|
|
3547
|
+
});
|
|
3548
|
+
this.sessions.set(session.id, session);
|
|
3549
|
+
} catch {
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
recoverSessionFromDisk(input) {
|
|
3554
|
+
const firstEvent = input.transcript.find((event) => event.type === "session_started") || {};
|
|
3555
|
+
const finishEvent = [...input.transcript].reverse().find((event) => event.type === "session_finished") || null;
|
|
3556
|
+
const manifestSession = input.manifest?.session || {};
|
|
3557
|
+
const executorName = normalizeExecutor(
|
|
3558
|
+
manifestSession.executor || firstEvent?.meta?.executor || firstEvent?.meta?.executor_name || "fake"
|
|
3559
|
+
);
|
|
3560
|
+
const id = String(manifestSession.id || firstEvent.session_id || basename(input.runDir));
|
|
3561
|
+
const target = manifestSession.target || firstEvent.target || targetForExecutor(executorName) || null;
|
|
3562
|
+
const deviceId = manifestSession.device_id || firstEvent?.meta?.deviceId || firstEvent?.meta?.device_id || null;
|
|
3563
|
+
const meta = sanitizeMeta(manifestSession.meta || input.manifest?.meta || firstEvent?.meta || {});
|
|
3564
|
+
const status = finishEvent ? finishEvent.status === "failed" ? "failed" : "finished" : "running";
|
|
3565
|
+
const error = status === "failed" ? finishEvent?.error || manifestSession.error || "session failed" : null;
|
|
3566
|
+
return {
|
|
3567
|
+
id,
|
|
3568
|
+
executorName,
|
|
3569
|
+
context: {
|
|
3570
|
+
deviceId: deviceId || null,
|
|
3571
|
+
target,
|
|
3572
|
+
runDir: input.runDir,
|
|
3573
|
+
appId: resolveMetaAppId(meta),
|
|
3574
|
+
preflightConfig: input.manifest?.context?.preflightConfig || null,
|
|
3575
|
+
networkProfile: null,
|
|
3576
|
+
lastRunOutput: null,
|
|
3577
|
+
lastFlowPath: null,
|
|
3578
|
+
lastRunFailed: status === "failed"
|
|
3579
|
+
},
|
|
3580
|
+
createdAt: manifestSession.created_at || input.transcriptInfo.birthtime?.toISOString?.() || input.transcriptInfo.mtime?.toISOString?.() || nowIso2(),
|
|
3581
|
+
updatedAt: manifestSession.updated_at || input.transcriptInfo.mtime?.toISOString?.() || nowIso2(),
|
|
3582
|
+
status,
|
|
3583
|
+
error,
|
|
3584
|
+
recovered: true,
|
|
3585
|
+
meta,
|
|
3586
|
+
runDir: input.runDir,
|
|
3587
|
+
transcriptPath: input.transcriptPath,
|
|
3588
|
+
transcriptPathRelative: relativeRunsPath(this.rootDir, input.runDir, "transcript.jsonl"),
|
|
3589
|
+
manifestPath: resolve(input.runDir, "session.json"),
|
|
3590
|
+
manifestPathRelative: relativeRunsPath(this.rootDir, input.runDir, "session.json"),
|
|
3591
|
+
transcript: input.transcript,
|
|
3592
|
+
actionCount: input.transcript.filter((event) => event.type === "action").length,
|
|
3593
|
+
checkpointCount: input.transcript.filter((event) => event.type === "checkpoint").length,
|
|
3594
|
+
artifactCount: input.transcript.filter((event) => event.type === "artifact").length,
|
|
3595
|
+
network: recoverNetworkState({
|
|
3596
|
+
transcript: input.transcript,
|
|
3597
|
+
manifestNetwork: manifestSession.network,
|
|
3598
|
+
capabilities: networkCapabilitiesForExecutor(executorName)
|
|
3599
|
+
})
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
resolveSessionArtifactPath(sessionId, artifactPath) {
|
|
3603
|
+
const session = this.requireSession(sessionId);
|
|
3604
|
+
const normalized = normalizeArtifactRelativePath(artifactPath);
|
|
3605
|
+
const absPath = resolve(session.runDir, normalized);
|
|
3606
|
+
const root = session.runDir.endsWith(sep) ? session.runDir : `${session.runDir}${sep}`;
|
|
3607
|
+
if (!absPath.startsWith(root)) {
|
|
3608
|
+
throw new Error("artifact path is outside the session run_dir");
|
|
3609
|
+
}
|
|
3610
|
+
return {
|
|
3611
|
+
sessionId: session.id,
|
|
3612
|
+
runDir: session.runDir,
|
|
3613
|
+
path: normalized,
|
|
3614
|
+
absPath
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
async persistBinaryArtifact(session, input) {
|
|
3618
|
+
return {
|
|
3619
|
+
t: input.t,
|
|
3620
|
+
type: "artifact",
|
|
3621
|
+
kind: input.kind,
|
|
3622
|
+
path: input.path,
|
|
3623
|
+
sha256: input.sha256,
|
|
3624
|
+
bytes: input.bytes,
|
|
3625
|
+
meta: input.meta || null
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3628
|
+
async persistTextArtifact(session, input) {
|
|
3629
|
+
const absPath = resolve(session.runDir, input.path);
|
|
3630
|
+
await mkdir2(dirnameSafe(absPath), { recursive: true });
|
|
3631
|
+
const payload = Buffer.from(String(input.content), "utf8");
|
|
3632
|
+
await writeFile2(absPath, payload);
|
|
3633
|
+
const sha256 = createHash3("sha256").update(payload).digest("hex");
|
|
3634
|
+
return {
|
|
3635
|
+
t: input.t,
|
|
3636
|
+
type: "artifact",
|
|
3637
|
+
kind: input.kind,
|
|
3638
|
+
path: input.path,
|
|
3639
|
+
sha256,
|
|
3640
|
+
bytes: payload.length,
|
|
3641
|
+
meta: input.meta || null
|
|
3642
|
+
};
|
|
3643
|
+
}
|
|
3644
|
+
async persistJsonArtifact(session, input) {
|
|
3645
|
+
return this.persistArtifactPayload(session, {
|
|
3646
|
+
...input,
|
|
3647
|
+
format: "json"
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
async persistArtifactPayload(session, input) {
|
|
3651
|
+
const absPath = resolve(session.runDir, input.path);
|
|
3652
|
+
await mkdir2(dirnameSafe(absPath), { recursive: true });
|
|
3653
|
+
const content = serializeArtifactPayload(input.payload, input.format);
|
|
3654
|
+
await writeFile2(absPath, content);
|
|
3655
|
+
const sha256 = createHash3("sha256").update(content).digest("hex");
|
|
3656
|
+
return {
|
|
3657
|
+
t: input.t,
|
|
3658
|
+
type: "artifact",
|
|
3659
|
+
kind: input.kind,
|
|
3660
|
+
path: input.path,
|
|
3661
|
+
sha256,
|
|
3662
|
+
bytes: content.length,
|
|
3663
|
+
meta: input.meta || null
|
|
3664
|
+
};
|
|
3665
|
+
}
|
|
3666
|
+
};
|
|
3667
|
+
function nowIso2() {
|
|
3668
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
3669
|
+
}
|
|
3670
|
+
function randomId2(prefix) {
|
|
3671
|
+
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
|
3672
|
+
}
|
|
3673
|
+
function normalizeExecutor(value) {
|
|
3674
|
+
const raw = String(value || "fake").trim();
|
|
3675
|
+
if (raw === "fake" || raw === "adb" || raw === "ios-sim" || raw === "maestro-ios") {
|
|
3676
|
+
return raw;
|
|
3677
|
+
}
|
|
3678
|
+
throw new Error(`Unknown ESVP executor: ${value}`);
|
|
3679
|
+
}
|
|
3680
|
+
function targetForExecutor(executor) {
|
|
3681
|
+
if (executor === "adb") return "android";
|
|
3682
|
+
if (executor === "ios-sim" || executor === "maestro-ios") return "ios";
|
|
3683
|
+
return "fake";
|
|
3684
|
+
}
|
|
3685
|
+
function resolveMetaAppId(meta) {
|
|
3686
|
+
if (!meta) return null;
|
|
3687
|
+
if (typeof meta.appId === "string" && meta.appId.trim()) return meta.appId.trim();
|
|
3688
|
+
if (typeof meta.app_id === "string" && meta.app_id.trim()) return meta.app_id.trim();
|
|
3689
|
+
return null;
|
|
3690
|
+
}
|
|
3691
|
+
async function readJsonFile(filePath) {
|
|
3692
|
+
const text = await readFile2(filePath, "utf8").catch(() => null);
|
|
3693
|
+
if (!text) return null;
|
|
3694
|
+
try {
|
|
3695
|
+
return JSON.parse(text);
|
|
3696
|
+
} catch {
|
|
3697
|
+
return null;
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
async function writeTranscriptFile(filePath, transcript) {
|
|
3701
|
+
const lines = transcript.map((event) => JSON.stringify(event)).join("\n");
|
|
3702
|
+
await writeFile2(filePath, `${lines}${lines ? "\n" : ""}`, "utf8");
|
|
3703
|
+
}
|
|
3704
|
+
async function readTranscriptFile(filePath) {
|
|
3705
|
+
const text = await readFile2(filePath, "utf8");
|
|
3706
|
+
return text.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
|
|
3707
|
+
}
|
|
3708
|
+
function createInitialNetworkState(executor) {
|
|
3709
|
+
return {
|
|
3710
|
+
supported: true,
|
|
3711
|
+
capabilities: networkCapabilitiesForExecutor(executor),
|
|
3712
|
+
active_profile: null,
|
|
3713
|
+
effective_profile: null,
|
|
3714
|
+
managed_proxy: null,
|
|
3715
|
+
configured_at: null,
|
|
3716
|
+
cleared_at: null,
|
|
3717
|
+
last_result: null,
|
|
3718
|
+
last_error: null,
|
|
3719
|
+
trace_count: 0,
|
|
3720
|
+
trace_kinds: []
|
|
3721
|
+
};
|
|
3722
|
+
}
|
|
3723
|
+
function recoverNetworkState(input) {
|
|
3724
|
+
if (input.manifestNetwork && typeof input.manifestNetwork === "object") {
|
|
3725
|
+
const managedProxyWasPersisted = Boolean(
|
|
3726
|
+
input.manifestNetwork.managed_proxy || input.manifestNetwork.managedProxy || String(input.manifestNetwork.effective_profile?.capture?.mode || "").trim().toLowerCase() === "esvp-managed-proxy"
|
|
3727
|
+
);
|
|
3728
|
+
const activeProfile = input.manifestNetwork.active_profile || input.manifestNetwork.activeProfile || null;
|
|
3729
|
+
return {
|
|
3730
|
+
...input.manifestNetwork,
|
|
3731
|
+
capabilities: input.manifestNetwork.capabilities || input.capabilities || null,
|
|
3732
|
+
...managedProxyWasPersisted ? {
|
|
3733
|
+
effective_profile: activeProfile,
|
|
3734
|
+
effectiveProfile: activeProfile,
|
|
3735
|
+
managed_proxy: null,
|
|
3736
|
+
managedProxy: null,
|
|
3737
|
+
last_error: typeof input.manifestNetwork.last_error === "string" && input.manifestNetwork.last_error || "managed proxy was released when the previous App Lab process exited",
|
|
3738
|
+
lastError: typeof input.manifestNetwork.lastError === "string" && input.manifestNetwork.lastError || "managed proxy was released when the previous App Lab process exited"
|
|
3739
|
+
} : {}
|
|
3740
|
+
};
|
|
3741
|
+
}
|
|
3742
|
+
const networkArtifacts = (input.transcript || []).filter((event) => event.type === "artifact" && String(event.kind || "").startsWith("network_"));
|
|
3743
|
+
const traceKinds = /* @__PURE__ */ new Set();
|
|
3744
|
+
for (const event of networkArtifacts) {
|
|
3745
|
+
if (event.kind === "network_trace" && event.meta?.trace_kind) {
|
|
3746
|
+
traceKinds.add(String(event.meta.trace_kind));
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
return {
|
|
3750
|
+
supported: networkArtifacts.length > 0 || Boolean(input.capabilities),
|
|
3751
|
+
capabilities: input.capabilities || null,
|
|
3752
|
+
active_profile: null,
|
|
3753
|
+
effective_profile: null,
|
|
3754
|
+
managed_proxy: null,
|
|
3755
|
+
configured_at: null,
|
|
3756
|
+
cleared_at: null,
|
|
3757
|
+
last_result: null,
|
|
3758
|
+
last_error: null,
|
|
3759
|
+
trace_count: networkArtifacts.filter((event) => event.kind === "network_trace").length,
|
|
3760
|
+
trace_kinds: [...traceKinds]
|
|
3761
|
+
};
|
|
3762
|
+
}
|
|
3763
|
+
function summarizeArtifactEvent(event) {
|
|
3764
|
+
return {
|
|
3765
|
+
kind: event.kind,
|
|
3766
|
+
path: event.path,
|
|
3767
|
+
meta: event.meta || null
|
|
3768
|
+
};
|
|
3769
|
+
}
|
|
3770
|
+
function recountEvents(session, events) {
|
|
3771
|
+
for (const event of events) {
|
|
3772
|
+
if (event.type === "action") session.actionCount += 1;
|
|
3773
|
+
if (event.type === "checkpoint") session.checkpointCount += 1;
|
|
3774
|
+
if (event.type === "artifact") session.artifactCount += 1;
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
function appendSessionEvents(session, events) {
|
|
3778
|
+
if (!Array.isArray(events) || events.length === 0) return;
|
|
3779
|
+
session.transcript.push(...events);
|
|
3780
|
+
recountEvents(session, events);
|
|
3781
|
+
session.updatedAt = nowIso2();
|
|
3782
|
+
}
|
|
3783
|
+
function extractReplayActionsFromTranscript(transcript) {
|
|
3784
|
+
const actions = [];
|
|
3785
|
+
const skipped_actions = [];
|
|
3786
|
+
let pendingReplayActionIndex = -1;
|
|
3787
|
+
for (const event of transcript || []) {
|
|
3788
|
+
if (!event || typeof event !== "object") continue;
|
|
3789
|
+
if (event.type === "action") {
|
|
3790
|
+
const name = String(event.name || "");
|
|
3791
|
+
if (!isReplayableTranscriptAction(name)) {
|
|
3792
|
+
skipped_actions.push({
|
|
3793
|
+
name,
|
|
3794
|
+
reason: "non_replayable_action"
|
|
3795
|
+
});
|
|
3796
|
+
pendingReplayActionIndex = -1;
|
|
3797
|
+
continue;
|
|
3798
|
+
}
|
|
3799
|
+
actions.push({
|
|
3800
|
+
name,
|
|
3801
|
+
args: sanitizeReplayActionArgs(event.args),
|
|
3802
|
+
checkpointAfter: false
|
|
3803
|
+
});
|
|
3804
|
+
pendingReplayActionIndex = actions.length - 1;
|
|
3805
|
+
continue;
|
|
3806
|
+
}
|
|
3807
|
+
if (event.type === "checkpoint" && pendingReplayActionIndex >= 0) {
|
|
3808
|
+
const action = actions[pendingReplayActionIndex];
|
|
3809
|
+
action.checkpointAfter = true;
|
|
3810
|
+
if (event.label) action.checkpointLabel = event.label;
|
|
3811
|
+
pendingReplayActionIndex = -1;
|
|
3812
|
+
continue;
|
|
3813
|
+
}
|
|
3814
|
+
if (event.type === "session_finished") {
|
|
3815
|
+
break;
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
return {
|
|
3819
|
+
action_count: actions.length,
|
|
3820
|
+
skipped_action_count: skipped_actions.length,
|
|
3821
|
+
actions,
|
|
3822
|
+
skipped_actions
|
|
3823
|
+
};
|
|
3824
|
+
}
|
|
3825
|
+
function sanitizeReplayActionArgs(args) {
|
|
3826
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return {};
|
|
3827
|
+
const out = {};
|
|
3828
|
+
for (const [key, value] of Object.entries(args)) {
|
|
3829
|
+
if (String(key).startsWith("_")) continue;
|
|
3830
|
+
out[key] = value;
|
|
3831
|
+
}
|
|
3832
|
+
return out;
|
|
3833
|
+
}
|
|
3834
|
+
function isReplayableTranscriptAction(name) {
|
|
3835
|
+
if (!name) return false;
|
|
3836
|
+
if (String(name).startsWith("monitor.")) return false;
|
|
3837
|
+
if (String(name).startsWith("network.")) return false;
|
|
3838
|
+
return true;
|
|
3839
|
+
}
|
|
3840
|
+
function comparisonStrategyForExecutors(originalExecutor, replayExecutor) {
|
|
3841
|
+
if (originalExecutor === "adb" || replayExecutor === "adb") {
|
|
3842
|
+
return "adb_ui_primary";
|
|
3843
|
+
}
|
|
3844
|
+
if (originalExecutor === "maestro-ios" || replayExecutor === "maestro-ios" || originalExecutor === "ios-sim" || replayExecutor === "ios-sim") {
|
|
3845
|
+
return "ios_step_parity";
|
|
3846
|
+
}
|
|
3847
|
+
return "strict_visual";
|
|
3848
|
+
}
|
|
3849
|
+
function compareCheckpointSequences(input) {
|
|
3850
|
+
const expected = (input.expectedTranscript || []).filter((event) => event.type === "checkpoint");
|
|
3851
|
+
const actual = (input.actualTranscript || []).filter((event) => event.type === "checkpoint");
|
|
3852
|
+
const maxLen = Math.max(expected.length, actual.length);
|
|
3853
|
+
const comparisons = [];
|
|
3854
|
+
let matchedMain = 0;
|
|
3855
|
+
let matchedLabel = 0;
|
|
3856
|
+
let matchedUi = 0;
|
|
3857
|
+
let matchedScreen = 0;
|
|
3858
|
+
let matchedState = 0;
|
|
3859
|
+
const strategy = input.strategy || "strict_visual";
|
|
3860
|
+
for (let index = 0; index < maxLen; index += 1) {
|
|
3861
|
+
const exp = expected[index] || null;
|
|
3862
|
+
const act = actual[index] || null;
|
|
3863
|
+
const row = {
|
|
3864
|
+
index,
|
|
3865
|
+
expected: exp ? {
|
|
3866
|
+
t: exp.t ?? null,
|
|
3867
|
+
label: exp.label || null,
|
|
3868
|
+
ui_hash: exp.ui_hash,
|
|
3869
|
+
screen_hash: exp.screen_hash,
|
|
3870
|
+
state_hash: exp.state_hash || null
|
|
3871
|
+
} : null,
|
|
3872
|
+
actual: act ? {
|
|
3873
|
+
t: act.t ?? null,
|
|
3874
|
+
label: act.label || null,
|
|
3875
|
+
ui_hash: act.ui_hash,
|
|
3876
|
+
screen_hash: act.screen_hash,
|
|
3877
|
+
state_hash: act.state_hash || null
|
|
3878
|
+
} : null,
|
|
3879
|
+
ui_hash_match: Boolean(exp && act && exp.ui_hash === act.ui_hash),
|
|
3880
|
+
screen_hash_match: Boolean(exp && act && exp.screen_hash === act.screen_hash),
|
|
3881
|
+
state_hash_match: Boolean(exp && act && exp.state_hash && act.state_hash && exp.state_hash === act.state_hash),
|
|
3882
|
+
label_match: Boolean(exp && act && exp.label && act.label && normalizeCheckpointLabel(exp.label) === normalizeCheckpointLabel(act.label))
|
|
3883
|
+
};
|
|
3884
|
+
row.strict_match = Boolean(row.ui_hash_match && row.screen_hash_match);
|
|
3885
|
+
row.main_match = strategy === "adb_ui_primary" ? Boolean(row.ui_hash_match) : strategy === "ios_step_parity" ? Boolean(row.label_match || row.strict_match) : Boolean(row.strict_match);
|
|
3886
|
+
if (row.label_match) matchedLabel += 1;
|
|
3887
|
+
if (row.ui_hash_match) matchedUi += 1;
|
|
3888
|
+
if (row.screen_hash_match) matchedScreen += 1;
|
|
3889
|
+
if (row.state_hash_match) matchedState += 1;
|
|
3890
|
+
if (row.main_match) matchedMain += 1;
|
|
3891
|
+
comparisons.push(row);
|
|
3892
|
+
}
|
|
3893
|
+
const expectedCount = expected.length;
|
|
3894
|
+
const mainRate = expectedCount > 0 ? matchedMain / expectedCount : 0;
|
|
3895
|
+
const verdict = expectedCount === 0 ? "no_checkpoints" : mainRate >= 0.8 ? "pass" : mainRate >= 0.5 ? "partial" : "fail";
|
|
3896
|
+
return {
|
|
3897
|
+
strategy,
|
|
3898
|
+
expected_checkpoint_count: expected.length,
|
|
3899
|
+
actual_checkpoint_count: actual.length,
|
|
3900
|
+
matched_main_count: matchedMain,
|
|
3901
|
+
matched_label_count: matchedLabel,
|
|
3902
|
+
matched_ui_count: matchedUi,
|
|
3903
|
+
matched_screen_count: matchedScreen,
|
|
3904
|
+
matched_state_count: matchedState,
|
|
3905
|
+
main_match_rate: Number(mainRate.toFixed(3)),
|
|
3906
|
+
label_match_rate: Number((expectedCount > 0 ? matchedLabel / expectedCount : 0).toFixed(3)),
|
|
3907
|
+
ui_match_rate: Number((expectedCount > 0 ? matchedUi / expectedCount : 0).toFixed(3)),
|
|
3908
|
+
screen_match_rate: Number((expectedCount > 0 ? matchedScreen / expectedCount : 0).toFixed(3)),
|
|
3909
|
+
state_match_rate: Number((expectedCount > 0 ? matchedState / expectedCount : 0).toFixed(3)),
|
|
3910
|
+
strict_match_rate: Number((expectedCount > 0 ? comparisons.filter((row) => row.strict_match).length / expectedCount : 0).toFixed(3)),
|
|
3911
|
+
verdict,
|
|
3912
|
+
divergences: comparisons.filter((row) => !row.main_match).slice(0, 20),
|
|
3913
|
+
comparisons: comparisons.slice(0, 50)
|
|
3914
|
+
};
|
|
3915
|
+
}
|
|
3916
|
+
function normalizeCheckpointLabel(label) {
|
|
3917
|
+
return String(label || "").trim().toLowerCase();
|
|
3918
|
+
}
|
|
3919
|
+
function normalizeCheckpointLabelInput(label) {
|
|
3920
|
+
const normalized = String(label || "").trim();
|
|
3921
|
+
return normalized || null;
|
|
3922
|
+
}
|
|
3923
|
+
function hasFinished(transcript) {
|
|
3924
|
+
return transcript.some((event) => event.type === "session_finished");
|
|
3925
|
+
}
|
|
3926
|
+
function lastEventTime(transcript) {
|
|
3927
|
+
return transcript.length ? Number(transcript[transcript.length - 1].t || 0) : 0;
|
|
3928
|
+
}
|
|
3929
|
+
function lastEventTimeBeforeFinish(transcript) {
|
|
3930
|
+
const finishIdx = transcript.findIndex((event) => event.type === "session_finished");
|
|
3931
|
+
if (finishIdx <= 0) return 0;
|
|
3932
|
+
return Number(transcript[finishIdx - 1]?.t || 0);
|
|
3933
|
+
}
|
|
3934
|
+
function insertEventsBeforeSessionFinished(transcript, events) {
|
|
3935
|
+
if (!Array.isArray(events) || events.length === 0) return;
|
|
3936
|
+
const finishIdx = transcript.findIndex((event) => event.type === "session_finished");
|
|
3937
|
+
if (finishIdx < 0) {
|
|
3938
|
+
transcript.push(...events);
|
|
3939
|
+
return;
|
|
3940
|
+
}
|
|
3941
|
+
transcript.splice(finishIdx, 0, ...events);
|
|
3942
|
+
}
|
|
3943
|
+
function relativeRunsPath(rootDir, runDir, filename) {
|
|
3944
|
+
if (!runDir.startsWith(rootDir)) return resolve(runDir, filename);
|
|
3945
|
+
return `runs/${runDir.slice(rootDir.length + 1)}/${filename}`;
|
|
3946
|
+
}
|
|
3947
|
+
function normalizeArtifactRelativePath(input) {
|
|
3948
|
+
const raw = String(input || "").trim();
|
|
3949
|
+
if (!raw) throw new Error("artifact path is empty");
|
|
3950
|
+
const segments = raw.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
3951
|
+
const normalized = segments.join("/");
|
|
3952
|
+
if (!normalized) throw new Error("invalid artifact path");
|
|
3953
|
+
if (segments.some((segment) => segment === "..")) {
|
|
3954
|
+
throw new Error("invalid artifact path");
|
|
3955
|
+
}
|
|
3956
|
+
return normalized;
|
|
3957
|
+
}
|
|
3958
|
+
function normalizeNetworkProfileInput(input) {
|
|
3959
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
3960
|
+
throw new Error("network profile must be an object");
|
|
3961
|
+
}
|
|
3962
|
+
const proxyInput = input.proxy && typeof input.proxy === "object" ? input.proxy : null;
|
|
3963
|
+
const faultsInput = input.faults && typeof input.faults === "object" ? input.faults : null;
|
|
3964
|
+
const captureInput = input.capture && typeof input.capture === "object" ? input.capture : null;
|
|
3965
|
+
return {
|
|
3966
|
+
profile: input.profile ? String(input.profile) : null,
|
|
3967
|
+
label: input.label ? String(input.label) : null,
|
|
3968
|
+
connectivity: normalizeNetworkConnectivity(input.connectivity),
|
|
3969
|
+
proxy: proxyInput ? {
|
|
3970
|
+
host: proxyInput.host ? String(proxyInput.host) : null,
|
|
3971
|
+
port: proxyInput.port != null ? Number(proxyInput.port) : null,
|
|
3972
|
+
protocol: proxyInput.protocol ? String(proxyInput.protocol) : "http",
|
|
3973
|
+
bypass: Array.isArray(proxyInput.bypass) ? proxyInput.bypass.map((value) => String(value)) : [],
|
|
3974
|
+
bind_host: proxyInput.bind_host ? String(proxyInput.bind_host) : proxyInput.bindHost ? String(proxyInput.bindHost) : null,
|
|
3975
|
+
advertise_host: proxyInput.advertise_host ? String(proxyInput.advertise_host) : proxyInput.advertiseHost ? String(proxyInput.advertiseHost) : null
|
|
3976
|
+
} : null,
|
|
3977
|
+
faults: faultsInput ? {
|
|
3978
|
+
delay_ms: numberOrNull(faultsInput.delay_ms ?? faultsInput.delayMs),
|
|
3979
|
+
timeout: faultsInput.timeout === true,
|
|
3980
|
+
offline_partial: faultsInput.offline_partial === true || faultsInput.offlinePartial === true,
|
|
3981
|
+
status_code: numberOrNull(faultsInput.status_code ?? faultsInput.statusCode),
|
|
3982
|
+
body_patch: faultsInput.body_patch ?? faultsInput.bodyPatch ?? null
|
|
3983
|
+
} : null,
|
|
3984
|
+
capture: captureInput ? {
|
|
3985
|
+
enabled: captureInput.enabled !== false,
|
|
3986
|
+
mode: captureInput.mode ? String(captureInput.mode) : null
|
|
3987
|
+
} : null
|
|
3988
|
+
};
|
|
3989
|
+
}
|
|
3990
|
+
function normalizeNetworkConnectivity(value) {
|
|
3991
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
3992
|
+
if (!raw) return null;
|
|
3993
|
+
if (raw === "offline" || raw === "online" || raw === "reset") return raw;
|
|
3994
|
+
throw new Error(`invalid network connectivity: ${value}`);
|
|
3995
|
+
}
|
|
3996
|
+
function normalizeNetworkTraceInput(input) {
|
|
3997
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
3998
|
+
throw new Error("network trace must be an object");
|
|
3999
|
+
}
|
|
4000
|
+
const traceKind = String(input.trace_kind || input.kind || "http_trace").trim();
|
|
4001
|
+
if (!traceKind) throw new Error("network trace.trace_kind is required");
|
|
4002
|
+
const payload = input.payload ?? input.trace ?? input.content;
|
|
4003
|
+
if (payload == null) throw new Error("network trace.payload is required");
|
|
4004
|
+
return {
|
|
4005
|
+
trace_kind: traceKind,
|
|
4006
|
+
label: input.label ? String(input.label) : null,
|
|
4007
|
+
format: inferNetworkTraceFormat(input.format, payload, traceKind),
|
|
4008
|
+
payload,
|
|
4009
|
+
source: input.source ? String(input.source) : null,
|
|
4010
|
+
request_id: input.request_id ? String(input.request_id) : null,
|
|
4011
|
+
url: input.url ? String(input.url) : null,
|
|
4012
|
+
method: input.method ? String(input.method) : null,
|
|
4013
|
+
status_code: input.status_code != null ? Number(input.status_code) : null
|
|
4014
|
+
};
|
|
4015
|
+
}
|
|
4016
|
+
function inferNetworkTraceFormat(explicitFormat, payload, traceKind) {
|
|
4017
|
+
if (explicitFormat) return String(explicitFormat);
|
|
4018
|
+
if (traceKind === "har") return "har";
|
|
4019
|
+
if (typeof payload === "string") return "text";
|
|
4020
|
+
return "json";
|
|
4021
|
+
}
|
|
4022
|
+
function buildNetworkArtifactPath(session, kind, options = {}) {
|
|
4023
|
+
const existingCount = (session.transcript || []).filter((event) => event.type === "artifact" && String(event.kind || "").startsWith("network_")).length;
|
|
4024
|
+
const seq = String(existingCount + 1).padStart(3, "0");
|
|
4025
|
+
const safeBase = slugify3(options.label || options.traceKind || kind);
|
|
4026
|
+
const ext = networkArtifactExt(kind, options.format, options.traceKind);
|
|
4027
|
+
return `network/${seq}-${kind}-${safeBase}${ext}`;
|
|
4028
|
+
}
|
|
4029
|
+
function networkArtifactExt(kind, format, traceKind) {
|
|
4030
|
+
if (kind === "network_trace" && traceKind === "har") return ".har.json";
|
|
4031
|
+
if (format === "json" || format === "har") return ".json";
|
|
4032
|
+
if (format === "txt" || format === "text") return ".txt";
|
|
4033
|
+
return ".json";
|
|
4034
|
+
}
|
|
4035
|
+
function serializeArtifactPayload(payload, format) {
|
|
4036
|
+
if (Buffer.isBuffer(payload)) return payload;
|
|
4037
|
+
if (format === "json" || format === "har" || !format) {
|
|
4038
|
+
return Buffer.from(`${JSON.stringify(payload, null, 2)}
|
|
4039
|
+
`, "utf8");
|
|
4040
|
+
}
|
|
4041
|
+
return Buffer.from(String(payload), "utf8");
|
|
4042
|
+
}
|
|
4043
|
+
function sanitizeMeta(meta) {
|
|
4044
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return {};
|
|
4045
|
+
return JSON.parse(JSON.stringify(meta));
|
|
4046
|
+
}
|
|
4047
|
+
function sanitizeNetworkResult(result) {
|
|
4048
|
+
if (!result || typeof result !== "object") return result ?? null;
|
|
4049
|
+
return JSON.parse(JSON.stringify(result));
|
|
4050
|
+
}
|
|
4051
|
+
function numberOrNull(value) {
|
|
4052
|
+
if (value == null || value === "") return null;
|
|
4053
|
+
const n = Number(value);
|
|
4054
|
+
return Number.isFinite(n) ? n : null;
|
|
4055
|
+
}
|
|
4056
|
+
function slugify3(value) {
|
|
4057
|
+
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "artifact";
|
|
4058
|
+
}
|
|
4059
|
+
function mergeNetworkCapabilities(...caps) {
|
|
4060
|
+
const normalized = caps.filter((value) => Boolean(value && typeof value === "object"));
|
|
4061
|
+
if (!normalized.length) return null;
|
|
4062
|
+
return Object.assign({}, ...normalized);
|
|
4063
|
+
}
|
|
4064
|
+
function dirnameSafe(pathLike) {
|
|
4065
|
+
const index = pathLike.lastIndexOf("/");
|
|
4066
|
+
return index >= 0 ? pathLike.slice(0, index) : ".";
|
|
4067
|
+
}
|
|
4068
|
+
|
|
4069
|
+
// src/core/integrations/esvp-local.ts
|
|
4070
|
+
async function resolveESVPConnection(serverUrl) {
|
|
4071
|
+
assertLocalOnlyESVPConfig(serverUrl);
|
|
4072
|
+
await getAppLabESVPLocalRuntime();
|
|
4073
|
+
return {
|
|
4074
|
+
mode: "local",
|
|
4075
|
+
serverUrl: LOCAL_ESVP_SERVER_URL
|
|
4076
|
+
};
|
|
4077
|
+
}
|
|
4078
|
+
function assertLocalOnlyESVPConfig(serverUrl) {
|
|
4079
|
+
const explicit = normalizeOptionalUrl(serverUrl);
|
|
4080
|
+
if (explicit && explicit !== LOCAL_ESVP_SERVER_URL) {
|
|
4081
|
+
throw new Error(
|
|
4082
|
+
"App Lab agora embute o runtime ESVP local no pr\xF3prio processo. Remova o --server/serverUrl informado e use o modo local padr\xE3o."
|
|
4083
|
+
);
|
|
4084
|
+
}
|
|
4085
|
+
const envUrl = normalizeOptionalUrl(process.env.ESVP_BASE_URL);
|
|
4086
|
+
if (envUrl && envUrl !== LOCAL_ESVP_SERVER_URL) {
|
|
4087
|
+
throw new Error(
|
|
4088
|
+
"ESVP_BASE_URL n\xE3o \xE9 mais suportado no App Lab local. Remova essa vari\xE1vel para usar o runtime ESVP embutido."
|
|
4089
|
+
);
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
function normalizeOptionalUrl(value) {
|
|
4093
|
+
if (typeof value !== "string") return null;
|
|
4094
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
4095
|
+
return normalized || null;
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
// src/core/integrations/esvp.ts
|
|
4099
|
+
async function getRuntime(serverUrl) {
|
|
4100
|
+
await resolveESVPConnection(serverUrl);
|
|
4101
|
+
return getAppLabESVPLocalRuntime();
|
|
4102
|
+
}
|
|
4103
|
+
function normalizeActionList(actions) {
|
|
4104
|
+
return actions.map((action) => {
|
|
4105
|
+
const normalized = {
|
|
4106
|
+
name: String(action?.name || ""),
|
|
4107
|
+
args: action?.args && typeof action.args === "object" ? action.args : {}
|
|
4108
|
+
};
|
|
4109
|
+
if (typeof action?.checkpointAfter === "boolean") {
|
|
4110
|
+
normalized.checkpointAfter = action.checkpointAfter;
|
|
4111
|
+
}
|
|
4112
|
+
if (typeof action?.checkpointLabel === "string") {
|
|
4113
|
+
normalized.checkpointLabel = action.checkpointLabel;
|
|
4114
|
+
}
|
|
4115
|
+
return normalized;
|
|
4116
|
+
});
|
|
4117
|
+
}
|
|
4118
|
+
async function listDevicesEnvelope(executor) {
|
|
4119
|
+
return {
|
|
4120
|
+
devices: await listDevicesForExecutor(executor)
|
|
4121
|
+
};
|
|
4122
|
+
}
|
|
4123
|
+
function getESVPBaseUrl(serverUrl) {
|
|
4124
|
+
assertLocalOnlyESVPConfig(serverUrl);
|
|
4125
|
+
return LOCAL_ESVP_SERVER_URL;
|
|
4126
|
+
}
|
|
4127
|
+
async function resolveESVPBaseUrl(serverUrl) {
|
|
4128
|
+
const connection = await resolveESVPConnection(serverUrl);
|
|
4129
|
+
return connection.serverUrl;
|
|
4130
|
+
}
|
|
4131
|
+
async function getESVPConnection(serverUrl) {
|
|
4132
|
+
return resolveESVPConnection(serverUrl);
|
|
4133
|
+
}
|
|
4134
|
+
async function getESVPHealth(serverUrl) {
|
|
4135
|
+
const runtime = await getRuntime(serverUrl);
|
|
4136
|
+
return runtime.getHealth();
|
|
4137
|
+
}
|
|
4138
|
+
async function listESVPSessions(serverUrl) {
|
|
4139
|
+
const runtime = await getRuntime(serverUrl);
|
|
4140
|
+
return {
|
|
4141
|
+
sessions: runtime.listSessions()
|
|
4142
|
+
};
|
|
4143
|
+
}
|
|
4144
|
+
async function createESVPSession(input, serverUrl) {
|
|
4145
|
+
const runtime = await getRuntime(serverUrl);
|
|
4146
|
+
return {
|
|
4147
|
+
session: await runtime.createSession(input)
|
|
4148
|
+
};
|
|
4149
|
+
}
|
|
4150
|
+
async function getESVPSession(sessionId, serverUrl) {
|
|
4151
|
+
const runtime = await getRuntime(serverUrl);
|
|
4152
|
+
return {
|
|
4153
|
+
session: runtime.getSession(sessionId)
|
|
4154
|
+
};
|
|
4155
|
+
}
|
|
4156
|
+
async function getESVPTranscript(sessionId, serverUrl) {
|
|
4157
|
+
const runtime = await getRuntime(serverUrl);
|
|
4158
|
+
return runtime.getTranscript(sessionId);
|
|
4159
|
+
}
|
|
4160
|
+
async function listESVPArtifacts(sessionId, serverUrl) {
|
|
4161
|
+
const runtime = await getRuntime(serverUrl);
|
|
4162
|
+
return {
|
|
4163
|
+
artifacts: runtime.listArtifacts(sessionId)
|
|
4164
|
+
};
|
|
4165
|
+
}
|
|
4166
|
+
async function runESVPActions(sessionId, input, serverUrl) {
|
|
4167
|
+
const runtime = await getRuntime(serverUrl);
|
|
4168
|
+
return runtime.runActions(sessionId, normalizeActionList(input.actions), {
|
|
4169
|
+
finish: input.finish === true,
|
|
4170
|
+
captureLogcat: input.captureLogcat === true,
|
|
4171
|
+
checkpointAfterEach: input.checkpointAfterEach === true
|
|
4172
|
+
});
|
|
4173
|
+
}
|
|
4174
|
+
async function finishESVPSession(sessionId, input = {}, serverUrl) {
|
|
4175
|
+
const runtime = await getRuntime(serverUrl);
|
|
4176
|
+
return {
|
|
4177
|
+
session: await runtime.finishSession(sessionId, input)
|
|
4178
|
+
};
|
|
4179
|
+
}
|
|
4180
|
+
async function runESVPPreflight(sessionId, config, serverUrl) {
|
|
4181
|
+
const runtime = await getRuntime(serverUrl);
|
|
4182
|
+
return runtime.runPreflight(sessionId, config);
|
|
4183
|
+
}
|
|
4184
|
+
async function inspectESVPSession(sessionId, input = {}, serverUrl) {
|
|
4185
|
+
const [session, transcript, artifacts] = await Promise.all([
|
|
4186
|
+
getESVPSession(sessionId, serverUrl),
|
|
4187
|
+
input.includeTranscript === true ? getESVPTranscript(sessionId, serverUrl) : Promise.resolve(null),
|
|
4188
|
+
input.includeArtifacts === true ? listESVPArtifacts(sessionId, serverUrl) : Promise.resolve(null)
|
|
4189
|
+
]);
|
|
4190
|
+
return {
|
|
4191
|
+
session: session?.session || session,
|
|
4192
|
+
transcript: transcript?.events || null,
|
|
4193
|
+
artifacts: artifacts?.artifacts || null
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
async function getESVPArtifactContent(sessionId, artifactPath, serverUrl) {
|
|
4197
|
+
const runtime = await getRuntime(serverUrl);
|
|
4198
|
+
return runtime.getArtifactContent(sessionId, artifactPath);
|
|
4199
|
+
}
|
|
4200
|
+
async function getESVPSessionNetwork(sessionId, serverUrl) {
|
|
4201
|
+
const runtime = await getRuntime(serverUrl);
|
|
4202
|
+
return runtime.getSessionNetwork(sessionId);
|
|
4203
|
+
}
|
|
4204
|
+
async function replayESVPSession(sessionId, input = {}, serverUrl) {
|
|
4205
|
+
const runtime = await getRuntime(serverUrl);
|
|
4206
|
+
return runtime.replaySessionToNewSession(sessionId, input);
|
|
4207
|
+
}
|
|
4208
|
+
async function getESVPReplayConsistency(sessionId, serverUrl) {
|
|
4209
|
+
const runtime = await getRuntime(serverUrl);
|
|
4210
|
+
return runtime.analyzeReplayConsistency(sessionId);
|
|
4211
|
+
}
|
|
4212
|
+
async function validateESVPReplay(sessionId, serverUrl) {
|
|
4213
|
+
const runtime = await getRuntime(serverUrl);
|
|
4214
|
+
const result = runtime.validateSessionReplay(sessionId);
|
|
4215
|
+
return {
|
|
4216
|
+
http_status: result?.supported === false || result?.ok === false ? 409 : 200,
|
|
4217
|
+
...result
|
|
4218
|
+
};
|
|
4219
|
+
}
|
|
4220
|
+
async function captureESVPCheckpoint(sessionId, input = {}, serverUrl) {
|
|
4221
|
+
const runtime = await getRuntime(serverUrl);
|
|
4222
|
+
return runtime.captureCheckpoint(sessionId, input.label);
|
|
4223
|
+
}
|
|
4224
|
+
async function configureESVPNetwork(sessionId, input, serverUrl) {
|
|
4225
|
+
const runtime = await getRuntime(serverUrl);
|
|
4226
|
+
return runtime.configureSessionNetwork(sessionId, input);
|
|
4227
|
+
}
|
|
4228
|
+
async function clearESVPNetwork(sessionId, serverUrl) {
|
|
4229
|
+
const runtime = await getRuntime(serverUrl);
|
|
4230
|
+
return runtime.clearSessionNetwork(sessionId, {});
|
|
4231
|
+
}
|
|
4232
|
+
async function attachESVPNetworkTrace(sessionId, input, serverUrl) {
|
|
4233
|
+
const runtime = await getRuntime(serverUrl);
|
|
4234
|
+
return runtime.attachSessionNetworkTrace(sessionId, input);
|
|
4235
|
+
}
|
|
4236
|
+
async function listESVPDevices(platform = "all", serverUrl) {
|
|
4237
|
+
await getRuntime(serverUrl);
|
|
4238
|
+
if (platform === "adb") {
|
|
4239
|
+
return listDevicesEnvelope("adb");
|
|
4240
|
+
}
|
|
4241
|
+
if (platform === "ios-sim") {
|
|
4242
|
+
return listDevicesEnvelope("ios-sim");
|
|
4243
|
+
}
|
|
4244
|
+
if (platform === "maestro-ios") {
|
|
4245
|
+
return listDevicesEnvelope("maestro-ios");
|
|
4246
|
+
}
|
|
4247
|
+
const [adb, iosSim, maestroIos] = await Promise.all([
|
|
4248
|
+
listDevicesEnvelope("adb").catch((error) => ({ error: error instanceof Error ? error.message : String(error), devices: [] })),
|
|
4249
|
+
listDevicesEnvelope("ios-sim").catch((error) => ({ error: error instanceof Error ? error.message : String(error), devices: [] })),
|
|
4250
|
+
listDevicesEnvelope("maestro-ios").catch((error) => ({ error: error instanceof Error ? error.message : String(error), devices: [] }))
|
|
4251
|
+
]);
|
|
4252
|
+
return {
|
|
4253
|
+
adb,
|
|
4254
|
+
iosSim,
|
|
4255
|
+
maestroIos
|
|
4256
|
+
};
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
export {
|
|
4260
|
+
findAndroidSdkPath,
|
|
4261
|
+
getEmulatorPath,
|
|
4262
|
+
getAdbCommand,
|
|
4263
|
+
listConnectedAndroidDevices,
|
|
4264
|
+
resolveAndroidDeviceSerial,
|
|
4265
|
+
isMaestroInstalled,
|
|
4266
|
+
isIdbInstalled,
|
|
4267
|
+
tapViaIdb,
|
|
4268
|
+
killZombieMaestroProcesses,
|
|
4269
|
+
getMaestroVersion,
|
|
4270
|
+
listMaestroDevices,
|
|
4271
|
+
generateMaestroFlow,
|
|
4272
|
+
runMaestroTest,
|
|
4273
|
+
runMaestroWithCapture,
|
|
4274
|
+
startMaestroStudio,
|
|
4275
|
+
createLoginFlow,
|
|
4276
|
+
createOnboardingFlow,
|
|
4277
|
+
createNavigationTestFlow,
|
|
4278
|
+
parseMaestroActionsFromYaml,
|
|
4279
|
+
getMaestroRecorder,
|
|
4280
|
+
LOCAL_ESVP_SERVER_URL,
|
|
4281
|
+
getESVPBaseUrl,
|
|
4282
|
+
resolveESVPBaseUrl,
|
|
4283
|
+
getESVPConnection,
|
|
4284
|
+
getESVPHealth,
|
|
4285
|
+
listESVPSessions,
|
|
4286
|
+
createESVPSession,
|
|
4287
|
+
getESVPSession,
|
|
4288
|
+
getESVPTranscript,
|
|
4289
|
+
listESVPArtifacts,
|
|
4290
|
+
runESVPActions,
|
|
4291
|
+
finishESVPSession,
|
|
4292
|
+
runESVPPreflight,
|
|
4293
|
+
inspectESVPSession,
|
|
4294
|
+
getESVPArtifactContent,
|
|
4295
|
+
getESVPSessionNetwork,
|
|
4296
|
+
replayESVPSession,
|
|
4297
|
+
getESVPReplayConsistency,
|
|
4298
|
+
validateESVPReplay,
|
|
4299
|
+
captureESVPCheckpoint,
|
|
4300
|
+
configureESVPNetwork,
|
|
4301
|
+
clearESVPNetwork,
|
|
4302
|
+
attachESVPNetworkTrace,
|
|
4303
|
+
listESVPDevices
|
|
4304
|
+
};
|