@ait-co/devtools 0.1.129 → 0.1.132

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.
Files changed (94) hide show
  1. package/dist/{attach-orchestrator-DvrgUIx7.js → attach-orchestrator-BiaYiNOr.js} +31 -13
  2. package/dist/{attach-orchestrator-DvrgUIx7.js.map → attach-orchestrator-BiaYiNOr.js.map} +1 -1
  3. package/dist/{attach-orchestrator-RhOdO3DS.js → attach-orchestrator-Cjdhir2U.js} +31 -13
  4. package/dist/{attach-orchestrator-RhOdO3DS.js.map → attach-orchestrator-Cjdhir2U.js.map} +1 -1
  5. package/dist/{attach-orchestrator-Bdt5Zdo9.js → attach-orchestrator-ca34r46y.js} +31 -13
  6. package/dist/{attach-orchestrator-Bdt5Zdo9.js.map → attach-orchestrator-ca34r46y.js.map} +1 -1
  7. package/dist/{cell-Ba9lCI3B.js → cell-Bi8MAPA8.js} +11 -7
  8. package/dist/cell-Bi8MAPA8.js.map +1 -0
  9. package/dist/{cell-DA_lcpDI.js → cell-Cw4fVOl2.js} +11 -7
  10. package/dist/cell-Cw4fVOl2.js.map +1 -0
  11. package/dist/{cell-DLRv4iG6.js → cell-rr6gL9AT.js} +11 -7
  12. package/dist/cell-rr6gL9AT.js.map +1 -0
  13. package/dist/{debug-server-e1qPe0ZB.js → debug-server-BBUzxZSf.js} +4 -4
  14. package/dist/{debug-server-e1qPe0ZB.js.map → debug-server-BBUzxZSf.js.map} +1 -1
  15. package/dist/{debug-server--Y0QZVJS.js → debug-server-Ch5p0ryv.js} +33 -11
  16. package/dist/debug-server-Ch5p0ryv.js.map +1 -0
  17. package/dist/{debug-server-Bu3pCG9c.js → debug-server-DoWAtTNj.js} +27 -9
  18. package/dist/debug-server-DoWAtTNj.js.map +1 -0
  19. package/dist/in-app/auto.js +15 -0
  20. package/dist/in-app/auto.js.map +1 -1
  21. package/dist/in-app/index.d.ts +27 -0
  22. package/dist/in-app/index.d.ts.map +1 -1
  23. package/dist/in-app/index.js +15 -0
  24. package/dist/in-app/index.js.map +1 -1
  25. package/dist/mcp/cli.js +211 -38
  26. package/dist/mcp/cli.js.map +1 -1
  27. package/dist/mcp/server.js +1 -1
  28. package/dist/panel/index.js +11 -1
  29. package/dist/panel/index.js.map +1 -1
  30. package/dist/{pool-C0pc7T2p.d.ts → pool-BtLh7U1y.d.ts} +24 -2
  31. package/dist/{pool-C0pc7T2p.d.ts.map → pool-BtLh7U1y.d.ts.map} +1 -1
  32. package/dist/{qr-http-server-DM2R25jP.cjs → qr-http-server-DX_9UA-p.cjs} +77 -6
  33. package/dist/qr-http-server-DX_9UA-p.cjs.map +1 -0
  34. package/dist/{qr-http-server-HANhOFwI.js → qr-http-server-DeRrKp_R.js} +77 -6
  35. package/dist/qr-http-server-DeRrKp_R.js.map +1 -0
  36. package/dist/{qr-http-server-CrGohOwC.js → qr-http-server-FVn61xAq.js} +77 -6
  37. package/dist/qr-http-server-FVn61xAq.js.map +1 -0
  38. package/dist/{qr-http-server-s1iW_5VQ.js → qr-http-server-KB147M-S.js} +77 -6
  39. package/dist/qr-http-server-KB147M-S.js.map +1 -0
  40. package/dist/{qr-http-server-BH0UFeUn.js → qr-http-server-V0xuU5TJ.js} +77 -6
  41. package/dist/qr-http-server-V0xuU5TJ.js.map +1 -0
  42. package/dist/{qr-http-server-Bt_RAxFn.cjs → qr-http-server-iH_Hh_si.cjs} +77 -6
  43. package/dist/qr-http-server-iH_Hh_si.cjs.map +1 -0
  44. package/dist/{relay-factory-Ck3vBonr.js → relay-factory-cNgPfrWg.js} +39 -13
  45. package/dist/relay-factory-cNgPfrWg.js.map +1 -0
  46. package/dist/{relay-worker-o0M2ndyJ.d.ts → relay-worker-BMSnlyls.d.ts} +39 -2
  47. package/dist/relay-worker-BMSnlyls.d.ts.map +1 -0
  48. package/dist/{relay-worker-Bt0QYKQH.js → relay-worker-l34xNuci.js} +28 -7
  49. package/dist/relay-worker-l34xNuci.js.map +1 -0
  50. package/dist/test-runner/bin.js +202 -36
  51. package/dist/test-runner/bin.js.map +1 -1
  52. package/dist/test-runner/config.d.ts +15 -9
  53. package/dist/test-runner/config.d.ts.map +1 -1
  54. package/dist/test-runner/config.js +1 -1
  55. package/dist/test-runner/pool.d.ts +1 -1
  56. package/dist/test-runner/pool.js +1 -1
  57. package/dist/test-runner/pool.js.map +1 -1
  58. package/dist/test-runner/relay-factory.d.ts +36 -8
  59. package/dist/test-runner/relay-factory.d.ts.map +1 -1
  60. package/dist/test-runner/relay-factory.js +38 -12
  61. package/dist/test-runner/relay-factory.js.map +1 -1
  62. package/dist/test-runner/relay-worker.d.ts +2 -2
  63. package/dist/test-runner/relay-worker.js +2 -2
  64. package/dist/test-runner/report.d.ts +26 -4
  65. package/dist/test-runner/report.d.ts.map +1 -1
  66. package/dist/test-runner/report.js +41 -7
  67. package/dist/test-runner/report.js.map +1 -1
  68. package/dist/{tunnel-DyEV6WwL.cjs → tunnel-DP453L4e.cjs} +4 -3
  69. package/dist/{tunnel-DyEV6WwL.cjs.map → tunnel-DP453L4e.cjs.map} +1 -1
  70. package/dist/{tunnel-BFwOSwT4.js → tunnel-Djf-zmjJ.js} +4 -3
  71. package/dist/{tunnel-BFwOSwT4.js.map → tunnel-Djf-zmjJ.js.map} +1 -1
  72. package/dist/unplugin/index.cjs +1 -1
  73. package/dist/unplugin/index.js +1 -1
  74. package/dist/unplugin/tunnel.cjs +3 -2
  75. package/dist/unplugin/tunnel.cjs.map +1 -1
  76. package/dist/unplugin/tunnel.d.cts.map +1 -1
  77. package/dist/unplugin/tunnel.d.ts.map +1 -1
  78. package/dist/unplugin/tunnel.js +3 -2
  79. package/dist/unplugin/tunnel.js.map +1 -1
  80. package/package.json +1 -1
  81. package/dist/cell-Ba9lCI3B.js.map +0 -1
  82. package/dist/cell-DA_lcpDI.js.map +0 -1
  83. package/dist/cell-DLRv4iG6.js.map +0 -1
  84. package/dist/debug-server--Y0QZVJS.js.map +0 -1
  85. package/dist/debug-server-Bu3pCG9c.js.map +0 -1
  86. package/dist/qr-http-server-BH0UFeUn.js.map +0 -1
  87. package/dist/qr-http-server-Bt_RAxFn.cjs.map +0 -1
  88. package/dist/qr-http-server-CrGohOwC.js.map +0 -1
  89. package/dist/qr-http-server-DM2R25jP.cjs.map +0 -1
  90. package/dist/qr-http-server-HANhOFwI.js.map +0 -1
  91. package/dist/qr-http-server-s1iW_5VQ.js.map +0 -1
  92. package/dist/relay-factory-Ck3vBonr.js.map +0 -1
  93. package/dist/relay-worker-Bt0QYKQH.js.map +0 -1
  94. package/dist/relay-worker-o0M2ndyJ.d.ts.map +0 -1
@@ -22,6 +22,23 @@ import { fileURLToPath } from "node:url";
22
22
  * safe to import from the MCP daemon graph.
23
23
  */
24
24
  /**
25
+ * Filename suffix that opts a test file into manual-variant scheduling
26
+ * (devtools#741). A file named `<name>.manual.ait.test.ts` is EXCLUDED from
27
+ * `discoverTestFiles`'s default output (so existing unattended runs are
28
+ * unaffected — the zero-diff-when-off constraint) and is only surfaced when
29
+ * the caller explicitly asks for manual files via {@link partitionManualTests}
30
+ * (wired from the CLI's `--manual-blocking` flag).
31
+ *
32
+ * This is the entire tagging contract for v1 — no separate manifest/config,
33
+ * just a filename convention. Documented here + in the CLI `--help` text
34
+ * (cli.ts USAGE) and the test-runner README.
35
+ */
36
+ const MANUAL_TEST_SUFFIX = ".manual.ait.test.ts";
37
+ /** True when `file`'s basename ends with {@link MANUAL_TEST_SUFFIX}. */
38
+ function isManualTestFile(file) {
39
+ return basename(file).endsWith(MANUAL_TEST_SUFFIX);
40
+ }
41
+ /**
25
42
  * Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of
26
43
  * ABSOLUTE test file paths, resolved relative to `cwd`.
27
44
  *
@@ -30,14 +47,46 @@ import { fileURLToPath } from "node:url";
30
47
  * resolved against `cwd`. `bundleTestFile` requires an absolute path, so the
31
48
  * absolute output feeds it directly.
32
49
  *
50
+ * By default, files matching {@link MANUAL_TEST_SUFFIX} are EXCLUDED from the
51
+ * result (devtools#741) — blocking-UI tests opt in via that filename
52
+ * convention and must never appear in an unattended run unless the caller
53
+ * explicitly asks for them via `includeManual: true`. This keeps the
54
+ * default (flag-off) discovery path byte-for-byte identical to before this
55
+ * option existed.
56
+ *
33
57
  * @param patterns Glob patterns or file paths (e.g. `['src/**\/*.ait.test.ts']`).
34
58
  * @param cwd Base directory for relative patterns/results.
59
+ * @param opts `{ includeManual }` — when true, manual-tagged files are kept
60
+ * in the (still-sorted) output instead of being filtered out.
61
+ * Use {@link partitionManualTests} to separate + reorder them.
35
62
  * @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.
36
63
  */
37
- async function discoverTestFiles(patterns, cwd) {
64
+ async function discoverTestFiles(patterns, cwd, opts) {
38
65
  const out = /* @__PURE__ */ new Set();
39
66
  for await (const match of glob(patterns, { cwd })) out.add(isAbsolute(match) ? match : resolve(cwd, match));
40
- return [...out].sort();
67
+ const sorted = [...out].sort();
68
+ if (opts?.includeManual) return sorted;
69
+ return sorted.filter((f) => !isManualTestFile(f));
70
+ }
71
+ /**
72
+ * Splits an already-discovered file list into `{ regular, manual }`, each
73
+ * still sorted. Used by the CLI's `--manual-blocking` path to schedule manual
74
+ * files strictly AFTER every regular file (devtools#741) — regular files run
75
+ * first (and produce the unattended-shaped part of the report) and manual
76
+ * files run last, each preceded by a dashboard prompt.
77
+ *
78
+ * Pure partition — does not itself decide inclusion; call
79
+ * `discoverTestFiles(patterns, cwd, { includeManual: true })` first so manual
80
+ * files are present in `files` to partition.
81
+ */
82
+ function partitionManualTests(files) {
83
+ const regular = [];
84
+ const manual = [];
85
+ for (const f of files) (isManualTestFile(f) ? manual : regular).push(f);
86
+ return {
87
+ regular,
88
+ manual
89
+ };
41
90
  }
42
91
  //#endregion
43
92
  //#region src/test-runner/relay-factory.ts
@@ -45,11 +94,14 @@ async function discoverTestFiles(patterns, cwd) {
45
94
  * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay
46
95
  * connection.
47
96
  *
48
- * `open()` performs the full attach lifecycle and BLOCKS for tens of seconds (up
49
- * to `timeoutMs`) while a human scans the rendered QR with their phone — there
50
- * is no way around the manual scan for env3. It resolves with the live
51
- * `CdpConnection` once a matching page attaches; `close()` tears the relay
52
- * family down.
97
+ * `open()` performs the full attach lifecycle and BLOCKS while a human scans
98
+ * the rendered QR with their phone — there is no way around the manual scan
99
+ * for env3. By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the
100
+ * runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is
101
+ * a human-paced action with no sound default bound (devtools#735). Passing an
102
+ * explicit `timeoutMs` opts into the old bounded behavior (CI/headless
103
+ * callers). It resolves with the live `CdpConnection` once a matching page
104
+ * attaches; `close()` tears the relay family down.
53
105
  *
54
106
  * The factory holds the booted relay family in a closure so `close()` can stop
55
107
  * it. A second `open()` on the same factory boots a fresh family (the previous
@@ -57,16 +109,18 @@ async function discoverTestFiles(patterns, cwd) {
57
109
  */
58
110
  function createRelayConnectionFactory(opts) {
59
111
  const projectRoot = opts.projectRoot ?? process.cwd();
60
- const timeoutMs = opts.timeoutMs ?? 6e5;
112
+ const timeoutMs = opts.timeoutMs ?? Number.POSITIVE_INFINITY;
61
113
  const headless = opts.headless === true;
62
114
  let family;
63
115
  let qrServer;
116
+ let phase = "active";
117
+ let manualPrompt = null;
64
118
  return {
65
119
  async open() {
66
- const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-Bdt5Zdo9.js");
67
- const { injectDebugIndicator, injectGlobals } = await import("../cell-DLRv4iG6.js");
120
+ const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-ca34r46y.js");
121
+ const { injectDebugIndicator, injectGlobals } = await import("../cell-rr6gL9AT.js");
68
122
  const { loadRelaySecretReadOnly } = await import("../relay-secret-store-CFc9n0OA.js");
69
- const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-e1qPe0ZB.js");
123
+ const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-BBUzxZSf.js");
70
124
  await loadRelaySecretReadOnly({ projectRoot });
71
125
  let resolveTunnelUp;
72
126
  const tunnelReady = new Promise((resolve) => {
@@ -77,6 +131,9 @@ function createRelayConnectionFactory(opts) {
77
131
  onWssUrl: () => {
78
132
  resolveTunnelUp();
79
133
  qrServer?.notifyStateChange();
134
+ },
135
+ onTunnelDown: () => {
136
+ qrServer?.notifyStateChange();
80
137
  }
81
138
  });
82
139
  family = booted;
@@ -93,12 +150,14 @@ function createRelayConnectionFactory(opts) {
93
150
  canOpenBrowser: () => !headless
94
151
  };
95
152
  try {
96
- const { startQrHttpServer } = await import("../qr-http-server-s1iW_5VQ.js");
153
+ const { startQrHttpServer } = await import("../qr-http-server-KB147M-S.js");
97
154
  const getDashboardState = () => ({
98
155
  tunnel: attachDeps.getTunnelStatus(),
99
156
  pages: null,
100
157
  attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,
101
- mode: "relay-dev"
158
+ mode: "relay-dev",
159
+ phase,
160
+ manualPrompt
102
161
  });
103
162
  qrServer = await startQrHttpServer(getDashboardState);
104
163
  attachDeps.qrHttpServer = qrServer;
@@ -151,7 +210,23 @@ function createRelayConnectionFactory(opts) {
151
210
  if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
152
211
  return booted.connection;
153
212
  },
154
- async close(_connection) {
213
+ onSessionPhase(next) {
214
+ phase = next;
215
+ qrServer?.notifyStateChange();
216
+ },
217
+ onManualPrompt(next) {
218
+ manualPrompt = next;
219
+ qrServer?.notifyStateChange();
220
+ },
221
+ async close(connection) {
222
+ if (phase !== "complete") {
223
+ phase = "complete";
224
+ qrServer?.notifyStateChange();
225
+ }
226
+ try {
227
+ const { injectDebugIndicator } = await import("../cell-rr6gL9AT.js");
228
+ await injectDebugIndicator(connection, { state: "disconnected" });
229
+ } catch {}
155
230
  family?.stop();
156
231
  family = void 0;
157
232
  await qrServer?.close();
@@ -484,7 +559,11 @@ var ChiiCdpConnection = class {
484
559
  * EventEmitter is missed (defensive belt-and-suspenders).
485
560
  *
486
561
  * @param filterFn - Predicate that the returned targets must satisfy.
487
- * @param timeoutMs - Reject after this many ms (default 90 000).
562
+ * @param timeoutMs - Reject after this many ms (default 90 000). Pass a
563
+ * non-finite value (`Infinity`) to disable the rejection timer entirely —
564
+ * used by the test-runner's unbounded QR-attach wait (devtools#735). Node
565
+ * clamps `setTimeout(fn, Infinity)` to ~1ms, so a non-finite value MUST
566
+ * skip arming the timer rather than pass it through.
488
567
  * @param pollIntervalMs - Fallback poll interval (default 500ms).
489
568
  */
490
569
  waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
@@ -496,7 +575,7 @@ var ChiiCdpConnection = class {
496
575
  const settle = (targets) => {
497
576
  if (settled) return;
498
577
  settled = true;
499
- clearTimeout(timeoutHandle);
578
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
500
579
  if (pollHandle !== null) {
501
580
  clearInterval(pollHandle);
502
581
  pollHandle = null;
@@ -507,7 +586,7 @@ var ChiiCdpConnection = class {
507
586
  const onAttach = (targets) => {
508
587
  if (filterFn(targets)) settle(targets);
509
588
  };
510
- const timeoutHandle = setTimeout(() => {
589
+ const timeoutHandle = Number.isFinite(timeoutMs) ? setTimeout(() => {
511
590
  if (settled) return;
512
591
  settled = true;
513
592
  if (pollHandle !== null) {
@@ -516,7 +595,7 @@ var ChiiCdpConnection = class {
516
595
  }
517
596
  this.emitter.off("target:attached", onAttach);
518
597
  reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
519
- }, timeoutMs);
598
+ }, timeoutMs) : null;
520
599
  this.emitter.on("target:attached", onAttach);
521
600
  pollHandle = setInterval(() => {
522
601
  this.refreshTargets().then((targets) => {
@@ -1292,6 +1371,13 @@ async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TI
1292
1371
  //#endregion
1293
1372
  //#region src/test-runner/relay-worker.ts
1294
1373
  /**
1374
+ * Per-file evaluate timeout (ms) for manual-variant files (devtools#741) — a
1375
+ * human is expected to be tapping through a native sheet, so the timeout must
1376
+ * be far longer than the unattended default (60s, rpc.ts DEFAULT_TIMEOUT_MS).
1377
+ * Fixed constant (not configurable in v1) — documented here + CLI `--help`.
1378
+ */
1379
+ const MANUAL_FILE_TIMEOUT_MS = 5 * 6e4;
1380
+ /**
1295
1381
  * Sentinel string embedded in the error message by `injectAndRunBundle` when
1296
1382
  * the per-file evaluate race hits the timeout. Used by the retry guard so only
1297
1383
  * genuine timeouts get a second attempt — not bundle errors or parse failures.
@@ -1362,12 +1448,21 @@ async function runTestFilesOverRelay(connection, files, opts) {
1362
1448
  process.stderr.write(`relay-worker: reconnect failed (${e instanceof Error ? e.message : String(e)}) — continuing, remaining files may fail\n`);
1363
1449
  }
1364
1450
  };
1451
+ const manualFiles = opts?.manualFiles;
1452
+ const manualTotal = manualFiles?.size ?? 0;
1453
+ let manualIndex = 0;
1365
1454
  try {
1366
1455
  for (const file of files) {
1367
1456
  if (pendingReconnectCheck) {
1368
1457
  await attemptReconnect("next-file");
1369
1458
  pendingReconnectCheck = false;
1370
1459
  }
1460
+ const isManual = manualFiles?.has(file) === true;
1461
+ if (isManual) {
1462
+ manualIndex += 1;
1463
+ opts?.onManualFile?.(file, manualIndex, manualTotal);
1464
+ }
1465
+ const fileTimeoutMs = isManual ? MANUAL_FILE_TIMEOUT_MS : opts?.timeoutMs;
1371
1466
  let fileEntry;
1372
1467
  try {
1373
1468
  const { code } = await bundleTestFile(file, opts?.bundleOptions);
@@ -1393,7 +1488,7 @@ async function runTestFilesOverRelay(connection, files, opts) {
1393
1488
  const attempt = async () => {
1394
1489
  let rpcResult;
1395
1490
  try {
1396
- rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
1491
+ rpcResult = await injectAndRunBundle(connection, code, fileTimeoutMs);
1397
1492
  } catch (e) {
1398
1493
  return {
1399
1494
  file,
@@ -1429,6 +1524,7 @@ async function runTestFilesOverRelay(connection, files, opts) {
1429
1524
  };
1430
1525
  }
1431
1526
  if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
1527
+ if (isManual) fileEntry.mode = "manual";
1432
1528
  fileResults.push(fileEntry);
1433
1529
  }
1434
1530
  } finally {
@@ -1542,9 +1638,11 @@ function serializeRelayReport(report, meta) {
1542
1638
  totals: report.totals,
1543
1639
  files: report.files.map((f) => {
1544
1640
  const file = relativise(meta.projectRoot, f.file);
1641
+ const modeField = f.mode === "manual" ? { mode: "manual" } : {};
1545
1642
  if ("error" in f.result) return {
1546
1643
  file,
1547
- error: f.result.error
1644
+ error: f.result.error,
1645
+ ...modeField
1548
1646
  };
1549
1647
  return {
1550
1648
  file,
@@ -1552,33 +1650,65 @@ function serializeRelayReport(report, meta) {
1552
1650
  passed: f.result.passed,
1553
1651
  failed: f.result.failed,
1554
1652
  skipped: f.result.skipped,
1555
- tests: f.result.tests
1653
+ tests: f.result.tests,
1654
+ ...modeField
1556
1655
  };
1557
1656
  })
1558
1657
  };
1559
1658
  }
1560
1659
  /**
1561
1660
  * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating
1562
- * `dir` if needed. Returns the absolute path written.
1661
+ * `dir` if needed. Returns the absolute path(s) written.
1563
1662
  *
1564
1663
  * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as
1565
1664
  * distinct artifacts in the same directory; the same cell metadata is also baked
1566
1665
  * into the body so a renamed/moved file still carries its provenance.
1567
1666
  *
1667
+ * Manual-run provenance (devtools#741): when `report.files` contains ANY
1668
+ * `mode: 'manual'` entry (i.e. this run included `--manual-blocking` files),
1669
+ * the manual-tagged files are written to a SEPARATE
1670
+ * `<dir>/<sdkLine>.<platform>.manual.json` artifact instead of the standard
1671
+ * one — the standard `<sdkLine>.<platform>.json` filename is reserved for the
1672
+ * regular (unattended) files only, so a manual run's presence never mutates
1673
+ * what the unattended-baseline filename means. If ALL files in the run are
1674
+ * regular, only the standard artifact is written (today's behavior,
1675
+ * unchanged). If a run mixes both (regular files + `--manual-blocking`
1676
+ * scheduled last), BOTH artifacts are written — the manual one ALONGSIDE, not
1677
+ * replacing, the standard one.
1678
+ *
1568
1679
  * SECRET-HANDLING: the written body contains no relay/secret fields (the schema
1569
1680
  * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
1570
1681
  *
1571
1682
  * @param report - The core relay run report.
1572
1683
  * @param dir - Output directory (created recursively if missing).
1573
1684
  * @param meta - Cell axes + projectRoot.
1574
- * @returns The absolute path of the written file.
1685
+ * @returns The absolute path(s) written, in order: standard first (if any
1686
+ * regular files ran), then manual (if any manual files ran). At least one
1687
+ * path is always returned when `report.files` is non-empty.
1575
1688
  */
1576
1689
  async function writeReportArtifact(report, dir, meta) {
1577
1690
  const serialised = serializeRelayReport(report, meta);
1578
1691
  await mkdir(dir, { recursive: true });
1579
- const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
1580
- await writeFile(outFile, `${JSON.stringify(serialised, null, 2)}\n`, "utf8");
1581
- return outFile;
1692
+ const regularFiles = serialised.files.filter((f) => f.mode !== "manual");
1693
+ const manualFiles = serialised.files.filter((f) => f.mode === "manual");
1694
+ const written = [];
1695
+ if (regularFiles.length > 0 || serialised.files.length === 0) {
1696
+ const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
1697
+ await writeFile(outFile, `${JSON.stringify({
1698
+ ...serialised,
1699
+ files: regularFiles
1700
+ }, null, 2)}\n`, "utf8");
1701
+ written.push(outFile);
1702
+ }
1703
+ if (manualFiles.length > 0) {
1704
+ const manualOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.manual.json`);
1705
+ await writeFile(manualOutFile, `${JSON.stringify({
1706
+ ...serialised,
1707
+ files: manualFiles
1708
+ }, null, 2)}\n`, "utf8");
1709
+ written.push(manualOutFile);
1710
+ }
1711
+ return written;
1582
1712
  }
1583
1713
  /**
1584
1714
  * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,
@@ -1649,9 +1779,9 @@ OPTIONS
1649
1779
  the CLI waits for a human to scan the QR code — use
1650
1780
  --attach-timeout for that.
1651
1781
  --attach-timeout <ms> How long to wait for a human to scan the QR code with
1652
- their phone (default: 600000 10 minutes). Omit to
1653
- use the relay factory's generous default. Decrease for
1654
- CI environments where a scan should arrive quickly.
1782
+ their phone. Omit (default) to wait indefinitely the
1783
+ runner stays up until you stop it (Ctrl-C/SIGTERM).
1784
+ Pass a value to bound the wait for CI/headless runs.
1655
1785
  --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
1656
1786
  --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
1657
1787
  (mock|ios|android, default: AIT_CELL_PLATFORM env)
@@ -1664,6 +1794,24 @@ OPTIONS
1664
1794
  --headless Disable browser auto-open (text QR only)
1665
1795
  --project-root <dir> Project root for .ait_relay secret lookup
1666
1796
  (default: current working directory)
1797
+ --manual-blocking Run manual-tagged test files (*.manual.ait.test.ts)
1798
+ LAST, after all regular files, with a human present.
1799
+ Before each manual file, the QR dashboard is pushed
1800
+ a step-by-step Korean prompt naming the file + its
1801
+ progress (k/n), and the same line is printed to
1802
+ stdout. Manual files get a 5-minute per-file evaluate
1803
+ timeout (vs. --timeout for everything else) since a
1804
+ human is expected to tap through a native sheet
1805
+ (photo picker, permission dialog, fullscreen ad).
1806
+ Without this flag (default off), *.manual.ait.test.ts
1807
+ files are EXCLUDED from the glob expansion entirely —
1808
+ existing unattended runs are byte-for-byte unaffected.
1809
+ With --report-dir, a run that included manual files
1810
+ ALSO writes <sdkLine>.<platform>.manual.json
1811
+ alongside (never replacing) the standard report, and
1812
+ each manual file's report entry is stamped
1813
+ mode: 'manual' — never diff a manual run against an
1814
+ unattended baseline as if they were equivalent.
1667
1815
  --help, -h Show this help message
1668
1816
 
1669
1817
  DESCRIPTION
@@ -1812,7 +1960,8 @@ async function main(argv = process.argv.slice(2)) {
1812
1960
  "report-dir": { type: "string" },
1813
1961
  "no-qr-stdout": { type: "boolean" },
1814
1962
  headless: { type: "boolean" },
1815
- "project-root": { type: "string" }
1963
+ "project-root": { type: "string" },
1964
+ "manual-blocking": { type: "boolean" }
1816
1965
  },
1817
1966
  allowPositionals: true
1818
1967
  });
@@ -1843,6 +1992,7 @@ async function main(argv = process.argv.slice(2)) {
1843
1992
  const projectRoot = typeof vals["project-root"] === "string" ? vals["project-root"] : process.cwd();
1844
1993
  const reportDir = typeof vals["report-dir"] === "string" ? vals["report-dir"] : void 0;
1845
1994
  const suppressQr = shouldSuppressQr(vals["no-qr-stdout"] === true);
1995
+ const manualBlocking = vals["manual-blocking"] === true;
1846
1996
  const cellSdkLine = typeof vals["cell-sdk-line"] === "string" ? vals["cell-sdk-line"] : void 0;
1847
1997
  const cellPlatform = typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM;
1848
1998
  const hasCell = cellSdkLine !== void 0 || cellPlatform !== void 0;
@@ -1857,13 +2007,16 @@ async function main(argv = process.argv.slice(2)) {
1857
2007
  process.exitCode = 1;
1858
2008
  return;
1859
2009
  }
1860
- const files = await discoverTestFiles(globs, process.cwd());
1861
- if (files.length === 0) {
2010
+ const discovered = await discoverTestFiles(globs, process.cwd(), { includeManual: manualBlocking });
2011
+ if (discovered.length === 0) {
1862
2012
  process.stderr.write(`devtools-test: no test files matched ${globs.join(", ")}\n`);
1863
2013
  process.exitCode = 1;
1864
2014
  return;
1865
2015
  }
1866
- process.stderr.write(`devtools-test: found ${files.length} test file(s)\n`);
2016
+ const { regular, manual } = partitionManualTests(discovered);
2017
+ const files = manualBlocking ? [...regular, ...manual] : regular;
2018
+ const manualFileSet = new Set(manual);
2019
+ process.stderr.write(manualBlocking && manual.length > 0 ? `devtools-test: found ${regular.length} regular + ${manual.length} manual (${MANUAL_TEST_SUFFIX}) test file(s)\n` : `devtools-test: found ${files.length} test file(s)\n`);
1867
2020
  if (hasCell) process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
1868
2021
  const factory = createRelayConnectionFactory({
1869
2022
  schemeUrl,
@@ -1889,18 +2042,30 @@ async function main(argv = process.argv.slice(2)) {
1889
2042
  }
1890
2043
  let exitCode = 0;
1891
2044
  try {
2045
+ factory.onSessionPhase?.("running");
1892
2046
  const report = await runWithConnection(connection, files, {
1893
2047
  timeoutMs: evaluateTimeoutMs,
1894
2048
  printSummary: true,
1895
- collectCaptures: reportDir !== void 0
2049
+ collectCaptures: reportDir !== void 0,
2050
+ manualFiles: manualBlocking && manualFileSet.size > 0 ? manualFileSet : void 0,
2051
+ onManualFile: (file, index, total) => {
2052
+ const name = basename(file);
2053
+ process.stdout.write(`수동 단계: ${name} — 폰에서 네이티브 시트가 뜨면 안내에 따라 조작하세요 (${index}/${total})\n`);
2054
+ factory.onManualPrompt?.({
2055
+ file: name,
2056
+ index,
2057
+ total
2058
+ });
2059
+ }
1896
2060
  });
2061
+ if (manualBlocking && manualFileSet.size > 0) factory.onManualPrompt?.(null);
1897
2062
  if (reportDir !== void 0) try {
1898
- const reportPath = await writeReportArtifact(report, reportDir, {
2063
+ const reportPaths = await writeReportArtifact(report, reportDir, {
1899
2064
  sdkLine: cell.sdkLine,
1900
2065
  platform: cell.platform,
1901
2066
  projectRoot
1902
2067
  });
1903
- process.stderr.write(`devtools-test: wrote report ${reportPath}\n`);
2068
+ for (const reportPath of reportPaths) process.stderr.write(`devtools-test: wrote report ${reportPath}\n`);
1904
2069
  const capturePaths = await writeCaptureArtifacts(report.captures, `${reportDir}/.ait-capture`, cell);
1905
2070
  if (capturePaths.length > 0) process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\n`);
1906
2071
  } catch (e) {
@@ -1908,6 +2073,7 @@ async function main(argv = process.argv.slice(2)) {
1908
2073
  }
1909
2074
  exitCode = report.totals.failed > 0 ? 1 : 0;
1910
2075
  } finally {
2076
+ factory.onSessionPhase?.("complete");
1911
2077
  await factory.close(connection);
1912
2078
  process.exitCode = exitCode;
1913
2079
  }