@ait-co/devtools 0.1.107 → 0.1.109

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 (54) hide show
  1. package/dist/bundle-KFs4t-wc.d.ts +96 -0
  2. package/dist/bundle-KFs4t-wc.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 +651 -9
  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 +59 -2
  17. package/dist/mcp/server.js.map +1 -1
  18. package/dist/panel/index.js +1 -1
  19. package/dist/pool-CuVMzWGB.d.ts +14577 -0
  20. package/dist/pool-CuVMzWGB.d.ts.map +1 -0
  21. package/dist/relay-worker-xxanNQGs.d.ts +74 -0
  22. package/dist/relay-worker-xxanNQGs.d.ts.map +1 -0
  23. package/dist/runtime-Wi5d6Ywz.d.ts +50 -0
  24. package/dist/runtime-Wi5d6Ywz.d.ts.map +1 -0
  25. package/dist/test-runner/bundle.d.ts +2 -0
  26. package/dist/test-runner/bundle.js +232 -0
  27. package/dist/test-runner/bundle.js.map +1 -0
  28. package/dist/test-runner/cli.d.ts +462 -0
  29. package/dist/test-runner/cli.d.ts.map +1 -0
  30. package/dist/test-runner/cli.js +516 -0
  31. package/dist/test-runner/cli.js.map +1 -0
  32. package/dist/test-runner/config.d.ts +80 -0
  33. package/dist/test-runner/config.d.ts.map +1 -0
  34. package/dist/test-runner/config.js +54 -0
  35. package/dist/test-runner/config.js.map +1 -0
  36. package/dist/test-runner/pool.d.ts +2 -0
  37. package/dist/test-runner/pool.js +136 -0
  38. package/dist/test-runner/pool.js.map +1 -0
  39. package/dist/test-runner/relay-worker.d.ts +2 -0
  40. package/dist/test-runner/relay-worker.js +96 -0
  41. package/dist/test-runner/relay-worker.js.map +1 -0
  42. package/dist/test-runner/rpc.d.ts +53 -0
  43. package/dist/test-runner/rpc.d.ts.map +1 -0
  44. package/dist/test-runner/rpc.js +78 -0
  45. package/dist/test-runner/rpc.js.map +1 -0
  46. package/dist/test-runner/task-graph.d.ts +38 -0
  47. package/dist/test-runner/task-graph.d.ts.map +1 -0
  48. package/dist/test-runner/task-graph.js +182 -0
  49. package/dist/test-runner/task-graph.js.map +1 -0
  50. package/dist/unplugin/index.d.cts +13 -32
  51. package/dist/unplugin/index.d.cts.map +1 -1
  52. package/dist/unplugin/index.d.ts +13 -32
  53. package/dist/unplugin/index.d.ts.map +1 -1
  54. package/package.json +13 -3
package/dist/mcp/cli.js CHANGED
@@ -2,12 +2,17 @@
2
2
  import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "../totp-WY6l0ysP.js";
3
3
  import { t as loadRelaySecretReadOnly } from "../relay-secret-store-DhzAnnj-.js";
4
4
  import { createRequire } from "node:module";
5
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
+ import { accessSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { argv } from "node:process";
7
7
  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 * as fs from "node:fs/promises";
13
+ import { glob } from "node:fs/promises";
14
+ import * as path from "node:path";
15
+ import { isAbsolute, join, resolve } from "node:path";
11
16
  import { EventEmitter } from "node:events";
12
17
  import { WebSocket, WebSocketServer } from "ws";
13
18
  import { randomBytes } from "node:crypto";
@@ -15,7 +20,6 @@ import { createServer } from "node:http";
15
20
  import { spawn } from "node:child_process";
16
21
  import net from "node:net";
17
22
  import { homedir, platform } from "node:os";
18
- import { join } from "node:path";
19
23
  import { Tunnel, bin, install } from "cloudflared";
20
24
  //#region \0rolldown/runtime.js
21
25
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
@@ -119,6 +123,511 @@ function startMaxAgeWatchdog(onExpired, opts = {}) {
119
123
  } };
120
124
  }
121
125
  //#endregion
126
+ //#region src/test-runner/discover.ts
127
+ /**
128
+ * Test-file discovery shared by the `devtools-test` CLI and the `run_tests`
129
+ * MCP tool, so both expand glob patterns with identical semantics.
130
+ *
131
+ * Uses Node's built-in `fs/promises` `glob` (Node 22+) — no extra dependency,
132
+ * which keeps the MCP daemon install graph lean (a plain glob lib would land in
133
+ * the `npx … devtools-mcp` path for no benefit).
134
+ *
135
+ * Pure Node IO only (`node:fs/promises` + `node:path`) — react-free, so it is
136
+ * safe to import from the MCP daemon graph.
137
+ */
138
+ /**
139
+ * Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of
140
+ * ABSOLUTE test file paths, resolved relative to `cwd`.
141
+ *
142
+ * A plain (non-glob) path passes through when it matches a real file; a glob
143
+ * expands against `cwd`. Absolute matches are kept as-is; relative matches are
144
+ * resolved against `cwd`. `bundleTestFile` requires an absolute path, so the
145
+ * absolute output feeds it directly.
146
+ *
147
+ * @param patterns Glob patterns or file paths (e.g. `['src/**\/*.phone.test.ts']`).
148
+ * @param cwd Base directory for relative patterns/results.
149
+ * @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.
150
+ */
151
+ async function discoverTestFiles(patterns, cwd) {
152
+ const out = /* @__PURE__ */ new Set();
153
+ for await (const match of glob(patterns, { cwd })) out.add(isAbsolute(match) ? match : resolve(cwd, match));
154
+ return [...out].sort();
155
+ }
156
+ //#endregion
157
+ //#region src/test-runner/bundle.ts
158
+ /**
159
+ * esbuild-based bundler for user test files.
160
+ *
161
+ * Bundles a single test file into a self-contained IIFE string that can be
162
+ * injected into a WebView via `Runtime.evaluate`. The bundle includes the
163
+ * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
164
+ * the `runTestModule(factory)` entry point.
165
+ *
166
+ * ## How the wiring works
167
+ *
168
+ * The bundle exposes two exports on `globalThis.__testBundle`:
169
+ * - `runTestModule` — the runtime's entry function.
170
+ * - `__userFactory` — an async function whose body is the user's top-level
171
+ * test registration code (describe/it/test calls).
172
+ *
173
+ * The Node-side RPC (`rpc.ts`) calls:
174
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
175
+ *
176
+ * `runTestModule` then installs `describe/it/test/expect` as globals, invokes
177
+ * the factory (which registers all tests), runs them, and returns a `RunReport`.
178
+ *
179
+ * ## Why a factory wrapper is needed
180
+ *
181
+ * Naively adding the runtime to `entryPoints` and bundling the user file would
182
+ * fail for two reasons:
183
+ * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
184
+ * scope. The user's top-level `describe(...)` calls expect them as globals —
185
+ * they are not globals until `runTestModule` installs them.
186
+ * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
187
+ * time, before the RPC layer calls `runTestModule` to reset state and start
188
+ * the test clock.
189
+ *
190
+ * The factory approach solves both: the user's registration code is deferred
191
+ * into a function that `runTestModule` calls AFTER installing the globals.
192
+ *
193
+ * ## Factory extraction algorithm
194
+ *
195
+ * The `userFactoryPlugin` reads the user file and splits lines into:
196
+ * - **top-level**: `import …` and re-export lines — kept at module scope
197
+ * (the only valid position for static `import` in ESM).
198
+ * - **body**: all other statements — moved into the body of the exported
199
+ * `__userFactory` async function.
200
+ *
201
+ * esbuild processes the re-generated module, following each static import
202
+ * through the normal dependency graph (including the SDK-redirect plugin).
203
+ *
204
+ * ## SDK redirect
205
+ *
206
+ * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
207
+ * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
208
+ * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
209
+ *
210
+ * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
211
+ */
212
+ /**
213
+ * Matches the bare SDK package and any sub-path import
214
+ * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
215
+ * Built from {@link SDK_PACKAGE} so the package name has a single source.
216
+ */
217
+ const SDK_IMPORT_FILTER = new RegExp(`^${"@apps-in-toss/web-framework".replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
218
+ /**
219
+ * esbuild plugin that intercepts SDK imports and redirects them to the
220
+ * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
221
+ *
222
+ * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
223
+ * esbuild resolves it to a virtual module that re-exports all named exports
224
+ * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
225
+ * be available in the test environment) while still making named imports work.
226
+ *
227
+ * If `window.__sdk` is absent (non-dog-food build), every access throws a
228
+ * descriptive error rather than returning `undefined` silently.
229
+ */
230
+ function sdkRedirectPlugin() {
231
+ return {
232
+ name: "sdk-redirect",
233
+ setup(build) {
234
+ build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
235
+ path: args.path,
236
+ namespace: "sdk-redirect"
237
+ }));
238
+ build.onLoad({
239
+ filter: /.*/,
240
+ namespace: "sdk-redirect"
241
+ }, () => ({
242
+ contents: `
243
+ var __proxy = (typeof window !== 'undefined' && window.__sdk)
244
+ ? window.__sdk
245
+ : new Proxy({}, {
246
+ get: function(_t, p) {
247
+ throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
248
+ }
249
+ });
250
+ module.exports = __proxy;
251
+ `,
252
+ loader: "js"
253
+ }));
254
+ }
255
+ };
256
+ }
257
+ /**
258
+ * esbuild plugin that transforms the user test file into a module that exports
259
+ * an async `__userFactory` function. The factory defers the user's top-level
260
+ * test registration code (describe/it/test calls) so it only runs when
261
+ * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
262
+ * installed describe/it/test/expect as globals.
263
+ *
264
+ * Algorithm:
265
+ * - Lines matching import declarations or re-export statements are kept at
266
+ * module top-level (the only valid ESM position for static `import`).
267
+ * - All other lines (describe/it/test calls, local declarations, etc.) are
268
+ * moved into the body of the exported async factory function.
269
+ *
270
+ * This preserves SDK import resolution (the sdk-redirect plugin processes
271
+ * top-level imports normally) while deferring test registration to the factory.
272
+ */
273
+ function userFactoryPlugin(absPath) {
274
+ const NAMESPACE = "user-test-factory";
275
+ return {
276
+ name: "user-test-factory",
277
+ setup(build) {
278
+ build.onResolve({ filter: /^user-test-factory$/ }, () => ({
279
+ path: absPath,
280
+ namespace: NAMESPACE
281
+ }));
282
+ build.onLoad({
283
+ filter: /.*/,
284
+ namespace: NAMESPACE
285
+ }, async (args) => {
286
+ const lines = (await fs.readFile(args.path, "utf8")).split("\n");
287
+ const topLevelLines = [];
288
+ const bodyLines = [];
289
+ const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
290
+ for (const line of lines) {
291
+ const trimmed = line.trimStart();
292
+ const indent = line.slice(0, line.length - trimmed.length);
293
+ if (trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"")) topLevelLines.push(line);
294
+ else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
295
+ else topLevelLines.push(line);
296
+ else bodyLines.push(line);
297
+ }
298
+ return {
299
+ contents: [
300
+ ...topLevelLines,
301
+ "",
302
+ "// biome-ignore lint: generated factory wrapper",
303
+ "export default async function __userFactory(): Promise<void> {",
304
+ ...bodyLines.map((l) => ` ${l}`),
305
+ "}"
306
+ ].join("\n"),
307
+ loader: "ts",
308
+ resolveDir: path.dirname(absPath)
309
+ };
310
+ });
311
+ }
312
+ };
313
+ }
314
+ /**
315
+ * Returns the absolute path to the co-located runtime module.
316
+ *
317
+ * In the source tree (running via tsx / ts-node) the file is `runtime.ts`.
318
+ * After `tsdown` compiles to `dist/test-runner/`, it becomes `runtime.js`.
319
+ * We try both extensions to support both environments.
320
+ */
321
+ function getRuntimePath() {
322
+ const dir = path.dirname(fileURLToPath(import.meta.url));
323
+ for (const ext of [".ts", ".js"]) {
324
+ const candidate = path.join(dir, `runtime${ext}`);
325
+ try {
326
+ accessSync(candidate);
327
+ return candidate;
328
+ } catch {}
329
+ }
330
+ return path.join(dir, "runtime.js");
331
+ }
332
+ /**
333
+ * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
334
+ *
335
+ * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
336
+ * - `runTestModule` — the runtime entry (from `runtime.ts`).
337
+ * - `__userFactory` — an async function wrapping the user's test registration
338
+ * code so it runs AFTER `runTestModule` installs the globals.
339
+ *
340
+ * Callers (rpc.ts) invoke:
341
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
342
+ *
343
+ * @param absPath - Absolute path to the user test file.
344
+ * @param opts - Optional bundling overrides.
345
+ */
346
+ async function bundleTestFile(absPath, opts) {
347
+ const globalName = opts?.globalName ?? "__testBundle";
348
+ const extraExternals = opts?.extraExternals ?? [];
349
+ const esbuild = await import("esbuild");
350
+ const runtimePath = getRuntimePath();
351
+ const wrapperContent = [
352
+ `import { runTestModule } from ${JSON.stringify(runtimePath)};`,
353
+ `import __userFactory from "user-test-factory";`,
354
+ `export { runTestModule, __userFactory };`
355
+ ].join("\n");
356
+ const result = await esbuild.build({
357
+ stdin: {
358
+ contents: wrapperContent,
359
+ loader: "ts",
360
+ resolveDir: path.dirname(absPath)
361
+ },
362
+ bundle: true,
363
+ format: "iife",
364
+ globalName,
365
+ platform: "browser",
366
+ target: "es2022",
367
+ write: false,
368
+ plugins: [userFactoryPlugin(absPath), sdkRedirectPlugin()],
369
+ external: extraExternals,
370
+ treeShaking: true,
371
+ footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
372
+ });
373
+ const warnings = result.warnings.map((w) => `${path.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
374
+ const outputFile = result.outputFiles?.[0];
375
+ if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
376
+ return {
377
+ code: outputFile.text,
378
+ warnings
379
+ };
380
+ }
381
+ //#endregion
382
+ //#region src/test-runner/rpc.ts
383
+ /** Maximum milliseconds to wait for a single evaluate round-trip. */
384
+ const DEFAULT_TIMEOUT_MS = 3e4;
385
+ /**
386
+ * Wraps bundle code in a self-executing IIFE that:
387
+ * 1. Evaluates the bundle (registering describe/it/test).
388
+ * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
389
+ * 3. Returns a JSON-serialised `RunReport` string.
390
+ *
391
+ * The double-serialisation (RunReport → JSON string → returnByValue string)
392
+ * is intentional: CDP `returnByValue` reliably transports strings; deeply
393
+ * nested objects can lose fidelity across the Chii relay.
394
+ *
395
+ * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
396
+ */
397
+ function buildRunTestsExpression(bundleCode) {
398
+ 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)}); }})()`;
399
+ }
400
+ /**
401
+ * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
402
+ * evaluate call into a typed `RpcRunResult`.
403
+ *
404
+ * Throws only on parse failure — an `ok:false` envelope is a normal result.
405
+ *
406
+ * SECRET-HANDLING: `rawValue` is not included in error messages.
407
+ */
408
+ function parseRunTestsResult(rawValue) {
409
+ if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
410
+ let parsed;
411
+ try {
412
+ parsed = JSON.parse(rawValue);
413
+ } catch {
414
+ throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
415
+ }
416
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
417
+ const obj = parsed;
418
+ if (obj.ok === true) return {
419
+ ok: true,
420
+ report: obj.value
421
+ };
422
+ if (obj.ok === false) return {
423
+ ok: false,
424
+ error: typeof obj.error === "string" ? obj.error : String(obj.error)
425
+ };
426
+ throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
427
+ }
428
+ /**
429
+ * Injects `bundleCode` into the attached page and awaits test execution.
430
+ *
431
+ * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
432
+ * async IIFE to settle. The 30-second CDP command timeout covers even
433
+ * long-running test suites; split into smaller files if you hit it.
434
+ *
435
+ * @param connection - Active CDP connection (relay or local).
436
+ * @param bundleCode - IIFE bundle string from `bundleTestFile`.
437
+ * @param timeoutMs - Override the default 30 s timeout.
438
+ *
439
+ * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
440
+ */
441
+ async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
442
+ const expression = buildRunTestsExpression(bundleCode);
443
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`rpc: evaluate timed out after ${timeoutMs}ms`)), timeoutMs));
444
+ const evalPromise = connection.send("Runtime.evaluate", {
445
+ expression,
446
+ returnByValue: true,
447
+ awaitPromise: true
448
+ });
449
+ const cdpResult = await Promise.race([evalPromise, timeoutPromise]);
450
+ if (cdpResult.exceptionDetails) {
451
+ const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
452
+ throw new Error(`rpc.injectAndRunBundle: ${msg}`);
453
+ }
454
+ return parseRunTestsResult(cdpResult.result.value);
455
+ }
456
+ //#endregion
457
+ //#region src/test-runner/relay-worker.ts
458
+ /**
459
+ * Runs all `files` sequentially over the given CDP `connection`.
460
+ *
461
+ * For each file:
462
+ * 1. Bundle with esbuild (includes SDK shim + runtime).
463
+ * 2. Inject into the attached page via `Runtime.evaluate`.
464
+ * 3. Await the `RunReport` JSON response.
465
+ * 4. Accumulate results.
466
+ *
467
+ * Returns a `RelayRunReport` with per-file results and flattened totals.
468
+ *
469
+ * This function does NOT open or manage the relay connection — the caller
470
+ * is responsible for attaching and closing it.
471
+ *
472
+ * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
473
+ * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
474
+ *
475
+ * @param connection - Active CDP connection (relay or local kind).
476
+ * @param files - Absolute paths to test files, run in order.
477
+ * @param opts - Optional per-run overrides.
478
+ */
479
+ async function runTestFilesOverRelay(connection, files, opts) {
480
+ const wallStart = Date.now();
481
+ const startedAt = new Date(wallStart).toISOString();
482
+ const fileResults = [];
483
+ for (const file of files) {
484
+ let fileEntry;
485
+ try {
486
+ const { code } = await bundleTestFile(file, opts?.bundleOptions);
487
+ const rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
488
+ if (rpcResult.ok) fileEntry = {
489
+ file,
490
+ result: rpcResult.report
491
+ };
492
+ else fileEntry = {
493
+ file,
494
+ result: { error: rpcResult.error }
495
+ };
496
+ } catch (e) {
497
+ fileEntry = {
498
+ file,
499
+ result: { error: e instanceof Error ? e.message : String(e) }
500
+ };
501
+ }
502
+ fileResults.push(fileEntry);
503
+ }
504
+ const totals = fileResults.reduce((acc, { result }) => {
505
+ if ("error" in result) {
506
+ acc.failed += 1;
507
+ acc.total += 1;
508
+ } else {
509
+ acc.passed += result.passed;
510
+ acc.failed += result.failed;
511
+ acc.skipped += result.skipped;
512
+ acc.total += result.passed + result.failed + result.skipped;
513
+ }
514
+ return acc;
515
+ }, {
516
+ passed: 0,
517
+ failed: 0,
518
+ skipped: 0,
519
+ total: 0
520
+ });
521
+ return {
522
+ startedAt,
523
+ duration: Date.now() - wallStart,
524
+ files: fileResults,
525
+ totals
526
+ };
527
+ }
528
+ //#endregion
529
+ //#region src/test-runner/cli.ts
530
+ /**
531
+ * `devtools-test` CLI.
532
+ *
533
+ * Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)
534
+ * and exposes `runWithConnection` — the pure run core that bundles, injects, and
535
+ * collects each file over a CDP connection. Today the run path that has a live
536
+ * connection is the `run_tests` MCP tool (it runs these files against the
537
+ * daemon's attached page); the CLI's own standalone relay attach (resolve CDP
538
+ * URL → attach → run → close) is not wired yet, so `main()` resolves the matched
539
+ * files and points the operator at the MCP tool.
540
+ *
541
+ * NOTE: no shebang in this source file — the tsdown entry's `banner` option
542
+ * injects `#!/usr/bin/env node` into the compiled output (same pattern as
543
+ * `src/mcp/cli.ts`).
544
+ */
545
+ const USAGE = `
546
+ devtools-test — run mini-app tests on a real device WebView over the CDP relay
547
+
548
+ USAGE
549
+ devtools-test <glob> [<glob> ...] [options]
550
+
551
+ OPTIONS
552
+ --timeout <ms> Per-file evaluate timeout in ms (default: 30000)
553
+ --help, -h Show this help message
554
+
555
+ DESCRIPTION
556
+ Bundles each matched test file with esbuild (SDK imports redirected to
557
+ window.__sdk), injects the bundle into the attached WebView via
558
+ Runtime.evaluate, and returns a RunReport.
559
+
560
+ A live CDP relay connection must be active before running tests. Use the
561
+ \`run_tests\` MCP tool (via \`devtools-mcp\` / \`/ait debug\`) to run these files
562
+ against an attached page — the CLI's own standalone relay attach is not wired
563
+ yet (it currently resolves the matched files and defers to that tool).
564
+
565
+ EXAMPLE
566
+ devtools-test 'src/**/*.phone.test.ts' --timeout 60000
567
+
568
+ `.trimStart();
569
+ /**
570
+ * Runs `files` over `connection` and returns the aggregate report.
571
+ * This pure function is the testable core of the CLI (and is what the
572
+ * `run_tests` MCP tool calls against the daemon's attached connection); it is
573
+ * separate from `main()` so tests can call it without spawning a subprocess.
574
+ *
575
+ * A standalone CLI relay attach/detach lifecycle (connect via Chii relay URL,
576
+ * `enableDomains`, run, then close) is not wired into `main()` yet.
577
+ */
578
+ async function runWithConnection(connection, files, opts) {
579
+ const report = await runTestFilesOverRelay(connection, files, opts);
580
+ if (opts?.printSummary) {
581
+ const { totals } = report;
582
+ process.stdout.write(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${report.duration}ms)\n`);
583
+ }
584
+ return report;
585
+ }
586
+ /**
587
+ * CLI entry point.
588
+ *
589
+ * Resolves the matched test files and prints a "relay attach required" notice:
590
+ * the CLI's own standalone relay attach (resolve CDP URL, attach, run, close) is
591
+ * not wired yet, so today these files run via the `run_tests` MCP tool against
592
+ * the daemon's attached page.
593
+ */
594
+ async function main$1(argv = process.argv.slice(2)) {
595
+ let parsed;
596
+ try {
597
+ parsed = parseArgs({
598
+ args: argv,
599
+ options: {
600
+ help: {
601
+ type: "boolean",
602
+ short: "h"
603
+ },
604
+ timeout: { type: "string" }
605
+ },
606
+ allowPositionals: true
607
+ });
608
+ } catch (e) {
609
+ process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
610
+ process.exitCode = 1;
611
+ return;
612
+ }
613
+ if (parsed.values.help || argv.length === 0) {
614
+ process.stdout.write(USAGE);
615
+ return;
616
+ }
617
+ const files = await discoverTestFiles(parsed.positionals, process.cwd());
618
+ if (files.length === 0) {
619
+ process.stderr.write(`devtools-test: no test files matched ${parsed.positionals.join(", ")}\n`);
620
+ process.exitCode = 1;
621
+ return;
622
+ }
623
+ 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`);
624
+ process.exitCode = 1;
625
+ }
626
+ if (import.meta.url === new URL(process.argv[1], "file://").href) main$1().catch((e) => {
627
+ process.stderr.write(`devtools-test: unexpected error: ${e instanceof Error ? e.message : String(e)}\n`);
628
+ process.exitCode = 1;
629
+ });
630
+ //#endregion
122
631
  //#region src/mcp/ait-chii-source.ts
123
632
  function isObject$4(value) {
124
633
  return typeof value === "object" && value !== null;
@@ -213,7 +722,11 @@ const ALLOWED_KEYS = new Set([
213
722
  "errorKind",
214
723
  "reason",
215
724
  "prevTargetId",
216
- "mode"
725
+ "mode",
726
+ "fileCount",
727
+ "passed",
728
+ "failed",
729
+ "skipped"
217
730
  ]);
218
731
  /**
219
732
  * Patterns that match secret values.
@@ -4033,6 +4546,34 @@ const DEBUG_TOOL_DEFINITIONS = [
4033
4546
  required: []
4034
4547
  },
4035
4548
  availableIn: "both"
4549
+ },
4550
+ {
4551
+ name: "run_tests",
4552
+ 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 (confirm is ignored in every non-live session: mock/local, relay-dev, relay-mobile). debug-mode only — dev-mode (--mode=dev) has no CDP. Tier C (both mock/local and relay). The devtools-test CLI shares this run core and file discovery, but its standalone relay attach is not wired yet — run via this tool for now.",
4553
+ inputSchema: {
4554
+ type: "object",
4555
+ properties: {
4556
+ files: {
4557
+ type: "array",
4558
+ items: { type: "string" },
4559
+ 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."
4560
+ },
4561
+ projectRoot: {
4562
+ type: "string",
4563
+ 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."
4564
+ },
4565
+ timeout_ms: {
4566
+ type: "number",
4567
+ description: "Per-file evaluate timeout in ms (default 30000, range 1000–600000). Out-of-range/invalid values fall back to the default."
4568
+ },
4569
+ confirm: {
4570
+ type: "boolean",
4571
+ description: "Required (true) to run in a relay-live session — test injection mutates page state. Ignored in every non-live session (mock/local, relay-dev, relay-mobile)."
4572
+ }
4573
+ },
4574
+ required: ["files"]
4575
+ },
4576
+ availableIn: "both"
4036
4577
  }
4037
4578
  ];
4038
4579
  const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
@@ -4855,7 +5396,7 @@ async function readMcpSdkVersion() {
4855
5396
  * some test environments that skip the build step).
4856
5397
  */
4857
5398
  function readDevtoolsVersion() {
4858
- return "0.1.107";
5399
+ return "0.1.109";
4859
5400
  }
4860
5401
  /**
4861
5402
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -5103,9 +5644,11 @@ async function renderQr(text) {
5103
5644
  return `${lines.join("\n")}\n`;
5104
5645
  }
5105
5646
  /**
5106
- * Renders the attach banner (relay URL + ASCII QR) as a string.
5647
+ * Renders the attach banner (relay URL + unicode half-block QR) as a string.
5107
5648
  *
5108
- * The QR encodes the base `wssUrl` only. When `totpEnabled` is true, a note
5649
+ * The QR is produced by `renderQr` (a half-block matrix, not the
5650
+ * `qrcode-terminal` ASCII art used by the unplugin banner) and encodes the
5651
+ * base `wssUrl` only. When `totpEnabled` is true, a note
5109
5652
  * is added that attach URLs generated by `build_attach_url` will include a
5110
5653
  * live TOTP code (`at=`) appended at call time.
5111
5654
  *
@@ -5385,6 +5928,16 @@ function isRelayMode(mode) {
5385
5928
  return mode === "relay-sandbox" || mode === "relay-staging" || mode === "relay-live";
5386
5929
  }
5387
5930
  /**
5931
+ * Single-attach guard for `run_tests` (#646). Two concurrent runs injecting
5932
+ * into the same single-attach page would interleave `Runtime.evaluate` and
5933
+ * corrupt each other's `globalThis.__testBundle`. The model is "reject the
5934
+ * second", not "queue" — a module-level flag is process-wide, which matches the
5935
+ * single physical attached page (only one target is live at a time). The
5936
+ * entry-time `conn` snapshot ensures a run finishes on the connection it started
5937
+ * on even if `router.active` flips mid-run.
5938
+ */
5939
+ let runTestsInFlight = false;
5940
+ /**
5388
5941
  * Waits for the first target matching `filterFn` to attach, using the
5389
5942
  * event-driven `waitForFirstTarget()` when the connection supports it
5390
5943
  * (interface-optional member, present on `ChiiCdpConnection`), or falling
@@ -5395,6 +5948,12 @@ function isRelayMode(mode) {
5395
5948
  * to resolve before the relay had observed the first inbound CDP message from
5396
5949
  * the phone.
5397
5950
  *
5951
+ * Timeout note: callers (e.g. the `build_attach_url` path) always pass an
5952
+ * explicit `timeoutMs`, sourced from the factory's `waitForAttachTimeoutMs`
5953
+ * (default 60 000). That value is forwarded to `waitForFirstTarget`, so it
5954
+ * overrides that method's own 90 000 signature default — the effective
5955
+ * wait on the tool path is 60 s, not 90 s.
5956
+ *
5398
5957
  * @param connection - The CDP connection (production or fake).
5399
5958
  * @param filterFn - Resolves when this predicate is satisfied.
5400
5959
  * @param timeoutMs - Maximum wait time in ms.
@@ -5447,7 +6006,7 @@ function createDebugServer(deps) {
5447
6006
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
5448
6007
  const server = new Server({
5449
6008
  name: "ait-debug",
5450
- version: "0.1.107"
6009
+ version: "0.1.109"
5451
6010
  }, { capabilities: { tools: { listChanged: true } } });
5452
6011
  server.setRequestHandler(ListToolsRequestSchema, () => {
5453
6012
  const conn = router.active;
@@ -5950,6 +6509,35 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
5950
6509
  if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
5951
6510
  return envelopeResult$1(sdkResult, name, env, conn.listTargets().length > 0);
5952
6511
  }
6512
+ case "run_tests": {
6513
+ const rawFiles = request.params.arguments?.files;
6514
+ if (!Array.isArray(rawFiles) || rawFiles.length === 0) return mcpError("run_tests: files 인자가 비어 있습니다. 실행할 테스트 파일 glob을 배열로 전달하세요.");
6515
+ const patterns = rawFiles.filter((p) => typeof p === "string" && p !== "");
6516
+ if (patterns.length === 0) return mcpError("run_tests: files 인자에 유효한 문자열 glob이 없습니다.");
6517
+ const rawRoot = request.params.arguments?.projectRoot;
6518
+ const projectRoot = typeof rawRoot === "string" ? rawRoot : process.cwd();
6519
+ const rawTimeout = request.params.arguments?.timeout_ms;
6520
+ const timeoutMs = typeof rawTimeout === "number" && rawTimeout >= 1e3 && rawTimeout <= 6e5 ? rawTimeout : void 0;
6521
+ if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("run_tests");
6522
+ if (runTestsInFlight) return mcpError("run_tests: 이미 다른 테스트 실행이 진행 중입니다 (single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.");
6523
+ runTestsInFlight = true;
6524
+ try {
6525
+ const files = await discoverTestFiles(patterns, projectRoot);
6526
+ if (files.length === 0) return mcpError(`run_tests: 매칭된 테스트 파일이 없습니다 (patterns: ${patterns.join(", ")}).`);
6527
+ if (conn.listTargets().length === 0) return pageMissingError("run_tests");
6528
+ logInfo("run_tests.start", { fileCount: files.length });
6529
+ const report = await runWithConnection(conn, files, { timeoutMs });
6530
+ logInfo("run_tests.done", {
6531
+ passed: report.totals.passed,
6532
+ failed: report.totals.failed,
6533
+ skipped: report.totals.skipped
6534
+ });
6535
+ const runAttached = conn.listTargets().length > 0;
6536
+ return envelopeResult$1(toRunTestsResult(report), name, env, runAttached);
6537
+ } finally {
6538
+ runTestsInFlight = false;
6539
+ }
6540
+ }
5953
6541
  default: return unknownTool(name);
5954
6542
  }
5955
6543
  } catch (err) {
@@ -6035,6 +6623,31 @@ function envelopeResult$1(value, tool, env, attached) {
6035
6623
  text: JSON.stringify(wrapped, null, 2)
6036
6624
  }] };
6037
6625
  }
6626
+ /**
6627
+ * Maps a {@link RelayRunReport} to a flat, agent-friendly object for the
6628
+ * `run_tests` tool result. SECRET-HANDLING: a RelayRunReport carries only
6629
+ * startedAt/duration/totals and per-file `{file, result}` — file paths are
6630
+ * surfaced (allowed), relay wss/TOTP URLs never appear in it. No stripping
6631
+ * needed; this only reshapes for readability.
6632
+ */
6633
+ function toRunTestsResult(report) {
6634
+ return {
6635
+ startedAt: report.startedAt,
6636
+ duration: report.duration,
6637
+ totals: report.totals,
6638
+ files: report.files.map((f) => "error" in f.result ? {
6639
+ file: f.file,
6640
+ error: f.result.error
6641
+ } : {
6642
+ file: f.file,
6643
+ duration: f.result.duration,
6644
+ passed: f.result.passed,
6645
+ failed: f.result.failed,
6646
+ skipped: f.result.skipped,
6647
+ tests: f.result.tests
6648
+ })
6649
+ };
6650
+ }
6038
6651
  function unknownTool(name) {
6039
6652
  return mcpError(`알 수 없는 tool: ${name}`);
6040
6653
  }
@@ -7456,6 +8069,34 @@ const DEV_TOOL_DEFINITIONS = [
7456
8069
  required: []
7457
8070
  },
7458
8071
  availableIn: "both"
8072
+ },
8073
+ {
8074
+ name: "run_tests",
8075
+ 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`.",
8076
+ inputSchema: {
8077
+ type: "object",
8078
+ properties: {
8079
+ files: {
8080
+ type: "array",
8081
+ items: { type: "string" },
8082
+ description: "Glob patterns or file paths to run."
8083
+ },
8084
+ projectRoot: {
8085
+ type: "string",
8086
+ description: "Glob base directory."
8087
+ },
8088
+ timeout_ms: {
8089
+ type: "number",
8090
+ description: "Per-file evaluate timeout in ms."
8091
+ },
8092
+ confirm: {
8093
+ type: "boolean",
8094
+ description: "Required in relay-live sessions."
8095
+ }
8096
+ },
8097
+ required: ["files"]
8098
+ },
8099
+ availableIn: "both"
7459
8100
  }
7460
8101
  ];
7461
8102
  /** All tool names served in dev-mode (including tier-filter stubs). */
@@ -7468,7 +8109,8 @@ const CDP_ONLY_TOOL_NAMES = new Set([
7468
8109
  "take_snapshot",
7469
8110
  "list_console_messages",
7470
8111
  "list_network_requests",
7471
- "list_exceptions"
8112
+ "list_exceptions",
8113
+ "run_tests"
7472
8114
  ]);
7473
8115
  /**
7474
8116
  * Tier B tools — relay-only per RFC #277.
@@ -7585,7 +8227,7 @@ function createDevServer(deps = {}) {
7585
8227
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
7586
8228
  const server = new Server({
7587
8229
  name: "ait-devtools",
7588
- version: "0.1.107"
8230
+ version: "0.1.109"
7589
8231
  }, { capabilities: { tools: {} } });
7590
8232
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
7591
8233
  server.setRequestHandler(CallToolRequestSchema, async (request) => {