@veolab/discoverylab 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-6EGBXRDK.js +30 -0
- package/dist/chunk-7DOG2W4O.js +6428 -0
- package/dist/chunk-7HZEDTS7.js +7802 -0
- package/dist/chunk-E3N3P2AG.js +6428 -0
- package/dist/chunk-G524UVBK.js +334 -0
- package/dist/chunk-XKX6NBHF.js +209 -0
- package/dist/chunk-XZZKFF5V.js +7787 -0
- package/dist/chunk-YEZ26ENO.js +1569 -0
- package/dist/chunk-ZJFWMSZF.js +7883 -0
- package/dist/cli.js +12 -6
- package/dist/index.js +6 -5
- package/dist/server-6N3KIEGP.js +16 -0
- package/dist/server-HKRIY7FP.js +14 -0
- package/dist/server-HON66OES.js +15 -0
- package/dist/server-IZEO7OJJ.js +14 -0
- package/dist/setup-B5YPNUE4.js +18 -0
- package/dist/tools-OPULIER6.js +178 -0
- package/dist/tools-YS4QHOTQ.js +179 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1569 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PROJECTS_DIR
|
|
3
|
+
} from "./chunk-TJ3H23LL.js";
|
|
4
|
+
|
|
5
|
+
// src/core/android/adb.ts
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
var ADB_COMMAND_CACHE_SUCCESS_TTL_MS = 5 * 60 * 1e3;
|
|
11
|
+
var ADB_COMMAND_CACHE_FAILURE_TTL_MS = 5 * 1e3;
|
|
12
|
+
var adbCommandCache = null;
|
|
13
|
+
function quoteCommand(cmd) {
|
|
14
|
+
return cmd.includes(" ") ? `"${cmd}"` : cmd;
|
|
15
|
+
}
|
|
16
|
+
function shellQuoteArg(value) {
|
|
17
|
+
const str = String(value ?? "");
|
|
18
|
+
if (!str) return "''";
|
|
19
|
+
return `'${str.replace(/'/g, `'"'"'`)}'`;
|
|
20
|
+
}
|
|
21
|
+
function findAndroidSdkPath() {
|
|
22
|
+
const envPaths = [
|
|
23
|
+
process.env.ANDROID_HOME,
|
|
24
|
+
process.env.ANDROID_SDK_ROOT,
|
|
25
|
+
process.env.ANDROID_SDK
|
|
26
|
+
].filter((value) => typeof value === "string" && value.trim().length > 0);
|
|
27
|
+
for (const envPath of envPaths) {
|
|
28
|
+
if (existsSync(join(envPath, "platform-tools", "adb"))) {
|
|
29
|
+
return envPath;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const home = homedir();
|
|
33
|
+
const commonPaths = [
|
|
34
|
+
join(home, "Library", "Android", "sdk"),
|
|
35
|
+
join(home, "Android", "Sdk"),
|
|
36
|
+
"/opt/android-sdk",
|
|
37
|
+
"/usr/local/android-sdk"
|
|
38
|
+
];
|
|
39
|
+
for (const sdkPath of commonPaths) {
|
|
40
|
+
if (existsSync(join(sdkPath, "platform-tools", "adb"))) {
|
|
41
|
+
return sdkPath;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function getAdbPath() {
|
|
47
|
+
const sdkPath = findAndroidSdkPath();
|
|
48
|
+
if (!sdkPath) return null;
|
|
49
|
+
const adbPath = join(sdkPath, "platform-tools", "adb");
|
|
50
|
+
return existsSync(adbPath) ? adbPath : null;
|
|
51
|
+
}
|
|
52
|
+
function getEmulatorPath() {
|
|
53
|
+
const sdkPath = findAndroidSdkPath();
|
|
54
|
+
if (!sdkPath) return null;
|
|
55
|
+
const emulatorPath = join(sdkPath, "emulator", "emulator");
|
|
56
|
+
return existsSync(emulatorPath) ? emulatorPath : null;
|
|
57
|
+
}
|
|
58
|
+
function getAdbCommand(options) {
|
|
59
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
if (!forceRefresh && adbCommandCache) {
|
|
62
|
+
const ttlMs = adbCommandCache.value ? ADB_COMMAND_CACHE_SUCCESS_TTL_MS : ADB_COMMAND_CACHE_FAILURE_TTL_MS;
|
|
63
|
+
if (now - adbCommandCache.checkedAt < ttlMs) {
|
|
64
|
+
return adbCommandCache.value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const candidates = [
|
|
68
|
+
getAdbPath(),
|
|
69
|
+
"/opt/homebrew/bin/adb",
|
|
70
|
+
"/usr/local/bin/adb",
|
|
71
|
+
"adb"
|
|
72
|
+
].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
try {
|
|
75
|
+
if (candidate === "adb") {
|
|
76
|
+
execSync("which adb", { stdio: "pipe", timeout: 2e3 });
|
|
77
|
+
} else if (!existsSync(candidate)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
adbCommandCache = { value: candidate, checkedAt: Date.now() };
|
|
81
|
+
return candidate;
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
adbCommandCache = { value: null, checkedAt: Date.now() };
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function normalizeAndroidDeviceToken(value) {
|
|
89
|
+
return value.trim().replace(/^android:/i, "").replace(/\s+/g, "_").toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
function listConnectedAndroidDevices(adbCommand = getAdbCommand()) {
|
|
92
|
+
if (!adbCommand) return [];
|
|
93
|
+
try {
|
|
94
|
+
const adbOutput = execSync(`${quoteCommand(adbCommand)} devices -l`, {
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
timeout: 4e3
|
|
97
|
+
});
|
|
98
|
+
const lines = adbOutput.split("\n").slice(1);
|
|
99
|
+
const devices = [];
|
|
100
|
+
for (const rawLine of lines) {
|
|
101
|
+
const line = rawLine.trim();
|
|
102
|
+
if (!line) continue;
|
|
103
|
+
const parts = line.split(/\s+/);
|
|
104
|
+
const serial = parts[0];
|
|
105
|
+
const state = parts[1];
|
|
106
|
+
if (!serial || !state) continue;
|
|
107
|
+
const modelMatch = line.match(/model:(\S+)/);
|
|
108
|
+
const deviceMatch = line.match(/device:(\S+)/);
|
|
109
|
+
const deviceInfo = {
|
|
110
|
+
serial,
|
|
111
|
+
state,
|
|
112
|
+
model: modelMatch?.[1],
|
|
113
|
+
device: deviceMatch?.[1]
|
|
114
|
+
};
|
|
115
|
+
if (serial.startsWith("emulator-") && state === "device") {
|
|
116
|
+
try {
|
|
117
|
+
const avdNameOutput = execSync(
|
|
118
|
+
`${quoteCommand(adbCommand)} -s ${shellQuoteArg(serial)} emu avd name`,
|
|
119
|
+
{
|
|
120
|
+
encoding: "utf8",
|
|
121
|
+
timeout: 1500
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
const avdName = avdNameOutput.split("\n").map((value) => value.trim()).find((value) => value && value !== "OK");
|
|
125
|
+
if (avdName) {
|
|
126
|
+
deviceInfo.avdName = avdName;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
devices.push(deviceInfo);
|
|
132
|
+
}
|
|
133
|
+
return devices;
|
|
134
|
+
} catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function resolveAndroidDeviceSerial(deviceId, adbCommand = getAdbCommand()) {
|
|
139
|
+
const connectedDevices = listConnectedAndroidDevices(adbCommand);
|
|
140
|
+
const onlineDevices = connectedDevices.filter((device) => device.state === "device");
|
|
141
|
+
if (onlineDevices.length === 0) return null;
|
|
142
|
+
const requested = typeof deviceId === "string" ? deviceId.trim() : "";
|
|
143
|
+
if (!requested) {
|
|
144
|
+
return onlineDevices[0]?.serial || null;
|
|
145
|
+
}
|
|
146
|
+
const requestedToken = normalizeAndroidDeviceToken(requested);
|
|
147
|
+
for (const device of onlineDevices) {
|
|
148
|
+
const candidates = [device.serial, device.avdName, device.model, device.device].filter((value) => Boolean(value)).map((value) => normalizeAndroidDeviceToken(value));
|
|
149
|
+
if (candidates.includes(requestedToken)) {
|
|
150
|
+
return device.serial;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/core/testing/maestro.ts
|
|
157
|
+
import { exec, execSync as execSync2, spawn } from "child_process";
|
|
158
|
+
import { promisify } from "util";
|
|
159
|
+
import * as fs from "fs";
|
|
160
|
+
import * as path from "path";
|
|
161
|
+
import * as os from "os";
|
|
162
|
+
import { createHash } from "crypto";
|
|
163
|
+
import { EventEmitter } from "events";
|
|
164
|
+
var execAsync = promisify(exec);
|
|
165
|
+
var MAESTRO_COMMAND_CACHE_SUCCESS_TTL_MS = 5 * 60 * 1e3;
|
|
166
|
+
var MAESTRO_COMMAND_CACHE_FAILURE_TTL_MS = 5 * 1e3;
|
|
167
|
+
var MAESTRO_DEVICE_CACHE_TTL_MS = 4 * 1e3;
|
|
168
|
+
var maestroCommandCache = null;
|
|
169
|
+
var maestroCommandResolveInFlight = null;
|
|
170
|
+
var maestroDeviceListCache = null;
|
|
171
|
+
var maestroDeviceListInFlight = null;
|
|
172
|
+
function quoteCommand2(cmd) {
|
|
173
|
+
return cmd.includes(" ") ? `"${cmd}"` : cmd;
|
|
174
|
+
}
|
|
175
|
+
function shellQuoteArg2(value) {
|
|
176
|
+
const str = String(value ?? "");
|
|
177
|
+
if (!str) return "''";
|
|
178
|
+
return `'${str.replace(/'/g, `'"'"'`)}'`;
|
|
179
|
+
}
|
|
180
|
+
function getAdbCommandOrThrow() {
|
|
181
|
+
const adbCommand = getAdbCommand();
|
|
182
|
+
if (!adbCommand) {
|
|
183
|
+
throw new Error("ADB not found. Set ANDROID_HOME/ANDROID_SDK_ROOT or add adb to PATH.");
|
|
184
|
+
}
|
|
185
|
+
return adbCommand;
|
|
186
|
+
}
|
|
187
|
+
function getMaestroCommandCandidates() {
|
|
188
|
+
const candidates = [];
|
|
189
|
+
const maestroHomePath = path.join(os.homedir(), ".maestro", "bin", "maestro");
|
|
190
|
+
if (fs.existsSync(maestroHomePath)) {
|
|
191
|
+
candidates.push(maestroHomePath);
|
|
192
|
+
}
|
|
193
|
+
const brewPaths = ["/opt/homebrew/bin/maestro", "/usr/local/bin/maestro"];
|
|
194
|
+
for (const brewPath of brewPaths) {
|
|
195
|
+
if (fs.existsSync(brewPath)) {
|
|
196
|
+
candidates.push(brewPath);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
candidates.push("maestro");
|
|
200
|
+
return candidates;
|
|
201
|
+
}
|
|
202
|
+
function getMaestroCommandCacheTtlMs(value) {
|
|
203
|
+
return value ? MAESTRO_COMMAND_CACHE_SUCCESS_TTL_MS : MAESTRO_COMMAND_CACHE_FAILURE_TTL_MS;
|
|
204
|
+
}
|
|
205
|
+
async function resolveMaestroCommand(options) {
|
|
206
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
if (!forceRefresh && maestroCommandCache) {
|
|
209
|
+
const ttlMs = getMaestroCommandCacheTtlMs(maestroCommandCache.value);
|
|
210
|
+
if (now - maestroCommandCache.checkedAt < ttlMs) {
|
|
211
|
+
return maestroCommandCache.value;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!forceRefresh && maestroCommandResolveInFlight) {
|
|
215
|
+
return maestroCommandResolveInFlight;
|
|
216
|
+
}
|
|
217
|
+
maestroCommandResolveInFlight = (async () => {
|
|
218
|
+
for (const candidate of getMaestroCommandCandidates()) {
|
|
219
|
+
try {
|
|
220
|
+
const cmd = quoteCommand2(candidate);
|
|
221
|
+
await execAsync(`${cmd} --version`);
|
|
222
|
+
maestroCommandCache = { value: candidate, checkedAt: Date.now() };
|
|
223
|
+
return candidate;
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
maestroCommandCache = { value: null, checkedAt: Date.now() };
|
|
228
|
+
return null;
|
|
229
|
+
})();
|
|
230
|
+
try {
|
|
231
|
+
return await maestroCommandResolveInFlight;
|
|
232
|
+
} finally {
|
|
233
|
+
maestroCommandResolveInFlight = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
var PENDING_SESSION_FILE = path.join(PROJECTS_DIR, ".maestro-pending-session.json");
|
|
237
|
+
async function isMaestroInstalled() {
|
|
238
|
+
try {
|
|
239
|
+
return !!await resolveMaestroCommand();
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function isIdbInstalled() {
|
|
245
|
+
try {
|
|
246
|
+
await execAsync("idb --version", { timeout: 3e3 });
|
|
247
|
+
return true;
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function tapViaIdb(deviceId, x, y) {
|
|
253
|
+
try {
|
|
254
|
+
await execAsync(`idb ui tap ${Math.round(x)} ${Math.round(y)} --udid ${deviceId}`, {
|
|
255
|
+
timeout: 5e3
|
|
256
|
+
});
|
|
257
|
+
return true;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error("[idb tap failed]", error instanceof Error ? error.message : error);
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function killZombieMaestroProcesses() {
|
|
264
|
+
try {
|
|
265
|
+
await execAsync('pkill -f "maestro test" || true', { timeout: 3e3 });
|
|
266
|
+
await execAsync('pkill -f "maestro record" || true', { timeout: 3e3 });
|
|
267
|
+
console.log("[MaestroCleanup] Killed zombie maestro processes");
|
|
268
|
+
} catch {
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async function getMaestroVersion() {
|
|
272
|
+
try {
|
|
273
|
+
const command = await resolveMaestroCommand();
|
|
274
|
+
if (!command) return null;
|
|
275
|
+
const cmd = quoteCommand2(command);
|
|
276
|
+
const { stdout } = await execAsync(`${cmd} --version`);
|
|
277
|
+
return stdout.trim();
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function listMaestroDevices(options) {
|
|
283
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
284
|
+
const ttlMs = Math.max(0, Number(options?.ttlMs ?? MAESTRO_DEVICE_CACHE_TTL_MS));
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
if (!forceRefresh && maestroDeviceListCache && now - maestroDeviceListCache.fetchedAt < ttlMs) {
|
|
287
|
+
return maestroDeviceListCache.devices;
|
|
288
|
+
}
|
|
289
|
+
if (!forceRefresh && maestroDeviceListInFlight) {
|
|
290
|
+
return maestroDeviceListInFlight;
|
|
291
|
+
}
|
|
292
|
+
maestroDeviceListInFlight = (async () => {
|
|
293
|
+
const devices = [];
|
|
294
|
+
try {
|
|
295
|
+
const { stdout: iosOutput } = await execAsync("xcrun simctl list devices -j");
|
|
296
|
+
const iosData = JSON.parse(iosOutput);
|
|
297
|
+
for (const [runtime, deviceList] of Object.entries(iosData.devices || {})) {
|
|
298
|
+
if (!Array.isArray(deviceList)) continue;
|
|
299
|
+
for (const device of deviceList) {
|
|
300
|
+
if (device.state === "Booted") {
|
|
301
|
+
devices.push({
|
|
302
|
+
id: device.udid,
|
|
303
|
+
name: device.name,
|
|
304
|
+
platform: "ios",
|
|
305
|
+
status: "connected"
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const adbCommand = getAdbCommand();
|
|
314
|
+
if (adbCommand) {
|
|
315
|
+
const { stdout: androidOutput } = await execAsync(`${quoteCommand2(adbCommand)} devices -l`);
|
|
316
|
+
const lines = androidOutput.split("\n").slice(1);
|
|
317
|
+
for (const line of lines) {
|
|
318
|
+
const match = line.match(/^(\S+)\s+device\s+(.*)$/);
|
|
319
|
+
if (match) {
|
|
320
|
+
const [, id, info] = match;
|
|
321
|
+
const modelMatch = info.match(/model:(\S+)/);
|
|
322
|
+
devices.push({
|
|
323
|
+
id,
|
|
324
|
+
name: modelMatch ? modelMatch[1] : id,
|
|
325
|
+
platform: "android",
|
|
326
|
+
status: "connected"
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
}
|
|
333
|
+
maestroDeviceListCache = { devices, fetchedAt: Date.now() };
|
|
334
|
+
return devices;
|
|
335
|
+
})();
|
|
336
|
+
try {
|
|
337
|
+
return await maestroDeviceListInFlight;
|
|
338
|
+
} finally {
|
|
339
|
+
maestroDeviceListInFlight = null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function generateMaestroFlow(flow) {
|
|
343
|
+
const lines = [];
|
|
344
|
+
lines.push(`appId: ${flow.appId}`);
|
|
345
|
+
lines.push("");
|
|
346
|
+
if (flow.name) {
|
|
347
|
+
lines.push(`name: ${flow.name}`);
|
|
348
|
+
lines.push("");
|
|
349
|
+
}
|
|
350
|
+
if (flow.env && Object.keys(flow.env).length > 0) {
|
|
351
|
+
lines.push("env:");
|
|
352
|
+
for (const [key, value] of Object.entries(flow.env)) {
|
|
353
|
+
lines.push(` ${key}: "${value}"`);
|
|
354
|
+
}
|
|
355
|
+
lines.push("");
|
|
356
|
+
}
|
|
357
|
+
if (flow.onFlowStart && flow.onFlowStart.length > 0) {
|
|
358
|
+
lines.push("onFlowStart:");
|
|
359
|
+
for (const step of flow.onFlowStart) {
|
|
360
|
+
lines.push(formatFlowStep(step, 2));
|
|
361
|
+
}
|
|
362
|
+
lines.push("");
|
|
363
|
+
}
|
|
364
|
+
if (flow.onFlowComplete && flow.onFlowComplete.length > 0) {
|
|
365
|
+
lines.push("onFlowComplete:");
|
|
366
|
+
for (const step of flow.onFlowComplete) {
|
|
367
|
+
lines.push(formatFlowStep(step, 2));
|
|
368
|
+
}
|
|
369
|
+
lines.push("");
|
|
370
|
+
}
|
|
371
|
+
lines.push("---");
|
|
372
|
+
for (const step of flow.steps) {
|
|
373
|
+
lines.push(formatFlowStep(step, 0));
|
|
374
|
+
}
|
|
375
|
+
return lines.join("\n");
|
|
376
|
+
}
|
|
377
|
+
function formatFlowStep(step, indent) {
|
|
378
|
+
const prefix = " ".repeat(indent);
|
|
379
|
+
const { action, params } = step;
|
|
380
|
+
if (!params || Object.keys(params).length === 0) {
|
|
381
|
+
return `${prefix}- ${action}`;
|
|
382
|
+
}
|
|
383
|
+
if (Object.keys(params).length === 1) {
|
|
384
|
+
const [key, value] = Object.entries(params)[0];
|
|
385
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
386
|
+
return `${prefix}- ${action}:
|
|
387
|
+
${prefix} ${key}: ${formatValue(value)}`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const lines = [`${prefix}- ${action}:`];
|
|
391
|
+
for (const [key, value] of Object.entries(params)) {
|
|
392
|
+
lines.push(`${prefix} ${key}: ${formatValue(value)}`);
|
|
393
|
+
}
|
|
394
|
+
return lines.join("\n");
|
|
395
|
+
}
|
|
396
|
+
function formatValue(value) {
|
|
397
|
+
if (typeof value === "string") {
|
|
398
|
+
return value.includes("\n") || value.includes(":") ? `"${value}"` : value;
|
|
399
|
+
}
|
|
400
|
+
return String(value);
|
|
401
|
+
}
|
|
402
|
+
var MaestroActions = {
|
|
403
|
+
// App lifecycle
|
|
404
|
+
launchApp: (appId) => ({ action: "launchApp", params: appId ? { appId } : void 0 }),
|
|
405
|
+
stopApp: (appId) => ({ action: "stopApp", params: appId ? { appId } : void 0 }),
|
|
406
|
+
clearState: (appId) => ({ action: "clearState", params: appId ? { appId } : void 0 }),
|
|
407
|
+
clearKeychain: () => ({ action: "clearKeychain" }),
|
|
408
|
+
// Navigation
|
|
409
|
+
tapOn: (text) => ({ action: "tapOn", params: { text } }),
|
|
410
|
+
tapOnId: (id) => ({ action: "tapOn", params: { id } }),
|
|
411
|
+
tapOnPoint: (x, y) => ({ action: "tapOn", params: { point: `${x},${y}` } }),
|
|
412
|
+
doubleTapOn: (text) => ({ action: "doubleTapOn", params: { text } }),
|
|
413
|
+
longPressOn: (text) => ({ action: "longPressOn", params: { text } }),
|
|
414
|
+
// Input
|
|
415
|
+
inputText: (text) => ({ action: "inputText", params: { text } }),
|
|
416
|
+
inputRandomText: () => ({ action: "inputRandomText" }),
|
|
417
|
+
inputRandomNumber: () => ({ action: "inputRandomNumber" }),
|
|
418
|
+
inputRandomEmail: () => ({ action: "inputRandomEmail" }),
|
|
419
|
+
eraseText: (chars) => ({ action: "eraseText", params: chars ? { charactersToErase: chars } : void 0 }),
|
|
420
|
+
// Gestures
|
|
421
|
+
scroll: () => ({ action: "scroll" }),
|
|
422
|
+
scrollDown: () => ({ action: "scrollUntilVisible", params: { direction: "DOWN" } }),
|
|
423
|
+
scrollUp: () => ({ action: "scrollUntilVisible", params: { direction: "UP" } }),
|
|
424
|
+
swipeLeft: () => ({ action: "swipe", params: { direction: "LEFT" } }),
|
|
425
|
+
swipeRight: () => ({ action: "swipe", params: { direction: "RIGHT" } }),
|
|
426
|
+
swipeDown: () => ({ action: "swipe", params: { direction: "DOWN" } }),
|
|
427
|
+
swipeUp: () => ({ action: "swipe", params: { direction: "UP" } }),
|
|
428
|
+
// Assertions
|
|
429
|
+
assertVisible: (text) => ({ action: "assertVisible", params: { text } }),
|
|
430
|
+
assertNotVisible: (text) => ({ action: "assertNotVisible", params: { text } }),
|
|
431
|
+
assertTrue: (condition) => ({ action: "assertTrue", params: { condition } }),
|
|
432
|
+
// Wait
|
|
433
|
+
waitForAnimationToEnd: (timeout) => ({
|
|
434
|
+
action: "waitForAnimationToEnd",
|
|
435
|
+
params: timeout ? { timeout } : void 0
|
|
436
|
+
}),
|
|
437
|
+
wait: (ms) => ({ action: "extendedWaitUntil", params: { timeout: ms } }),
|
|
438
|
+
// Screenshots
|
|
439
|
+
takeScreenshot: (path2) => ({ action: "takeScreenshot", params: { path: path2 } }),
|
|
440
|
+
// Keyboard
|
|
441
|
+
hideKeyboard: () => ({ action: "hideKeyboard" }),
|
|
442
|
+
pressKey: (key) => ({ action: "pressKey", params: { key } }),
|
|
443
|
+
back: () => ({ action: "back" }),
|
|
444
|
+
// Conditional
|
|
445
|
+
runFlow: (flowPath) => ({ action: "runFlow", params: { file: flowPath } }),
|
|
446
|
+
runScript: (script) => ({ action: "runScript", params: { script } }),
|
|
447
|
+
// Device
|
|
448
|
+
openLink: (url) => ({ action: "openLink", params: { link: url } }),
|
|
449
|
+
setLocation: (lat, lon) => ({ action: "setLocation", params: { latitude: lat, longitude: lon } }),
|
|
450
|
+
travel: (steps) => ({ action: "travel", params: { points: steps } })
|
|
451
|
+
};
|
|
452
|
+
async function runMaestroTest(options) {
|
|
453
|
+
const installed = await isMaestroInstalled();
|
|
454
|
+
if (!installed) {
|
|
455
|
+
return {
|
|
456
|
+
success: false,
|
|
457
|
+
error: 'Maestro CLI is not installed. Install with: curl -Ls "https://get.maestro.mobile.dev" | bash'
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const {
|
|
461
|
+
flowPath,
|
|
462
|
+
appId,
|
|
463
|
+
device,
|
|
464
|
+
env = {},
|
|
465
|
+
timeout = 3e5,
|
|
466
|
+
// 5 minutes default
|
|
467
|
+
captureVideo = false,
|
|
468
|
+
captureScreenshots = false,
|
|
469
|
+
outputDir = path.join(PROJECTS_DIR, "maestro-output", Date.now().toString())
|
|
470
|
+
} = options;
|
|
471
|
+
const command = await resolveMaestroCommand();
|
|
472
|
+
if (!command) {
|
|
473
|
+
return {
|
|
474
|
+
success: false,
|
|
475
|
+
error: 'Maestro CLI is not installed or not runnable. Install with: curl -Ls "https://get.maestro.mobile.dev" | bash'
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (!fs.existsSync(flowPath)) {
|
|
479
|
+
return { success: false, error: `Flow file not found: ${flowPath}` };
|
|
480
|
+
}
|
|
481
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
482
|
+
const startTime = Date.now();
|
|
483
|
+
const args = ["test", flowPath];
|
|
484
|
+
const maestroCmd = quoteCommand2(command);
|
|
485
|
+
if (device) {
|
|
486
|
+
args.push("--device", device);
|
|
487
|
+
}
|
|
488
|
+
for (const [key, value] of Object.entries(env)) {
|
|
489
|
+
args.push("-e", `${key}=${value}`);
|
|
490
|
+
}
|
|
491
|
+
args.push("--format", "junit");
|
|
492
|
+
args.push("--output", path.join(outputDir, "report.xml"));
|
|
493
|
+
try {
|
|
494
|
+
const shellArgs = args.map(shellQuoteArg2).join(" ");
|
|
495
|
+
const { stdout, stderr } = await execAsync(`${maestroCmd} ${shellArgs}`, {
|
|
496
|
+
timeout,
|
|
497
|
+
env: { ...process.env, ...env }
|
|
498
|
+
});
|
|
499
|
+
const duration = Date.now() - startTime;
|
|
500
|
+
const screenshots = [];
|
|
501
|
+
const videoPath = captureVideo ? path.join(outputDir, "recording.mp4") : void 0;
|
|
502
|
+
if (captureScreenshots) {
|
|
503
|
+
const files = await fs.promises.readdir(outputDir);
|
|
504
|
+
for (const file of files) {
|
|
505
|
+
if (file.endsWith(".png")) {
|
|
506
|
+
screenshots.push(path.join(outputDir, file));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
success: true,
|
|
512
|
+
duration,
|
|
513
|
+
flowPath,
|
|
514
|
+
output: stdout + stderr,
|
|
515
|
+
screenshots,
|
|
516
|
+
video: videoPath
|
|
517
|
+
};
|
|
518
|
+
} catch (error) {
|
|
519
|
+
const duration = Date.now() - startTime;
|
|
520
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
521
|
+
const errLike = error;
|
|
522
|
+
const stdout = typeof errLike?.stdout === "string" ? errLike.stdout : Buffer.isBuffer(errLike?.stdout) ? errLike.stdout.toString("utf8") : "";
|
|
523
|
+
const stderr = typeof errLike?.stderr === "string" ? errLike.stderr : Buffer.isBuffer(errLike?.stderr) ? errLike.stderr.toString("utf8") : "";
|
|
524
|
+
const detailedOutput = [stderr?.trim(), stdout?.trim(), message].filter(Boolean).join("\n\n").slice(-16e3);
|
|
525
|
+
return {
|
|
526
|
+
success: false,
|
|
527
|
+
error: stderr?.trim() || message,
|
|
528
|
+
duration,
|
|
529
|
+
flowPath,
|
|
530
|
+
output: detailedOutput || message
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async function runMaestroWithCapture(options, onProgress) {
|
|
535
|
+
const {
|
|
536
|
+
flowPath,
|
|
537
|
+
device,
|
|
538
|
+
outputDir = path.join(PROJECTS_DIR, "maestro-output", Date.now().toString())
|
|
539
|
+
} = options;
|
|
540
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
541
|
+
const videoPath = path.join(outputDir, "recording.mp4");
|
|
542
|
+
let recordingProcess = null;
|
|
543
|
+
try {
|
|
544
|
+
onProgress?.("Starting screen recording...");
|
|
545
|
+
if (device) {
|
|
546
|
+
const devices = await listMaestroDevices();
|
|
547
|
+
const targetDevice = devices.find((d) => d.id === device || d.name === device);
|
|
548
|
+
if (targetDevice?.platform === "ios") {
|
|
549
|
+
recordingProcess = spawn("xcrun", ["simctl", "io", device, "recordVideo", videoPath]);
|
|
550
|
+
} else if (targetDevice?.platform === "android") {
|
|
551
|
+
const adbCommand = getAdbCommandOrThrow();
|
|
552
|
+
recordingProcess = spawn(adbCommand, ["-s", device, "shell", "screenrecord", "/sdcard/recording.mp4"]);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
onProgress?.("Running Maestro test...");
|
|
556
|
+
const result = await runMaestroTest({ ...options, outputDir });
|
|
557
|
+
onProgress?.("Stopping screen recording...");
|
|
558
|
+
if (recordingProcess) {
|
|
559
|
+
recordingProcess.kill("SIGINT");
|
|
560
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
561
|
+
const devices = await listMaestroDevices();
|
|
562
|
+
const targetDevice = devices.find((d) => d.id === device || d.name === device);
|
|
563
|
+
if (targetDevice?.platform === "android") {
|
|
564
|
+
const adbCommand = getAdbCommandOrThrow();
|
|
565
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(device)} pull /sdcard/recording.mp4 "${videoPath}"`);
|
|
566
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(device)} shell rm /sdcard/recording.mp4`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
...result,
|
|
571
|
+
video: fs.existsSync(videoPath) ? videoPath : void 0
|
|
572
|
+
};
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (recordingProcess) {
|
|
575
|
+
recordingProcess.kill("SIGKILL");
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
success: false,
|
|
579
|
+
error: error instanceof Error ? error.message : String(error),
|
|
580
|
+
flowPath
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
async function startMaestroStudio(appId) {
|
|
585
|
+
const installed = await isMaestroInstalled();
|
|
586
|
+
if (!installed) {
|
|
587
|
+
return {
|
|
588
|
+
success: false,
|
|
589
|
+
error: "Maestro CLI is not installed"
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const args = ["studio"];
|
|
594
|
+
if (appId) {
|
|
595
|
+
args.push(appId);
|
|
596
|
+
}
|
|
597
|
+
const maestroCmd = await resolveMaestroCommand();
|
|
598
|
+
if (!maestroCmd) {
|
|
599
|
+
return { success: false, error: "Maestro CLI is not installed or not runnable" };
|
|
600
|
+
}
|
|
601
|
+
spawn(maestroCmd, args, {
|
|
602
|
+
detached: true,
|
|
603
|
+
stdio: "ignore"
|
|
604
|
+
}).unref();
|
|
605
|
+
return { success: true };
|
|
606
|
+
} catch (error) {
|
|
607
|
+
return {
|
|
608
|
+
success: false,
|
|
609
|
+
error: error instanceof Error ? error.message : String(error)
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function createLoginFlow(appId, usernameField, passwordField, loginButton, successIndicator) {
|
|
614
|
+
return {
|
|
615
|
+
appId,
|
|
616
|
+
name: "Login Flow",
|
|
617
|
+
steps: [
|
|
618
|
+
MaestroActions.launchApp(appId),
|
|
619
|
+
MaestroActions.waitForAnimationToEnd(),
|
|
620
|
+
MaestroActions.tapOn(usernameField),
|
|
621
|
+
MaestroActions.inputText("${USERNAME}"),
|
|
622
|
+
MaestroActions.tapOn(passwordField),
|
|
623
|
+
MaestroActions.inputText("${PASSWORD}"),
|
|
624
|
+
MaestroActions.hideKeyboard(),
|
|
625
|
+
MaestroActions.tapOn(loginButton),
|
|
626
|
+
MaestroActions.waitForAnimationToEnd(5e3),
|
|
627
|
+
MaestroActions.assertVisible(successIndicator)
|
|
628
|
+
]
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function createOnboardingFlow(appId, screens) {
|
|
632
|
+
const steps = [
|
|
633
|
+
MaestroActions.launchApp(appId),
|
|
634
|
+
MaestroActions.waitForAnimationToEnd()
|
|
635
|
+
];
|
|
636
|
+
for (const screen of screens) {
|
|
637
|
+
if (screen.waitFor) {
|
|
638
|
+
steps.push(MaestroActions.assertVisible(screen.waitFor));
|
|
639
|
+
}
|
|
640
|
+
steps.push(MaestroActions.tapOn(screen.nextButton));
|
|
641
|
+
steps.push(MaestroActions.waitForAnimationToEnd());
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
appId,
|
|
645
|
+
name: "Onboarding Flow",
|
|
646
|
+
steps
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function createNavigationTestFlow(appId, tabs) {
|
|
650
|
+
const steps = [
|
|
651
|
+
MaestroActions.launchApp(appId),
|
|
652
|
+
MaestroActions.waitForAnimationToEnd()
|
|
653
|
+
];
|
|
654
|
+
for (const tab of tabs) {
|
|
655
|
+
steps.push(MaestroActions.tapOn(tab));
|
|
656
|
+
steps.push(MaestroActions.waitForAnimationToEnd());
|
|
657
|
+
steps.push(MaestroActions.takeScreenshot(`${tab.replace(/\s+/g, "_")}.png`));
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
appId,
|
|
661
|
+
name: "Navigation Test",
|
|
662
|
+
steps
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
var MaestroRecorder = class extends EventEmitter {
|
|
666
|
+
session = null;
|
|
667
|
+
adbProcess = null;
|
|
668
|
+
videoProcess = null;
|
|
669
|
+
maestroRecordProcess = null;
|
|
670
|
+
// Native maestro record process
|
|
671
|
+
actionCounter = 0;
|
|
672
|
+
screenshotInterval = null;
|
|
673
|
+
useNativeMaestroRecord = true;
|
|
674
|
+
// Use native maestro record for better accuracy
|
|
675
|
+
legacyCaptureStarted = false;
|
|
676
|
+
lastMaestroRecordErrorLine = null;
|
|
677
|
+
baseScreenshotIntervalMs = 2e3;
|
|
678
|
+
maxScreenshotIntervalMs = 12e3;
|
|
679
|
+
currentScreenshotIntervalMs = this.baseScreenshotIntervalMs;
|
|
680
|
+
nextScreenshotAt = 0;
|
|
681
|
+
lastScreenshotHash = null;
|
|
682
|
+
unchangedScreenshotCount = 0;
|
|
683
|
+
screenshotBackoffThreshold = 3;
|
|
684
|
+
constructor() {
|
|
685
|
+
super();
|
|
686
|
+
this.loadPendingSession();
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Check if maestro is available in system PATH
|
|
690
|
+
*/
|
|
691
|
+
isMaestroInPath() {
|
|
692
|
+
try {
|
|
693
|
+
execSync2("which maestro", { encoding: "utf-8", timeout: 2e3 });
|
|
694
|
+
return true;
|
|
695
|
+
} catch {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
canRunMaestro(cmd) {
|
|
700
|
+
try {
|
|
701
|
+
execSync2(`"${cmd}" --version`, { encoding: "utf-8", timeout: 3e3 });
|
|
702
|
+
return true;
|
|
703
|
+
} catch {
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
startLegacyCapture(deviceId, platform) {
|
|
708
|
+
if (platform === "android") {
|
|
709
|
+
if (!this.adbProcess) {
|
|
710
|
+
this.startAndroidEventCapture(deviceId);
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
this.startIOSEventCapture(deviceId);
|
|
714
|
+
}
|
|
715
|
+
this.legacyCaptureStarted = true;
|
|
716
|
+
}
|
|
717
|
+
fallbackToLegacyCapture(reason) {
|
|
718
|
+
if (!this.session || this.session.status !== "recording") return;
|
|
719
|
+
if (this.legacyCaptureStarted) return;
|
|
720
|
+
console.log(`[MaestroRecorder] Native record stopped (${reason}). Falling back to manual capture.`);
|
|
721
|
+
this.session.captureMode = "manual";
|
|
722
|
+
const stderrSuffix = this.lastMaestroRecordErrorLine ? ` | ${this.lastMaestroRecordErrorLine}` : "";
|
|
723
|
+
this.session.captureModeReason = `maestro record falhou (${reason})${stderrSuffix}`;
|
|
724
|
+
this.startLegacyCapture(this.session.deviceId, this.session.platform);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Save session state to disk for recovery after server restart
|
|
728
|
+
*/
|
|
729
|
+
savePendingSession() {
|
|
730
|
+
if (!this.session) return;
|
|
731
|
+
try {
|
|
732
|
+
fs.writeFileSync(PENDING_SESSION_FILE, JSON.stringify(this.session, null, 2));
|
|
733
|
+
} catch (error) {
|
|
734
|
+
console.error("[MaestroRecorder] Failed to save pending session:", error);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Load pending session from disk (after server restart)
|
|
739
|
+
*/
|
|
740
|
+
loadPendingSession() {
|
|
741
|
+
try {
|
|
742
|
+
if (fs.existsSync(PENDING_SESSION_FILE)) {
|
|
743
|
+
const data = fs.readFileSync(PENDING_SESSION_FILE, "utf8");
|
|
744
|
+
const savedSession = JSON.parse(data);
|
|
745
|
+
if (savedSession.status === "recording") {
|
|
746
|
+
console.log("[MaestroRecorder] Restoring pending session from disk:", savedSession.id);
|
|
747
|
+
this.session = savedSession;
|
|
748
|
+
this.actionCounter = savedSession.actions.length;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
} catch (error) {
|
|
752
|
+
console.error("[MaestroRecorder] Failed to load pending session:", error);
|
|
753
|
+
try {
|
|
754
|
+
fs.unlinkSync(PENDING_SESSION_FILE);
|
|
755
|
+
} catch {
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Clear pending session file (after successful stop)
|
|
761
|
+
*/
|
|
762
|
+
clearPendingSession() {
|
|
763
|
+
try {
|
|
764
|
+
if (fs.existsSync(PENDING_SESSION_FILE)) {
|
|
765
|
+
fs.unlinkSync(PENDING_SESSION_FILE);
|
|
766
|
+
}
|
|
767
|
+
} catch (error) {
|
|
768
|
+
console.error("[MaestroRecorder] Failed to clear pending session:", error);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Persist session metadata to session.json (available during recording)
|
|
773
|
+
*/
|
|
774
|
+
async writeSessionMetadata() {
|
|
775
|
+
if (!this.session) return;
|
|
776
|
+
try {
|
|
777
|
+
const metadataPath = path.join(this.session.screenshotsDir, "..", "session.json");
|
|
778
|
+
await fs.promises.writeFile(metadataPath, JSON.stringify(this.session, null, 2));
|
|
779
|
+
} catch (error) {
|
|
780
|
+
console.error("[MaestroRecorder] Failed to write session metadata:", error);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Start recording a mobile session
|
|
785
|
+
*/
|
|
786
|
+
async startRecording(name, deviceId, deviceName, platform, appId, options = {}) {
|
|
787
|
+
if (this.session?.status === "recording") {
|
|
788
|
+
const hasActiveProcesses = !!(this.videoProcess || this.maestroRecordProcess || this.adbProcess);
|
|
789
|
+
if (hasActiveProcesses) {
|
|
790
|
+
throw new Error("Recording already in progress");
|
|
791
|
+
}
|
|
792
|
+
console.log("[MaestroRecorder] Stale recording session detected. Clearing before starting a new one.");
|
|
793
|
+
this.session = null;
|
|
794
|
+
if (this.screenshotInterval) {
|
|
795
|
+
clearInterval(this.screenshotInterval);
|
|
796
|
+
this.screenshotInterval = null;
|
|
797
|
+
}
|
|
798
|
+
this.clearPendingSession();
|
|
799
|
+
}
|
|
800
|
+
const sessionId = `maestro_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
801
|
+
const baseDir = path.join(PROJECTS_DIR, "maestro-recordings", sessionId);
|
|
802
|
+
const screenshotsDir = path.join(baseDir, "screenshots");
|
|
803
|
+
await fs.promises.mkdir(screenshotsDir, { recursive: true });
|
|
804
|
+
this.session = {
|
|
805
|
+
id: sessionId,
|
|
806
|
+
name,
|
|
807
|
+
startedAt: Date.now(),
|
|
808
|
+
appId,
|
|
809
|
+
deviceId,
|
|
810
|
+
deviceName,
|
|
811
|
+
platform,
|
|
812
|
+
actions: [],
|
|
813
|
+
screenshotsDir,
|
|
814
|
+
status: "recording"
|
|
815
|
+
};
|
|
816
|
+
this.actionCounter = 0;
|
|
817
|
+
try {
|
|
818
|
+
const adbCommand = platform === "android" ? getAdbCommandOrThrow() : null;
|
|
819
|
+
const flowPath = path.join(baseDir, "test.yaml");
|
|
820
|
+
this.session.flowPath = flowPath;
|
|
821
|
+
const videoPath = path.join(baseDir, "recording.mp4");
|
|
822
|
+
this.session.videoPath = videoPath;
|
|
823
|
+
const maestroHomePath = path.join(os.homedir(), ".maestro", "bin", "maestro");
|
|
824
|
+
const maestroPath = fs.existsSync(maestroHomePath) ? maestroHomePath : "maestro";
|
|
825
|
+
const maestroExists = fs.existsSync(maestroHomePath) || this.isMaestroInPath();
|
|
826
|
+
const maestroRunnable = maestroExists && this.canRunMaestro(maestroPath);
|
|
827
|
+
const preferNativeRecord = options.preferNativeRecord !== false;
|
|
828
|
+
const useNativeRecord = preferNativeRecord && this.useNativeMaestroRecord && maestroRunnable;
|
|
829
|
+
if (!maestroRunnable && this.useNativeMaestroRecord) {
|
|
830
|
+
console.log("[MaestroRecorder] \u26A0\uFE0F Maestro CLI not available or not runnable. Falling back to screenshot-based capture.");
|
|
831
|
+
console.log('[MaestroRecorder] Ensure Java is installed or reinstall Maestro: curl -Ls "https://get.maestro.mobile.dev" | bash');
|
|
832
|
+
this.session.captureModeReason = "Maestro CLI indispon\xEDvel/n\xE3o execut\xE1vel (verifique Java e reinstale o Maestro).";
|
|
833
|
+
}
|
|
834
|
+
if (!preferNativeRecord) {
|
|
835
|
+
console.log("[MaestroRecorder] Native record disabled for this session (manual capture mode).");
|
|
836
|
+
this.session.captureModeReason = "Native Maestro record desabilitado para esta sess\xE3o.";
|
|
837
|
+
}
|
|
838
|
+
let nativeRecordStarted = false;
|
|
839
|
+
this.legacyCaptureStarted = false;
|
|
840
|
+
if (useNativeRecord) {
|
|
841
|
+
console.log("[MaestroRecorder] Starting native maestro record...");
|
|
842
|
+
this.lastMaestroRecordErrorLine = null;
|
|
843
|
+
try {
|
|
844
|
+
const recordArgs = ["record", flowPath, "--device", deviceId];
|
|
845
|
+
this.maestroRecordProcess = spawn(maestroPath, recordArgs, {
|
|
846
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
847
|
+
env: { ...process.env }
|
|
848
|
+
});
|
|
849
|
+
nativeRecordStarted = true;
|
|
850
|
+
this.maestroRecordProcess.stdout?.on("data", (data) => {
|
|
851
|
+
console.log("[MaestroRecord]", data.toString().trim());
|
|
852
|
+
});
|
|
853
|
+
this.maestroRecordProcess.stderr?.on("data", (data) => {
|
|
854
|
+
const text = data.toString().trim();
|
|
855
|
+
console.log("[MaestroRecord ERROR]", text);
|
|
856
|
+
const lastLine = text.split("\n").map((line) => line.trim()).filter(Boolean).pop();
|
|
857
|
+
if (lastLine) {
|
|
858
|
+
this.lastMaestroRecordErrorLine = lastLine;
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
this.maestroRecordProcess.on("error", (err) => {
|
|
862
|
+
console.log("[MaestroRecord] Spawn error:", err.message);
|
|
863
|
+
this.fallbackToLegacyCapture("spawn error");
|
|
864
|
+
this.maestroRecordProcess = null;
|
|
865
|
+
});
|
|
866
|
+
this.maestroRecordProcess.on("close", (code) => {
|
|
867
|
+
console.log(`[MaestroRecord] Process exited with code ${code}`);
|
|
868
|
+
this.fallbackToLegacyCapture(`exit code ${code}`);
|
|
869
|
+
this.maestroRecordProcess = null;
|
|
870
|
+
});
|
|
871
|
+
} catch (err) {
|
|
872
|
+
console.log("[MaestroRecorder] Failed to start native record, using legacy capture");
|
|
873
|
+
this.maestroRecordProcess = null;
|
|
874
|
+
nativeRecordStarted = false;
|
|
875
|
+
this.session.captureModeReason = err instanceof Error ? `Falha ao iniciar maestro record: ${err.message}` : "Falha ao iniciar maestro record";
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
this.session.captureMode = nativeRecordStarted ? "native" : "manual";
|
|
879
|
+
if (nativeRecordStarted) {
|
|
880
|
+
this.session.captureModeReason = void 0;
|
|
881
|
+
}
|
|
882
|
+
if (!nativeRecordStarted && this.session.flowPath) {
|
|
883
|
+
try {
|
|
884
|
+
const initialYaml = this.generateFlowYaml();
|
|
885
|
+
await fs.promises.writeFile(this.session.flowPath, initialYaml);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
console.error("[MaestroRecorder] Failed to write initial flow YAML:", error);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
await this.writeSessionMetadata();
|
|
891
|
+
if (nativeRecordStarted) {
|
|
892
|
+
if (platform === "ios") {
|
|
893
|
+
this.videoProcess = spawn("xcrun", ["simctl", "io", deviceId, "recordVideo", videoPath]);
|
|
894
|
+
} else {
|
|
895
|
+
const tempVideoPath = "/sdcard/maestro-recording.mp4";
|
|
896
|
+
this.videoProcess = spawn(adbCommand, ["-s", deviceId, "shell", "screenrecord", "--time-limit", "180", tempVideoPath]);
|
|
897
|
+
}
|
|
898
|
+
} else {
|
|
899
|
+
if (useNativeRecord) {
|
|
900
|
+
console.log("[MaestroRecorder] Native record failed to start, using legacy capture");
|
|
901
|
+
}
|
|
902
|
+
if (platform === "android") {
|
|
903
|
+
const tempVideoPath = "/sdcard/maestro-recording.mp4";
|
|
904
|
+
this.videoProcess = spawn(adbCommand, ["-s", deviceId, "shell", "screenrecord", "--time-limit", "180", tempVideoPath]);
|
|
905
|
+
this.startLegacyCapture(deviceId, platform);
|
|
906
|
+
} else {
|
|
907
|
+
this.videoProcess = spawn("xcrun", ["simctl", "io", deviceId, "recordVideo", videoPath]);
|
|
908
|
+
this.startLegacyCapture(deviceId, platform);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
this.currentScreenshotIntervalMs = this.baseScreenshotIntervalMs;
|
|
912
|
+
this.nextScreenshotAt = 0;
|
|
913
|
+
this.lastScreenshotHash = null;
|
|
914
|
+
this.unchangedScreenshotCount = 0;
|
|
915
|
+
this.screenshotInterval = setInterval(() => {
|
|
916
|
+
this.captureScreenshot(void 0, { reason: "periodic" });
|
|
917
|
+
}, 2e3);
|
|
918
|
+
this.savePendingSession();
|
|
919
|
+
this.emit("status", "recording");
|
|
920
|
+
return this.session;
|
|
921
|
+
} catch (error) {
|
|
922
|
+
this.session.status = "error";
|
|
923
|
+
this.clearPendingSession();
|
|
924
|
+
this.emit("error", error);
|
|
925
|
+
throw error;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Capture Android touch events via ADB
|
|
930
|
+
*/
|
|
931
|
+
startAndroidEventCapture(deviceId) {
|
|
932
|
+
const adbCommand = getAdbCommand();
|
|
933
|
+
if (!adbCommand) {
|
|
934
|
+
console.error("[MaestroRecorder] ADB not found. Android event capture is unavailable.");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
exec(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(deviceId)} shell wm size`, (err, stdout) => {
|
|
938
|
+
if (err) return;
|
|
939
|
+
const match = stdout.match(/(\d+)x(\d+)/);
|
|
940
|
+
const screenWidth = match ? parseInt(match[1]) : 1080;
|
|
941
|
+
const screenHeight = match ? parseInt(match[2]) : 1920;
|
|
942
|
+
this.adbProcess = spawn(adbCommand, ["-s", deviceId, "shell", "getevent", "-lt"]);
|
|
943
|
+
let touchStartTime = 0;
|
|
944
|
+
let touchStartX = 0;
|
|
945
|
+
let touchStartY = 0;
|
|
946
|
+
let currentX = 0;
|
|
947
|
+
let currentY = 0;
|
|
948
|
+
let isTracking = false;
|
|
949
|
+
this.adbProcess.stdout?.on("data", (data) => {
|
|
950
|
+
const lines = data.toString().split("\n");
|
|
951
|
+
for (const line of lines) {
|
|
952
|
+
if (line.includes("ABS_MT_POSITION_X")) {
|
|
953
|
+
const match2 = line.match(/ABS_MT_POSITION_X\s+([0-9a-f]+)/i);
|
|
954
|
+
if (match2) {
|
|
955
|
+
currentX = Math.round(parseInt(match2[1], 16) / 32767 * screenWidth);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if (line.includes("ABS_MT_POSITION_Y")) {
|
|
959
|
+
const match2 = line.match(/ABS_MT_POSITION_Y\s+([0-9a-f]+)/i);
|
|
960
|
+
if (match2) {
|
|
961
|
+
currentY = Math.round(parseInt(match2[1], 16) / 32767 * screenHeight);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (line.includes("BTN_TOUCH") && line.includes("DOWN")) {
|
|
965
|
+
touchStartTime = Date.now();
|
|
966
|
+
touchStartX = currentX;
|
|
967
|
+
touchStartY = currentY;
|
|
968
|
+
isTracking = true;
|
|
969
|
+
}
|
|
970
|
+
if (line.includes("BTN_TOUCH") && line.includes("UP") && isTracking) {
|
|
971
|
+
const duration = Date.now() - touchStartTime;
|
|
972
|
+
const deltaX = Math.abs(currentX - touchStartX);
|
|
973
|
+
const deltaY = Math.abs(currentY - touchStartY);
|
|
974
|
+
if (deltaX > 50 || deltaY > 50) {
|
|
975
|
+
this.recordAction({
|
|
976
|
+
type: "swipe",
|
|
977
|
+
x: touchStartX,
|
|
978
|
+
y: touchStartY,
|
|
979
|
+
endX: currentX,
|
|
980
|
+
endY: currentY,
|
|
981
|
+
duration,
|
|
982
|
+
description: `Swipe from (${touchStartX}, ${touchStartY}) to (${currentX}, ${currentY})`
|
|
983
|
+
});
|
|
984
|
+
} else if (duration > 500) {
|
|
985
|
+
this.recordAction({
|
|
986
|
+
type: "longPress",
|
|
987
|
+
x: touchStartX,
|
|
988
|
+
y: touchStartY,
|
|
989
|
+
duration,
|
|
990
|
+
description: `Long press at (${touchStartX}, ${touchStartY})`
|
|
991
|
+
});
|
|
992
|
+
} else {
|
|
993
|
+
this.recordAction({
|
|
994
|
+
type: "tap",
|
|
995
|
+
x: touchStartX,
|
|
996
|
+
y: touchStartY,
|
|
997
|
+
description: `Tap at (${touchStartX}, ${touchStartY})`
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
isTracking = false;
|
|
1001
|
+
}
|
|
1002
|
+
if (line.includes("KEY_BACK") && line.includes("DOWN")) {
|
|
1003
|
+
this.recordAction({
|
|
1004
|
+
type: "back",
|
|
1005
|
+
description: "Press back button"
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
if (line.includes("KEY_HOME") && line.includes("DOWN")) {
|
|
1009
|
+
this.recordAction({
|
|
1010
|
+
type: "home",
|
|
1011
|
+
description: "Press home button"
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Capture iOS events (limited - mainly screenshots)
|
|
1020
|
+
*/
|
|
1021
|
+
startIOSEventCapture(deviceId) {
|
|
1022
|
+
console.log("[MaestroRecorder] iOS event capture started (screenshot-based)");
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Record an action
|
|
1026
|
+
*/
|
|
1027
|
+
async recordAction(actionData) {
|
|
1028
|
+
if (!this.session || this.session.status !== "recording") return;
|
|
1029
|
+
this.actionCounter++;
|
|
1030
|
+
const actionId = `action_${this.actionCounter.toString().padStart(3, "0")}`;
|
|
1031
|
+
const screenshotPath = await this.captureScreenshot(actionId, { force: true, reason: "action" });
|
|
1032
|
+
const action = {
|
|
1033
|
+
id: actionId,
|
|
1034
|
+
type: actionData.type || "tap",
|
|
1035
|
+
timestamp: Date.now(),
|
|
1036
|
+
x: actionData.x,
|
|
1037
|
+
y: actionData.y,
|
|
1038
|
+
endX: actionData.endX,
|
|
1039
|
+
endY: actionData.endY,
|
|
1040
|
+
text: actionData.text,
|
|
1041
|
+
direction: actionData.direction,
|
|
1042
|
+
seconds: actionData.seconds,
|
|
1043
|
+
appId: actionData.appId,
|
|
1044
|
+
duration: actionData.duration,
|
|
1045
|
+
description: actionData.description || actionData.type || "Action",
|
|
1046
|
+
screenshotPath
|
|
1047
|
+
};
|
|
1048
|
+
this.session.actions.push(action);
|
|
1049
|
+
this.emit("action", action);
|
|
1050
|
+
console.log("[MaestroRecorder] Action captured:", action.type, action.description);
|
|
1051
|
+
if (action.type === "launch") {
|
|
1052
|
+
const launchAppId = action.appId || action.text;
|
|
1053
|
+
if (launchAppId && !this.session.appId) {
|
|
1054
|
+
this.session.appId = launchAppId;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
const shouldWriteFlow = this.session.captureMode !== "native" || !this.maestroRecordProcess;
|
|
1058
|
+
if (shouldWriteFlow && this.session.flowPath) {
|
|
1059
|
+
try {
|
|
1060
|
+
const flowYaml = this.generateFlowYaml();
|
|
1061
|
+
await fs.promises.writeFile(this.session.flowPath, flowYaml);
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
console.error("[MaestroRecorder] Failed to update flow YAML during recording:", error);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
await this.writeSessionMetadata();
|
|
1067
|
+
this.savePendingSession();
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Capture a screenshot
|
|
1071
|
+
*/
|
|
1072
|
+
async captureScreenshot(name, options = {}) {
|
|
1073
|
+
if (!this.session) return void 0;
|
|
1074
|
+
const now = Date.now();
|
|
1075
|
+
if (!options.force && now < this.nextScreenshotAt) {
|
|
1076
|
+
return void 0;
|
|
1077
|
+
}
|
|
1078
|
+
const screenshotName = name ? `${name}.png` : `screenshot_${Date.now()}.png`;
|
|
1079
|
+
const screenshotPath = path.join(this.session.screenshotsDir, screenshotName);
|
|
1080
|
+
try {
|
|
1081
|
+
if (this.session.platform === "android") {
|
|
1082
|
+
const adbCommand = getAdbCommandOrThrow();
|
|
1083
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(this.session.deviceId)} exec-out screencap -p > "${screenshotPath}"`);
|
|
1084
|
+
} else {
|
|
1085
|
+
await execAsync(`xcrun simctl io ${this.session.deviceId} screenshot "${screenshotPath}"`);
|
|
1086
|
+
}
|
|
1087
|
+
const screenshotBuffer = await fs.promises.readFile(screenshotPath);
|
|
1088
|
+
const screenshotHash = createHash("sha1").update(screenshotBuffer).digest("hex");
|
|
1089
|
+
if (!options.force && this.lastScreenshotHash === screenshotHash) {
|
|
1090
|
+
this.unchangedScreenshotCount += 1;
|
|
1091
|
+
await fs.promises.unlink(screenshotPath);
|
|
1092
|
+
if (this.unchangedScreenshotCount >= this.screenshotBackoffThreshold) {
|
|
1093
|
+
this.currentScreenshotIntervalMs = Math.min(
|
|
1094
|
+
this.currentScreenshotIntervalMs * 2,
|
|
1095
|
+
this.maxScreenshotIntervalMs
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
this.nextScreenshotAt = now + this.currentScreenshotIntervalMs;
|
|
1099
|
+
return void 0;
|
|
1100
|
+
}
|
|
1101
|
+
this.lastScreenshotHash = screenshotHash;
|
|
1102
|
+
this.unchangedScreenshotCount = 0;
|
|
1103
|
+
this.currentScreenshotIntervalMs = this.baseScreenshotIntervalMs;
|
|
1104
|
+
this.nextScreenshotAt = now + this.currentScreenshotIntervalMs;
|
|
1105
|
+
this.emit("screenshot", screenshotPath);
|
|
1106
|
+
return screenshotPath;
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
console.error("Screenshot failed:", error);
|
|
1109
|
+
return void 0;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Add a manual action (for iOS or user-initiated)
|
|
1114
|
+
*/
|
|
1115
|
+
addManualAction(type, description, params) {
|
|
1116
|
+
return this.recordAction({
|
|
1117
|
+
type,
|
|
1118
|
+
description,
|
|
1119
|
+
...params
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Stop recording and generate outputs
|
|
1124
|
+
*/
|
|
1125
|
+
async stopRecording() {
|
|
1126
|
+
if (!this.session) throw new Error("No recording session");
|
|
1127
|
+
this.session.status = "stopped";
|
|
1128
|
+
this.session.endedAt = Date.now();
|
|
1129
|
+
await killZombieMaestroProcesses();
|
|
1130
|
+
if (this.adbProcess) {
|
|
1131
|
+
this.adbProcess.kill();
|
|
1132
|
+
this.adbProcess = null;
|
|
1133
|
+
}
|
|
1134
|
+
if (this.screenshotInterval) {
|
|
1135
|
+
clearInterval(this.screenshotInterval);
|
|
1136
|
+
this.screenshotInterval = null;
|
|
1137
|
+
}
|
|
1138
|
+
if (this.maestroRecordProcess) {
|
|
1139
|
+
console.log("[MaestroRecorder] Stopping native maestro record...");
|
|
1140
|
+
this.maestroRecordProcess.kill("SIGINT");
|
|
1141
|
+
await new Promise((resolve) => {
|
|
1142
|
+
const timeout = setTimeout(() => {
|
|
1143
|
+
console.log("[MaestroRecorder] Timeout waiting for maestro record, forcing kill");
|
|
1144
|
+
this.maestroRecordProcess?.kill("SIGKILL");
|
|
1145
|
+
resolve();
|
|
1146
|
+
}, 5e3);
|
|
1147
|
+
this.maestroRecordProcess?.on("close", () => {
|
|
1148
|
+
clearTimeout(timeout);
|
|
1149
|
+
resolve();
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
this.maestroRecordProcess = null;
|
|
1153
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
1154
|
+
const recordedActions = this.session.actions;
|
|
1155
|
+
let parsedActions = [];
|
|
1156
|
+
if (this.session.flowPath && fs.existsSync(this.session.flowPath)) {
|
|
1157
|
+
console.log("[MaestroRecorder] Reading generated YAML from:", this.session.flowPath);
|
|
1158
|
+
try {
|
|
1159
|
+
const yamlContent = await fs.promises.readFile(this.session.flowPath, "utf-8");
|
|
1160
|
+
parsedActions = this.parseActionsFromYaml(yamlContent);
|
|
1161
|
+
console.log(`[MaestroRecorder] Parsed ${parsedActions.length} actions from YAML`);
|
|
1162
|
+
this.session.actions = this.mergeParsedActionsWithRecorded(parsedActions, recordedActions);
|
|
1163
|
+
} catch (e) {
|
|
1164
|
+
console.error("[MaestroRecorder] Failed to read/parse YAML:", e);
|
|
1165
|
+
}
|
|
1166
|
+
} else {
|
|
1167
|
+
console.log("[MaestroRecorder] No YAML file found at:", this.session.flowPath);
|
|
1168
|
+
}
|
|
1169
|
+
const flowPath = this.session.flowPath || path.join(this.session.screenshotsDir, "..", "test.yaml");
|
|
1170
|
+
if ((parsedActions.length === 0 || !fs.existsSync(flowPath)) && recordedActions.length > 0) {
|
|
1171
|
+
const flowYaml = this.generateFlowYaml();
|
|
1172
|
+
await fs.promises.writeFile(flowPath, flowYaml);
|
|
1173
|
+
this.session.flowPath = flowPath;
|
|
1174
|
+
this.session.actions = recordedActions;
|
|
1175
|
+
console.log("[MaestroRecorder] Fallback YAML generated from recorded actions");
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (this.videoProcess) {
|
|
1179
|
+
this.videoProcess.kill("SIGINT");
|
|
1180
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
1181
|
+
if (this.session.platform === "android" && this.session.videoPath) {
|
|
1182
|
+
try {
|
|
1183
|
+
const adbCommand = getAdbCommandOrThrow();
|
|
1184
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(this.session.deviceId)} pull /sdcard/maestro-recording.mp4 "${this.session.videoPath}"`);
|
|
1185
|
+
await execAsync(`${quoteCommand2(adbCommand)} -s ${shellQuoteArg2(this.session.deviceId)} shell rm /sdcard/maestro-recording.mp4`);
|
|
1186
|
+
} catch (e) {
|
|
1187
|
+
console.error("Failed to pull video:", e);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
this.videoProcess = null;
|
|
1191
|
+
}
|
|
1192
|
+
if (!this.session.flowPath || !fs.existsSync(this.session.flowPath)) {
|
|
1193
|
+
if (this.session.actions.length > 0) {
|
|
1194
|
+
const flowYaml = this.generateFlowYaml();
|
|
1195
|
+
const fallbackFlowPath = path.join(this.session.screenshotsDir, "..", "test.yaml");
|
|
1196
|
+
await fs.promises.writeFile(fallbackFlowPath, flowYaml);
|
|
1197
|
+
this.session.flowPath = fallbackFlowPath;
|
|
1198
|
+
console.log("[MaestroRecorder] Generated flow YAML from recorded actions");
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
const metadataPath = path.join(this.session.screenshotsDir, "..", "session.json");
|
|
1202
|
+
await fs.promises.writeFile(metadataPath, JSON.stringify(this.session, null, 2));
|
|
1203
|
+
this.clearPendingSession();
|
|
1204
|
+
this.emit("status", "stopped");
|
|
1205
|
+
this.emit("stopped", this.session);
|
|
1206
|
+
const result = this.session;
|
|
1207
|
+
this.session = null;
|
|
1208
|
+
return result;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Parse actions from Maestro YAML content
|
|
1212
|
+
*/
|
|
1213
|
+
parseActionsFromYaml(yamlContent) {
|
|
1214
|
+
const actions = [];
|
|
1215
|
+
const lines = yamlContent.split("\n");
|
|
1216
|
+
let currentAction = null;
|
|
1217
|
+
let actionIndex = 0;
|
|
1218
|
+
const nextId = () => `action_${String(++actionIndex).padStart(3, "0")}`;
|
|
1219
|
+
const cleanValue = (value) => value.trim().replace(/^["']|["']$/g, "");
|
|
1220
|
+
const pushAction = (action) => {
|
|
1221
|
+
actions.push({
|
|
1222
|
+
id: action.id || nextId(),
|
|
1223
|
+
type: action.type || "tap",
|
|
1224
|
+
timestamp: action.timestamp || Date.now(),
|
|
1225
|
+
description: action.description || action.type || "Action",
|
|
1226
|
+
x: action.x,
|
|
1227
|
+
y: action.y,
|
|
1228
|
+
endX: action.endX,
|
|
1229
|
+
endY: action.endY,
|
|
1230
|
+
text: action.text,
|
|
1231
|
+
direction: action.direction,
|
|
1232
|
+
seconds: action.seconds,
|
|
1233
|
+
appId: action.appId,
|
|
1234
|
+
duration: action.duration,
|
|
1235
|
+
screenshotPath: action.screenshotPath
|
|
1236
|
+
});
|
|
1237
|
+
};
|
|
1238
|
+
for (const line of lines) {
|
|
1239
|
+
const trimmed = line.trim();
|
|
1240
|
+
if (trimmed.startsWith("#") || trimmed === "" || trimmed === "---") {
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
if (trimmed.startsWith("appId:")) {
|
|
1244
|
+
const parsedAppId = cleanValue(trimmed.replace("appId:", ""));
|
|
1245
|
+
if (this.session && parsedAppId && !this.session.appId) {
|
|
1246
|
+
this.session.appId = parsedAppId;
|
|
1247
|
+
}
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if (trimmed.startsWith("- tapOn:")) {
|
|
1251
|
+
const inlineValue = cleanValue(trimmed.replace("- tapOn:", ""));
|
|
1252
|
+
if (inlineValue) {
|
|
1253
|
+
pushAction({
|
|
1254
|
+
type: "tap",
|
|
1255
|
+
description: `Tap on "${inlineValue}"`,
|
|
1256
|
+
text: inlineValue
|
|
1257
|
+
});
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
currentAction = { type: "tap", description: "Tap" };
|
|
1261
|
+
} else if (trimmed.startsWith("- tap:")) {
|
|
1262
|
+
currentAction = { type: "tap", description: "Tap" };
|
|
1263
|
+
} else if (trimmed.startsWith("- swipe:")) {
|
|
1264
|
+
currentAction = { type: "swipe", description: "Swipe" };
|
|
1265
|
+
} else if (trimmed.startsWith("- scroll:")) {
|
|
1266
|
+
currentAction = { type: "scroll", description: "Scroll" };
|
|
1267
|
+
} else if (trimmed.startsWith("- scrollUntilVisible:")) {
|
|
1268
|
+
currentAction = { type: "scroll", description: "Scroll" };
|
|
1269
|
+
} else if (trimmed.startsWith("- inputText:")) {
|
|
1270
|
+
const text = cleanValue(trimmed.replace("- inputText:", ""));
|
|
1271
|
+
pushAction({
|
|
1272
|
+
type: "input",
|
|
1273
|
+
description: `Input: ${text.slice(0, 20)}${text.length > 20 ? "..." : ""}`,
|
|
1274
|
+
text
|
|
1275
|
+
});
|
|
1276
|
+
continue;
|
|
1277
|
+
} else if (trimmed.startsWith("- launchApp:")) {
|
|
1278
|
+
const appId = cleanValue(trimmed.replace("- launchApp:", ""));
|
|
1279
|
+
pushAction({
|
|
1280
|
+
type: "launch",
|
|
1281
|
+
description: `Launch: ${appId}`,
|
|
1282
|
+
text: appId,
|
|
1283
|
+
appId
|
|
1284
|
+
});
|
|
1285
|
+
continue;
|
|
1286
|
+
} else if (trimmed === "- launchApp") {
|
|
1287
|
+
pushAction({
|
|
1288
|
+
type: "launch",
|
|
1289
|
+
description: "Launch app"
|
|
1290
|
+
});
|
|
1291
|
+
continue;
|
|
1292
|
+
} else if (trimmed.startsWith("- assertVisible:")) {
|
|
1293
|
+
const inlineValue = cleanValue(trimmed.replace("- assertVisible:", ""));
|
|
1294
|
+
if (inlineValue) {
|
|
1295
|
+
pushAction({
|
|
1296
|
+
type: "assert",
|
|
1297
|
+
description: `Assert visible "${inlineValue}"`,
|
|
1298
|
+
text: inlineValue
|
|
1299
|
+
});
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
currentAction = { type: "assert", description: "Assert visible" };
|
|
1303
|
+
} else if (trimmed.startsWith("- extendedWaitUntil:")) {
|
|
1304
|
+
currentAction = { type: "wait", description: "Wait" };
|
|
1305
|
+
} else if (trimmed.startsWith("- pressKey:")) {
|
|
1306
|
+
const key = cleanValue(trimmed.replace("- pressKey:", "")).toLowerCase();
|
|
1307
|
+
if (key === "back") {
|
|
1308
|
+
pushAction({ type: "back", description: "Back button" });
|
|
1309
|
+
} else if (key === "home") {
|
|
1310
|
+
pushAction({ type: "home", description: "Home button" });
|
|
1311
|
+
} else {
|
|
1312
|
+
pushAction({
|
|
1313
|
+
type: "pressKey",
|
|
1314
|
+
description: `Press: ${key}`,
|
|
1315
|
+
text: key
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
continue;
|
|
1319
|
+
} else if (trimmed.startsWith("- back")) {
|
|
1320
|
+
pushAction({ type: "back", description: "Back button" });
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
if (currentAction && trimmed.startsWith("point:")) {
|
|
1324
|
+
const point = cleanValue(trimmed.replace("point:", ""));
|
|
1325
|
+
const [x, y] = point.split(",").map((n) => parseInt(n.trim()));
|
|
1326
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
1327
|
+
currentAction.x = x;
|
|
1328
|
+
currentAction.y = y;
|
|
1329
|
+
currentAction.description = `Tap at (${x}, ${y})`;
|
|
1330
|
+
pushAction(currentAction);
|
|
1331
|
+
currentAction = null;
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (currentAction && trimmed.startsWith("text:")) {
|
|
1336
|
+
const text = cleanValue(trimmed.replace("text:", ""));
|
|
1337
|
+
currentAction.text = text;
|
|
1338
|
+
if (currentAction.type === "assert") {
|
|
1339
|
+
currentAction.description = `Assert visible "${text}"`;
|
|
1340
|
+
} else {
|
|
1341
|
+
currentAction.description = `Tap on "${text}"`;
|
|
1342
|
+
}
|
|
1343
|
+
pushAction(currentAction);
|
|
1344
|
+
currentAction = null;
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
if (currentAction && trimmed.startsWith("id:")) {
|
|
1348
|
+
const id = cleanValue(trimmed.replace("id:", ""));
|
|
1349
|
+
currentAction.text = id;
|
|
1350
|
+
currentAction.description = `Tap on id: ${id}`;
|
|
1351
|
+
pushAction(currentAction);
|
|
1352
|
+
currentAction = null;
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
if (currentAction && (currentAction.type === "swipe" || currentAction.type === "scroll") && trimmed.startsWith("direction:")) {
|
|
1356
|
+
const direction = cleanValue(trimmed.replace("direction:", "")).toLowerCase();
|
|
1357
|
+
currentAction.direction = direction;
|
|
1358
|
+
currentAction.description = currentAction.type === "swipe" ? `Swipe ${direction}` : `Scroll ${direction}`;
|
|
1359
|
+
pushAction(currentAction);
|
|
1360
|
+
currentAction = null;
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
if (currentAction && currentAction.type === "wait" && trimmed.startsWith("timeout:")) {
|
|
1364
|
+
const timeoutMs = parseInt(cleanValue(trimmed.replace("timeout:", "")), 10);
|
|
1365
|
+
const seconds = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.max(1, Math.round(timeoutMs / 1e3)) : 3;
|
|
1366
|
+
currentAction.seconds = seconds;
|
|
1367
|
+
currentAction.description = `Wait ${seconds}s`;
|
|
1368
|
+
pushAction(currentAction);
|
|
1369
|
+
currentAction = null;
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
if (currentAction && currentAction.type === "swipe") {
|
|
1373
|
+
if (trimmed.startsWith("start:")) {
|
|
1374
|
+
const point = cleanValue(trimmed.replace("start:", ""));
|
|
1375
|
+
const [x, y] = point.split(",").map((n) => parseInt(n.trim()));
|
|
1376
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
1377
|
+
currentAction.x = x;
|
|
1378
|
+
currentAction.y = y;
|
|
1379
|
+
}
|
|
1380
|
+
} else if (trimmed.startsWith("end:")) {
|
|
1381
|
+
const point = cleanValue(trimmed.replace("end:", ""));
|
|
1382
|
+
const [x, y] = point.split(",").map((n) => parseInt(n.trim()));
|
|
1383
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
1384
|
+
currentAction.endX = x;
|
|
1385
|
+
currentAction.endY = y;
|
|
1386
|
+
currentAction.description = `Swipe from (${currentAction.x}, ${currentAction.y}) to (${x}, ${y})`;
|
|
1387
|
+
pushAction(currentAction);
|
|
1388
|
+
currentAction = null;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return actions;
|
|
1394
|
+
}
|
|
1395
|
+
mergeParsedActionsWithRecorded(parsedActions, recordedActions) {
|
|
1396
|
+
if (parsedActions.length === 0) return recordedActions;
|
|
1397
|
+
if (recordedActions.length === 0) return parsedActions;
|
|
1398
|
+
const recordedWithScreens = recordedActions.filter((action) => action.screenshotPath);
|
|
1399
|
+
if (recordedWithScreens.length === 0) return parsedActions;
|
|
1400
|
+
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";
|
|
1401
|
+
let recordedIndex = 0;
|
|
1402
|
+
return parsedActions.map((action) => {
|
|
1403
|
+
if (!shouldMapScreenshot(action) || recordedIndex >= recordedWithScreens.length) {
|
|
1404
|
+
return action;
|
|
1405
|
+
}
|
|
1406
|
+
const recorded = recordedWithScreens[recordedIndex++];
|
|
1407
|
+
return {
|
|
1408
|
+
...recorded,
|
|
1409
|
+
...action,
|
|
1410
|
+
id: recorded.id || action.id,
|
|
1411
|
+
screenshotPath: recorded.screenshotPath,
|
|
1412
|
+
x: action.x ?? recorded.x,
|
|
1413
|
+
y: action.y ?? recorded.y,
|
|
1414
|
+
endX: action.endX ?? recorded.endX,
|
|
1415
|
+
endY: action.endY ?? recorded.endY,
|
|
1416
|
+
text: action.text ?? recorded.text,
|
|
1417
|
+
direction: action.direction ?? recorded.direction,
|
|
1418
|
+
seconds: action.seconds ?? recorded.seconds,
|
|
1419
|
+
appId: action.appId ?? recorded.appId,
|
|
1420
|
+
duration: action.duration ?? recorded.duration,
|
|
1421
|
+
timestamp: recorded.timestamp || action.timestamp,
|
|
1422
|
+
description: action.description || recorded.description,
|
|
1423
|
+
type: action.type
|
|
1424
|
+
};
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Generate Maestro YAML from recorded actions
|
|
1429
|
+
*/
|
|
1430
|
+
generateFlowYaml() {
|
|
1431
|
+
if (!this.session) return "";
|
|
1432
|
+
const escapeYaml = (value) => value.replace(/"/g, '\\"');
|
|
1433
|
+
const lines = [
|
|
1434
|
+
`# Maestro Flow: ${this.session.name}`,
|
|
1435
|
+
`# Recorded: ${new Date(this.session.startedAt).toISOString()}`,
|
|
1436
|
+
`# Generated by DiscoveryLab`,
|
|
1437
|
+
``
|
|
1438
|
+
];
|
|
1439
|
+
if (this.session.appId) {
|
|
1440
|
+
lines.push(`appId: ${this.session.appId}`);
|
|
1441
|
+
lines.push(``);
|
|
1442
|
+
}
|
|
1443
|
+
lines.push(`---`);
|
|
1444
|
+
lines.push(``);
|
|
1445
|
+
for (const action of this.session.actions) {
|
|
1446
|
+
lines.push(`# ${action.description}`);
|
|
1447
|
+
switch (action.type) {
|
|
1448
|
+
case "tap":
|
|
1449
|
+
if (action.text) {
|
|
1450
|
+
lines.push(`- tapOn:`);
|
|
1451
|
+
lines.push(` text: "${escapeYaml(action.text)}"`);
|
|
1452
|
+
} else if (action.x !== void 0 && action.y !== void 0) {
|
|
1453
|
+
lines.push(`- tapOn:`);
|
|
1454
|
+
lines.push(` point: "${action.x},${action.y}"`);
|
|
1455
|
+
}
|
|
1456
|
+
break;
|
|
1457
|
+
case "swipe":
|
|
1458
|
+
if (action.x !== void 0 && action.y !== void 0 && action.endX !== void 0 && action.endY !== void 0) {
|
|
1459
|
+
lines.push(`- swipe:`);
|
|
1460
|
+
lines.push(` start: "${action.x},${action.y}"`);
|
|
1461
|
+
lines.push(` end: "${action.endX},${action.endY}"`);
|
|
1462
|
+
if (action.duration) {
|
|
1463
|
+
lines.push(` duration: ${action.duration}`);
|
|
1464
|
+
}
|
|
1465
|
+
} else if (action.direction) {
|
|
1466
|
+
lines.push(`- swipe:`);
|
|
1467
|
+
lines.push(` direction: "${action.direction.toUpperCase()}"`);
|
|
1468
|
+
}
|
|
1469
|
+
break;
|
|
1470
|
+
case "longPress":
|
|
1471
|
+
if (action.x !== void 0 && action.y !== void 0) {
|
|
1472
|
+
lines.push(`- longPressOn:`);
|
|
1473
|
+
lines.push(` point: "${action.x},${action.y}"`);
|
|
1474
|
+
}
|
|
1475
|
+
break;
|
|
1476
|
+
case "input":
|
|
1477
|
+
if (action.text) {
|
|
1478
|
+
lines.push(`- inputText: "${escapeYaml(action.text)}"`);
|
|
1479
|
+
}
|
|
1480
|
+
break;
|
|
1481
|
+
case "back":
|
|
1482
|
+
lines.push(`- pressKey: back`);
|
|
1483
|
+
break;
|
|
1484
|
+
case "home":
|
|
1485
|
+
lines.push(`- pressKey: home`);
|
|
1486
|
+
break;
|
|
1487
|
+
case "scroll":
|
|
1488
|
+
if (action.direction && action.direction.toLowerCase() === "down") {
|
|
1489
|
+
lines.push(`- scrollUntilVisible:`);
|
|
1490
|
+
lines.push(` element: ".*"`);
|
|
1491
|
+
lines.push(` direction: "DOWN"`);
|
|
1492
|
+
} else {
|
|
1493
|
+
lines.push(`- scroll`);
|
|
1494
|
+
}
|
|
1495
|
+
break;
|
|
1496
|
+
case "launch": {
|
|
1497
|
+
const launchAppId = action.appId || action.text;
|
|
1498
|
+
if (launchAppId) {
|
|
1499
|
+
lines.push(`- launchApp: "${escapeYaml(launchAppId)}"`);
|
|
1500
|
+
if (!this.session.appId) {
|
|
1501
|
+
this.session.appId = launchAppId;
|
|
1502
|
+
}
|
|
1503
|
+
} else {
|
|
1504
|
+
lines.push(`- launchApp`);
|
|
1505
|
+
}
|
|
1506
|
+
break;
|
|
1507
|
+
}
|
|
1508
|
+
case "assert":
|
|
1509
|
+
if (action.text) {
|
|
1510
|
+
lines.push(`- assertVisible:`);
|
|
1511
|
+
lines.push(` text: "${escapeYaml(action.text)}"`);
|
|
1512
|
+
}
|
|
1513
|
+
break;
|
|
1514
|
+
case "wait": {
|
|
1515
|
+
const seconds = action.seconds && action.seconds > 0 ? Math.round(action.seconds) : 3;
|
|
1516
|
+
lines.push(`- extendedWaitUntil:`);
|
|
1517
|
+
lines.push(` visible: ".*"`);
|
|
1518
|
+
lines.push(` timeout: ${seconds * 1e3}`);
|
|
1519
|
+
break;
|
|
1520
|
+
}
|
|
1521
|
+
default:
|
|
1522
|
+
lines.push(`# Unknown action: ${action.type}`);
|
|
1523
|
+
}
|
|
1524
|
+
lines.push(``);
|
|
1525
|
+
}
|
|
1526
|
+
return lines.join("\n");
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Get current session
|
|
1530
|
+
*/
|
|
1531
|
+
getSession() {
|
|
1532
|
+
return this.session;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Check if recording is active
|
|
1536
|
+
*/
|
|
1537
|
+
isRecording() {
|
|
1538
|
+
return this.session?.status === "recording";
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
var maestroRecorderInstance = null;
|
|
1542
|
+
function getMaestroRecorder() {
|
|
1543
|
+
if (!maestroRecorderInstance) {
|
|
1544
|
+
maestroRecorderInstance = new MaestroRecorder();
|
|
1545
|
+
}
|
|
1546
|
+
return maestroRecorderInstance;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
export {
|
|
1550
|
+
findAndroidSdkPath,
|
|
1551
|
+
getEmulatorPath,
|
|
1552
|
+
getAdbCommand,
|
|
1553
|
+
listConnectedAndroidDevices,
|
|
1554
|
+
resolveAndroidDeviceSerial,
|
|
1555
|
+
isMaestroInstalled,
|
|
1556
|
+
isIdbInstalled,
|
|
1557
|
+
tapViaIdb,
|
|
1558
|
+
killZombieMaestroProcesses,
|
|
1559
|
+
getMaestroVersion,
|
|
1560
|
+
listMaestroDevices,
|
|
1561
|
+
generateMaestroFlow,
|
|
1562
|
+
runMaestroTest,
|
|
1563
|
+
runMaestroWithCapture,
|
|
1564
|
+
startMaestroStudio,
|
|
1565
|
+
createLoginFlow,
|
|
1566
|
+
createOnboardingFlow,
|
|
1567
|
+
createNavigationTestFlow,
|
|
1568
|
+
getMaestroRecorder
|
|
1569
|
+
};
|