@ait-co/devtools 0.1.106 → 0.1.108

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 (91) hide show
  1. package/dist/bundle-BJm5jk56.d.ts +49 -0
  2. package/dist/bundle-BJm5jk56.d.ts.map +1 -0
  3. package/dist/cdp-connection-C0AP0tH2.d.ts +277 -0
  4. package/dist/cdp-connection-C0AP0tH2.d.ts.map +1 -0
  5. package/dist/in-app/auto.d.ts +17 -0
  6. package/dist/in-app/auto.d.ts.map +1 -1
  7. package/dist/in-app/auto.js +76 -0
  8. package/dist/in-app/auto.js.map +1 -1
  9. package/dist/in-app/index.d.ts +48 -1
  10. package/dist/in-app/index.d.ts.map +1 -1
  11. package/dist/in-app/index.js +60 -1
  12. package/dist/in-app/index.js.map +1 -1
  13. package/dist/mcp/cli.js +507 -11
  14. package/dist/mcp/cli.js.map +1 -1
  15. package/dist/mcp/server.d.ts.map +1 -1
  16. package/dist/mcp/server.js +60 -3
  17. package/dist/mcp/server.js.map +1 -1
  18. package/dist/panel/index.js +1 -1
  19. package/dist/pool-Dkp7I9Bf.d.ts +14577 -0
  20. package/dist/pool-Dkp7I9Bf.d.ts.map +1 -0
  21. package/dist/{relay-secret-store-I5q2Wvvv.cjs → relay-secret-store-CPBBlV3J.cjs} +2 -2
  22. package/dist/{relay-secret-store-I5q2Wvvv.cjs.map → relay-secret-store-CPBBlV3J.cjs.map} +1 -1
  23. package/dist/{relay-secret-store-Bns5rndt.js → relay-secret-store-DBwzoCXQ.js} +2 -2
  24. package/dist/{relay-secret-store-Bns5rndt.js.map → relay-secret-store-DBwzoCXQ.js.map} +1 -1
  25. package/dist/{relay-secret-store-B0DH-8Qb.js → relay-secret-store-DhzAnnj-.js} +2 -2
  26. package/dist/{relay-secret-store-B0DH-8Qb.js.map → relay-secret-store-DhzAnnj-.js.map} +1 -1
  27. package/dist/{relay-url-store-CvmnevcO.cjs → relay-url-store-C9QLhB2p.cjs} +2 -2
  28. package/dist/{relay-url-store-CvmnevcO.cjs.map → relay-url-store-C9QLhB2p.cjs.map} +1 -1
  29. package/dist/{relay-url-store-BPeUZsiY.js → relay-url-store-CwKT7i04.js} +2 -2
  30. package/dist/{relay-url-store-BPeUZsiY.js.map → relay-url-store-CwKT7i04.js.map} +1 -1
  31. package/dist/{relay-url-store-DJHZjk8o.js → relay-url-store-j16TRTiJ.js} +2 -2
  32. package/dist/{relay-url-store-DJHZjk8o.js.map → relay-url-store-j16TRTiJ.js.map} +1 -1
  33. package/dist/relay-worker-BzFQ3fv9.d.ts +74 -0
  34. package/dist/relay-worker-BzFQ3fv9.d.ts.map +1 -0
  35. package/dist/runtime-ORdrpizY.d.ts +50 -0
  36. package/dist/runtime-ORdrpizY.d.ts.map +1 -0
  37. package/dist/test-runner/bundle.d.ts +2 -0
  38. package/dist/test-runner/bundle.js +95 -0
  39. package/dist/test-runner/bundle.js.map +1 -0
  40. package/dist/test-runner/cli.d.ts +417 -0
  41. package/dist/test-runner/cli.d.ts.map +1 -0
  42. package/dist/test-runner/cli.js +377 -0
  43. package/dist/test-runner/cli.js.map +1 -0
  44. package/dist/test-runner/config.d.ts +80 -0
  45. package/dist/test-runner/config.d.ts.map +1 -0
  46. package/dist/test-runner/config.js +54 -0
  47. package/dist/test-runner/config.js.map +1 -0
  48. package/dist/test-runner/pool.d.ts +2 -0
  49. package/dist/test-runner/pool.js +136 -0
  50. package/dist/test-runner/pool.js.map +1 -0
  51. package/dist/test-runner/relay-worker.d.ts +2 -0
  52. package/dist/test-runner/relay-worker.js +96 -0
  53. package/dist/test-runner/relay-worker.js.map +1 -0
  54. package/dist/test-runner/rpc.d.ts +53 -0
  55. package/dist/test-runner/rpc.d.ts.map +1 -0
  56. package/dist/test-runner/rpc.js +78 -0
  57. package/dist/test-runner/rpc.js.map +1 -0
  58. package/dist/test-runner/task-graph.d.ts +38 -0
  59. package/dist/test-runner/task-graph.d.ts.map +1 -0
  60. package/dist/test-runner/task-graph.js +182 -0
  61. package/dist/test-runner/task-graph.js.map +1 -0
  62. package/dist/{totp-BmKSPb5d.js → totp-95OAa20j.js} +2 -2
  63. package/dist/totp-95OAa20j.js.map +1 -0
  64. package/dist/{totp-BwDZ6dUT.cjs → totp-BjtoQNfu.cjs} +2 -2
  65. package/dist/totp-BjtoQNfu.cjs.map +1 -0
  66. package/dist/totp-D1pulXLa.js +3 -0
  67. package/dist/{totp-DYdP9N3o.js → totp-DIbrZtI7.js} +2 -2
  68. package/dist/totp-DIbrZtI7.js.map +1 -0
  69. package/dist/{totp-CNw0w89F.cjs → totp-Df252ZdA.cjs} +2 -2
  70. package/dist/totp-Df252ZdA.cjs.map +1 -0
  71. package/dist/{totp-Xq3ACwkm.js → totp-WY6l0ysP.js} +2 -2
  72. package/dist/totp-WY6l0ysP.js.map +1 -0
  73. package/dist/{tunnel-BmDcTrnU.js → tunnel-BjJROkcj.js} +2 -2
  74. package/dist/{tunnel-BmDcTrnU.js.map → tunnel-BjJROkcj.js.map} +1 -1
  75. package/dist/{tunnel-RB5zB8IK.cjs → tunnel-d_G9AIFn.cjs} +2 -2
  76. package/dist/{tunnel-RB5zB8IK.cjs.map → tunnel-d_G9AIFn.cjs.map} +1 -1
  77. package/dist/unplugin/index.cjs +4 -4
  78. package/dist/unplugin/index.d.cts +13 -32
  79. package/dist/unplugin/index.d.cts.map +1 -1
  80. package/dist/unplugin/index.d.ts +13 -32
  81. package/dist/unplugin/index.d.ts.map +1 -1
  82. package/dist/unplugin/index.js +4 -4
  83. package/dist/unplugin/tunnel.cjs +1 -1
  84. package/dist/unplugin/tunnel.js +1 -1
  85. package/package.json +13 -3
  86. package/dist/totp-BcBNRoDD.js +0 -3
  87. package/dist/totp-BmKSPb5d.js.map +0 -1
  88. package/dist/totp-BwDZ6dUT.cjs.map +0 -1
  89. package/dist/totp-CNw0w89F.cjs.map +0 -1
  90. package/dist/totp-DYdP9N3o.js.map +0 -1
  91. package/dist/totp-Xq3ACwkm.js.map +0 -1
package/dist/mcp/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "../totp-Xq3ACwkm.js";
3
- import { t as loadRelaySecretReadOnly } from "../relay-secret-store-B0DH-8Qb.js";
2
+ import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "../totp-WY6l0ysP.js";
3
+ import { t as loadRelaySecretReadOnly } from "../relay-secret-store-DhzAnnj-.js";
4
4
  import { createRequire } from "node:module";
5
5
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { argv } from "node:process";
@@ -8,6 +8,10 @@ import { fileURLToPath } from "node:url";
8
8
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
+ import { parseArgs } from "node:util";
12
+ import { glob } from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { isAbsolute, join, resolve } from "node:path";
11
15
  import { EventEmitter } from "node:events";
12
16
  import { WebSocket, WebSocketServer } from "ws";
13
17
  import { randomBytes } from "node:crypto";
@@ -15,7 +19,6 @@ import { createServer } from "node:http";
15
19
  import { spawn } from "node:child_process";
16
20
  import net from "node:net";
17
21
  import { homedir, platform } from "node:os";
18
- import { join } from "node:path";
19
22
  import { Tunnel, bin, install } from "cloudflared";
20
23
  //#region \0rolldown/runtime.js
21
24
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
@@ -119,6 +122,375 @@ function startMaxAgeWatchdog(onExpired, opts = {}) {
119
122
  } };
120
123
  }
121
124
  //#endregion
125
+ //#region src/test-runner/discover.ts
126
+ /**
127
+ * Test-file discovery shared by the `devtools-test` CLI and the `run_tests`
128
+ * MCP tool, so both expand glob patterns with identical semantics.
129
+ *
130
+ * Uses Node's built-in `fs/promises` `glob` (Node 22+) — no extra dependency,
131
+ * which keeps the MCP daemon install graph lean (a plain glob lib would land in
132
+ * the `npx … devtools-mcp` path for no benefit).
133
+ *
134
+ * Pure Node IO only (`node:fs/promises` + `node:path`) — react-free, so it is
135
+ * safe to import from the MCP daemon graph.
136
+ */
137
+ /**
138
+ * Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of
139
+ * ABSOLUTE test file paths, resolved relative to `cwd`.
140
+ *
141
+ * A plain (non-glob) path passes through when it matches a real file; a glob
142
+ * expands against `cwd`. Absolute matches are kept as-is; relative matches are
143
+ * resolved against `cwd`. `bundleTestFile` requires an absolute path, so the
144
+ * absolute output feeds it directly.
145
+ *
146
+ * @param patterns Glob patterns or file paths (e.g. `['src/**\/*.phone.test.ts']`).
147
+ * @param cwd Base directory for relative patterns/results.
148
+ * @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.
149
+ */
150
+ async function discoverTestFiles(patterns, cwd) {
151
+ const out = /* @__PURE__ */ new Set();
152
+ for await (const match of glob(patterns, { cwd })) out.add(isAbsolute(match) ? match : resolve(cwd, match));
153
+ return [...out].sort();
154
+ }
155
+ //#endregion
156
+ //#region src/test-runner/bundle.ts
157
+ /**
158
+ * esbuild-based bundler for user test files.
159
+ *
160
+ * Bundles a single test file into a self-contained IIFE string that can be
161
+ * injected into a WebView via `Runtime.evaluate`. The user's SDK imports
162
+ * (`@apps-in-toss/web-framework` and sub-paths) are intercepted via an
163
+ * esbuild plugin that redirects them to `window.__sdk`, which the in-app
164
+ * debug gate (`src/in-app/auto.ts`) installs as a namespace mirror of the
165
+ * SDK exports (works for both 2.x and 3.x SDK).
166
+ *
167
+ * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
168
+ */
169
+ /**
170
+ * Matches the bare SDK package and any sub-path import
171
+ * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
172
+ * Built from {@link SDK_PACKAGE} so the package name has a single source.
173
+ */
174
+ const SDK_IMPORT_FILTER = new RegExp(`^${"@apps-in-toss/web-framework".replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
175
+ /**
176
+ * esbuild plugin that intercepts SDK imports and redirects them to the
177
+ * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
178
+ *
179
+ * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
180
+ * esbuild resolves it to a virtual module that re-exports all named exports
181
+ * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
182
+ * be available in the test environment) while still making named imports work.
183
+ *
184
+ * If `window.__sdk` is absent (non-dog-food build), every access throws a
185
+ * descriptive error rather than returning `undefined` silently.
186
+ */
187
+ function sdkRedirectPlugin() {
188
+ return {
189
+ name: "sdk-redirect",
190
+ setup(build) {
191
+ build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
192
+ path: args.path,
193
+ namespace: "sdk-redirect"
194
+ }));
195
+ build.onLoad({
196
+ filter: /.*/,
197
+ namespace: "sdk-redirect"
198
+ }, () => ({
199
+ contents: `
200
+ var __proxy = (typeof window !== 'undefined' && window.__sdk)
201
+ ? window.__sdk
202
+ : new Proxy({}, {
203
+ get: function(_t, p) {
204
+ throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
205
+ }
206
+ });
207
+ module.exports = __proxy;
208
+ `,
209
+ loader: "js"
210
+ }));
211
+ }
212
+ };
213
+ }
214
+ /**
215
+ * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
216
+ *
217
+ * The IIFE installs `window.__testBundle` (or the custom `globalName`) with
218
+ * `runTestModule` as the callable entry point.
219
+ *
220
+ * @param absPath - Absolute path to the user test file.
221
+ * @param opts - Optional bundling overrides.
222
+ */
223
+ async function bundleTestFile(absPath, opts) {
224
+ const globalName = opts?.globalName ?? "__testBundle";
225
+ const extraExternals = opts?.extraExternals ?? [];
226
+ const result = await (await import("esbuild")).build({
227
+ entryPoints: [absPath],
228
+ bundle: true,
229
+ format: "iife",
230
+ globalName,
231
+ platform: "browser",
232
+ target: "es2022",
233
+ write: false,
234
+ plugins: [sdkRedirectPlugin()],
235
+ external: extraExternals,
236
+ treeShaking: true
237
+ });
238
+ const warnings = result.warnings.map((w) => `${path.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
239
+ const outputFile = result.outputFiles?.[0];
240
+ if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
241
+ return {
242
+ code: outputFile.text,
243
+ warnings
244
+ };
245
+ }
246
+ //#endregion
247
+ //#region src/test-runner/rpc.ts
248
+ /** Maximum milliseconds to wait for a single evaluate round-trip. */
249
+ const DEFAULT_TIMEOUT_MS = 3e4;
250
+ /**
251
+ * Wraps bundle code in a self-executing IIFE that:
252
+ * 1. Evaluates the bundle (registering describe/it/test).
253
+ * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
254
+ * 3. Returns a JSON-serialised `RunReport` string.
255
+ *
256
+ * The double-serialisation (RunReport → JSON string → returnByValue string)
257
+ * is intentional: CDP `returnByValue` reliably transports strings; deeply
258
+ * nested objects can lose fidelity across the Chii relay.
259
+ *
260
+ * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
261
+ */
262
+ function buildRunTestsExpression(bundleCode) {
263
+ 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') { return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule is not a function'}); } try { const report = await globalThis.__testBundle.runTestModule(); return JSON.stringify({ok:true,value:report}); } catch(e) { return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)}); }})()`;
264
+ }
265
+ /**
266
+ * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
267
+ * evaluate call into a typed `RpcRunResult`.
268
+ *
269
+ * Throws only on parse failure — an `ok:false` envelope is a normal result.
270
+ *
271
+ * SECRET-HANDLING: `rawValue` is not included in error messages.
272
+ */
273
+ function parseRunTestsResult(rawValue) {
274
+ if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
275
+ let parsed;
276
+ try {
277
+ parsed = JSON.parse(rawValue);
278
+ } catch {
279
+ throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
280
+ }
281
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
282
+ const obj = parsed;
283
+ if (obj.ok === true) return {
284
+ ok: true,
285
+ report: obj.value
286
+ };
287
+ if (obj.ok === false) return {
288
+ ok: false,
289
+ error: typeof obj.error === "string" ? obj.error : String(obj.error)
290
+ };
291
+ throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
292
+ }
293
+ /**
294
+ * Injects `bundleCode` into the attached page and awaits test execution.
295
+ *
296
+ * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
297
+ * async IIFE to settle. The 30-second CDP command timeout covers even
298
+ * long-running test suites; split into smaller files if you hit it.
299
+ *
300
+ * @param connection - Active CDP connection (relay or local).
301
+ * @param bundleCode - IIFE bundle string from `bundleTestFile`.
302
+ * @param timeoutMs - Override the default 30 s timeout.
303
+ *
304
+ * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
305
+ */
306
+ async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
307
+ const expression = buildRunTestsExpression(bundleCode);
308
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`rpc: evaluate timed out after ${timeoutMs}ms`)), timeoutMs));
309
+ const evalPromise = connection.send("Runtime.evaluate", {
310
+ expression,
311
+ returnByValue: true,
312
+ awaitPromise: true
313
+ });
314
+ const cdpResult = await Promise.race([evalPromise, timeoutPromise]);
315
+ if (cdpResult.exceptionDetails) {
316
+ const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
317
+ throw new Error(`rpc.injectAndRunBundle: ${msg}`);
318
+ }
319
+ return parseRunTestsResult(cdpResult.result.value);
320
+ }
321
+ //#endregion
322
+ //#region src/test-runner/relay-worker.ts
323
+ /**
324
+ * Runs all `files` sequentially over the given CDP `connection`.
325
+ *
326
+ * For each file:
327
+ * 1. Bundle with esbuild (includes SDK shim + runtime).
328
+ * 2. Inject into the attached page via `Runtime.evaluate`.
329
+ * 3. Await the `RunReport` JSON response.
330
+ * 4. Accumulate results.
331
+ *
332
+ * Returns a `RelayRunReport` with per-file results and flattened totals.
333
+ *
334
+ * This function does NOT open or manage the relay connection — the caller
335
+ * is responsible for attaching and closing it.
336
+ *
337
+ * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
338
+ * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
339
+ *
340
+ * @param connection - Active CDP connection (relay or local kind).
341
+ * @param files - Absolute paths to test files, run in order.
342
+ * @param opts - Optional per-run overrides.
343
+ */
344
+ async function runTestFilesOverRelay(connection, files, opts) {
345
+ const wallStart = Date.now();
346
+ const startedAt = new Date(wallStart).toISOString();
347
+ const fileResults = [];
348
+ for (const file of files) {
349
+ let fileEntry;
350
+ try {
351
+ const { code } = await bundleTestFile(file, opts?.bundleOptions);
352
+ const rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
353
+ if (rpcResult.ok) fileEntry = {
354
+ file,
355
+ result: rpcResult.report
356
+ };
357
+ else fileEntry = {
358
+ file,
359
+ result: { error: rpcResult.error }
360
+ };
361
+ } catch (e) {
362
+ fileEntry = {
363
+ file,
364
+ result: { error: e instanceof Error ? e.message : String(e) }
365
+ };
366
+ }
367
+ fileResults.push(fileEntry);
368
+ }
369
+ const totals = fileResults.reduce((acc, { result }) => {
370
+ if ("error" in result) {
371
+ acc.failed += 1;
372
+ acc.total += 1;
373
+ } else {
374
+ acc.passed += result.passed;
375
+ acc.failed += result.failed;
376
+ acc.skipped += result.skipped;
377
+ acc.total += result.passed + result.failed + result.skipped;
378
+ }
379
+ return acc;
380
+ }, {
381
+ passed: 0,
382
+ failed: 0,
383
+ skipped: 0,
384
+ total: 0
385
+ });
386
+ return {
387
+ startedAt,
388
+ duration: Date.now() - wallStart,
389
+ files: fileResults,
390
+ totals
391
+ };
392
+ }
393
+ //#endregion
394
+ //#region src/test-runner/cli.ts
395
+ /**
396
+ * `devtools-test` CLI — MVP skeleton.
397
+ *
398
+ * Parses argv, prints usage, and delegates to `runTestFilesOverRelay` when
399
+ * a live CDP connection is provided. The relay connection wiring
400
+ * (attach → run → detach) is tracked in issue #645 / #646.
401
+ *
402
+ * MVP contract: `--help` works, `runWithConnection` is a testable pure
403
+ * function, and the binary entry exists in package.json.
404
+ *
405
+ * NOTE: no shebang in this source file — the tsdown entry's `banner` option
406
+ * injects `#!/usr/bin/env node` into the compiled output (same pattern as
407
+ * `src/mcp/cli.ts`).
408
+ */
409
+ const USAGE = `
410
+ devtools-test — run mini-app tests on a real device WebView over the CDP relay
411
+
412
+ USAGE
413
+ devtools-test <glob> [<glob> ...] [options]
414
+
415
+ OPTIONS
416
+ --timeout <ms> Per-file evaluate timeout in ms (default: 30000)
417
+ --help, -h Show this help message
418
+
419
+ DESCRIPTION
420
+ Bundles each matched test file with esbuild (SDK imports redirected to
421
+ window.__sdk), injects the bundle into the attached WebView via
422
+ Runtime.evaluate, and returns a RunReport.
423
+
424
+ A live CDP relay connection must be active before running tests.
425
+ Use \`/ait debug\` (devtools-mcp) to attach and then call this CLI from
426
+ the same process context.
427
+
428
+ Full Vitest pool integration and the \`run_tests\` MCP tool are tracked in
429
+ issues #645 and #646 respectively. This MVP provides the transport layer.
430
+
431
+ EXAMPLE
432
+ devtools-test 'src/**/*.phone.test.ts' --timeout 60000
433
+
434
+ `.trimStart();
435
+ /**
436
+ * Runs `files` over `connection` and returns the aggregate report.
437
+ * This pure function is the testable core of the CLI; it is separate from
438
+ * `main()` so tests can call it without spawning a subprocess.
439
+ *
440
+ * TODO (#645): add real relay attach/detach lifecycle here (connect via
441
+ * Chii relay URL, call enableDomains, run, then close).
442
+ */
443
+ async function runWithConnection(connection, files, opts) {
444
+ const report = await runTestFilesOverRelay(connection, files, opts);
445
+ if (opts?.printSummary) {
446
+ const { totals } = report;
447
+ process.stdout.write(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${report.duration}ms)\n`);
448
+ }
449
+ return report;
450
+ }
451
+ /**
452
+ * CLI entry point.
453
+ *
454
+ * MVP: prints usage and a "relay attach required" notice. Real relay wiring
455
+ * (resolve CDP URL, attach, run, close) is tracked in issues #645 / #646.
456
+ */
457
+ async function main$1(argv = process.argv.slice(2)) {
458
+ let parsed;
459
+ try {
460
+ parsed = parseArgs({
461
+ args: argv,
462
+ options: {
463
+ help: {
464
+ type: "boolean",
465
+ short: "h"
466
+ },
467
+ timeout: { type: "string" }
468
+ },
469
+ allowPositionals: true
470
+ });
471
+ } catch (e) {
472
+ process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
473
+ process.exitCode = 1;
474
+ return;
475
+ }
476
+ if (parsed.values.help || argv.length === 0) {
477
+ process.stdout.write(USAGE);
478
+ return;
479
+ }
480
+ const files = await discoverTestFiles(parsed.positionals, process.cwd());
481
+ if (files.length === 0) {
482
+ process.stderr.write(`devtools-test: no test files matched ${parsed.positionals.join(", ")}\n`);
483
+ process.exitCode = 1;
484
+ return;
485
+ }
486
+ process.stderr.write(`devtools-test: matched ${files.length} test file(s), but direct CLI relay attach is not yet wired.\n Use the devtools-mcp server (\`devtools-mcp\`) to start a debug session,\n then the \`run_tests\` MCP tool to run these files against the attached page.\n Direct CLI relay wiring is tracked in issue #645.\n`);
487
+ process.exitCode = 1;
488
+ }
489
+ if (import.meta.url === new URL(process.argv[1], "file://").href) main$1().catch((e) => {
490
+ process.stderr.write(`devtools-test: unexpected error: ${e instanceof Error ? e.message : String(e)}\n`);
491
+ process.exitCode = 1;
492
+ });
493
+ //#endregion
122
494
  //#region src/mcp/ait-chii-source.ts
123
495
  function isObject$4(value) {
124
496
  return typeof value === "object" && value !== null;
@@ -213,7 +585,11 @@ const ALLOWED_KEYS = new Set([
213
585
  "errorKind",
214
586
  "reason",
215
587
  "prevTargetId",
216
- "mode"
588
+ "mode",
589
+ "fileCount",
590
+ "passed",
591
+ "failed",
592
+ "skipped"
217
593
  ]);
218
594
  /**
219
595
  * Patterns that match secret values.
@@ -4033,6 +4409,34 @@ const DEBUG_TOOL_DEFINITIONS = [
4033
4409
  required: []
4034
4410
  },
4035
4411
  availableIn: "both"
4412
+ },
4413
+ {
4414
+ name: "run_tests",
4415
+ description: "Runs mini-app test files on the attached page over CDP (Runtime.evaluate). Each matched file is bundled with esbuild (SDK imports redirected to the live mock/SDK), injected into the attached WebView, and executed; returns per-file results plus flattened totals (passed/failed/skipped/total). Requires an attached page — call list_pages first to confirm one is attached. Files run SEQUENTIALLY (single-attach model: the relay/local target serves one page), and one run_tests call runs at a time (a concurrent call is rejected). Test verification (assert/snapshot) is delegated to the in-page Vitest runtime; this tool is the transport + report. The per-file results array is the progress record — on partial failure you see exactly which files passed/failed/timed-out. In a relay-live session this is a state-mutating injection and is blocked unless confirm=true (ignored in mock/local/relay-dev/relay-mobile). debug-mode only — dev-mode (--mode=dev) has no CDP. Tier C (both mock/local and relay). Use the test-runner CLI (devtools-test) for the same run outside MCP.",
4416
+ inputSchema: {
4417
+ type: "object",
4418
+ properties: {
4419
+ files: {
4420
+ type: "array",
4421
+ items: { type: "string" },
4422
+ description: "Glob patterns or file paths to run (e.g. [\"src/**/*.phone.test.ts\"]). Resolved relative to projectRoot when given, else the daemon cwd. Required, non-empty."
4423
+ },
4424
+ projectRoot: {
4425
+ type: "string",
4426
+ description: "Absolute path to the mini-app project root used as the glob base. Pass this because the daemon's cwd is fixed at launch. Optional."
4427
+ },
4428
+ timeout_ms: {
4429
+ type: "number",
4430
+ description: "Per-file evaluate timeout in ms (default 30000, range 1000–600000). Out-of-range/invalid values fall back to the default."
4431
+ },
4432
+ confirm: {
4433
+ type: "boolean",
4434
+ description: "Required (true) to run in a relay-live session — test injection mutates page state. Ignored in mock/local/relay-dev/relay-mobile sessions."
4435
+ }
4436
+ },
4437
+ required: ["files"]
4438
+ },
4439
+ availableIn: "both"
4036
4440
  }
4037
4441
  ];
4038
4442
  const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
@@ -4855,7 +5259,7 @@ async function readMcpSdkVersion() {
4855
5259
  * some test environments that skip the build step).
4856
5260
  */
4857
5261
  function readDevtoolsVersion() {
4858
- return "0.1.106";
5262
+ return "0.1.108";
4859
5263
  }
4860
5264
  /**
4861
5265
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -5385,6 +5789,16 @@ function isRelayMode(mode) {
5385
5789
  return mode === "relay-sandbox" || mode === "relay-staging" || mode === "relay-live";
5386
5790
  }
5387
5791
  /**
5792
+ * Single-attach guard for `run_tests` (#646). Two concurrent runs injecting
5793
+ * into the same single-attach page would interleave `Runtime.evaluate` and
5794
+ * corrupt each other's `globalThis.__testBundle`. The model is "reject the
5795
+ * second", not "queue" — a module-level flag is process-wide, which matches the
5796
+ * single physical attached page (only one target is live at a time). The
5797
+ * entry-time `conn` snapshot ensures a run finishes on the connection it started
5798
+ * on even if `router.active` flips mid-run.
5799
+ */
5800
+ let runTestsInFlight = false;
5801
+ /**
5388
5802
  * Waits for the first target matching `filterFn` to attach, using the
5389
5803
  * event-driven `waitForFirstTarget()` when the connection supports it
5390
5804
  * (interface-optional member, present on `ChiiCdpConnection`), or falling
@@ -5447,7 +5861,7 @@ function createDebugServer(deps) {
5447
5861
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
5448
5862
  const server = new Server({
5449
5863
  name: "ait-debug",
5450
- version: "0.1.106"
5864
+ version: "0.1.108"
5451
5865
  }, { capabilities: { tools: { listChanged: true } } });
5452
5866
  server.setRequestHandler(ListToolsRequestSchema, () => {
5453
5867
  const conn = router.active;
@@ -5535,7 +5949,7 @@ function createDebugServer(deps) {
5535
5949
  const buildProjectRoot = typeof rawBuildProjectRoot === "string" ? rawBuildProjectRoot : void 0;
5536
5950
  let tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
5537
5951
  if (tunnelHttpUrl === "" && buildProjectRoot !== void 0) {
5538
- const { readRelayUrls } = await import("../relay-url-store-BPeUZsiY.js");
5952
+ const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
5539
5953
  tunnelHttpUrl = (await readRelayUrls({ projectRoot: buildProjectRoot }))?.tunnelBaseUrl ?? "";
5540
5954
  }
5541
5955
  if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 직접 전달하세요.");
@@ -5950,6 +6364,35 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
5950
6364
  if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
5951
6365
  return envelopeResult$1(sdkResult, name, env, conn.listTargets().length > 0);
5952
6366
  }
6367
+ case "run_tests": {
6368
+ const rawFiles = request.params.arguments?.files;
6369
+ if (!Array.isArray(rawFiles) || rawFiles.length === 0) return mcpError("run_tests: files 인자가 비어 있습니다. 실행할 테스트 파일 glob을 배열로 전달하세요.");
6370
+ const patterns = rawFiles.filter((p) => typeof p === "string" && p !== "");
6371
+ if (patterns.length === 0) return mcpError("run_tests: files 인자에 유효한 문자열 glob이 없습니다.");
6372
+ const rawRoot = request.params.arguments?.projectRoot;
6373
+ const projectRoot = typeof rawRoot === "string" ? rawRoot : process.cwd();
6374
+ const rawTimeout = request.params.arguments?.timeout_ms;
6375
+ const timeoutMs = typeof rawTimeout === "number" && rawTimeout >= 1e3 && rawTimeout <= 6e5 ? rawTimeout : void 0;
6376
+ if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("run_tests");
6377
+ if (runTestsInFlight) return mcpError("run_tests: 이미 다른 테스트 실행이 진행 중입니다 (single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.");
6378
+ runTestsInFlight = true;
6379
+ try {
6380
+ const files = await discoverTestFiles(patterns, projectRoot);
6381
+ if (files.length === 0) return mcpError(`run_tests: 매칭된 테스트 파일이 없습니다 (patterns: ${patterns.join(", ")}).`);
6382
+ if (conn.listTargets().length === 0) return pageMissingError("run_tests");
6383
+ logInfo("run_tests.start", { fileCount: files.length });
6384
+ const report = await runWithConnection(conn, files, { timeoutMs });
6385
+ logInfo("run_tests.done", {
6386
+ passed: report.totals.passed,
6387
+ failed: report.totals.failed,
6388
+ skipped: report.totals.skipped
6389
+ });
6390
+ const runAttached = conn.listTargets().length > 0;
6391
+ return envelopeResult$1(toRunTestsResult(report), name, env, runAttached);
6392
+ } finally {
6393
+ runTestsInFlight = false;
6394
+ }
6395
+ }
5953
6396
  default: return unknownTool(name);
5954
6397
  }
5955
6398
  } catch (err) {
@@ -6035,6 +6478,30 @@ function envelopeResult$1(value, tool, env, attached) {
6035
6478
  text: JSON.stringify(wrapped, null, 2)
6036
6479
  }] };
6037
6480
  }
6481
+ /**
6482
+ * Maps a {@link RelayRunReport} to a flat, agent-friendly object for the
6483
+ * `run_tests` tool result. SECRET-HANDLING: a RelayRunReport carries only
6484
+ * startedAt/duration/totals and per-file `{file, result}` — file paths are
6485
+ * surfaced (allowed), relay wss/TOTP URLs never appear in it. No stripping
6486
+ * needed; this only reshapes for readability.
6487
+ */
6488
+ function toRunTestsResult(report) {
6489
+ return {
6490
+ startedAt: report.startedAt,
6491
+ duration: report.duration,
6492
+ totals: report.totals,
6493
+ files: report.files.map((f) => "error" in f.result ? {
6494
+ file: f.file,
6495
+ error: f.result.error
6496
+ } : {
6497
+ file: f.file,
6498
+ passed: f.result.passed,
6499
+ failed: f.result.failed,
6500
+ skipped: f.result.skipped,
6501
+ tests: f.result.tests
6502
+ })
6503
+ };
6504
+ }
6038
6505
  function unknownTool(name) {
6039
6506
  return mcpError(`알 수 없는 tool: ${name}`);
6040
6507
  }
@@ -6289,7 +6756,7 @@ async function readRelayLocalUrl(env = process.env, projectRoot) {
6289
6756
  const envValue = (env.AIT_RELAY_LOCAL_URL ?? "").trim();
6290
6757
  if (envValue !== "") return envValue;
6291
6758
  if (projectRoot !== void 0) try {
6292
- const { readRelayUrls } = await import("../relay-url-store-BPeUZsiY.js");
6759
+ const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
6293
6760
  const stored = await readRelayUrls({ projectRoot });
6294
6761
  if (stored?.relayLocalUrl) return stored.relayLocalUrl;
6295
6762
  } catch {}
@@ -6343,7 +6810,7 @@ async function readMobileRelayBaseUrl(env = process.env, projectRoot) {
6343
6810
  const envValue = typeof raw === "string" ? raw.trim() : "";
6344
6811
  if (envValue !== "") return envValue;
6345
6812
  if (projectRoot !== void 0) {
6346
- const { readRelayUrls } = await import("../relay-url-store-BPeUZsiY.js");
6813
+ const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
6347
6814
  const stored = await readRelayUrls({ projectRoot });
6348
6815
  if (stored?.relayBaseUrl !== void 0) return stored.relayBaseUrl;
6349
6816
  }
@@ -7456,6 +7923,34 @@ const DEV_TOOL_DEFINITIONS = [
7456
7923
  required: []
7457
7924
  },
7458
7925
  availableIn: "both"
7926
+ },
7927
+ {
7928
+ name: "run_tests",
7929
+ description: "Runs mini-app test files on the attached page over CDP (Runtime.evaluate). NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
7930
+ inputSchema: {
7931
+ type: "object",
7932
+ properties: {
7933
+ files: {
7934
+ type: "array",
7935
+ items: { type: "string" },
7936
+ description: "Glob patterns or file paths to run."
7937
+ },
7938
+ projectRoot: {
7939
+ type: "string",
7940
+ description: "Glob base directory."
7941
+ },
7942
+ timeout_ms: {
7943
+ type: "number",
7944
+ description: "Per-file evaluate timeout in ms."
7945
+ },
7946
+ confirm: {
7947
+ type: "boolean",
7948
+ description: "Required in relay-live sessions."
7949
+ }
7950
+ },
7951
+ required: ["files"]
7952
+ },
7953
+ availableIn: "both"
7459
7954
  }
7460
7955
  ];
7461
7956
  /** All tool names served in dev-mode (including tier-filter stubs). */
@@ -7468,7 +7963,8 @@ const CDP_ONLY_TOOL_NAMES = new Set([
7468
7963
  "take_snapshot",
7469
7964
  "list_console_messages",
7470
7965
  "list_network_requests",
7471
- "list_exceptions"
7966
+ "list_exceptions",
7967
+ "run_tests"
7472
7968
  ]);
7473
7969
  /**
7474
7970
  * Tier B tools — relay-only per RFC #277.
@@ -7585,7 +8081,7 @@ function createDevServer(deps = {}) {
7585
8081
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
7586
8082
  const server = new Server({
7587
8083
  name: "ait-devtools",
7588
- version: "0.1.106"
8084
+ version: "0.1.108"
7589
8085
  }, { capabilities: { tools: {} } });
7590
8086
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
7591
8087
  server.setRequestHandler(CallToolRequestSchema, async (request) => {