@ait-co/devtools 0.1.144 → 0.2.0

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 (181) hide show
  1. package/README.en.md +33 -217
  2. package/README.md +33 -246
  3. package/dist/in-app/auto.d.ts +1 -138
  4. package/dist/in-app/auto.js +28 -1102
  5. package/dist/in-app/auto.js.map +1 -1
  6. package/dist/in-app/index.d.ts +38 -547
  7. package/dist/in-app/index.d.ts.map +1 -1
  8. package/dist/in-app/index.js +62 -939
  9. package/dist/in-app/index.js.map +1 -1
  10. package/dist/mcp/cli.d.ts +1 -54
  11. package/dist/mcp/cli.js +33 -9720
  12. package/dist/mcp/cli.js.map +1 -1
  13. package/dist/mcp/server.d.ts +1 -88
  14. package/dist/mcp/server.js +36 -1076
  15. package/dist/mcp/server.js.map +1 -1
  16. package/dist/mock/index.d.ts +19 -20
  17. package/dist/mock/index.d.ts.map +1 -1
  18. package/dist/mock/index.js.map +1 -1
  19. package/dist/panel/index.js +1 -103
  20. package/dist/panel/index.js.map +1 -1
  21. package/dist/relay-url-store-CPZAn-T5.js +107 -0
  22. package/dist/relay-url-store-CPZAn-T5.js.map +1 -0
  23. package/dist/relay-url-store-DLjlvMSA.cjs +108 -0
  24. package/dist/relay-url-store-DLjlvMSA.cjs.map +1 -0
  25. package/dist/stubs/bin-devtools-mcp.js +58 -0
  26. package/dist/stubs/bin-devtools-mcp.js.map +1 -0
  27. package/dist/stubs/bin-devtools-test.d.ts +2 -0
  28. package/dist/stubs/bin-devtools-test.js +55 -0
  29. package/dist/stubs/bin-devtools-test.js.map +1 -0
  30. package/dist/test-runner/config.d.ts +1 -231
  31. package/dist/test-runner/config.js +41 -45
  32. package/dist/test-runner/config.js.map +1 -1
  33. package/dist/{tunnel-BGT9Curk.cjs → tunnel-BKZkOyQp.cjs} +1 -1
  34. package/dist/{tunnel-BGT9Curk.cjs.map → tunnel-BKZkOyQp.cjs.map} +1 -1
  35. package/dist/{tunnel-BOKmLzBO.js → tunnel-CqSCIrdU.js} +1 -1
  36. package/dist/{tunnel-BOKmLzBO.js.map → tunnel-CqSCIrdU.js.map} +1 -1
  37. package/dist/unplugin/index.cjs +9 -18
  38. package/dist/unplugin/index.cjs.map +1 -1
  39. package/dist/unplugin/index.d.cts +22 -3
  40. package/dist/unplugin/index.d.cts.map +1 -1
  41. package/dist/unplugin/index.d.ts +23 -4
  42. package/dist/unplugin/index.d.ts.map +1 -1
  43. package/dist/unplugin/index.js +10 -19
  44. package/dist/unplugin/index.js.map +1 -1
  45. package/package.json +10 -25
  46. package/dist/attach-orchestrator-0F0m_UqQ.js +0 -1845
  47. package/dist/attach-orchestrator-0F0m_UqQ.js.map +0 -1
  48. package/dist/attach-orchestrator-D65KxFy_.js +0 -1831
  49. package/dist/attach-orchestrator-D65KxFy_.js.map +0 -1
  50. package/dist/attach-orchestrator-DL3NQ9ca.js +0 -1846
  51. package/dist/attach-orchestrator-DL3NQ9ca.js.map +0 -1
  52. package/dist/bundle-C796JIwG.d.ts +0 -159
  53. package/dist/bundle-C796JIwG.d.ts.map +0 -1
  54. package/dist/capture-DsP525OZ.d.ts +0 -58
  55. package/dist/capture-DsP525OZ.d.ts.map +0 -1
  56. package/dist/cdp-connection-rP1WdnH5.d.ts +0 -287
  57. package/dist/cdp-connection-rP1WdnH5.d.ts.map +0 -1
  58. package/dist/cell-BaLvusOl.js +0 -68
  59. package/dist/cell-BaLvusOl.js.map +0 -1
  60. package/dist/cell-CBUS3-nT.js +0 -274
  61. package/dist/cell-CBUS3-nT.js.map +0 -1
  62. package/dist/cell-EBKKpAAT.js +0 -307
  63. package/dist/cell-EBKKpAAT.js.map +0 -1
  64. package/dist/chii-relay-B3ZhjGMi.js +0 -304
  65. package/dist/chii-relay-B3ZhjGMi.js.map +0 -1
  66. package/dist/chii-relay-CGMlePMd.cjs +0 -304
  67. package/dist/chii-relay-CGMlePMd.cjs.map +0 -1
  68. package/dist/debug-server-B3ABDrRI.js +0 -456
  69. package/dist/debug-server-B3ABDrRI.js.map +0 -1
  70. package/dist/debug-server-BWhwrVXa.js +0 -1158
  71. package/dist/debug-server-BWhwrVXa.js.map +0 -1
  72. package/dist/debug-server-CfQNxxGW.js +0 -600
  73. package/dist/debug-server-CfQNxxGW.js.map +0 -1
  74. package/dist/devtools-opener-3Drge_RJ.js +0 -75
  75. package/dist/devtools-opener-3Drge_RJ.js.map +0 -1
  76. package/dist/devtools-opener-CJpEsXXQ.js +0 -76
  77. package/dist/devtools-opener-CJpEsXXQ.js.map +0 -1
  78. package/dist/devtools-opener-CxtryS8c.js +0 -75
  79. package/dist/devtools-opener-CxtryS8c.js.map +0 -1
  80. package/dist/in-app/auto.d.ts.map +0 -1
  81. package/dist/mcp/cli.d.ts.map +0 -1
  82. package/dist/mcp/server.d.ts.map +0 -1
  83. package/dist/pool-DcaaOwUq.d.ts +0 -14761
  84. package/dist/pool-DcaaOwUq.d.ts.map +0 -1
  85. package/dist/qr-http-server-C_lqOrgc.js +0 -1644
  86. package/dist/qr-http-server-C_lqOrgc.js.map +0 -1
  87. package/dist/qr-http-server-CopuMbub.js +0 -1644
  88. package/dist/qr-http-server-CopuMbub.js.map +0 -1
  89. package/dist/qr-http-server-DrbIVDjO.js +0 -1645
  90. package/dist/qr-http-server-DrbIVDjO.js.map +0 -1
  91. package/dist/relay-factory-N9QobQxG.js +0 -206
  92. package/dist/relay-factory-N9QobQxG.js.map +0 -1
  93. package/dist/relay-secret-store-BR0YIkNv.cjs +0 -241
  94. package/dist/relay-secret-store-BR0YIkNv.cjs.map +0 -1
  95. package/dist/relay-secret-store-Bmyleu0A.js +0 -154
  96. package/dist/relay-secret-store-Bmyleu0A.js.map +0 -1
  97. package/dist/relay-secret-store-CQenfcSL.js +0 -154
  98. package/dist/relay-secret-store-CQenfcSL.js.map +0 -1
  99. package/dist/relay-secret-store-CYM8CBIF.js +0 -240
  100. package/dist/relay-secret-store-CYM8CBIF.js.map +0 -1
  101. package/dist/relay-secret-store-DKxs7zwq.js +0 -153
  102. package/dist/relay-secret-store-DKxs7zwq.js.map +0 -1
  103. package/dist/relay-secret-store-WJ8EGkIl.js +0 -153
  104. package/dist/relay-secret-store-WJ8EGkIl.js.map +0 -1
  105. package/dist/relay-url-store-BR2XodiO.js +0 -123
  106. package/dist/relay-url-store-BR2XodiO.js.map +0 -1
  107. package/dist/relay-url-store-C1as_m5G.cjs +0 -115
  108. package/dist/relay-url-store-C1as_m5G.cjs.map +0 -1
  109. package/dist/relay-url-store-CH63fVCm.js +0 -122
  110. package/dist/relay-url-store-CH63fVCm.js.map +0 -1
  111. package/dist/relay-url-store-CzFo_84F.js +0 -114
  112. package/dist/relay-url-store-CzFo_84F.js.map +0 -1
  113. package/dist/relay-url-store-DaY1QPes.js +0 -123
  114. package/dist/relay-url-store-DaY1QPes.js.map +0 -1
  115. package/dist/relay-url-store-xmUuTjXA.js +0 -122
  116. package/dist/relay-url-store-xmUuTjXA.js.map +0 -1
  117. package/dist/relay-worker-B5HKkGUY.js +0 -832
  118. package/dist/relay-worker-B5HKkGUY.js.map +0 -1
  119. package/dist/relay-worker-YdlpZQl9.d.ts +0 -214
  120. package/dist/relay-worker-YdlpZQl9.d.ts.map +0 -1
  121. package/dist/rolldown-runtime-DGkTqVfb.js +0 -15
  122. package/dist/rolldown-runtime-DUslC3ob.js +0 -14
  123. package/dist/runtime-kn9DxOeg.d.ts +0 -249
  124. package/dist/runtime-kn9DxOeg.d.ts.map +0 -1
  125. package/dist/test-runner/bin.js +0 -2584
  126. package/dist/test-runner/bin.js.map +0 -1
  127. package/dist/test-runner/bridge-stub.d.ts +0 -125
  128. package/dist/test-runner/bridge-stub.d.ts.map +0 -1
  129. package/dist/test-runner/bridge-stub.js +0 -92
  130. package/dist/test-runner/bridge-stub.js.map +0 -1
  131. package/dist/test-runner/bundle.d.ts +0 -2
  132. package/dist/test-runner/bundle.js +0 -439
  133. package/dist/test-runner/bundle.js.map +0 -1
  134. package/dist/test-runner/capture.d.ts +0 -2
  135. package/dist/test-runner/capture.js +0 -44
  136. package/dist/test-runner/capture.js.map +0 -1
  137. package/dist/test-runner/config.d.ts.map +0 -1
  138. package/dist/test-runner/method-pace.d.ts +0 -82
  139. package/dist/test-runner/method-pace.d.ts.map +0 -1
  140. package/dist/test-runner/method-pace.js +0 -120
  141. package/dist/test-runner/method-pace.js.map +0 -1
  142. package/dist/test-runner/pool.d.ts +0 -2
  143. package/dist/test-runner/pool.js +0 -136
  144. package/dist/test-runner/pool.js.map +0 -1
  145. package/dist/test-runner/relay-factory.d.ts +0 -11245
  146. package/dist/test-runner/relay-factory.d.ts.map +0 -1
  147. package/dist/test-runner/relay-factory.js +0 -206
  148. package/dist/test-runner/relay-factory.js.map +0 -1
  149. package/dist/test-runner/relay-worker.d.ts +0 -2
  150. package/dist/test-runner/relay-worker.js +0 -2
  151. package/dist/test-runner/report.d.ts +0 -163
  152. package/dist/test-runner/report.d.ts.map +0 -1
  153. package/dist/test-runner/report.js +0 -198
  154. package/dist/test-runner/report.js.map +0 -1
  155. package/dist/test-runner/rpc.d.ts +0 -56
  156. package/dist/test-runner/rpc.d.ts.map +0 -1
  157. package/dist/test-runner/rpc.js +0 -98
  158. package/dist/test-runner/rpc.js.map +0 -1
  159. package/dist/test-runner/runtime.d.ts +0 -2
  160. package/dist/test-runner/runtime.js +0 -659
  161. package/dist/test-runner/runtime.js.map +0 -1
  162. package/dist/test-runner/task-graph.d.ts +0 -38
  163. package/dist/test-runner/task-graph.d.ts.map +0 -1
  164. package/dist/test-runner/task-graph.js +0 -182
  165. package/dist/test-runner/task-graph.js.map +0 -1
  166. package/dist/throttle-DKKzX1qC.js +0 -59
  167. package/dist/throttle-DKKzX1qC.js.map +0 -1
  168. package/dist/totp-BqmCLSNA.js +0 -189
  169. package/dist/totp-BqmCLSNA.js.map +0 -1
  170. package/dist/totp-CMHR5lsW.cjs +0 -191
  171. package/dist/totp-CMHR5lsW.cjs.map +0 -1
  172. package/dist/totp-CZLLKfOC.js +0 -200
  173. package/dist/totp-CZLLKfOC.js.map +0 -1
  174. package/dist/totp-DAxys-r0.js +0 -199
  175. package/dist/totp-DAxys-r0.js.map +0 -1
  176. package/dist/totp-DfekTBk3.js +0 -211
  177. package/dist/totp-DfekTBk3.js.map +0 -1
  178. package/dist/totp-Dwft0Kz7.js +0 -3
  179. package/dist/totp-WY6l0ysP.js +0 -190
  180. package/dist/totp-WY6l0ysP.js.map +0 -1
  181. /package/dist/{test-runner/bin.d.ts → stubs/bin-devtools-mcp.d.ts} +0 -0
@@ -1,2584 +0,0 @@
1
- #!/usr/bin/env node
2
- import { m as logInfo } from "../attach-orchestrator-DL3NQ9ca.js";
3
- import { r as generateTotp } from "../totp-CZLLKfOC.js";
4
- import { r as runPermissionPreflight, t as PERMISSION_PREFLIGHT_TIMEOUT_MS } from "../cell-EBKKpAAT.js";
5
- import * as path$1 from "node:path";
6
- import path, { basename, isAbsolute, resolve } from "node:path";
7
- import { parseArgs } from "node:util";
8
- import * as fs from "node:fs/promises";
9
- import { glob, mkdir, writeFile } from "node:fs/promises";
10
- import { EventEmitter } from "node:events";
11
- import { WebSocket } from "ws";
12
- import { accessSync } from "node:fs";
13
- import { fileURLToPath } from "node:url";
14
- //#region src/test-runner/discover.ts
15
- /**
16
- * Test-file discovery shared by the `devtools-test` CLI and the `run_tests`
17
- * MCP tool, so both expand glob patterns with identical semantics.
18
- *
19
- * Uses Node's built-in `fs/promises` `glob` (Node 22+) — no extra dependency,
20
- * which keeps the MCP daemon install graph lean (a plain glob lib would land in
21
- * the `npx … devtools-mcp` path for no benefit).
22
- *
23
- * Pure Node IO only (`node:fs/promises` + `node:path`) — react-free, so it is
24
- * safe to import from the MCP daemon graph.
25
- */
26
- /**
27
- * Filename suffix that opts a test file into manual-variant scheduling
28
- * (devtools#741). A file named `<name>.manual.ait.test.ts` is EXCLUDED from
29
- * `discoverTestFiles`'s default output (so existing unattended runs are
30
- * unaffected — the zero-diff-when-off constraint) and is only surfaced when
31
- * the caller explicitly asks for manual files via {@link partitionManualTests}
32
- * (wired from the CLI's `--manual-blocking` flag).
33
- *
34
- * This is the entire tagging contract for v1 — no separate manifest/config,
35
- * just a filename convention. Documented here + in the CLI `--help` text
36
- * (cli.ts USAGE) and the test-runner README.
37
- */
38
- const MANUAL_TEST_SUFFIX = ".manual.ait.test.ts";
39
- /** True when `file`'s basename ends with {@link MANUAL_TEST_SUFFIX}. */
40
- function isManualTestFile(file) {
41
- return basename(file).endsWith(MANUAL_TEST_SUFFIX);
42
- }
43
- /**
44
- * Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of
45
- * ABSOLUTE test file paths, resolved relative to `cwd`.
46
- *
47
- * A plain (non-glob) path passes through when it matches a real file; a glob
48
- * expands against `cwd`. Absolute matches are kept as-is; relative matches are
49
- * resolved against `cwd`. `bundleTestFile` requires an absolute path, so the
50
- * absolute output feeds it directly.
51
- *
52
- * By default, files matching {@link MANUAL_TEST_SUFFIX} are EXCLUDED from the
53
- * result (devtools#741) — blocking-UI tests opt in via that filename
54
- * convention and must never appear in an unattended run unless the caller
55
- * explicitly asks for them via `includeManual: true`. This keeps the
56
- * default (flag-off) discovery path byte-for-byte identical to before this
57
- * option existed.
58
- *
59
- * @param patterns Glob patterns or file paths (e.g. `['src/**\/*.ait.test.ts']`).
60
- * @param cwd Base directory for relative patterns/results.
61
- * @param opts `{ includeManual }` — when true, manual-tagged files are kept
62
- * in the (still-sorted) output instead of being filtered out.
63
- * Use {@link partitionManualTests} to separate + reorder them.
64
- * @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.
65
- */
66
- async function discoverTestFiles(patterns, cwd, opts) {
67
- const out = /* @__PURE__ */ new Set();
68
- for await (const match of glob(patterns, { cwd })) out.add(isAbsolute(match) ? match : resolve(cwd, match));
69
- const sorted = [...out].sort();
70
- if (opts?.includeManual) return sorted;
71
- return sorted.filter((f) => !isManualTestFile(f));
72
- }
73
- /**
74
- * Splits an already-discovered file list into `{ regular, manual }`, each
75
- * still sorted. Used by the CLI's `--manual-blocking` path to schedule manual
76
- * files strictly AFTER every regular file (devtools#741) — regular files run
77
- * first (and produce the unattended-shaped part of the report) and manual
78
- * files run last, each preceded by a dashboard prompt.
79
- *
80
- * Pure partition — does not itself decide inclusion; call
81
- * `discoverTestFiles(patterns, cwd, { includeManual: true })` first so manual
82
- * files are present in `files` to partition.
83
- */
84
- function partitionManualTests(files) {
85
- const regular = [];
86
- const manual = [];
87
- for (const f of files) (isManualTestFile(f) ? manual : regular).push(f);
88
- return {
89
- regular,
90
- manual
91
- };
92
- }
93
- //#endregion
94
- //#region src/test-runner/relay-factory.ts
95
- /**
96
- * Builds a {@link RelayConnectionFactory} that opens a standalone relay
97
- * connection for env 3 (intoss-private scheme, default) or env 2 (AITC Sandbox
98
- * PWA launcher deep-link, when `opts.attachLauncher` is set — devtools#774).
99
- *
100
- * `open()` performs the full attach lifecycle and BLOCKS while a human scans
101
- * the rendered QR with their phone — there is no way around the manual scan.
102
- * By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the
103
- * runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is
104
- * a human-paced action with no sound default bound (devtools#735). Passing an
105
- * explicit `timeoutMs` opts into the old bounded behavior (CI/headless
106
- * callers). It resolves with the live `CdpConnection` once a matching page
107
- * attaches; `close()` tears the relay family down.
108
- *
109
- * The factory holds the booted relay family in a closure so `close()` can stop
110
- * it. A second `open()` on the same factory boots a fresh family (the previous
111
- * one should have been `close()`d first).
112
- */
113
- function createRelayConnectionFactory(opts) {
114
- const projectRoot = opts.projectRoot ?? process.cwd();
115
- const timeoutMs = opts.timeoutMs ?? Number.POSITIVE_INFINITY;
116
- const headless = opts.headless === true;
117
- let family;
118
- let qrServer;
119
- let attachWatcher;
120
- let phase = "active";
121
- let manualPrompt = null;
122
- return {
123
- async open() {
124
- const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-DL3NQ9ca.js").then((n) => n.i);
125
- const { injectDebugIndicator, injectGlobals } = await import("../cell-EBKKpAAT.js").then((n) => n.n);
126
- const { loadRelaySecretReadOnly } = await import("../relay-secret-store-Bmyleu0A.js");
127
- const { bootRelayFamily, buildRelayVerifyAuth, startAttachWatcher } = await import("../debug-server-B3ABDrRI.js");
128
- const { buildChiiInspectorUrl } = await import("../devtools-opener-CJpEsXXQ.js");
129
- const { generateTotp } = await import("../totp-CZLLKfOC.js").then((n) => n.i);
130
- await loadRelaySecretReadOnly({ projectRoot });
131
- let resolveTunnelUp;
132
- const tunnelReady = new Promise((resolve) => {
133
- resolveTunnelUp = resolve;
134
- });
135
- const booted = await bootRelayFamily({
136
- verifyAuth: buildRelayVerifyAuth(),
137
- onWssUrl: () => {
138
- resolveTunnelUp();
139
- qrServer?.notifyStateChange();
140
- },
141
- onTunnelDown: () => {
142
- qrServer?.notifyStateChange();
143
- }
144
- });
145
- family = booted;
146
- if (booted.getTunnelStatus?.().up) resolveTunnelUp();
147
- let lastAttachParts;
148
- const attachDeps = {
149
- getTunnelStatus: booted.getTunnelStatus ?? (() => ({
150
- up: false,
151
- wssUrl: null
152
- })),
153
- getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
154
- qrHttpServer: void 0,
155
- onAttachUrlBuilt: void 0,
156
- canOpenBrowser: () => !headless
157
- };
158
- try {
159
- const { startQrHttpServer } = await import("../qr-http-server-DrbIVDjO.js");
160
- const getDashboardState = () => ({
161
- tunnel: attachDeps.getTunnelStatus(),
162
- pages: booted.connection.listTargets().map((t) => ({
163
- id: t.id,
164
- url: t.url
165
- })),
166
- attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,
167
- mode: opts.attachLauncher === true ? "relay-mobile" : "relay-dev",
168
- phase,
169
- manualPrompt
170
- });
171
- const getDirectInspectorUrl = () => {
172
- if (!booted.relayHttpUrl) return {
173
- ok: false,
174
- reason: "relayDown"
175
- };
176
- const targets = booted.connection.listTargets();
177
- if (targets.length === 0) return {
178
- ok: false,
179
- reason: "noTarget"
180
- };
181
- const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
182
- if (!totpSecret) return {
183
- ok: false,
184
- reason: "totpUnavailable"
185
- };
186
- const url = buildChiiInspectorUrl(booted.relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
187
- if (url === null) return {
188
- ok: false,
189
- reason: "totpUnavailable"
190
- };
191
- return {
192
- ok: true,
193
- url
194
- };
195
- };
196
- qrServer = await startQrHttpServer(getDashboardState, {
197
- dashboardPort: opts.dashboardPort,
198
- getDirectInspectorUrl
199
- });
200
- attachDeps.qrHttpServer = qrServer;
201
- attachDeps.onAttachUrlBuilt = (parts) => {
202
- lastAttachParts = parts;
203
- qrServer?.notifyStateChange();
204
- };
205
- attachWatcher = startAttachWatcher(booted.connection, void 0, 1e3, () => qrServer?.notifyStateChange(), () => qrServer?.notifyStateChange());
206
- process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\n`);
207
- } catch {}
208
- const TUNNEL_BOOT_TIMEOUT_MS = 15e3;
209
- await Promise.race([tunnelReady, new Promise((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS))]);
210
- let prep;
211
- if (opts.attachLauncher === true) {
212
- const priorTunnelBaseUrl = process.env.AIT_TUNNEL_BASE_URL;
213
- process.env.AIT_TUNNEL_BASE_URL = opts.appUrl ?? "";
214
- try {
215
- prep = await prepareAttach(attachDeps, "relay-mobile", {}, booted.connection);
216
- } finally {
217
- if (priorTunnelBaseUrl === void 0) delete process.env.AIT_TUNNEL_BASE_URL;
218
- else process.env.AIT_TUNNEL_BASE_URL = priorTunnelBaseUrl;
219
- }
220
- } else prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
221
- if (!prep.ok) {
222
- booted.stop();
223
- family = void 0;
224
- attachWatcher?.stop();
225
- attachWatcher = void 0;
226
- await qrServer?.close();
227
- qrServer = void 0;
228
- throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
229
- }
230
- const waitResult = await renderAndMaybeWait(attachDeps, prep, true, timeoutMs, booted.connection);
231
- if (waitResult.isError) {
232
- booted.stop();
233
- family = void 0;
234
- attachWatcher?.stop();
235
- attachWatcher = void 0;
236
- await qrServer?.close();
237
- qrServer = void 0;
238
- const timeoutSec = Math.round(timeoutMs / 1e3);
239
- throw new Error(`createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`);
240
- }
241
- const qrChunks = waitResult.content.filter((c) => c.type === "text").map((c) => c.text);
242
- opts.onQrContent(qrChunks);
243
- const PAGE_READY_RETRIES = 3;
244
- const PAGE_READY_RETRY_DELAY_MS = 1500;
245
- let lastEnableError;
246
- for (let attempt = 1; attempt <= PAGE_READY_RETRIES; attempt++) try {
247
- await booted.connection.enableDomains();
248
- lastEnableError = void 0;
249
- break;
250
- } catch (err) {
251
- lastEnableError = err instanceof Error ? err : new Error(String(err));
252
- if (attempt < PAGE_READY_RETRIES) await new Promise((resolve) => setTimeout(resolve, PAGE_READY_RETRY_DELAY_MS));
253
- }
254
- if (lastEnableError !== void 0) {
255
- booted.stop();
256
- family = void 0;
257
- attachWatcher?.stop();
258
- attachWatcher = void 0;
259
- await qrServer?.close();
260
- qrServer = void 0;
261
- throw new Error(`createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round(PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS / 1e3)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`);
262
- }
263
- await injectDebugIndicator(booted.connection);
264
- if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
265
- if (opts.stubBlocking === true) await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });
266
- if (opts.paceMs !== void 0 && opts.paceMs > 0) await injectGlobals(booted.connection, { __AIT_PACE_MS__: opts.paceMs });
267
- if (opts.paceMethodMs !== void 0 && opts.paceMethodMs > 0) await injectGlobals(booted.connection, { __AIT_PACE_METHOD_MS__: opts.paceMethodMs });
268
- return booted.connection;
269
- },
270
- onSessionPhase(next) {
271
- phase = next;
272
- qrServer?.notifyStateChange();
273
- },
274
- onManualPrompt(next) {
275
- manualPrompt = next;
276
- qrServer?.notifyStateChange();
277
- },
278
- async close(connection) {
279
- if (phase !== "complete") {
280
- phase = "complete";
281
- qrServer?.notifyStateChange();
282
- }
283
- try {
284
- const { injectDebugIndicator } = await import("../cell-EBKKpAAT.js").then((n) => n.n);
285
- await injectDebugIndicator(connection, { state: "disconnected" });
286
- } catch {}
287
- family?.stop();
288
- family = void 0;
289
- attachWatcher?.stop();
290
- attachWatcher = void 0;
291
- await qrServer?.close();
292
- qrServer = void 0;
293
- }
294
- };
295
- }
296
- //#endregion
297
- //#region src/shared/relay-auth-close.ts
298
- /**
299
- * Shared constants for the relay's named TOTP-auth rejection (issue #478).
300
- *
301
- * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
302
- * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
303
- * indistinguishable from a network failure on the browser side — the
304
- * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
305
- * could not tell "stale TOTP code" apart from "tunnel down" and stayed
306
- * silent. The fix is accept-then-close: complete the handshake, then close
307
- * with an application close code that NAMES the rejection.
308
- *
309
- * Three parties share this contract:
310
- * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
311
- * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
312
- * surfaces the code to the launcher shell;
313
- * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
314
- * as an auth failure on its own `/client` dial (defensive — #439's fresh
315
- * code mint means it should not normally hit this).
316
- *
317
- * This module is intentionally dependency-free (no Node, no DOM) so it is
318
- * safe to import from both the browser in-app bundle and the MCP daemon
319
- * bundle.
320
- *
321
- * SECRET-HANDLING: these are fixed enum values. The close reason / error body
322
- * must never grow to carry a secret, a TOTP code, or a host.
323
- */
324
- /**
325
- * WebSocket close code sent by the relay when TOTP auth is rejected.
326
- *
327
- * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
328
- * HTTP 401 so it reads as "unauthorized" at a glance.
329
- */
330
- const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
331
- /**
332
- * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
333
- * the `error` value of the relay's HTTP 401 JSON body. Enum string only —
334
- * never interpolated with request data.
335
- */
336
- const RELAY_AUTH_REJECT_REASON = "totp-rejected";
337
- //#endregion
338
- //#region src/mcp/chii-connection.ts
339
- /**
340
- * Production `CdpConnection` backed by the local Chii relay.
341
- *
342
- * Topology (debug mode):
343
- * phone target.js --WS--> Chii relay :9100 <--WS-- this connection
344
- *
345
- * The phone connects to the relay as a `target`; this module connects as a
346
- * `client` (the role a CDP frontend would take) so CDP events the page emits
347
- * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
348
- * events in ring buffers the tool layer reads via `getBufferedEvents`.
349
- *
350
- * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
351
- *
352
- * Attach reliability (#281):
353
- * `refreshTargets()` emits an internal 'target:attached' event whenever a
354
- * new target is added to the relay. `waitForFirstTarget()` awaits that event
355
- * (with a polling-interval fallback) so `start_attach`'s attach wait
356
- * resolves deterministically rather than racing between polling rounds.
357
- */
358
- /** Max events retained per domain ring buffer. */
359
- const DEFAULT_BUFFER_SIZE = 500;
360
- /**
361
- * Substrings that mark a "relay websocket is dead" class error, as produced by
362
- * `handleDisconnect('relay WebSocket 연결이 끊겼습니다')` (ws close handler) and
363
- * the fail-fast `sendCommand` rejection (`relay에 연결되어 있지 않습니다 (...)`).
364
- *
365
- * Exported so callers outside this module (e.g. `test-runner/relay-worker.ts`)
366
- * can detect the same error class without hardcoding a fragile full-sentence
367
- * match — mirrors the `EVALUATE_TIMEOUT_MARKER` precedent in
368
- * `test-runner/relay-worker.ts`. Kept in sync with `classifyToolError`'s
369
- * `relayDisconnectError` branch in `mcp/errors.ts`.
370
- */
371
- const RELAY_DISCONNECT_MARKERS = ["relay WebSocket", "relay에 연결되어 있지 않습니다"];
372
- /**
373
- * Returns true when `message` matches the relay-websocket-dead error class
374
- * (socket closed, socket errored, or fail-fast "not connected" rejection).
375
- */
376
- function isRelayDisconnectMessage(message) {
377
- return RELAY_DISCONNECT_MARKERS.some((marker) => message.includes(marker));
378
- }
379
- function isObject(value) {
380
- return typeof value === "object" && value !== null;
381
- }
382
- function parseInbound(raw) {
383
- let parsed;
384
- try {
385
- parsed = JSON.parse(raw);
386
- } catch {
387
- return null;
388
- }
389
- if (!isObject(parsed)) return null;
390
- const message = {};
391
- if (typeof parsed.id === "number") message.id = parsed.id;
392
- if (typeof parsed.method === "string") message.method = parsed.method;
393
- if ("params" in parsed) message.params = parsed.params;
394
- if ("result" in parsed) message.result = parsed.result;
395
- if (isObject(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
396
- return message;
397
- }
398
- const PHASE_1_EVENTS = [
399
- "Runtime.consoleAPICalled",
400
- "Network.requestWillBeSent",
401
- "Network.responseReceived"
402
- ];
403
- /**
404
- * Ring buffer size for `Runtime.exceptionThrown`.
405
- *
406
- * Exceptions are rarer than console messages but each is heavier (stack
407
- * trace). 50 is generous enough to cover a crash scenario while keeping
408
- * memory bounded.
409
- *
410
- * **Lifecycle note**: the exception buffer intentionally survives `replaced` /
411
- * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
412
- * transitions. Rationale: an exception fired just before a crash is exactly
413
- * the signal we want to preserve for root-cause analysis. The buffer
414
- * represents "exceptions seen in this MCP session", not "exceptions in the
415
- * current page".
416
- */
417
- const EXCEPTION_BUFFER_SIZE = 50;
418
- /** Default per-command timeout if neither option nor env var is set. */
419
- const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
420
- /**
421
- * Production CDP connection. Polls the relay for the first attached target,
422
- * opens a client websocket to it, enables Phase 1 domains, and buffers events.
423
- */
424
- var ChiiCdpConnection = class {
425
- /** Authoritative connection kind (issue #348) — relay-backed. */
426
- kind = "relay";
427
- relayBaseUrl;
428
- bufferSize;
429
- commandTimeoutMs;
430
- totpSecret;
431
- emitter = new EventEmitter();
432
- buffers = /* @__PURE__ */ new Map();
433
- targets = /* @__PURE__ */ new Map();
434
- ws = null;
435
- connectionState = "idle";
436
- nextCommandId = 1;
437
- /**
438
- * The single active target id under the single-attach model.
439
- * Updated by `refreshTargets()` whenever a non-null target is present.
440
- * Used to detect a new (different) target attach and evict the previous one.
441
- */
442
- activeTargetId = null;
443
- /** In-flight enableDomains() promise — concurrent callers share it. */
444
- enablingPromise = null;
445
- /** Pending request→response commands keyed by CDP message id. */
446
- pending = /* @__PURE__ */ new Map();
447
- /**
448
- * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
449
- * or `null` if no crash has been detected since the last `enableDomains()`.
450
- */
451
- lastCrashDetectedAt = null;
452
- /**
453
- * Per-target last-seen timestamp (ms since epoch). Updated on any inbound
454
- * CDP message carrying data from a target. Keyed by target id.
455
- */
456
- targetLastSeenAt = /* @__PURE__ */ new Map();
457
- /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
458
- heartbeatHandle = null;
459
- /** Lifecycle event listeners (crash / destroyed / detached). */
460
- lifecycleListeners = [];
461
- constructor(options) {
462
- this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
463
- this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
464
- this.totpSecret = options.totpSecret;
465
- const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
466
- this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
467
- for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
468
- this.buffers.set("Runtime.exceptionThrown", []);
469
- this.emitter.setMaxListeners(0);
470
- }
471
- /** Refresh the attached-target list from the relay's `GET /targets`. */
472
- async refreshTargets() {
473
- let targetsUrl = `${this.relayBaseUrl}/targets`;
474
- if (this.totpSecret) {
475
- const code = generateTotp(this.totpSecret);
476
- targetsUrl += `?at=${encodeURIComponent(code)}`;
477
- }
478
- const res = await fetch(targetsUrl);
479
- if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
480
- const body = await res.json();
481
- const list = isObject(body) && Array.isArray(body.targets) ? body.targets : [];
482
- let newestTargetId = null;
483
- for (const item of list) {
484
- if (!isObject(item) || typeof item.id !== "string") continue;
485
- newestTargetId = item.id;
486
- }
487
- if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
488
- const prevId = this.activeTargetId;
489
- logInfo("page.detached", { prevTargetId: prevId });
490
- this.evictTarget(prevId);
491
- }
492
- this.targets.clear();
493
- for (const item of list) {
494
- if (!isObject(item) || typeof item.id !== "string") continue;
495
- if (item.id !== newestTargetId) continue;
496
- this.targets.set(item.id, {
497
- id: item.id,
498
- title: typeof item.title === "string" ? item.title : "",
499
- url: typeof item.url === "string" ? item.url : ""
500
- });
501
- }
502
- if (newestTargetId !== null) this.activeTargetId = newestTargetId;
503
- else this.activeTargetId = null;
504
- const result = [...this.targets.values()];
505
- if (newestTargetId !== null) this.emitter.emit("target:attached", result);
506
- return result;
507
- }
508
- listTargets() {
509
- return [...this.targets.values()];
510
- }
511
- /**
512
- * Waits until at least one target matching `filterFn` is attached, then
513
- * resolves with the full target list at that moment.
514
- *
515
- * Resolution happens on whichever comes first:
516
- * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
517
- * the /targets poll finding a new target), OR
518
- * (b) a `'target:attached'` event from `handleMessage()` (triggered by
519
- * the first inbound CDP message from a target — confirms the relay
520
- * websocket has data from the phone, not just a target entry in the map).
521
- *
522
- * This dual-signal approach eliminates the polling race that previously
523
- * caused `wait_for_attach` to resolve before the first CDP message arrived.
524
- *
525
- * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
526
- * EventEmitter is missed (defensive belt-and-suspenders).
527
- *
528
- * @param filterFn - Predicate that the returned targets must satisfy.
529
- * @param timeoutMs - Reject after this many ms (default 90 000). Pass a
530
- * non-finite value (`Infinity`) to disable the rejection timer entirely —
531
- * used by the test-runner's unbounded QR-attach wait (devtools#735). Node
532
- * clamps `setTimeout(fn, Infinity)` to ~1ms, so a non-finite value MUST
533
- * skip arming the timer rather than pass it through.
534
- * @param pollIntervalMs - Fallback poll interval (default 500ms).
535
- */
536
- waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
537
- const current = this.listTargets();
538
- if (filterFn(current)) return Promise.resolve(current);
539
- return new Promise((resolve, reject) => {
540
- let settled = false;
541
- let pollHandle = null;
542
- const settle = (targets) => {
543
- if (settled) return;
544
- settled = true;
545
- if (timeoutHandle !== null) clearTimeout(timeoutHandle);
546
- if (pollHandle !== null) {
547
- clearInterval(pollHandle);
548
- pollHandle = null;
549
- }
550
- this.emitter.off("target:attached", onAttach);
551
- resolve(targets);
552
- };
553
- const onAttach = (targets) => {
554
- if (filterFn(targets)) settle(targets);
555
- };
556
- const timeoutHandle = Number.isFinite(timeoutMs) ? setTimeout(() => {
557
- if (settled) return;
558
- settled = true;
559
- if (pollHandle !== null) {
560
- clearInterval(pollHandle);
561
- pollHandle = null;
562
- }
563
- this.emitter.off("target:attached", onAttach);
564
- reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
565
- }, timeoutMs) : null;
566
- this.emitter.on("target:attached", onAttach);
567
- pollHandle = setInterval(() => {
568
- this.refreshTargets().then((targets) => {
569
- if (filterFn(targets)) settle(targets);
570
- }, () => {});
571
- }, pollIntervalMs);
572
- });
573
- }
574
- /**
575
- * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
576
- * detected since the last `enableDomains()` call, or `null` if none.
577
- */
578
- getLastCrashDetectedAt() {
579
- return this.lastCrashDetectedAt;
580
- }
581
- /**
582
- * Last-seen timestamp (ms since epoch) for a given target id, or `null` if
583
- * the target is unknown / no message has been received from it yet.
584
- */
585
- getTargetLastSeenAt(targetId) {
586
- return this.targetLastSeenAt.get(targetId) ?? null;
587
- }
588
- /** Subscribe to target lifecycle events (crash / destroyed / detached). */
589
- onLifecycle(listener) {
590
- this.lifecycleListeners.push(listener);
591
- return () => {
592
- const idx = this.lifecycleListeners.indexOf(listener);
593
- if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
594
- };
595
- }
596
- /**
597
- * Connect a client websocket to the first attached target and enable Phase 1
598
- * domains. Resolves once the socket is open and enable commands are sent.
599
- */
600
- async enableDomains() {
601
- if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
602
- if (this.enablingPromise) return this.enablingPromise;
603
- this.enablingPromise = this._doEnableDomains().finally(() => {
604
- this.enablingPromise = null;
605
- });
606
- return this.enablingPromise;
607
- }
608
- async _doEnableDomains() {
609
- const target = (await this.refreshTargets())[0];
610
- if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
611
- let clientUrl = `${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`;
612
- if (this.totpSecret) {
613
- const code = generateTotp(this.totpSecret);
614
- clientUrl += `&at=${encodeURIComponent(code)}`;
615
- }
616
- const ws = new WebSocket(clientUrl);
617
- this.ws = ws;
618
- await new Promise((resolve, reject) => {
619
- ws.once("open", () => resolve());
620
- ws.once("error", (err) => reject(err));
621
- ws.once("close", (code) => {
622
- if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
623
- });
624
- });
625
- this.lastCrashDetectedAt = null;
626
- this.targetLastSeenAt.clear();
627
- this.connectionState = "connected";
628
- ws.on("message", (data) => this.handleMessage(data.toString()));
629
- ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
630
- ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
631
- this.sendFireAndForget("Runtime.enable");
632
- this.sendFireAndForget("Network.enable");
633
- this.sendFireAndForget("DOM.enable");
634
- this.sendFireAndForget("Page.enable");
635
- this.sendFireAndForget("Inspector.enable");
636
- this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
637
- this.startHeartbeat(target.id);
638
- }
639
- /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
640
- sendFireAndForget(method, params = {}) {
641
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
642
- const id = this.nextCommandId++;
643
- this.ws.send(JSON.stringify({
644
- id,
645
- method,
646
- params
647
- }));
648
- }
649
- /**
650
- * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
651
- * error frame or when no websocket is open (no page attached yet).
652
- *
653
- * @param opts.timeoutMs - Per-call override for this connection's command
654
- * watchdog (devtools#747) — see the `CdpConnection.send` docblock for the
655
- * contract callers racing their own longer timeout must follow.
656
- */
657
- send(method, params, opts) {
658
- return this.sendCommand(method, params ?? {}, opts);
659
- }
660
- /**
661
- * Issue an arbitrary request→response command over the relay and resolve with
662
- * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
663
- * `AIT.*` methods, forwarded over the same Chii channel) build on this.
664
- *
665
- * Rejects immediately if the connection is disconnected (fail-fast — no
666
- * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
667
- * reattach.
668
- *
669
- * Times out after `opts.timeoutMs` when given, else `commandTimeoutMs`
670
- * (default 30s, env `AIT_CDP_COMMAND_TIMEOUT_MS`) — see devtools#747: the
671
- * default 30s watchdog used to undercut the test-runner's own longer
672
- * file-evaluate race no matter what `--timeout` the caller asked for. On
673
- * timeout the pending entry is cleaned up and the promise rejects with a
674
- * descriptive Korean error. `Number.isFinite` guards against a non-finite
675
- * override (e.g. `Infinity`, mirroring `waitForFirstTarget`'s convention)
676
- * so an intentional "no watchdog" override doesn't get clamped by
677
- * `setTimeout`.
678
- */
679
- sendCommand(method, params = {}, opts) {
680
- if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
681
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No mini-app page attached to the Chii relay yet. Call enableDomains() first."));
682
- const id = this.nextCommandId++;
683
- const ws = this.ws;
684
- const timeoutMs = opts?.timeoutMs !== void 0 && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : this.commandTimeoutMs;
685
- return new Promise((resolve, reject) => {
686
- const handle = setTimeout(() => {
687
- this.pending.delete(id);
688
- reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
689
- }, timeoutMs);
690
- this.pending.set(id, {
691
- resolve: (v) => {
692
- clearTimeout(handle);
693
- resolve(v);
694
- },
695
- reject: (e) => {
696
- clearTimeout(handle);
697
- reject(e);
698
- }
699
- });
700
- ws.send(JSON.stringify({
701
- id,
702
- method,
703
- params
704
- }));
705
- });
706
- }
707
- /**
708
- * Called on WebSocket `close` or `error` after a successful connection.
709
- * Rejects all pending commands and marks the connection as disconnected so
710
- * subsequent `sendCommand` calls fail fast (no auto-reconnect).
711
- */
712
- handleDisconnect(reason) {
713
- if (this.connectionState === "disconnected") return;
714
- this.connectionState = "disconnected";
715
- this.ws = null;
716
- this.stopHeartbeat();
717
- const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
718
- for (const waiter of this.pending.values()) waiter.reject(err);
719
- this.pending.clear();
720
- }
721
- /**
722
- * Evict a previously active target under the single-attach model.
723
- * Rejects pending commands with a 'replaced-by-new-attach' reason and emits
724
- * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
725
- * targetId. The caller is responsible for rebuilding the targets map afterwards.
726
- *
727
- * The error message uses 'replaced-by-new-attach' so test assertions can match it.
728
- */
729
- evictTarget(targetId) {
730
- const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
731
- this.targets.delete(targetId);
732
- this.targetLastSeenAt.delete(targetId);
733
- const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
734
- for (const waiter of this.pending.values()) waiter.reject(err);
735
- this.pending.clear();
736
- const event = {
737
- kind: "replaced",
738
- targetId,
739
- detectedAt
740
- };
741
- for (const listener of this.lifecycleListeners) try {
742
- listener(event);
743
- } catch {}
744
- }
745
- /**
746
- * Handle a page-level crash or target destruction event.
747
- * Removes the target from the in-memory map, rejects all pending commands,
748
- * and emits a lifecycle event.
749
- *
750
- * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
751
- * @param targetId - The target ID from the event params (may be null for
752
- * Inspector.targetCrashed which has no targetId in the params).
753
- */
754
- handleTargetGone(kind, targetId) {
755
- const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
756
- this.lastCrashDetectedAt = Date.now();
757
- if (targetId !== null) {
758
- this.targets.delete(targetId);
759
- this.targetLastSeenAt.delete(targetId);
760
- if (this.activeTargetId === targetId) this.activeTargetId = null;
761
- } else {
762
- this.targets.clear();
763
- this.targetLastSeenAt.clear();
764
- this.activeTargetId = null;
765
- }
766
- const err = /* @__PURE__ */ new Error(`[ait-debug] ${kind === "crashed" ? "page crash (Inspector.targetCrashed)" : kind === "destroyed" ? "target 종료 (Target.targetDestroyed)" : "target detach (Target.detachedFromTarget)"} 감지됨 — relay에서 제거됐습니다. 새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).`);
767
- for (const waiter of this.pending.values()) waiter.reject(err);
768
- this.pending.clear();
769
- const event = {
770
- kind,
771
- targetId,
772
- detectedAt
773
- };
774
- for (const listener of this.lifecycleListeners) try {
775
- listener(event);
776
- } catch {}
777
- }
778
- /**
779
- * Start the optional CDP heartbeat loop.
780
- *
781
- * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
782
- * we send `Runtime.evaluate({expression: '1'})` to each active target. If
783
- * the command times out (2 s hard deadline) or errors, we treat the target
784
- * as dead and call `handleTargetGone`.
785
- *
786
- * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
787
- * even when the phone app has crashed, so the ws-level disconnect (#252) won't
788
- * fire. The heartbeat catches this gap.
789
- *
790
- * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
791
- */
792
- startHeartbeat(initialTargetId) {
793
- this.stopHeartbeat();
794
- const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
795
- if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
796
- const PING_TIMEOUT_MS = 2e3;
797
- this.heartbeatHandle = setInterval(() => {
798
- const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
799
- for (const targetId of targetIds) {
800
- const pingPromise = this.sendCommand("Runtime.evaluate", {
801
- expression: "1",
802
- returnByValue: true,
803
- timeout: PING_TIMEOUT_MS
804
- });
805
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
806
- Promise.race([pingPromise, timeoutPromise]).catch(() => {
807
- if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
808
- });
809
- }
810
- }, envMs);
811
- }
812
- stopHeartbeat() {
813
- if (this.heartbeatHandle !== null) {
814
- clearInterval(this.heartbeatHandle);
815
- this.heartbeatHandle = null;
816
- }
817
- }
818
- handleMessage(raw) {
819
- const message = parseInbound(raw);
820
- if (!message) return;
821
- if (typeof message.id === "number" && this.pending.has(message.id)) {
822
- const waiter = this.pending.get(message.id);
823
- this.pending.delete(message.id);
824
- if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
825
- else waiter.resolve(message.result);
826
- return;
827
- }
828
- const now = Date.now();
829
- let firstMessageSeen = false;
830
- for (const targetId of this.targets.keys()) {
831
- if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
832
- this.targetLastSeenAt.set(targetId, now);
833
- }
834
- if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
835
- if (typeof message.method !== "string") return;
836
- if (message.method === "Inspector.targetCrashed") {
837
- this.handleTargetGone("crashed", null);
838
- return;
839
- }
840
- if (message.method === "Target.targetDestroyed") {
841
- const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
842
- this.handleTargetGone("destroyed", targetId);
843
- return;
844
- }
845
- if (message.method === "Target.detachedFromTarget") {
846
- const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
847
- this.handleTargetGone("detached", targetId);
848
- return;
849
- }
850
- if (!this.buffers.has(message.method)) return;
851
- const event = message.method;
852
- const buffer = this.buffers.get(event);
853
- if (!buffer) return;
854
- buffer.push(message.params);
855
- const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
856
- if (buffer.length > cap) buffer.shift();
857
- this.emitter.emit(event, message.params);
858
- }
859
- getBufferedEvents(event) {
860
- return this.buffers.get(event) ?? [];
861
- }
862
- on(event, listener) {
863
- this.emitter.on(event, listener);
864
- return () => this.emitter.off(event, listener);
865
- }
866
- /** Close the relay client websocket and reject any in-flight commands. */
867
- close() {
868
- const ws = this.ws;
869
- this.stopHeartbeat();
870
- this.handleDisconnect("Chii relay connection closed");
871
- ws?.close();
872
- }
873
- };
874
- //#endregion
875
- //#region src/test-runner/bundle.ts
876
- /**
877
- * esbuild-based bundler for user test files.
878
- *
879
- * Bundles a single test file into a self-contained IIFE string that can be
880
- * injected into a WebView via `Runtime.evaluate`. The bundle includes the
881
- * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
882
- * the `runTestModule(factory)` entry point.
883
- *
884
- * ## How the wiring works
885
- *
886
- * The bundle exposes two exports on `globalThis.__testBundle`:
887
- * - `runTestModule` — the runtime's entry function.
888
- * - `__userFactory` — an async function whose body is the user's top-level
889
- * test registration code (describe/it/test calls).
890
- *
891
- * The Node-side RPC (`rpc.ts`) calls:
892
- * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
893
- *
894
- * `runTestModule` then installs `describe/it/test/expect` as globals, invokes
895
- * the factory (which registers all tests), runs them, and returns a `RunReport`.
896
- *
897
- * ## Why a factory wrapper is needed
898
- *
899
- * Naively adding the runtime to `entryPoints` and bundling the user file would
900
- * fail for two reasons:
901
- * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
902
- * scope. The user's top-level `describe(...)` calls expect them as globals —
903
- * they are not globals until `runTestModule` installs them.
904
- * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
905
- * time, before the RPC layer calls `runTestModule` to reset state and start
906
- * the test clock.
907
- *
908
- * The factory approach solves both: the user's registration code is deferred
909
- * into a function that `runTestModule` calls AFTER installing the globals.
910
- *
911
- * ## Factory extraction algorithm
912
- *
913
- * The `userFactoryPlugin` reads the user file and splits lines into:
914
- * - **top-level**: `import …` and re-export lines — kept at module scope
915
- * (the only valid position for static `import` in ESM).
916
- * - **body**: all other statements — moved into the body of the exported
917
- * `__userFactory` async function.
918
- *
919
- * esbuild processes the re-generated module, following each static import
920
- * through the normal dependency graph (including the SDK-redirect plugin).
921
- *
922
- * ## SDK redirect
923
- *
924
- * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
925
- * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
926
- * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
927
- *
928
- * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
929
- */
930
- /** The SDK package name that mini-app test code imports from. */
931
- const SDK_PACKAGE = "@apps-in-toss/web-framework";
932
- /**
933
- * Names the runtime installs as globals before invoking the user factory.
934
- * The `vitest` virtual module re-exports each as a lazy getter that reads from
935
- * `globalThis` at access time. Keep in sync with the globals installed in
936
- * `runtime.ts#runTestModule`.
937
- */
938
- const VITEST_GLOBAL_NAMES = [
939
- "describe",
940
- "it",
941
- "test",
942
- "expect",
943
- "beforeAll",
944
- "afterAll",
945
- "beforeEach",
946
- "afterEach",
947
- "vi"
948
- ];
949
- /**
950
- * Matches the bare SDK package and any sub-path import
951
- * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
952
- * Built from {@link SDK_PACKAGE} so the package name has a single source.
953
- */
954
- const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
955
- /**
956
- * esbuild plugin that intercepts SDK imports and redirects them to the
957
- * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
958
- *
959
- * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
960
- * esbuild resolves it to a virtual module that re-exports all named exports
961
- * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
962
- * be available in the test environment) while still making named imports work.
963
- *
964
- * If `window.__sdk` is absent (non-dog-food build), every access throws a
965
- * descriptive error rather than returning `undefined` silently.
966
- *
967
- * Bridge-stub interception (devtools#740, DT-2): the virtual module runs the
968
- * resolved `window.__sdk` object through `wrapSdkWithStub` before exporting
969
- * it. `wrapSdkWithStub` is a pass-through (returns the same object,
970
- * unwrapped) unless `isStubBlockingEnabled()` reads `true` off
971
- * `globalThis.__AIT_STUB_BLOCKING__` — an opt-in flag injected the same way
972
- * `__AIT_CELL__` is (`cell.ts#injectGlobals`, wired from the CLI's
973
- * `--stub-blocking` flag). Default off => zero behavior change: every existing
974
- * (non-stub) test run resolves the exact same `window.__sdk` reference it did
975
- * before this plugin learned about stubbing.
976
- *
977
- * Per-method pacing (devtools#769): BEFORE the stub wrap, `window.__sdk` is
978
- * run through `wrapWithMethodPacing`, which enforces a minimum interval
979
- * between calls to the SAME named SDK function (`getPaceMethodMs()` reads the
980
- * `__AIT_PACE_METHOD_MS__` page global — see `method-pace.ts`). Composition
981
- * order is pacing INSIDE the stub (`wrapSdkWithStub(wrapWithMethodPacing(sdk,
982
- * gap), enabled)`): a stubbed name resolves instantly from
983
- * `bridge-stub.ts`'s fixtures without ever reaching the paced wrapper, and
984
- * only calls that fall through to the real bridge — the ones that can
985
- * actually trigger `APP_BRIDGE_THROTTLED` — pay the pacing cost. `gapMs <= 0`
986
- * (default, no `--pace-method` / non-2.x cell) is a no-op fast path — see
987
- * `method-pace.ts#wrapWithMethodPacing`'s doc for the full contract.
988
- */
989
- /**
990
- * Builds the virtual CommonJS-style module contents the `sdk-redirect`
991
- * esbuild plugin loads for every `@apps-in-toss/web-framework` import.
992
- *
993
- * Pulled out to a pure, exported function (rather than inlined at the
994
- * `build.onLoad` call site) so unit tests can assert the exact composition
995
- * order (pacing wraps the raw SDK; the stub wraps the paced result) as a
996
- * string-level contract, independent of esbuild — esbuild cannot run inside
997
- * this repo's jsdom vitest environment (see `bundleTestFile`'s module doc's
998
- * lazy-import note), so a real bundling round-trip is out of unit-test reach
999
- * here; `wrapSdkWithStub`/`wrapWithMethodPacing`'s own composed-call behavior
1000
- * is covered directly (see `bundle.test.ts`).
1001
- *
1002
- * Generates a virtual CommonJS-style module so that esbuild does NOT perform
1003
- * strict named-export matching. When `format:'iife'` bundles a CJS module, it
1004
- * wraps it with its own `__toCommonJS` helper and satisfies named imports via
1005
- * property access on the `module.exports` object — which is our Proxy. This
1006
- * means `import { getPlatformOS } from '...'` becomes
1007
- * `__proxy.getPlatformOS` at runtime, which correctly reads from
1008
- * `window.__sdk`.
1009
- *
1010
- * The bridge-stub and method-pace imports are STATIC imports of the real
1011
- * `bridge-stub.ts`/`method-pace.ts` modules (not inlined JS) so esbuild
1012
- * bundles each once and the fixture registry / pacing registry have a single
1013
- * source of truth shared with their own unit tests.
1014
- *
1015
- * Exported for unit testing.
1016
- */
1017
- function buildSdkRedirectModuleContents() {
1018
- return `
1019
- import { wrapSdkWithStub, isStubBlockingEnabled } from ${JSON.stringify(getBridgeStubPath())};
1020
- import { wrapWithMethodPacing, getPaceMethodMs } from ${JSON.stringify(getMethodPacePath())};
1021
- var __rawSdk = (typeof window !== 'undefined' && window.__sdk)
1022
- ? window.__sdk
1023
- : new Proxy({}, {
1024
- get: function(_t, p) {
1025
- throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
1026
- }
1027
- });
1028
- var __pacedSdk = wrapWithMethodPacing(__rawSdk, getPaceMethodMs());
1029
- var __proxy = wrapSdkWithStub(__pacedSdk, isStubBlockingEnabled());
1030
- module.exports = __proxy;
1031
- `;
1032
- }
1033
- function sdkRedirectPlugin() {
1034
- return {
1035
- name: "sdk-redirect",
1036
- setup(build) {
1037
- build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
1038
- path: args.path,
1039
- namespace: "sdk-redirect"
1040
- }));
1041
- build.onLoad({
1042
- filter: /.*/,
1043
- namespace: "sdk-redirect"
1044
- }, () => ({
1045
- contents: buildSdkRedirectModuleContents(),
1046
- loader: "js",
1047
- resolveDir: process.cwd()
1048
- }));
1049
- }
1050
- };
1051
- }
1052
- /**
1053
- * esbuild plugin that intercepts `import … from 'vitest'` and replaces it with
1054
- * a virtual module that delegates every named import to `globalThis` at ACCESS
1055
- * time (not at bundle-evaluation time).
1056
- *
1057
- * The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/
1058
- * afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle
1059
- * IIFE is evaluated. A value-copy redirect (`export var describe =
1060
- * globalThis.describe`) would therefore capture `undefined` at evaluation time
1061
- * and the user's `describe(...)` calls would be no-ops — registering zero tests.
1062
- *
1063
- * The fix defers the lookup to call time using per-name **getter** exports.
1064
- * We emit a CommonJS module that:
1065
- * 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named
1066
- * import directly to a property access on the module (NOT wrapped under a
1067
- * `default` shim — which is what happens for a bare Proxy whose own-keys
1068
- * are empty, leaving every named import `undefined`);
1069
- * 2. defines each global name as a getter that reads `globalThis[name]` on
1070
- * every access. So `import { describe } from 'vitest'` compiles to
1071
- * `import_vitest.describe`, whose getter returns the real `describe` only
1072
- * when the factory calls it — after `runTestModule` installs the globals.
1073
- *
1074
- * A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes
1075
- * the virtual module through `__toESM`, which enumerates own-keys (none on an
1076
- * empty Proxy target) and therefore exposes zero named exports. Explicit getter
1077
- * properties give `__toESM` real keys to map while keeping access lazy.
1078
- */
1079
- function vitestRedirectPlugin() {
1080
- return {
1081
- name: "vitest-redirect",
1082
- setup(build) {
1083
- build.onResolve({ filter: /^vitest$/ }, () => ({
1084
- path: "vitest",
1085
- namespace: "vitest-redirect"
1086
- }));
1087
- build.onLoad({
1088
- filter: /^vitest$/,
1089
- namespace: "vitest-redirect"
1090
- }, () => {
1091
- return {
1092
- contents: `Object.defineProperty(exports, '__esModule', { value: true });\n${VITEST_GLOBAL_NAMES.map((name) => `Object.defineProperty(exports, ${JSON.stringify(name)}, { enumerable: true, get: function() { return globalThis[${JSON.stringify(name)}]; } });`).join("\n")}\n`,
1093
- loader: "js"
1094
- };
1095
- });
1096
- }
1097
- };
1098
- }
1099
- /**
1100
- * esbuild plugin that transforms the user test file into a module that exports
1101
- * an async `__userFactory` function. The factory defers the user's top-level
1102
- * test registration code (describe/it/test calls) so it only runs when
1103
- * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
1104
- * installed describe/it/test/expect as globals.
1105
- *
1106
- * Algorithm:
1107
- * - Import declarations and re-export statements are kept at module top-level
1108
- * (the only valid ESM position for static `import`). A statement that spans
1109
- * multiple lines — e.g. a named import with one member per line:
1110
- * import {
1111
- * appLogin,
1112
- * getAnonymousKey,
1113
- * } from '@apps-in-toss/web-framework';
1114
- * is tracked as a single block: every line from the opening `import {` /
1115
- * `export {` through the closing `from '…'` (or side-effect `'…'`) line is
1116
- * kept together at top-level. This prevents the member lines and the
1117
- * closing `} from '…'` line from leaking into the factory body, which would
1118
- * leave an unterminated `import {` at module scope (the #678 env3 failure:
1119
- * esbuild threw `Expected "as" but found "{"` on multi-line SDK imports).
1120
- * - All other lines (describe/it/test calls, local declarations, etc.) are
1121
- * moved into the body of the exported async factory function.
1122
- *
1123
- * This preserves SDK import resolution (the sdk-redirect plugin processes
1124
- * top-level imports normally) while deferring test registration to the factory.
1125
- */
1126
- function userFactoryPlugin(absPath) {
1127
- const NAMESPACE = "user-test-factory";
1128
- return {
1129
- name: "user-test-factory",
1130
- setup(build) {
1131
- build.onResolve({ filter: /^user-test-factory$/ }, () => ({
1132
- path: absPath,
1133
- namespace: NAMESPACE
1134
- }));
1135
- build.onLoad({
1136
- filter: /.*/,
1137
- namespace: NAMESPACE
1138
- }, async (args) => {
1139
- const lines = (await fs.readFile(args.path, "utf8")).split("\n");
1140
- const topLevelLines = [];
1141
- const bodyLines = [];
1142
- const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
1143
- const isImportStart = (trimmed) => trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"");
1144
- const endsStatement = (trimmed) => /['"]\s*;?\s*$/.test(trimmed.replace(/\/\/.*$/, "").trimEnd());
1145
- let inImportBlock = false;
1146
- for (const line of lines) {
1147
- const trimmed = line.trimStart();
1148
- const indent = line.slice(0, line.length - trimmed.length);
1149
- if (inImportBlock) {
1150
- topLevelLines.push(line);
1151
- if (endsStatement(trimmed)) inImportBlock = false;
1152
- continue;
1153
- }
1154
- if (isImportStart(trimmed)) {
1155
- topLevelLines.push(line);
1156
- if (!endsStatement(trimmed)) inImportBlock = true;
1157
- } else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
1158
- else {
1159
- topLevelLines.push(line);
1160
- if (/\bfrom\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith("{")) inImportBlock = true;
1161
- }
1162
- else bodyLines.push(line);
1163
- }
1164
- return {
1165
- contents: [
1166
- ...topLevelLines,
1167
- "",
1168
- "// biome-ignore lint: generated factory wrapper",
1169
- "export default async function __userFactory(): Promise<void> {",
1170
- ...bodyLines.map((l) => ` ${l}`),
1171
- "}"
1172
- ].join("\n"),
1173
- loader: "ts",
1174
- resolveDir: path$1.dirname(absPath)
1175
- };
1176
- });
1177
- }
1178
- };
1179
- }
1180
- /**
1181
- * Returns the absolute filesystem path to a page-side leaf module shipped
1182
- * alongside `bundle.ts` in dist (e.g. `runtime.js`, `bridge-stub.js`) — a
1183
- * fully self-contained module with no further page-side deps of its own.
1184
- *
1185
- * Rolldown code-splitting duplicates this bundling logic into shared chunks
1186
- * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
1187
- * dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`
1188
- * daemon (dist/mcp/cli.js) pulls it through a ROOT chunk
1189
- * (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list
1190
- * is therefore wrong from at least one chunk — the live #697 regression.
1191
- *
1192
- * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
1193
- * probes the co-located `<name>.js` and the nested `test-runner/<name>.js`,
1194
- * then ascends one directory at a time (bounded) repeating both probes. The
1195
- * nested probe catches dist/test-runner/<name>.js from the dist/ root level no
1196
- * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
1197
- * relocation). The build always emits dist/test-runner/<name>.js (tsdown entry
1198
- * `'test-runner/<name>'`; guarded by scripts/check-test-runner-dist.sh for
1199
- * `runtime`).
1200
- *
1201
- * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
1202
- * read, bypassing Node module resolution entirely, so this works identically in
1203
- * the npx-daemon context (its own dist tree) and the consumer-CLI context
1204
- * (the mini-app's installed @ait-co/devtools dist) — neither needs the package
1205
- * to be node-resolvable from the caller.
1206
- *
1207
- * @param moduleName - The leaf module's basename without extension, e.g.
1208
- * `'runtime'` or `'bridge-stub'`.
1209
- */
1210
- function getPageSideModulePath(moduleName) {
1211
- const startDir = path$1.dirname(fileURLToPath(import.meta.url));
1212
- const RELATIVE_PROBES = [
1213
- [`${moduleName}.js`],
1214
- ["test-runner", `${moduleName}.js`],
1215
- [`${moduleName}.ts`],
1216
- ["test-runner", `${moduleName}.ts`]
1217
- ];
1218
- let dir = startDir;
1219
- for (let i = 0; i < 12; i++) {
1220
- for (const segs of RELATIVE_PROBES) {
1221
- const candidate = path$1.join(dir, ...segs);
1222
- try {
1223
- accessSync(candidate);
1224
- return candidate;
1225
- } catch {}
1226
- }
1227
- const parent = path$1.dirname(dir);
1228
- if (parent === dir) break;
1229
- dir = parent;
1230
- }
1231
- return path$1.join(startDir, `${moduleName}.js`);
1232
- }
1233
- /** Absolute path to the test-runner page-side runtime (describe/it/test/expect). */
1234
- function getRuntimePath() {
1235
- return getPageSideModulePath("runtime");
1236
- }
1237
- /**
1238
- * Absolute path to the bridge-stub interceptor (devtools#740, DT-2) —
1239
- * `wrapSdkWithStub`/`isStubBlockingEnabled`, statically imported by the
1240
- * `sdk-redirect` virtual module.
1241
- */
1242
- function getBridgeStubPath() {
1243
- return getPageSideModulePath("bridge-stub");
1244
- }
1245
- /**
1246
- * Absolute path to the per-method pacing wrapper (devtools#769) —
1247
- * `wrapWithMethodPacing`/`getPaceMethodMs`, statically imported by the
1248
- * `sdk-redirect` virtual module.
1249
- */
1250
- function getMethodPacePath() {
1251
- return getPageSideModulePath("method-pace");
1252
- }
1253
- /**
1254
- * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
1255
- *
1256
- * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
1257
- * - `runTestModule` — the runtime entry (from `runtime.ts`).
1258
- * - `__userFactory` — an async function wrapping the user's test registration
1259
- * code so it runs AFTER `runTestModule` installs the globals.
1260
- *
1261
- * Callers (rpc.ts) invoke:
1262
- * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
1263
- *
1264
- * @param absPath - Absolute path to the user test file.
1265
- * @param opts - Optional bundling overrides.
1266
- */
1267
- async function bundleTestFile(absPath, opts) {
1268
- const globalName = opts?.globalName ?? "__testBundle";
1269
- const extraExternals = opts?.extraExternals ?? [];
1270
- const esbuild = await import("esbuild");
1271
- const runtimePath = getRuntimePath();
1272
- const wrapperContent = [
1273
- `import { runTestModule } from ${JSON.stringify(runtimePath)};`,
1274
- `import __userFactory from "user-test-factory";`,
1275
- `export { runTestModule, __userFactory };`
1276
- ].join("\n");
1277
- const result = await esbuild.build({
1278
- stdin: {
1279
- contents: wrapperContent,
1280
- loader: "ts",
1281
- resolveDir: path$1.dirname(absPath)
1282
- },
1283
- bundle: true,
1284
- format: "iife",
1285
- globalName,
1286
- platform: "browser",
1287
- target: "es2022",
1288
- write: false,
1289
- plugins: [
1290
- userFactoryPlugin(absPath),
1291
- vitestRedirectPlugin(),
1292
- sdkRedirectPlugin()
1293
- ],
1294
- external: extraExternals,
1295
- treeShaking: true,
1296
- footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
1297
- });
1298
- const warnings = result.warnings.map((w) => `${path$1.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
1299
- const outputFile = result.outputFiles?.[0];
1300
- if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
1301
- return {
1302
- code: outputFile.text,
1303
- warnings
1304
- };
1305
- }
1306
- //#endregion
1307
- //#region src/test-runner/capture.ts
1308
- /** The exact console-line prefix sdk-example's `flushCapture` emits. */
1309
- const CAPTURE_PREFIX = "__AIT_CAPTURE__ ";
1310
- /**
1311
- * Parses raw console line texts into {@link AitCaptureLine}s.
1312
- *
1313
- * Filtering rules (each independently drops a line — never throws):
1314
- * - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);
1315
- * - there must be a non-empty category token (up to the next space);
1316
- * - the remaining payload must be valid JSON (`JSON.parse` succeeds).
1317
- *
1318
- * Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are
1319
- * silently discarded — capture harvesting is best-effort and must never fail a
1320
- * run or leak a malformed/secret-bearing line.
1321
- *
1322
- * @param raw - Console line objects (only `.text` is read).
1323
- * @returns The captured lines, in input order.
1324
- */
1325
- function parseCaptureLines(raw) {
1326
- const out = [];
1327
- for (const { text } of raw) {
1328
- if (!text.startsWith(CAPTURE_PREFIX)) continue;
1329
- const body = text.slice(16);
1330
- const spaceIdx = body.indexOf(" ");
1331
- if (spaceIdx === -1) continue;
1332
- const category = body.slice(0, spaceIdx);
1333
- const json = body.slice(spaceIdx + 1);
1334
- if (category === "" || json === "") continue;
1335
- try {
1336
- JSON.parse(json);
1337
- } catch {
1338
- continue;
1339
- }
1340
- out.push({
1341
- category,
1342
- json
1343
- });
1344
- }
1345
- return out;
1346
- }
1347
- //#endregion
1348
- //#region src/test-runner/rpc.ts
1349
- /** Maximum milliseconds to wait for a single evaluate round-trip. */
1350
- const DEFAULT_TIMEOUT_MS = 6e4;
1351
- /**
1352
- * Extra headroom (ms) added on top of the rpc-level `timeoutMs` when telling
1353
- * the underlying `CdpConnection` its per-command watchdog budget (devtools#747).
1354
- * The rpc-level race above is the authoritative file timeout — this margin
1355
- * only ensures the connection's own (shorter-by-default) watchdog never fires
1356
- * first and masks the rpc-level timeout error with a connection-level one.
1357
- */
1358
- const CONNECTION_TIMEOUT_MARGIN_MS = 5e3;
1359
- /**
1360
- * Wraps bundle code in a self-executing IIFE that:
1361
- * 1. Evaluates the bundle (registering describe/it/test).
1362
- * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
1363
- * 3. Returns a JSON-serialised `RunReport` string.
1364
- *
1365
- * The double-serialisation (RunReport → JSON string → returnByValue string)
1366
- * is intentional: CDP `returnByValue` reliably transports strings; deeply
1367
- * nested objects can lose fidelity across the Chii relay.
1368
- *
1369
- * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
1370
- */
1371
- function buildRunTestsExpression(bundleCode) {
1372
- return `(async () => { try { ${bundleCode} } catch(e) { return JSON.stringify({ok:false,error:'bundle-eval: ' + String(e && e.message || e)}); } if (typeof globalThis.__testBundle !== 'object' || typeof globalThis.__testBundle.runTestModule !== 'function' || typeof globalThis.__testBundle.__userFactory !== 'function') { return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule or __userFactory is not a function'}); } try { const report = await globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory); return JSON.stringify({ok:true,value:report}); } catch(e) { return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)}); }})()`;
1373
- }
1374
- /**
1375
- * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
1376
- * evaluate call into a typed `RpcRunResult`.
1377
- *
1378
- * Throws only on parse failure — an `ok:false` envelope is a normal result.
1379
- *
1380
- * SECRET-HANDLING: `rawValue` is not included in error messages.
1381
- */
1382
- function parseRunTestsResult(rawValue) {
1383
- if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
1384
- let parsed;
1385
- try {
1386
- parsed = JSON.parse(rawValue);
1387
- } catch {
1388
- throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
1389
- }
1390
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
1391
- const obj = parsed;
1392
- if (obj.ok === true) return {
1393
- ok: true,
1394
- report: obj.value
1395
- };
1396
- if (obj.ok === false) return {
1397
- ok: false,
1398
- error: typeof obj.error === "string" ? obj.error : String(obj.error)
1399
- };
1400
- throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
1401
- }
1402
- /**
1403
- * Injects `bundleCode` into the attached page and awaits test execution.
1404
- *
1405
- * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
1406
- * async IIFE to settle. `timeoutMs` (default 60s) is the authoritative file
1407
- * timeout — this function races it against the evaluate call AND (devtools#747)
1408
- * threads it down to the underlying `CdpConnection`'s own per-command watchdog
1409
- * (plus a small margin) so that watchdog can never undercut this race. Split
1410
- * into smaller files if you hit the timeout.
1411
- *
1412
- * @param connection - Active CDP connection (relay or local).
1413
- * @param bundleCode - IIFE bundle string from `bundleTestFile`.
1414
- * @param timeoutMs - Override the default 60 s timeout.
1415
- *
1416
- * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
1417
- */
1418
- async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
1419
- const expression = buildRunTestsExpression(bundleCode);
1420
- const TIMEOUT_SENTINEL = Symbol("timeout");
1421
- const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs));
1422
- const evalPromise = connection.send("Runtime.evaluate", {
1423
- expression,
1424
- returnByValue: true,
1425
- awaitPromise: true
1426
- }, { timeoutMs: timeoutMs + CONNECTION_TIMEOUT_MARGIN_MS });
1427
- const raceResult = await Promise.race([evalPromise.then((v) => ({
1428
- tag: "eval",
1429
- v
1430
- })), timeoutPromise.then(() => ({ tag: "timeout" }))]);
1431
- if (raceResult.tag === "timeout") return {
1432
- ok: false,
1433
- error: `rpc: evaluate timed out after ${timeoutMs}ms`
1434
- };
1435
- const cdpResult = raceResult.v;
1436
- if (cdpResult.exceptionDetails) {
1437
- const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
1438
- throw new Error(`rpc.injectAndRunBundle: ${msg}`);
1439
- }
1440
- return parseRunTestsResult(cdpResult.result.value);
1441
- }
1442
- //#endregion
1443
- //#region src/test-runner/relay-worker.ts
1444
- /**
1445
- * Per-file evaluate timeout (ms) for manual-variant files (devtools#741) — a
1446
- * human is expected to be tapping through a native sheet, so the timeout must
1447
- * be far longer than the unattended default (60s, rpc.ts DEFAULT_TIMEOUT_MS).
1448
- * Fixed constant (not configurable in v1) — documented here + CLI `--help`.
1449
- */
1450
- const MANUAL_FILE_TIMEOUT_MS = 5 * 6e4;
1451
- /**
1452
- * Sentinel string embedded in the error message by `injectAndRunBundle` when
1453
- * the per-file evaluate race hits the timeout. Used by the retry guard so only
1454
- * genuine timeouts get a second attempt — not bundle errors or parse failures.
1455
- *
1456
- * Exported for unit tests that assert the retry path is taken.
1457
- */
1458
- const EVALUATE_TIMEOUT_MARKER = "rpc: evaluate timed out after";
1459
- /**
1460
- * Runs all `files` sequentially over the given CDP `connection`.
1461
- *
1462
- * For each file:
1463
- * 1. Bundle with esbuild (includes SDK shim + runtime).
1464
- * 2. Inject into the attached page via `Runtime.evaluate`.
1465
- * 3. Await the `RunReport` JSON response.
1466
- * 4. Accumulate results.
1467
- *
1468
- * Returns a `RelayRunReport` with per-file results and flattened totals.
1469
- *
1470
- * This function does NOT open or manage the relay connection — the caller
1471
- * is responsible for attaching and closing it.
1472
- *
1473
- * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
1474
- * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
1475
- *
1476
- * @param connection - Active CDP connection (relay or local kind).
1477
- * @param files - Absolute paths to test files, run in order.
1478
- * @param opts - Optional per-run overrides.
1479
- */
1480
- async function runTestFilesOverRelay(connection, files, opts) {
1481
- const wallStart = Date.now();
1482
- const startedAt = new Date(wallStart).toISOString();
1483
- const fileResults = [];
1484
- let domainsEnabled = false;
1485
- try {
1486
- await connection.enableDomains();
1487
- domainsEnabled = true;
1488
- } catch (e) {
1489
- process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
1490
- }
1491
- const collectCaptures = opts?.collectCaptures === true;
1492
- const liveConsole = [];
1493
- let unsubscribeConsole;
1494
- if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
1495
- liveConsole.push(event);
1496
- });
1497
- let pendingReconnectCheck = false;
1498
- /**
1499
- * Attempts one `enableDomains()` reconnect. Logs the attempt and outcome to
1500
- * stderr. Never throws — a failed reconnect just means the caller proceeds
1501
- * as today (each remaining file / the retry will fail-fast on the still-dead
1502
- * socket).
1503
- *
1504
- * `context` selects the fixed log literal: 'next-file' for the between-files
1505
- * case (a file's FINAL result was a WS-dead-class error — the primary #731
1506
- * scenario), 'retry-precheck' for the defensive reconnect before retrying a
1507
- * timed-out file (cheap no-op via `enableDomains()`'s idempotency when the
1508
- * socket never actually died).
1509
- *
1510
- * SECRET-HANDLING: fixed literals only — no relay wss URL, TOTP code, or
1511
- * tunnel host.
1512
- */
1513
- const attemptReconnect = async (context) => {
1514
- process.stderr.write(context === "next-file" ? "relay-worker: relay connection lost — attempting reconnect before next file\n" : "relay-worker: evaluate timed out — attempting reconnect before retry\n");
1515
- try {
1516
- await connection.enableDomains();
1517
- process.stderr.write("relay-worker: reconnect succeeded\n");
1518
- } catch (e) {
1519
- process.stderr.write(`relay-worker: reconnect failed (${e instanceof Error ? e.message : String(e)}) — continuing, remaining files may fail\n`);
1520
- }
1521
- };
1522
- const manualFiles = opts?.manualFiles;
1523
- const manualTotal = manualFiles?.size ?? 0;
1524
- let manualIndex = 0;
1525
- const stubBlockingFiles = opts?.stubBlockingFiles;
1526
- let preflightPermissions;
1527
- if (files.length > 0) preflightPermissions = await runPermissionPreflight(connection, PERMISSION_PREFLIGHT_TIMEOUT_MS, opts?.preflightSdkLine !== "3.x");
1528
- const paceMs = opts?.paceMs;
1529
- let ranFirstFile = false;
1530
- try {
1531
- for (const file of files) {
1532
- if (pendingReconnectCheck) {
1533
- await attemptReconnect("next-file");
1534
- pendingReconnectCheck = false;
1535
- }
1536
- if (paceMs !== void 0 && paceMs > 0 && ranFirstFile) await new Promise((resolve) => setTimeout(resolve, paceMs));
1537
- ranFirstFile = true;
1538
- const isStubbed = stubBlockingFiles?.has(file) === true;
1539
- const isManual = !isStubbed && manualFiles?.has(file) === true;
1540
- if (isManual) {
1541
- manualIndex += 1;
1542
- opts?.onManualFile?.(file, manualIndex, manualTotal);
1543
- }
1544
- const fileTimeoutMs = isManual ? MANUAL_FILE_TIMEOUT_MS : opts?.timeoutMs;
1545
- let fileEntry;
1546
- try {
1547
- const { code } = await bundleTestFile(file, opts?.bundleOptions);
1548
- /**
1549
- * Runs one evaluate attempt and returns a FileResult, or `null` when the
1550
- * result is a genuine timeout and the caller should retry.
1551
- *
1552
- * We need to distinguish:
1553
- * - `rpcResult.ok = false` + timeout error → retry candidate (`return null`)
1554
- * - `rpcResult.ok = false` + other error → final error, no retry
1555
- * - `injectAndRunBundle` throws → CDP exceptionDetails OR a
1556
- * relay-disconnect rejection from `connection.send()` (page engine
1557
- * threw, or the ws died mid-evaluate); treated as a final
1558
- * (non-retryable) error either way.
1559
- *
1560
- * The Promise.race timeout in rpc.ts RETURNS `{ok:false, error: '…'}` (it
1561
- * does NOT throw/reject). Only genuine CDP `exceptionDetails` (or a dead
1562
- * `connection.send()`) cause a throw. This distinction is what makes the
1563
- * EVALUATE_TIMEOUT_MARKER gate below reachable — the timeout result
1564
- * surfaces as `rpcResult.ok=false` with the marker string, not as a
1565
- * caught exception.
1566
- */
1567
- const attempt = async () => {
1568
- let rpcResult;
1569
- try {
1570
- rpcResult = await injectAndRunBundle(connection, code, fileTimeoutMs);
1571
- } catch (e) {
1572
- return {
1573
- file,
1574
- result: { error: e instanceof Error ? e.message : String(e) }
1575
- };
1576
- }
1577
- if (rpcResult.ok) return {
1578
- file,
1579
- result: rpcResult.report
1580
- };
1581
- if (rpcResult.error.includes("rpc: evaluate timed out after")) return null;
1582
- return {
1583
- file,
1584
- result: { error: rpcResult.error }
1585
- };
1586
- };
1587
- const firstResult = await attempt();
1588
- if (firstResult !== null) fileEntry = firstResult;
1589
- else {
1590
- await attemptReconnect("retry-precheck");
1591
- process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\n`);
1592
- const retryResult = await attempt();
1593
- if (retryResult !== null) fileEntry = retryResult;
1594
- else fileEntry = {
1595
- file,
1596
- result: { error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 6e4}ms (after retry) — the device's app may have been backgrounded` }
1597
- };
1598
- }
1599
- } catch (e) {
1600
- fileEntry = {
1601
- file,
1602
- result: { error: e instanceof Error ? e.message : String(e) }
1603
- };
1604
- }
1605
- if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
1606
- if (isStubbed) fileEntry.mode = "stubbed";
1607
- else if (isManual) fileEntry.mode = "manual";
1608
- fileResults.push(fileEntry);
1609
- }
1610
- } finally {
1611
- unsubscribeConsole?.();
1612
- }
1613
- const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
1614
- const totals = fileResults.reduce((acc, { result }) => {
1615
- if ("error" in result) {
1616
- acc.failed += 1;
1617
- acc.total += 1;
1618
- } else {
1619
- acc.passed += result.passed;
1620
- acc.failed += result.failed;
1621
- acc.skipped += result.skipped;
1622
- acc.total += result.passed + result.failed + result.skipped;
1623
- }
1624
- return acc;
1625
- }, {
1626
- passed: 0,
1627
- failed: 0,
1628
- skipped: 0,
1629
- total: 0
1630
- });
1631
- return {
1632
- startedAt,
1633
- duration: Date.now() - wallStart,
1634
- files: fileResults,
1635
- totals,
1636
- captures,
1637
- ...preflightPermissions ? { preflight: { permissions: preflightPermissions } } : {}
1638
- };
1639
- }
1640
- /**
1641
- * Renders one `Runtime.consoleAPICalled` event to a single line of text, the
1642
- * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
1643
- * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
1644
- * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
1645
- * onto the test-runner entry.
1646
- *
1647
- * SECRET-HANDLING: this only stringifies console args; the caller's
1648
- * allowlist-prefix parser then discards everything that is not a genuine
1649
- * `__AIT_CAPTURE__` line.
1650
- */
1651
- function renderConsoleLineText(event) {
1652
- return event.args.map((arg) => {
1653
- if (arg.value !== void 0) {
1654
- if (typeof arg.value === "string") return arg.value;
1655
- try {
1656
- return JSON.stringify(arg.value);
1657
- } catch {
1658
- return String(arg.value);
1659
- }
1660
- }
1661
- if (arg.description !== void 0) return arg.description;
1662
- if (arg.className !== void 0) return arg.className;
1663
- return arg.subtype ?? arg.type;
1664
- }).join(" ");
1665
- }
1666
- //#endregion
1667
- //#region src/test-runner/report.ts
1668
- /**
1669
- * Runner-agnostic report serialisation for env3 test runs (devtools#696).
1670
- *
1671
- * Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the
1672
- * standalone `devtools-test` CLI (`cli.ts`) — call the same core
1673
- * `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This
1674
- * module is the single, runner-neutral place that turns that in-memory report
1675
- * into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed
1676
- * cell-by-cell after the fact.
1677
- *
1678
- * The serialised schema is deliberately MINIMAL and secret-free:
1679
- *
1680
- * - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`
1681
- * leakage — see {@link RunnerAgnosticReport.files});
1682
- * - the cell metadata (sdkLine/platform) is baked INTO the body, not only the
1683
- * filename, so a moved artifact never loses its provenance;
1684
- * - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all
1685
- * (enforced by the type + this comment) — error strings are the matcher
1686
- * message only, inherited from rpc.ts which already strips expression/value.
1687
- *
1688
- * react-free — depends only on the type-level `RelayRunReport` and `node:fs` /
1689
- * `node:path`. Safe to bundle without pulling the chii/cloudflared graph.
1690
- */
1691
- /**
1692
- * Converts an absolute (or already-relative) file path to a projectRoot-relative
1693
- * one. `path.relative` returns `''` when the paths are equal — guard that to the
1694
- * basename so the field is never empty.
1695
- *
1696
- * SECRET-HANDLING: this is the single choke point that strips absolute project
1697
- * paths from the artifact.
1698
- */
1699
- function relativise(projectRoot, file) {
1700
- const rel = path.relative(projectRoot, file);
1701
- if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return path.basename(file);
1702
- return rel;
1703
- }
1704
- /**
1705
- * Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free
1706
- * on-disk shape. Pure — no IO; testable with a plain report + meta.
1707
- *
1708
- * @param report - The core relay run report.
1709
- * @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).
1710
- */
1711
- function serializeRelayReport(report, meta) {
1712
- return {
1713
- cell: {
1714
- sdkLine: meta.sdkLine,
1715
- platform: meta.platform
1716
- },
1717
- startedAt: report.startedAt,
1718
- duration: report.duration,
1719
- totals: report.totals,
1720
- ...report.preflight ? { preflight: report.preflight } : {},
1721
- files: report.files.map((f) => {
1722
- const file = relativise(meta.projectRoot, f.file);
1723
- const modeField = f.mode === "manual" || f.mode === "stubbed" ? { mode: f.mode } : {};
1724
- if ("error" in f.result) return {
1725
- file,
1726
- error: f.result.error,
1727
- ...modeField
1728
- };
1729
- return {
1730
- file,
1731
- duration: f.result.duration,
1732
- passed: f.result.passed,
1733
- failed: f.result.failed,
1734
- skipped: f.result.skipped,
1735
- tests: f.result.tests,
1736
- ...modeField
1737
- };
1738
- })
1739
- };
1740
- }
1741
- /**
1742
- * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating
1743
- * `dir` if needed. Returns the absolute path(s) written.
1744
- *
1745
- * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as
1746
- * distinct artifacts in the same directory; the same cell metadata is also baked
1747
- * into the body so a renamed/moved file still carries its provenance.
1748
- *
1749
- * Manual-run provenance (devtools#741): when `report.files` contains ANY
1750
- * `mode: 'manual'` entry (i.e. this run included `--manual-blocking` files),
1751
- * the manual-tagged files are written to a SEPARATE
1752
- * `<dir>/<sdkLine>.<platform>.manual.json` artifact instead of the standard
1753
- * one — the standard `<sdkLine>.<platform>.json` filename is reserved for the
1754
- * regular (unattended) files only, so a manual run's presence never mutates
1755
- * what the unattended-baseline filename means.
1756
- *
1757
- * Stub-blocking provenance (devtools#740, DT-2): `mode: 'stubbed'` files are
1758
- * written to a THIRD, separate `<dir>/<sdkLine>.<platform>.stubbed.json`
1759
- * artifact — never merged into the standard file (it is unattended, like the
1760
- * standard file, so a naive filename split could conflate the two) and never
1761
- * merged into `.manual.json` (it did NOT have a human present). Its body is
1762
- * ALSO stamped `cell.bridgeStub: true` — the mandatory HYBRID-cell provenance
1763
- * from the issue: a stubbed result must never be silently mixed into the
1764
- * real-device baseline report.
1765
- *
1766
- * If ALL files in the run are regular, only the standard artifact is written
1767
- * (today's behavior, unchanged). Any combination of the three modes present
1768
- * in a single run writes ONLY the corresponding artifacts — each is additive,
1769
- * never replacing another.
1770
- *
1771
- * SECRET-HANDLING: the written body contains no relay/secret fields (the schema
1772
- * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
1773
- *
1774
- * @param report - The core relay run report.
1775
- * @param dir - Output directory (created recursively if missing).
1776
- * @param meta - Cell axes + projectRoot.
1777
- * @returns The absolute path(s) written, in order: standard first (if any
1778
- * regular files ran), then manual (if any manual files ran), then stubbed
1779
- * (if any stubbed files ran). At least one path is always returned when
1780
- * `report.files` is non-empty.
1781
- */
1782
- async function writeReportArtifact(report, dir, meta) {
1783
- const serialised = serializeRelayReport(report, meta);
1784
- await mkdir(dir, { recursive: true });
1785
- const regularFiles = serialised.files.filter((f) => f.mode === void 0);
1786
- const manualFiles = serialised.files.filter((f) => f.mode === "manual");
1787
- const stubbedFiles = serialised.files.filter((f) => f.mode === "stubbed");
1788
- const written = [];
1789
- if (regularFiles.length > 0 || serialised.files.length === 0) {
1790
- const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
1791
- await writeFile(outFile, `${JSON.stringify({
1792
- ...serialised,
1793
- files: regularFiles
1794
- }, null, 2)}\n`, "utf8");
1795
- written.push(outFile);
1796
- }
1797
- if (manualFiles.length > 0) {
1798
- const manualOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.manual.json`);
1799
- await writeFile(manualOutFile, `${JSON.stringify({
1800
- ...serialised,
1801
- files: manualFiles
1802
- }, null, 2)}\n`, "utf8");
1803
- written.push(manualOutFile);
1804
- }
1805
- if (stubbedFiles.length > 0) {
1806
- const stubbedOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.stubbed.json`);
1807
- await writeFile(stubbedOutFile, `${JSON.stringify({
1808
- ...serialised,
1809
- cell: {
1810
- ...serialised.cell,
1811
- bridgeStub: true
1812
- },
1813
- files: stubbedFiles
1814
- }, null, 2)}\n`, "utf8");
1815
- written.push(stubbedOutFile);
1816
- }
1817
- return written;
1818
- }
1819
- /**
1820
- * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,
1821
- * named `<category>.<sdkLine>.<platform>.json` — the SAME convention
1822
- * sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3
1823
- * capture artifacts line up for diffing.
1824
- *
1825
- * Each line's `json` payload is an opaque JSON array of capture records. Lines
1826
- * sharing a category are concatenated into one array, in harvest order.
1827
- *
1828
- * SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser
1829
- * dropped wss/scheme noise); the `json` payload is written verbatim but is a
1830
- * capture record array, not a relay/secret.
1831
- *
1832
- * @param captures - Parsed capture lines (from `RelayRunReport.captures`).
1833
- * @param dir - Output directory (created recursively if missing).
1834
- * @param cell - Cell axes for the filename suffix.
1835
- * @returns The absolute paths written (one per category), in category order.
1836
- */
1837
- async function writeCaptureArtifacts(captures, dir, cell) {
1838
- if (captures.length === 0) return [];
1839
- const byCategory = /* @__PURE__ */ new Map();
1840
- for (const { category, json } of captures) {
1841
- let merged = byCategory.get(category);
1842
- if (!merged) {
1843
- merged = [];
1844
- byCategory.set(category, merged);
1845
- }
1846
- const parsed = JSON.parse(json);
1847
- if (Array.isArray(parsed)) merged.push(...parsed);
1848
- else merged.push(parsed);
1849
- }
1850
- await mkdir(dir, { recursive: true });
1851
- const written = [];
1852
- for (const [category, records] of byCategory) {
1853
- const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);
1854
- await writeFile(outFile, `${JSON.stringify(records, null, 2)}\n`, "utf8");
1855
- written.push(outFile);
1856
- }
1857
- return written;
1858
- }
1859
- //#endregion
1860
- //#region src/test-runner/teardown.ts
1861
- /**
1862
- * Runs `steps` in strict order, each bounded by `perStepTimeoutMs` so a
1863
- * single hung `close()` cannot block the rest — a later step (e.g. "close
1864
- * the QR HTTP server") still runs even if an earlier one (e.g. "flip the
1865
- * on-phone badge over CDP") times out.
1866
- *
1867
- * Order is load-bearing: `relay-factory.ts`'s existing `close()` already
1868
- * encodes "badge over still-open channel, THEN close that channel" — this
1869
- * function does not reorder or parallelize, it just bounds+reports whatever
1870
- * sequence the caller provides.
1871
- */
1872
- async function runTeardownSteps(steps, options = {}) {
1873
- const { perStepTimeoutMs = 5e3, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout } = options;
1874
- const results = [];
1875
- for (const step of steps) results.push(await runOneStep(step, perStepTimeoutMs, setTimeoutFn, clearTimeoutFn));
1876
- return results;
1877
- }
1878
- async function runOneStep(step, perStepTimeoutMs, setTimeoutFn, clearTimeoutFn) {
1879
- let timeoutHandle;
1880
- const timeoutPromise = new Promise((resolve) => {
1881
- timeoutHandle = setTimeoutFn(() => resolve("timeout"), perStepTimeoutMs);
1882
- });
1883
- try {
1884
- const outcome = await Promise.race([Promise.resolve().then(() => step.close()).then(() => "ok"), timeoutPromise]);
1885
- if (timeoutHandle !== void 0) clearTimeoutFn(timeoutHandle);
1886
- return {
1887
- name: step.name,
1888
- status: outcome
1889
- };
1890
- } catch (e) {
1891
- if (timeoutHandle !== void 0) clearTimeoutFn(timeoutHandle);
1892
- return {
1893
- name: step.name,
1894
- status: "error",
1895
- error: e instanceof Error ? e.message : String(e)
1896
- };
1897
- }
1898
- }
1899
- /**
1900
- * Arms a grace-period timer that force-exits the process if it has not
1901
- * already exited on its own by the time the timer fires. The timer is
1902
- * deliberately NOT `.unref()`'d — its entire purpose is to hold the event
1903
- * loop open long enough to fire if nothing else does, so unref'ing it would
1904
- * defeat the backstop. `disarm()` clears it — the CLI calls `disarm()`
1905
- * immediately once `main()`'s own teardown (Step 6, cli.ts) has finished,
1906
- * so on the happy path the timer never fires (Node drains the loop and
1907
- * exits naturally at whatever `process.exitCode` was already set to).
1908
- *
1909
- * SECRET-HANDLING: this function never touches stdout/stderr content — the
1910
- * caller (cli.ts) is responsible for flushing any final output before the
1911
- * backstop's `exitFn` runs.
1912
- */
1913
- function armExitBackstop(options) {
1914
- const { graceMs = 3e3, exitCode, setTimeoutFn = setTimeout, exitFn = (code) => process.exit(code) } = options;
1915
- let fired = false;
1916
- const handle = setTimeoutFn(() => {
1917
- fired = true;
1918
- exitFn(exitCode);
1919
- }, graceMs);
1920
- return {
1921
- disarm() {
1922
- clearTimeout(handle);
1923
- },
1924
- get fired() {
1925
- return fired;
1926
- }
1927
- };
1928
- }
1929
- //#endregion
1930
- //#region src/test-runner/cli.ts
1931
- /**
1932
- * `devtools-test` CLI.
1933
- *
1934
- * Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)
1935
- * and exposes `runWithConnection` — the pure run core that bundles, injects, and
1936
- * collects each file over a CDP connection. The CLI's `main()` performs a
1937
- * standalone relay attach (boot relay → QR → phone scan → cell inject → run).
1938
- *
1939
- * NOTE: no shebang in this source file — the tsdown entry's `banner` option
1940
- * injects `#!/usr/bin/env node` into the compiled output (same pattern as
1941
- * `src/mcp/cli.ts`).
1942
- */
1943
- const USAGE = `
1944
- devtools-test — run mini-app tests on a real device WebView over the CDP relay
1945
-
1946
- USAGE
1947
- devtools-test <glob> [<glob> ...] [options]
1948
-
1949
- OPTIONS
1950
- --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
1951
- (required for standalone relay attach / env3). Unused
1952
- and ignored when --attach-launcher is set.
1953
- --attach-launcher Attach over the env-2 AITC Sandbox PWA launcher
1954
- (real-device WebKit) instead of the env-3 intoss
1955
- scheme deep-link. The relay/QR/dashboard/attach-wait
1956
- are identical; only the QR is a launcher deep-link.
1957
- Requires --app-url. The SDK still hits the mock in
1958
- env 2, so this measures the mock + standard Web API
1959
- layer's engine-attributable behavior (env1↔env2
1960
- equivalence), NOT native-bridge fidelity (that is env3).
1961
- --app-url <url> The consumer dev server's HTTP tunnel URL (e.g. the
1962
- *.trycloudflare.com URL from \`pnpm dev:phone:cdp\`)
1963
- that the launcher PWA frames. REQUIRED with
1964
- --attach-launcher; ignored otherwise. SECRET: a tunnel
1965
- host — never printed; it rides only inside the QR.
1966
- --timeout <ms> Per-file evaluate timeout in ms (default: 60000).
1967
- Controls how long a single test file is allowed to run
1968
- before it is considered hung. Does NOT affect how long
1969
- the CLI waits for a human to scan the QR code — use
1970
- --attach-timeout for that.
1971
- --attach-timeout <ms> How long to wait for a human to scan the QR code with
1972
- their phone. Omit (default) to wait indefinitely — the
1973
- runner stays up until you stop it (Ctrl-C/SIGTERM).
1974
- Pass a value to bound the wait for CI/headless runs.
1975
- --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
1976
- --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
1977
- (mock|ios|android|ios-pwa, default: AIT_CELL_PLATFORM
1978
- env). Use ios-pwa for env-2 (--attach-launcher) runs
1979
- so env1(mock@desktop) and env2(mock@WebKit) captures
1980
- stay distinguishable. An unknown value is rejected.
1981
- --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
1982
- (report: <sdkLine>.<platform>.json; captures:
1983
- <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
1984
- Omitted = nothing saved. Enables console capture.
1985
- --dashboard-port <port> Base port for the QR dashboard HTTP server. On
1986
- EADDRINUSE it increments (+1, up to 20 tries) before
1987
- falling back to an ephemeral port. Omit to use
1988
- AIT_DEBUG_HTTP_PORT env or the built-in default
1989
- (8317) — pass 0 to force a random ephemeral port.
1990
- --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
1991
- non-interactive stdout / CI / AIT_NO_QR_STDOUT)
1992
- --headless Disable browser auto-open (text QR only)
1993
- --project-root <dir> Project root for .ait_relay secret lookup
1994
- (default: current working directory)
1995
- --pace <ms> Minimum delay in ms between test-to-test AND
1996
- file-to-file bridge calls (default: 0, i.e. no
1997
- added delay — today's behavior byte-for-byte).
1998
- Falls back to the AIT_PACE env var when omitted
1999
- (--pace takes precedence over the env var when both
2000
- are given). Use on a 2.x cell scan when the native
2001
- per-method bridge rate limit (APP_BRIDGE_THROTTLED,
2002
- devtools#767) is rejecting rapid same-method calls —
2003
- 3.x cells are unaffected by that limiter and do not
2004
- need this flag.
2005
- --pace-method <ms> Minimum delay in ms BETWEEN calls to the SAME named
2006
- SDK function — paces a same-method burst WITHIN a
2007
- single test body (e.g. a clipboard happy-path loop
2008
- calling setClipboardText/getClipboardText 8 times
2009
- back to back), which --pace's test/file spacing
2010
- cannot reach (devtools#769). Falls back to the
2011
- AIT_PACE_METHOD env var when omitted (--pace-method
2012
- takes precedence over the env var when both are
2013
- given). Default: 250ms when --cell-sdk-line is 2.x
2014
- (unset --cell-sdk-line also defaults to 2.x — see
2015
- --cell-sdk-line), 0 (no added delay) otherwise. Pass
2016
- --pace-method 0 to opt out even on a 2.x cell.
2017
- --manual-blocking Run manual-tagged test files (*.manual.ait.test.ts)
2018
- LAST, after all regular files, with a human present.
2019
- Before each manual file, the QR dashboard is pushed
2020
- a step-by-step Korean prompt naming the file + its
2021
- progress (k/n), and the same line is printed to
2022
- stdout. Manual files get a 5-minute per-file evaluate
2023
- timeout (vs. --timeout for everything else) since a
2024
- human is expected to tap through a native sheet
2025
- (photo picker, permission dialog, fullscreen ad).
2026
- Without this flag (default off), *.manual.ait.test.ts
2027
- files are EXCLUDED from the glob expansion entirely —
2028
- existing unattended runs are byte-for-byte unaffected.
2029
- With --report-dir, a run that included manual files
2030
- ALSO writes <sdkLine>.<platform>.manual.json
2031
- alongside (never replacing) the standard report, and
2032
- each manual file's report entry is stamped
2033
- mode: 'manual' — never diff a manual run against an
2034
- unattended baseline as if they were equivalent.
2035
- --stub-blocking Run manual-tagged test files (*.manual.ait.test.ts)
2036
- UNATTENDED (devtools#740, DT-2) by intercepting a
2037
- fixed allowlist of blocking-UI SDK calls (ads
2038
- show*, openPermissionDialog/requestPermission,
2039
- saveBase64Data) in the page and answering them from
2040
- fixtures captured by a real --manual-blocking run,
2041
- instead of forwarding them to native UI. Implies
2042
- --manual-blocking (manual files are included in the
2043
- run); no human presence or QR-dashboard prompt is
2044
- needed for them. HYBRID cell, not pure env3 — every
2045
- other SDK call in the same run still hits the real
2046
- native bridge. With --report-dir, files that ran
2047
- under the stub are written to a SEPARATE
2048
- <sdkLine>.<platform>.stubbed.json artifact (never
2049
- merged into the standard or .manual.json report) and
2050
- the report body is stamped cell.bridgeStub: true —
2051
- never diff a stubbed run against a real device
2052
- baseline (manual or unattended) as if equivalent.
2053
- --help, -h Show this help message
2054
-
2055
- DESCRIPTION
2056
- Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
2057
- device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
2058
- each matched test file with esbuild (SDK imports redirected to window.__sdk),
2059
- injects the bundle into the attached WebView via Runtime.evaluate, and prints
2060
- a summary.
2061
-
2062
- With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
2063
- runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
2064
- compared offline.
2065
-
2066
- The test files run against the live relay connection started by this process;
2067
- no separate MCP daemon is required.
2068
-
2069
- EXAMPLE (env 3 — intoss-private scheme)
2070
- devtools-test 'src/**/*.ait.test.ts' \\
2071
- --scheme-url "intoss-private://..." \\
2072
- --cell-sdk-line 3.x \\
2073
- --cell-platform ios \\
2074
- --report-dir .ait-report \\
2075
- --timeout 60000
2076
-
2077
- EXAMPLE (env 2 — AITC Sandbox PWA launcher)
2078
- devtools-test 'src/**/*.ait.test.ts' \\
2079
- --attach-launcher \\
2080
- --app-url "https://<subdomain>.trycloudflare.com" \\
2081
- --cell-sdk-line 3.x \\
2082
- --cell-platform ios-pwa \\
2083
- --report-dir .ait-report
2084
-
2085
- `.trimStart();
2086
- /**
2087
- * Grace period (ms) for the exit backstop armed around Step 6 teardown
2088
- * (devtools#755). See `teardown.ts`'s module doc for the root-cause writeup
2089
- * — the two upstream `http.Server#close()` hangs are fixed at the source, so
2090
- * this backstop is a last-mile safety net that should never fire in
2091
- * practice. Exported so tests can assert the backstop is armed with the
2092
- * expected value without hardcoding the literal twice.
2093
- */
2094
- const EXIT_BACKSTOP_GRACE_MS = 3e3;
2095
- /**
2096
- * Parses --timeout and --attach-timeout raw string values into the two
2097
- * distinct clocks.
2098
- *
2099
- * Returns an error string on invalid input, or the resolved timeouts on
2100
- * success. The caller (main) writes the error to stderr and exits 1.
2101
- *
2102
- * Exported for unit testing — main() is the only other caller.
2103
- */
2104
- function resolveTimeouts(rawTimeout, rawAttachTimeout) {
2105
- const evaluateTimeoutMs = rawTimeout !== void 0 ? parseInt(rawTimeout, 10) : 6e4;
2106
- if (Number.isNaN(evaluateTimeoutMs) || evaluateTimeoutMs <= 0) return "--timeout must be a positive integer";
2107
- const attachTimeoutMs = rawAttachTimeout !== void 0 ? parseInt(rawAttachTimeout, 10) : void 0;
2108
- if (attachTimeoutMs !== void 0 && (Number.isNaN(attachTimeoutMs) || attachTimeoutMs <= 0)) return "--attach-timeout must be a positive integer";
2109
- return {
2110
- evaluateTimeoutMs,
2111
- attachTimeoutMs
2112
- };
2113
- }
2114
- /**
2115
- * Parses the `--dashboard-port` raw string value into a validated port
2116
- * number, or `undefined` when the flag was omitted (letting relay-factory /
2117
- * qr-http-server resolve their own default — env then the built-in fixed
2118
- * default, devtools#752).
2119
- *
2120
- * `0` is a valid, meaningful value (explicit opt-out to pure ephemeral) and
2121
- * is passed through as-is — it must NOT be confused with "omitted".
2122
- *
2123
- * Returns an error string on invalid input (non-integer, negative, or
2124
- * >65535), or the resolved port on success. Exported for unit testing.
2125
- */
2126
- function resolveDashboardPort(raw) {
2127
- if (raw === void 0) return void 0;
2128
- const port = parseInt(raw, 10);
2129
- if (Number.isNaN(port) || port < 0 || port > 65535) return "--dashboard-port must be an integer between 0 and 65535";
2130
- return port;
2131
- }
2132
- /**
2133
- * Rewrites a lone `--pace <value>` or `--pace-method <value>` pair from space
2134
- * syntax to `=` syntax (`--pace=<value>` / `--pace-method=<value>`) when
2135
- * `<value>` starts with `-` (devtools#768 review; extended to `--pace-method`
2136
- * in devtools#769).
2137
- *
2138
- * Node's `util.parseArgs` treats a `type: 'string'` option's value as
2139
- * "ambiguous" and throws its OWN parser error whenever that value starts with
2140
- * a dash and was passed via the space form (`--pace -1`) — it never reaches
2141
- * this module's `resolvePace`/`resolvePaceMethod`, so the friendly
2142
- * `'--pace must be a non-negative integer'` message (and its unit-tested path)
2143
- * was unreachable from the real CLI entry point for the single most natural
2144
- * way a user would try a negative value, even though every other flag in
2145
- * {@link USAGE} is documented in space syntax.
2146
- *
2147
- * Scoped narrowly to `--pace`/`--pace=...` and `--pace-method`/
2148
- * `--pace-method=...` tokens only — every other flag is untouched, and
2149
- * positionals/other options keep flowing through `parseArgs` unchanged. Only
2150
- * rewrites the space form; `--pace=-1`/`--pace-method=-1` already parse fine
2151
- * and are passed through untouched.
2152
- *
2153
- * Exported for unit testing.
2154
- */
2155
- function normalizePaceArgv(argv) {
2156
- const out = [];
2157
- for (let i = 0; i < argv.length; i++) {
2158
- const arg = argv[i];
2159
- const next = argv[i + 1];
2160
- if ((arg === "--pace" || arg === "--pace-method") && next !== void 0 && next.startsWith("-") && next !== "--") {
2161
- out.push(`${arg}=${next}`);
2162
- i++;
2163
- continue;
2164
- }
2165
- out.push(arg);
2166
- }
2167
- return out;
2168
- }
2169
- /**
2170
- * Parses `--pace` (falling back to the `AIT_PACE` env var when the flag is
2171
- * omitted) into a validated millisecond delay (devtools#767).
2172
- *
2173
- * Opt-in, zero-diff-when-absent: BOTH omitted resolves to `0` — no pacing —
2174
- * which is byte-for-byte today's behavior (`runtime.ts` treats `0`/absent
2175
- * `__AIT_PACE_MS__` identically, and `relay-worker.ts`'s file-to-file gap is
2176
- * skipped entirely when the resolved value is 0). `--pace` takes precedence
2177
- * over `AIT_PACE` when both are present, mirroring `--cell-platform` /
2178
- * `AIT_CELL_PLATFORM`'s existing flag-over-env precedent.
2179
- *
2180
- * Returns an error string on invalid input (non-integer or negative), or the
2181
- * resolved delay (`>= 0`) on success. Exported for unit testing.
2182
- */
2183
- function resolvePace(rawFlag, rawEnv) {
2184
- const raw = rawFlag ?? rawEnv;
2185
- if (raw === void 0) return 0;
2186
- const ms = parseInt(raw, 10);
2187
- if (Number.isNaN(ms) || ms < 0) return "--pace must be a non-negative integer";
2188
- return ms;
2189
- }
2190
- /**
2191
- * Parses `--pace-method` (falling back to the `AIT_PACE_METHOD` env var when
2192
- * the flag is omitted) into a validated millisecond per-method minimum
2193
- * interval (devtools#769).
2194
- *
2195
- * Precedence, highest first: `--pace-method` flag > `AIT_PACE_METHOD` env >
2196
- * sdkLine-aware default (`{@link DEFAULT_PACE_METHOD_MS_2X}` for a 2.x cell,
2197
- * `0` otherwise) — an EXPLICIT flag or env value always wins, including
2198
- * `--pace-method 0` to opt out on a 2.x cell. Mirrors `resolvePace`'s
2199
- * flag-over-env precedent, with the sdkLine default spliced in only when
2200
- * BOTH are absent.
2201
- *
2202
- * Returns an error string on invalid input (non-integer or negative), or the
2203
- * resolved delay (`>= 0`) on success. Exported for unit testing.
2204
- *
2205
- * @param rawFlag - The raw `--pace-method` string value, if passed.
2206
- * @param rawEnv - The raw `AIT_PACE_METHOD` env value, if set.
2207
- * @param resolvedCellSdkLine - The effective `cell.sdkLine` value (already
2208
- * defaulted to '2.x' by the caller when `--cell-sdk-line` was omitted — see
2209
- * `main()`'s `cell` construction) — used ONLY to pick the sdkLine-aware
2210
- * default when both `rawFlag` and `rawEnv` are absent.
2211
- */
2212
- function resolvePaceMethod(rawFlag, rawEnv, resolvedCellSdkLine) {
2213
- const raw = rawFlag ?? rawEnv;
2214
- if (raw === void 0) return resolvedCellSdkLine === "2.x" ? 250 : 0;
2215
- const ms = parseInt(raw, 10);
2216
- if (Number.isNaN(ms) || ms < 0) return "--pace-method must be a non-negative integer";
2217
- return ms;
2218
- }
2219
- /**
2220
- * The set of `__AIT_CELL__.platform` values the runner accepts (devtools#774).
2221
- *
2222
- * `mock`/`ios`/`android` mirror sdk-example `aitCapture`'s existing `Platform`
2223
- * union; `ios-pwa` is the new env-2 axis value (issue #774) — env 1 is
2224
- * mock@desktop-Chromium and env 2 is mock@real-device-WebKit, so the two share
2225
- * `sdkLine`/`ios` but must be DISTINGUISHABLE in the capture cell to diff
2226
- * engine-attributable differences. `ios-pwa` names "mock SDK, real-device iOS
2227
- * WebKit via the launcher PWA" — it is NOT `ios` (which is env 3's native
2228
- * bridge). The consuming `aitCapture` type extension is a separate sdk-example
2229
- * PR (out of scope here); the runner only needs to inject + validate the label.
2230
- *
2231
- * Kept as a validated allowlist (rather than a free string) purely as a
2232
- * typo guard: `platform` flows into report/capture FILENAMES
2233
- * (`<sdkLine>.<platform>.json`), so a silent typo would fork the artifact set
2234
- * and break offline diffing. This is the first validation on `--cell-platform`
2235
- * — before #774 it was an unvalidated free string defaulting to `mock`.
2236
- */
2237
- const ALLOWED_CELL_PLATFORMS = [
2238
- "mock",
2239
- "ios",
2240
- "android",
2241
- "ios-pwa"
2242
- ];
2243
- /**
2244
- * Validates a resolved `--cell-platform` value against {@link ALLOWED_CELL_PLATFORMS}.
2245
- *
2246
- * `undefined` (flag + `AIT_CELL_PLATFORM` env both absent) passes through as
2247
- * `{ ok: true, value: undefined }` — the caller applies its own `'mock'`
2248
- * default, so an omitted platform is never an error. A present-but-unknown
2249
- * value returns `{ ok: false, error }` naming the allowed set.
2250
- *
2251
- * Uses a tagged result (not the `string`-is-error convention of
2252
- * `resolveDashboardPort`/`resolvePace`) because BOTH the success value and the
2253
- * error message are strings here — a bare `string` return would be ambiguous.
2254
- * Exported for unit testing.
2255
- */
2256
- function resolveCellPlatform(raw) {
2257
- if (raw === void 0) return {
2258
- ok: true,
2259
- value: void 0
2260
- };
2261
- if (ALLOWED_CELL_PLATFORMS.includes(raw)) return {
2262
- ok: true,
2263
- value: raw
2264
- };
2265
- return {
2266
- ok: false,
2267
- error: `--cell-platform must be one of ${ALLOWED_CELL_PLATFORMS.join("|")} (got "${raw}")`
2268
- };
2269
- }
2270
- /**
2271
- * Renders per-file result lines and the aggregate totals line to a string.
2272
- *
2273
- * Each file gets one line:
2274
- * - Error/timeout: `FAIL <basename>: <error-class>`
2275
- * - Pass (0 tests): `OK <basename>: 0 passed (empty file)`
2276
- * - Pass: `OK <basename>: N passed[, M failed][, K skipped]`
2277
- *
2278
- * The aggregate totals line always follows.
2279
- *
2280
- * SECRET-HANDLING: only `basename(file)` is used — no absolute paths, relay
2281
- * URLs, wss URLs, scheme URLs, or TOTP codes appear in the output. The error
2282
- * string comes from `result.error` which is already secret-free (relay-worker
2283
- * produces only error-class messages like "rpc: evaluate timed out after
2284
- * 30000ms").
2285
- *
2286
- * Exported so unit tests can assert the per-file lines without spawning a
2287
- * subprocess or going through the full relay attach flow.
2288
- */
2289
- function renderSummary(report) {
2290
- const lines = [];
2291
- for (const { file, result } of report.files) {
2292
- const name = basename(file);
2293
- if ("error" in result) lines.push(`FAIL ${name}: ${result.error}`);
2294
- else {
2295
- const parts = [`${result.passed} passed`];
2296
- if (result.failed > 0) parts.push(`${result.failed} failed`);
2297
- if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
2298
- const suffix = result.passed + result.failed + result.skipped === 0 ? " (empty file)" : "";
2299
- lines.push(`OK ${name}: ${parts.join(", ")}${suffix}`);
2300
- }
2301
- }
2302
- const { totals, duration } = report;
2303
- lines.push(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${duration}ms)`);
2304
- return lines.join("\n");
2305
- }
2306
- /**
2307
- * Runs `files` over `connection` and returns the aggregate report.
2308
- * This pure function is the testable core of the CLI (and is what the
2309
- * `run_tests` MCP tool calls against the daemon's attached connection); it is
2310
- * separate from `main()` so tests can call it without spawning a subprocess.
2311
- */
2312
- async function runWithConnection(connection, files, opts) {
2313
- const report = await runTestFilesOverRelay(connection, files, opts);
2314
- if (opts?.printSummary) process.stdout.write(`\n${renderSummary(report)}\n`);
2315
- return report;
2316
- }
2317
- /**
2318
- * Decides whether to suppress the QR/attach block on stdout.
2319
- *
2320
- * Suppress when EITHER the user passed `--no-qr-stdout`, OR stdout is not a TTY
2321
- * / `CI` is set / `AIT_NO_QR_STDOUT` is set (non-interactive — a captured stdout
2322
- * must not leak the relay wss + TOTP `at=` code that the QR block encodes). The
2323
- * suppression is whole-chunk: `attachUrl` AND `relayUrl` ride in the same block.
2324
- *
2325
- * Exported for unit testing.
2326
- */
2327
- function shouldSuppressQr(noQrFlag) {
2328
- return noQrFlag || !process.stdout.isTTY || process.env.CI !== void 0 || process.env.AIT_NO_QR_STDOUT !== void 0;
2329
- }
2330
- /**
2331
- * CLI entry point.
2332
- *
2333
- * Performs a standalone relay attach → run lifecycle, sharing the attach
2334
- * assembly with the Vitest pool via `createRelayConnectionFactory` (single
2335
- * source — no drift):
2336
- *
2337
- * 1. Parse args: globs, --timeout (per-file evaluate), --attach-timeout (QR
2338
- * scan wait), --cell-sdk-line, --cell-platform, --scheme-url (required for
2339
- * env3) OR --attach-launcher + --app-url (env2, devtools#774), --report-dir,
2340
- * --dashboard-port, --no-qr-stdout, --headless, --project-root.
2341
- * 2. Discover test files; exit 1 if none.
2342
- * 3. factory.open() — boot relay → render QR (suppressed on non-interactive
2343
- * stdout) → wait for phone (up to attachTimeoutMs) → inject cell →
2344
- * enableDomains. Returns the conn.
2345
- * 4. runWithConnection(conn, files, { evaluateTimeoutMs, collectCaptures,
2346
- * printSummary }).
2347
- * 5. With --report-dir: write the runner-agnostic report + capture files.
2348
- * 6. factory.close(); process.exitCode = failed > 0 ? 1 : 0.
2349
- *
2350
- * The CLI is not a daemon — no lock, router, SSE, or tools_list is needed.
2351
- * Attach timeout exits with code 1; test failures exit with code 1.
2352
- *
2353
- * SECRET-HANDLING: scheme_url / relay wssUrl / TOTP codes are never written to
2354
- * stdout/stderr directly. The QR block (which encodes the TOTP `at=` code) is
2355
- * printed only when stdout is interactive AND not suppressed.
2356
- */
2357
- async function main(argv = process.argv.slice(2)) {
2358
- let parsed;
2359
- try {
2360
- parsed = parseArgs({
2361
- args: normalizePaceArgv(argv),
2362
- options: {
2363
- help: {
2364
- type: "boolean",
2365
- short: "h"
2366
- },
2367
- timeout: { type: "string" },
2368
- "attach-timeout": { type: "string" },
2369
- "scheme-url": { type: "string" },
2370
- "attach-launcher": { type: "boolean" },
2371
- "app-url": { type: "string" },
2372
- "cell-sdk-line": { type: "string" },
2373
- "cell-platform": { type: "string" },
2374
- "report-dir": { type: "string" },
2375
- "dashboard-port": { type: "string" },
2376
- "no-qr-stdout": { type: "boolean" },
2377
- headless: { type: "boolean" },
2378
- "project-root": { type: "string" },
2379
- pace: { type: "string" },
2380
- "pace-method": { type: "string" },
2381
- "manual-blocking": { type: "boolean" },
2382
- "stub-blocking": { type: "boolean" }
2383
- },
2384
- allowPositionals: true
2385
- });
2386
- } catch (e) {
2387
- process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
2388
- process.exitCode = 1;
2389
- return;
2390
- }
2391
- if (parsed.values.help || argv.length === 0) {
2392
- process.stdout.write(USAGE);
2393
- return;
2394
- }
2395
- const vals = parsed.values;
2396
- const timeouts = resolveTimeouts(typeof vals.timeout === "string" ? vals.timeout : void 0, typeof vals["attach-timeout"] === "string" ? vals["attach-timeout"] : void 0);
2397
- if (typeof timeouts === "string") {
2398
- process.stderr.write(`devtools-test: ${timeouts}\n`);
2399
- process.exitCode = 1;
2400
- return;
2401
- }
2402
- const { evaluateTimeoutMs, attachTimeoutMs } = timeouts;
2403
- const attachLauncher = vals["attach-launcher"] === true;
2404
- const schemeUrl = typeof vals["scheme-url"] === "string" ? vals["scheme-url"] : "";
2405
- const appUrl = typeof vals["app-url"] === "string" ? vals["app-url"] : "";
2406
- if (attachLauncher) {
2407
- if (appUrl === "") {
2408
- process.stderr.write("devtools-test: --app-url is required with --attach-launcher (env 2).\n Pass the consumer dev server's tunnel URL (e.g. the HTTP *.trycloudflare.com\n URL printed by `pnpm dev:phone:cdp`). --scheme-url is not used in this mode.\n");
2409
- process.exitCode = 1;
2410
- return;
2411
- }
2412
- } else if (schemeUrl === "") {
2413
- process.stderr.write("devtools-test: --scheme-url is required for standalone relay attach.\n Pass the intoss-private:// URL from `ait deploy --scheme-only`.\n (For env 2 / AITC Sandbox PWA, use --attach-launcher --app-url <tunnel-url> instead.)\n");
2414
- process.exitCode = 1;
2415
- return;
2416
- }
2417
- const headless = vals.headless === true;
2418
- const projectRoot = typeof vals["project-root"] === "string" ? vals["project-root"] : process.cwd();
2419
- const reportDir = typeof vals["report-dir"] === "string" ? vals["report-dir"] : void 0;
2420
- const dashboardPort = resolveDashboardPort(typeof vals["dashboard-port"] === "string" ? vals["dashboard-port"] : void 0);
2421
- if (typeof dashboardPort === "string") {
2422
- process.stderr.write(`devtools-test: ${dashboardPort}\n`);
2423
- process.exitCode = 1;
2424
- return;
2425
- }
2426
- const paceMs = resolvePace(typeof vals.pace === "string" ? vals.pace : void 0, process.env.AIT_PACE);
2427
- if (typeof paceMs === "string") {
2428
- process.stderr.write(`devtools-test: ${paceMs}\n`);
2429
- process.exitCode = 1;
2430
- return;
2431
- }
2432
- const suppressQr = shouldSuppressQr(vals["no-qr-stdout"] === true);
2433
- const stubBlocking = vals["stub-blocking"] === true;
2434
- const manualBlocking = vals["manual-blocking"] === true || stubBlocking;
2435
- const cellSdkLine = typeof vals["cell-sdk-line"] === "string" ? vals["cell-sdk-line"] : void 0;
2436
- const cellPlatformResult = resolveCellPlatform(typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM);
2437
- if (!cellPlatformResult.ok) {
2438
- process.stderr.write(`devtools-test: ${cellPlatformResult.error}\n`);
2439
- process.exitCode = 1;
2440
- return;
2441
- }
2442
- const cellPlatform = cellPlatformResult.value;
2443
- const hasCell = cellSdkLine !== void 0 || cellPlatform !== void 0;
2444
- const cell = {
2445
- sdkLine: cellSdkLine ?? "2.x",
2446
- platform: cellPlatform ?? "mock"
2447
- };
2448
- const paceMethodMs = resolvePaceMethod(typeof vals["pace-method"] === "string" ? vals["pace-method"] : void 0, process.env.AIT_PACE_METHOD, cell.sdkLine);
2449
- if (typeof paceMethodMs === "string") {
2450
- process.stderr.write(`devtools-test: ${paceMethodMs}\n`);
2451
- process.exitCode = 1;
2452
- return;
2453
- }
2454
- const globs = parsed.positionals;
2455
- if (globs.length === 0) {
2456
- process.stderr.write(`devtools-test: at least one glob pattern is required\n`);
2457
- process.stdout.write(USAGE);
2458
- process.exitCode = 1;
2459
- return;
2460
- }
2461
- const discovered = await discoverTestFiles(globs, process.cwd(), { includeManual: manualBlocking });
2462
- if (discovered.length === 0) {
2463
- process.stderr.write(`devtools-test: no test files matched ${globs.join(", ")}\n`);
2464
- process.exitCode = 1;
2465
- return;
2466
- }
2467
- const { regular, manual } = partitionManualTests(discovered);
2468
- const files = manualBlocking ? [...regular, ...manual] : regular;
2469
- const manualFileSet = new Set(manual);
2470
- 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`);
2471
- if (hasCell) process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
2472
- if (stubBlocking) process.stderr.write("devtools-test: --stub-blocking enabled — blocking-UI calls in manual-tagged files will be answered from fixtures (devtools#740), not forwarded to native UI\n");
2473
- if (paceMs > 0) process.stderr.write(`devtools-test: --pace ${paceMs}ms enabled (devtools#767)\n`);
2474
- if (paceMethodMs > 0) process.stderr.write(`devtools-test: --pace-method ${paceMethodMs}ms enabled (devtools#769)\n`);
2475
- const factory = createRelayConnectionFactory({
2476
- schemeUrl,
2477
- ...attachLauncher ? {
2478
- attachLauncher: true,
2479
- appUrl
2480
- } : {},
2481
- projectRoot,
2482
- ...attachTimeoutMs !== void 0 ? { timeoutMs: attachTimeoutMs } : {},
2483
- ...dashboardPort !== void 0 ? { dashboardPort } : {},
2484
- headless,
2485
- cell: hasCell ? cell : void 0,
2486
- stubBlocking,
2487
- paceMs,
2488
- paceMethodMs,
2489
- onQrContent: (chunks) => {
2490
- if (suppressQr) {
2491
- process.stdout.write("QR suppressed (non-interactive)\n");
2492
- return;
2493
- }
2494
- for (const chunk of chunks) process.stdout.write(`${chunk}\n`);
2495
- }
2496
- });
2497
- let connection;
2498
- try {
2499
- connection = await factory.open();
2500
- } catch (e) {
2501
- process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
2502
- process.exitCode = 1;
2503
- return;
2504
- }
2505
- let exitCode = 0;
2506
- try {
2507
- factory.onSessionPhase?.("running");
2508
- const report = await runWithConnection(connection, files, {
2509
- timeoutMs: evaluateTimeoutMs,
2510
- printSummary: true,
2511
- collectCaptures: reportDir !== void 0,
2512
- paceMs,
2513
- preflightSdkLine: cell.sdkLine,
2514
- manualFiles: manualBlocking && manualFileSet.size > 0 ? manualFileSet : void 0,
2515
- stubBlockingFiles: stubBlocking && manualFileSet.size > 0 ? manualFileSet : void 0,
2516
- onManualFile: (file, index, total) => {
2517
- const name = basename(file);
2518
- process.stdout.write(`수동 단계: ${name} — 폰에서 네이티브 시트가 뜨면 안내에 따라 조작하세요 (${index}/${total})\n`);
2519
- factory.onManualPrompt?.({
2520
- file: name,
2521
- index,
2522
- total
2523
- });
2524
- }
2525
- });
2526
- if (manualBlocking && manualFileSet.size > 0) factory.onManualPrompt?.(null);
2527
- if (reportDir !== void 0) try {
2528
- const reportPaths = await writeReportArtifact(report, reportDir, {
2529
- sdkLine: cell.sdkLine,
2530
- platform: cell.platform,
2531
- projectRoot
2532
- });
2533
- for (const reportPath of reportPaths) process.stderr.write(`devtools-test: wrote report ${reportPath}\n`);
2534
- const capturePaths = await writeCaptureArtifacts(report.captures, `${reportDir}/.ait-capture`, cell);
2535
- if (capturePaths.length > 0) process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\n`);
2536
- } catch (e) {
2537
- process.stderr.write(`devtools-test: failed to write report artifacts: ${e instanceof Error ? e.message : String(e)}\n`);
2538
- }
2539
- exitCode = report.totals.failed > 0 ? 1 : 0;
2540
- } finally {
2541
- factory.onSessionPhase?.("complete");
2542
- const backstop = armExitBackstop({
2543
- graceMs: EXIT_BACKSTOP_GRACE_MS,
2544
- exitCode
2545
- });
2546
- await runTeardownSteps([{
2547
- name: "factory.close",
2548
- close: () => factory.close(connection)
2549
- }]);
2550
- backstop.disarm();
2551
- process.exitCode = exitCode;
2552
- }
2553
- }
2554
- //#endregion
2555
- //#region src/test-runner/bin.ts
2556
- /**
2557
- * `devtools-test` bin entry point — export-free by design.
2558
- *
2559
- * This file is intentionally export-free so that Rolldown (tsdown) emits the
2560
- * main() call directly into the output bundle rather than hoisting the module
2561
- * body into a shared chunk and replacing this file with a re-export wrapper.
2562
- *
2563
- * Background (#711): cli.ts exports `main`, `runWithConnection`, and
2564
- * `shouldSuppressQr` for use by tests and relay-factory. When cli.ts was also
2565
- * the bin entry, Rolldown split the module body into a shared chunk
2566
- * (`cli-<hash>.js`) and reduced the bin output to a 3-line re-export wrapper.
2567
- * The self-invoke guard (`import.meta.url === process.argv[1]`) evaluated
2568
- * inside the shared chunk where `import.meta.url` is the chunk path, not the
2569
- * bin path — a structural permanent mismatch — so main() was never called and
2570
- * every `devtools-test` / `pnpm test:env3` invocation exited 0 as a silent
2571
- * no-op.
2572
- *
2573
- * NOTE: no shebang in this source file — the tsdown entry's `banner` option
2574
- * injects `#!/usr/bin/env node` into the compiled output (same pattern as
2575
- * other Node bin entries in tsdown.config.ts).
2576
- */
2577
- main().catch((e) => {
2578
- process.stderr.write(`devtools-test: unexpected error: ${e instanceof Error ? e.message : String(e)}\n`);
2579
- process.exitCode = 1;
2580
- });
2581
- //#endregion
2582
- export { RELAY_AUTH_REJECT_CLOSE_CODE as n, RELAY_AUTH_REJECT_REASON as r, ChiiCdpConnection as t };
2583
-
2584
- //# sourceMappingURL=bin.js.map