@ait-co/devtools 0.1.117 → 0.1.119

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 (103) hide show
  1. package/dist/attach-orchestrator-CFotueL3.js +1749 -0
  2. package/dist/attach-orchestrator-CFotueL3.js.map +1 -0
  3. package/dist/attach-orchestrator-CII9Gl5m.js +2797 -0
  4. package/dist/attach-orchestrator-CII9Gl5m.js.map +1 -0
  5. package/dist/attach-orchestrator-C_6xb_L5.js +3 -0
  6. package/dist/attach-orchestrator-DEJfLm7V.js +1749 -0
  7. package/dist/attach-orchestrator-DEJfLm7V.js.map +1 -0
  8. package/dist/{cli-CvUNQKqw.js → attach-orchestrator-qFEMBUwb.js} +3 -805
  9. package/dist/attach-orchestrator-qFEMBUwb.js.map +1 -0
  10. package/dist/{bundle-KFs4t-wc.d.ts → bundle-CA1TCXED.d.ts} +1 -1
  11. package/dist/bundle-CA1TCXED.d.ts.map +1 -0
  12. package/dist/capture-BmK_cBqQ.d.ts +58 -0
  13. package/dist/capture-BmK_cBqQ.d.ts.map +1 -0
  14. package/dist/{cdp-connection-C0AP0tH2.d.ts → cdp-connection-DB2zgthr.d.ts} +1 -1
  15. package/dist/{cdp-connection-C0AP0tH2.d.ts.map → cdp-connection-DB2zgthr.d.ts.map} +1 -1
  16. package/dist/cell-BJiaGlVu.js +3 -0
  17. package/dist/cell-BPWZUm_X.js +85 -0
  18. package/dist/cell-BPWZUm_X.js.map +1 -0
  19. package/dist/cell-Cv3e_qwn.js +84 -0
  20. package/dist/cell-Cv3e_qwn.js.map +1 -0
  21. package/dist/cell-bLwQ4gTq.js +85 -0
  22. package/dist/cell-bLwQ4gTq.js.map +1 -0
  23. package/dist/cell-nrWFeqvG.js +84 -0
  24. package/dist/cell-nrWFeqvG.js.map +1 -0
  25. package/dist/cli-RhzASQef.js +1057 -0
  26. package/dist/cli-RhzASQef.js.map +1 -0
  27. package/dist/debug-server-BhHVy3XT.js +1191 -0
  28. package/dist/debug-server-BhHVy3XT.js.map +1 -0
  29. package/dist/{debug-server-jrI9U_f4.js → debug-server-Bi6jLuY6.js} +467 -2989
  30. package/dist/debug-server-Bi6jLuY6.js.map +1 -0
  31. package/dist/debug-server-Buc8yoa7.js +1909 -0
  32. package/dist/debug-server-Buc8yoa7.js.map +1 -0
  33. package/dist/{debug-server-BvrESnaV.js → debug-server-C_5ag9jj.js} +5 -3
  34. package/dist/debug-server-C_5ag9jj.js.map +1 -0
  35. package/dist/mcp/cli.js +3 -2
  36. package/dist/mcp/cli.js.map +1 -1
  37. package/dist/mcp/server.js +1 -1
  38. package/dist/panel/index.js +1 -1
  39. package/dist/{pool-htlVnEFl.d.ts → pool-U9FrxEuR.d.ts} +5 -5
  40. package/dist/{pool-htlVnEFl.d.ts.map → pool-U9FrxEuR.d.ts.map} +1 -1
  41. package/dist/relay-factory-DVI7TrX4.js +69 -0
  42. package/dist/relay-factory-DVI7TrX4.js.map +1 -0
  43. package/dist/relay-secret-store-BcVrWwTq.js +153 -0
  44. package/dist/relay-secret-store-BcVrWwTq.js.map +1 -0
  45. package/dist/{relay-secret-store-BHcOmaNK.js → relay-secret-store-BlFEhqwb.js} +1 -1
  46. package/dist/{relay-secret-store-CmqchhR5.js → relay-secret-store-CFc9n0OA.js} +1 -1
  47. package/dist/{relay-secret-store-CmqchhR5.js.map → relay-secret-store-CFc9n0OA.js.map} +1 -1
  48. package/dist/{relay-secret-store-CkA7KNUb.js → relay-secret-store-CmDDfZMh.js} +2 -2
  49. package/dist/{relay-secret-store-CkA7KNUb.js.map → relay-secret-store-CmDDfZMh.js.map} +1 -1
  50. package/dist/relay-secret-store-DNPJKHNs.js +153 -0
  51. package/dist/relay-secret-store-DNPJKHNs.js.map +1 -0
  52. package/dist/relay-url-store-CdA58fgw.js +122 -0
  53. package/dist/relay-url-store-CdA58fgw.js.map +1 -0
  54. package/dist/{relay-url-store-C0qukm3R.js → relay-url-store-CnL2zwbH.js} +2 -2
  55. package/dist/{relay-url-store-C0qukm3R.js.map → relay-url-store-CnL2zwbH.js.map} +1 -1
  56. package/dist/{relay-url-store-NDtEcOE-.js → relay-url-store-DGQ-HPQC.js} +2 -2
  57. package/dist/{relay-url-store-NDtEcOE-.js.map → relay-url-store-DGQ-HPQC.js.map} +1 -1
  58. package/dist/relay-url-store-Dv5HSzwb.js +122 -0
  59. package/dist/relay-url-store-Dv5HSzwb.js.map +1 -0
  60. package/dist/{relay-worker-DWW4vZxU.d.ts → relay-worker-D-3aAyDO.d.ts} +28 -4
  61. package/dist/relay-worker-D-3aAyDO.d.ts.map +1 -0
  62. package/dist/{runtime-BU-iMHz6.d.ts → runtime-D57Rtnsd.d.ts} +1 -1
  63. package/dist/{runtime-BU-iMHz6.d.ts.map → runtime-D57Rtnsd.d.ts.map} +1 -1
  64. package/dist/test-runner/bundle.d.ts +1 -1
  65. package/dist/test-runner/bundle.js +45 -27
  66. package/dist/test-runner/bundle.js.map +1 -1
  67. package/dist/test-runner/capture.d.ts +2 -0
  68. package/dist/test-runner/capture.js +44 -0
  69. package/dist/test-runner/capture.js.map +1 -0
  70. package/dist/test-runner/cli.d.ts +85 -12
  71. package/dist/test-runner/cli.d.ts.map +1 -1
  72. package/dist/test-runner/cli.js +2 -2
  73. package/dist/test-runner/config.d.ts +76 -2
  74. package/dist/test-runner/config.d.ts.map +1 -1
  75. package/dist/test-runner/config.js +4 -2
  76. package/dist/test-runner/config.js.map +1 -1
  77. package/dist/test-runner/pool.d.ts +1 -1
  78. package/dist/test-runner/relay-factory.d.ts +11136 -0
  79. package/dist/test-runner/relay-factory.d.ts.map +1 -0
  80. package/dist/test-runner/relay-factory.js +69 -0
  81. package/dist/test-runner/relay-factory.js.map +1 -0
  82. package/dist/test-runner/relay-worker.d.ts +1 -1
  83. package/dist/test-runner/relay-worker.js +66 -20
  84. package/dist/test-runner/relay-worker.js.map +1 -1
  85. package/dist/test-runner/report.d.ts +106 -0
  86. package/dist/test-runner/report.d.ts.map +1 -0
  87. package/dist/test-runner/report.js +139 -0
  88. package/dist/test-runner/report.js.map +1 -0
  89. package/dist/test-runner/rpc.d.ts +2 -2
  90. package/dist/test-runner/runtime.d.ts +1 -1
  91. package/dist/test-runner/task-graph.d.ts +1 -1
  92. package/dist/{totp-D70zD5tJ.js → totp-0bvlo9Yb.js} +1 -1
  93. package/dist/{totp-D70zD5tJ.js.map → totp-0bvlo9Yb.js.map} +1 -1
  94. package/dist/totp-DfekTBk3.js +211 -0
  95. package/dist/totp-DfekTBk3.js.map +1 -0
  96. package/dist/totp-zGIlDsZS.js +3 -0
  97. package/package.json +1 -1
  98. package/dist/bundle-KFs4t-wc.d.ts.map +0 -1
  99. package/dist/cli-CvUNQKqw.js.map +0 -1
  100. package/dist/debug-server-BvrESnaV.js.map +0 -1
  101. package/dist/debug-server-jrI9U_f4.js.map +0 -1
  102. package/dist/relay-worker-DWW4vZxU.d.ts.map +0 -1
  103. package/dist/totp-D104cJQN.js +0 -3
@@ -0,0 +1,1057 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import * as fs from "node:fs/promises";
4
+ import { glob, mkdir, writeFile } from "node:fs/promises";
5
+ import * as path$1 from "node:path";
6
+ import path, { isAbsolute, resolve } from "node:path";
7
+ import { accessSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ //#region src/test-runner/discover.ts
10
+ /**
11
+ * Test-file discovery shared by the `devtools-test` CLI and the `run_tests`
12
+ * MCP tool, so both expand glob patterns with identical semantics.
13
+ *
14
+ * Uses Node's built-in `fs/promises` `glob` (Node 22+) — no extra dependency,
15
+ * which keeps the MCP daemon install graph lean (a plain glob lib would land in
16
+ * the `npx … devtools-mcp` path for no benefit).
17
+ *
18
+ * Pure Node IO only (`node:fs/promises` + `node:path`) — react-free, so it is
19
+ * safe to import from the MCP daemon graph.
20
+ */
21
+ /**
22
+ * Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of
23
+ * ABSOLUTE test file paths, resolved relative to `cwd`.
24
+ *
25
+ * A plain (non-glob) path passes through when it matches a real file; a glob
26
+ * expands against `cwd`. Absolute matches are kept as-is; relative matches are
27
+ * resolved against `cwd`. `bundleTestFile` requires an absolute path, so the
28
+ * absolute output feeds it directly.
29
+ *
30
+ * @param patterns Glob patterns or file paths (e.g. `['src/**\/*.ait.test.ts']`).
31
+ * @param cwd Base directory for relative patterns/results.
32
+ * @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.
33
+ */
34
+ async function discoverTestFiles(patterns, cwd) {
35
+ const out = /* @__PURE__ */ new Set();
36
+ for await (const match of glob(patterns, { cwd })) out.add(isAbsolute(match) ? match : resolve(cwd, match));
37
+ return [...out].sort();
38
+ }
39
+ //#endregion
40
+ //#region src/test-runner/relay-factory.ts
41
+ /**
42
+ * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay
43
+ * connection.
44
+ *
45
+ * `open()` performs the full attach lifecycle and BLOCKS for tens of seconds (up
46
+ * to `timeoutMs`) while a human scans the rendered QR with their phone — there
47
+ * is no way around the manual scan for env3. It resolves with the live
48
+ * `CdpConnection` once a matching page attaches; `close()` tears the relay
49
+ * family down.
50
+ *
51
+ * The factory holds the booted relay family in a closure so `close()` can stop
52
+ * it. A second `open()` on the same factory boots a fresh family (the previous
53
+ * one should have been `close()`d first).
54
+ */
55
+ function createRelayConnectionFactory(opts) {
56
+ const projectRoot = opts.projectRoot ?? process.cwd();
57
+ const timeoutMs = opts.timeoutMs ?? 6e5;
58
+ const headless = opts.headless === true;
59
+ let family;
60
+ return {
61
+ async open() {
62
+ const { prepareAttach, renderAndMaybeWait } = await import("./attach-orchestrator-qFEMBUwb.js");
63
+ const { injectDebugIndicator, injectGlobals } = await import("./cell-BPWZUm_X.js");
64
+ const { loadRelaySecretReadOnly } = await import("./relay-secret-store-CFc9n0OA.js");
65
+ const { bootRelayFamily, buildRelayVerifyAuth } = await import("./debug-server-C_5ag9jj.js");
66
+ await loadRelaySecretReadOnly({ projectRoot });
67
+ const booted = await bootRelayFamily({ verifyAuth: buildRelayVerifyAuth() });
68
+ family = booted;
69
+ const attachDeps = {
70
+ getTunnelStatus: booted.getTunnelStatus ?? (() => ({
71
+ up: false,
72
+ wssUrl: null
73
+ })),
74
+ getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
75
+ qrHttpServer: void 0,
76
+ onAttachUrlBuilt: void 0,
77
+ canOpenBrowser: () => !headless
78
+ };
79
+ const prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
80
+ if (!prep.ok) {
81
+ booted.stop();
82
+ family = void 0;
83
+ throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
84
+ }
85
+ const waitResult = await renderAndMaybeWait(attachDeps, prep, true, timeoutMs, booted.connection);
86
+ if (waitResult.isError) {
87
+ booted.stop();
88
+ family = void 0;
89
+ const timeoutSec = Math.round(timeoutMs / 1e3);
90
+ throw new Error(`createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`);
91
+ }
92
+ const qrChunks = waitResult.content.filter((c) => c.type === "text").map((c) => c.text);
93
+ opts.onQrContent(qrChunks);
94
+ await injectDebugIndicator(booted.connection);
95
+ if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
96
+ await booted.connection.enableDomains();
97
+ return booted.connection;
98
+ },
99
+ async close(_connection) {
100
+ family?.stop();
101
+ family = void 0;
102
+ }
103
+ };
104
+ }
105
+ //#endregion
106
+ //#region src/test-runner/bundle.ts
107
+ /**
108
+ * esbuild-based bundler for user test files.
109
+ *
110
+ * Bundles a single test file into a self-contained IIFE string that can be
111
+ * injected into a WebView via `Runtime.evaluate`. The bundle includes the
112
+ * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
113
+ * the `runTestModule(factory)` entry point.
114
+ *
115
+ * ## How the wiring works
116
+ *
117
+ * The bundle exposes two exports on `globalThis.__testBundle`:
118
+ * - `runTestModule` — the runtime's entry function.
119
+ * - `__userFactory` — an async function whose body is the user's top-level
120
+ * test registration code (describe/it/test calls).
121
+ *
122
+ * The Node-side RPC (`rpc.ts`) calls:
123
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
124
+ *
125
+ * `runTestModule` then installs `describe/it/test/expect` as globals, invokes
126
+ * the factory (which registers all tests), runs them, and returns a `RunReport`.
127
+ *
128
+ * ## Why a factory wrapper is needed
129
+ *
130
+ * Naively adding the runtime to `entryPoints` and bundling the user file would
131
+ * fail for two reasons:
132
+ * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
133
+ * scope. The user's top-level `describe(...)` calls expect them as globals —
134
+ * they are not globals until `runTestModule` installs them.
135
+ * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
136
+ * time, before the RPC layer calls `runTestModule` to reset state and start
137
+ * the test clock.
138
+ *
139
+ * The factory approach solves both: the user's registration code is deferred
140
+ * into a function that `runTestModule` calls AFTER installing the globals.
141
+ *
142
+ * ## Factory extraction algorithm
143
+ *
144
+ * The `userFactoryPlugin` reads the user file and splits lines into:
145
+ * - **top-level**: `import …` and re-export lines — kept at module scope
146
+ * (the only valid position for static `import` in ESM).
147
+ * - **body**: all other statements — moved into the body of the exported
148
+ * `__userFactory` async function.
149
+ *
150
+ * esbuild processes the re-generated module, following each static import
151
+ * through the normal dependency graph (including the SDK-redirect plugin).
152
+ *
153
+ * ## SDK redirect
154
+ *
155
+ * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
156
+ * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
157
+ * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
158
+ *
159
+ * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
160
+ */
161
+ /** The SDK package name that mini-app test code imports from. */
162
+ const SDK_PACKAGE = "@apps-in-toss/web-framework";
163
+ /**
164
+ * Names the runtime installs as globals before invoking the user factory.
165
+ * The `vitest` virtual module re-exports each as a lazy getter that reads from
166
+ * `globalThis` at access time. Keep in sync with the globals installed in
167
+ * `runtime.ts#runTestModule`.
168
+ */
169
+ const VITEST_GLOBAL_NAMES = [
170
+ "describe",
171
+ "it",
172
+ "test",
173
+ "expect",
174
+ "beforeAll",
175
+ "afterAll",
176
+ "beforeEach",
177
+ "afterEach",
178
+ "vi"
179
+ ];
180
+ /**
181
+ * Matches the bare SDK package and any sub-path import
182
+ * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
183
+ * Built from {@link SDK_PACKAGE} so the package name has a single source.
184
+ */
185
+ const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
186
+ /**
187
+ * esbuild plugin that intercepts SDK imports and redirects them to the
188
+ * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
189
+ *
190
+ * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
191
+ * esbuild resolves it to a virtual module that re-exports all named exports
192
+ * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
193
+ * be available in the test environment) while still making named imports work.
194
+ *
195
+ * If `window.__sdk` is absent (non-dog-food build), every access throws a
196
+ * descriptive error rather than returning `undefined` silently.
197
+ */
198
+ function sdkRedirectPlugin() {
199
+ return {
200
+ name: "sdk-redirect",
201
+ setup(build) {
202
+ build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
203
+ path: args.path,
204
+ namespace: "sdk-redirect"
205
+ }));
206
+ build.onLoad({
207
+ filter: /.*/,
208
+ namespace: "sdk-redirect"
209
+ }, () => ({
210
+ contents: `
211
+ var __proxy = (typeof window !== 'undefined' && window.__sdk)
212
+ ? window.__sdk
213
+ : new Proxy({}, {
214
+ get: function(_t, p) {
215
+ throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
216
+ }
217
+ });
218
+ module.exports = __proxy;
219
+ `,
220
+ loader: "js"
221
+ }));
222
+ }
223
+ };
224
+ }
225
+ /**
226
+ * esbuild plugin that intercepts `import … from 'vitest'` and replaces it with
227
+ * a virtual module that delegates every named import to `globalThis` at ACCESS
228
+ * time (not at bundle-evaluation time).
229
+ *
230
+ * The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/
231
+ * afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle
232
+ * IIFE is evaluated. A value-copy redirect (`export var describe =
233
+ * globalThis.describe`) would therefore capture `undefined` at evaluation time
234
+ * and the user's `describe(...)` calls would be no-ops — registering zero tests.
235
+ *
236
+ * The fix defers the lookup to call time using per-name **getter** exports.
237
+ * We emit a CommonJS module that:
238
+ * 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named
239
+ * import directly to a property access on the module (NOT wrapped under a
240
+ * `default` shim — which is what happens for a bare Proxy whose own-keys
241
+ * are empty, leaving every named import `undefined`);
242
+ * 2. defines each global name as a getter that reads `globalThis[name]` on
243
+ * every access. So `import { describe } from 'vitest'` compiles to
244
+ * `import_vitest.describe`, whose getter returns the real `describe` only
245
+ * when the factory calls it — after `runTestModule` installs the globals.
246
+ *
247
+ * A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes
248
+ * the virtual module through `__toESM`, which enumerates own-keys (none on an
249
+ * empty Proxy target) and therefore exposes zero named exports. Explicit getter
250
+ * properties give `__toESM` real keys to map while keeping access lazy.
251
+ */
252
+ function vitestRedirectPlugin() {
253
+ return {
254
+ name: "vitest-redirect",
255
+ setup(build) {
256
+ build.onResolve({ filter: /^vitest$/ }, () => ({
257
+ path: "vitest",
258
+ namespace: "vitest-redirect"
259
+ }));
260
+ build.onLoad({
261
+ filter: /^vitest$/,
262
+ namespace: "vitest-redirect"
263
+ }, () => {
264
+ return {
265
+ 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`,
266
+ loader: "js"
267
+ };
268
+ });
269
+ }
270
+ };
271
+ }
272
+ /**
273
+ * esbuild plugin that transforms the user test file into a module that exports
274
+ * an async `__userFactory` function. The factory defers the user's top-level
275
+ * test registration code (describe/it/test calls) so it only runs when
276
+ * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
277
+ * installed describe/it/test/expect as globals.
278
+ *
279
+ * Algorithm:
280
+ * - Import declarations and re-export statements are kept at module top-level
281
+ * (the only valid ESM position for static `import`). A statement that spans
282
+ * multiple lines — e.g. a named import with one member per line:
283
+ * import {
284
+ * appLogin,
285
+ * getAnonymousKey,
286
+ * } from '@apps-in-toss/web-framework';
287
+ * is tracked as a single block: every line from the opening `import {` /
288
+ * `export {` through the closing `from '…'` (or side-effect `'…'`) line is
289
+ * kept together at top-level. This prevents the member lines and the
290
+ * closing `} from '…'` line from leaking into the factory body, which would
291
+ * leave an unterminated `import {` at module scope (the #678 env3 failure:
292
+ * esbuild threw `Expected "as" but found "{"` on multi-line SDK imports).
293
+ * - All other lines (describe/it/test calls, local declarations, etc.) are
294
+ * moved into the body of the exported async factory function.
295
+ *
296
+ * This preserves SDK import resolution (the sdk-redirect plugin processes
297
+ * top-level imports normally) while deferring test registration to the factory.
298
+ */
299
+ function userFactoryPlugin(absPath) {
300
+ const NAMESPACE = "user-test-factory";
301
+ return {
302
+ name: "user-test-factory",
303
+ setup(build) {
304
+ build.onResolve({ filter: /^user-test-factory$/ }, () => ({
305
+ path: absPath,
306
+ namespace: NAMESPACE
307
+ }));
308
+ build.onLoad({
309
+ filter: /.*/,
310
+ namespace: NAMESPACE
311
+ }, async (args) => {
312
+ const lines = (await fs.readFile(args.path, "utf8")).split("\n");
313
+ const topLevelLines = [];
314
+ const bodyLines = [];
315
+ const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
316
+ const isImportStart = (trimmed) => trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"");
317
+ const endsStatement = (trimmed) => /['"]\s*;?\s*$/.test(trimmed.replace(/\/\/.*$/, "").trimEnd());
318
+ let inImportBlock = false;
319
+ for (const line of lines) {
320
+ const trimmed = line.trimStart();
321
+ const indent = line.slice(0, line.length - trimmed.length);
322
+ if (inImportBlock) {
323
+ topLevelLines.push(line);
324
+ if (endsStatement(trimmed)) inImportBlock = false;
325
+ continue;
326
+ }
327
+ if (isImportStart(trimmed)) {
328
+ topLevelLines.push(line);
329
+ if (!endsStatement(trimmed)) inImportBlock = true;
330
+ } else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
331
+ else {
332
+ topLevelLines.push(line);
333
+ if (/\bfrom\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith("{")) inImportBlock = true;
334
+ }
335
+ else bodyLines.push(line);
336
+ }
337
+ return {
338
+ contents: [
339
+ ...topLevelLines,
340
+ "",
341
+ "// biome-ignore lint: generated factory wrapper",
342
+ "export default async function __userFactory(): Promise<void> {",
343
+ ...bodyLines.map((l) => ` ${l}`),
344
+ "}"
345
+ ].join("\n"),
346
+ loader: "ts",
347
+ resolveDir: path$1.dirname(absPath)
348
+ };
349
+ });
350
+ }
351
+ };
352
+ }
353
+ /**
354
+ * Returns the absolute filesystem path to the test-runner runtime module
355
+ * (dist/test-runner/runtime.js — a fully self-contained page-side bundle).
356
+ *
357
+ * Rolldown code-splitting duplicates this bundling logic into shared chunks
358
+ * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
359
+ * dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`
360
+ * daemon (dist/mcp/cli.js) pulls it through a ROOT chunk
361
+ * (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list
362
+ * is therefore wrong from at least one chunk — the live #697 regression.
363
+ *
364
+ * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
365
+ * probes the co-located `runtime.js` and the nested `test-runner/runtime.js`,
366
+ * then ascends one directory at a time (bounded) repeating both probes. The
367
+ * nested probe catches dist/test-runner/runtime.js from the dist/ root level no
368
+ * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
369
+ * relocation). The build always emits dist/test-runner/runtime.js (tsdown entry
370
+ * `'test-runner/runtime'`; guarded by scripts/check-test-runner-dist.sh).
371
+ *
372
+ * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
373
+ * read, bypassing Node module resolution entirely, so this works identically in
374
+ * the npx-daemon context (its own dist tree) and the consumer-CLI context
375
+ * (the mini-app's installed @ait-co/devtools dist) — neither needs the package
376
+ * to be node-resolvable from the caller.
377
+ */
378
+ function getRuntimePath() {
379
+ const startDir = path$1.dirname(fileURLToPath(import.meta.url));
380
+ const RELATIVE_PROBES = [
381
+ ["runtime.js"],
382
+ ["test-runner", "runtime.js"],
383
+ ["runtime.ts"],
384
+ ["test-runner", "runtime.ts"]
385
+ ];
386
+ let dir = startDir;
387
+ for (let i = 0; i < 12; i++) {
388
+ for (const segs of RELATIVE_PROBES) {
389
+ const candidate = path$1.join(dir, ...segs);
390
+ try {
391
+ accessSync(candidate);
392
+ return candidate;
393
+ } catch {}
394
+ }
395
+ const parent = path$1.dirname(dir);
396
+ if (parent === dir) break;
397
+ dir = parent;
398
+ }
399
+ return path$1.join(startDir, "runtime.js");
400
+ }
401
+ /**
402
+ * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
403
+ *
404
+ * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
405
+ * - `runTestModule` — the runtime entry (from `runtime.ts`).
406
+ * - `__userFactory` — an async function wrapping the user's test registration
407
+ * code so it runs AFTER `runTestModule` installs the globals.
408
+ *
409
+ * Callers (rpc.ts) invoke:
410
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
411
+ *
412
+ * @param absPath - Absolute path to the user test file.
413
+ * @param opts - Optional bundling overrides.
414
+ */
415
+ async function bundleTestFile(absPath, opts) {
416
+ const globalName = opts?.globalName ?? "__testBundle";
417
+ const extraExternals = opts?.extraExternals ?? [];
418
+ const esbuild = await import("esbuild");
419
+ const runtimePath = getRuntimePath();
420
+ const wrapperContent = [
421
+ `import { runTestModule } from ${JSON.stringify(runtimePath)};`,
422
+ `import __userFactory from "user-test-factory";`,
423
+ `export { runTestModule, __userFactory };`
424
+ ].join("\n");
425
+ const result = await esbuild.build({
426
+ stdin: {
427
+ contents: wrapperContent,
428
+ loader: "ts",
429
+ resolveDir: path$1.dirname(absPath)
430
+ },
431
+ bundle: true,
432
+ format: "iife",
433
+ globalName,
434
+ platform: "browser",
435
+ target: "es2022",
436
+ write: false,
437
+ plugins: [
438
+ userFactoryPlugin(absPath),
439
+ vitestRedirectPlugin(),
440
+ sdkRedirectPlugin()
441
+ ],
442
+ external: extraExternals,
443
+ treeShaking: true,
444
+ footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
445
+ });
446
+ const warnings = result.warnings.map((w) => `${path$1.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
447
+ const outputFile = result.outputFiles?.[0];
448
+ if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
449
+ return {
450
+ code: outputFile.text,
451
+ warnings
452
+ };
453
+ }
454
+ //#endregion
455
+ //#region src/test-runner/capture.ts
456
+ /** The exact console-line prefix sdk-example's `flushCapture` emits. */
457
+ const CAPTURE_PREFIX = "__AIT_CAPTURE__ ";
458
+ /**
459
+ * Parses raw console line texts into {@link AitCaptureLine}s.
460
+ *
461
+ * Filtering rules (each independently drops a line — never throws):
462
+ * - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);
463
+ * - there must be a non-empty category token (up to the next space);
464
+ * - the remaining payload must be valid JSON (`JSON.parse` succeeds).
465
+ *
466
+ * Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are
467
+ * silently discarded — capture harvesting is best-effort and must never fail a
468
+ * run or leak a malformed/secret-bearing line.
469
+ *
470
+ * @param raw - Console line objects (only `.text` is read).
471
+ * @returns The captured lines, in input order.
472
+ */
473
+ function parseCaptureLines(raw) {
474
+ const out = [];
475
+ for (const { text } of raw) {
476
+ if (!text.startsWith(CAPTURE_PREFIX)) continue;
477
+ const body = text.slice(16);
478
+ const spaceIdx = body.indexOf(" ");
479
+ if (spaceIdx === -1) continue;
480
+ const category = body.slice(0, spaceIdx);
481
+ const json = body.slice(spaceIdx + 1);
482
+ if (category === "" || json === "") continue;
483
+ try {
484
+ JSON.parse(json);
485
+ } catch {
486
+ continue;
487
+ }
488
+ out.push({
489
+ category,
490
+ json
491
+ });
492
+ }
493
+ return out;
494
+ }
495
+ //#endregion
496
+ //#region src/test-runner/rpc.ts
497
+ /** Maximum milliseconds to wait for a single evaluate round-trip. */
498
+ const DEFAULT_TIMEOUT_MS = 3e4;
499
+ /**
500
+ * Wraps bundle code in a self-executing IIFE that:
501
+ * 1. Evaluates the bundle (registering describe/it/test).
502
+ * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
503
+ * 3. Returns a JSON-serialised `RunReport` string.
504
+ *
505
+ * The double-serialisation (RunReport → JSON string → returnByValue string)
506
+ * is intentional: CDP `returnByValue` reliably transports strings; deeply
507
+ * nested objects can lose fidelity across the Chii relay.
508
+ *
509
+ * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
510
+ */
511
+ function buildRunTestsExpression(bundleCode) {
512
+ 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)}); }})()`;
513
+ }
514
+ /**
515
+ * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
516
+ * evaluate call into a typed `RpcRunResult`.
517
+ *
518
+ * Throws only on parse failure — an `ok:false` envelope is a normal result.
519
+ *
520
+ * SECRET-HANDLING: `rawValue` is not included in error messages.
521
+ */
522
+ function parseRunTestsResult(rawValue) {
523
+ if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
524
+ let parsed;
525
+ try {
526
+ parsed = JSON.parse(rawValue);
527
+ } catch {
528
+ throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
529
+ }
530
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
531
+ const obj = parsed;
532
+ if (obj.ok === true) return {
533
+ ok: true,
534
+ report: obj.value
535
+ };
536
+ if (obj.ok === false) return {
537
+ ok: false,
538
+ error: typeof obj.error === "string" ? obj.error : String(obj.error)
539
+ };
540
+ throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
541
+ }
542
+ /**
543
+ * Injects `bundleCode` into the attached page and awaits test execution.
544
+ *
545
+ * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
546
+ * async IIFE to settle. The 30-second CDP command timeout covers even
547
+ * long-running test suites; split into smaller files if you hit it.
548
+ *
549
+ * @param connection - Active CDP connection (relay or local).
550
+ * @param bundleCode - IIFE bundle string from `bundleTestFile`.
551
+ * @param timeoutMs - Override the default 30 s timeout.
552
+ *
553
+ * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
554
+ */
555
+ async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
556
+ const expression = buildRunTestsExpression(bundleCode);
557
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`rpc: evaluate timed out after ${timeoutMs}ms`)), timeoutMs));
558
+ const evalPromise = connection.send("Runtime.evaluate", {
559
+ expression,
560
+ returnByValue: true,
561
+ awaitPromise: true
562
+ });
563
+ const cdpResult = await Promise.race([evalPromise, timeoutPromise]);
564
+ if (cdpResult.exceptionDetails) {
565
+ const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
566
+ throw new Error(`rpc.injectAndRunBundle: ${msg}`);
567
+ }
568
+ return parseRunTestsResult(cdpResult.result.value);
569
+ }
570
+ //#endregion
571
+ //#region src/test-runner/relay-worker.ts
572
+ /**
573
+ * Runs all `files` sequentially over the given CDP `connection`.
574
+ *
575
+ * For each file:
576
+ * 1. Bundle with esbuild (includes SDK shim + runtime).
577
+ * 2. Inject into the attached page via `Runtime.evaluate`.
578
+ * 3. Await the `RunReport` JSON response.
579
+ * 4. Accumulate results.
580
+ *
581
+ * Returns a `RelayRunReport` with per-file results and flattened totals.
582
+ *
583
+ * This function does NOT open or manage the relay connection — the caller
584
+ * is responsible for attaching and closing it.
585
+ *
586
+ * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
587
+ * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
588
+ *
589
+ * @param connection - Active CDP connection (relay or local kind).
590
+ * @param files - Absolute paths to test files, run in order.
591
+ * @param opts - Optional per-run overrides.
592
+ */
593
+ async function runTestFilesOverRelay(connection, files, opts) {
594
+ const wallStart = Date.now();
595
+ const startedAt = new Date(wallStart).toISOString();
596
+ const fileResults = [];
597
+ let domainsEnabled = false;
598
+ try {
599
+ await connection.enableDomains();
600
+ domainsEnabled = true;
601
+ } catch (e) {
602
+ process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
603
+ }
604
+ const collectCaptures = opts?.collectCaptures === true;
605
+ const liveConsole = [];
606
+ let unsubscribeConsole;
607
+ if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
608
+ liveConsole.push(event);
609
+ });
610
+ try {
611
+ for (const file of files) {
612
+ let fileEntry;
613
+ try {
614
+ const { code } = await bundleTestFile(file, opts?.bundleOptions);
615
+ const rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
616
+ if (rpcResult.ok) fileEntry = {
617
+ file,
618
+ result: rpcResult.report
619
+ };
620
+ else fileEntry = {
621
+ file,
622
+ result: { error: rpcResult.error }
623
+ };
624
+ } catch (e) {
625
+ fileEntry = {
626
+ file,
627
+ result: { error: e instanceof Error ? e.message : String(e) }
628
+ };
629
+ }
630
+ fileResults.push(fileEntry);
631
+ }
632
+ } finally {
633
+ unsubscribeConsole?.();
634
+ }
635
+ const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
636
+ const totals = fileResults.reduce((acc, { result }) => {
637
+ if ("error" in result) {
638
+ acc.failed += 1;
639
+ acc.total += 1;
640
+ } else {
641
+ acc.passed += result.passed;
642
+ acc.failed += result.failed;
643
+ acc.skipped += result.skipped;
644
+ acc.total += result.passed + result.failed + result.skipped;
645
+ }
646
+ return acc;
647
+ }, {
648
+ passed: 0,
649
+ failed: 0,
650
+ skipped: 0,
651
+ total: 0
652
+ });
653
+ return {
654
+ startedAt,
655
+ duration: Date.now() - wallStart,
656
+ files: fileResults,
657
+ totals,
658
+ captures
659
+ };
660
+ }
661
+ /**
662
+ * Renders one `Runtime.consoleAPICalled` event to a single line of text, the
663
+ * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
664
+ * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
665
+ * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
666
+ * onto the test-runner entry.
667
+ *
668
+ * SECRET-HANDLING: this only stringifies console args; the caller's
669
+ * allowlist-prefix parser then discards everything that is not a genuine
670
+ * `__AIT_CAPTURE__` line.
671
+ */
672
+ function renderConsoleLineText(event) {
673
+ return event.args.map((arg) => {
674
+ if (arg.value !== void 0) {
675
+ if (typeof arg.value === "string") return arg.value;
676
+ try {
677
+ return JSON.stringify(arg.value);
678
+ } catch {
679
+ return String(arg.value);
680
+ }
681
+ }
682
+ if (arg.description !== void 0) return arg.description;
683
+ if (arg.className !== void 0) return arg.className;
684
+ return arg.subtype ?? arg.type;
685
+ }).join(" ");
686
+ }
687
+ //#endregion
688
+ //#region src/test-runner/report.ts
689
+ /**
690
+ * Runner-agnostic report serialisation for env3 test runs (devtools#696).
691
+ *
692
+ * Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the
693
+ * standalone `devtools-test` CLI (`cli.ts`) — call the same core
694
+ * `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This
695
+ * module is the single, runner-neutral place that turns that in-memory report
696
+ * into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed
697
+ * cell-by-cell after the fact.
698
+ *
699
+ * The serialised schema is deliberately MINIMAL and secret-free:
700
+ *
701
+ * - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`
702
+ * leakage — see {@link RunnerAgnosticReport.files});
703
+ * - the cell metadata (sdkLine/platform) is baked INTO the body, not only the
704
+ * filename, so a moved artifact never loses its provenance;
705
+ * - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all
706
+ * (enforced by the type + this comment) — error strings are the matcher
707
+ * message only, inherited from rpc.ts which already strips expression/value.
708
+ *
709
+ * react-free — depends only on the type-level `RelayRunReport` and `node:fs` /
710
+ * `node:path`. Safe to bundle without pulling the chii/cloudflared graph.
711
+ */
712
+ /**
713
+ * Converts an absolute (or already-relative) file path to a projectRoot-relative
714
+ * one. `path.relative` returns `''` when the paths are equal — guard that to the
715
+ * basename so the field is never empty.
716
+ *
717
+ * SECRET-HANDLING: this is the single choke point that strips absolute project
718
+ * paths from the artifact.
719
+ */
720
+ function relativise(projectRoot, file) {
721
+ const rel = path.relative(projectRoot, file);
722
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return path.basename(file);
723
+ return rel;
724
+ }
725
+ /**
726
+ * Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free
727
+ * on-disk shape. Pure — no IO; testable with a plain report + meta.
728
+ *
729
+ * @param report - The core relay run report.
730
+ * @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).
731
+ */
732
+ function serializeRelayReport(report, meta) {
733
+ return {
734
+ cell: {
735
+ sdkLine: meta.sdkLine,
736
+ platform: meta.platform
737
+ },
738
+ startedAt: report.startedAt,
739
+ duration: report.duration,
740
+ totals: report.totals,
741
+ files: report.files.map((f) => {
742
+ const file = relativise(meta.projectRoot, f.file);
743
+ if ("error" in f.result) return {
744
+ file,
745
+ error: f.result.error
746
+ };
747
+ return {
748
+ file,
749
+ duration: f.result.duration,
750
+ passed: f.result.passed,
751
+ failed: f.result.failed,
752
+ skipped: f.result.skipped,
753
+ tests: f.result.tests
754
+ };
755
+ })
756
+ };
757
+ }
758
+ /**
759
+ * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating
760
+ * `dir` if needed. Returns the absolute path written.
761
+ *
762
+ * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as
763
+ * distinct artifacts in the same directory; the same cell metadata is also baked
764
+ * into the body so a renamed/moved file still carries its provenance.
765
+ *
766
+ * SECRET-HANDLING: the written body contains no relay/secret fields (the schema
767
+ * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
768
+ *
769
+ * @param report - The core relay run report.
770
+ * @param dir - Output directory (created recursively if missing).
771
+ * @param meta - Cell axes + projectRoot.
772
+ * @returns The absolute path of the written file.
773
+ */
774
+ async function writeReportArtifact(report, dir, meta) {
775
+ const serialised = serializeRelayReport(report, meta);
776
+ await mkdir(dir, { recursive: true });
777
+ const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
778
+ await writeFile(outFile, `${JSON.stringify(serialised, null, 2)}\n`, "utf8");
779
+ return outFile;
780
+ }
781
+ /**
782
+ * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,
783
+ * named `<category>.<sdkLine>.<platform>.json` — the SAME convention
784
+ * sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3
785
+ * capture artifacts line up for diffing.
786
+ *
787
+ * Each line's `json` payload is an opaque JSON array of capture records. Lines
788
+ * sharing a category are concatenated into one array, in harvest order.
789
+ *
790
+ * SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser
791
+ * dropped wss/scheme noise); the `json` payload is written verbatim but is a
792
+ * capture record array, not a relay/secret.
793
+ *
794
+ * @param captures - Parsed capture lines (from `RelayRunReport.captures`).
795
+ * @param dir - Output directory (created recursively if missing).
796
+ * @param cell - Cell axes for the filename suffix.
797
+ * @returns The absolute paths written (one per category), in category order.
798
+ */
799
+ async function writeCaptureArtifacts(captures, dir, cell) {
800
+ if (captures.length === 0) return [];
801
+ const byCategory = /* @__PURE__ */ new Map();
802
+ for (const { category, json } of captures) {
803
+ let merged = byCategory.get(category);
804
+ if (!merged) {
805
+ merged = [];
806
+ byCategory.set(category, merged);
807
+ }
808
+ const parsed = JSON.parse(json);
809
+ if (Array.isArray(parsed)) merged.push(...parsed);
810
+ else merged.push(parsed);
811
+ }
812
+ await mkdir(dir, { recursive: true });
813
+ const written = [];
814
+ for (const [category, records] of byCategory) {
815
+ const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);
816
+ await writeFile(outFile, `${JSON.stringify(records, null, 2)}\n`, "utf8");
817
+ written.push(outFile);
818
+ }
819
+ return written;
820
+ }
821
+ //#endregion
822
+ //#region src/test-runner/cli.ts
823
+ /**
824
+ * `devtools-test` CLI.
825
+ *
826
+ * Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)
827
+ * and exposes `runWithConnection` — the pure run core that bundles, injects, and
828
+ * collects each file over a CDP connection. The CLI's `main()` performs a
829
+ * standalone relay attach (boot relay → QR → phone scan → cell inject → run).
830
+ *
831
+ * NOTE: no shebang in this source file — the tsdown entry's `banner` option
832
+ * injects `#!/usr/bin/env node` into the compiled output (same pattern as
833
+ * `src/mcp/cli.ts`).
834
+ */
835
+ const USAGE = `
836
+ devtools-test — run mini-app tests on a real device WebView over the CDP relay
837
+
838
+ USAGE
839
+ devtools-test <glob> [<glob> ...] [options]
840
+
841
+ OPTIONS
842
+ --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
843
+ (required for standalone relay attach / env3)
844
+ --timeout <ms> Per-file evaluate timeout in ms (default: 30000)
845
+ --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
846
+ --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
847
+ (mock|ios|android, default: AIT_CELL_PLATFORM env)
848
+ --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
849
+ (report: <sdkLine>.<platform>.json; captures:
850
+ <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
851
+ Omitted = nothing saved. Enables console capture.
852
+ --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
853
+ non-interactive stdout / CI / AIT_NO_QR_STDOUT)
854
+ --headless Disable browser auto-open (text QR only)
855
+ --project-root <dir> Project root for .ait_relay secret lookup
856
+ (default: current working directory)
857
+ --help, -h Show this help message
858
+
859
+ DESCRIPTION
860
+ Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
861
+ device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
862
+ each matched test file with esbuild (SDK imports redirected to window.__sdk),
863
+ injects the bundle into the attached WebView via Runtime.evaluate, and prints
864
+ a summary.
865
+
866
+ With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
867
+ runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
868
+ compared offline.
869
+
870
+ The test files run against the live relay connection started by this process;
871
+ no separate MCP daemon is required.
872
+
873
+ EXAMPLE
874
+ devtools-test 'src/**/*.ait.test.ts' \\
875
+ --scheme-url "intoss-private://..." \\
876
+ --cell-sdk-line 3.x \\
877
+ --cell-platform ios \\
878
+ --report-dir .ait-report \\
879
+ --timeout 60000
880
+
881
+ `.trimStart();
882
+ /**
883
+ * Runs `files` over `connection` and returns the aggregate report.
884
+ * This pure function is the testable core of the CLI (and is what the
885
+ * `run_tests` MCP tool calls against the daemon's attached connection); it is
886
+ * separate from `main()` so tests can call it without spawning a subprocess.
887
+ */
888
+ async function runWithConnection(connection, files, opts) {
889
+ const report = await runTestFilesOverRelay(connection, files, opts);
890
+ if (opts?.printSummary) {
891
+ const { totals } = report;
892
+ process.stdout.write(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${report.duration}ms)\n`);
893
+ }
894
+ return report;
895
+ }
896
+ /**
897
+ * Decides whether to suppress the QR/attach block on stdout.
898
+ *
899
+ * Suppress when EITHER the user passed `--no-qr-stdout`, OR stdout is not a TTY
900
+ * / `CI` is set / `AIT_NO_QR_STDOUT` is set (non-interactive — a captured stdout
901
+ * must not leak the relay wss + TOTP `at=` code that the QR block encodes). The
902
+ * suppression is whole-chunk: `attachUrl` AND `relayUrl` ride in the same block.
903
+ *
904
+ * Exported for unit testing.
905
+ */
906
+ function shouldSuppressQr(noQrFlag) {
907
+ return noQrFlag || !process.stdout.isTTY || process.env.CI !== void 0 || process.env.AIT_NO_QR_STDOUT !== void 0;
908
+ }
909
+ /**
910
+ * CLI entry point.
911
+ *
912
+ * Performs a standalone relay attach → run lifecycle, sharing the attach
913
+ * assembly with the Vitest pool via `createRelayConnectionFactory` (single
914
+ * source — no drift):
915
+ *
916
+ * 1. Parse args: globs, --timeout, --cell-sdk-line, --cell-platform,
917
+ * --scheme-url (required for env3), --report-dir, --no-qr-stdout,
918
+ * --headless, --project-root.
919
+ * 2. Discover test files; exit 1 if none.
920
+ * 3. factory.open() — boot relay → render QR (suppressed on non-interactive
921
+ * stdout) → wait for phone → inject cell → enableDomains. Returns the conn.
922
+ * 4. runWithConnection(conn, files, { timeoutMs, collectCaptures, printSummary }).
923
+ * 5. With --report-dir: write the runner-agnostic report + capture files.
924
+ * 6. factory.close(); process.exitCode = failed > 0 ? 1 : 0.
925
+ *
926
+ * The CLI is not a daemon — no lock, router, SSE, or tools_list is needed.
927
+ * Attach timeout exits with code 1; test failures exit with code 1.
928
+ *
929
+ * SECRET-HANDLING: scheme_url / relay wssUrl / TOTP codes are never written to
930
+ * stdout/stderr directly. The QR block (which encodes the TOTP `at=` code) is
931
+ * printed only when stdout is interactive AND not suppressed.
932
+ */
933
+ async function main(argv = process.argv.slice(2)) {
934
+ let parsed;
935
+ try {
936
+ parsed = parseArgs({
937
+ args: argv,
938
+ options: {
939
+ help: {
940
+ type: "boolean",
941
+ short: "h"
942
+ },
943
+ timeout: { type: "string" },
944
+ "scheme-url": { type: "string" },
945
+ "cell-sdk-line": { type: "string" },
946
+ "cell-platform": { type: "string" },
947
+ "report-dir": { type: "string" },
948
+ "no-qr-stdout": { type: "boolean" },
949
+ headless: { type: "boolean" },
950
+ "project-root": { type: "string" }
951
+ },
952
+ allowPositionals: true
953
+ });
954
+ } catch (e) {
955
+ process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
956
+ process.exitCode = 1;
957
+ return;
958
+ }
959
+ if (parsed.values.help || argv.length === 0) {
960
+ process.stdout.write(USAGE);
961
+ return;
962
+ }
963
+ const vals = parsed.values;
964
+ const rawTimeout = typeof vals.timeout === "string" ? vals.timeout : void 0;
965
+ const timeoutMs = rawTimeout !== void 0 ? parseInt(rawTimeout, 10) : 3e4;
966
+ if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
967
+ process.stderr.write(`devtools-test: --timeout must be a positive integer\n`);
968
+ process.exitCode = 1;
969
+ return;
970
+ }
971
+ const schemeUrl = typeof vals["scheme-url"] === "string" ? vals["scheme-url"] : "";
972
+ if (schemeUrl === "") {
973
+ 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");
974
+ process.exitCode = 1;
975
+ return;
976
+ }
977
+ const headless = vals.headless === true;
978
+ const projectRoot = typeof vals["project-root"] === "string" ? vals["project-root"] : process.cwd();
979
+ const reportDir = typeof vals["report-dir"] === "string" ? vals["report-dir"] : void 0;
980
+ const suppressQr = shouldSuppressQr(vals["no-qr-stdout"] === true);
981
+ const cellSdkLine = typeof vals["cell-sdk-line"] === "string" ? vals["cell-sdk-line"] : void 0;
982
+ const cellPlatform = typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM;
983
+ const hasCell = cellSdkLine !== void 0 || cellPlatform !== void 0;
984
+ const cell = {
985
+ sdkLine: cellSdkLine ?? "2.x",
986
+ platform: cellPlatform ?? "mock"
987
+ };
988
+ const globs = parsed.positionals;
989
+ if (globs.length === 0) {
990
+ process.stderr.write(`devtools-test: at least one glob pattern is required\n`);
991
+ process.stdout.write(USAGE);
992
+ process.exitCode = 1;
993
+ return;
994
+ }
995
+ const files = await discoverTestFiles(globs, process.cwd());
996
+ if (files.length === 0) {
997
+ process.stderr.write(`devtools-test: no test files matched ${globs.join(", ")}\n`);
998
+ process.exitCode = 1;
999
+ return;
1000
+ }
1001
+ process.stderr.write(`devtools-test: found ${files.length} test file(s)\n`);
1002
+ if (hasCell) process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
1003
+ const factory = createRelayConnectionFactory({
1004
+ schemeUrl,
1005
+ projectRoot,
1006
+ timeoutMs,
1007
+ headless,
1008
+ cell: hasCell ? cell : void 0,
1009
+ onQrContent: (chunks) => {
1010
+ if (suppressQr) {
1011
+ process.stdout.write("QR suppressed (non-interactive)\n");
1012
+ return;
1013
+ }
1014
+ for (const chunk of chunks) process.stdout.write(`${chunk}\n`);
1015
+ }
1016
+ });
1017
+ let connection;
1018
+ try {
1019
+ connection = await factory.open();
1020
+ } catch (e) {
1021
+ process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
1022
+ process.exitCode = 1;
1023
+ return;
1024
+ }
1025
+ let exitCode = 0;
1026
+ try {
1027
+ const report = await runWithConnection(connection, files, {
1028
+ timeoutMs,
1029
+ printSummary: true,
1030
+ collectCaptures: reportDir !== void 0
1031
+ });
1032
+ if (reportDir !== void 0) try {
1033
+ const reportPath = await writeReportArtifact(report, reportDir, {
1034
+ sdkLine: cell.sdkLine,
1035
+ platform: cell.platform,
1036
+ projectRoot
1037
+ });
1038
+ process.stderr.write(`devtools-test: wrote report ${reportPath}\n`);
1039
+ const capturePaths = await writeCaptureArtifacts(report.captures, `${reportDir}/.ait-capture`, cell);
1040
+ if (capturePaths.length > 0) process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\n`);
1041
+ } catch (e) {
1042
+ process.stderr.write(`devtools-test: failed to write report artifacts: ${e instanceof Error ? e.message : String(e)}\n`);
1043
+ }
1044
+ exitCode = report.totals.failed > 0 ? 1 : 0;
1045
+ } finally {
1046
+ await factory.close(connection);
1047
+ process.exitCode = exitCode;
1048
+ }
1049
+ }
1050
+ if (import.meta.url === new URL(process.argv[1], "file://").href) main().catch((e) => {
1051
+ process.stderr.write(`devtools-test: unexpected error: ${e instanceof Error ? e.message : String(e)}\n`);
1052
+ process.exitCode = 1;
1053
+ });
1054
+ //#endregion
1055
+ export { runWithConnection as n, shouldSuppressQr as r, main as t };
1056
+
1057
+ //# sourceMappingURL=cli-RhzASQef.js.map