@testrelic/maestro-analytics 1.1.0 → 1.2.0-next.54

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/cli.cjs CHANGED
@@ -1,5 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
3
25
 
4
26
  // src/config.ts
5
27
  var import_core2 = require("@testrelic/core");
@@ -190,6 +212,37 @@ function mergeCloudConfig(fileConfig, reporterOptions) {
190
212
  }
191
213
 
192
214
  // src/config.ts
215
+ var DEFAULT_REDACT_HEADERS = [
216
+ "authorization",
217
+ "cookie",
218
+ "set-cookie",
219
+ "x-api-key"
220
+ ];
221
+ var DEFAULT_REDACT_BODY_FIELDS = [
222
+ "password",
223
+ "secret",
224
+ "token",
225
+ "apiKey",
226
+ "api_key"
227
+ ];
228
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
229
+ function resolveNetworkCapture(options) {
230
+ const o = options ?? {};
231
+ const envEnabled = process.env.TESTRELIC_CAPTURE_NETWORK === "1" || process.env.TESTRELIC_CAPTURE_NETWORK?.toLowerCase() === "true";
232
+ return Object.freeze({
233
+ enabled: o.enabled ?? envEnabled ?? false,
234
+ proxyPort: o.proxyPort ?? (process.env.TESTRELIC_PROXY_PORT ? Number.parseInt(process.env.TESTRELIC_PROXY_PORT, 10) : 8080),
235
+ proxyHost: o.proxyHost ?? process.env.TESTRELIC_PROXY_HOST ?? "127.0.0.1",
236
+ harPath: o.harPath ?? process.env.TESTRELIC_HAR_PATH ?? null,
237
+ outputDir: o.outputDir ?? null,
238
+ skipCertInstall: o.skipCertInstall ?? false,
239
+ includeUrls: Object.freeze(o.includeUrls ? [...o.includeUrls] : []),
240
+ excludeUrls: Object.freeze(o.excludeUrls ? [...o.excludeUrls] : []),
241
+ redactHeaders: Object.freeze(o.redactHeaders ? [...o.redactHeaders] : [...DEFAULT_REDACT_HEADERS]),
242
+ redactBodyFields: Object.freeze(o.redactBodyFields ? [...o.redactBodyFields] : [...DEFAULT_REDACT_BODY_FIELDS]),
243
+ maxBodyBytes: o.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
244
+ });
245
+ }
193
246
  var DANGEROUS_KEYS2 = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
194
247
  function hasPrototypePollution(obj) {
195
248
  if (typeof obj !== "object" || obj === null) return false;
@@ -237,6 +290,7 @@ function resolveConfig(options) {
237
290
  target.quiet = options?.quiet ?? false;
238
291
  target.reportMode = options?.reportMode ?? "batch";
239
292
  target.cloud = resolveCloudFromMerge(options?.cloud ?? null);
293
+ target.network = resolveNetworkCapture(options?.network);
240
294
  return Object.freeze(target);
241
295
  }
242
296
  function resolveCloudFromMerge(reporterOptions) {
@@ -251,14 +305,323 @@ function resolveCloudFromMerge(reporterOptions) {
251
305
  }
252
306
 
253
307
  // src/maestro-runner.ts
308
+ var import_node_child_process3 = require("child_process");
309
+ var import_node_fs4 = require("fs");
310
+ var import_node_path4 = require("path");
311
+ var import_node_os3 = require("os");
312
+ var import_node_crypto2 = require("crypto");
313
+
314
+ // src/network-proxy.ts
254
315
  var import_node_child_process = require("child_process");
255
316
  var import_node_fs2 = require("fs");
256
317
  var import_node_path2 = require("path");
318
+ var import_node_url = require("url");
257
319
  var import_node_os = require("os");
258
320
  var import_node_crypto = require("crypto");
321
+ var import_meta = {};
322
+ function resolveAddonPath() {
323
+ const candidates = [];
324
+ try {
325
+ const here = (0, import_node_path2.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
326
+ candidates.push((0, import_node_path2.join)(here, "proxy-addon", "testrelic_capture.py"));
327
+ candidates.push((0, import_node_path2.join)(here, "..", "src", "proxy-addon", "testrelic_capture.py"));
328
+ } catch {
329
+ }
330
+ const maybeDirname = globalThis.__dirname;
331
+ if (maybeDirname) {
332
+ candidates.push((0, import_node_path2.join)(maybeDirname, "proxy-addon", "testrelic_capture.py"));
333
+ }
334
+ for (const c of candidates) {
335
+ if ((0, import_node_fs2.existsSync)(c)) return (0, import_node_path2.resolve)(c);
336
+ }
337
+ return null;
338
+ }
339
+ function generateRunDir(base) {
340
+ if (base) {
341
+ (0, import_node_fs2.mkdirSync)(base, { recursive: true });
342
+ return base;
343
+ }
344
+ const id = (0, import_node_crypto.randomBytes)(6).toString("hex");
345
+ const dir = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `testrelic-maestro-network-${id}`);
346
+ (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
347
+ return dir;
348
+ }
349
+ async function isMitmdumpAvailable() {
350
+ return new Promise((resolvePromise) => {
351
+ const child = (0, import_node_child_process.spawn)("mitmdump", ["--version"], { shell: true });
352
+ let resolved = false;
353
+ const settle = (ok) => {
354
+ if (resolved) return;
355
+ resolved = true;
356
+ resolvePromise(ok);
357
+ };
358
+ child.on("error", () => settle(false));
359
+ child.on("close", (code) => settle(code === 0));
360
+ setTimeout(() => {
361
+ try {
362
+ child.kill();
363
+ } catch {
364
+ }
365
+ settle(false);
366
+ }, 5e3);
367
+ });
368
+ }
369
+ async function startNetworkProxy(options) {
370
+ const { config, verbose = true } = options;
371
+ if (!config.enabled) return null;
372
+ if (!await isMitmdumpAvailable()) {
373
+ if (verbose) {
374
+ process.stderr.write(
375
+ "\u26A0 TestRelic: --capture-network requested but `mitmdump` is not on PATH.\n Install mitmproxy (`brew install mitmproxy` / `pipx install mitmproxy`)\n or supply --har-path to import a HAR file instead.\n"
376
+ );
377
+ }
378
+ return null;
379
+ }
380
+ const addon = resolveAddonPath();
381
+ if (!addon) {
382
+ if (verbose) {
383
+ process.stderr.write("\u26A0 TestRelic: network-capture addon not found in package; skipping capture.\n");
384
+ }
385
+ return null;
386
+ }
387
+ const outputDir = generateRunDir(options.outputDir ?? config.outputDir ?? void 0);
388
+ const outputPath = (0, import_node_path2.join)(outputDir, "network.jsonl");
389
+ const args = [
390
+ "--listen-host",
391
+ config.proxyHost,
392
+ "--listen-port",
393
+ String(config.proxyPort),
394
+ "-s",
395
+ addon,
396
+ "--set",
397
+ `testrelic_output=${outputPath}`,
398
+ "--set",
399
+ `testrelic_redact_headers=${config.redactHeaders.join(",")}`,
400
+ "--set",
401
+ `testrelic_redact_body_fields=${config.redactBodyFields.join(",")}`,
402
+ "--set",
403
+ `testrelic_max_body_bytes=${config.maxBodyBytes}`,
404
+ // Reduce noise; the addon writes its own log lines on warn.
405
+ "--set",
406
+ "console_eventlog_verbosity=warn",
407
+ "--set",
408
+ "termlog_verbosity=warn"
409
+ ];
410
+ let child;
411
+ try {
412
+ child = (0, import_node_child_process.spawn)("mitmdump", args, {
413
+ stdio: ["ignore", "pipe", "pipe"],
414
+ shell: true
415
+ });
416
+ } catch (err) {
417
+ if (verbose) {
418
+ process.stderr.write(`\u26A0 TestRelic: failed to spawn mitmdump: ${err.message}
419
+ `);
420
+ }
421
+ return null;
422
+ }
423
+ child.stdout?.on("data", (chunk) => {
424
+ if (verbose) process.stderr.write(`[mitmdump] ${chunk.toString("utf-8")}`);
425
+ });
426
+ child.stderr?.on("data", (chunk) => {
427
+ if (verbose) process.stderr.write(`[mitmdump] ${chunk.toString("utf-8")}`);
428
+ });
429
+ await new Promise((r) => setTimeout(r, 750));
430
+ const stop = () => new Promise((resolvePromise) => {
431
+ if (child.killed || child.exitCode !== null) {
432
+ resolvePromise();
433
+ return;
434
+ }
435
+ let settled = false;
436
+ const settle = () => {
437
+ if (settled) return;
438
+ settled = true;
439
+ resolvePromise();
440
+ };
441
+ child.once("close", settle);
442
+ child.once("exit", settle);
443
+ try {
444
+ child.kill("SIGINT");
445
+ } catch {
446
+ try {
447
+ child.kill();
448
+ } catch {
449
+ }
450
+ }
451
+ setTimeout(() => {
452
+ if (!settled) {
453
+ try {
454
+ child.kill("SIGKILL");
455
+ } catch {
456
+ }
457
+ settle();
458
+ }
459
+ }, 4e3);
460
+ });
461
+ return {
462
+ outputPath,
463
+ outputDir,
464
+ host: config.proxyHost,
465
+ port: config.proxyPort,
466
+ stop
467
+ };
468
+ }
469
+
470
+ // src/device-proxy-setup.ts
471
+ var import_node_child_process2 = require("child_process");
472
+ var import_node_path3 = require("path");
473
+ var import_node_os2 = require("os");
474
+ var import_node_fs3 = require("fs");
475
+ var NOOP_HANDLE = { teardown: async () => {
476
+ } };
477
+ function commandExists(cmd) {
478
+ const result = (0, import_node_child_process2.spawnSync)(cmd, ["--version"], { shell: true, stdio: "ignore" });
479
+ return result.error === void 0 || result.error.code !== "ENOENT";
480
+ }
481
+ function logHint(quiet, msg) {
482
+ if (!quiet) process.stderr.write(`${msg}
483
+ `);
484
+ }
485
+ function logManualInstructions(quiet, platform, proxyHost, proxyPort) {
486
+ if (quiet) return;
487
+ if (platform === "android") {
488
+ process.stderr.write(
489
+ `\u2139 TestRelic: real-device Android capture \u2014 set the device proxy manually:
490
+ Settings \u2192 Wi-Fi \u2192 (long-press your network) \u2192 Modify \u2192 Proxy: Manual
491
+ Host: ${proxyHost} Port: ${proxyPort}
492
+ Then install mitmproxy CA: open http://mitm.it from the device after the proxy is reachable.
493
+ `
494
+ );
495
+ } else if (platform === "ios") {
496
+ process.stderr.write(
497
+ `\u2139 TestRelic: real-device iOS capture \u2014 set the device proxy manually:
498
+ Settings \u2192 Wi-Fi \u2192 (i) on your network \u2192 Configure Proxy \u2192 Manual
499
+ Server: ${proxyHost} Port: ${proxyPort}
500
+ Then install + TRUST the mitmproxy CA:
501
+ 1. Open http://mitm.it from Safari on the device \u2192 install profile.
502
+ 2. Settings \u2192 General \u2192 About \u2192 Certificate Trust Settings \u2192 enable mitmproxy.
503
+ `
504
+ );
505
+ }
506
+ }
507
+ async function adb(args, deviceId) {
508
+ return new Promise((resolvePromise) => {
509
+ const full = deviceId ? ["-s", deviceId, ...args] : args;
510
+ const child = (0, import_node_child_process2.spawn)("adb", full, { shell: true, stdio: ["ignore", "pipe", "pipe"] });
511
+ let out = "";
512
+ let err = "";
513
+ child.stdout?.on("data", (c) => {
514
+ out += c.toString();
515
+ });
516
+ child.stderr?.on("data", (c) => {
517
+ err += c.toString();
518
+ });
519
+ child.on("error", () => resolvePromise({ code: 127, stdout: out, stderr: err || "adb not on PATH" }));
520
+ child.on("close", (code) => resolvePromise({ code: code ?? 1, stdout: out, stderr: err }));
521
+ });
522
+ }
523
+ async function setUpAndroidEmulator(opts) {
524
+ const { deviceId, proxyHost, proxyPort, quiet, skipCertInstall } = opts;
525
+ if (!commandExists("adb")) {
526
+ logHint(quiet, "\u26A0 TestRelic: `adb` not on PATH; skipping automatic Android proxy setup. See manual instructions:");
527
+ logManualInstructions(quiet, "android", proxyHost, proxyPort);
528
+ return NOOP_HANDLE;
529
+ }
530
+ const setResult = await adb(["shell", "settings", "put", "global", "http_proxy", `${proxyHost}:${proxyPort}`], deviceId);
531
+ if (setResult.code !== 0) {
532
+ logHint(quiet, `\u26A0 TestRelic: adb set proxy failed: ${setResult.stderr.trim() || "unknown error"}`);
533
+ logManualInstructions(quiet, "android", proxyHost, proxyPort);
534
+ return NOOP_HANDLE;
535
+ }
536
+ logHint(quiet, `\u2139 TestRelic: Android proxy set to ${proxyHost}:${proxyPort} on ${deviceId ?? "default device"}.`);
537
+ if (!skipCertInstall) {
538
+ logHint(
539
+ quiet,
540
+ "\u2139 TestRelic: if HTTPS calls fail, install the mitmproxy CA on the emulator:\n open http://mitm.it from Chrome on the emulator and follow prompts,\n OR for a system-level install:\n adb root && adb remount && adb push <mitm-ca>.0 /system/etc/security/cacerts/ && adb reboot\n Set --skip-cert-install to silence this hint."
541
+ );
542
+ }
543
+ const teardown = async () => {
544
+ await adb(["shell", "settings", "put", "global", "http_proxy", ":0"], deviceId);
545
+ };
546
+ return { teardown };
547
+ }
548
+ async function xcrun(args) {
549
+ return new Promise((resolvePromise) => {
550
+ const child = (0, import_node_child_process2.spawn)("xcrun", args, { shell: true, stdio: ["ignore", "pipe", "pipe"] });
551
+ let out = "";
552
+ let err = "";
553
+ child.stdout?.on("data", (c) => {
554
+ out += c.toString();
555
+ });
556
+ child.stderr?.on("data", (c) => {
557
+ err += c.toString();
558
+ });
559
+ child.on("error", () => resolvePromise({ code: 127, stdout: out, stderr: err || "xcrun not on PATH" }));
560
+ child.on("close", (code) => resolvePromise({ code: code ?? 1, stdout: out, stderr: err }));
561
+ });
562
+ }
563
+ async function setUpIosSimulator(opts) {
564
+ const { proxyHost, proxyPort, quiet, skipCertInstall } = opts;
565
+ if (process.platform !== "darwin") {
566
+ logHint(quiet, "\u26A0 TestRelic: iOS simulator capture requires macOS; the host is not Darwin. Skipping setup.");
567
+ logManualInstructions(quiet, "ios", proxyHost, proxyPort);
568
+ return NOOP_HANDLE;
569
+ }
570
+ if (!commandExists("xcrun")) {
571
+ logHint(quiet, "\u26A0 TestRelic: `xcrun` not on PATH; skipping automatic iOS simulator setup.");
572
+ logManualInstructions(quiet, "ios", proxyHost, proxyPort);
573
+ return NOOP_HANDLE;
574
+ }
575
+ if (!skipCertInstall) {
576
+ const caCandidate = (0, import_node_path3.join)(process.env.HOME ?? "", ".mitmproxy", "mitmproxy-ca-cert.pem");
577
+ if ((0, import_node_fs3.existsSync)(caCandidate)) {
578
+ const udid = opts.deviceId ?? "booted";
579
+ const res = await xcrun(["simctl", "keychain", udid, "add-root-cert", caCandidate]);
580
+ if (res.code === 0) {
581
+ logHint(quiet, `\u2139 TestRelic: installed mitmproxy CA on iOS simulator (${udid}).`);
582
+ } else {
583
+ logHint(quiet, `\u26A0 TestRelic: failed to add-root-cert via simctl: ${res.stderr.trim()}`);
584
+ logManualInstructions(quiet, "ios", proxyHost, proxyPort);
585
+ }
586
+ } else {
587
+ logHint(
588
+ quiet,
589
+ `\u26A0 TestRelic: mitmproxy CA not found at ${caCandidate}. Run mitmdump once to generate it,
590
+ then re-run with --capture-network. Falling back to manual instructions:`
591
+ );
592
+ logManualInstructions(quiet, "ios", proxyHost, proxyPort);
593
+ }
594
+ }
595
+ logHint(
596
+ quiet,
597
+ `\u2139 TestRelic: in the iOS simulator, configure Wi-Fi \u2192 (i) \u2192 Manual proxy:
598
+ Server: ${proxyHost} Port: ${proxyPort}
599
+ (We do not set host-wide \`networksetup\` proxy automatically; see docs.)`
600
+ );
601
+ return NOOP_HANDLE;
602
+ }
603
+ async function setUpDeviceProxy(options) {
604
+ switch (options.platform) {
605
+ case "android":
606
+ return setUpAndroidEmulator(options);
607
+ case "ios":
608
+ return setUpIosSimulator(options);
609
+ case "web":
610
+ logHint(options.quiet, "\u2139 TestRelic: --capture-network ignored for Maestro web flows (browser already handles HTTP).");
611
+ return NOOP_HANDLE;
612
+ case "unknown":
613
+ default:
614
+ logHint(options.quiet, "\u26A0 TestRelic: target platform unknown; printing manual proxy setup instructions:");
615
+ logManualInstructions(options.quiet, "android", options.proxyHost, options.proxyPort);
616
+ logManualInstructions(options.quiet, "ios", options.proxyHost, options.proxyPort);
617
+ return NOOP_HANDLE;
618
+ }
619
+ }
620
+
621
+ // src/maestro-runner.ts
259
622
  function generateTempDir() {
260
- const id = (0, import_node_crypto.randomBytes)(8).toString("hex");
261
- return (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `testrelic-maestro-${id}`);
623
+ const id = (0, import_node_crypto2.randomBytes)(8).toString("hex");
624
+ return (0, import_node_path4.join)((0, import_node_os3.tmpdir)(), `testrelic-maestro-${id}`);
262
625
  }
263
626
  function buildMaestroArgs(options, tempDir) {
264
627
  const args = [];
@@ -269,9 +632,9 @@ function buildMaestroArgs(options, tempDir) {
269
632
  args.push("--device", options.device);
270
633
  }
271
634
  args.push("test");
272
- const junitPath = (0, import_node_path2.join)(tempDir, "report.xml");
273
- const testOutputDir = (0, import_node_path2.join)(tempDir, "artifacts");
274
- const debugOutputDir = (0, import_node_path2.join)(tempDir, "debug");
635
+ const junitPath = (0, import_node_path4.join)(tempDir, "report.xml");
636
+ const testOutputDir = (0, import_node_path4.join)(tempDir, "artifacts");
637
+ const debugOutputDir = (0, import_node_path4.join)(tempDir, "debug");
275
638
  args.push("--format", "junit");
276
639
  args.push("--output", junitPath);
277
640
  args.push("--test-output-dir", testOutputDir);
@@ -302,60 +665,124 @@ function buildMaestroArgs(options, tempDir) {
302
665
  args.push(...options.flowPaths);
303
666
  return args;
304
667
  }
305
- async function runMaestro(options) {
306
- const tempDir = options.outputDir ? (0, import_node_path2.resolve)(options.outputDir) : generateTempDir();
307
- (0, import_node_fs2.mkdirSync)((0, import_node_path2.join)(tempDir, "artifacts"), { recursive: true });
308
- (0, import_node_fs2.mkdirSync)((0, import_node_path2.join)(tempDir, "debug"), { recursive: true });
309
- const args = buildMaestroArgs(options, tempDir);
310
- const junitPath = (0, import_node_path2.join)(tempDir, "report.xml");
311
- const testOutputDir = (0, import_node_path2.join)(tempDir, "artifacts");
312
- const debugOutputDir = (0, import_node_path2.join)(tempDir, "debug");
668
+ function normalisePlatform(p) {
669
+ switch ((p ?? "").toLowerCase()) {
670
+ case "android":
671
+ return "android";
672
+ case "ios":
673
+ return "ios";
674
+ case "web":
675
+ return "web";
676
+ default:
677
+ return "unknown";
678
+ }
679
+ }
680
+ function runMaestroProc(args, junitPath, testOutputDir, debugOutputDir, networkJsonlDir, quiet, onClose) {
313
681
  return new Promise((resolvePromise) => {
314
682
  const stdoutChunks = [];
315
683
  const stderrChunks = [];
316
- const proc = (0, import_node_child_process.spawn)("maestro", args, {
684
+ const proc = (0, import_node_child_process3.spawn)("maestro", args, {
317
685
  stdio: ["inherit", "pipe", "pipe"],
318
686
  shell: true
319
687
  });
320
688
  proc.stdout.on("data", (chunk) => {
321
689
  stdoutChunks.push(chunk);
322
- if (!options.quiet) process.stdout.write(chunk);
690
+ if (!quiet) process.stdout.write(chunk);
323
691
  });
324
692
  proc.stderr.on("data", (chunk) => {
325
693
  stderrChunks.push(chunk);
326
- if (!options.quiet) process.stderr.write(chunk);
694
+ if (!quiet) process.stderr.write(chunk);
327
695
  });
328
- proc.on("close", (code) => {
696
+ proc.on("close", async (code) => {
697
+ try {
698
+ await onClose();
699
+ } catch {
700
+ }
329
701
  resolvePromise({
330
702
  exitCode: code ?? 1,
331
703
  junitPath,
332
704
  testOutputDir,
333
705
  debugOutputDir,
334
706
  stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
335
- stderr: Buffer.concat(stderrChunks).toString("utf-8")
707
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
708
+ networkJsonlDir
336
709
  });
337
710
  });
338
- proc.on("error", (err) => {
711
+ proc.on("error", async (err) => {
712
+ try {
713
+ await onClose();
714
+ } catch {
715
+ }
339
716
  resolvePromise({
340
717
  exitCode: 127,
341
718
  junitPath,
342
719
  testOutputDir,
343
720
  debugOutputDir,
344
721
  stdout: "",
345
- stderr: `Failed to start maestro CLI: ${err.message}`
722
+ stderr: `Failed to start maestro CLI: ${err.message}`,
723
+ networkJsonlDir
346
724
  });
347
725
  });
348
726
  });
349
727
  }
728
+ async function runMaestro(options, extras = {}) {
729
+ const tempDir = options.outputDir ? (0, import_node_path4.resolve)(options.outputDir) : generateTempDir();
730
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.join)(tempDir, "artifacts"), { recursive: true });
731
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.join)(tempDir, "debug"), { recursive: true });
732
+ const args = buildMaestroArgs(options, tempDir);
733
+ const junitPath = (0, import_node_path4.join)(tempDir, "report.xml");
734
+ const testOutputDir = (0, import_node_path4.join)(tempDir, "artifacts");
735
+ const debugOutputDir = (0, import_node_path4.join)(tempDir, "debug");
736
+ let proxyHandle = null;
737
+ let deviceHandle = null;
738
+ const networkConfig = extras.network;
739
+ if (networkConfig?.enabled) {
740
+ const networkDir = (0, import_node_path4.join)(tempDir, "network");
741
+ proxyHandle = await startNetworkProxy({
742
+ config: networkConfig,
743
+ outputDir: networkDir,
744
+ verbose: !options.quiet
745
+ });
746
+ if (proxyHandle) {
747
+ deviceHandle = await setUpDeviceProxy({
748
+ platform: normalisePlatform(options.platform),
749
+ deviceId: options.device,
750
+ proxyHost: proxyHandle.host,
751
+ proxyPort: proxyHandle.port,
752
+ skipCertInstall: networkConfig.skipCertInstall,
753
+ quiet: options.quiet
754
+ });
755
+ }
756
+ }
757
+ const networkJsonlDir = proxyHandle?.outputDir ?? null;
758
+ const cleanup = async () => {
759
+ if (proxyHandle) {
760
+ await new Promise((r) => setTimeout(r, 500));
761
+ }
762
+ if (deviceHandle) {
763
+ try {
764
+ await deviceHandle.teardown();
765
+ } catch {
766
+ }
767
+ }
768
+ if (proxyHandle) {
769
+ try {
770
+ await proxyHandle.stop();
771
+ } catch {
772
+ }
773
+ }
774
+ };
775
+ return runMaestroProc(args, junitPath, testOutputDir, debugOutputDir, networkJsonlDir, options.quiet, cleanup);
776
+ }
350
777
 
351
778
  // src/report-orchestrator.ts
352
- var import_node_fs15 = require("fs");
353
- var import_node_path13 = require("path");
354
- var import_node_crypto3 = require("crypto");
779
+ var import_node_fs18 = require("fs");
780
+ var import_node_path16 = require("path");
781
+ var import_node_crypto4 = require("crypto");
355
782
 
356
783
  // src/parsers/junit-parser.ts
357
784
  var import_fast_xml_parser = require("fast-xml-parser");
358
- var import_node_fs3 = require("fs");
785
+ var import_node_fs5 = require("fs");
359
786
  var parser = new import_fast_xml_parser.XMLParser({
360
787
  ignoreAttributes: false,
361
788
  attributeNamePrefix: "@_",
@@ -453,13 +880,13 @@ function parseJUnitXml(xmlContent) {
453
880
  return { testSuites: suites, totalTests, totalFailures, totalErrors, totalSkipped, totalTime };
454
881
  }
455
882
  function parseJUnitFile(filePath) {
456
- const content = (0, import_node_fs3.readFileSync)(filePath, "utf-8");
883
+ const content = (0, import_node_fs5.readFileSync)(filePath, "utf-8");
457
884
  return parseJUnitXml(content);
458
885
  }
459
886
 
460
887
  // src/parsers/command-parser.ts
461
- var import_node_fs4 = require("fs");
462
- var import_node_path3 = require("path");
888
+ var import_node_fs6 = require("fs");
889
+ var import_node_path5 = require("path");
463
890
  var INTERACTION_COMMANDS = /* @__PURE__ */ new Set([
464
891
  "tapOn",
465
892
  "doubleTapOn",
@@ -524,6 +951,9 @@ var AI_COMMANDS = /* @__PURE__ */ new Set([
524
951
  "assertNoDefectsWithAI",
525
952
  "extractTextWithAI"
526
953
  ]);
954
+ var REDACTED_DETAIL_COMMANDS = /* @__PURE__ */ new Set(["inputText", "pasteText"]);
955
+ var MAX_SCRIPT_DETAIL_LENGTH = 200;
956
+ var MAX_AI_PROMPT_LENGTH = 400;
527
957
  function categorizeCommand(command) {
528
958
  if (AI_COMMANDS.has(command)) return "ai";
529
959
  if (ASSERTION_COMMANDS.has(command)) return "assertion";
@@ -564,22 +994,165 @@ function extractMaestroSelector(params) {
564
994
  if (typeof params.path === "string") return params.path;
565
995
  return void 0;
566
996
  }
997
+ function truncate(value, max) {
998
+ if (value.length <= max) return value;
999
+ return `${value.slice(0, max)}\u2026`;
1000
+ }
1001
+ function redactText(value) {
1002
+ if (value.length === 0) return "";
1003
+ return `[REDACTED ${value.length} chars]`;
1004
+ }
1005
+ function stringifyPoint(point) {
1006
+ if (!point || typeof point !== "object") return void 0;
1007
+ const p = point;
1008
+ if (typeof p.x === "number" && typeof p.y === "number") return `${p.x},${p.y}`;
1009
+ return void 0;
1010
+ }
1011
+ function extractCommandDetails(commandName, params) {
1012
+ const details = {};
1013
+ switch (commandName) {
1014
+ case "inputText":
1015
+ case "pasteText": {
1016
+ const text = params.text ?? params.value;
1017
+ if (typeof text === "string") {
1018
+ details.text = REDACTED_DETAIL_COMMANDS.has(commandName) ? redactText(text) : text;
1019
+ }
1020
+ break;
1021
+ }
1022
+ case "swipe":
1023
+ case "scroll":
1024
+ case "scrollUntilVisible": {
1025
+ if (typeof params.direction === "string") details.direction = params.direction;
1026
+ const startPoint = stringifyPoint(params.start ?? params.startRelative);
1027
+ const endPoint = stringifyPoint(params.end ?? params.endRelative);
1028
+ if (startPoint) details.start = startPoint;
1029
+ if (endPoint) details.end = endPoint;
1030
+ if (typeof params.duration === "number") details.swipeDurationMs = params.duration;
1031
+ if (typeof params.timeout === "number") details.timeoutMs = params.timeout;
1032
+ break;
1033
+ }
1034
+ case "setLocation": {
1035
+ if (typeof params.latitude === "number") details.latitude = params.latitude;
1036
+ if (typeof params.longitude === "number") details.longitude = params.longitude;
1037
+ break;
1038
+ }
1039
+ case "pressKey":
1040
+ case "back": {
1041
+ if (typeof params.key === "string") details.key = params.key;
1042
+ else if (typeof params.code === "string") details.key = params.code;
1043
+ break;
1044
+ }
1045
+ case "setOrientation": {
1046
+ if (typeof params.orientation === "string") details.orientation = params.orientation;
1047
+ break;
1048
+ }
1049
+ case "setAirplaneMode":
1050
+ case "toggleAirplaneMode": {
1051
+ if (typeof params.value === "string" || typeof params.value === "boolean") {
1052
+ details.value = params.value;
1053
+ }
1054
+ break;
1055
+ }
1056
+ case "setPermissions": {
1057
+ if (params.permissions && typeof params.permissions === "object") {
1058
+ details.permissions = params.permissions;
1059
+ }
1060
+ break;
1061
+ }
1062
+ case "addMedia": {
1063
+ if (Array.isArray(params.mediaPaths)) details.mediaPaths = params.mediaPaths;
1064
+ else if (typeof params.path === "string") details.mediaPaths = [params.path];
1065
+ break;
1066
+ }
1067
+ case "launchApp":
1068
+ case "killApp":
1069
+ case "stopApp":
1070
+ case "clearState": {
1071
+ const appId = params.appId ?? params.packageName ?? params.bundleId;
1072
+ if (typeof appId === "string") details.appId = appId;
1073
+ if (params.arguments) details.arguments = params.arguments;
1074
+ break;
1075
+ }
1076
+ case "openLink": {
1077
+ if (typeof params.url === "string") details.url = params.url;
1078
+ else if (typeof params.link === "string") details.url = params.link;
1079
+ break;
1080
+ }
1081
+ case "runScript":
1082
+ case "evalScript": {
1083
+ if (typeof params.path === "string") details.path = params.path;
1084
+ if (typeof params.script === "string") details.script = truncate(params.script, MAX_SCRIPT_DETAIL_LENGTH);
1085
+ if (typeof params.sourceDescription === "string") details.sourceDescription = params.sourceDescription;
1086
+ break;
1087
+ }
1088
+ case "assertWithAI":
1089
+ case "assertNoDefectsWithAI":
1090
+ case "extractTextWithAI": {
1091
+ const prompt = params.assertion ?? params.prompt ?? params.question;
1092
+ if (typeof prompt === "string") details.prompt = truncate(prompt, MAX_AI_PROMPT_LENGTH);
1093
+ break;
1094
+ }
1095
+ case "extendedWaitUntil":
1096
+ case "waitForAnimationToEnd": {
1097
+ if (typeof params.timeout === "number") details.timeoutMs = params.timeout;
1098
+ break;
1099
+ }
1100
+ case "repeat":
1101
+ case "retry": {
1102
+ if (typeof params.times === "number") details.times = params.times;
1103
+ if (typeof params.maxRetries === "number") details.maxRetries = params.maxRetries;
1104
+ if (typeof params.condition === "object" && params.condition) details.condition = params.condition;
1105
+ break;
1106
+ }
1107
+ case "runFlow": {
1108
+ if (typeof params.file === "string") details.file = params.file;
1109
+ if (typeof params.flowId === "string") details.flowId = params.flowId;
1110
+ break;
1111
+ }
1112
+ default:
1113
+ for (const key of ["text", "value", "url"]) {
1114
+ const v = params[key];
1115
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
1116
+ details[key] = v;
1117
+ break;
1118
+ }
1119
+ }
1120
+ }
1121
+ return Object.keys(details).length > 0 ? details : void 0;
1122
+ }
1123
+ function extractChildCommands(params, raw, baseTimestamp) {
1124
+ const candidates = [params.commands, raw.commands, raw.subSteps, raw.children];
1125
+ for (const c of candidates) {
1126
+ if (Array.isArray(c) && c.length > 0) {
1127
+ return c.map(
1128
+ (entry, i) => parseRawCommand(entry, i, baseTimestamp)
1129
+ );
1130
+ }
1131
+ }
1132
+ return [];
1133
+ }
567
1134
  function parseRawCommand(raw, index, baseTimestamp) {
568
1135
  let command;
569
1136
  let selector;
1137
+ let details;
1138
+ let children = [];
570
1139
  if (typeof raw.command === "object" && raw.command !== null && !Array.isArray(raw.command)) {
571
1140
  const commandObj = raw.command;
572
1141
  const rawKey = Object.keys(commandObj)[0] ?? `step-${index}`;
573
1142
  command = normalizeMaestroCommandName(rawKey);
574
1143
  const params = commandObj[rawKey] ?? {};
575
1144
  selector = extractMaestroSelector(params);
1145
+ details = extractCommandDetails(command, params);
1146
+ children = extractChildCommands(params, raw, baseTimestamp);
576
1147
  } else {
577
1148
  command = raw.command ?? raw.commandName ?? raw.name ?? `step-${index}`;
578
1149
  selector = raw.selector ?? void 0;
1150
+ children = extractChildCommands({}, raw, baseTimestamp);
579
1151
  }
580
1152
  const meta = raw.metadata ?? {};
581
1153
  const status = mapStatus(meta.status ?? raw.status);
582
1154
  const duration = meta.duration ?? raw.duration ?? raw.durationMs ?? 0;
1155
+ const sequenceNumber = typeof meta.sequenceNumber === "number" ? meta.sequenceNumber : void 0;
583
1156
  let timestamp;
584
1157
  if (typeof meta.timestamp === "number") {
585
1158
  timestamp = new Date(meta.timestamp).toISOString();
@@ -594,7 +1167,10 @@ function parseRawCommand(raw, index, baseTimestamp) {
594
1167
  duration,
595
1168
  timestamp,
596
1169
  ...selector ? { selector } : {},
597
- ...error ? { error } : {}
1170
+ ...error ? { error } : {},
1171
+ ...details ? { details } : {},
1172
+ ...sequenceNumber !== void 0 ? { sequenceNumber } : {},
1173
+ ...children.length > 0 ? { children } : {}
598
1174
  };
599
1175
  }
600
1176
  function mapStatus(status) {
@@ -625,24 +1201,24 @@ function parseCommandsJson(jsonContent, baseTimestamp) {
625
1201
  }
626
1202
  function parseCommandsFile(filePath, baseTimestamp) {
627
1203
  try {
628
- const content = (0, import_node_fs4.readFileSync)(filePath, "utf-8");
1204
+ const content = (0, import_node_fs6.readFileSync)(filePath, "utf-8");
629
1205
  return parseCommandsJson(content, baseTimestamp);
630
1206
  } catch {
631
1207
  return [];
632
1208
  }
633
1209
  }
634
1210
  function discoverCommandFiles(artifactsDir) {
635
- if (!(0, import_node_fs4.existsSync)(artifactsDir)) return [];
1211
+ if (!(0, import_node_fs6.existsSync)(artifactsDir)) return [];
636
1212
  try {
637
- return (0, import_node_fs4.readdirSync)(artifactsDir, { recursive: true }).map(String).filter((f) => (0, import_node_path3.basename)(f).startsWith("commands") && f.endsWith(".json")).map((f) => (0, import_node_path3.join)(artifactsDir, f));
1213
+ return (0, import_node_fs6.readdirSync)(artifactsDir, { recursive: true }).map(String).filter((f) => (0, import_node_path5.basename)(f).startsWith("commands") && f.endsWith(".json")).map((f) => (0, import_node_path5.join)(artifactsDir, f));
638
1214
  } catch {
639
1215
  return [];
640
1216
  }
641
1217
  }
642
1218
 
643
1219
  // src/parsers/flow-parser.ts
644
- var import_node_fs5 = require("fs");
645
- var import_node_path4 = require("path");
1220
+ var import_node_fs7 = require("fs");
1221
+ var import_node_path6 = require("path");
646
1222
  var import_yaml = require("yaml");
647
1223
  function extractSubflowRefs(body) {
648
1224
  const refs = [];
@@ -736,21 +1312,21 @@ function parseFlowYaml(content, filePath) {
736
1312
  };
737
1313
  }
738
1314
  function parseFlowFile(filePath) {
739
- const content = (0, import_node_fs5.readFileSync)(filePath, "utf-8");
1315
+ const content = (0, import_node_fs7.readFileSync)(filePath, "utf-8");
740
1316
  return parseFlowYaml(content, filePath);
741
1317
  }
742
1318
  function discoverFlowFiles(dir) {
743
- if (!(0, import_node_fs5.existsSync)(dir)) return [];
1319
+ if (!(0, import_node_fs7.existsSync)(dir)) return [];
744
1320
  const results = [];
745
1321
  function walk(currentDir) {
746
1322
  try {
747
- const entries = (0, import_node_fs5.readdirSync)(currentDir, { withFileTypes: true });
1323
+ const entries = (0, import_node_fs7.readdirSync)(currentDir, { withFileTypes: true });
748
1324
  for (const entry of entries) {
749
- const fullPath = (0, import_node_path4.join)(currentDir, entry.name);
1325
+ const fullPath = (0, import_node_path6.join)(currentDir, entry.name);
750
1326
  if (entry.isDirectory()) {
751
1327
  walk(fullPath);
752
1328
  } else if (entry.isFile()) {
753
- const ext = (0, import_node_path4.extname)(entry.name).toLowerCase();
1329
+ const ext = (0, import_node_path6.extname)(entry.name).toLowerCase();
754
1330
  if (ext === ".yaml" || ext === ".yml") {
755
1331
  results.push(fullPath);
756
1332
  }
@@ -764,11 +1340,11 @@ function discoverFlowFiles(dir) {
764
1340
  }
765
1341
 
766
1342
  // src/parsers/log-parser.ts
767
- var import_node_fs6 = require("fs");
768
- var import_node_path5 = require("path");
769
- var import_node_fs7 = require("fs");
770
- var LOG_LINE_REGEX = /^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(\w+)\s+(.+)$/;
771
- var TIMESTAMP_ONLY_REGEX = /^\[?(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]?\s+(\w+)\s+(.+)$/;
1343
+ var import_node_fs8 = require("fs");
1344
+ var import_node_path7 = require("path");
1345
+ var import_node_fs9 = require("fs");
1346
+ var LOG_LINE_REGEX = /^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+\[?\s*(\w+)\s*\]?\s+(.+)$/;
1347
+ var TIMESTAMP_ONLY_REGEX = /^\[?(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]?\s+\[?\s*(\w+)\s*\]?\s+(.+)$/;
772
1348
  function parseLevel(raw) {
773
1349
  const upper = raw.toUpperCase();
774
1350
  if (upper === "DEBUG" || upper === "TRACE" || upper === "VERBOSE") return "DEBUG";
@@ -777,10 +1353,31 @@ function parseLevel(raw) {
777
1353
  if (upper === "ERROR" || upper === "FATAL" || upper === "SEVERE") return "ERROR";
778
1354
  return "INFO";
779
1355
  }
780
- function parseLogContent(content) {
1356
+ function buildEntryTimestamp(dateAnchor, hhmmss) {
1357
+ const m = /^(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(hhmmss);
1358
+ if (!m) {
1359
+ return (/* @__PURE__ */ new Date()).toISOString();
1360
+ }
1361
+ const h = Number(m[1]);
1362
+ const mi = Number(m[2]);
1363
+ const s = Number(m[3]);
1364
+ const ms = m[4] ? Number(m[4].padEnd(3, "0")) : 0;
1365
+ const local = new Date(
1366
+ dateAnchor.getFullYear(),
1367
+ dateAnchor.getMonth(),
1368
+ dateAnchor.getDate(),
1369
+ h,
1370
+ mi,
1371
+ s,
1372
+ ms
1373
+ );
1374
+ return local.toISOString();
1375
+ }
1376
+ function parseLogContent(content, defaultDate) {
781
1377
  const entries = [];
782
1378
  const lines = content.split("\n");
783
- const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1379
+ const anchorDate = defaultDate instanceof Date ? defaultDate : null;
1380
+ const dateAnchorStr = typeof defaultDate === "string" ? defaultDate : defaultDate instanceof Date ? `${defaultDate.getFullYear()}-${String(defaultDate.getMonth() + 1).padStart(2, "0")}-${String(defaultDate.getDate()).padStart(2, "0")}` : (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
784
1381
  for (const line2 of lines) {
785
1382
  const trimmed = line2.trim();
786
1383
  if (!trimmed) continue;
@@ -796,8 +1393,9 @@ function parseLogContent(content) {
796
1393
  }
797
1394
  match = TIMESTAMP_ONLY_REGEX.exec(trimmed);
798
1395
  if (match) {
1396
+ const ts = anchorDate ? buildEntryTimestamp(anchorDate, match[1]) : `${dateAnchorStr}T${match[1]}`;
799
1397
  entries.push({
800
- timestamp: `${today}T${match[1]}`,
1398
+ timestamp: ts,
801
1399
  level: parseLevel(match[2]),
802
1400
  message: match[3],
803
1401
  source: null
@@ -814,10 +1412,10 @@ function parseLogContent(content) {
814
1412
  }
815
1413
  return entries;
816
1414
  }
817
- function parseLogFile(filePath) {
1415
+ function parseLogFile(filePath, defaultDate) {
818
1416
  try {
819
- const content = (0, import_node_fs6.readFileSync)(filePath, "utf-8");
820
- return parseLogContent(content);
1417
+ const content = (0, import_node_fs8.readFileSync)(filePath, "utf-8");
1418
+ return parseLogContent(content, defaultDate);
821
1419
  } catch {
822
1420
  return [];
823
1421
  }
@@ -838,17 +1436,17 @@ function detectPlatformFromLogs(entries) {
838
1436
  return "unknown";
839
1437
  }
840
1438
  function discoverLogFiles(dir) {
841
- if (!(0, import_node_fs6.existsSync)(dir)) return [];
1439
+ if (!(0, import_node_fs8.existsSync)(dir)) return [];
842
1440
  try {
843
- return (0, import_node_fs7.readdirSync)(dir, { recursive: true }).map(String).filter((f) => (0, import_node_path5.basename)(f) === "maestro.log" || f.endsWith(".log")).map((f) => (0, import_node_path5.join)(dir, f));
1441
+ return (0, import_node_fs9.readdirSync)(dir, { recursive: true }).map(String).filter((f) => (0, import_node_path7.basename)(f) === "maestro.log" || f.endsWith(".log")).map((f) => (0, import_node_path7.join)(dir, f));
844
1442
  } catch {
845
1443
  return [];
846
1444
  }
847
1445
  }
848
1446
 
849
1447
  // src/parsers/ai-report-parser.ts
850
- var import_node_fs8 = require("fs");
851
- var import_node_path6 = require("path");
1448
+ var import_node_fs10 = require("fs");
1449
+ var import_node_path8 = require("path");
852
1450
  var DEFECT_SECTION_REGEX = /(?:defect|issue|bug|error|warning|problem)/i;
853
1451
  var SEVERITY_CRITICAL_REGEX = /(?:critical|severe|major|blocker)/i;
854
1452
  var SEVERITY_WARNING_REGEX = /(?:warning|moderate|minor)/i;
@@ -935,38 +1533,250 @@ function parseAiReportHtml(htmlContent) {
935
1533
  }
936
1534
  function parseAiReportFile(filePath) {
937
1535
  try {
938
- const content = (0, import_node_fs8.readFileSync)(filePath, "utf-8");
1536
+ const content = (0, import_node_fs10.readFileSync)(filePath, "utf-8");
939
1537
  return parseAiReportHtml(content);
940
1538
  } catch {
941
1539
  return { defects: [], totalDefects: 0, hasIssues: false, rawHtml: null };
942
1540
  }
943
1541
  }
944
1542
  function discoverAiReports(dir) {
945
- if (!(0, import_node_fs8.existsSync)(dir)) return [];
1543
+ if (!(0, import_node_fs10.existsSync)(dir)) return [];
946
1544
  try {
947
- return (0, import_node_fs8.readdirSync)(dir, { recursive: true }).map(String).filter((f) => {
948
- const name = (0, import_node_path6.basename)(f).toLowerCase();
949
- const ext = (0, import_node_path6.extname)(f).toLowerCase();
1545
+ return (0, import_node_fs10.readdirSync)(dir, { recursive: true }).map(String).filter((f) => {
1546
+ const name = (0, import_node_path8.basename)(f).toLowerCase();
1547
+ const ext = (0, import_node_path8.extname)(f).toLowerCase();
950
1548
  return ext === ".html" && (name.includes("insight") || name.includes("ai") || name.includes("analysis"));
951
- }).map((f) => (0, import_node_path6.join)(dir, f));
1549
+ }).map((f) => (0, import_node_path8.join)(dir, f));
952
1550
  } catch {
953
1551
  return [];
954
1552
  }
955
1553
  }
956
1554
 
1555
+ // src/parsers/network-parser.ts
1556
+ var import_node_fs11 = require("fs");
1557
+ var import_node_path9 = require("path");
1558
+
1559
+ // src/api-redactor.ts
1560
+ var REDACTED = "[REDACTED]";
1561
+ function redactHeaders(headers, redactList) {
1562
+ if (!headers) return null;
1563
+ const lower = new Set(redactList.map((h) => h.toLowerCase()));
1564
+ const out = {};
1565
+ for (const [k, v] of Object.entries(headers)) {
1566
+ out[k] = lower.has(k.toLowerCase()) ? REDACTED : v;
1567
+ }
1568
+ return out;
1569
+ }
1570
+ function redactJsonBody(value, fields) {
1571
+ if (value === null || typeof value !== "object") return value;
1572
+ if (Array.isArray(value)) return value.map((v) => redactJsonBody(v, fields));
1573
+ const out = {};
1574
+ for (const [k, v] of Object.entries(value)) {
1575
+ out[k] = fields.has(k) ? REDACTED : redactJsonBody(v, fields);
1576
+ }
1577
+ return out;
1578
+ }
1579
+ function redactBody(body, fields) {
1580
+ if (!body || fields.length === 0) return body;
1581
+ try {
1582
+ const parsed = JSON.parse(body);
1583
+ const redacted = redactJsonBody(parsed, new Set(fields));
1584
+ return JSON.stringify(redacted);
1585
+ } catch {
1586
+ return body;
1587
+ }
1588
+ }
1589
+ function redactApiCall(call, redactHeadersList, redactBodyFields) {
1590
+ return {
1591
+ ...call,
1592
+ requestHeaders: redactHeaders(call.requestHeaders, redactHeadersList),
1593
+ responseHeaders: redactHeaders(call.responseHeaders, redactHeadersList),
1594
+ requestBody: redactBody(call.requestBody, redactBodyFields),
1595
+ responseBody: call.isBinary ? call.responseBody : redactBody(call.responseBody, redactBodyFields)
1596
+ };
1597
+ }
1598
+ function matchesUrlFilter(url, include, exclude) {
1599
+ const matches = (pat) => typeof pat === "string" ? url.includes(pat) : pat.test(url);
1600
+ if (exclude.some(matches)) return false;
1601
+ if (include.length === 0) return true;
1602
+ return include.some(matches);
1603
+ }
1604
+
1605
+ // src/parsers/network-parser.ts
1606
+ function parseJsonlLine(line2) {
1607
+ const trimmed = line2.trim();
1608
+ if (!trimmed) return null;
1609
+ try {
1610
+ const obj = JSON.parse(trimmed);
1611
+ if (typeof obj.url !== "string" || typeof obj.method !== "string") return null;
1612
+ return {
1613
+ id: obj.id ?? `api-call-${Date.now()}`,
1614
+ timestamp: obj.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
1615
+ method: obj.method,
1616
+ url: obj.url,
1617
+ requestHeaders: obj.requestHeaders ?? null,
1618
+ requestBody: obj.requestBody ?? null,
1619
+ responseStatusCode: obj.responseStatusCode ?? null,
1620
+ responseStatusText: obj.responseStatusText ?? null,
1621
+ responseHeaders: obj.responseHeaders ?? null,
1622
+ responseBody: obj.responseBody ?? null,
1623
+ responseTimeMs: typeof obj.responseTimeMs === "number" ? obj.responseTimeMs : 0,
1624
+ isBinary: obj.isBinary ?? false,
1625
+ error: obj.error ?? null
1626
+ };
1627
+ } catch {
1628
+ return null;
1629
+ }
1630
+ }
1631
+ function parseNetworkJsonl(content, config) {
1632
+ const records = [];
1633
+ for (const line2 of content.split(/\r?\n/)) {
1634
+ const rec = parseJsonlLine(line2);
1635
+ if (!rec) continue;
1636
+ if (!matchesUrlFilter(rec.url, config.includeUrls, config.excludeUrls)) continue;
1637
+ records.push(redactApiCall(rec, config.redactHeaders, config.redactBodyFields));
1638
+ }
1639
+ return records;
1640
+ }
1641
+ function parseNetworkJsonlFile(filePath, config) {
1642
+ try {
1643
+ return parseNetworkJsonl((0, import_node_fs11.readFileSync)(filePath, "utf-8"), config);
1644
+ } catch {
1645
+ return [];
1646
+ }
1647
+ }
1648
+ function discoverNetworkJsonlFiles(dir) {
1649
+ if (!(0, import_node_fs11.existsSync)(dir)) return [];
1650
+ try {
1651
+ return (0, import_node_fs11.readdirSync)(dir, { recursive: true }).map(String).filter((f) => f.endsWith(".jsonl")).map((f) => (0, import_node_path9.join)(dir, f));
1652
+ } catch {
1653
+ return [];
1654
+ }
1655
+ }
1656
+ function harHeadersToRecord(headers) {
1657
+ if (!headers || headers.length === 0) return null;
1658
+ const out = {};
1659
+ for (const h of headers) {
1660
+ if (typeof h.name === "string" && typeof h.value === "string") out[h.name] = h.value;
1661
+ }
1662
+ return Object.keys(out).length > 0 ? out : null;
1663
+ }
1664
+ function harBodyToString(postData) {
1665
+ if (!postData) return null;
1666
+ if (typeof postData.text === "string") return postData.text;
1667
+ if (Array.isArray(postData.params)) {
1668
+ return postData.params.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value ?? "")}`).join("&");
1669
+ }
1670
+ return null;
1671
+ }
1672
+ function isBinaryMime(mime) {
1673
+ if (!mime) return false;
1674
+ return /^(image|audio|video|application\/(octet-stream|pdf|zip|protobuf))/.test(mime);
1675
+ }
1676
+ function harEntryToRecord(entry, index) {
1677
+ const req = entry.request;
1678
+ const res = entry.response;
1679
+ if (!req?.method || !req.url) return null;
1680
+ const content = res?.content;
1681
+ const isBinary = isBinaryMime(content?.mimeType);
1682
+ return {
1683
+ id: `har-call-${index}`,
1684
+ timestamp: entry.startedDateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
1685
+ method: req.method,
1686
+ url: req.url,
1687
+ requestHeaders: harHeadersToRecord(req.headers),
1688
+ requestBody: harBodyToString(req.postData),
1689
+ responseStatusCode: typeof res?.status === "number" && res.status > 0 ? res.status : null,
1690
+ responseStatusText: res?.statusText ?? null,
1691
+ responseHeaders: harHeadersToRecord(res?.headers),
1692
+ responseBody: content?.text ?? null,
1693
+ responseTimeMs: typeof entry.time === "number" ? entry.time : 0,
1694
+ isBinary,
1695
+ error: null
1696
+ };
1697
+ }
1698
+ function parseHar(content, config) {
1699
+ let parsed;
1700
+ try {
1701
+ parsed = JSON.parse(content);
1702
+ } catch {
1703
+ return [];
1704
+ }
1705
+ const entries = parsed.log?.entries ?? [];
1706
+ const records = [];
1707
+ entries.forEach((entry, i) => {
1708
+ const rec = harEntryToRecord(entry, i);
1709
+ if (!rec) return;
1710
+ if (!matchesUrlFilter(rec.url, config.includeUrls, config.excludeUrls)) return;
1711
+ records.push(redactApiCall(rec, config.redactHeaders, config.redactBodyFields));
1712
+ });
1713
+ return records;
1714
+ }
1715
+ function parseHarFile(filePath, config) {
1716
+ try {
1717
+ return parseHar((0, import_node_fs11.readFileSync)(filePath, "utf-8"), config);
1718
+ } catch {
1719
+ return [];
1720
+ }
1721
+ }
1722
+ function bindApiCallsToFlows(calls, flows) {
1723
+ const buckets = /* @__PURE__ */ new Map();
1724
+ for (const f of flows) buckets.set(f.testId, []);
1725
+ if (calls.length === 0 || flows.length === 0) return buckets;
1726
+ const windows = flows.map((f) => ({
1727
+ testId: f.testId,
1728
+ startMs: Date.parse(f.startedAt),
1729
+ endMs: Date.parse(f.completedAt)
1730
+ }));
1731
+ for (const call of calls) {
1732
+ const t = Date.parse(call.timestamp);
1733
+ if (!Number.isFinite(t)) continue;
1734
+ const hit = windows.find((w) => Number.isFinite(w.startMs) && Number.isFinite(w.endMs) && t >= w.startMs && t <= w.endMs);
1735
+ if (hit) {
1736
+ buckets.get(hit.testId).push(call);
1737
+ continue;
1738
+ }
1739
+ let best = null;
1740
+ for (const w of windows) {
1741
+ if (!Number.isFinite(w.startMs) || !Number.isFinite(w.endMs)) continue;
1742
+ const dist = t < w.startMs ? w.startMs - t : t - w.endMs;
1743
+ if (!best || dist < best.dist) best = { testId: w.testId, dist };
1744
+ }
1745
+ if (best) buckets.get(best.testId).push(call);
1746
+ }
1747
+ return buckets;
1748
+ }
1749
+ function collectNetworkByFlow(jsonlDir, config, flows) {
1750
+ if (!config.enabled && !config.harPath) return /* @__PURE__ */ new Map();
1751
+ const all = [];
1752
+ if (jsonlDir) {
1753
+ for (const file of discoverNetworkJsonlFiles(jsonlDir)) {
1754
+ all.push(...parseNetworkJsonlFile(file, config));
1755
+ }
1756
+ }
1757
+ if (config.harPath && (0, import_node_fs11.existsSync)(config.harPath)) {
1758
+ all.push(...parseHarFile(config.harPath, config));
1759
+ }
1760
+ if (all.length === 0) return /* @__PURE__ */ new Map();
1761
+ return bindApiCallsToFlows(all, flows);
1762
+ }
1763
+ function reindexFlowCalls(calls) {
1764
+ return calls.map((c, i) => ({ ...c, id: `api-call-${i}` }));
1765
+ }
1766
+
957
1767
  // src/artifact-collector.ts
958
- var import_node_fs9 = require("fs");
959
- var import_node_path7 = require("path");
1768
+ var import_node_fs12 = require("fs");
1769
+ var import_node_path10 = require("path");
960
1770
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp"]);
961
1771
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov"]);
962
1772
  function walkDir(dir) {
963
1773
  const results = [];
964
- if (!(0, import_node_fs9.existsSync)(dir)) return results;
1774
+ if (!(0, import_node_fs12.existsSync)(dir)) return results;
965
1775
  function recurse(current) {
966
1776
  try {
967
- const entries = (0, import_node_fs9.readdirSync)(current, { withFileTypes: true });
1777
+ const entries = (0, import_node_fs12.readdirSync)(current, { withFileTypes: true });
968
1778
  for (const entry of entries) {
969
- const fullPath = (0, import_node_path7.join)(current, entry.name);
1779
+ const fullPath = (0, import_node_path10.join)(current, entry.name);
970
1780
  if (entry.isDirectory()) {
971
1781
  recurse(fullPath);
972
1782
  } else if (entry.isFile()) {
@@ -993,8 +1803,8 @@ function collectArtifacts(testOutputDir, debugOutputDir) {
993
1803
  for (const filePath of allFiles) {
994
1804
  if (seen.has(filePath)) continue;
995
1805
  seen.add(filePath);
996
- const name = (0, import_node_path7.basename)(filePath).toLowerCase();
997
- const ext = (0, import_node_path7.extname)(filePath).toLowerCase();
1806
+ const name = (0, import_node_path10.basename)(filePath).toLowerCase();
1807
+ const ext = (0, import_node_path10.extname)(filePath).toLowerCase();
998
1808
  if (IMAGE_EXTENSIONS.has(ext)) {
999
1809
  screenshotPaths.push(filePath);
1000
1810
  continue;
@@ -1055,24 +1865,106 @@ function mapCommandCategory(category) {
1055
1865
  return "custom_step";
1056
1866
  }
1057
1867
  }
1058
- function commandToAction(cmd) {
1868
+ function buildStepTitle(cmd) {
1869
+ const d = cmd.details;
1870
+ if (d) {
1871
+ switch (cmd.command) {
1872
+ case "inputText":
1873
+ case "pasteText":
1874
+ if (typeof d.text === "string") return `${cmd.command} \u2192 ${d.text}`;
1875
+ break;
1876
+ case "swipe":
1877
+ case "scroll":
1878
+ case "scrollUntilVisible":
1879
+ if (typeof d.direction === "string") return `${cmd.command} ${d.direction}`;
1880
+ break;
1881
+ case "setLocation":
1882
+ if (typeof d.latitude === "number" && typeof d.longitude === "number") {
1883
+ return `setLocation ${d.latitude},${d.longitude}`;
1884
+ }
1885
+ break;
1886
+ case "pressKey":
1887
+ if (typeof d.key === "string") return `pressKey ${d.key}`;
1888
+ break;
1889
+ case "setOrientation":
1890
+ if (typeof d.orientation === "string") return `setOrientation ${d.orientation}`;
1891
+ break;
1892
+ case "launchApp":
1893
+ case "killApp":
1894
+ case "stopApp":
1895
+ case "clearState":
1896
+ if (typeof d.appId === "string") return `${cmd.command} ${d.appId}`;
1897
+ break;
1898
+ case "openLink":
1899
+ if (typeof d.url === "string") return `openLink ${d.url}`;
1900
+ break;
1901
+ case "runScript":
1902
+ case "evalScript":
1903
+ if (typeof d.path === "string") return `${cmd.command} ${d.path}`;
1904
+ if (typeof d.sourceDescription === "string") return `${cmd.command} ${d.sourceDescription}`;
1905
+ break;
1906
+ case "assertWithAI":
1907
+ case "assertNoDefectsWithAI":
1908
+ case "extractTextWithAI":
1909
+ if (typeof d.prompt === "string") return `${cmd.command} \u2192 ${d.prompt}`;
1910
+ break;
1911
+ case "retry":
1912
+ if (typeof d.maxRetries === "number") return `retry \xD7 ${d.maxRetries}`;
1913
+ break;
1914
+ case "repeat":
1915
+ if (typeof d.times === "number") return `repeat \xD7 ${d.times}`;
1916
+ break;
1917
+ case "runFlow":
1918
+ if (typeof d.file === "string") return `runFlow ${d.file}`;
1919
+ break;
1920
+ }
1921
+ }
1922
+ return cmd.selector ? `${cmd.command} \u2192 ${cmd.selector}` : cmd.command;
1923
+ }
1924
+ function commandToAction(cmd, flowStartedMs) {
1925
+ const cmdMs = Date.parse(cmd.timestamp);
1926
+ const videoOffset = Number.isFinite(cmdMs) && Number.isFinite(flowStartedMs) ? Math.max(0, (cmdMs - flowStartedMs) / 1e3) : null;
1927
+ const children = (cmd.children ?? []).map((c) => commandToAction(c, flowStartedMs));
1059
1928
  return {
1060
- title: cmd.selector ? `${cmd.command} \u2192 ${cmd.selector}` : cmd.command,
1929
+ title: buildStepTitle(cmd),
1061
1930
  category: mapCommandCategory(cmd.category),
1062
1931
  status: cmd.status === "failed" ? "failed" : "passed",
1063
1932
  duration: cmd.duration,
1064
1933
  timestamp: cmd.timestamp,
1065
- videoOffset: null,
1934
+ videoOffset,
1066
1935
  error: cmd.error ?? null,
1067
- children: []
1936
+ children
1068
1937
  };
1069
1938
  }
1070
- function buildTestResult(flow) {
1939
+ function sortActions(a, b) {
1940
+ const ta = Date.parse(a.timestamp);
1941
+ const tb = Date.parse(b.timestamp);
1942
+ if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) return ta - tb;
1943
+ return 0;
1944
+ }
1945
+ function buildTestResult(flow, apiCalls) {
1946
+ const flowStartedMs = Date.parse(flow.startedAt);
1947
+ const orderedCommands = [...flow.commands].sort((a, b) => {
1948
+ const ta = Date.parse(a.timestamp);
1949
+ const tb = Date.parse(b.timestamp);
1950
+ if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) return ta - tb;
1951
+ const sa = a.sequenceNumber ?? 0;
1952
+ const sb = b.sequenceNumber ?? 0;
1953
+ return sa - sb;
1954
+ });
1955
+ const orderedAssertions = [...flow.assertions].sort((a, b) => {
1956
+ const ta = Date.parse(a.timestamp);
1957
+ const tb = Date.parse(b.timestamp);
1958
+ if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) return ta - tb;
1959
+ const sa = a.sequenceNumber ?? 0;
1960
+ const sb = b.sequenceNumber ?? 0;
1961
+ return sa - sb;
1962
+ });
1071
1963
  const actions = [
1072
- ...flow.commands.map(commandToAction),
1073
- ...flow.assertions.map(commandToAction)
1964
+ ...orderedCommands.map((c) => commandToAction(c, flowStartedMs)),
1965
+ ...orderedAssertions.map((c) => commandToAction(c, flowStartedMs))
1074
1966
  ];
1075
- actions.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
1967
+ actions.sort(sortActions);
1076
1968
  const artifacts = flow.screenshotPaths.length > 0 || flow.videoPath ? {
1077
1969
  screenshot: flow.screenshotPaths[0] ?? void 0,
1078
1970
  video: flow.videoPath ?? void 0
@@ -1100,14 +1992,16 @@ function buildTestResult(flow) {
1100
1992
  actualStatus: flow.status,
1101
1993
  artifacts,
1102
1994
  networkRequests: null,
1103
- apiCalls: null,
1995
+ apiCalls: apiCalls.length > 0 ? [...apiCalls] : null,
1104
1996
  apiAssertions: null,
1105
1997
  actions: actions.length > 0 ? actions : null,
1106
1998
  consoleLogs: null
1107
1999
  };
1108
2000
  }
1109
- function buildTimelineEntry(flow) {
1110
- const testResult = buildTestResult(flow);
2001
+ function buildTimelineEntry(flow, apiCallsByFlow) {
2002
+ const testId = `${flow.flowFile}::${flow.flowName}`;
2003
+ const apiCalls = apiCallsByFlow?.get(testId) ?? [];
2004
+ const testResult = buildTestResult(flow, apiCalls);
1111
2005
  return {
1112
2006
  url: flow.appId ?? "maestro-flow",
1113
2007
  navigationType: "page_load",
@@ -1120,8 +2014,8 @@ function buildTimelineEntry(flow) {
1120
2014
  tests: [testResult]
1121
2015
  };
1122
2016
  }
1123
- function buildTimeline(flows) {
1124
- return flows.map(buildTimelineEntry);
2017
+ function buildTimeline(flows, apiCallsByFlow) {
2018
+ return flows.map((f) => buildTimelineEntry(f, apiCallsByFlow));
1125
2019
  }
1126
2020
 
1127
2021
  // src/summary-builder.ts
@@ -1132,6 +2026,58 @@ var EMPTY_STATUS_RANGE = {
1132
2026
  "5xx": 0,
1133
2027
  error: 0
1134
2028
  };
2029
+ function statusBucket(code) {
2030
+ if (code === null) return "error";
2031
+ if (code >= 200 && code < 300) return "2xx";
2032
+ if (code >= 300 && code < 400) return "3xx";
2033
+ if (code >= 400 && code < 500) return "4xx";
2034
+ if (code >= 500 && code < 600) return "5xx";
2035
+ return "error";
2036
+ }
2037
+ function percentile(sorted, p) {
2038
+ if (sorted.length === 0) return null;
2039
+ const idx = Math.min(sorted.length - 1, Math.floor(p / 100 * sorted.length));
2040
+ return sorted[idx];
2041
+ }
2042
+ function buildApiStats(calls) {
2043
+ if (calls.length === 0) {
2044
+ return {
2045
+ totalApiCalls: 0,
2046
+ uniqueApiUrls: 0,
2047
+ apiCallsByMethod: {},
2048
+ apiCallsByStatusRange: { ...EMPTY_STATUS_RANGE },
2049
+ apiResponseTime: null
2050
+ };
2051
+ }
2052
+ const byMethod = {};
2053
+ const byStatus = { ...EMPTY_STATUS_RANGE };
2054
+ const urls = /* @__PURE__ */ new Set();
2055
+ const times = [];
2056
+ for (const c of calls) {
2057
+ byMethod[c.method] = (byMethod[c.method] ?? 0) + 1;
2058
+ byStatus[statusBucket(c.responseStatusCode)] += 1;
2059
+ urls.add(c.url);
2060
+ if (Number.isFinite(c.responseTimeMs) && c.responseTimeMs > 0) {
2061
+ times.push(c.responseTimeMs);
2062
+ }
2063
+ }
2064
+ times.sort((a, b) => a - b);
2065
+ const apiResponseTime = times.length > 0 ? {
2066
+ p50: percentile(times, 50) ?? 0,
2067
+ p95: percentile(times, 95) ?? 0,
2068
+ p99: percentile(times, 99) ?? 0,
2069
+ avg: times.reduce((s, n) => s + n, 0) / times.length,
2070
+ min: times[0],
2071
+ max: times[times.length - 1]
2072
+ } : null;
2073
+ return {
2074
+ totalApiCalls: calls.length,
2075
+ uniqueApiUrls: urls.size,
2076
+ apiCallsByMethod: byMethod,
2077
+ apiCallsByStatusRange: byStatus,
2078
+ apiResponseTime
2079
+ };
2080
+ }
1135
2081
  function buildSummary(timeline) {
1136
2082
  let total = 0;
1137
2083
  let passed = 0;
@@ -1146,6 +2092,7 @@ function buildSummary(timeline) {
1146
2092
  let totalActionSteps = 0;
1147
2093
  const actionCategoryCounts = {};
1148
2094
  const uniqueUrls = /* @__PURE__ */ new Set();
2095
+ const allApiCalls = [];
1149
2096
  for (const entry of timeline) {
1150
2097
  uniqueUrls.add(entry.url);
1151
2098
  for (const test of entry.tests) {
@@ -1180,8 +2127,12 @@ function buildSummary(timeline) {
1180
2127
  }
1181
2128
  }
1182
2129
  }
2130
+ if (test.apiCalls && test.apiCalls.length > 0) {
2131
+ allApiCalls.push(...test.apiCalls);
2132
+ }
1183
2133
  }
1184
2134
  }
2135
+ const apiStats = buildApiStats(allApiCalls);
1185
2136
  return {
1186
2137
  total,
1187
2138
  passed,
@@ -1189,11 +2140,7 @@ function buildSummary(timeline) {
1189
2140
  flaky,
1190
2141
  skipped,
1191
2142
  timedout,
1192
- totalApiCalls: 0,
1193
- uniqueApiUrls: 0,
1194
- apiCallsByMethod: {},
1195
- apiCallsByStatusRange: EMPTY_STATUS_RANGE,
1196
- apiResponseTime: null,
2143
+ ...apiStats,
1197
2144
  totalAssertions,
1198
2145
  passedAssertions,
1199
2146
  failedAssertions,
@@ -1206,8 +2153,8 @@ function buildSummary(timeline) {
1206
2153
  }
1207
2154
 
1208
2155
  // src/html-report.ts
1209
- var import_node_fs10 = require("fs");
1210
- var import_node_path8 = require("path");
2156
+ var import_node_fs13 = require("fs");
2157
+ var import_node_path11 = require("path");
1211
2158
 
1212
2159
  // src/html-css.ts
1213
2160
  var CSS = `
@@ -2469,13 +3416,13 @@ function renderHtmlDocument(reportJson) {
2469
3416
 
2470
3417
  // src/html-report.ts
2471
3418
  function generateHtmlReport(report, aiDefects, screenshotPaths, outputPath) {
2472
- const htmlPath = (0, import_node_path8.resolve)(outputPath);
2473
- const htmlDir = (0, import_node_path8.dirname)(htmlPath);
2474
- if (!(0, import_node_fs10.existsSync)(htmlDir)) (0, import_node_fs10.mkdirSync)(htmlDir, { recursive: true });
3419
+ const htmlPath = (0, import_node_path11.resolve)(outputPath);
3420
+ const htmlDir = (0, import_node_path11.dirname)(htmlPath);
3421
+ if (!(0, import_node_fs13.existsSync)(htmlDir)) (0, import_node_fs13.mkdirSync)(htmlDir, { recursive: true });
2475
3422
  const payload = { report, aiDefects, screenshotPaths };
2476
3423
  const reportJson = JSON.stringify(payload);
2477
3424
  const html = renderHtmlDocument(reportJson);
2478
- (0, import_node_fs10.writeFileSync)(htmlPath, html, "utf-8");
3425
+ (0, import_node_fs13.writeFileSync)(htmlPath, html, "utf-8");
2479
3426
  }
2480
3427
 
2481
3428
  // src/console-summary.ts
@@ -2538,7 +3485,7 @@ function printConsoleSummary(summary, outputPath, htmlReportPath, quiet, aiDefec
2538
3485
  }
2539
3486
 
2540
3487
  // src/browser-open.ts
2541
- var import_node_child_process2 = require("child_process");
3488
+ var import_node_child_process4 = require("child_process");
2542
3489
  function openInBrowser(filePath) {
2543
3490
  const platform = process.platform;
2544
3491
  let command;
@@ -2549,14 +3496,14 @@ function openInBrowser(filePath) {
2549
3496
  } else {
2550
3497
  command = `xdg-open "${filePath}"`;
2551
3498
  }
2552
- (0, import_node_child_process2.exec)(command, () => {
3499
+ (0, import_node_child_process4.exec)(command, () => {
2553
3500
  });
2554
3501
  }
2555
3502
 
2556
3503
  // src/cloud-client.ts
2557
- var import_node_fs13 = require("fs");
2558
- var import_node_path11 = require("path");
2559
- var import_node_crypto2 = require("crypto");
3504
+ var import_node_fs16 = require("fs");
3505
+ var import_node_path14 = require("path");
3506
+ var import_node_crypto3 = require("crypto");
2560
3507
 
2561
3508
  // src/cloud-auth.ts
2562
3509
  var import_core3 = require("@testrelic/core");
@@ -2587,7 +3534,7 @@ async function healthCheck(endpoint) {
2587
3534
  }
2588
3535
  }
2589
3536
  async function sleep(ms) {
2590
- return new Promise((resolve5) => setTimeout(resolve5, ms));
3537
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
2591
3538
  }
2592
3539
  async function parseCloudError(response) {
2593
3540
  try {
@@ -2737,13 +3684,13 @@ async function resolveRepo(endpoint, accessToken, gitId, displayName, timeout, b
2737
3684
  }
2738
3685
 
2739
3686
  // src/git-metadata.ts
2740
- var import_node_child_process3 = require("child_process");
2741
- var import_node_fs11 = require("fs");
2742
- var import_node_path9 = require("path");
3687
+ var import_node_child_process5 = require("child_process");
3688
+ var import_node_fs14 = require("fs");
3689
+ var import_node_path12 = require("path");
2743
3690
  var GIT_TIMEOUT_MS = 5e3;
2744
3691
  function execGit(command, cwd) {
2745
3692
  try {
2746
- const result = (0, import_node_child_process3.execSync)(command, {
3693
+ const result = (0, import_node_child_process5.execSync)(command, {
2747
3694
  cwd,
2748
3695
  timeout: GIT_TIMEOUT_MS,
2749
3696
  encoding: "utf-8",
@@ -2788,7 +3735,7 @@ function deriveRepoDisplayName(normalizedUrl) {
2788
3735
  }
2789
3736
  function readPackageJsonName(dirPath) {
2790
3737
  try {
2791
- const raw = (0, import_node_fs11.readFileSync)((0, import_node_path9.join)(dirPath, "package.json"), "utf-8");
3738
+ const raw = (0, import_node_fs14.readFileSync)((0, import_node_path12.join)(dirPath, "package.json"), "utf-8");
2792
3739
  const pkg = JSON.parse(raw);
2793
3740
  return typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : null;
2794
3741
  } catch {
@@ -2798,25 +3745,25 @@ function readPackageJsonName(dirPath) {
2798
3745
  function deriveNonGitProjectId(dirPath) {
2799
3746
  const pkgName = readPackageJsonName(dirPath);
2800
3747
  if (pkgName) return pkgName;
2801
- return `local/${(0, import_node_path9.basename)(dirPath)}`;
3748
+ return `local/${(0, import_node_path12.basename)(dirPath)}`;
2802
3749
  }
2803
3750
 
2804
3751
  // src/cloud-queue.ts
2805
- var import_node_fs12 = require("fs");
2806
- var import_node_path10 = require("path");
3752
+ var import_node_fs15 = require("fs");
3753
+ var import_node_path13 = require("path");
2807
3754
  var import_core4 = require("@testrelic/core");
2808
3755
  var QUEUE_ENTRY_VERSION = "1.0.0";
2809
3756
  var FLUSH_BATCH_SIZE = 10;
2810
3757
  function writeToQueue(queueDirectory, runId, type, reason, targetEndpoint, method, payload, headers) {
2811
3758
  try {
2812
- (0, import_node_fs12.mkdirSync)(queueDirectory, { recursive: true });
3759
+ (0, import_node_fs15.mkdirSync)(queueDirectory, { recursive: true });
2813
3760
  const timestamp = Date.now();
2814
3761
  const filename = `${timestamp}-${runId}-${type}.json`;
2815
- const filePath = (0, import_node_path10.join)(queueDirectory, filename);
3762
+ const filePath = (0, import_node_path13.join)(queueDirectory, filename);
2816
3763
  const entry = { version: QUEUE_ENTRY_VERSION, queuedAt: new Date(timestamp).toISOString(), reason, retryCount: 0, targetEndpoint, method, payload, headers };
2817
3764
  const tmpPath = filePath + ".tmp";
2818
- (0, import_node_fs12.writeFileSync)(tmpPath, JSON.stringify(entry, null, 2), "utf-8");
2819
- (0, import_node_fs12.renameSync)(tmpPath, filePath);
3765
+ (0, import_node_fs15.writeFileSync)(tmpPath, JSON.stringify(entry, null, 2), "utf-8");
3766
+ (0, import_node_fs15.renameSync)(tmpPath, filePath);
2820
3767
  } catch (err) {
2821
3768
  const code = err.code;
2822
3769
  if (code === "ENOSPC") {
@@ -2829,7 +3776,7 @@ function writeToQueue(queueDirectory, runId, type, reason, targetEndpoint, metho
2829
3776
  async function flushQueue(queueDirectory, endpoint, accessToken) {
2830
3777
  let files;
2831
3778
  try {
2832
- files = (0, import_node_fs12.readdirSync)(queueDirectory).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp")).sort();
3779
+ files = (0, import_node_fs15.readdirSync)(queueDirectory).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp")).sort();
2833
3780
  } catch {
2834
3781
  return;
2835
3782
  }
@@ -2840,14 +3787,14 @@ async function flushQueue(queueDirectory, endpoint, accessToken) {
2840
3787
  }
2841
3788
  for (const batch of batches) {
2842
3789
  for (const file of batch) {
2843
- const filePath = (0, import_node_path10.join)(queueDirectory, file);
3790
+ const filePath = (0, import_node_path13.join)(queueDirectory, file);
2844
3791
  try {
2845
- const stat = (0, import_node_fs12.statSync)(filePath);
3792
+ const stat = (0, import_node_fs15.statSync)(filePath);
2846
3793
  if (!stat.isFile()) continue;
2847
- const raw = (0, import_node_fs12.readFileSync)(filePath, "utf-8");
3794
+ const raw = (0, import_node_fs15.readFileSync)(filePath, "utf-8");
2848
3795
  const entry = JSON.parse(raw);
2849
3796
  if (!(0, import_core4.isValidQueueEntry)(entry)) {
2850
- (0, import_node_fs12.unlinkSync)(filePath);
3797
+ (0, import_node_fs15.unlinkSync)(filePath);
2851
3798
  continue;
2852
3799
  }
2853
3800
  const queueEntry = entry;
@@ -2857,7 +3804,7 @@ async function flushQueue(queueDirectory, endpoint, accessToken) {
2857
3804
  body: JSON.stringify(queueEntry.payload)
2858
3805
  });
2859
3806
  if (response.ok) {
2860
- (0, import_node_fs12.unlinkSync)(filePath);
3807
+ (0, import_node_fs15.unlinkSync)(filePath);
2861
3808
  } else {
2862
3809
  return;
2863
3810
  }
@@ -2869,21 +3816,21 @@ async function flushQueue(queueDirectory, endpoint, accessToken) {
2869
3816
  }
2870
3817
  function cleanupExpiredQueue(queueDirectory, maxAge) {
2871
3818
  try {
2872
- const files = (0, import_node_fs12.readdirSync)(queueDirectory).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp"));
3819
+ const files = (0, import_node_fs15.readdirSync)(queueDirectory).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp"));
2873
3820
  const now = Date.now();
2874
3821
  for (const file of files) {
2875
- const filePath = (0, import_node_path10.join)(queueDirectory, file);
3822
+ const filePath = (0, import_node_path13.join)(queueDirectory, file);
2876
3823
  try {
2877
- const raw = (0, import_node_fs12.readFileSync)(filePath, "utf-8");
3824
+ const raw = (0, import_node_fs15.readFileSync)(filePath, "utf-8");
2878
3825
  const entry = JSON.parse(raw);
2879
3826
  const queuedAt = entry.queuedAt;
2880
3827
  if (typeof queuedAt === "string") {
2881
3828
  const queuedTime = new Date(queuedAt).getTime();
2882
- if (now - queuedTime > maxAge) (0, import_node_fs12.unlinkSync)(filePath);
3829
+ if (now - queuedTime > maxAge) (0, import_node_fs15.unlinkSync)(filePath);
2883
3830
  }
2884
3831
  } catch {
2885
3832
  try {
2886
- (0, import_node_fs12.unlinkSync)(filePath);
3833
+ (0, import_node_fs15.unlinkSync)(filePath);
2887
3834
  } catch {
2888
3835
  }
2889
3836
  }
@@ -3026,7 +3973,7 @@ var CloudClient = class {
3026
3973
  }
3027
3974
  if (this.flushPromise) {
3028
3975
  try {
3029
- await Promise.race([this.flushPromise, new Promise((resolve5) => setTimeout(resolve5, FLUSH_TIMEOUT_MS))]);
3976
+ await Promise.race([this.flushPromise, new Promise((resolve6) => setTimeout(resolve6, FLUSH_TIMEOUT_MS))]);
3030
3977
  } catch {
3031
3978
  }
3032
3979
  this.flushPromise = null;
@@ -3084,10 +4031,10 @@ var CloudClient = class {
3084
4031
  }
3085
4032
  readRepoCache(gitId) {
3086
4033
  if (!this.config) return null;
3087
- const cachePath = (0, import_node_path11.join)(this.config.queueDirectory, "..", "cache", "repo.json");
4034
+ const cachePath = (0, import_node_path14.join)(this.config.queueDirectory, "..", "cache", "repo.json");
3088
4035
  try {
3089
- if (!(0, import_node_fs13.existsSync)(cachePath)) return null;
3090
- const raw = (0, import_node_fs13.readFileSync)(cachePath, "utf-8");
4036
+ if (!(0, import_node_fs16.existsSync)(cachePath)) return null;
4037
+ const raw = (0, import_node_fs16.readFileSync)(cachePath, "utf-8");
3091
4038
  const cache = JSON.parse(raw);
3092
4039
  if (cache.gitId !== gitId) return null;
3093
4040
  if (Date.now() - cache.resolvedAt > PROJECT_CACHE_TTL_MS) return null;
@@ -3100,20 +4047,20 @@ var CloudClient = class {
3100
4047
  }
3101
4048
  writeRepoCache(gitId, repoId, displayName) {
3102
4049
  if (!this.config) return;
3103
- const cacheDir = (0, import_node_path11.join)(this.config.queueDirectory, "..", "cache");
3104
- const cachePath = (0, import_node_path11.join)(cacheDir, "repo.json");
4050
+ const cacheDir = (0, import_node_path14.join)(this.config.queueDirectory, "..", "cache");
4051
+ const cachePath = (0, import_node_path14.join)(cacheDir, "repo.json");
3105
4052
  try {
3106
- (0, import_node_fs13.mkdirSync)(cacheDir, { recursive: true });
4053
+ (0, import_node_fs16.mkdirSync)(cacheDir, { recursive: true });
3107
4054
  const cache = { repoId, gitId, displayName, resolvedAt: Date.now(), apiKeyHash: this.hashApiKey() ?? "" };
3108
4055
  const tmpPath = cachePath + ".tmp";
3109
- (0, import_node_fs13.writeFileSync)(tmpPath, JSON.stringify(cache, null, 2), "utf-8");
3110
- (0, import_node_fs13.renameSync)(tmpPath, cachePath);
4056
+ (0, import_node_fs16.writeFileSync)(tmpPath, JSON.stringify(cache, null, 2), "utf-8");
4057
+ (0, import_node_fs16.renameSync)(tmpPath, cachePath);
3111
4058
  } catch {
3112
4059
  }
3113
4060
  }
3114
4061
  hashApiKey() {
3115
4062
  if (!this.config?.apiKey) return null;
3116
- return (0, import_node_crypto2.createHash)("sha256").update(this.config.apiKey).digest("hex").substring(0, 16);
4063
+ return (0, import_node_crypto3.createHash)("sha256").update(this.config.apiKey).digest("hex").substring(0, 16);
3117
4064
  }
3118
4065
  startBackgroundFlush() {
3119
4066
  if (!this.config || !this.authState.accessToken) return;
@@ -3122,7 +4069,7 @@ var CloudClient = class {
3122
4069
  const endpoint = this.config.endpoint;
3123
4070
  this.flushPromise = Promise.race([
3124
4071
  flushQueue(queueDir, endpoint, accessToken),
3125
- new Promise((resolve5) => setTimeout(resolve5, FLUSH_TIMEOUT_MS))
4072
+ new Promise((resolve6) => setTimeout(resolve6, FLUSH_TIMEOUT_MS))
3126
4073
  ]).catch(() => {
3127
4074
  });
3128
4075
  }
@@ -3179,7 +4126,7 @@ function buildUploadPayload(report, repoGitId, git, ci, tests) {
3179
4126
  return payload;
3180
4127
  }
3181
4128
  async function sleep2(ms) {
3182
- return new Promise((resolve5) => setTimeout(resolve5, ms));
4129
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
3183
4130
  }
3184
4131
  async function retryWithBackoff(url, init, onTokenRefresh) {
3185
4132
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
@@ -3255,8 +4202,8 @@ async function uploadBatchRun(endpoint, accessToken, payload, onTokenRefresh) {
3255
4202
  }
3256
4203
 
3257
4204
  // src/cloud-artifact-upload.ts
3258
- var import_node_fs14 = require("fs");
3259
- var import_node_path12 = require("path");
4205
+ var import_node_fs17 = require("fs");
4206
+ var import_node_path15 = require("path");
3260
4207
  var import_node_stream = require("stream");
3261
4208
  var UPLOAD_CONCURRENCY = 5;
3262
4209
  var RETRY_DELAYS_MS2 = [1e3, 3e3, 9e3];
@@ -3275,7 +4222,7 @@ var activeUploads = 0;
3275
4222
  var pendingUploads = [];
3276
4223
  async function acquireSlot() {
3277
4224
  if (activeUploads >= UPLOAD_CONCURRENCY) {
3278
- await new Promise((resolve5) => pendingUploads.push(resolve5));
4225
+ await new Promise((resolve6) => pendingUploads.push(resolve6));
3279
4226
  }
3280
4227
  activeUploads++;
3281
4228
  }
@@ -3284,7 +4231,7 @@ function releaseSlot() {
3284
4231
  if (pendingUploads.length > 0) pendingUploads.shift()();
3285
4232
  }
3286
4233
  async function sleep3(ms) {
3287
- return new Promise((resolve5) => setTimeout(resolve5, ms));
4234
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
3288
4235
  }
3289
4236
  function getContentType(filePath) {
3290
4237
  const ext = filePath.substring(filePath.lastIndexOf(".")).toLowerCase();
@@ -3292,14 +4239,14 @@ function getContentType(filePath) {
3292
4239
  }
3293
4240
  function getFileSize(filePath) {
3294
4241
  try {
3295
- return (0, import_node_fs14.statSync)(filePath).size;
4242
+ return (0, import_node_fs17.statSync)(filePath).size;
3296
4243
  } catch {
3297
4244
  return 0;
3298
4245
  }
3299
4246
  }
3300
4247
  async function requestUploadUrl(endpoint, accessToken, request, sizeBytes, contentType) {
3301
4248
  const url = `${endpoint}/artifacts/upload-url`;
3302
- const body = JSON.stringify({ runId: request.runId, testId: request.testId, fileName: (0, import_node_path12.basename)(request.filePath), contentType, type: request.type, sizeBytes });
4249
+ const body = JSON.stringify({ runId: request.runId, testId: request.testId, fileName: (0, import_node_path15.basename)(request.filePath), contentType, type: request.type, sizeBytes });
3303
4250
  for (let attempt = 0; attempt < MAX_RETRIES2; attempt++) {
3304
4251
  try {
3305
4252
  const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${accessToken}` }, body });
@@ -3322,7 +4269,7 @@ async function requestUploadUrl(endpoint, accessToken, request, sizeBytes, conte
3322
4269
  async function putFileToPresignedUrl(presignedUrl, filePath, contentType, sizeBytes) {
3323
4270
  for (let attempt = 0; attempt < MAX_RETRIES2; attempt++) {
3324
4271
  try {
3325
- const nodeStream = (0, import_node_fs14.createReadStream)(filePath);
4272
+ const nodeStream = (0, import_node_fs17.createReadStream)(filePath);
3326
4273
  const webStream = import_node_stream.Readable.toWeb(nodeStream);
3327
4274
  const response = await fetch(presignedUrl, {
3328
4275
  method: "PUT",
@@ -3615,35 +4562,35 @@ async function finalizeAndUpload(cloudClient, cloudConfig, testRunId, report, co
3615
4562
  // src/report-orchestrator.ts
3616
4563
  function matchVideoToFlow(flowFile, videoPaths, flowRecordingPath) {
3617
4564
  if (videoPaths.length === 0) return null;
3618
- const flowBase = (0, import_node_path13.basename)(flowFile, (0, import_node_path13.extname)(flowFile)).toLowerCase();
4565
+ const flowBase = (0, import_node_path16.basename)(flowFile, (0, import_node_path16.extname)(flowFile)).toLowerCase();
3619
4566
  const exact = videoPaths.find(
3620
- (v) => (0, import_node_path13.basename)(v, (0, import_node_path13.extname)(v)).toLowerCase() === flowBase
4567
+ (v) => (0, import_node_path16.basename)(v, (0, import_node_path16.extname)(v)).toLowerCase() === flowBase
3621
4568
  );
3622
4569
  if (exact) return exact;
3623
4570
  const forward = videoPaths.find(
3624
- (v) => (0, import_node_path13.basename)(v).toLowerCase().includes(flowBase)
4571
+ (v) => (0, import_node_path16.basename)(v).toLowerCase().includes(flowBase)
3625
4572
  );
3626
4573
  if (forward) return forward;
3627
4574
  const reverse = videoPaths.find((v) => {
3628
- const videoBase = (0, import_node_path13.basename)(v, (0, import_node_path13.extname)(v)).toLowerCase();
4575
+ const videoBase = (0, import_node_path16.basename)(v, (0, import_node_path16.extname)(v)).toLowerCase();
3629
4576
  return videoBase.length >= 3 && (flowBase.startsWith(videoBase) || flowBase.includes(videoBase));
3630
4577
  });
3631
4578
  if (reverse) return reverse;
3632
4579
  if (flowRecordingPath) {
3633
- const recBase = (0, import_node_path13.basename)(flowRecordingPath, (0, import_node_path13.extname)(flowRecordingPath)).toLowerCase();
3634
- const byRec = videoPaths.find((v) => (0, import_node_path13.basename)(v, (0, import_node_path13.extname)(v)).toLowerCase() === recBase);
4580
+ const recBase = (0, import_node_path16.basename)(flowRecordingPath, (0, import_node_path16.extname)(flowRecordingPath)).toLowerCase();
4581
+ const byRec = videoPaths.find((v) => (0, import_node_path16.basename)(v, (0, import_node_path16.extname)(v)).toLowerCase() === recBase);
3635
4582
  if (byRec) return byRec;
3636
- const byRecPartial = videoPaths.find((v) => (0, import_node_path13.basename)(v).toLowerCase().includes(recBase));
4583
+ const byRecPartial = videoPaths.find((v) => (0, import_node_path16.basename)(v).toLowerCase().includes(recBase));
3637
4584
  if (byRecPartial) return byRecPartial;
3638
4585
  }
3639
4586
  return null;
3640
4587
  }
3641
4588
  function matchScreenshotsToFlow(flowFile, screenshotPaths) {
3642
4589
  if (screenshotPaths.length === 0) return [];
3643
- const flowBase = (0, import_node_path13.basename)(flowFile, (0, import_node_path13.extname)(flowFile)).toLowerCase();
4590
+ const flowBase = (0, import_node_path16.basename)(flowFile, (0, import_node_path16.extname)(flowFile)).toLowerCase();
3644
4591
  const matched = screenshotPaths.filter((s) => {
3645
- const name = (0, import_node_path13.basename)(s).toLowerCase();
3646
- const nameBase = (0, import_node_path13.basename)(s, (0, import_node_path13.extname)(s)).toLowerCase();
4592
+ const name = (0, import_node_path16.basename)(s).toLowerCase();
4593
+ const nameBase = (0, import_node_path16.basename)(s, (0, import_node_path16.extname)(s)).toLowerCase();
3647
4594
  if (name.startsWith(flowBase) || name.includes(flowBase)) return true;
3648
4595
  return nameBase.length >= 3 && (flowBase.startsWith(nameBase) || flowBase.includes(nameBase));
3649
4596
  });
@@ -3660,21 +4607,24 @@ function extractRecordingPath(commands) {
3660
4607
  }
3661
4608
  return null;
3662
4609
  }
4610
+ function normaliseCommandFileName(filePath) {
4611
+ return (0, import_node_path16.basename)(filePath).replace(/^commands[-_]?/i, "").replace(/\.json$/i, "").replace(/^\(/, "").replace(/\)$/, "").toLowerCase();
4612
+ }
3663
4613
  function buildFlowRecordingMap(commandSteps) {
3664
4614
  const map = /* @__PURE__ */ new Map();
3665
4615
  for (const [filePath, commands] of commandSteps) {
3666
- const name = (0, import_node_path13.basename)(filePath).replace(/^commands[-_]?/i, "").replace(/\.json$/i, "").toLowerCase();
4616
+ const name = normaliseCommandFileName(filePath);
3667
4617
  const recPath = extractRecordingPath(commands);
3668
4618
  if (recPath && name) map.set(name, recPath);
3669
4619
  }
3670
4620
  return map;
3671
4621
  }
3672
4622
  function commandsForFlow(commandSteps, flowFile) {
3673
- const flowBase = (0, import_node_path13.basename)(flowFile, (0, import_node_path13.extname)(flowFile)).toLowerCase();
4623
+ const flowBase = (0, import_node_path16.basename)(flowFile, (0, import_node_path16.extname)(flowFile)).toLowerCase();
3674
4624
  if (!flowBase || commandSteps.size === 0) {
3675
4625
  return { commands: [], matchedAnyFile: false };
3676
4626
  }
3677
- const stripCmdPrefix = (p) => (0, import_node_path13.basename)(p, (0, import_node_path13.extname)(p)).replace(/^commands[-_]?/i, "").toLowerCase();
4627
+ const stripCmdPrefix = (p) => normaliseCommandFileName(p);
3678
4628
  for (const [cmdFile, cmds] of commandSteps) {
3679
4629
  if (stripCmdPrefix(cmdFile) === flowBase) {
3680
4630
  return { commands: cmds, matchedAnyFile: true };
@@ -3706,7 +4656,7 @@ function platformToOs(platform) {
3706
4656
  return void 0;
3707
4657
  }
3708
4658
  }
3709
- function flowToTestResult(flow) {
4659
+ function flowToTestResult(flow, apiCalls) {
3710
4660
  const consoleLogs = flow.logEntries.length > 0 ? flow.logEntries.map((entry) => ({
3711
4661
  level: entry.level.toLowerCase(),
3712
4662
  message: entry.message,
@@ -3733,7 +4683,8 @@ function flowToTestResult(flow) {
3733
4683
  platform: flow.platform !== "unknown" ? flow.platform : void 0,
3734
4684
  os: platformToOs(flow.platform),
3735
4685
  deviceName: flow.deviceId,
3736
- ...consoleLogs ? { consoleLogs } : {}
4686
+ ...consoleLogs ? { consoleLogs } : {},
4687
+ ...apiCalls && apiCalls.length > 0 ? { apiCalls } : {}
3737
4688
  };
3738
4689
  }
3739
4690
  function logEntriesForFlow(allEntries, flowStartedAt, flowCompletedAt) {
@@ -3752,13 +4703,19 @@ function logEntriesForFlow(allEntries, flowStartedAt, flowCompletedAt) {
3752
4703
  async function orchestrateReport(input) {
3753
4704
  const { config } = input;
3754
4705
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3755
- const testRunId = config.testRunId ?? (0, import_node_crypto3.randomUUID)();
4706
+ const testRunId = config.testRunId ?? (0, import_node_crypto4.randomUUID)();
3756
4707
  const artifacts = collectArtifacts(input.testOutputDir, input.debugOutputDir);
3757
4708
  const junitPath = input.junitPath ?? artifacts.junitReportPath;
3758
4709
  const logFiles = input.debugOutputDir ? discoverLogFiles(input.debugOutputDir) : artifacts.logPaths;
3759
4710
  const allLogEntries = [];
3760
4711
  for (const logFile of logFiles) {
3761
- const entries = parseLogFile(logFile);
4712
+ let mtimeDate;
4713
+ try {
4714
+ const { statSync: statSync5 } = await import("fs");
4715
+ mtimeDate = statSync5(logFile).mtime;
4716
+ } catch {
4717
+ }
4718
+ const entries = parseLogFile(logFile, mtimeDate);
3762
4719
  if (entries.length > 0) allLogEntries.push(...entries);
3763
4720
  }
3764
4721
  let platform = input.platform ?? "unknown";
@@ -3767,7 +4724,7 @@ async function orchestrateReport(input) {
3767
4724
  if (detected !== "unknown") platform = detected;
3768
4725
  }
3769
4726
  const flowMetadataMap = /* @__PURE__ */ new Map();
3770
- if (input.flowsDir && (0, import_node_fs15.existsSync)(input.flowsDir)) {
4727
+ if (input.flowsDir && (0, import_node_fs18.existsSync)(input.flowsDir)) {
3771
4728
  const flowFiles = discoverFlowFiles(input.flowsDir);
3772
4729
  for (const flowFile of flowFiles) {
3773
4730
  const meta = parseFlowFile(flowFile);
@@ -3788,7 +4745,7 @@ async function orchestrateReport(input) {
3788
4745
  }
3789
4746
  const flowRecordingMap = buildFlowRecordingMap(commandSteps);
3790
4747
  const flowResults = [];
3791
- if (junitPath && (0, import_node_fs15.existsSync)(junitPath)) {
4748
+ if (junitPath && (0, import_node_fs18.existsSync)(junitPath)) {
3792
4749
  const junit = parseJUnitFile(junitPath);
3793
4750
  const allCommands = Array.from(commandSteps.values()).flat();
3794
4751
  const allAssertions = allCommands.filter((c) => c.category === "assertion");
@@ -3799,14 +4756,19 @@ async function orchestrateReport(input) {
3799
4756
  const meta = flowMetadataMap.get(name);
3800
4757
  const status = testCase.status === "SUCCESS" ? "passed" : testCase.status === "SKIPPED" ? "skipped" : "failed";
3801
4758
  const durationMs = testCase.time * 1e3;
3802
- const flowStartedAt = startedAt;
3803
- const flowCompletedAt = new Date(new Date(flowStartedAt).getTime() + durationMs).toISOString();
3804
4759
  const flowFile = testCase.classname || name;
3805
- const flowBase = (0, import_node_path13.basename)(flowFile, (0, import_node_path13.extname)(flowFile)).toLowerCase();
4760
+ const flowBase = (0, import_node_path16.basename)(flowFile, (0, import_node_path16.extname)(flowFile)).toLowerCase();
3806
4761
  const recordingPath = flowRecordingMap.get(flowBase) ?? null;
3807
4762
  const { commands: flowCmds, matchedAnyFile } = commandsForFlow(commandSteps, flowFile);
3808
4763
  const perFlowCommands = matchedAnyFile ? flowCmds.filter((c) => c.category !== "assertion") : allNonAssertions;
3809
4764
  const perFlowAssertions = matchedAnyFile ? flowCmds.filter((c) => c.category === "assertion") : allAssertions;
4765
+ const allFlowCmds = [...perFlowCommands, ...perFlowAssertions];
4766
+ const earliestCmdMs = allFlowCmds.reduce((min, c) => {
4767
+ const t = Date.parse(c.timestamp);
4768
+ return Number.isFinite(t) && t < min ? t : min;
4769
+ }, Number.POSITIVE_INFINITY);
4770
+ const flowStartedAt = Number.isFinite(earliestCmdMs) ? new Date(earliestCmdMs).toISOString() : startedAt;
4771
+ const flowCompletedAt = new Date(new Date(flowStartedAt).getTime() + durationMs).toISOString();
3810
4772
  flowResults.push({
3811
4773
  flowName: name,
3812
4774
  flowFile,
@@ -3863,7 +4825,20 @@ async function orchestrateReport(input) {
3863
4825
  });
3864
4826
  }
3865
4827
  }
3866
- const timeline = buildTimeline(flowResults);
4828
+ const apiCallsByFlow = (() => {
4829
+ if (!config.network?.enabled && !config.network?.harPath) return void 0;
4830
+ const windows = flowResults.map((f) => ({
4831
+ testId: `${f.flowFile}::${f.flowName}`,
4832
+ startedAt: f.startedAt,
4833
+ completedAt: f.completedAt
4834
+ }));
4835
+ const bound = collectNetworkByFlow(input.networkJsonlDir ?? null, config.network, windows);
4836
+ if (bound.size === 0) return void 0;
4837
+ const remapped = /* @__PURE__ */ new Map();
4838
+ for (const [k, v] of bound) remapped.set(k, reindexFlowCalls(v));
4839
+ return remapped;
4840
+ })();
4841
+ const timeline = buildTimeline(flowResults, apiCallsByFlow);
3867
4842
  const summary = buildSummary(timeline);
3868
4843
  const completedAt = (/* @__PURE__ */ new Date()).toISOString();
3869
4844
  const totalDuration = Date.now() - new Date(startedAt).getTime();
@@ -3879,12 +4854,12 @@ async function orchestrateReport(input) {
3879
4854
  timeline,
3880
4855
  shardRunIds: null
3881
4856
  };
3882
- (0, import_node_fs15.mkdirSync)((0, import_node_path13.dirname)((0, import_node_path13.resolve)(config.outputPath)), { recursive: true });
3883
- (0, import_node_fs15.writeFileSync)((0, import_node_path13.resolve)(config.outputPath), JSON.stringify(report, null, 2), "utf-8");
3884
- generateHtmlReport(report, allAiDefects, artifacts.screenshotPaths, (0, import_node_path13.resolve)(config.htmlReportPath));
4857
+ (0, import_node_fs18.mkdirSync)((0, import_node_path16.dirname)((0, import_node_path16.resolve)(config.outputPath)), { recursive: true });
4858
+ (0, import_node_fs18.writeFileSync)((0, import_node_path16.resolve)(config.outputPath), JSON.stringify(report, null, 2), "utf-8");
4859
+ generateHtmlReport(report, allAiDefects, artifacts.screenshotPaths, (0, import_node_path16.resolve)(config.htmlReportPath));
3885
4860
  printConsoleSummary(summary, config.outputPath, config.htmlReportPath, config.quiet, allAiDefects);
3886
4861
  if (config.openReport) {
3887
- openInBrowser((0, import_node_path13.resolve)(config.htmlReportPath));
4862
+ openInBrowser((0, import_node_path16.resolve)(config.htmlReportPath));
3888
4863
  }
3889
4864
  if (config.cloud) {
3890
4865
  const cloudClient = new CloudClient(config.cloud);
@@ -3893,7 +4868,7 @@ async function orchestrateReport(input) {
3893
4868
  const videoArtifacts = [];
3894
4869
  if (videoPathsToUpload.length > 0) {
3895
4870
  for (const flow of flowResults) {
3896
- const flowBase = (0, import_node_path13.basename)(flow.flowFile, (0, import_node_path13.extname)(flow.flowFile)).toLowerCase();
4871
+ const flowBase = (0, import_node_path16.basename)(flow.flowFile, (0, import_node_path16.extname)(flow.flowFile)).toLowerCase();
3897
4872
  const recordingPath = flowRecordingMap.get(flowBase) ?? null;
3898
4873
  const matched = matchVideoToFlow(flow.flowFile, videoPathsToUpload, recordingPath);
3899
4874
  if (matched) {
@@ -3915,7 +4890,10 @@ async function orchestrateReport(input) {
3915
4890
  }
3916
4891
  const matchedPaths = new Set(videoArtifacts.map((va) => va.path));
3917
4892
  const unmatchedVideos = videoPathsToUpload.filter((v) => !matchedPaths.has(v));
3918
- const testsForUpload = flowResults.map(flowToTestResult);
4893
+ const testsForUpload = flowResults.map((f) => {
4894
+ const testId = `${f.flowFile}::${f.flowName}`;
4895
+ return flowToTestResult(f, apiCallsByFlow?.get(testId));
4896
+ });
3919
4897
  await finalizeAndUpload(
3920
4898
  cloudClient,
3921
4899
  config.cloud,
@@ -3962,6 +4940,16 @@ Test Options:
3962
4940
  -e, --env <KEY=VALUE> Environment variable
3963
4941
  --quiet Suppress output
3964
4942
 
4943
+ Network Capture Options (mobile API logs):
4944
+ --capture-network Spawn mitmproxy to capture HTTP traffic from the app
4945
+ (requires mitmdump on PATH; --har-path is an alternative)
4946
+ --proxy-port <n> Proxy listen port (default: 8080)
4947
+ --proxy-host <host> Proxy listen host (default: 127.0.0.1)
4948
+ --har-path <file> Import a pre-recorded HAR file instead of running a proxy
4949
+ --skip-cert-install Skip auto-installing the mitmproxy CA on the device
4950
+ --proxy-include <pat> Only capture URLs matching this substring or regex (repeatable)
4951
+ --proxy-exclude <pat> Drop URLs matching this substring or regex (repeatable)
4952
+
3965
4953
  Report Options:
3966
4954
  --junit <path> Path to JUnit XML report
3967
4955
  --artifacts <dir> Path to Maestro test output directory
@@ -4001,6 +4989,15 @@ function parseArgs(args) {
4001
4989
  } else {
4002
4990
  options["env"] = args[i];
4003
4991
  }
4992
+ } else if (key === "proxy-include" || key === "proxy-exclude") {
4993
+ const existing = options[key];
4994
+ if (Array.isArray(existing)) {
4995
+ existing.push(args[i]);
4996
+ } else if (typeof existing === "string") {
4997
+ options[key] = [existing, args[i]];
4998
+ } else {
4999
+ options[key] = args[i];
5000
+ }
4004
5001
  } else {
4005
5002
  options[key] = args[i];
4006
5003
  }
@@ -4036,11 +5033,30 @@ function parseEnvVars(raw) {
4036
5033
  }
4037
5034
  return env;
4038
5035
  }
5036
+ function asStringArray(v) {
5037
+ if (!v) return [];
5038
+ return Array.isArray(v) ? v : [v];
5039
+ }
5040
+ function buildNetworkOptions(options) {
5041
+ const enabled = options["capture-network"] === "true";
5042
+ const harPath = typeof options["har-path"] === "string" ? options["har-path"] : void 0;
5043
+ if (!enabled && !harPath) return void 0;
5044
+ return {
5045
+ enabled,
5046
+ proxyPort: typeof options["proxy-port"] === "string" ? Number.parseInt(options["proxy-port"], 10) : void 0,
5047
+ proxyHost: typeof options["proxy-host"] === "string" ? options["proxy-host"] : void 0,
5048
+ harPath,
5049
+ skipCertInstall: options["skip-cert-install"] === "true",
5050
+ includeUrls: asStringArray(options["proxy-include"]),
5051
+ excludeUrls: asStringArray(options["proxy-exclude"])
5052
+ };
5053
+ }
4039
5054
  async function handleTest(options, positional) {
4040
5055
  if (positional.length === 0) {
4041
5056
  process.stderr.write("Error: No flow paths specified. Usage: testrelic-maestro test <flow-paths...>\n");
4042
5057
  process.exit(1);
4043
5058
  }
5059
+ const network = buildNetworkOptions(options);
4044
5060
  const testOptions = {
4045
5061
  flowPaths: positional,
4046
5062
  apiKey: typeof options["api-key"] === "string" ? options["api-key"] : void 0,
@@ -4054,12 +5070,10 @@ async function handleTest(options, positional) {
4054
5070
  shards: typeof options["shards"] === "string" ? parseInt(options["shards"], 10) : void 0,
4055
5071
  config: typeof options["config"] === "string" ? options["config"] : void 0,
4056
5072
  env: parseEnvVars(options["env"]),
4057
- quiet: options["quiet"] === "true"
5073
+ quiet: options["quiet"] === "true",
5074
+ network
4058
5075
  };
4059
5076
  process.stderr.write("\u2139 TestRelic: Running Maestro tests...\n");
4060
- const runResult = await runMaestro(testOptions);
4061
- process.stderr.write(`\u2139 TestRelic: Maestro exited with code ${runResult.exitCode}. Parsing results...
4062
- `);
4063
5077
  const config = resolveConfig({
4064
5078
  outputPath: typeof options["output-dir"] === "string" ? `${options["output-dir"]}/testrelic-maestro.json` : void 0,
4065
5079
  htmlReportPath: typeof options["html-report"] === "string" ? options["html-report"] : void 0,
@@ -4069,18 +5083,26 @@ async function handleTest(options, positional) {
4069
5083
  endpoint: typeof options["endpoint"] === "string" ? options["endpoint"] : void 0
4070
5084
  },
4071
5085
  quiet: options["quiet"] === "true",
4072
- flowsDir: positional[0]
5086
+ flowsDir: positional[0],
5087
+ network
4073
5088
  });
5089
+ const runResult = await runMaestro(testOptions, { network: config.network });
5090
+ process.stderr.write(`\u2139 TestRelic: Maestro exited with code ${runResult.exitCode}. Parsing results...
5091
+ `);
4074
5092
  await orchestrateReport({
4075
5093
  junitPath: runResult.junitPath,
4076
5094
  testOutputDir: runResult.testOutputDir,
4077
5095
  debugOutputDir: runResult.debugOutputDir,
4078
5096
  flowsDir: positional[0],
4079
- config
5097
+ config,
5098
+ platform: testOptions.platform ?? "unknown",
5099
+ device: testOptions.device,
5100
+ networkJsonlDir: runResult.networkJsonlDir
4080
5101
  });
4081
5102
  process.exit(runResult.exitCode);
4082
5103
  }
4083
5104
  async function handleReport(options) {
5105
+ const network = buildNetworkOptions(options);
4084
5106
  const config = resolveConfig({
4085
5107
  outputPath: typeof options["output-dir"] === "string" ? `${options["output-dir"]}/testrelic-maestro.json` : void 0,
4086
5108
  htmlReportPath: typeof options["html-report"] === "string" ? options["html-report"] : void 0,
@@ -4090,14 +5112,16 @@ async function handleReport(options) {
4090
5112
  endpoint: typeof options["endpoint"] === "string" ? options["endpoint"] : void 0
4091
5113
  },
4092
5114
  quiet: options["quiet"] === "true",
4093
- flowsDir: typeof options["flows-dir"] === "string" ? options["flows-dir"] : void 0
5115
+ flowsDir: typeof options["flows-dir"] === "string" ? options["flows-dir"] : void 0,
5116
+ network
4094
5117
  });
4095
5118
  process.stderr.write("\u2139 TestRelic: Parsing Maestro artifacts...\n");
4096
5119
  await orchestrateReport({
4097
5120
  junitPath: typeof options["junit"] === "string" ? options["junit"] : void 0,
4098
5121
  testOutputDir: typeof options["artifacts"] === "string" ? options["artifacts"] : void 0,
4099
5122
  flowsDir: typeof options["flows-dir"] === "string" ? options["flows-dir"] : void 0,
4100
- config
5123
+ config,
5124
+ networkJsonlDir: typeof options["network-dir"] === "string" ? options["network-dir"] : null
4101
5125
  });
4102
5126
  }
4103
5127
  async function main() {