@ait-co/devtools 0.1.137 → 0.1.138

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 (119) hide show
  1. package/dist/{attach-orchestrator-Cjdhir2U.js → attach-orchestrator-6kz_ZdWL.js} +5 -3
  2. package/dist/{attach-orchestrator-Cjdhir2U.js.map → attach-orchestrator-6kz_ZdWL.js.map} +1 -1
  3. package/dist/{attach-orchestrator-CE0S09YU.js → attach-orchestrator-BEiz3wAx.js} +5 -3
  4. package/dist/{attach-orchestrator-CMoDuG2A.js.map → attach-orchestrator-BEiz3wAx.js.map} +1 -1
  5. package/dist/{attach-orchestrator-CMoDuG2A.js → attach-orchestrator-BG8TGJOZ.js} +5 -3
  6. package/dist/{attach-orchestrator-CE0S09YU.js.map → attach-orchestrator-BG8TGJOZ.js.map} +1 -1
  7. package/dist/{bundle-BKqyhEK9.d.ts → bundle-C796JIwG.d.ts} +65 -2
  8. package/dist/bundle-C796JIwG.d.ts.map +1 -0
  9. package/dist/{capture-ltuV0gZa.d.ts → capture-DsP525OZ.d.ts} +1 -1
  10. package/dist/{capture-ltuV0gZa.d.ts.map → capture-DsP525OZ.d.ts.map} +1 -1
  11. package/dist/{cdp-connection-BYE9meXe.d.ts → cdp-connection-rP1WdnH5.d.ts} +1 -1
  12. package/dist/{cdp-connection-BYE9meXe.d.ts.map → cdp-connection-rP1WdnH5.d.ts.map} +1 -1
  13. package/dist/{cell-D1y4shoV.js → cell-BMlFh08P.js} +2 -2
  14. package/dist/cell-BMlFh08P.js.map +1 -0
  15. package/dist/{cell-DHA578lX.js → cell-Dtgtusog.js} +90 -16
  16. package/dist/cell-Dtgtusog.js.map +1 -0
  17. package/dist/{cell-BIdSQb9T.js → cell-KsRXFdDs.js} +122 -16
  18. package/dist/cell-KsRXFdDs.js.map +1 -0
  19. package/dist/{debug-server-CBK7dn8O.js → debug-server-6c22qSWa.js} +132 -9
  20. package/dist/debug-server-6c22qSWa.js.map +1 -0
  21. package/dist/{debug-server-BQplCzf8.js → debug-server-BQHbaKPT.js} +134 -11
  22. package/dist/debug-server-BQHbaKPT.js.map +1 -0
  23. package/dist/{debug-server-D2wpPK3_.js → debug-server-DWat7d-9.js} +82 -6
  24. package/dist/debug-server-DWat7d-9.js.map +1 -0
  25. package/dist/devtools-opener-3Drge_RJ.js +75 -0
  26. package/dist/devtools-opener-3Drge_RJ.js.map +1 -0
  27. package/dist/devtools-opener-CJpEsXXQ.js +76 -0
  28. package/dist/devtools-opener-CJpEsXXQ.js.map +1 -0
  29. package/dist/devtools-opener-CxtryS8c.js +75 -0
  30. package/dist/devtools-opener-CxtryS8c.js.map +1 -0
  31. package/dist/in-app/auto.js +59 -12
  32. package/dist/in-app/auto.js.map +1 -1
  33. package/dist/in-app/index.d.ts +21 -2
  34. package/dist/in-app/index.d.ts.map +1 -1
  35. package/dist/in-app/index.js +60 -13
  36. package/dist/in-app/index.js.map +1 -1
  37. package/dist/mcp/cli.js +299 -60
  38. package/dist/mcp/cli.js.map +1 -1
  39. package/dist/mcp/server.js +1 -1
  40. package/dist/mock/index.d.ts +64 -1
  41. package/dist/mock/index.d.ts.map +1 -1
  42. package/dist/mock/index.js +221 -14
  43. package/dist/mock/index.js.map +1 -1
  44. package/dist/panel/index.js +178 -23
  45. package/dist/panel/index.js.map +1 -1
  46. package/dist/{pool-CmfvXnwh.d.ts → pool-CrP5CPvU.d.ts} +3 -3
  47. package/dist/{pool-CmfvXnwh.d.ts.map → pool-CrP5CPvU.d.ts.map} +1 -1
  48. package/dist/{qr-http-server-ZkT6F7is.js → qr-http-server-D1hpoyBF.js} +1 -1
  49. package/dist/{qr-http-server-ZkT6F7is.js.map → qr-http-server-D1hpoyBF.js.map} +1 -1
  50. package/dist/qr-http-server-D4rGz8M7.js +1640 -0
  51. package/dist/qr-http-server-D4rGz8M7.js.map +1 -0
  52. package/dist/{qr-http-server-C7q8Gn04.js → qr-http-server-DIjCV7h_.js} +1 -1
  53. package/dist/{qr-http-server-C7q8Gn04.js.map → qr-http-server-DIjCV7h_.js.map} +1 -1
  54. package/dist/{relay-factory-DVzpBRxz.js → relay-factory-C1X3G3WY.js} +71 -15
  55. package/dist/relay-factory-C1X3G3WY.js.map +1 -0
  56. package/dist/{relay-secret-store-CLEGyHou.js → relay-secret-store-Bmyleu0A.js} +1 -1
  57. package/dist/{relay-secret-store-CLEGyHou.js.map → relay-secret-store-Bmyleu0A.js.map} +1 -1
  58. package/dist/{relay-secret-store-DhzAnnj-.js → relay-secret-store-CQenfcSL.js} +2 -2
  59. package/dist/{relay-secret-store-DhzAnnj-.js.map → relay-secret-store-CQenfcSL.js.map} +1 -1
  60. package/dist/{relay-secret-store-BcVrWwTq.js → relay-secret-store-DKxs7zwq.js} +1 -1
  61. package/dist/{relay-secret-store-BcVrWwTq.js.map → relay-secret-store-DKxs7zwq.js.map} +1 -1
  62. package/dist/{relay-secret-store-DGduVJhs.js → relay-secret-store-WJ8EGkIl.js} +1 -1
  63. package/dist/{relay-secret-store-DGduVJhs.js.map → relay-secret-store-WJ8EGkIl.js.map} +1 -1
  64. package/dist/{relay-url-store-CKW8RQzf.js → relay-url-store-BR2XodiO.js} +2 -2
  65. package/dist/{relay-url-store-CKW8RQzf.js.map → relay-url-store-BR2XodiO.js.map} +1 -1
  66. package/dist/{relay-url-store-CdA58fgw.js → relay-url-store-CH63fVCm.js} +2 -2
  67. package/dist/{relay-url-store-B0X8TsGr.js.map → relay-url-store-CH63fVCm.js.map} +1 -1
  68. package/dist/{relay-url-store-CwKT7i04.js → relay-url-store-DaY1QPes.js} +2 -2
  69. package/dist/{relay-url-store-CwKT7i04.js.map → relay-url-store-DaY1QPes.js.map} +1 -1
  70. package/dist/{relay-url-store-B0X8TsGr.js → relay-url-store-xmUuTjXA.js} +2 -2
  71. package/dist/{relay-url-store-CdA58fgw.js.map → relay-url-store-xmUuTjXA.js.map} +1 -1
  72. package/dist/{relay-worker-6fy1eaIo.js → relay-worker-BUXI0K0b.js} +8 -4
  73. package/dist/{relay-worker-6fy1eaIo.js.map → relay-worker-BUXI0K0b.js.map} +1 -1
  74. package/dist/{relay-worker-DJnZkXza.d.ts → relay-worker-DcyboK43.d.ts} +30 -5
  75. package/dist/relay-worker-DcyboK43.d.ts.map +1 -0
  76. package/dist/{runtime-DfHHZms2.d.ts → runtime-BiigOuvb.d.ts} +30 -3
  77. package/dist/runtime-BiigOuvb.d.ts.map +1 -0
  78. package/dist/test-runner/bin.js +376 -38
  79. package/dist/test-runner/bin.js.map +1 -1
  80. package/dist/test-runner/bundle.d.ts +2 -2
  81. package/dist/test-runner/bundle.js +66 -13
  82. package/dist/test-runner/bundle.js.map +1 -1
  83. package/dist/test-runner/capture.d.ts +1 -1
  84. package/dist/test-runner/config.d.ts +58 -5
  85. package/dist/test-runner/config.d.ts.map +1 -1
  86. package/dist/test-runner/config.js +1 -1
  87. package/dist/test-runner/method-pace.d.ts +82 -0
  88. package/dist/test-runner/method-pace.d.ts.map +1 -0
  89. package/dist/test-runner/method-pace.js +120 -0
  90. package/dist/test-runner/method-pace.js.map +1 -0
  91. package/dist/test-runner/pool.d.ts +1 -1
  92. package/dist/test-runner/pool.js +1 -1
  93. package/dist/test-runner/relay-factory.d.ts +57 -4
  94. package/dist/test-runner/relay-factory.d.ts.map +1 -1
  95. package/dist/test-runner/relay-factory.js +70 -14
  96. package/dist/test-runner/relay-factory.js.map +1 -1
  97. package/dist/test-runner/relay-worker.d.ts +1 -1
  98. package/dist/test-runner/relay-worker.js +1 -1
  99. package/dist/test-runner/report.d.ts +3 -3
  100. package/dist/test-runner/rpc.d.ts +2 -2
  101. package/dist/test-runner/runtime.d.ts +2 -2
  102. package/dist/test-runner/runtime.js +83 -6
  103. package/dist/test-runner/runtime.js.map +1 -1
  104. package/dist/test-runner/task-graph.d.ts +1 -1
  105. package/dist/throttle-DKKzX1qC.js +59 -0
  106. package/dist/throttle-DKKzX1qC.js.map +1 -0
  107. package/dist/totp-Dwft0Kz7.js +3 -0
  108. package/package.json +1 -1
  109. package/dist/bundle-BKqyhEK9.d.ts.map +0 -1
  110. package/dist/cell-BIdSQb9T.js.map +0 -1
  111. package/dist/cell-D1y4shoV.js.map +0 -1
  112. package/dist/cell-DHA578lX.js.map +0 -1
  113. package/dist/debug-server-BQplCzf8.js.map +0 -1
  114. package/dist/debug-server-CBK7dn8O.js.map +0 -1
  115. package/dist/debug-server-D2wpPK3_.js.map +0 -1
  116. package/dist/relay-factory-DVzpBRxz.js.map +0 -1
  117. package/dist/relay-worker-DJnZkXza.d.ts.map +0 -1
  118. package/dist/runtime-DfHHZms2.d.ts.map +0 -1
  119. package/dist/totp-D1pulXLa.js +0 -3
@@ -1 +1 @@
1
- {"version":3,"file":"bin.js","names":["path"],"sources":["../../src/test-runner/discover.ts","../../src/test-runner/relay-factory.ts","../../src/shared/relay-auth-close.ts","../../src/mcp/chii-connection.ts","../../src/test-runner/bundle.ts","../../src/test-runner/capture.ts","../../src/test-runner/rpc.ts","../../src/test-runner/relay-worker.ts","../../src/test-runner/report.ts","../../src/test-runner/teardown.ts","../../src/test-runner/cli.ts","../../src/test-runner/bin.ts"],"sourcesContent":["/**\n * Test-file discovery shared by the `devtools-test` CLI and the `run_tests`\n * MCP tool, so both expand glob patterns with identical semantics.\n *\n * Uses Node's built-in `fs/promises` `glob` (Node 22+) — no extra dependency,\n * which keeps the MCP daemon install graph lean (a plain glob lib would land in\n * the `npx … devtools-mcp` path for no benefit).\n *\n * Pure Node IO only (`node:fs/promises` + `node:path`) — react-free, so it is\n * safe to import from the MCP daemon graph.\n */\n\nimport { glob } from 'node:fs/promises';\nimport { basename, isAbsolute, resolve } from 'node:path';\n\n/**\n * Filename suffix that opts a test file into manual-variant scheduling\n * (devtools#741). A file named `<name>.manual.ait.test.ts` is EXCLUDED from\n * `discoverTestFiles`'s default output (so existing unattended runs are\n * unaffected — the zero-diff-when-off constraint) and is only surfaced when\n * the caller explicitly asks for manual files via {@link partitionManualTests}\n * (wired from the CLI's `--manual-blocking` flag).\n *\n * This is the entire tagging contract for v1 — no separate manifest/config,\n * just a filename convention. Documented here + in the CLI `--help` text\n * (cli.ts USAGE) and the test-runner README.\n */\nexport const MANUAL_TEST_SUFFIX = '.manual.ait.test.ts';\n\n/** True when `file`'s basename ends with {@link MANUAL_TEST_SUFFIX}. */\nexport function isManualTestFile(file: string): boolean {\n return basename(file).endsWith(MANUAL_TEST_SUFFIX);\n}\n\n/**\n * Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of\n * ABSOLUTE test file paths, resolved relative to `cwd`.\n *\n * A plain (non-glob) path passes through when it matches a real file; a glob\n * expands against `cwd`. Absolute matches are kept as-is; relative matches are\n * resolved against `cwd`. `bundleTestFile` requires an absolute path, so the\n * absolute output feeds it directly.\n *\n * By default, files matching {@link MANUAL_TEST_SUFFIX} are EXCLUDED from the\n * result (devtools#741) — blocking-UI tests opt in via that filename\n * convention and must never appear in an unattended run unless the caller\n * explicitly asks for them via `includeManual: true`. This keeps the\n * default (flag-off) discovery path byte-for-byte identical to before this\n * option existed.\n *\n * @param patterns Glob patterns or file paths (e.g. `['src/**\\/*.ait.test.ts']`).\n * @param cwd Base directory for relative patterns/results.\n * @param opts `{ includeManual }` — when true, manual-tagged files are kept\n * in the (still-sorted) output instead of being filtered out.\n * Use {@link partitionManualTests} to separate + reorder them.\n * @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.\n */\nexport async function discoverTestFiles(\n patterns: string[],\n cwd: string,\n opts?: { includeManual?: boolean },\n): Promise<string[]> {\n const out = new Set<string>();\n for await (const match of glob(patterns, { cwd })) {\n out.add(isAbsolute(match) ? match : resolve(cwd, match));\n }\n const sorted = [...out].sort();\n if (opts?.includeManual) return sorted;\n return sorted.filter((f) => !isManualTestFile(f));\n}\n\n/**\n * Splits an already-discovered file list into `{ regular, manual }`, each\n * still sorted. Used by the CLI's `--manual-blocking` path to schedule manual\n * files strictly AFTER every regular file (devtools#741) — regular files run\n * first (and produce the unattended-shaped part of the report) and manual\n * files run last, each preceded by a dashboard prompt.\n *\n * Pure partition — does not itself decide inclusion; call\n * `discoverTestFiles(patterns, cwd, { includeManual: true })` first so manual\n * files are present in `files` to partition.\n */\nexport function partitionManualTests(files: string[]): { regular: string[]; manual: string[] } {\n const regular: string[] = [];\n const manual: string[] = [];\n for (const f of files) {\n (isManualTestFile(f) ? manual : regular).push(f);\n }\n return { regular, manual };\n}\n","/**\n * Relay connection factory for the Vitest custom pool (devtools#696).\n *\n * The Vitest pool (`pool.ts`) takes a {@link RelayConnectionFactory} that knows\n * how to `open()` a live CDP relay connection (boot relay → QR → phone scan →\n * cell inject → enableDomains) and `close()` it. Before this module that exact\n * assembly lived only inside the `devtools-test` CLI's `main()`; the standalone\n * CLI and the Vitest pool would otherwise each hand-roll it and drift.\n *\n * This is the single source of that assembly. `cli.ts` is refactored to call\n * `createRelayConnectionFactory(...).open()`, and `definePhoneVitestConfig`\n * (config.ts) exposes it so a downstream `vitest.config.ts` can wire the pool:\n *\n * import { createRelayConnectionFactory } from '@ait-co/devtools/test-runner';\n * const connection = createRelayConnectionFactory({\n * schemeUrl,\n * cell,\n * // REQUIRED — own the stdout decision (the chunks carry the relay wss +\n * // TOTP code). Suppress on non-interactive stdout; print otherwise.\n * onQrContent: (chunks) => {\n * if (!process.stdout.isTTY) return;\n * for (const c of chunks) process.stdout.write(`${c}\\n`);\n * },\n * });\n * export default defineConfig({ test: definePhoneVitestConfig({ connection }) });\n *\n * The heavy boot graph (chii relay, cloudflared, ws, debug-server) is pulled via\n * DYNAMIC import inside `open()` — so merely importing this module (or the\n * `@ait-co/devtools/test-runner` barrel) does NOT statically drag that graph in.\n *\n * SECRET-HANDLING: relay wss URLs, scheme URLs, and the TOTP secret/code are\n * never logged. `open()` reads the project-local `.ait_relay` secret read-only\n * (never mints) and the minted TOTP code rides only inside the QR `at=` param.\n *\n * Node-only. react-free (CdpConnection + lazily-imported MCP boot helpers only).\n */\n\nimport type { AttachDeps, AttachUrlParts } from '../mcp/attach-orchestrator.js';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport type { DashboardState, QrHttpServer } from '../mcp/qr-http-server.js';\nimport type { RelayConnectionFactory } from './pool.js';\n\n// NOTE: every value import below is a DYNAMIC import inside `open()`. This module\n// keeps ONLY type-level static imports so that re-exporting it from the\n// `@ait-co/devtools/test-runner` barrel (config.ts) does NOT statically drag the\n// heavy MCP graph (cell.ts → attach-orchestrator.ts → tools.ts → server-lock,\n// plus chii/cloudflared via debug-server) onto that Node-config entry.\n\n/** Options for {@link createRelayConnectionFactory}. */\nexport interface RelayConnectionFactoryOptions {\n /**\n * intoss-private:// scheme URL from `ait deploy --scheme-only` (env3). The\n * phone cold-loads the candidate bundle this URL points at. SECRET-HANDLING:\n * never logged.\n */\n schemeUrl: string;\n /**\n * Project root for the `.ait_relay` secret lookup (read-only). Defaults to\n * `process.cwd()`.\n */\n projectRoot?: string;\n /**\n * Attach wait timeout in ms — how long `open()` waits for the phone to scan\n * the QR and attach. Omitted (the default) means **wait indefinitely** —\n * the runner stays up until the user stops it (Ctrl-C/SIGTERM). QR-scan\n * wait is a human-paced action; there is no sound default bound for it\n * (devtools#735). Pass an explicit value to opt into a bounded wait (CI/\n * headless callers — `--attach-timeout` on the CLI). (The per-file evaluate\n * timeout is separate, passed via the pool's `run` options.)\n */\n timeoutMs?: number;\n /** Disable browser auto-open of the QR dashboard (text QR only). */\n headless?: boolean;\n /**\n * Cell axes injected as `__AIT_CELL__` before the first test bundle runs, so\n * sdk-example's capture picks up the correct sdkLine/platform. Optional — when\n * omitted no cell is injected. The values are not secrets.\n */\n cell?: { sdkLine: string; platform: string };\n /**\n * When `true`, injects `__AIT_STUB_BLOCKING__ = true` before the first test\n * bundle runs (devtools#740, DT-2) — `bundle.ts`'s sdk-redirect module reads\n * this flag via `isStubBlockingEnabled()` and answers the blocking-UI APIs\n * in `bridge-stub.ts`'s `STUB_REGISTRY` from fixtures instead of forwarding\n * them to native. Optional — omitted/false means no flag is injected, which\n * is byte-for-byte the pre-#740 behavior (the sdk-redirect module treats\n * absence the same as `false`). Not a secret.\n */\n stubBlocking?: boolean;\n /**\n * Overrides the dashboard HTTP server's bind base port (devtools#752).\n * Threaded straight through to `startQrHttpServer`'s `dashboardPort`\n * option — see that option's doc for the increment-on-EADDRINUSE scan\n * behaviour and the `AIT_DEBUG_HTTP_PORT` env fallback. Omitted means\n * `startQrHttpServer` resolves its own default (env → fixed default).\n */\n dashboardPort?: number;\n /**\n * Receives the QR/attach render content (text chunks) from\n * `renderAndMaybeWait`, so the caller decides whether to print them — e.g.\n * suppress on non-interactive stdout.\n *\n * REQUIRED (not optional) on purpose: these chunks contain the QR payload\n * (which encodes the relay wss + TOTP `at=` code) and the attach JSON block.\n * Making the hook mandatory means the factory never falls back to printing\n * them itself — a downstream `vitest.config.ts` consumer that wires this via\n * the `@ait-co/devtools/test-runner` barrel is forced to make an explicit\n * stdout decision rather than silently leaking the secret-bearing block.\n *\n * SECRET-HANDLING: when a non-interactive caller is detected the hook MUST\n * suppress the WHOLE chunk (not just `attachUrl`), since `relayUrl` rides in\n * the same block.\n */\n onQrContent: (textChunks: string[]) => void;\n}\n\n/**\n * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay\n * connection.\n *\n * `open()` performs the full attach lifecycle and BLOCKS while a human scans\n * the rendered QR with their phone — there is no way around the manual scan\n * for env3. By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the\n * runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is\n * a human-paced action with no sound default bound (devtools#735). Passing an\n * explicit `timeoutMs` opts into the old bounded behavior (CI/headless\n * callers). It resolves with the live `CdpConnection` once a matching page\n * attaches; `close()` tears the relay family down.\n *\n * The factory holds the booted relay family in a closure so `close()` can stop\n * it. A second `open()` on the same factory boots a fresh family (the previous\n * one should have been `close()`d first).\n */\nexport function createRelayConnectionFactory(\n opts: RelayConnectionFactoryOptions,\n): RelayConnectionFactory {\n const projectRoot = opts.projectRoot ?? process.cwd();\n // Undefined/absent → Infinity (wait forever). An explicit finite value opts\n // into the old bounded behavior. Number.isFinite guards downstream\n // (renderAndMaybeWait / waitForFirstTarget) treat Infinity as \"no timer\".\n const timeoutMs = opts.timeoutMs ?? Number.POSITIVE_INFINITY;\n const headless = opts.headless === true;\n\n // Captured so close() can stop the family that open() booted.\n let family: { connection: CdpConnection; stop(): void } | undefined;\n // QR HTTP server — started during open() when not headless, closed in close().\n let qrServer: QrHttpServer | undefined;\n // Session-phase state (#730) — drives the dashboard's `phase` field so the\n // CLI's run start/complete and final teardown push an immediate SSE update\n // instead of the dashboard just going dark when the process exits.\n let phase: DashboardState['phase'] = 'active';\n // Manual-blocking prompt state (devtools#741) — drives the dashboard's\n // `manualPrompt` field. `null` outside a manual step.\n let manualPrompt: DashboardState['manualPrompt'] = null;\n\n return {\n async open(): Promise<CdpConnection> {\n // Dynamic imports: keep the chii/cloudflared/debug-server graph OFF the\n // static import graph of this module (and the test-runner config barrel).\n const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import(\n '../mcp/attach-orchestrator.js'\n );\n const { injectDebugIndicator, injectGlobals } = await import('./cell.js');\n const { loadRelaySecretReadOnly } = await import('../mcp/relay-secret-store.js');\n const { bootRelayFamily, buildRelayVerifyAuth } = await import('../mcp/debug-server.js');\n\n // Load the project-local .ait_relay secret into AIT_DEBUG_TOTP_SECRET\n // BEFORE booting the relay so assertRelayAuthConfigured()/buildRelayVerifyAuth()\n // at the boot site see it. Read-only — never mints. SECRET-HANDLING: the\n // value is never logged here.\n await loadRelaySecretReadOnly({ projectRoot });\n\n // PRIMARY FIX (devtools#714): bootRelayFamily starts the cloudflared tunnel\n // as a background promise and returns immediately with tunnel.up === false.\n // We must NOT call prepareAttach until the tunnel is up — it fails fast when\n // tunnel.up is false (attach-orchestrator.ts tunnel-down guard).\n //\n // Wire onWssUrl (mirroring the MCP daemon path in debug-server.ts) to:\n // 1. resolve a caller-held tunnel-ready promise so open() can await it, and\n // 2. re-push dashboard SSE on late tunnel-up events (notifyStateChange).\n //\n // SECRET-HANDLING: onWssUrl receives the relay wss URL — never log it.\n let resolveTunnelUp!: () => void;\n const tunnelReady = new Promise<void>((resolve) => {\n resolveTunnelUp = resolve;\n });\n\n const booted = await bootRelayFamily({\n verifyAuth: buildRelayVerifyAuth(),\n onWssUrl: () => {\n // Resolve the tunnel-ready gate so the prepareAttach call below is\n // unblocked. qrServer may not be set yet at call time (it's started\n // after bootRelayFamily), so we use optional chaining — if the tunnel\n // happens to come up after qrServer is started, notifyStateChange pushes\n // the freshly minted attachUrl to any waiting dashboard SSE clients.\n // SECRET-HANDLING: wssUrl is NOT forwarded here — the value travels only\n // inside the closure via getTunnelStatus().wssUrl (used by mintAttachUrl).\n resolveTunnelUp();\n qrServer?.notifyStateChange();\n },\n // #730: parity with the MCP daemon path (debug-server.ts's onTunnelDown\n // wiring) — without this, a permanent tunnel drop during a standalone\n // CLI run left the dashboard showing a dead-but-scannable QR until the\n // watchdog (up to ~210s) finally fired.\n onTunnelDown: () => {\n qrServer?.notifyStateChange();\n },\n });\n family = booted;\n\n // If the tunnel is already up (extremely fast boot or test double), resolve\n // immediately so we don't stall on a promise that will never fire.\n if (booted.getTunnelStatus?.().up) {\n resolveTunnelUp();\n }\n\n // Track the last-captured attach parts so getDashboardState can mint a\n // fresh attach URL on every dashboard request/SSE push (fresh TOTP at=).\n // SECRET-HANDLING: parts contain the relay wss + scheme URL — never logged.\n let lastAttachParts: AttachUrlParts | undefined;\n\n // Assemble AttachDeps. We set qrHttpServer and onAttachUrlBuilt below\n // (before prepareAttach) after optionally starting the web-QR server.\n const attachDeps: AttachDeps = {\n getTunnelStatus: booted.getTunnelStatus ?? (() => ({ up: false, wssUrl: null })),\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n qrHttpServer: undefined, // filled in below if web-QR server started\n onAttachUrlBuilt: undefined, // filled in below\n canOpenBrowser: () => !headless,\n };\n\n // Web-QR server: reuse the same loopback HTTP dashboard that the MCP\n // start_attach path uses (src/mcp/qr-http-server.ts). This makes the QR\n // scannable even when stdout is non-interactive (Claude Code `!` / CI),\n // because the browser is opened by URL — not via captured stdout.\n //\n // Headless decision: we start the server even in --headless mode so the\n // printed stderr URL can be opened manually. The existing\n // canOpenBrowser: () => !headless gate inside renderAndMaybeWait prevents\n // the auto-open; headless users see only the stderr URL.\n //\n // On failure we fall back gracefully to the text-QR path — do NOT crash.\n // SECRET-HANDLING: only http://127.0.0.1:<port>/ (no secrets) goes to stderr.\n try {\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n\n const getDashboardState = (): DashboardState => ({\n tunnel: attachDeps.getTunnelStatus(),\n pages: null, // CLI/pool: no page-list introspection needed\n attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,\n mode: 'relay-dev' as const,\n phase, // #730 — CLI-only 'running'/'complete' transitions via onSessionPhase\n manualPrompt, // #741 — CLI-only --manual-blocking transitions via onManualPrompt\n });\n\n qrServer = await startQrHttpServer(getDashboardState, {\n dashboardPort: opts.dashboardPort,\n });\n\n // Wire the QR server into attachDeps BEFORE prepareAttach is called so\n // renderAndMaybeWait sees it and takes Path 2/3 (web-QR) instead of Path 4.\n attachDeps.qrHttpServer = qrServer;\n\n // Capture attach parts via onAttachUrlBuilt so getDashboardState can\n // mint a fresh URL (fresh TOTP at= code) on every dashboard render.\n attachDeps.onAttachUrlBuilt = (parts: AttachUrlParts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n };\n\n // Print the loopback dashboard URL to stderr — it carries no secrets\n // (TOTP codes and relay wss live only in the in-memory HTTP response).\n process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\\n`);\n } catch {\n // startQrHttpServer failed (e.g. port conflict, import error). Fall back\n // to the existing text-QR path by leaving qrHttpServer: undefined.\n // qrServer remains undefined; close() handles that with optional chaining.\n }\n\n // PRIMARY FIX (devtools#714): await tunnel readiness before calling\n // prepareAttach. Race: a 15 s timeout is generous — cloudflared typically\n // comes up in < 5 s. On timeout we still call prepareAttach; it will hit\n // the tunnel-down guard and throw a secret-free error (same as before,\n // but now with a clear diagnostic instead of a silent WAITING freeze).\n //\n // Implementation: Promise.race against a 15 000 ms timeout signal. We do\n // NOT use a timer-based early-exit because the existing code already\n // surface-fails on tunnel-down inside prepareAttach with a secret-free\n // message. The timeout here is purely a \"give the tunnel a fair chance\"\n // gate — not a correctness boundary.\n const TUNNEL_BOOT_TIMEOUT_MS = 15_000;\n await Promise.race([\n tunnelReady,\n new Promise<void>((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS)),\n ]);\n\n const prep = await prepareAttach(\n attachDeps,\n 'relay-dev',\n { scheme_url: opts.schemeUrl },\n booted.connection,\n );\n if (!prep.ok) {\n booted.stop();\n family = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the failure path\n // so the loopback port listener does not leak. The normal-exit path is\n // handled by close(); this mirrors that cleanup for the error path.\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: do NOT surface `prep.error.content` in the thrown\n // message. Some prep error paths build their text from attach\n // components, and the CLI catch writes `e.message` to stderr — embedding\n // that text risks leaking the scheme/relay wss URL. The detailed\n // diagnostic is the daemon/dashboard's job; the factory throws a\n // secret-free message only.\n throw new Error(\n 'createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up',\n );\n }\n\n // Render the QR + wait for the phone to attach. SECRET-HANDLING: this\n // function never logs scheme/wss/TOTP values.\n const waitResult = await renderAndMaybeWait(\n attachDeps,\n prep,\n true,\n timeoutMs,\n booted.connection,\n );\n if (waitResult.isError) {\n booted.stop();\n family = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the timeout path\n // (mirrors the prep.ok failure path above).\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING (BLOCKER fix): `waitResult.content` is the timeout\n // result built from `buildTimeoutError(baseText, ...)`, and `baseText`\n // is `JSON.stringify({ attachUrl, relayUrl, ... })` — `attachUrl` carries\n // the TOTP `at=` code and `relayUrl` is the relay `wss://` URL. The CLI\n // catch writes `e.message` to stderr, so extracting that text here would\n // leak the relay wss + TOTP code on every timeout. Throw a secret-free\n // message with only the timeout duration.\n const timeoutSec = Math.round(timeoutMs / 1000);\n throw new Error(\n `createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`,\n );\n }\n\n // Surface the QR/attach render content to the caller, which owns the\n // stdout decision (suppress on non-interactive stdout). There is no\n // fallback that prints here itself: `onQrContent` is a required option so\n // the secret-bearing block (attachUrl TOTP + relayUrl wss) can never be\n // emitted without an explicit caller decision. SECRET-HANDLING.\n const qrChunks = waitResult.content\n .filter((c): c is { type: 'text'; text: string } => c.type === 'text')\n .map((c) => c.text);\n opts.onQrContent(qrChunks);\n\n // PAGE-READY GATE (devtools#720, fix (a)+(b)):\n //\n // FIX (a) — ORDER: enableDomains() MUST run before injectDebugIndicator()\n // and injectGlobals(). Both inject calls use Runtime.evaluate via\n // sendCommand(), which guards with `if (!this.ws) reject(\"Call\n // enableDomains() first\")`. With the old ordering (inject → inject →\n // enableDomains) injectDebugIndicator's throw was swallowed by cell.ts\n // try/catch but injectGlobals (no try/catch) propagated fatally — when\n // --cell was set open() hard-threw before enableDomains ever ran and the\n // CLI exited with 0 files executed.\n //\n // FIX (b) — BOUNDED RETRY: the window between waitForFirstTarget (non-\n // empty /targets) and enableDomains (page-level WS open) is where a\n // Cloudflare edge idle-drop can disconnect the phone. enableDomains()\n // calls refreshTargets() internally; if the target list is empty at that\n // point it throws \"No mini-app page attached\". We absorb up to\n // PAGE_READY_RETRIES transient failures by waiting briefly and retrying\n // the refreshTargets+enableDomains sequence. enableDomains() is\n // idempotent (concurrent callers share the in-flight promise), so retries\n // are safe. Only after all retries fail do we surface a secret-free\n // error. This is the CLI equivalent of the MCP daemon's soft-fail\n // cushion in relay-worker.ts.\n //\n // Neither fix touches chii-connection.ts or the MCP daemon path.\n const PAGE_READY_RETRIES = 3;\n const PAGE_READY_RETRY_DELAY_MS = 1_500;\n\n let lastEnableError: Error | undefined;\n for (let attempt = 1; attempt <= PAGE_READY_RETRIES; attempt++) {\n try {\n // FIX (a): enableDomains first — opens the page-level CDP websocket.\n await booted.connection.enableDomains();\n lastEnableError = undefined;\n break;\n } catch (err) {\n lastEnableError = err instanceof Error ? err : new Error(String(err));\n if (attempt < PAGE_READY_RETRIES) {\n // FIX (b): brief pause then re-poll /targets before retrying\n // enableDomains. Use a non-leaking approach: wait, then fall\n // through to the next loop iteration.\n await new Promise<void>((resolve) => setTimeout(resolve, PAGE_READY_RETRY_DELAY_MS));\n }\n }\n }\n\n if (lastEnableError !== undefined) {\n booted.stop();\n family = undefined;\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: message contains only a duration + attempt count.\n // No relay wss URL, scheme URL, or TOTP code is included.\n throw new Error(\n `createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round((PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS) / 1000)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`,\n );\n }\n\n // FIX (a): inject AFTER enableDomains so the page-level CDP websocket is\n // open and sendCommand() will not hit the ws===null guard.\n\n // Show the on-phone \"Debugger Connected\" badge. injectDebugIndicator\n // swallows its own errors (cell.ts try/catch), so a badge failure is\n // non-fatal — test execution proceeds regardless.\n await injectDebugIndicator(booted.connection);\n\n // Inject the cell globals before any test bundle runs (session-global).\n // injectGlobals() does NOT swallow errors — a genuine type/eval failure\n // here surfaces clearly instead of silently skipping cell injection.\n if (opts.cell !== undefined) {\n await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });\n }\n\n // devtools#740 (DT-2): inject the bridge-stub gate before any test\n // bundle runs, session-global like __AIT_CELL__ above. Only injected\n // when explicitly requested — omitted/false means the sdk-redirect\n // module's `isStubBlockingEnabled()` reads `undefined`, which is\n // treated identically to `false` (zero-diff-when-off).\n if (opts.stubBlocking === true) {\n await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });\n }\n\n return booted.connection;\n },\n\n // #730: drives the dashboard's `phase` field so the CLI's run start/end\n // push an immediate SSE update instead of the dashboard just going dark.\n onSessionPhase(next: 'running' | 'complete'): void {\n phase = next;\n qrServer?.notifyStateChange();\n },\n\n // #741: drives the dashboard's `manualPrompt` field for --manual-blocking\n // — the CLI calls this immediately before injecting each manual file\n // (prompt set) and once more with `null` after the last one (prompt\n // cleared). Push is immediate (not waited on for ack) — v1 does not block\n // on dashboard acknowledgment, only on the file's own (long) evaluate.\n onManualPrompt(next: { file: string; index: number; total: number } | null): void {\n manualPrompt = next;\n qrServer?.notifyStateChange();\n },\n\n async close(connection: CdpConnection): Promise<void> {\n // #730: flip the on-phone badge to disconnected AND push the terminal\n // dashboard frame BEFORE tearing anything down, so neither surface goes\n // dark without explanation when the CLI exits (dog-food gaps #1 + #2).\n // Both are best-effort over the still-open channels — the ordering\n // (before family.stop()/qrServer.close()) is load-bearing:\n // - qrServer.notifyStateChange() writes synchronously to the still-open\n // SSE sockets, so the 'complete' frame is on the wire before the HTTP\n // server is closed below.\n // - injectDebugIndicator runs over the still-open CDP channel, so the\n // phone actually receives the disconnected-state update before the\n // relay/tunnel are torn down.\n if (phase !== 'complete') {\n phase = 'complete';\n qrServer?.notifyStateChange();\n }\n try {\n const { injectDebugIndicator } = await import('./cell.js');\n await injectDebugIndicator(connection, { state: 'disconnected' });\n } catch {\n // Channel may already be down (e.g. attach never completed) — non-fatal,\n // the badge is informational UI only.\n }\n // family.stop() is synchronous best-effort: closes the CDP connection and\n // shuts down the relay + cloudflared child.\n family?.stop();\n family = undefined;\n // Close the web-QR HTTP server if one was started during open().\n // close() is idempotent via optional chaining + reassignment to undefined.\n await qrServer?.close();\n qrServer = undefined;\n },\n };\n}\n","/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Production `CdpConnection` backed by the local Chii relay.\n *\n * Topology (debug mode):\n * phone target.js --WS--> Chii relay :9100 <--WS-- this connection\n *\n * The phone connects to the relay as a `target`; this module connects as a\n * `client` (the role a CDP frontend would take) so CDP events the page emits\n * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent\n * events in ring buffers the tool layer reads via `getBufferedEvents`.\n *\n * Node-only: imports `ws`. Never bundled into the browser/in-app entries.\n *\n * Attach reliability (#281):\n * `refreshTargets()` emits an internal 'target:attached' event whenever a\n * new target is added to the relay. `waitForFirstTarget()` awaits that event\n * (with a polling-interval fallback) so `start_attach`'s attach wait\n * resolves deterministically rather than racing between polling rounds.\n */\n\nimport { EventEmitter } from 'node:events';\nimport { WebSocket } from 'ws';\nimport { RELAY_AUTH_REJECT_CLOSE_CODE } from '../shared/relay-auth-close.js';\nimport type {\n CdpCommandMap,\n CdpCommandName,\n CdpConnection,\n CdpEventMap,\n CdpEventName,\n CdpTarget,\n} from './cdp-connection.js';\nimport { logInfo } from './log.js';\nimport { generateTotp } from './totp.js';\n\n/** Max events retained per domain ring buffer. */\nconst DEFAULT_BUFFER_SIZE = 500;\n\n/**\n * Substrings that mark a \"relay websocket is dead\" class error, as produced by\n * `handleDisconnect('relay WebSocket 연결이 끊겼습니다')` (ws close handler) and\n * the fail-fast `sendCommand` rejection (`relay에 연결되어 있지 않습니다 (...)`).\n *\n * Exported so callers outside this module (e.g. `test-runner/relay-worker.ts`)\n * can detect the same error class without hardcoding a fragile full-sentence\n * match — mirrors the `EVALUATE_TIMEOUT_MARKER` precedent in\n * `test-runner/relay-worker.ts`. Kept in sync with `classifyToolError`'s\n * `relayDisconnectError` branch in `mcp/errors.ts`.\n */\nexport const RELAY_DISCONNECT_MARKERS = [\n 'relay WebSocket',\n 'relay에 연결되어 있지 않습니다',\n] as const;\n\n/**\n * Returns true when `message` matches the relay-websocket-dead error class\n * (socket closed, socket errored, or fail-fast \"not connected\" rejection).\n */\nexport function isRelayDisconnectMessage(message: string): boolean {\n return RELAY_DISCONNECT_MARKERS.some((marker) => message.includes(marker));\n}\n\n/** A CDP message arriving over the relay websocket. */\ninterface CdpInboundMessage {\n id?: number;\n method?: string;\n params?: unknown;\n result?: unknown;\n error?: { message: string };\n}\n\n/**\n * Events emitted by `ChiiCdpConnection` for crash / lifecycle notifications.\n * Consumers (e.g. the tool layer) can subscribe with `.onLifecycle(cb)`.\n */\nexport interface TargetLifecycleEvent {\n /**\n * 'crashed' → Inspector.targetCrashed\n * 'destroyed' → Target.targetDestroyed\n * 'detached' → Target.detachedFromTarget\n * 'replaced' → evicted by single-attach model (last-attach wins)\n */\n kind: 'crashed' | 'destroyed' | 'detached' | 'replaced';\n targetId: string | null;\n /** ISO timestamp of detection. */\n detectedAt: string;\n}\n\nfunction isObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\nfunction parseInbound(raw: string): CdpInboundMessage | null {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!isObject(parsed)) return null;\n const message: CdpInboundMessage = {};\n if (typeof parsed.id === 'number') message.id = parsed.id;\n if (typeof parsed.method === 'string') message.method = parsed.method;\n if ('params' in parsed) message.params = parsed.params;\n if ('result' in parsed) message.result = parsed.result;\n if (isObject(parsed.error) && typeof parsed.error.message === 'string') {\n message.error = { message: parsed.error.message };\n }\n return message;\n}\n\nconst PHASE_1_EVENTS: readonly CdpEventName[] = [\n 'Runtime.consoleAPICalled',\n 'Network.requestWillBeSent',\n 'Network.responseReceived',\n];\n\n/**\n * Ring buffer size for `Runtime.exceptionThrown`.\n *\n * Exceptions are rarer than console messages but each is heavier (stack\n * trace). 50 is generous enough to cover a crash scenario while keeping\n * memory bounded.\n *\n * **Lifecycle note**: the exception buffer intentionally survives `replaced` /\n * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target\n * transitions. Rationale: an exception fired just before a crash is exactly\n * the signal we want to preserve for root-cause analysis. The buffer\n * represents \"exceptions seen in this MCP session\", not \"exceptions in the\n * current page\".\n */\nconst EXCEPTION_BUFFER_SIZE = 50;\n\nexport interface ChiiCdpConnectionOptions {\n /** Base URL of the local Chii relay HTTP/WS server, e.g. `http://127.0.0.1:9100`. */\n relayBaseUrl: string;\n /** Per-domain ring buffer size. */\n bufferSize?: number;\n /**\n * Default per-command timeout in milliseconds.\n * Override via env `AIT_CDP_COMMAND_TIMEOUT_MS`.\n * Defaults to 30 000 ms (30s).\n */\n commandTimeoutMs?: number;\n /**\n * Hex-encoded TOTP secret (the SECRET, never a code). When set, each client WS\n * (re)connect mints a fresh `at=` code so it lands inside the relay's 90s\n * acceptance window. Leave undefined when the relay has TOTP disabled.\n * SECRET-HANDLING: stored privately, never logged; the minted code rides only\n * in the WS URL query.\n */\n totpSecret?: string;\n}\n\n/** Default per-command timeout if neither option nor env var is set. */\nconst DEFAULT_COMMAND_TIMEOUT_MS = 30_000;\n\n/**\n * Production CDP connection. Polls the relay for the first attached target,\n * opens a client websocket to it, enables Phase 1 domains, and buffers events.\n */\nexport class ChiiCdpConnection implements CdpConnection {\n /** Authoritative connection kind (issue #348) — relay-backed. */\n readonly kind = 'relay' as const;\n\n private readonly relayBaseUrl: string;\n private readonly bufferSize: number;\n private readonly commandTimeoutMs: number;\n private readonly totpSecret: string | undefined;\n private readonly emitter = new EventEmitter();\n private readonly buffers = new Map<CdpEventName, unknown[]>();\n private readonly targets = new Map<string, CdpTarget>();\n\n private ws: WebSocket | null = null;\n private connectionState: 'idle' | 'connected' | 'disconnected' = 'idle';\n private nextCommandId = 1;\n /**\n * The single active target id under the single-attach model.\n * Updated by `refreshTargets()` whenever a non-null target is present.\n * Used to detect a new (different) target attach and evict the previous one.\n */\n private activeTargetId: string | null = null;\n /** In-flight enableDomains() promise — concurrent callers share it. */\n private enablingPromise: Promise<void> | null = null;\n /** Pending request→response commands keyed by CDP message id. */\n private readonly pending = new Map<\n number,\n { resolve: (result: unknown) => void; reject: (err: Error) => void }\n >();\n\n /**\n * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,\n * or `null` if no crash has been detected since the last `enableDomains()`.\n */\n private lastCrashDetectedAt: number | null = null;\n\n /**\n * Per-target last-seen timestamp (ms since epoch). Updated on any inbound\n * CDP message carrying data from a target. Keyed by target id.\n */\n private readonly targetLastSeenAt = new Map<string, number>();\n\n /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */\n private heartbeatHandle: ReturnType<typeof setInterval> | null = null;\n\n /** Lifecycle event listeners (crash / destroyed / detached). */\n private readonly lifecycleListeners: Array<(event: TargetLifecycleEvent) => void> = [];\n\n constructor(options: ChiiCdpConnectionOptions) {\n this.relayBaseUrl = options.relayBaseUrl.replace(/\\/$/, '');\n this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;\n this.totpSecret = options.totpSecret;\n const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS\n ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS)\n : undefined;\n this.commandTimeoutMs =\n (envMs !== undefined && Number.isFinite(envMs) && envMs > 0 ? envMs : undefined) ??\n options.commandTimeoutMs ??\n DEFAULT_COMMAND_TIMEOUT_MS;\n for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);\n // Exception buffer initialized separately — its per-event size cap\n // (EXCEPTION_BUFFER_SIZE=50) is enforced in handleMessage below.\n this.buffers.set('Runtime.exceptionThrown', []);\n // EventEmitter caps listeners at 10 by default; the tool layer may add\n // several short-lived subscriptions, so lift the cap.\n this.emitter.setMaxListeners(0);\n }\n\n /** Refresh the attached-target list from the relay's `GET /targets`. */\n async refreshTargets(): Promise<CdpTarget[]> {\n // When TOTP is active, append a freshly-minted code as `?at=<code>` so the\n // relay's /targets gate (issue #474) accepts this daemon poll. Mirrors the\n // existing /client WS pattern (~line 412). `generateTotp` defaults `when` to\n // Date.now() — rely on that default; never hand-compute the time.\n // SECRET-HANDLING: never log `code` or `this.totpSecret`; the code rides only\n // in the at= param.\n let targetsUrl = `${this.relayBaseUrl}/targets`;\n if (this.totpSecret) {\n const code = generateTotp(this.totpSecret);\n targetsUrl += `?at=${encodeURIComponent(code)}`;\n }\n const res = await fetch(targetsUrl);\n if (!res.ok) {\n throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);\n }\n const body: unknown = await res.json();\n const list = isObject(body) && Array.isArray(body.targets) ? body.targets : [];\n\n // Single-attach model: find the \"newest\" target id from the relay response.\n // The relay may return multiple targets if the previous session did not cleanly\n // detach. We keep only the last entry (last-attach wins) and evict the previous\n // active target if it differs.\n let newestTargetId: string | null = null;\n for (const item of list) {\n if (!isObject(item) || typeof item.id !== 'string') continue;\n newestTargetId = item.id; // last wins\n }\n\n // Evict previous active target when a genuinely new targetId arrives.\n if (\n newestTargetId !== null &&\n this.activeTargetId !== null &&\n newestTargetId !== this.activeTargetId\n ) {\n const prevId = this.activeTargetId;\n // SECRET-HANDLING: prevTargetId is a Chii internal ID (not a secret) but\n // keep it short — no URL or credentials logged here.\n logInfo('page.detached', { prevTargetId: prevId });\n this.evictTarget(prevId);\n }\n\n // Rebuild the targets map with at most the single newest target.\n this.targets.clear();\n for (const item of list) {\n if (!isObject(item) || typeof item.id !== 'string') continue;\n // Single-attach model: only register the newest target.\n if (item.id !== newestTargetId) continue;\n this.targets.set(item.id, {\n id: item.id,\n title: typeof item.title === 'string' ? item.title : '',\n url: typeof item.url === 'string' ? item.url : '',\n });\n }\n\n if (newestTargetId !== null) {\n this.activeTargetId = newestTargetId;\n } else {\n this.activeTargetId = null;\n }\n\n const result = [...this.targets.values()];\n\n // Emit 'target:attached' for every newly-seen target so waitForFirstTarget()\n // can race against the next refreshTargets() polling round.\n if (newestTargetId !== null) {\n this.emitter.emit('target:attached', result);\n }\n\n return result;\n }\n\n listTargets(): CdpTarget[] {\n return [...this.targets.values()];\n }\n\n /**\n * Waits until at least one target matching `filterFn` is attached, then\n * resolves with the full target list at that moment.\n *\n * Resolution happens on whichever comes first:\n * (a) a `'target:attached'` event from `refreshTargets()` (triggered by\n * the /targets poll finding a new target), OR\n * (b) a `'target:attached'` event from `handleMessage()` (triggered by\n * the first inbound CDP message from a target — confirms the relay\n * websocket has data from the phone, not just a target entry in the map).\n *\n * This dual-signal approach eliminates the polling race that previously\n * caused `wait_for_attach` to resolve before the first CDP message arrived.\n *\n * Falls back to checking `listTargets()` every `pollIntervalMs` in case the\n * EventEmitter is missed (defensive belt-and-suspenders).\n *\n * @param filterFn - Predicate that the returned targets must satisfy.\n * @param timeoutMs - Reject after this many ms (default 90 000). Pass a\n * non-finite value (`Infinity`) to disable the rejection timer entirely —\n * used by the test-runner's unbounded QR-attach wait (devtools#735). Node\n * clamps `setTimeout(fn, Infinity)` to ~1ms, so a non-finite value MUST\n * skip arming the timer rather than pass it through.\n * @param pollIntervalMs - Fallback poll interval (default 500ms).\n */\n waitForFirstTarget(\n filterFn: (targets: CdpTarget[]) => boolean,\n timeoutMs = 90_000,\n pollIntervalMs = 500,\n ): Promise<CdpTarget[]> {\n // Fast path: already attached.\n const current = this.listTargets();\n if (filterFn(current)) return Promise.resolve(current);\n\n return new Promise<CdpTarget[]>((resolve, reject) => {\n let settled = false;\n let pollHandle: ReturnType<typeof setInterval> | null = null;\n\n const settle = (targets: CdpTarget[]): void => {\n if (settled) return;\n settled = true;\n if (timeoutHandle !== null) clearTimeout(timeoutHandle);\n if (pollHandle !== null) {\n clearInterval(pollHandle);\n pollHandle = null;\n }\n this.emitter.off('target:attached', onAttach);\n resolve(targets);\n };\n\n const onAttach = (targets: CdpTarget[]): void => {\n if (filterFn(targets)) settle(targets);\n };\n\n // Non-finite timeoutMs (Infinity) means \"wait forever\" — do NOT arm the\n // rejection timer. Node clamps setTimeout(fn, Infinity) to ~1ms, which\n // would reject almost immediately if passed through unguarded.\n const timeoutHandle: ReturnType<typeof setTimeout> | null = Number.isFinite(timeoutMs)\n ? setTimeout(() => {\n if (settled) return;\n settled = true;\n if (pollHandle !== null) {\n clearInterval(pollHandle);\n pollHandle = null;\n }\n this.emitter.off('target:attached', onAttach);\n reject(\n new Error(\n `waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`,\n ),\n );\n }, timeoutMs)\n : null;\n\n // Primary: event-driven path.\n this.emitter.on('target:attached', onAttach);\n\n // Fallback: polling path — also calls refreshTargets() to keep the in-memory\n // target map up-to-date. This ensures the polling path works even without\n // a live WebSocket (pre-enableDomains) and catches targets that appear\n // between 'target:attached' events.\n pollHandle = setInterval(() => {\n // Refresh from relay, then check. Errors are ignored — we keep polling.\n this.refreshTargets().then(\n (targets) => {\n if (filterFn(targets)) settle(targets);\n },\n () => {\n // Relay temporarily unreachable — keep polling.\n },\n );\n }, pollIntervalMs);\n });\n }\n\n /**\n * Timestamp (ms since epoch) of the most recent crash/destroy/detach event\n * detected since the last `enableDomains()` call, or `null` if none.\n */\n getLastCrashDetectedAt(): number | null {\n return this.lastCrashDetectedAt;\n }\n\n /**\n * Last-seen timestamp (ms since epoch) for a given target id, or `null` if\n * the target is unknown / no message has been received from it yet.\n */\n getTargetLastSeenAt(targetId: string): number | null {\n return this.targetLastSeenAt.get(targetId) ?? null;\n }\n\n /** Subscribe to target lifecycle events (crash / destroyed / detached). */\n onLifecycle(listener: (event: TargetLifecycleEvent) => void): () => void {\n this.lifecycleListeners.push(listener);\n return () => {\n const idx = this.lifecycleListeners.indexOf(listener);\n if (idx !== -1) this.lifecycleListeners.splice(idx, 1);\n };\n }\n\n /**\n * Connect a client websocket to the first attached target and enable Phase 1\n * domains. Resolves once the socket is open and enable commands are sent.\n */\n async enableDomains(): Promise<void> {\n if (this.ws && this.ws.readyState === WebSocket.OPEN) return;\n // If a connect attempt is already in-flight, await it rather than racing\n // to open a second websocket that would overwrite `this.ws` and leak the first.\n if (this.enablingPromise) return this.enablingPromise;\n this.enablingPromise = this._doEnableDomains().finally(() => {\n this.enablingPromise = null;\n });\n return this.enablingPromise;\n }\n\n private async _doEnableDomains(): Promise<void> {\n const targets = await this.refreshTargets();\n const target = targets[0];\n if (!target) {\n throw new Error('No mini-app page attached to the Chii relay yet.');\n }\n\n const wsBase = this.relayBaseUrl.replace(/^http/, 'ws');\n const clientId = `devtools-mcp-${Date.now()}`;\n let clientUrl = `${wsBase}/client/${clientId}?target=${encodeURIComponent(target.id)}`;\n // Append a freshly-minted TOTP code so the relay's WS upgrade gate\n // (chii-relay.ts verifyAuth) accepts this /client upgrade. Minted per-connect\n // so reconnects stay inside the 90s acceptance window. generateTotp defaults\n // `when` to Date.now() — rely on that default; never hand-compute the time.\n // SECRET-HANDLING: never log `code` or `this.totpSecret`; the code rides only\n // in the URL query.\n if (this.totpSecret) {\n const code = generateTotp(this.totpSecret);\n clientUrl += `&at=${encodeURIComponent(code)}`;\n }\n const ws = new WebSocket(clientUrl);\n this.ws = ws;\n\n await new Promise<void>((resolve, reject) => {\n ws.once('open', () => resolve());\n ws.once('error', (err: Error) => reject(err));\n // Issue #478: the relay rejects auth with accept-then-close (4401)\n // instead of a raw 401 destroy, so a rejected dial no longer surfaces\n // as an 'error' event. 'open' always precedes the close frame, making\n // this reject a settled-promise no-op in practice — kept as a defensive\n // boundary (and for any relay that closes before open). The post-open\n // 4401 is recognised by the persistent close handler below.\n ws.once('close', (code: number) => {\n if (code === RELAY_AUTH_REJECT_CLOSE_CODE) {\n reject(\n new Error(\n 'relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다.',\n ),\n );\n }\n });\n });\n\n // Reset crash state when a new connection is established.\n this.lastCrashDetectedAt = null;\n this.targetLastSeenAt.clear();\n // activeTargetId is already set by refreshTargets() above; don't reset here.\n\n this.connectionState = 'connected';\n ws.on('message', (data: WebSocket.RawData) => this.handleMessage(data.toString()));\n // Issue #478: close 4401 is the relay's named TOTP rejection\n // (accept-then-close) — name it as an auth failure instead of a generic\n // drop. #439's per-connect fresh code mint means this should not happen in\n // practice; defensive alignment with the relay contract.\n ws.on('close', (code: number) =>\n this.handleDisconnect(\n code === RELAY_AUTH_REJECT_CLOSE_CODE\n ? 'relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)'\n : 'relay WebSocket 연결이 끊겼습니다',\n ),\n );\n ws.on('error', (err: Error) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));\n\n this.sendFireAndForget('Runtime.enable');\n this.sendFireAndForget('Network.enable');\n // DOM/Page domains back the Phase 2 command tools; Chii answers their\n // request→response commands once enabled.\n this.sendFireAndForget('DOM.enable');\n this.sendFireAndForget('Page.enable');\n // Subscribe to page-level crash and target lifecycle events.\n // Inspector.targetCrashed fires when a page OOM/JS-crash/native-bridge crash.\n // Target.setDiscoverTargets enables Target.targetDestroyed + Target.detachedFromTarget.\n this.sendFireAndForget('Inspector.enable');\n this.sendFireAndForget('Target.setDiscoverTargets', { discover: true });\n\n // Optional heartbeat: env AIT_CDP_HEARTBEAT_MS=N enables a ping loop.\n this.startHeartbeat(target.id);\n }\n\n /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */\n private sendFireAndForget(method: string, params: Record<string, unknown> = {}): void {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;\n const id = this.nextCommandId++;\n this.ws.send(JSON.stringify({ id, method, params }));\n }\n\n /**\n * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP\n * error frame or when no websocket is open (no page attached yet).\n *\n * @param opts.timeoutMs - Per-call override for this connection's command\n * watchdog (devtools#747) — see the `CdpConnection.send` docblock for the\n * contract callers racing their own longer timeout must follow.\n */\n send<M extends CdpCommandName>(\n method: M,\n params?: CdpCommandMap[M]['params'],\n opts?: { timeoutMs?: number },\n ): Promise<CdpCommandMap[M]['result']> {\n return this.sendCommand(method, (params ?? {}) as Record<string, unknown>, opts) as Promise<\n CdpCommandMap[M]['result']\n >;\n }\n\n /**\n * Issue an arbitrary request→response command over the relay and resolve with\n * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3\n * `AIT.*` methods, forwarded over the same Chii channel) build on this.\n *\n * Rejects immediately if the connection is disconnected (fail-fast — no\n * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to\n * reattach.\n *\n * Times out after `opts.timeoutMs` when given, else `commandTimeoutMs`\n * (default 30s, env `AIT_CDP_COMMAND_TIMEOUT_MS`) — see devtools#747: the\n * default 30s watchdog used to undercut the test-runner's own longer\n * file-evaluate race no matter what `--timeout` the caller asked for. On\n * timeout the pending entry is cleaned up and the promise rejects with a\n * descriptive Korean error. `Number.isFinite` guards against a non-finite\n * override (e.g. `Infinity`, mirroring `waitForFirstTarget`'s convention)\n * so an intentional \"no watchdog\" override doesn't get clamped by\n * `setTimeout`.\n */\n sendCommand(\n method: string,\n params: Record<string, unknown> = {},\n opts?: { timeoutMs?: number },\n ): Promise<unknown> {\n // Fail-fast: connection already known to be dead — don't write into a dead socket.\n if (this.connectionState === 'disconnected') {\n return Promise.reject(\n new Error(\n `relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`,\n ),\n );\n }\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n return Promise.reject(\n new Error('No mini-app page attached to the Chii relay yet. Call enableDomains() first.'),\n );\n }\n const id = this.nextCommandId++;\n const ws = this.ws;\n const timeoutMs =\n opts?.timeoutMs !== undefined && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0\n ? opts.timeoutMs\n : this.commandTimeoutMs;\n return new Promise<unknown>((resolve, reject) => {\n const handle = setTimeout(() => {\n this.pending.delete(id);\n reject(\n new Error(\n `CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). ` +\n '폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. ' +\n 'list_pages로 attach 상태를 확인하세요.',\n ),\n );\n }, timeoutMs);\n this.pending.set(id, {\n resolve: (v) => {\n clearTimeout(handle);\n resolve(v);\n },\n reject: (e) => {\n clearTimeout(handle);\n reject(e);\n },\n });\n ws.send(JSON.stringify({ id, method, params }));\n });\n }\n\n /**\n * Called on WebSocket `close` or `error` after a successful connection.\n * Rejects all pending commands and marks the connection as disconnected so\n * subsequent `sendCommand` calls fail fast (no auto-reconnect).\n */\n private handleDisconnect(reason: string): void {\n if (this.connectionState === 'disconnected') return; // already handled\n this.connectionState = 'disconnected';\n this.ws = null;\n this.stopHeartbeat();\n const err = new Error(\n `${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`,\n );\n for (const waiter of this.pending.values()) {\n waiter.reject(err);\n }\n this.pending.clear();\n }\n\n /**\n * Evict a previously active target under the single-attach model.\n * Rejects pending commands with a 'replaced-by-new-attach' reason and emits\n * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific\n * targetId. The caller is responsible for rebuilding the targets map afterwards.\n *\n * The error message uses 'replaced-by-new-attach' so test assertions can match it.\n */\n private evictTarget(targetId: string): void {\n const detectedAt = new Date().toISOString();\n this.targets.delete(targetId);\n this.targetLastSeenAt.delete(targetId);\n\n const err = new Error(\n `[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). ` +\n 'list_pages로 현재 attach 상태를 확인하세요.',\n );\n for (const waiter of this.pending.values()) {\n waiter.reject(err);\n }\n this.pending.clear();\n\n const event: TargetLifecycleEvent = { kind: 'replaced', targetId, detectedAt };\n for (const listener of this.lifecycleListeners) {\n try {\n listener(event);\n } catch {\n // Listeners must not crash the connection.\n }\n }\n }\n\n /**\n * Handle a page-level crash or target destruction event.\n * Removes the target from the in-memory map, rejects all pending commands,\n * and emits a lifecycle event.\n *\n * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'\n * @param targetId - The target ID from the event params (may be null for\n * Inspector.targetCrashed which has no targetId in the params).\n */\n private handleTargetGone(kind: TargetLifecycleEvent['kind'], targetId: string | null): void {\n const detectedAt = new Date().toISOString();\n this.lastCrashDetectedAt = Date.now();\n\n // Remove matching target(s) from the in-memory map.\n if (targetId !== null) {\n this.targets.delete(targetId);\n this.targetLastSeenAt.delete(targetId);\n // Also clear activeTargetId when the active target is gone.\n if (this.activeTargetId === targetId) {\n this.activeTargetId = null;\n }\n } else {\n // Inspector.targetCrashed carries no targetId — clear all targets.\n this.targets.clear();\n this.targetLastSeenAt.clear();\n this.activeTargetId = null;\n }\n\n // Reject pending commands with a descriptive Korean error.\n const label =\n kind === 'crashed'\n ? 'page crash (Inspector.targetCrashed)'\n : kind === 'destroyed'\n ? 'target 종료 (Target.targetDestroyed)'\n : 'target detach (Target.detachedFromTarget)';\n const err = new Error(\n `[ait-debug] ${label} 감지됨 — relay에서 제거됐습니다. ` +\n '새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).',\n );\n for (const waiter of this.pending.values()) {\n waiter.reject(err);\n }\n this.pending.clear();\n\n // Notify lifecycle listeners.\n const event: TargetLifecycleEvent = { kind, targetId, detectedAt };\n for (const listener of this.lifecycleListeners) {\n try {\n listener(event);\n } catch {\n // Listeners must not crash the connection.\n }\n }\n }\n\n /**\n * Start the optional CDP heartbeat loop.\n *\n * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval\n * we send `Runtime.evaluate({expression: '1'})` to each active target. If\n * the command times out (2 s hard deadline) or errors, we treat the target\n * as dead and call `handleTargetGone`.\n *\n * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws\n * even when the phone app has crashed, so the ws-level disconnect (#252) won't\n * fire. The heartbeat catches this gap.\n *\n * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.\n */\n private startHeartbeat(initialTargetId: string): void {\n this.stopHeartbeat(); // clear any previous interval\n\n const envMs = process.env.AIT_CDP_HEARTBEAT_MS\n ? Number(process.env.AIT_CDP_HEARTBEAT_MS)\n : undefined;\n if (envMs === undefined || !Number.isFinite(envMs) || envMs <= 0) return;\n\n const PING_TIMEOUT_MS = 2_000;\n\n this.heartbeatHandle = setInterval(() => {\n // Take a snapshot of current targets to avoid mutation during iteration.\n const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];\n for (const targetId of targetIds) {\n // Issue a lightweight eval with a 2 s deadline.\n const pingPromise = this.sendCommand('Runtime.evaluate', {\n expression: '1',\n returnByValue: true,\n timeout: PING_TIMEOUT_MS,\n });\n const timeoutPromise = new Promise<never>((_, reject) =>\n setTimeout(\n () => reject(new Error('heartbeat timeout')),\n PING_TIMEOUT_MS + 500, // slightly longer than the CDP timeout\n ),\n );\n Promise.race([pingPromise, timeoutPromise]).catch(() => {\n // Ping failed: mark target as dead if it still exists in the map.\n if (this.targets.has(targetId)) {\n this.handleTargetGone('destroyed', targetId);\n }\n });\n }\n }, envMs) as unknown as ReturnType<typeof setInterval>;\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatHandle !== null) {\n clearInterval(this.heartbeatHandle);\n this.heartbeatHandle = null;\n }\n }\n\n private handleMessage(raw: string): void {\n const message = parseInbound(raw);\n if (!message) return;\n\n // Command response (has an id matching a pending request).\n if (typeof message.id === 'number' && this.pending.has(message.id)) {\n const waiter = this.pending.get(message.id);\n this.pending.delete(message.id);\n if (waiter) {\n if (message.error) waiter.reject(new Error(message.error.message));\n else waiter.resolve(message.result);\n }\n return;\n }\n\n // Any inbound message implies the connection is active — update lastSeenAt\n // for whichever target we currently know about (single-target model).\n // Also emit 'target:attached' on the first inbound message from a target\n // (targetLastSeenAt unset) so waitForFirstTarget() resolves on first CDP\n // message, not just on the next /targets poll.\n const now = Date.now();\n let firstMessageSeen = false;\n for (const targetId of this.targets.keys()) {\n if (!this.targetLastSeenAt.has(targetId)) {\n firstMessageSeen = true;\n }\n this.targetLastSeenAt.set(targetId, now);\n }\n if (firstMessageSeen && this.targets.size > 0) {\n this.emitter.emit('target:attached', [...this.targets.values()]);\n }\n\n if (typeof message.method !== 'string') return;\n\n // --- Target lifecycle events ---\n\n // Inspector.targetCrashed: page OOM / JS exception / native bridge crash.\n // Params are usually empty; no targetId field in the event.\n if (message.method === 'Inspector.targetCrashed') {\n this.handleTargetGone('crashed', null);\n return;\n }\n\n // Target.targetDestroyed: params = { targetId: string }\n if (message.method === 'Target.targetDestroyed') {\n const targetId =\n isObject(message.params) && typeof message.params.targetId === 'string'\n ? message.params.targetId\n : null;\n this.handleTargetGone('destroyed', targetId);\n return;\n }\n\n // Target.detachedFromTarget: params = { sessionId, targetId? }\n if (message.method === 'Target.detachedFromTarget') {\n const targetId =\n isObject(message.params) && typeof message.params.targetId === 'string'\n ? message.params.targetId\n : null;\n this.handleTargetGone('detached', targetId);\n return;\n }\n\n // --- Phase 1 event stream (buffered ring-buffer) ---\n if (!this.buffers.has(message.method as CdpEventName)) return;\n const event = message.method as CdpEventName;\n const buffer = this.buffers.get(event);\n if (!buffer) return;\n buffer.push(message.params);\n // Runtime.exceptionThrown uses a dedicated smaller cap (50); all other\n // Phase 1 events use the default bufferSize (500).\n const cap = event === 'Runtime.exceptionThrown' ? EXCEPTION_BUFFER_SIZE : this.bufferSize;\n if (buffer.length > cap) buffer.shift();\n this.emitter.emit(event, message.params);\n }\n\n getBufferedEvents<E extends CdpEventName>(event: E): ReadonlyArray<CdpEventMap[E]> {\n const buffer = this.buffers.get(event);\n return (buffer ?? []) as ReadonlyArray<CdpEventMap[E]>;\n }\n\n on<E extends CdpEventName>(event: E, listener: (payload: CdpEventMap[E]) => void): () => void {\n this.emitter.on(event, listener as (payload: unknown) => void);\n return () => this.emitter.off(event, listener as (payload: unknown) => void);\n }\n\n /** Close the relay client websocket and reject any in-flight commands. */\n close(): void {\n const ws = this.ws;\n this.stopHeartbeat();\n // handleDisconnect clears this.ws and pending; call it first so the 'close'\n // event from ws.close() below is a no-op (already disconnected).\n this.handleDisconnect('Chii relay connection closed');\n ws?.close();\n }\n}\n","/**\n * esbuild-based bundler for user test files.\n *\n * Bundles a single test file into a self-contained IIFE string that can be\n * injected into a WebView via `Runtime.evaluate`. The bundle includes the\n * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and\n * the `runTestModule(factory)` entry point.\n *\n * ## How the wiring works\n *\n * The bundle exposes two exports on `globalThis.__testBundle`:\n * - `runTestModule` — the runtime's entry function.\n * - `__userFactory` — an async function whose body is the user's top-level\n * test registration code (describe/it/test calls).\n *\n * The Node-side RPC (`rpc.ts`) calls:\n * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`\n *\n * `runTestModule` then installs `describe/it/test/expect` as globals, invokes\n * the factory (which registers all tests), runs them, and returns a `RunReport`.\n *\n * ## Why a factory wrapper is needed\n *\n * Naively adding the runtime to `entryPoints` and bundling the user file would\n * fail for two reasons:\n * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE\n * scope. The user's top-level `describe(...)` calls expect them as globals —\n * they are not globals until `runTestModule` installs them.\n * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation\n * time, before the RPC layer calls `runTestModule` to reset state and start\n * the test clock.\n *\n * The factory approach solves both: the user's registration code is deferred\n * into a function that `runTestModule` calls AFTER installing the globals.\n *\n * ## Factory extraction algorithm\n *\n * The `userFactoryPlugin` reads the user file and splits lines into:\n * - **top-level**: `import …` and re-export lines — kept at module scope\n * (the only valid position for static `import` in ESM).\n * - **body**: all other statements — moved into the body of the exported\n * `__userFactory` async function.\n *\n * esbuild processes the re-generated module, following each static import\n * through the normal dependency graph (including the SDK-redirect plugin).\n *\n * ## SDK redirect\n *\n * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via\n * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that\n * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.\n *\n * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.\n */\n\nimport { accessSync } from 'node:fs';\nimport * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n// esbuild is imported for TYPES only at module scope; the runtime module is\n// loaded lazily inside `bundleTestFile` via dynamic import. esbuild runs a\n// startup invariant check (`TextEncoder().encode('') instanceof Uint8Array`)\n// that fails in a jsdom realm — a static import would break every MCP/test\n// module that merely *imports* this file's transitive graph (e.g. debug-server →\n// run_tests). Lazy load keeps esbuild off the import graph until a bundle is\n// actually built, and mirrors the cloudflared/chii dynamic-import precedent.\nimport type * as esbuild from 'esbuild';\n\n/** Options accepted by `bundleTestFile`. */\nexport interface BundleOptions {\n /**\n * Additional esbuild `external` patterns. The SDK package\n * (`@apps-in-toss/web-framework` and `@apps-in-toss/web-framework/*`) is\n * always handled by the SDK redirect plugin — callers may add more patterns\n * to be left as globals.\n */\n extraExternals?: string[];\n /**\n * Global name for the IIFE output object. Defaults to `__testBundle`.\n * The runtime entry uses this to call `__testBundle.runTestModule(__userFactory)`.\n */\n globalName?: string;\n}\n\n/**\n * The result of bundling a test file.\n * `code` is a self-contained IIFE string ready for `Runtime.evaluate`.\n */\nexport interface BundleResult {\n code: string;\n warnings: string[];\n}\n\n/** The SDK package name that mini-app test code imports from. */\nconst SDK_PACKAGE = '@apps-in-toss/web-framework';\n\n/**\n * Names the runtime installs as globals before invoking the user factory.\n * The `vitest` virtual module re-exports each as a lazy getter that reads from\n * `globalThis` at access time. Keep in sync with the globals installed in\n * `runtime.ts#runTestModule`.\n */\nconst VITEST_GLOBAL_NAMES = [\n 'describe',\n 'it',\n 'test',\n 'expect',\n 'beforeAll',\n 'afterAll',\n 'beforeEach',\n 'afterEach',\n 'vi',\n] as const;\n\n/**\n * Matches the bare SDK package and any sub-path import\n * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).\n * Built from {@link SDK_PACKAGE} so the package name has a single source.\n */\nconst SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}`);\n\n/**\n * esbuild plugin that intercepts SDK imports and redirects them to the\n * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.\n *\n * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),\n * esbuild resolves it to a virtual module that re-exports all named exports\n * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not\n * be available in the test environment) while still making named imports work.\n *\n * If `window.__sdk` is absent (non-dog-food build), every access throws a\n * descriptive error rather than returning `undefined` silently.\n *\n * Bridge-stub interception (devtools#740, DT-2): the virtual module runs the\n * resolved `window.__sdk` object through `wrapSdkWithStub` before exporting\n * it. `wrapSdkWithStub` is a pass-through (returns the same object,\n * unwrapped) unless `isStubBlockingEnabled()` reads `true` off\n * `globalThis.__AIT_STUB_BLOCKING__` — an opt-in flag injected the same way\n * `__AIT_CELL__` is (`cell.ts#injectGlobals`, wired from the CLI's\n * `--stub-blocking` flag). Default off => zero behavior change: every existing\n * (non-stub) test run resolves the exact same `window.__sdk` reference it did\n * before this plugin learned about stubbing.\n */\nfunction sdkRedirectPlugin(): esbuild.Plugin {\n return {\n name: 'sdk-redirect',\n setup(build) {\n // Match the bare package and any sub-path imports\n build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({\n path: args.path,\n namespace: 'sdk-redirect',\n }));\n\n build.onLoad({ filter: /.*/, namespace: 'sdk-redirect' }, () => ({\n // Generate a virtual CommonJS-style module so that esbuild does NOT perform\n // strict named-export matching. When `format:'iife'` bundles a CJS module,\n // it wraps it with its own __toCommonJS helper and satisfies named imports\n // via property access on the module.exports object — which is our Proxy.\n // This means `import { getPlatformOS } from '...'` becomes\n // `__proxy.getPlatformOS` at runtime, which correctly reads from window.__sdk.\n //\n // The bridge-stub import is a STATIC import of the real `bridge-stub.ts`\n // module (not inlined JS) so esbuild bundles it once and the fixture\n // registry has a single source of truth shared with its own unit tests.\n contents: `\nimport { wrapSdkWithStub, isStubBlockingEnabled } from ${JSON.stringify(getBridgeStubPath())};\nvar __rawSdk = (typeof window !== 'undefined' && window.__sdk)\n ? window.__sdk\n : new Proxy({}, {\n get: function(_t, p) {\n throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));\n }\n });\nvar __proxy = wrapSdkWithStub(__rawSdk, isStubBlockingEnabled());\nmodule.exports = __proxy;\n`,\n loader: 'js',\n // resolveDir is required so esbuild can resolve the absolute\n // getBridgeStubPath() import above from a filesystem base — a virtual\n // module has no implicit directory of its own. process.cwd() is a\n // safe base for an ABSOLUTE import specifier (it is never used to\n // resolve anything relative).\n resolveDir: process.cwd(),\n }));\n },\n };\n}\n\n/**\n * esbuild plugin that intercepts `import … from 'vitest'` and replaces it with\n * a virtual module that delegates every named import to `globalThis` at ACCESS\n * time (not at bundle-evaluation time).\n *\n * The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/\n * afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle\n * IIFE is evaluated. A value-copy redirect (`export var describe =\n * globalThis.describe`) would therefore capture `undefined` at evaluation time\n * and the user's `describe(...)` calls would be no-ops — registering zero tests.\n *\n * The fix defers the lookup to call time using per-name **getter** exports.\n * We emit a CommonJS module that:\n * 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named\n * import directly to a property access on the module (NOT wrapped under a\n * `default` shim — which is what happens for a bare Proxy whose own-keys\n * are empty, leaving every named import `undefined`);\n * 2. defines each global name as a getter that reads `globalThis[name]` on\n * every access. So `import { describe } from 'vitest'` compiles to\n * `import_vitest.describe`, whose getter returns the real `describe` only\n * when the factory calls it — after `runTestModule` installs the globals.\n *\n * A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes\n * the virtual module through `__toESM`, which enumerates own-keys (none on an\n * empty Proxy target) and therefore exposes zero named exports. Explicit getter\n * properties give `__toESM` real keys to map while keeping access lazy.\n */\nfunction vitestRedirectPlugin(): esbuild.Plugin {\n return {\n name: 'vitest-redirect',\n setup(build) {\n build.onResolve({ filter: /^vitest$/ }, () => ({\n path: 'vitest',\n namespace: 'vitest-redirect',\n }));\n\n build.onLoad({ filter: /^vitest$/, namespace: 'vitest-redirect' }, () => {\n const getters = VITEST_GLOBAL_NAMES.map(\n (name) =>\n `Object.defineProperty(exports, ${JSON.stringify(name)}, { enumerable: true, get: function() { return globalThis[${JSON.stringify(name)}]; } });`,\n ).join('\\n');\n // __esModule lets esbuild __toESM map named imports to these getters\n // directly. Each getter reads globalThis lazily so the value resolves at\n // call time — after runTestModule installs the globals.\n return {\n contents: `Object.defineProperty(exports, '__esModule', { value: true });\\n${getters}\\n`,\n loader: 'js',\n };\n });\n },\n };\n}\n\n/**\n * esbuild plugin that transforms the user test file into a module that exports\n * an async `__userFactory` function. The factory defers the user's top-level\n * test registration code (describe/it/test calls) so it only runs when\n * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has\n * installed describe/it/test/expect as globals.\n *\n * Algorithm:\n * - Import declarations and re-export statements are kept at module top-level\n * (the only valid ESM position for static `import`). A statement that spans\n * multiple lines — e.g. a named import with one member per line:\n * import {\n * appLogin,\n * getAnonymousKey,\n * } from '@apps-in-toss/web-framework';\n * is tracked as a single block: every line from the opening `import {` /\n * `export {` through the closing `from '…'` (or side-effect `'…'`) line is\n * kept together at top-level. This prevents the member lines and the\n * closing `} from '…'` line from leaking into the factory body, which would\n * leave an unterminated `import {` at module scope (the #678 env3 failure:\n * esbuild threw `Expected \"as\" but found \"{\"` on multi-line SDK imports).\n * - All other lines (describe/it/test calls, local declarations, etc.) are\n * moved into the body of the exported async factory function.\n *\n * This preserves SDK import resolution (the sdk-redirect plugin processes\n * top-level imports normally) while deferring test registration to the factory.\n */\nfunction userFactoryPlugin(absPath: string): esbuild.Plugin {\n const NAMESPACE = 'user-test-factory';\n return {\n name: 'user-test-factory',\n setup(build) {\n // Resolve the virtual \"user-test-factory\" specifier to our namespace.\n build.onResolve({ filter: /^user-test-factory$/ }, () => ({\n path: absPath,\n namespace: NAMESPACE,\n }));\n\n // Load the user file, split imports from body, wrap body in the factory.\n build.onLoad({ filter: /.*/, namespace: NAMESPACE }, async (args) => {\n const source = await fs.readFile(args.path, 'utf8');\n const lines = source.split('\\n');\n\n const topLevelLines: string[] = [];\n const bodyLines: string[] = [];\n\n // Matches `export` value declarations that cannot appear inside a\n // function body. We strip the `export` keyword so they become plain\n // declarations inside the factory.\n const EXPORT_DECLARATION_RE =\n /^(export\\s+)(default\\s+|async\\s+function\\s+|function\\s+|class\\s+|const\\s+|let\\s+|var\\s+)/;\n\n // True when `trimmed` begins a static `import` statement.\n const isImportStart = (trimmed: string): boolean =>\n trimmed.startsWith('import ') ||\n trimmed.startsWith('import{') ||\n trimmed.startsWith(\"import'\") ||\n trimmed.startsWith('import\"');\n\n // A module-scope `import`/`export … from` statement is \"complete\" on a\n // single line when it ends with a quoted module specifier (optionally\n // followed by `;`/whitespace), e.g. `… from '@x';` or side-effect\n // `import './x';`. A line ending in `{` or `,` (the common multi-line\n // named-import shape) is therefore NOT complete and must accumulate\n // further lines until the closing `from '…'` line.\n const endsStatement = (trimmed: string): boolean =>\n /['\"]\\s*;?\\s*$/.test(trimmed.replace(/\\/\\/.*$/, '').trimEnd());\n\n // When set, we are inside an unterminated multi-line import/re-export\n // block: every subsequent line stays at top level until the block ends.\n let inImportBlock = false;\n\n for (const line of lines) {\n const trimmed = line.trimStart();\n const indent = line.slice(0, line.length - trimmed.length);\n\n // Continuation of a multi-line import / re-export block — keep at\n // top level. The block ends on the line that terminates the\n // statement (closing `from '…'` / side-effect `'…'`).\n if (inImportBlock) {\n topLevelLines.push(line);\n if (endsStatement(trimmed)) {\n inImportBlock = false;\n }\n continue;\n }\n\n // Static import declarations must stay at module top level\n // (the ESM spec forbids `import` inside a function body). If the\n // statement does not terminate on this line, open an import block so\n // the member lines and closing `} from '…'` line stay top-level too.\n if (isImportStart(trimmed)) {\n topLevelLines.push(line);\n if (!endsStatement(trimmed)) {\n inImportBlock = true;\n }\n } else if (trimmed.startsWith('export ')) {\n // Determine whether this is a re-export (stays top-level) or a value\n // declaration (goes into the factory, export keyword stripped).\n const m = trimmed.match(EXPORT_DECLARATION_RE);\n if (m) {\n // Value declaration — strip `export ` and move into factory body.\n // e.g. `export function hello()` → `function hello()`\n // `export const x = 1` → `const x = 1`\n bodyLines.push(indent + trimmed.slice('export '.length));\n } else {\n // Re-export or `export type { … }` — stays at top level. A\n // multi-line `export { … } from '…'` opens an import block too.\n topLevelLines.push(line);\n if (/\\bfrom\\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith('{')) {\n inImportBlock = true;\n }\n }\n } else {\n bodyLines.push(line);\n }\n }\n\n const factoryContent = [\n ...topLevelLines,\n '',\n '// biome-ignore lint: generated factory wrapper',\n 'export default async function __userFactory(): Promise<void> {',\n ...bodyLines.map((l) => ` ${l}`),\n '}',\n ].join('\\n');\n\n return {\n contents: factoryContent,\n loader: 'ts',\n resolveDir: path.dirname(absPath),\n };\n });\n },\n };\n}\n\n/**\n * Returns the absolute filesystem path to a page-side leaf module shipped\n * alongside `bundle.ts` in dist (e.g. `runtime.js`, `bridge-stub.js`) — a\n * fully self-contained module with no further page-side deps of its own.\n *\n * Rolldown code-splitting duplicates this bundling logic into shared chunks\n * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from\n * dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`\n * daemon (dist/mcp/cli.js) pulls it through a ROOT chunk\n * (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list\n * is therefore wrong from at least one chunk — the live #697 regression.\n *\n * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it\n * probes the co-located `<name>.js` and the nested `test-runner/<name>.js`,\n * then ascends one directory at a time (bounded) repeating both probes. The\n * nested probe catches dist/test-runner/<name>.js from the dist/ root level no\n * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future\n * relocation). The build always emits dist/test-runner/<name>.js (tsdown entry\n * `'test-runner/<name>'`; guarded by scripts/check-test-runner-dist.sh for\n * `runtime`).\n *\n * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file\n * read, bypassing Node module resolution entirely, so this works identically in\n * the npx-daemon context (its own dist tree) and the consumer-CLI context\n * (the mini-app's installed @ait-co/devtools dist) — neither needs the package\n * to be node-resolvable from the caller.\n *\n * @param moduleName - The leaf module's basename without extension, e.g.\n * `'runtime'` or `'bridge-stub'`.\n */\nfunction getPageSideModulePath(moduleName: string): string {\n const startDir = path.dirname(fileURLToPath(import.meta.url));\n // .js first so a SHIPPED chunk never feeds esbuild raw TypeScript; the .ts\n // probe is last, for the source-tree (tsx) dev case where the module sits\n // beside bundle.ts and no dist exists.\n const RELATIVE_PROBES: string[][] = [\n [`${moduleName}.js`],\n ['test-runner', `${moduleName}.js`],\n [`${moduleName}.ts`],\n ['test-runner', `${moduleName}.ts`],\n ];\n // Bounded ascent: a package's dist is only a few levels deep. 12 is far more\n // than any real layout (root chunk → dist is 0 hops; dist/mcp → dist is 1)\n // and terminates well before the filesystem root in every case.\n let dir = startDir;\n for (let i = 0; i < 12; i++) {\n for (const segs of RELATIVE_PROBES) {\n const candidate = path.join(dir, ...segs);\n try {\n accessSync(candidate);\n return candidate;\n } catch {\n // try next probe\n }\n }\n const parent = path.dirname(dir);\n if (parent === dir) break; // reached filesystem root\n dir = parent;\n }\n // Nothing matched — return the co-located path so esbuild emits a clear\n // \"Could not resolve\" against a concrete path rather than failing cryptically.\n return path.join(startDir, `${moduleName}.js`);\n}\n\n/** Absolute path to the test-runner page-side runtime (describe/it/test/expect). */\nfunction getRuntimePath(): string {\n return getPageSideModulePath('runtime');\n}\n\n/**\n * Absolute path to the bridge-stub interceptor (devtools#740, DT-2) —\n * `wrapSdkWithStub`/`isStubBlockingEnabled`, statically imported by the\n * `sdk-redirect` virtual module.\n */\nfunction getBridgeStubPath(): string {\n return getPageSideModulePath('bridge-stub');\n}\n\n/**\n * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.\n *\n * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:\n * - `runTestModule` — the runtime entry (from `runtime.ts`).\n * - `__userFactory` — an async function wrapping the user's test registration\n * code so it runs AFTER `runTestModule` installs the globals.\n *\n * Callers (rpc.ts) invoke:\n * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`\n *\n * @param absPath - Absolute path to the user test file.\n * @param opts - Optional bundling overrides.\n */\nexport async function bundleTestFile(absPath: string, opts?: BundleOptions): Promise<BundleResult> {\n const globalName = opts?.globalName ?? '__testBundle';\n const extraExternals = opts?.extraExternals ?? [];\n\n // Lazy load esbuild at call time (see the module-scope import note).\n const esbuild = await import('esbuild');\n const runtimePath = getRuntimePath();\n\n // Stdin wrapper: import the runtime and the user factory, re-export both.\n // esbuild follows the static imports to include runtime.ts and the user file\n // (via the userFactoryPlugin) in the single IIFE output.\n const wrapperContent = [\n `import { runTestModule } from ${JSON.stringify(runtimePath)};`,\n `import __userFactory from \"user-test-factory\";`,\n `export { runTestModule, __userFactory };`,\n ].join('\\n');\n\n const result = await esbuild.build({\n stdin: {\n contents: wrapperContent,\n loader: 'ts',\n // resolveDir is used for relative imports from the wrapper. Since the\n // wrapper only imports absolute paths (runtimePath) and the virtual\n // \"user-test-factory\" specifier (resolved by plugin), the directory\n // doesn't matter — but we still provide a sensible default.\n resolveDir: path.dirname(absPath),\n },\n bundle: true,\n format: 'iife',\n globalName,\n platform: 'browser',\n target: 'es2022',\n write: false,\n plugins: [userFactoryPlugin(absPath), vitestRedirectPlugin(), sdkRedirectPlugin()],\n external: extraExternals,\n treeShaking: true,\n // Ensure the IIFE result is always reachable via globalThis regardless of\n // the evaluation context. esbuild's `globalName` emits:\n // var __testBundle = (() => { ... })();\n // When `Runtime.evaluate` runs this bundle code inside an outer wrapper\n // (rpc.ts's async IIFE), `var` creates a local variable — NOT a global\n // property — so `globalThis.__testBundle` stays `undefined`. The footer\n // explicitly assigns the local variable to `globalThis` to close that gap.\n footer: {\n js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};`,\n },\n });\n\n const warnings = result.warnings.map(\n (w) =>\n `${path.relative(process.cwd(), w.location?.file ?? '')}:${w.location?.line ?? '?'}: ${w.text}`,\n );\n\n const outputFile = result.outputFiles?.[0];\n if (!outputFile) {\n throw new Error('bundleTestFile: esbuild produced no output — check entryPoints');\n }\n\n return { code: outputFile.text, warnings };\n}\n","/**\n * `__AIT_CAPTURE__` console-line parser (devtools#696).\n *\n * env3 test runs inject sdk-example's `aitCapture.ts`, which — when running in a\n * WebView (no filesystem) — emits one `console.log` line per category in the\n * stable form:\n *\n * __AIT_CAPTURE__ <category> <json>\n *\n * The relay-worker harvests `Runtime.consoleAPICalled` events as plain text\n * lines (see `relay-worker.ts`) and hands them here. This module's only job is\n * to recognise the allowlisted prefix, split off the category, and keep the\n * remaining JSON string OPAQUE — devtools does NOT own or interpret the\n * `__AIT_CAPTURE__` record shape (that lives in sdk-example). We carry\n * `{ category, json }` through untouched so the 2.x↔3.0 line comparison stays a\n * downstream concern.\n *\n * Why a strict allowlist prefix (SECRET-HANDLING): the raw console stream may\n * also contain wss/scheme/relay noise lines. Only lines that EXACTLY start with\n * `'__AIT_CAPTURE__ '` pass; everything else is discarded, so a stray relay URL\n * can never be serialised into a capture artifact.\n *\n * react-free, dependency-free — safe to import from any test-runner entry\n * without dragging in the chii/cloudflared Node graph.\n */\n\n/**\n * One parsed capture line. `json` is the raw JSON string exactly as the page\n * emitted it (an array of sdk-example capture records). devtools treats it as\n * opaque — it is validated as parseable JSON but never inspected.\n */\nexport interface AitCaptureLine {\n /** The capture category (first whitespace-delimited token after the prefix). */\n category: string;\n /** The remaining JSON payload, verbatim. Opaque to devtools. */\n json: string;\n}\n\n/** The exact console-line prefix sdk-example's `flushCapture` emits. */\nconst CAPTURE_PREFIX = '__AIT_CAPTURE__ ';\n\n/**\n * Parses raw console line texts into {@link AitCaptureLine}s.\n *\n * Filtering rules (each independently drops a line — never throws):\n * - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);\n * - there must be a non-empty category token (up to the next space);\n * - the remaining payload must be valid JSON (`JSON.parse` succeeds).\n *\n * Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are\n * silently discarded — capture harvesting is best-effort and must never fail a\n * run or leak a malformed/secret-bearing line.\n *\n * @param raw - Console line objects (only `.text` is read).\n * @returns The captured lines, in input order.\n */\nexport function parseCaptureLines(raw: ReadonlyArray<{ text: string }>): AitCaptureLine[] {\n const out: AitCaptureLine[] = [];\n for (const { text } of raw) {\n // Allowlist: only the exact `__AIT_CAPTURE__ ` prefix passes. wss/scheme\n // noise lines are dropped here (SECRET-HANDLING).\n if (!text.startsWith(CAPTURE_PREFIX)) continue;\n\n const body = text.slice(CAPTURE_PREFIX.length);\n const spaceIdx = body.indexOf(' ');\n // No payload separator → malformed; drop.\n if (spaceIdx === -1) continue;\n\n const category = body.slice(0, spaceIdx);\n const json = body.slice(spaceIdx + 1);\n if (category === '' || json === '') continue;\n\n // Validate the payload is parseable JSON, but keep it as an OPAQUE string —\n // devtools does not own the record shape. Broken JSON is discarded (no throw).\n try {\n JSON.parse(json);\n } catch {\n continue;\n }\n\n out.push({ category, json });\n }\n return out;\n}\n","/**\n * Node-side RPC helpers for injecting and collecting test execution over CDP.\n *\n * Uses the same IIFE + JSON.stringify envelope pattern as `buildCallSdkExpression`\n * in `src/mcp/tools.ts` to reliably shuttle structured results through\n * `Runtime.evaluate`'s `returnByValue: true` boundary.\n *\n * SECRET-HANDLING: bundle code, relay URLs, and result values are NOT logged.\n */\n\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport type { RunReport } from './runtime.js';\n\n/** Maximum milliseconds to wait for a single evaluate round-trip. */\nconst DEFAULT_TIMEOUT_MS = 60_000;\n\n/**\n * Extra headroom (ms) added on top of the rpc-level `timeoutMs` when telling\n * the underlying `CdpConnection` its per-command watchdog budget (devtools#747).\n * The rpc-level race above is the authoritative file timeout — this margin\n * only ensures the connection's own (shorter-by-default) watchdog never fires\n * first and masks the rpc-level timeout error with a connection-level one.\n */\nconst CONNECTION_TIMEOUT_MARGIN_MS = 5_000;\n\n/**\n * Wraps bundle code in a self-executing IIFE that:\n * 1. Evaluates the bundle (registering describe/it/test).\n * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.\n * 3. Returns a JSON-serialised `RunReport` string.\n *\n * The double-serialisation (RunReport → JSON string → returnByValue string)\n * is intentional: CDP `returnByValue` reliably transports strings; deeply\n * nested objects can lose fidelity across the Chii relay.\n *\n * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.\n */\nexport function buildRunTestsExpression(bundleCode: string): string {\n // We trust bundleCode is already a self-contained IIFE that installs\n // `window.__testBundle` (or `globalThis.__testBundle`).\n // We then call `__testBundle.runTestModule()` and return a JSON string.\n return (\n `(async () => {` +\n // Step 1: evaluate the bundle to register tests\n ` try { ${bundleCode} } catch(e) {` +\n ` return JSON.stringify({ok:false,error:'bundle-eval: ' + String(e && e.message || e)});` +\n ` }` +\n // Step 2: check that the expected exports are present\n ` if (typeof globalThis.__testBundle !== 'object' || typeof globalThis.__testBundle.runTestModule !== 'function' || typeof globalThis.__testBundle.__userFactory !== 'function') {` +\n ` return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule or __userFactory is not a function'});` +\n ` }` +\n // Step 3: run tests — pass the factory so runTestModule installs globals\n // first, then invokes the factory to register describe/it/test blocks.\n ` try {` +\n ` const report = await globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory);` +\n ` return JSON.stringify({ok:true,value:report});` +\n ` } catch(e) {` +\n ` return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)});` +\n ` }` +\n `})()`\n );\n}\n\n/**\n * Result of `injectAndRunBundle`.\n */\nexport type RpcRunResult = { ok: true; report: RunReport } | { ok: false; error: string };\n\n/**\n * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`\n * evaluate call into a typed `RpcRunResult`.\n *\n * Throws only on parse failure — an `ok:false` envelope is a normal result.\n *\n * SECRET-HANDLING: `rawValue` is not included in error messages.\n */\nexport function parseRunTestsResult(rawValue: unknown): RpcRunResult {\n if (typeof rawValue !== 'string') {\n throw new Error(\n `rpc.parseRunTestsResult: unexpected return type \"${typeof rawValue}\" — expected JSON string`,\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(rawValue);\n } catch {\n // Do NOT include rawValue — could contain secrets.\n throw new Error('rpc.parseRunTestsResult: bridge returned non-JSON string');\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw new Error('rpc.parseRunTestsResult: parsed result is not an object');\n }\n const obj = parsed as Record<string, unknown>;\n if (obj.ok === true) {\n return { ok: true, report: obj.value as RunReport };\n }\n if (obj.ok === false) {\n return {\n ok: false,\n error: typeof obj.error === 'string' ? obj.error : String(obj.error),\n };\n }\n throw new Error('rpc.parseRunTestsResult: result missing \"ok\" field');\n}\n\n/**\n * Injects `bundleCode` into the attached page and awaits test execution.\n *\n * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the\n * async IIFE to settle. `timeoutMs` (default 60s) is the authoritative file\n * timeout — this function races it against the evaluate call AND (devtools#747)\n * threads it down to the underlying `CdpConnection`'s own per-command watchdog\n * (plus a small margin) so that watchdog can never undercut this race. Split\n * into smaller files if you hit the timeout.\n *\n * @param connection - Active CDP connection (relay or local).\n * @param bundleCode - IIFE bundle string from `bundleTestFile`.\n * @param timeoutMs - Override the default 60 s timeout.\n *\n * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.\n */\nexport async function injectAndRunBundle(\n connection: CdpConnection,\n bundleCode: string,\n timeoutMs = DEFAULT_TIMEOUT_MS,\n): Promise<RpcRunResult> {\n const expression = buildRunTestsExpression(bundleCode);\n\n // Use a tagged race so we can distinguish \"timeout won\" from \"evaluate won\"\n // without relying on exception identity. The timeout arm RETURNS a typed\n // RpcRunResult (ok:false + EVALUATE_TIMEOUT_MARKER) so the caller (relay-\n // worker's attempt()) can detect it via `.error.includes(EVALUATE_TIMEOUT_MARKER)`\n // and trigger the one-retry path.\n //\n // Genuine CDP `exceptionDetails` (engine exceptions thrown inside the page)\n // still THROW — those are deterministic failures that a retry cannot fix and\n // relay-worker's catch block finalises them correctly.\n //\n // DO NOT change the literal string below — relay-worker.ts imports\n // EVALUATE_TIMEOUT_MARKER = 'rpc: evaluate timed out after' and uses\n // `.includes()` to recognise this exact prefix.\n const TIMEOUT_SENTINEL = Symbol('timeout');\n const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) =>\n setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs),\n );\n\n // devtools#747: thread our own timeout budget down to the connection's\n // per-command watchdog (ChiiCdpConnection's default is 30s and used to fire\n // before this rpc-level race no matter how large `timeoutMs` was). A small\n // margin keeps the connection watchdog from firing a beat before our own\n // race settles — the rpc-level race above remains the authoritative file\n // timeout; this override only ensures the connection layer never undercuts\n // it. `LocalCdpConnection` ignores the option (no watchdog of its own).\n const evalPromise = connection.send(\n 'Runtime.evaluate',\n {\n expression,\n returnByValue: true,\n awaitPromise: true,\n },\n { timeoutMs: timeoutMs + CONNECTION_TIMEOUT_MARGIN_MS },\n );\n\n const raceResult = await Promise.race([\n evalPromise.then((v) => ({ tag: 'eval' as const, v })),\n timeoutPromise.then(() => ({ tag: 'timeout' as const })),\n ]);\n\n if (raceResult.tag === 'timeout') {\n return { ok: false, error: `rpc: evaluate timed out after ${timeoutMs}ms` };\n }\n\n const cdpResult = raceResult.v;\n\n if (cdpResult.exceptionDetails) {\n // Surface only the engine error string — not the expression or value.\n const msg =\n cdpResult.exceptionDetails.exception?.description ??\n cdpResult.exceptionDetails.text ??\n 'Runtime.evaluate threw an exception';\n throw new Error(`rpc.injectAndRunBundle: ${msg}`);\n }\n\n return parseRunTestsResult(cdpResult.result.value);\n}\n","/**\n * Orchestrator: runs a list of test files sequentially over a CDP relay.\n *\n * Each file goes through: bundle → inject → run → collect.\n * This is the transport layer: it does NOT integrate with Vitest's pool or the\n * MCP surface. The Vitest custom pool (`pool.ts`) and the `run_tests` MCP tool\n * are separate callers that build on this orchestrator.\n *\n * Single-attach constraint: only one page is active at a time. Files run\n * sequentially; parallel execution across targets is out of scope.\n *\n * The per-file timeout is inherited from `injectAndRunBundle` (default 60 s).\n * For suites that exceed it, split the file into smaller pieces.\n *\n * SECRET-HANDLING: file paths are surfaced in reports; relay URLs are not.\n */\n\nimport type { CdpConnection, ConsoleApiCalledEvent } from '../mcp/cdp-connection.js';\nimport { isRelayDisconnectMessage } from '../mcp/chii-connection.js';\nimport { type BundleOptions, bundleTestFile } from './bundle.js';\nimport { type AitCaptureLine, parseCaptureLines } from './capture.js';\nimport { runPermissionPreflight } from './cell.js';\nimport { injectAndRunBundle } from './rpc.js';\nimport type { RunReport, TestResult } from './runtime.js';\n\n/**\n * Per-file evaluate timeout (ms) for manual-variant files (devtools#741) — a\n * human is expected to be tapping through a native sheet, so the timeout must\n * be far longer than the unattended default (60s, rpc.ts DEFAULT_TIMEOUT_MS).\n * Fixed constant (not configurable in v1) — documented here + CLI `--help`.\n */\nexport const MANUAL_FILE_TIMEOUT_MS = 5 * 60_000;\n\n/** Per-file result in the aggregate `RunReport`. */\nexport interface FileResult {\n /** Absolute or relative path to the test file. */\n file: string;\n /** Full run report for this file, or an error if bundling/injection failed. */\n result: RunReport | { error: string };\n /**\n * Present only for files run under `--manual-blocking` or `--stub-blocking`\n * — report provenance so an attended/stubbed run is never confused with an\n * unattended baseline. Absent (not `false`) for regular files —\n * absence-means-unattended is the contract (report.ts mirrors this).\n *\n * - `'manual'` — devtools#741, human-attended, real native envelopes.\n * - `'stubbed'` — devtools#740 (DT-2), unattended, blocking-UI calls\n * answered from `bridge-stub.ts` fixtures instead of native UI. A\n * HYBRID cell, not pure env3 — see `bridge-stub.ts`'s module doc for the\n * full honesty contract. Never diffed against `'manual'`/unattended as\n * if equivalent.\n */\n mode?: 'manual' | 'stubbed';\n}\n\n/** Aggregate report returned by `runTestFilesOverRelay`. */\nexport interface RelayRunReport {\n /** ISO timestamp of when the run started. */\n startedAt: string;\n /** Total elapsed wall-clock milliseconds. */\n duration: number;\n /** Per-file results in execution order. */\n files: FileResult[];\n /** Flattened totals across all files. */\n totals: {\n passed: number;\n failed: number;\n skipped: number;\n total: number;\n };\n /**\n * `__AIT_CAPTURE__` lines harvested from the page console during the run\n * (additive field, devtools#696). Empty unless `collectCaptures` was set.\n * Each entry is opaque (`{ category, json }`) — devtools does not interpret\n * the record shape, only forwards it for downstream 2.x↔3.0 diffing.\n *\n * SECRET-HANDLING: only lines matching the `__AIT_CAPTURE__ ` allowlist\n * prefix reach here; relay/wss/scheme noise lines are dropped in\n * `parseCaptureLines`.\n */\n captures: AitCaptureLine[];\n /**\n * Permission-state preflight result (devtools#739) — the same map exposed\n * to the page as `globalThis.__AIT_PERMS__`, collected once before the\n * first file runs. `undefined` when the preflight failed, timed out, or\n * `window.__sdk` was not yet installed — a non-fatal outcome that does NOT\n * fail the run. Report provenance only: 4-cell diffs correlate test\n * outcomes with device permission state via this field. No secrets — only\n * `'allowed'|'denied'|'notDetermined'|'unavailable'` strings per key.\n */\n preflight?: { permissions: Record<string, string> };\n}\n\n/** Options for `runTestFilesOverRelay`. */\nexport interface RelayRunOptions {\n /**\n * Options forwarded to `bundleTestFile` for each file.\n */\n bundleOptions?: BundleOptions;\n /**\n * Per-file evaluate timeout in milliseconds. Defaults to 60 000 (rpc.ts\n * DEFAULT_TIMEOUT_MS). Increase for long-running suites or split the file.\n */\n timeoutMs?: number;\n /**\n * When `true`, registers a live `Runtime.consoleAPICalled` listener for the\n * duration of the run and harvests `__AIT_CAPTURE__` lines into\n * `RelayRunReport.captures`. Defaults to **false** so the build-only\n * eval/e2e path (and the Vitest pool) pay no listener/state overhead —\n * `captures` is then an empty array.\n *\n * The listener is registered just BEFORE the first file runs and removed in a\n * `finally` so it never accumulates across runs (no ring-buffer drain — the\n * default 500-entry buffer would silently drop lines via `shift`).\n */\n collectCaptures?: boolean;\n /**\n * Absolute paths of files to run in MANUAL-VARIANT mode (devtools#741,\n * `--manual-blocking`). Callers pass the `manual` half of\n * `partitionManualTests(files)` — this set does not itself reorder `files`\n * (the caller is responsible for placing manual files last); it only\n * controls two per-file behaviors for members of the set:\n *\n * 1. the evaluate timeout is `MANUAL_FILE_TIMEOUT_MS` (5 min) instead of\n * `opts.timeoutMs` — a human is expected to be tapping through a\n * native sheet;\n * 2. `onManualFile` fires BEFORE the file is injected, and the file's\n * `FileResult.mode` is stamped `'manual'`.\n *\n * Absent/empty = no manual files in this run (default; zero-diff path).\n */\n manualFiles?: ReadonlySet<string>;\n /**\n * Called immediately before a manual file is injected (devtools#741) —\n * `(file, index, total)` where `index` is 1-based position among manual\n * files. The CLI uses this to push a dashboard prompt + print to stdout.\n * Never called for regular (non-manual) files OR for files in\n * `stubBlockingFiles` (devtools#740) — a stubbed file needs no human\n * prompt.\n */\n onManualFile?: (file: string, index: number, total: number) => void;\n /**\n * Absolute paths of files to run in STUB-BLOCKING mode (devtools#740,\n * DT-2, `--stub-blocking`). Typically the SAME set as `manualFiles` — the\n * whole point is to run the manual-tagged suite unattended — but kept as a\n * separate option so a caller could in principle stub a subset. Members of\n * this set:\n *\n * 1. use `opts.timeoutMs` (the REGULAR per-file timeout), never\n * `MANUAL_FILE_TIMEOUT_MS` — the fixture answers synchronously, no\n * human is tapping through anything;\n * 2. do NOT trigger `onManualFile` (no dashboard prompt / stdout banner —\n * there is no human to prompt);\n * 3. are stamped `FileResult.mode = 'stubbed'` instead of `'manual'`.\n *\n * A file present in BOTH `manualFiles` and `stubBlockingFiles` is treated\n * as stubbed (rule 1-3 above win) — `stubBlockingFiles` takes precedence.\n * The actual blocking-UI interception happens PAGE-SIDE\n * (`bridge-stub.ts`'s `wrapSdkWithStub`, gated by the\n * `__AIT_STUB_BLOCKING__` global the caller injects via\n * `relay-factory.ts`'s `stubBlocking` option) — this set only controls the\n * two Node-side behaviors above; it does not itself flip the page-side gate.\n *\n * Absent/empty = no stubbed files in this run (default; zero-diff path).\n */\n stubBlockingFiles?: ReadonlySet<string>;\n}\n\n/**\n * Sentinel string embedded in the error message by `injectAndRunBundle` when\n * the per-file evaluate race hits the timeout. Used by the retry guard so only\n * genuine timeouts get a second attempt — not bundle errors or parse failures.\n *\n * Exported for unit tests that assert the retry path is taken.\n */\nexport const EVALUATE_TIMEOUT_MARKER = 'rpc: evaluate timed out after';\n\n/**\n * Runs all `files` sequentially over the given CDP `connection`.\n *\n * For each file:\n * 1. Bundle with esbuild (includes SDK shim + runtime).\n * 2. Inject into the attached page via `Runtime.evaluate`.\n * 3. Await the `RunReport` JSON response.\n * 4. Accumulate results.\n *\n * Returns a `RelayRunReport` with per-file results and flattened totals.\n *\n * This function does NOT open or manage the relay connection — the caller\n * is responsible for attaching and closing it.\n *\n * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here\n * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.\n *\n * @param connection - Active CDP connection (relay or local kind).\n * @param files - Absolute paths to test files, run in order.\n * @param opts - Optional per-run overrides.\n */\nexport async function runTestFilesOverRelay(\n connection: CdpConnection,\n files: string[],\n opts?: RelayRunOptions,\n): Promise<RelayRunReport> {\n const wallStart = Date.now();\n const startedAt = new Date(wallStart).toISOString();\n const fileResults: FileResult[] = [];\n\n // Enable CDP domains ONCE up front. Without this the relay connection has not\n // opened its client websocket, so the very first `Runtime.evaluate` (in\n // `injectAndRunBundle`) explodes AND `Runtime.consoleAPICalled` never streams\n // — the console capture harvest would be structurally 0. `enableDomains()` is\n // idempotent (see chii-connection: early-returns when the ws is already OPEN,\n // and shares an in-flight promise), so calling it here is safe even when a\n // caller (the MCP `run_tests` path) already enabled it.\n //\n // Failure here (e.g. no target attached yet) must NOT throw the whole run: the\n // per-file inject below produces a structured error result instead. We warn on\n // stderr (secret-free) and continue. SECRET-HANDLING: the message names the\n // failure only — no relay/wss URL.\n let domainsEnabled = false;\n try {\n await connection.enableDomains();\n domainsEnabled = true;\n } catch (e) {\n process.stderr.write(\n `relay-worker: enableDomains() failed before run — console capture may be empty (${\n e instanceof Error ? e.message : String(e)\n })\\n`,\n );\n }\n\n // Live console capture (#696): when requested, accumulate every\n // `Runtime.consoleAPICalled` event into a local array via a LIVE listener\n // registered just before the run. We deliberately do NOT drain\n // `getBufferedEvents` after the fact — that ring buffer caps at 500 and\n // `shift()`s older entries, so a chatty run would silently lose capture lines.\n // The listener is removed in `finally` so it never bleeds into the next run.\n //\n // Gate the listener on `domainsEnabled`: if enableDomains() soft-failed, the\n // client ws never opened so `Runtime.consoleAPICalled` cannot stream —\n // registering would only add a dead listener that never fires. (The `finally`\n // unsubscribe is an undefined no-op in that case, so this stays safe.)\n const collectCaptures = opts?.collectCaptures === true;\n const liveConsole: ConsoleApiCalledEvent[] = [];\n let unsubscribeConsole: (() => void) | undefined;\n if (collectCaptures && domainsEnabled) {\n unsubscribeConsole = connection.on('Runtime.consoleAPICalled', (event) => {\n liveConsole.push(event);\n });\n }\n\n // Tracks whether the PREVIOUS file's final result was a WS-dead-class error\n // (relay disconnect). When true, we attempt one `enableDomains()` reconnect\n // before processing the NEXT file — a single dropped socket (e.g. Cloudflare\n // edge idle-drop mid-evaluate) should not cascade into every remaining file\n // failing instantly (#731). `enableDomains()` is idempotent (early-returns\n // when the ws is already OPEN, shares an in-flight promise), so calling it\n // speculatively here is safe.\n let pendingReconnectCheck = false;\n\n /**\n * Attempts one `enableDomains()` reconnect. Logs the attempt and outcome to\n * stderr. Never throws — a failed reconnect just means the caller proceeds\n * as today (each remaining file / the retry will fail-fast on the still-dead\n * socket).\n *\n * `context` selects the fixed log literal: 'next-file' for the between-files\n * case (a file's FINAL result was a WS-dead-class error — the primary #731\n * scenario), 'retry-precheck' for the defensive reconnect before retrying a\n * timed-out file (cheap no-op via `enableDomains()`'s idempotency when the\n * socket never actually died).\n *\n * SECRET-HANDLING: fixed literals only — no relay wss URL, TOTP code, or\n * tunnel host.\n */\n const attemptReconnect = async (context: 'next-file' | 'retry-precheck'): Promise<void> => {\n process.stderr.write(\n context === 'next-file'\n ? 'relay-worker: relay connection lost — attempting reconnect before next file\\n'\n : 'relay-worker: evaluate timed out — attempting reconnect before retry\\n',\n );\n try {\n await connection.enableDomains();\n process.stderr.write('relay-worker: reconnect succeeded\\n');\n } catch (e) {\n process.stderr.write(\n `relay-worker: reconnect failed (${\n e instanceof Error ? e.message : String(e)\n }) — continuing, remaining files may fail\\n`,\n );\n }\n };\n\n // Manual-variant bookkeeping (devtools#741): resolve the total up front so\n // `onManualFile`'s `(index, total)` is stable even if `files` is a mix of\n // regular + manual entries in a single call.\n const manualFiles = opts?.manualFiles;\n const manualTotal = manualFiles?.size ?? 0;\n let manualIndex = 0;\n // Stub-blocking bookkeeping (devtools#740, DT-2) — see stubBlockingFiles's\n // doc for why this takes precedence over manualFiles membership.\n const stubBlockingFiles = opts?.stubBlockingFiles;\n\n // Permission-state preflight (devtools#739): run ONCE per session, before\n // the FIRST file's bundle is injected — never per file. Non-fatal: any\n // failure is swallowed inside `runPermissionPreflight` (one stderr line),\n // so a broken/absent `window.__sdk` never blocks the run. Skipped entirely\n // when `files` is empty (no bundle will run either).\n let preflightPermissions: Record<string, string> | undefined;\n if (files.length > 0) {\n preflightPermissions = await runPermissionPreflight(connection);\n }\n\n try {\n for (const file of files) {\n if (pendingReconnectCheck) {\n await attemptReconnect('next-file');\n pendingReconnectCheck = false;\n }\n\n const isStubbed = stubBlockingFiles?.has(file) === true;\n // stubBlockingFiles takes precedence over manualFiles membership (see\n // that option's doc) — a stubbed file needs no human prompt and no\n // extended timeout even if it also appears in manualFiles (the normal\n // case: the CLI passes the same set to both when --stub-blocking is on).\n const isManual = !isStubbed && manualFiles?.has(file) === true;\n if (isManual) {\n manualIndex += 1;\n opts?.onManualFile?.(file, manualIndex, manualTotal);\n }\n // Manual files get a far longer evaluate timeout (a human is tapping\n // through a native sheet) — everything else (including stubbed files,\n // which resolve synchronously from a fixture) keeps opts.timeoutMs.\n const fileTimeoutMs = isManual ? MANUAL_FILE_TIMEOUT_MS : opts?.timeoutMs;\n\n let fileEntry: FileResult;\n try {\n const { code } = await bundleTestFile(file, opts?.bundleOptions);\n\n /**\n * Runs one evaluate attempt and returns a FileResult, or `null` when the\n * result is a genuine timeout and the caller should retry.\n *\n * We need to distinguish:\n * - `rpcResult.ok = false` + timeout error → retry candidate (`return null`)\n * - `rpcResult.ok = false` + other error → final error, no retry\n * - `injectAndRunBundle` throws → CDP exceptionDetails OR a\n * relay-disconnect rejection from `connection.send()` (page engine\n * threw, or the ws died mid-evaluate); treated as a final\n * (non-retryable) error either way.\n *\n * The Promise.race timeout in rpc.ts RETURNS `{ok:false, error: '…'}` (it\n * does NOT throw/reject). Only genuine CDP `exceptionDetails` (or a dead\n * `connection.send()`) cause a throw. This distinction is what makes the\n * EVALUATE_TIMEOUT_MARKER gate below reachable — the timeout result\n * surfaces as `rpcResult.ok=false` with the marker string, not as a\n * caught exception.\n */\n const attempt = async (): Promise<FileResult | null> => {\n let rpcResult: Awaited<ReturnType<typeof injectAndRunBundle>>;\n try {\n rpcResult = await injectAndRunBundle(connection, code, fileTimeoutMs);\n } catch (e) {\n // injectAndRunBundle throws for CDP exceptionDetails OR a relay ws\n // death mid-evaluate (connection.send() fail-fast rejection) —\n // either way this attempt is final (non-retryable). The\n // WS-dead-class check below (after `fileEntry` is assigned)\n // arranges the between-files reconnect (#731).\n return {\n file,\n result: { error: e instanceof Error ? e.message : String(e) },\n };\n }\n\n if (rpcResult.ok) {\n return { file, result: rpcResult.report };\n }\n\n // Timed-out evaluates get one retry; other errors are final.\n if (rpcResult.error.includes(EVALUATE_TIMEOUT_MARKER)) {\n return null; // signal \"retry\"\n }\n return { file, result: { error: rpcResult.error } };\n };\n\n const firstResult = await attempt();\n if (firstResult !== null) {\n fileEntry = firstResult;\n } else {\n // First attempt timed out — the retry-precheck case (#731): the\n // dead-air during a long timeout is exactly what let the Cloudflare\n // edge idle-drop the relay ws in the observed run (permissions:\n // attempt 1 timed out, the retry then hit an already-dead socket).\n // `enableDomains()` is idempotent (no-op when the ws is already\n // OPEN), so a defensive reconnect attempt here is safe even when\n // the socket never actually died — it only costs a wasted round\n // trip on the common \"genuinely just slow\" case, and it's the only\n // way to catch this class since `CdpConnection` does not expose a\n // public \"is the socket alive\" probe.\n await attemptReconnect('retry-precheck');\n // First attempt timed out — retry once. A transient native dialog\n // (camera picker, location permission sheet, GPS cold-fix) may have\n // cleared by now.\n process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\\n`);\n const retryResult = await attempt();\n if (retryResult !== null) {\n fileEntry = retryResult;\n } else {\n // Second timeout: build a final error entry.\n fileEntry = {\n file,\n result: {\n // This fallback must equal rpc.ts DEFAULT_TIMEOUT_MS (60_000)\n // so the displayed budget matches what was actually used when\n // the caller omitted opts.timeoutMs (#731).\n error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 60_000}ms (after retry)`,\n },\n };\n }\n }\n } catch (e) {\n // Capture bundle errors per-file so subsequent files still run.\n fileEntry = {\n file,\n result: { error: e instanceof Error ? e.message : String(e) },\n };\n }\n\n // If this file's FINAL result is a WS-dead-class error, arrange for a\n // reconnect attempt before the NEXT file (#731) — do not abort the loop.\n if ('error' in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) {\n pendingReconnectCheck = true;\n }\n\n // Report provenance (devtools#741 / devtools#740): stamp mode once,\n // here, on whatever fileEntry ended up being (success, error, or\n // timeout) — simpler and less error-prone than threading the flag\n // through every return site inside `attempt()`. Absent for regular\n // files (the contract is absence-means-unattended, not `mode:\n // undefined`). isStubbed takes precedence — see stubBlockingFiles's doc.\n if (isStubbed) {\n fileEntry.mode = 'stubbed';\n } else if (isManual) {\n fileEntry.mode = 'manual';\n }\n\n fileResults.push(fileEntry);\n }\n } finally {\n // Always remove the live listener — leaking it would accumulate across runs\n // on a reused connection (the Vitest pool keeps one connection for the whole\n // run; the MCP daemon keeps one for the session).\n unsubscribeConsole?.();\n }\n\n // Convert the accumulated console events to `__AIT_CAPTURE__` lines. Each\n // event's args are rendered to a single line text (inlined console rendering —\n // no tools.ts import, to keep this module off the heavy MCP graph), then the\n // allowlist-prefix parser keeps only genuine capture lines.\n const captures = collectCaptures\n ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) })))\n : [];\n\n const totals = fileResults.reduce(\n (acc, { result }) => {\n if ('error' in result) {\n // Treat whole-file errors as a single failure.\n acc.failed += 1;\n acc.total += 1;\n } else {\n acc.passed += result.passed;\n acc.failed += result.failed;\n acc.skipped += result.skipped;\n acc.total += result.passed + result.failed + result.skipped;\n }\n return acc;\n },\n { passed: 0, failed: 0, skipped: 0, total: 0 },\n );\n\n return {\n startedAt,\n duration: Date.now() - wallStart,\n files: fileResults,\n totals,\n captures,\n ...(preflightPermissions ? { preflight: { permissions: preflightPermissions } } : {}),\n };\n}\n\n/**\n * Renders one `Runtime.consoleAPICalled` event to a single line of text, the\n * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-\n * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,\n * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)\n * onto the test-runner entry.\n *\n * SECRET-HANDLING: this only stringifies console args; the caller's\n * allowlist-prefix parser then discards everything that is not a genuine\n * `__AIT_CAPTURE__` line.\n */\nfunction renderConsoleLineText(event: ConsoleApiCalledEvent): string {\n return event.args\n .map((arg) => {\n if (arg.value !== undefined) {\n if (typeof arg.value === 'string') return arg.value;\n try {\n return JSON.stringify(arg.value);\n } catch {\n return String(arg.value);\n }\n }\n if (arg.description !== undefined) return arg.description;\n if (arg.className !== undefined) return arg.className;\n return arg.subtype ?? arg.type;\n })\n .join(' ');\n}\n\n/**\n * Flattens all test results from a `RelayRunReport` into a single array.\n * Files that errored during bundle/inject produce a synthetic failed entry.\n */\nexport function flattenResults(report: RelayRunReport): Array<TestResult & { file: string }> {\n const out: Array<TestResult & { file: string }> = [];\n for (const { file, result } of report.files) {\n if ('error' in result) {\n out.push({\n file,\n name: `<bundle/inject error>`,\n status: 'fail',\n duration: 0,\n error: result.error,\n });\n } else {\n for (const t of result.tests) {\n out.push({ ...t, file });\n }\n }\n }\n return out;\n}\n","/**\n * Runner-agnostic report serialisation for env3 test runs (devtools#696).\n *\n * Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the\n * standalone `devtools-test` CLI (`cli.ts`) — call the same core\n * `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This\n * module is the single, runner-neutral place that turns that in-memory report\n * into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed\n * cell-by-cell after the fact.\n *\n * The serialised schema is deliberately MINIMAL and secret-free:\n *\n * - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`\n * leakage — see {@link RunnerAgnosticReport.files});\n * - the cell metadata (sdkLine/platform) is baked INTO the body, not only the\n * filename, so a moved artifact never loses its provenance;\n * - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all\n * (enforced by the type + this comment) — error strings are the matcher\n * message only, inherited from rpc.ts which already strips expression/value.\n *\n * react-free — depends only on the type-level `RelayRunReport` and `node:fs` /\n * `node:path`. Safe to bundle without pulling the chii/cloudflared graph.\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport type { AitCaptureLine } from './capture.js';\nimport type { RelayRunReport } from './relay-worker.js';\nimport type { TestResult } from './runtime.js';\n\n/** The cell axes a report is stamped with — the test-matrix coordinates. */\nexport interface ReportCellMeta {\n /** SDK line under test (e.g. `'2.x'` / `'3.x'`). */\n sdkLine: string;\n /** Platform under test (e.g. `'ios'` / `'android'` / `'mock'`). */\n platform: string;\n /**\n * Project root the run was launched from. Used ONLY to relativise file paths\n * out of the serialised report — never stored in the output. SECRET-HANDLING:\n * absolute project paths must not leak into artifacts.\n */\n projectRoot: string;\n}\n\n/** Per-file slice of a {@link RunnerAgnosticReport}. */\nexport interface RunnerAgnosticFileReport {\n /**\n * Test file path, RELATIVE to `projectRoot`. Never absolute — `path.relative`\n * strips the machine-specific prefix so the artifact is portable and leaks no\n * local filesystem layout.\n */\n file: string;\n /** Whole-file bundle/inject error (matcher message only), when the file failed. */\n error?: string;\n /** In-page run duration (ms) for this file, when it ran. */\n duration?: number;\n passed?: number;\n failed?: number;\n skipped?: number;\n /** Per-test results, when the file ran. Error strings are matcher messages only. */\n tests?: TestResult[];\n /**\n * Present only when this file ran under `--manual-blocking` or\n * `--stub-blocking`. Absent (never `false`) for unattended files:\n * absence-means-unattended is the contract, mirroring `FileResult.mode` in\n * relay-worker.ts.\n *\n * - `'manual'` — devtools#741, human-attended, real native envelopes.\n * - `'stubbed'` — devtools#740 (DT-2), unattended, blocking-UI calls\n * answered from fixtures. A manual/stubbed-stamped record must never be\n * diffed against an unattended (or each other's) baseline as if\n * equivalent.\n */\n mode?: 'manual' | 'stubbed';\n}\n\n/**\n * The runner-neutral, secret-free report written to disk. There are\n * intentionally NO wss/scheme/TOTP/relayUrl fields on this type — the absence is\n * load-bearing (SECRET-HANDLING) and must not be \"completed\" by a future edit.\n */\nexport interface RunnerAgnosticReport {\n /**\n * Cell axes this run belongs to — baked into the body for portability.\n * `bridgeStub` is present + `true` ONLY on the artifact written for\n * stub-blocking files (devtools#740, DT-2) — see `writeReportArtifact`'s\n * doc for why this is a SEPARATE `.stubbed.json` artifact rather than a\n * flag inside the standard/`.manual.json` report. Absent (never `false`)\n * on every other artifact — this is the mandatory HYBRID-cell provenance\n * stamp the issue calls for: a stubbed run must never be silently mixed\n * into the real-device baseline report.\n */\n cell: { sdkLine: string; platform: string; bridgeStub?: true };\n /** ISO timestamp of when the run started (from the core report). */\n startedAt: string;\n /** Total wall-clock ms (bundling + sequential injection). */\n duration: number;\n /** Flattened totals across all files. */\n totals: RelayRunReport['totals'];\n /** Per-file results with projectRoot-relative paths. */\n files: RunnerAgnosticFileReport[];\n /**\n * Permission-state preflight result (devtools#739), mirrored verbatim from\n * `RelayRunReport.preflight` — see that field's docblock. Absent when the\n * preflight did not complete (non-fatal; the run still succeeds). No\n * secrets — permission-state strings only.\n */\n preflight?: { permissions: Record<string, string> };\n}\n\n/**\n * Converts an absolute (or already-relative) file path to a projectRoot-relative\n * one. `path.relative` returns `''` when the paths are equal — guard that to the\n * basename so the field is never empty.\n *\n * SECRET-HANDLING: this is the single choke point that strips absolute project\n * paths from the artifact.\n */\nfunction relativise(projectRoot: string, file: string): string {\n const rel = path.relative(projectRoot, file);\n if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {\n // Outside the project root (or equal) — fall back to the basename rather\n // than emitting an absolute or `../../..` traversal path.\n return path.basename(file);\n }\n return rel;\n}\n\n/**\n * Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free\n * on-disk shape. Pure — no IO; testable with a plain report + meta.\n *\n * @param report - The core relay run report.\n * @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).\n */\nexport function serializeRelayReport(\n report: RelayRunReport,\n meta: ReportCellMeta,\n): RunnerAgnosticReport {\n return {\n cell: { sdkLine: meta.sdkLine, platform: meta.platform },\n startedAt: report.startedAt,\n duration: report.duration,\n totals: report.totals,\n ...(report.preflight ? { preflight: report.preflight } : {}),\n files: report.files.map((f): RunnerAgnosticFileReport => {\n const file = relativise(meta.projectRoot, f.file);\n const modeField =\n f.mode === 'manual' || f.mode === 'stubbed' ? ({ mode: f.mode } as const) : {};\n if ('error' in f.result) {\n // Matcher/inject error message only — rpc.ts already stripped the\n // expression/value upstream.\n return { file, error: f.result.error, ...modeField };\n }\n return {\n file,\n duration: f.result.duration,\n passed: f.result.passed,\n failed: f.result.failed,\n skipped: f.result.skipped,\n tests: f.result.tests,\n ...modeField,\n };\n }),\n };\n}\n\n/**\n * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating\n * `dir` if needed. Returns the absolute path(s) written.\n *\n * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as\n * distinct artifacts in the same directory; the same cell metadata is also baked\n * into the body so a renamed/moved file still carries its provenance.\n *\n * Manual-run provenance (devtools#741): when `report.files` contains ANY\n * `mode: 'manual'` entry (i.e. this run included `--manual-blocking` files),\n * the manual-tagged files are written to a SEPARATE\n * `<dir>/<sdkLine>.<platform>.manual.json` artifact instead of the standard\n * one — the standard `<sdkLine>.<platform>.json` filename is reserved for the\n * regular (unattended) files only, so a manual run's presence never mutates\n * what the unattended-baseline filename means.\n *\n * Stub-blocking provenance (devtools#740, DT-2): `mode: 'stubbed'` files are\n * written to a THIRD, separate `<dir>/<sdkLine>.<platform>.stubbed.json`\n * artifact — never merged into the standard file (it is unattended, like the\n * standard file, so a naive filename split could conflate the two) and never\n * merged into `.manual.json` (it did NOT have a human present). Its body is\n * ALSO stamped `cell.bridgeStub: true` — the mandatory HYBRID-cell provenance\n * from the issue: a stubbed result must never be silently mixed into the\n * real-device baseline report.\n *\n * If ALL files in the run are regular, only the standard artifact is written\n * (today's behavior, unchanged). Any combination of the three modes present\n * in a single run writes ONLY the corresponding artifacts — each is additive,\n * never replacing another.\n *\n * SECRET-HANDLING: the written body contains no relay/secret fields (the schema\n * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.\n *\n * @param report - The core relay run report.\n * @param dir - Output directory (created recursively if missing).\n * @param meta - Cell axes + projectRoot.\n * @returns The absolute path(s) written, in order: standard first (if any\n * regular files ran), then manual (if any manual files ran), then stubbed\n * (if any stubbed files ran). At least one path is always returned when\n * `report.files` is non-empty.\n */\nexport async function writeReportArtifact(\n report: RelayRunReport,\n dir: string,\n meta: ReportCellMeta,\n): Promise<string[]> {\n const serialised = serializeRelayReport(report, meta);\n await mkdir(dir, { recursive: true });\n\n const regularFiles = serialised.files.filter((f) => f.mode === undefined);\n const manualFiles = serialised.files.filter((f) => f.mode === 'manual');\n const stubbedFiles = serialised.files.filter((f) => f.mode === 'stubbed');\n\n const written: string[] = [];\n\n // Standard artifact: written whenever there are regular files, OR when the\n // whole run is empty (preserves the pre-#741 \"always write one file\"\n // behavior for a run with zero files, e.g. an empty glob match).\n if (regularFiles.length > 0 || serialised.files.length === 0) {\n const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);\n await writeFile(\n outFile,\n `${JSON.stringify({ ...serialised, files: regularFiles }, null, 2)}\\n`,\n 'utf8',\n );\n written.push(outFile);\n }\n\n if (manualFiles.length > 0) {\n const manualOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.manual.json`);\n await writeFile(\n manualOutFile,\n `${JSON.stringify({ ...serialised, files: manualFiles }, null, 2)}\\n`,\n 'utf8',\n );\n written.push(manualOutFile);\n }\n\n if (stubbedFiles.length > 0) {\n const stubbedOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.stubbed.json`);\n await writeFile(\n stubbedOutFile,\n `${JSON.stringify(\n {\n ...serialised,\n cell: { ...serialised.cell, bridgeStub: true as const },\n files: stubbedFiles,\n },\n null,\n 2,\n )}\\n`,\n 'utf8',\n );\n written.push(stubbedOutFile);\n }\n\n return written;\n}\n\n/**\n * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,\n * named `<category>.<sdkLine>.<platform>.json` — the SAME convention\n * sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3\n * capture artifacts line up for diffing.\n *\n * Each line's `json` payload is an opaque JSON array of capture records. Lines\n * sharing a category are concatenated into one array, in harvest order.\n *\n * SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser\n * dropped wss/scheme noise); the `json` payload is written verbatim but is a\n * capture record array, not a relay/secret.\n *\n * @param captures - Parsed capture lines (from `RelayRunReport.captures`).\n * @param dir - Output directory (created recursively if missing).\n * @param cell - Cell axes for the filename suffix.\n * @returns The absolute paths written (one per category), in category order.\n */\nexport async function writeCaptureArtifacts(\n captures: ReadonlyArray<AitCaptureLine>,\n dir: string,\n cell: { sdkLine: string; platform: string },\n): Promise<string[]> {\n if (captures.length === 0) return [];\n\n // Group payloads by category, preserving harvest order. Each payload is an\n // opaque JSON array string; concatenate parsed arrays under the same category.\n const byCategory = new Map<string, unknown[]>();\n for (const { category, json } of captures) {\n let merged = byCategory.get(category);\n if (!merged) {\n merged = [];\n byCategory.set(category, merged);\n }\n // The parser already validated `json` parses; an array payload is expected.\n const parsed = JSON.parse(json) as unknown;\n if (Array.isArray(parsed)) {\n merged.push(...parsed);\n } else {\n merged.push(parsed);\n }\n }\n\n await mkdir(dir, { recursive: true });\n const written: string[] = [];\n for (const [category, records] of byCategory) {\n const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);\n await writeFile(outFile, `${JSON.stringify(records, null, 2)}\\n`, 'utf8');\n written.push(outFile);\n }\n return written;\n}\n","/**\n * Bounded teardown orchestrator for the `devtools-test` CLI (devtools#755).\n *\n * ## Symptom (run7~run10, 4 consecutive real-device reproductions)\n * The CLI writes the report + capture artifacts (\"devtools-test: wrote report\n * …\", \"wrote N capture file(s)\") and the QR dashboard HTTP server goes down\n * (port no longer listening), but the Node process stays alive indefinitely —\n * a human had to `SIGTERM`/`pkill` it every run.\n *\n * ## Root cause (confirmed via `process.getActiveResourcesInfo()`, not guessed)\n * Two independent `http.Server#close()` call sites never resolved their\n * callback while a socket was still attached — verified with a standalone\n * repro script before writing this module (see PR body for the diagnostic\n * transcript, not committed here since it's throwaway):\n *\n * 1. `qr-http-server.ts`'s dashboard HTTP server: a still-open browser tab\n * on the QR dashboard holds a `GET /events` SSE connection\n * (`Connection: keep-alive`, no `res.end()` — that's by design, it's a\n * live push stream). `server.close(cb)` only stops accepting NEW\n * connections; it does not touch already-open sockets, so `cb` never\n * fired while that tab stayed open. Fixed by calling\n * `server.closeAllConnections()` immediately before `close()`.\n * 2. `chii-relay.ts`'s relay HTTP+WS server: once a WebSocket connection\n * completes the `upgrade` handshake, Node removes that socket from the\n * HTTP server's own connection tracking — `closeAllConnections()` does\n * NOT reach it (verified: still hangs even with that call added). An\n * open CDP WS leg (phone target or relay-worker client) therefore also\n * blocked `close(cb)` forever. Fixed by terminating every live socket\n * in chii's captured `_wss.clients` set before calling `close()`.\n *\n * Both are fixed at the source in those two files — this module is NOT a\n * workaround for either leak; with both source fixes in place the backstop\n * below never fires in practice (asserted by the \"process exits on its own\"\n * e2e-style test in `teardown.test.ts`). This module exists as the CLI's\n * last-mile safety net for any OTHER handle outside our control (a\n * third-party dependency — `cloudflared`, `chii`, `ws` — holding something\n * we don't own the lifecycle of) and as a single, testable teardown\n * sequence with per-step timeouts so one hung close() cannot prevent the\n * rest from running.\n */\n\n/** A single teardown action — e.g. \"close the CDP connection\", \"stop the relay family\". */\nexport interface TeardownStep {\n /** Short label used only in the returned report (never logged with secrets). */\n name: string;\n /** Performs the close. Must be idempotent — may be invoked defensively more than once. */\n close(): Promise<void> | void;\n}\n\n/** Outcome of a single step, as reported by {@link runTeardownSteps}. */\nexport interface TeardownStepResult {\n name: string;\n /** `'ok'` = resolved within its slice. `'error'` = threw/rejected. `'timeout'` = did not settle in time. */\n status: 'ok' | 'error' | 'timeout';\n /** Present when status is 'error'. */\n error?: string;\n}\n\n/** Options for {@link runTeardownSteps}. */\nexport interface RunTeardownStepsOptions {\n /** Per-step timeout in ms — a hung step is abandoned (marked 'timeout') so later steps still run. Default 5000. */\n perStepTimeoutMs?: number;\n /** Injectable clock for tests — defaults to the real `setTimeout`/`clearTimeout`. */\n setTimeoutFn?: typeof setTimeout;\n clearTimeoutFn?: typeof clearTimeout;\n}\n\n/**\n * Runs `steps` in strict order, each bounded by `perStepTimeoutMs` so a\n * single hung `close()` cannot block the rest — a later step (e.g. \"close\n * the QR HTTP server\") still runs even if an earlier one (e.g. \"flip the\n * on-phone badge over CDP\") times out.\n *\n * Order is load-bearing: `relay-factory.ts`'s existing `close()` already\n * encodes \"badge over still-open channel, THEN close that channel\" — this\n * function does not reorder or parallelize, it just bounds+reports whatever\n * sequence the caller provides.\n */\nexport async function runTeardownSteps(\n steps: TeardownStep[],\n options: RunTeardownStepsOptions = {},\n): Promise<TeardownStepResult[]> {\n const {\n perStepTimeoutMs = 5_000,\n setTimeoutFn = setTimeout,\n clearTimeoutFn = clearTimeout,\n } = options;\n\n const results: TeardownStepResult[] = [];\n for (const step of steps) {\n results.push(await runOneStep(step, perStepTimeoutMs, setTimeoutFn, clearTimeoutFn));\n }\n return results;\n}\n\nasync function runOneStep(\n step: TeardownStep,\n perStepTimeoutMs: number,\n setTimeoutFn: typeof setTimeout,\n clearTimeoutFn: typeof clearTimeout,\n): Promise<TeardownStepResult> {\n let timeoutHandle: ReturnType<typeof setTimeout> | undefined;\n const timeoutPromise = new Promise<'timeout'>((resolve) => {\n timeoutHandle = setTimeoutFn(() => resolve('timeout'), perStepTimeoutMs);\n });\n\n try {\n const outcome = await Promise.race([\n Promise.resolve()\n .then(() => step.close())\n .then(() => 'ok' as const),\n timeoutPromise,\n ]);\n if (timeoutHandle !== undefined) clearTimeoutFn(timeoutHandle);\n return { name: step.name, status: outcome };\n } catch (e) {\n if (timeoutHandle !== undefined) clearTimeoutFn(timeoutHandle);\n return { name: step.name, status: 'error', error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/** Options for {@link armExitBackstop}. */\nexport interface ArmExitBackstopOptions {\n /**\n * Grace period (ms) after teardown completes before forcing exit if the\n * process has not already exited on its own. This is a LAST resort for\n * handles outside `runTeardownSteps`'s enumerated steps — with the\n * upstream leaks fixed (see module doc), this should never fire in\n * practice. Default 3000.\n */\n graceMs?: number;\n /** The exit code to pass to `process.exit`. */\n exitCode: number;\n /** Injectable clock for tests. */\n setTimeoutFn?: typeof setTimeout;\n /** Injectable exit for tests — defaults to `process.exit`. Never call this directly in a test assertion without overriding it. */\n exitFn?: (code: number) => void;\n}\n\n/** Handle returned by {@link armExitBackstop} so the happy path can disarm it. */\nexport interface ExitBackstop {\n /** Cancels the pending forced exit — call this once the process is expected to drain naturally. */\n disarm(): void;\n /** True once the backstop has fired (only meaningful in tests — production calls `process.exit`). */\n readonly fired: boolean;\n}\n\n/**\n * Arms a grace-period timer that force-exits the process if it has not\n * already exited on its own by the time the timer fires. The timer is\n * deliberately NOT `.unref()`'d — its entire purpose is to hold the event\n * loop open long enough to fire if nothing else does, so unref'ing it would\n * defeat the backstop. `disarm()` clears it — the CLI calls `disarm()`\n * immediately once `main()`'s own teardown (Step 6, cli.ts) has finished,\n * so on the happy path the timer never fires (Node drains the loop and\n * exits naturally at whatever `process.exitCode` was already set to).\n *\n * SECRET-HANDLING: this function never touches stdout/stderr content — the\n * caller (cli.ts) is responsible for flushing any final output before the\n * backstop's `exitFn` runs.\n */\nexport function armExitBackstop(options: ArmExitBackstopOptions): ExitBackstop {\n const {\n graceMs = 3_000,\n exitCode,\n setTimeoutFn = setTimeout,\n exitFn = (code: number) => process.exit(code),\n } = options;\n\n let fired = false;\n const handle = setTimeoutFn(() => {\n fired = true;\n exitFn(exitCode);\n }, graceMs);\n\n return {\n disarm(): void {\n clearTimeout(handle);\n },\n get fired(): boolean {\n return fired;\n },\n };\n}\n","/**\n * `devtools-test` CLI.\n *\n * Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)\n * and exposes `runWithConnection` — the pure run core that bundles, injects, and\n * collects each file over a CDP connection. The CLI's `main()` performs a\n * standalone relay attach (boot relay → QR → phone scan → cell inject → run).\n *\n * NOTE: no shebang in this source file — the tsdown entry's `banner` option\n * injects `#!/usr/bin/env node` into the compiled output (same pattern as\n * `src/mcp/cli.ts`).\n */\n\nimport { basename } from 'node:path';\nimport { parseArgs } from 'node:util';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport { discoverTestFiles, MANUAL_TEST_SUFFIX, partitionManualTests } from './discover.js';\nimport { createRelayConnectionFactory } from './relay-factory.js';\nimport type { RelayRunOptions, RelayRunReport } from './relay-worker.js';\nimport { runTestFilesOverRelay } from './relay-worker.js';\nimport { writeCaptureArtifacts, writeReportArtifact } from './report.js';\nimport { armExitBackstop, runTeardownSteps } from './teardown.js';\n\n/* -------------------------------------------------------------------------- */\n/* CLI help */\n/* -------------------------------------------------------------------------- */\n\nconst USAGE = `\ndevtools-test — run mini-app tests on a real device WebView over the CDP relay\n\nUSAGE\n devtools-test <glob> [<glob> ...] [options]\n\nOPTIONS\n --scheme-url <url> intoss-private:// URL from \\`ait deploy --scheme-only\\`\n (required for standalone relay attach / env3)\n --timeout <ms> Per-file evaluate timeout in ms (default: 60000).\n Controls how long a single test file is allowed to run\n before it is considered hung. Does NOT affect how long\n the CLI waits for a human to scan the QR code — use\n --attach-timeout for that.\n --attach-timeout <ms> How long to wait for a human to scan the QR code with\n their phone. Omit (default) to wait indefinitely — the\n runner stays up until you stop it (Ctrl-C/SIGTERM).\n Pass a value to bound the wait for CI/headless runs.\n --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)\n --cell-platform <plat> Platform to inject as __AIT_CELL__.platform\n (mock|ios|android, default: AIT_CELL_PLATFORM env)\n --report-dir <dir> Persist a runner-agnostic report + captures to <dir>\n (report: <sdkLine>.<platform>.json; captures:\n <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).\n Omitted = nothing saved. Enables console capture.\n --dashboard-port <port> Base port for the QR dashboard HTTP server. On\n EADDRINUSE it increments (+1, up to 20 tries) before\n falling back to an ephemeral port. Omit to use\n AIT_DEBUG_HTTP_PORT env or the built-in default\n (8317) — pass 0 to force a random ephemeral port.\n --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for\n non-interactive stdout / CI / AIT_NO_QR_STDOUT)\n --headless Disable browser auto-open (text QR only)\n --project-root <dir> Project root for .ait_relay secret lookup\n (default: current working directory)\n --manual-blocking Run manual-tagged test files (*.manual.ait.test.ts)\n LAST, after all regular files, with a human present.\n Before each manual file, the QR dashboard is pushed\n a step-by-step Korean prompt naming the file + its\n progress (k/n), and the same line is printed to\n stdout. Manual files get a 5-minute per-file evaluate\n timeout (vs. --timeout for everything else) since a\n human is expected to tap through a native sheet\n (photo picker, permission dialog, fullscreen ad).\n Without this flag (default off), *.manual.ait.test.ts\n files are EXCLUDED from the glob expansion entirely —\n existing unattended runs are byte-for-byte unaffected.\n With --report-dir, a run that included manual files\n ALSO writes <sdkLine>.<platform>.manual.json\n alongside (never replacing) the standard report, and\n each manual file's report entry is stamped\n mode: 'manual' — never diff a manual run against an\n unattended baseline as if they were equivalent.\n --stub-blocking Run manual-tagged test files (*.manual.ait.test.ts)\n UNATTENDED (devtools#740, DT-2) by intercepting a\n fixed allowlist of blocking-UI SDK calls (ads\n show*, openPermissionDialog/requestPermission,\n saveBase64Data) in the page and answering them from\n fixtures captured by a real --manual-blocking run,\n instead of forwarding them to native UI. Implies\n --manual-blocking (manual files are included in the\n run); no human presence or QR-dashboard prompt is\n needed for them. HYBRID cell, not pure env3 — every\n other SDK call in the same run still hits the real\n native bridge. With --report-dir, files that ran\n under the stub are written to a SEPARATE\n <sdkLine>.<platform>.stubbed.json artifact (never\n merged into the standard or .manual.json report) and\n the report body is stamped cell.bridgeStub: true —\n never diff a stubbed run against a real device\n baseline (manual or unattended) as if equivalent.\n --help, -h Show this help message\n\nDESCRIPTION\n Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real\n device to scan and attach, injects the cell globals (__AIT_CELL__), bundles\n each matched test file with esbuild (SDK imports redirected to window.__sdk),\n injects the bundle into the attached WebView via Runtime.evaluate, and prints\n a summary.\n\n With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a\n runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be\n compared offline.\n\n The test files run against the live relay connection started by this process;\n no separate MCP daemon is required.\n\nEXAMPLE\n devtools-test 'src/**/*.ait.test.ts' \\\\\n --scheme-url \"intoss-private://...\" \\\\\n --cell-sdk-line 3.x \\\\\n --cell-platform ios \\\\\n --report-dir .ait-report \\\\\n --timeout 60000\n\n`.trimStart();\n\n/**\n * Grace period (ms) for the exit backstop armed around Step 6 teardown\n * (devtools#755). See `teardown.ts`'s module doc for the root-cause writeup\n * — the two upstream `http.Server#close()` hangs are fixed at the source, so\n * this backstop is a last-mile safety net that should never fire in\n * practice. Exported so tests can assert the backstop is armed with the\n * expected value without hardcoding the literal twice.\n */\nexport const EXIT_BACKSTOP_GRACE_MS = 3_000;\n\n/* -------------------------------------------------------------------------- */\n/* Timeout resolution (exported for unit tests) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Resolved timeout values derived from raw CLI flag strings.\n *\n * Exported so unit tests can assert the two clocks in isolation without\n * spawning a subprocess.\n */\nexport interface ResolvedTimeouts {\n /** Per-file evaluate timeout (ms). From --timeout; default 60 000. */\n evaluateTimeoutMs: number;\n /**\n * QR-scan wait timeout (ms), or `undefined` when --attach-timeout was not\n * supplied. `undefined` signals \"let relay-factory.ts's default govern\" —\n * which is an UNBOUNDED wait (devtools#735): the runner stays up until the\n * user stops it (Ctrl-C/SIGTERM). We intentionally do not inline that\n * default here so the factory remains the single source of truth for it.\n */\n attachTimeoutMs: number | undefined;\n}\n\n/**\n * Parses --timeout and --attach-timeout raw string values into the two\n * distinct clocks.\n *\n * Returns an error string on invalid input, or the resolved timeouts on\n * success. The caller (main) writes the error to stderr and exits 1.\n *\n * Exported for unit testing — main() is the only other caller.\n */\nexport function resolveTimeouts(\n rawTimeout: string | undefined,\n rawAttachTimeout: string | undefined,\n): ResolvedTimeouts | string {\n // Default must match rpc.ts DEFAULT_TIMEOUT_MS (60_000) — the CLI's own\n // fallback used to be 30_000, which silently overrode rpc.ts's 60s bump\n // (#726) on every CLI invocation that omitted --timeout (#731).\n const evaluateTimeoutMs = rawTimeout !== undefined ? parseInt(rawTimeout, 10) : 60_000;\n if (Number.isNaN(evaluateTimeoutMs) || evaluateTimeoutMs <= 0) {\n return '--timeout must be a positive integer';\n }\n\n const attachTimeoutMs =\n rawAttachTimeout !== undefined ? parseInt(rawAttachTimeout, 10) : undefined;\n if (attachTimeoutMs !== undefined && (Number.isNaN(attachTimeoutMs) || attachTimeoutMs <= 0)) {\n return '--attach-timeout must be a positive integer';\n }\n\n return { evaluateTimeoutMs, attachTimeoutMs };\n}\n\n/**\n * Parses the `--dashboard-port` raw string value into a validated port\n * number, or `undefined` when the flag was omitted (letting relay-factory /\n * qr-http-server resolve their own default — env then the built-in fixed\n * default, devtools#752).\n *\n * `0` is a valid, meaningful value (explicit opt-out to pure ephemeral) and\n * is passed through as-is — it must NOT be confused with \"omitted\".\n *\n * Returns an error string on invalid input (non-integer, negative, or\n * >65535), or the resolved port on success. Exported for unit testing.\n */\nexport function resolveDashboardPort(raw: string | undefined): number | undefined | string {\n if (raw === undefined) return undefined;\n const port = parseInt(raw, 10);\n if (Number.isNaN(port) || port < 0 || port > 65535) {\n return '--dashboard-port must be an integer between 0 and 65535';\n }\n return port;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Per-file summary rendering (exported for unit tests) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Renders per-file result lines and the aggregate totals line to a string.\n *\n * Each file gets one line:\n * - Error/timeout: `FAIL <basename>: <error-class>`\n * - Pass (0 tests): `OK <basename>: 0 passed (empty file)`\n * - Pass: `OK <basename>: N passed[, M failed][, K skipped]`\n *\n * The aggregate totals line always follows.\n *\n * SECRET-HANDLING: only `basename(file)` is used — no absolute paths, relay\n * URLs, wss URLs, scheme URLs, or TOTP codes appear in the output. The error\n * string comes from `result.error` which is already secret-free (relay-worker\n * produces only error-class messages like \"rpc: evaluate timed out after\n * 30000ms\").\n *\n * Exported so unit tests can assert the per-file lines without spawning a\n * subprocess or going through the full relay attach flow.\n */\nexport function renderSummary(report: RelayRunReport): string {\n const lines: string[] = [];\n\n for (const { file, result } of report.files) {\n const name = basename(file);\n if ('error' in result) {\n // Timed-out or errored file — the error string is already secret-free\n // (relay-worker only surfaces error-class text, never URLs or codes).\n lines.push(`FAIL ${name}: ${result.error}`);\n } else {\n const parts: string[] = [`${result.passed} passed`];\n if (result.failed > 0) parts.push(`${result.failed} failed`);\n if (result.skipped > 0) parts.push(`${result.skipped} skipped`);\n const suffix = result.passed + result.failed + result.skipped === 0 ? ' (empty file)' : '';\n lines.push(`OK ${name}: ${parts.join(', ')}${suffix}`);\n }\n }\n\n const { totals, duration } = report;\n lines.push(\n `\\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${duration}ms)`,\n );\n\n return lines.join('\\n');\n}\n\n/* -------------------------------------------------------------------------- */\n/* Pure run function (testable without a real relay) */\n/* -------------------------------------------------------------------------- */\n\n/** Options for `runWithConnection`. */\nexport interface RunWithConnectionOptions extends RelayRunOptions {\n /** If true, print a summary to stdout. Defaults to false in tests. */\n printSummary?: boolean;\n}\n\n/**\n * Runs `files` over `connection` and returns the aggregate report.\n * This pure function is the testable core of the CLI (and is what the\n * `run_tests` MCP tool calls against the daemon's attached connection); it is\n * separate from `main()` so tests can call it without spawning a subprocess.\n */\nexport async function runWithConnection(\n connection: CdpConnection,\n files: string[],\n opts?: RunWithConnectionOptions,\n): Promise<RelayRunReport> {\n const report = await runTestFilesOverRelay(connection, files, opts);\n\n if (opts?.printSummary) {\n process.stdout.write(`\\n${renderSummary(report)}\\n`);\n }\n\n return report;\n}\n\n/* -------------------------------------------------------------------------- */\n/* main() — CLI entry point */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Decides whether to suppress the QR/attach block on stdout.\n *\n * Suppress when EITHER the user passed `--no-qr-stdout`, OR stdout is not a TTY\n * / `CI` is set / `AIT_NO_QR_STDOUT` is set (non-interactive — a captured stdout\n * must not leak the relay wss + TOTP `at=` code that the QR block encodes). The\n * suppression is whole-chunk: `attachUrl` AND `relayUrl` ride in the same block.\n *\n * Exported for unit testing.\n */\nexport function shouldSuppressQr(noQrFlag: boolean): boolean {\n return (\n noQrFlag ||\n !process.stdout.isTTY ||\n process.env.CI !== undefined ||\n process.env.AIT_NO_QR_STDOUT !== undefined\n );\n}\n\n/**\n * CLI entry point.\n *\n * Performs a standalone relay attach → run lifecycle, sharing the attach\n * assembly with the Vitest pool via `createRelayConnectionFactory` (single\n * source — no drift):\n *\n * 1. Parse args: globs, --timeout (per-file evaluate), --attach-timeout (QR\n * scan wait), --cell-sdk-line, --cell-platform, --scheme-url (required for\n * env3), --report-dir, --dashboard-port, --no-qr-stdout, --headless,\n * --project-root.\n * 2. Discover test files; exit 1 if none.\n * 3. factory.open() — boot relay → render QR (suppressed on non-interactive\n * stdout) → wait for phone (up to attachTimeoutMs) → inject cell →\n * enableDomains. Returns the conn.\n * 4. runWithConnection(conn, files, { evaluateTimeoutMs, collectCaptures,\n * printSummary }).\n * 5. With --report-dir: write the runner-agnostic report + capture files.\n * 6. factory.close(); process.exitCode = failed > 0 ? 1 : 0.\n *\n * The CLI is not a daemon — no lock, router, SSE, or tools_list is needed.\n * Attach timeout exits with code 1; test failures exit with code 1.\n *\n * SECRET-HANDLING: scheme_url / relay wssUrl / TOTP codes are never written to\n * stdout/stderr directly. The QR block (which encodes the TOTP `at=` code) is\n * printed only when stdout is interactive AND not suppressed.\n */\nexport async function main(argv: string[] = process.argv.slice(2)): Promise<void> {\n // ── Step 1: parse arguments ───────────────────────────────────────────────\n let parsed: ReturnType<typeof parseArgs>;\n try {\n parsed = parseArgs({\n args: argv,\n options: {\n help: { type: 'boolean', short: 'h' },\n timeout: { type: 'string' },\n 'attach-timeout': { type: 'string' },\n 'scheme-url': { type: 'string' },\n 'cell-sdk-line': { type: 'string' },\n 'cell-platform': { type: 'string' },\n 'report-dir': { type: 'string' },\n 'dashboard-port': { type: 'string' },\n 'no-qr-stdout': { type: 'boolean' },\n headless: { type: 'boolean' },\n 'project-root': { type: 'string' },\n 'manual-blocking': { type: 'boolean' },\n 'stub-blocking': { type: 'boolean' },\n } as const,\n allowPositionals: true,\n });\n } catch (e) {\n process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\\n`);\n process.exitCode = 1;\n return;\n }\n\n if (parsed.values.help || argv.length === 0) {\n process.stdout.write(USAGE);\n return;\n }\n\n // Extract string-typed flags explicitly — parseArgs returns `string | boolean | (string | boolean)[]`\n // for the union `values` type when options have mixed `type` fields, so we\n // narrow each flag here.\n const vals = parsed.values;\n\n // Resolve the two timeout clocks via the exported pure helper (unit-tested).\n const timeouts = resolveTimeouts(\n typeof vals.timeout === 'string' ? vals.timeout : undefined,\n typeof vals['attach-timeout'] === 'string' ? vals['attach-timeout'] : undefined,\n );\n if (typeof timeouts === 'string') {\n process.stderr.write(`devtools-test: ${timeouts}\\n`);\n process.exitCode = 1;\n return;\n }\n const { evaluateTimeoutMs, attachTimeoutMs } = timeouts;\n\n const schemeUrl = typeof vals['scheme-url'] === 'string' ? vals['scheme-url'] : '';\n if (schemeUrl === '') {\n process.stderr.write(\n `devtools-test: --scheme-url is required for standalone relay attach.\\n` +\n ` Pass the intoss-private:// URL from \\`ait deploy --scheme-only\\`.\\n`,\n );\n process.exitCode = 1;\n return;\n }\n\n const headless = vals.headless === true;\n const projectRoot =\n typeof vals['project-root'] === 'string' ? vals['project-root'] : process.cwd();\n const reportDir = typeof vals['report-dir'] === 'string' ? vals['report-dir'] : undefined;\n const dashboardPort = resolveDashboardPort(\n typeof vals['dashboard-port'] === 'string' ? vals['dashboard-port'] : undefined,\n );\n if (typeof dashboardPort === 'string') {\n process.stderr.write(`devtools-test: ${dashboardPort}\\n`);\n process.exitCode = 1;\n return;\n }\n const suppressQr = shouldSuppressQr(vals['no-qr-stdout'] === true);\n // devtools#740 (DT-2): --stub-blocking IMPLIES --manual-blocking (manual\n // files must be discovered/included for there to be anything to stub) —\n // a user who passes --stub-blocking alone should not have to also\n // remember --manual-blocking for it to do anything.\n const stubBlocking = vals['stub-blocking'] === true;\n const manualBlocking = vals['manual-blocking'] === true || stubBlocking;\n\n // Cell: --cell-sdk-line and --cell-platform (fall back to AIT_CELL_PLATFORM env).\n const cellSdkLine = typeof vals['cell-sdk-line'] === 'string' ? vals['cell-sdk-line'] : undefined;\n const cellPlatform =\n typeof vals['cell-platform'] === 'string'\n ? vals['cell-platform']\n : process.env.AIT_CELL_PLATFORM;\n const hasCell = cellSdkLine !== undefined || cellPlatform !== undefined;\n // The cell injected onto the page and the report/capture filename suffix.\n // sdk-example's own fallbacks are '2.x'/'mock'; mirror them when a flag is\n // absent so artifacts still get a stable, meaningful cell suffix.\n const cell = { sdkLine: cellSdkLine ?? '2.x', platform: cellPlatform ?? 'mock' };\n\n // ── Step 2: discover test files ───────────────────────────────────────────\n const globs = parsed.positionals;\n if (globs.length === 0) {\n process.stderr.write(`devtools-test: at least one glob pattern is required\\n`);\n process.stdout.write(USAGE);\n process.exitCode = 1;\n return;\n }\n\n // Tagging contract (devtools#741): *.manual.ait.test.ts files are excluded\n // from discovery unless --manual-blocking is passed, in which case they are\n // included and then scheduled strictly AFTER every regular file below —\n // this is the entire \"manual-variant\" ordering guarantee.\n const discovered = await discoverTestFiles(globs, process.cwd(), {\n includeManual: manualBlocking,\n });\n if (discovered.length === 0) {\n process.stderr.write(`devtools-test: no test files matched ${globs.join(', ')}\\n`);\n process.exitCode = 1;\n return;\n }\n const { regular, manual } = partitionManualTests(discovered);\n // Manual files always run LAST, regardless of the discovery/glob order —\n // `files` below is what's actually injected, in run order.\n const files = manualBlocking ? [...regular, ...manual] : regular;\n const manualFileSet = new Set(manual);\n process.stderr.write(\n manualBlocking && manual.length > 0\n ? `devtools-test: found ${regular.length} regular + ${manual.length} manual (${MANUAL_TEST_SUFFIX}) test file(s)\\n`\n : `devtools-test: found ${files.length} test file(s)\\n`,\n );\n\n // ── Step 3: open the relay connection via the shared factory ──────────────\n // The factory boots the relay, renders the QR, waits for the phone, injects\n // the cell, and enables CDP domains — the same assembly the Vitest pool uses.\n // We pass `onQrContent` so the CLI owns the stdout decision: suppress the whole\n // QR block on non-interactive stdout (it encodes the relay wss + TOTP code).\n // SECRET-HANDLING: scheme_url / wss / TOTP are never logged by the factory.\n if (hasCell) {\n process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\\n`);\n }\n if (stubBlocking) {\n process.stderr.write(\n 'devtools-test: --stub-blocking enabled — blocking-UI calls in manual-tagged ' +\n 'files will be answered from fixtures (devtools#740), not forwarded to native UI\\n',\n );\n }\n const factory = createRelayConnectionFactory({\n schemeUrl,\n projectRoot,\n // attachTimeoutMs is only forwarded when the user explicitly passed\n // --attach-timeout; otherwise we omit it so relay-factory.ts's built-in\n // UNBOUNDED default governs (devtools#735) — single source of truth.\n ...(attachTimeoutMs !== undefined ? { timeoutMs: attachTimeoutMs } : {}),\n // dashboardPort is only forwarded when the user explicitly passed\n // --dashboard-port; otherwise omit so relay-factory/qr-http-server's own\n // default resolution governs (env → fixed default, devtools#752).\n ...(dashboardPort !== undefined ? { dashboardPort } : {}),\n headless,\n cell: hasCell ? cell : undefined,\n stubBlocking,\n onQrContent: (chunks) => {\n if (suppressQr) {\n process.stdout.write('QR suppressed (non-interactive)\\n');\n return;\n }\n for (const chunk of chunks) process.stdout.write(`${chunk}\\n`);\n },\n });\n\n let connection: CdpConnection;\n try {\n connection = await factory.open();\n } catch (e) {\n process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\\n`);\n process.exitCode = 1;\n return;\n }\n\n // Ensure cleanup on early exit.\n let exitCode = 0;\n try {\n // ── Step 4: run test files ──────────────────────────────────────────────\n // #730: mark the dashboard as 'running' before the run starts so the QR\n // page reflects an in-progress session rather than staying 'active' the\n // whole time. collectCaptures is enabled only when a report dir is given\n // (the only sink for captures) — keeps the no-report path free of\n // listener overhead.\n factory.onSessionPhase?.('running');\n const report = await runWithConnection(connection, files, {\n timeoutMs: evaluateTimeoutMs,\n printSummary: true,\n collectCaptures: reportDir !== undefined,\n manualFiles: manualBlocking && manualFileSet.size > 0 ? manualFileSet : undefined,\n // devtools#740 (DT-2): same file set as manualFiles when --stub-blocking\n // is on — stubBlockingFiles takes precedence in relay-worker.ts, so\n // these files run unattended (regular timeout, no dashboard prompt,\n // mode: 'stubbed') even though they are also present in manualFileSet.\n stubBlockingFiles: stubBlocking && manualFileSet.size > 0 ? manualFileSet : undefined,\n // #741: before each manual file, push the dashboard prompt AND print\n // the same Korean instruction line to stdout (human may be watching\n // either surface). Clearing the prompt (dashboard only) happens once\n // the whole run ends, in the finally block below — a manual file is\n // always the tail of `files`, so there is no \"next regular file\" to\n // clear it for mid-run.\n onManualFile: (file, index, total) => {\n const name = basename(file);\n process.stdout.write(\n `수동 단계: ${name} — 폰에서 네이티브 시트가 뜨면 안내에 따라 조작하세요 (${index}/${total})\\n`,\n );\n factory.onManualPrompt?.({ file: name, index, total });\n },\n });\n\n // Clear the dashboard's manual prompt now that the run (including any\n // manual tail) has finished — leaves no stale \"수동 단계\" banner up once\n // the human is done.\n if (manualBlocking && manualFileSet.size > 0) {\n factory.onManualPrompt?.(null);\n }\n\n // ── Step 5: persist artifacts (only with --report-dir) ──────────────────\n if (reportDir !== undefined) {\n try {\n const reportPaths = await writeReportArtifact(report, reportDir, {\n sdkLine: cell.sdkLine,\n platform: cell.platform,\n projectRoot,\n });\n for (const reportPath of reportPaths) {\n process.stderr.write(`devtools-test: wrote report ${reportPath}\\n`);\n }\n const capturePaths = await writeCaptureArtifacts(\n report.captures,\n `${reportDir}/.ait-capture`,\n cell,\n );\n if (capturePaths.length > 0) {\n process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\\n`);\n }\n } catch (e) {\n // Artifact write failure must not mask the test result.\n process.stderr.write(\n `devtools-test: failed to write report artifacts: ${e instanceof Error ? e.message : String(e)}\\n`,\n );\n }\n }\n\n exitCode = report.totals.failed > 0 ? 1 : 0;\n } finally {\n // ── Step 6: teardown (devtools#755) ─────────────────────────────────────\n // #730: mark the dashboard 'complete' BEFORE close() so the terminal SSE\n // frame reaches any open dashboard tab before the HTTP server is closed.\n // Redundant-but-safe with close()'s own internal push (belt-and-suspenders\n // so the frame flushes even if the two ever run in a different order).\n factory.onSessionPhase?.('complete');\n\n // Bounded backstop (devtools#755): arm BEFORE the teardown steps run, so\n // even a step that hangs past its own per-step timeout cannot leave the\n // process running forever. The two known root causes of the run7~10 hang\n // (dashboard SSE tab + relay WS leg blocking http.Server#close()) are\n // fixed at the source (qr-http-server.ts, chii-relay.ts) — this backstop\n // exists for any OTHER handle outside those two files' control and\n // should not fire in normal operation.\n const backstop = armExitBackstop({ graceMs: EXIT_BACKSTOP_GRACE_MS, exitCode });\n\n // factory.close() stops the relay family (closes the CDP connection +\n // shuts down the relay + cloudflared child) and closes the QR HTTP\n // server. Wrapped in a single named step so a hang here cannot silently\n // block process exit — runTeardownSteps bounds it and the outer backstop\n // covers anything runTeardownSteps itself cannot enumerate.\n await runTeardownSteps([\n {\n name: 'factory.close',\n close: () => factory.close(connection),\n },\n ]);\n\n // Happy path: teardown finished within its own bounds — disarm the\n // backstop so the process exits naturally at process.exitCode instead of\n // waiting out the full grace period.\n backstop.disarm();\n process.exitCode = exitCode;\n }\n}\n","/**\n * `devtools-test` bin entry point — export-free by design.\n *\n * This file is intentionally export-free so that Rolldown (tsdown) emits the\n * main() call directly into the output bundle rather than hoisting the module\n * body into a shared chunk and replacing this file with a re-export wrapper.\n *\n * Background (#711): cli.ts exports `main`, `runWithConnection`, and\n * `shouldSuppressQr` for use by tests and relay-factory. When cli.ts was also\n * the bin entry, Rolldown split the module body into a shared chunk\n * (`cli-<hash>.js`) and reduced the bin output to a 3-line re-export wrapper.\n * The self-invoke guard (`import.meta.url === process.argv[1]`) evaluated\n * inside the shared chunk where `import.meta.url` is the chunk path, not the\n * bin path — a structural permanent mismatch — so main() was never called and\n * every `devtools-test` / `pnpm test:env3` invocation exited 0 as a silent\n * no-op.\n *\n * NOTE: no shebang in this source file — the tsdown entry's `banner` option\n * injects `#!/usr/bin/env node` into the compiled output (same pattern as\n * other Node bin entries in tsdown.config.ts).\n */\n\nimport { main } from './cli.js';\n\nmain().catch((e: unknown) => {\n process.stderr.write(\n `devtools-test: unexpected error: ${e instanceof Error ? e.message : String(e)}\\n`,\n );\n process.exitCode = 1;\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"bin.js","names":["path"],"sources":["../../src/test-runner/discover.ts","../../src/test-runner/relay-factory.ts","../../src/shared/relay-auth-close.ts","../../src/mcp/chii-connection.ts","../../src/test-runner/bundle.ts","../../src/test-runner/capture.ts","../../src/test-runner/rpc.ts","../../src/test-runner/relay-worker.ts","../../src/test-runner/report.ts","../../src/test-runner/teardown.ts","../../src/test-runner/cli.ts","../../src/test-runner/bin.ts"],"sourcesContent":["/**\n * Test-file discovery shared by the `devtools-test` CLI and the `run_tests`\n * MCP tool, so both expand glob patterns with identical semantics.\n *\n * Uses Node's built-in `fs/promises` `glob` (Node 22+) — no extra dependency,\n * which keeps the MCP daemon install graph lean (a plain glob lib would land in\n * the `npx … devtools-mcp` path for no benefit).\n *\n * Pure Node IO only (`node:fs/promises` + `node:path`) — react-free, so it is\n * safe to import from the MCP daemon graph.\n */\n\nimport { glob } from 'node:fs/promises';\nimport { basename, isAbsolute, resolve } from 'node:path';\n\n/**\n * Filename suffix that opts a test file into manual-variant scheduling\n * (devtools#741). A file named `<name>.manual.ait.test.ts` is EXCLUDED from\n * `discoverTestFiles`'s default output (so existing unattended runs are\n * unaffected — the zero-diff-when-off constraint) and is only surfaced when\n * the caller explicitly asks for manual files via {@link partitionManualTests}\n * (wired from the CLI's `--manual-blocking` flag).\n *\n * This is the entire tagging contract for v1 — no separate manifest/config,\n * just a filename convention. Documented here + in the CLI `--help` text\n * (cli.ts USAGE) and the test-runner README.\n */\nexport const MANUAL_TEST_SUFFIX = '.manual.ait.test.ts';\n\n/** True when `file`'s basename ends with {@link MANUAL_TEST_SUFFIX}. */\nexport function isManualTestFile(file: string): boolean {\n return basename(file).endsWith(MANUAL_TEST_SUFFIX);\n}\n\n/**\n * Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of\n * ABSOLUTE test file paths, resolved relative to `cwd`.\n *\n * A plain (non-glob) path passes through when it matches a real file; a glob\n * expands against `cwd`. Absolute matches are kept as-is; relative matches are\n * resolved against `cwd`. `bundleTestFile` requires an absolute path, so the\n * absolute output feeds it directly.\n *\n * By default, files matching {@link MANUAL_TEST_SUFFIX} are EXCLUDED from the\n * result (devtools#741) — blocking-UI tests opt in via that filename\n * convention and must never appear in an unattended run unless the caller\n * explicitly asks for them via `includeManual: true`. This keeps the\n * default (flag-off) discovery path byte-for-byte identical to before this\n * option existed.\n *\n * @param patterns Glob patterns or file paths (e.g. `['src/**\\/*.ait.test.ts']`).\n * @param cwd Base directory for relative patterns/results.\n * @param opts `{ includeManual }` — when true, manual-tagged files are kept\n * in the (still-sorted) output instead of being filtered out.\n * Use {@link partitionManualTests} to separate + reorder them.\n * @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.\n */\nexport async function discoverTestFiles(\n patterns: string[],\n cwd: string,\n opts?: { includeManual?: boolean },\n): Promise<string[]> {\n const out = new Set<string>();\n for await (const match of glob(patterns, { cwd })) {\n out.add(isAbsolute(match) ? match : resolve(cwd, match));\n }\n const sorted = [...out].sort();\n if (opts?.includeManual) return sorted;\n return sorted.filter((f) => !isManualTestFile(f));\n}\n\n/**\n * Splits an already-discovered file list into `{ regular, manual }`, each\n * still sorted. Used by the CLI's `--manual-blocking` path to schedule manual\n * files strictly AFTER every regular file (devtools#741) — regular files run\n * first (and produce the unattended-shaped part of the report) and manual\n * files run last, each preceded by a dashboard prompt.\n *\n * Pure partition — does not itself decide inclusion; call\n * `discoverTestFiles(patterns, cwd, { includeManual: true })` first so manual\n * files are present in `files` to partition.\n */\nexport function partitionManualTests(files: string[]): { regular: string[]; manual: string[] } {\n const regular: string[] = [];\n const manual: string[] = [];\n for (const f of files) {\n (isManualTestFile(f) ? manual : regular).push(f);\n }\n return { regular, manual };\n}\n","/**\n * Relay connection factory for the Vitest custom pool (devtools#696).\n *\n * The Vitest pool (`pool.ts`) takes a {@link RelayConnectionFactory} that knows\n * how to `open()` a live CDP relay connection (boot relay → QR → phone scan →\n * cell inject → enableDomains) and `close()` it. Before this module that exact\n * assembly lived only inside the `devtools-test` CLI's `main()`; the standalone\n * CLI and the Vitest pool would otherwise each hand-roll it and drift.\n *\n * This is the single source of that assembly. `cli.ts` is refactored to call\n * `createRelayConnectionFactory(...).open()`, and `definePhoneVitestConfig`\n * (config.ts) exposes it so a downstream `vitest.config.ts` can wire the pool:\n *\n * import { createRelayConnectionFactory } from '@ait-co/devtools/test-runner';\n * const connection = createRelayConnectionFactory({\n * schemeUrl,\n * cell,\n * // REQUIRED — own the stdout decision (the chunks carry the relay wss +\n * // TOTP code). Suppress on non-interactive stdout; print otherwise.\n * onQrContent: (chunks) => {\n * if (!process.stdout.isTTY) return;\n * for (const c of chunks) process.stdout.write(`${c}\\n`);\n * },\n * });\n * export default defineConfig({ test: definePhoneVitestConfig({ connection }) });\n *\n * The heavy boot graph (chii relay, cloudflared, ws, debug-server) is pulled via\n * DYNAMIC import inside `open()` — so merely importing this module (or the\n * `@ait-co/devtools/test-runner` barrel) does NOT statically drag that graph in.\n *\n * SECRET-HANDLING: relay wss URLs, scheme URLs, and the TOTP secret/code are\n * never logged. `open()` reads the project-local `.ait_relay` secret read-only\n * (never mints) and the minted TOTP code rides only inside the QR `at=` param.\n *\n * Node-only. react-free (CdpConnection + lazily-imported MCP boot helpers only).\n */\n\nimport type { AttachDeps, AttachUrlParts } from '../mcp/attach-orchestrator.js';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport type { DashboardState, QrHttpServer } from '../mcp/qr-http-server.js';\nimport type { RelayConnectionFactory } from './pool.js';\n\n// NOTE: every value import below is a DYNAMIC import inside `open()`. This module\n// keeps ONLY type-level static imports so that re-exporting it from the\n// `@ait-co/devtools/test-runner` barrel (config.ts) does NOT statically drag the\n// heavy MCP graph (cell.ts → attach-orchestrator.ts → tools.ts → server-lock,\n// plus chii/cloudflared via debug-server) onto that Node-config entry.\n\n/** Options for {@link createRelayConnectionFactory}. */\nexport interface RelayConnectionFactoryOptions {\n /**\n * intoss-private:// scheme URL from `ait deploy --scheme-only` (env3). The\n * phone cold-loads the candidate bundle this URL points at. SECRET-HANDLING:\n * never logged.\n *\n * Ignored when {@link attachLauncher} is `true` — the env-2 launcher path\n * builds its attach URL from {@link appUrl} + the relay wss, not a scheme URL.\n * Pass `''` in that case.\n */\n schemeUrl: string;\n /**\n * env-2 (AITC Sandbox PWA) attach mode (devtools#774). When `true`, `open()`\n * builds the attach URL as a **launcher deep-link**\n * (`buildLauncherAttachUrl(appUrl, wssUrl, totp)` — the same env-2 path the\n * MCP daemon takes via `prepareAttach(deps, 'relay-mobile', …)`, issue #378)\n * instead of an intoss-private scheme deep-link. The relay boot, QR\n * rendering, dashboard, and attach-wait are byte-for-byte identical to env 3;\n * only the URL family differs. Omitted/false keeps the env-3 scheme path\n * unchanged (zero behavior change).\n */\n attachLauncher?: boolean;\n /**\n * The consumer dev server's HTTP tunnel URL (e.g. an sdk-example\n * `dev:phone:cdp` `*.trycloudflare.com` URL) that the launcher PWA frames.\n * REQUIRED when {@link attachLauncher} is `true`; ignored otherwise.\n *\n * Threaded to the `relay-mobile` `prepareAttach` branch via the\n * `AIT_TUNNEL_BASE_URL` env var (the same key the MCP daemon reads), so the\n * exact same env-2 assembly runs — no divergent code path.\n *\n * SECRET-HANDLING: this is a tunnel host at the same sensitivity as the relay\n * wss — NEVER logged. It rides only inside the QR/dashboard attach capsule.\n */\n appUrl?: string;\n /**\n * Project root for the `.ait_relay` secret lookup (read-only). Defaults to\n * `process.cwd()`.\n */\n projectRoot?: string;\n /**\n * Attach wait timeout in ms — how long `open()` waits for the phone to scan\n * the QR and attach. Omitted (the default) means **wait indefinitely** —\n * the runner stays up until the user stops it (Ctrl-C/SIGTERM). QR-scan\n * wait is a human-paced action; there is no sound default bound for it\n * (devtools#735). Pass an explicit value to opt into a bounded wait (CI/\n * headless callers — `--attach-timeout` on the CLI). (The per-file evaluate\n * timeout is separate, passed via the pool's `run` options.)\n */\n timeoutMs?: number;\n /** Disable browser auto-open of the QR dashboard (text QR only). */\n headless?: boolean;\n /**\n * Cell axes injected as `__AIT_CELL__` before the first test bundle runs, so\n * sdk-example's capture picks up the correct sdkLine/platform. Optional — when\n * omitted no cell is injected. The values are not secrets.\n */\n cell?: { sdkLine: string; platform: string };\n /**\n * When `true`, injects `__AIT_STUB_BLOCKING__ = true` before the first test\n * bundle runs (devtools#740, DT-2) — `bundle.ts`'s sdk-redirect module reads\n * this flag via `isStubBlockingEnabled()` and answers the blocking-UI APIs\n * in `bridge-stub.ts`'s `STUB_REGISTRY` from fixtures instead of forwarding\n * them to native. Optional — omitted/false means no flag is injected, which\n * is byte-for-byte the pre-#740 behavior (the sdk-redirect module treats\n * absence the same as `false`). Not a secret.\n */\n stubBlocking?: boolean;\n /**\n * Injects `__AIT_PACE_MS__ = paceMs` before the first test bundle runs\n * (devtools#767) — `runtime.ts`'s test-execution loop reads this page\n * global and waits `paceMs` before every test after the first one that\n * actually runs. Optional — omitted/0 means no flag is injected, which is\n * byte-for-byte the pre-#767 behavior (the loop treats an absent/non-\n * positive value as \"no pacing\"). Not a secret. This is the PAGE-side half\n * of `--pace`; the RUNNER-side (file-to-file) half is a plain `opts.paceMs`\n * forwarded to `runTestFilesOverRelay` by the caller (cli.ts) — this\n * factory only owns the page-global injection.\n */\n paceMs?: number;\n /**\n * Injects `__AIT_PACE_METHOD_MS__ = paceMethodMs` before the first test\n * bundle runs (devtools#769) — `bundle.ts`'s sdk-redirect virtual module\n * reads this page global via `method-pace.ts#getPaceMethodMs()` and paces\n * every SDK call to a minimum interval BETWEEN calls to the SAME named\n * function, independent of `--pace`'s test-to-test/file-to-file spacing.\n * Optional — omitted/0 means no flag is injected, which is byte-for-byte\n * the pre-#769 behavior (`getPaceMethodMs()` treats an absent/non-positive\n * value as \"no pacing\", and `wrapWithMethodPacing` returns the SDK object\n * untouched in that case). Not a secret.\n */\n paceMethodMs?: number;\n /**\n * Overrides the dashboard HTTP server's bind base port (devtools#752).\n * Threaded straight through to `startQrHttpServer`'s `dashboardPort`\n * option — see that option's doc for the increment-on-EADDRINUSE scan\n * behaviour and the `AIT_DEBUG_HTTP_PORT` env fallback. Omitted means\n * `startQrHttpServer` resolves its own default (env → fixed default).\n */\n dashboardPort?: number;\n /**\n * Receives the QR/attach render content (text chunks) from\n * `renderAndMaybeWait`, so the caller decides whether to print them — e.g.\n * suppress on non-interactive stdout.\n *\n * REQUIRED (not optional) on purpose: these chunks contain the QR payload\n * (which encodes the relay wss + TOTP `at=` code) and the attach JSON block.\n * Making the hook mandatory means the factory never falls back to printing\n * them itself — a downstream `vitest.config.ts` consumer that wires this via\n * the `@ait-co/devtools/test-runner` barrel is forced to make an explicit\n * stdout decision rather than silently leaking the secret-bearing block.\n *\n * SECRET-HANDLING: when a non-interactive caller is detected the hook MUST\n * suppress the WHOLE chunk (not just `attachUrl`), since `relayUrl` rides in\n * the same block.\n */\n onQrContent: (textChunks: string[]) => void;\n}\n\n/**\n * Builds a {@link RelayConnectionFactory} that opens a standalone relay\n * connection for env 3 (intoss-private scheme, default) or env 2 (AITC Sandbox\n * PWA launcher deep-link, when `opts.attachLauncher` is set — devtools#774).\n *\n * `open()` performs the full attach lifecycle and BLOCKS while a human scans\n * the rendered QR with their phone — there is no way around the manual scan.\n * By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the\n * runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is\n * a human-paced action with no sound default bound (devtools#735). Passing an\n * explicit `timeoutMs` opts into the old bounded behavior (CI/headless\n * callers). It resolves with the live `CdpConnection` once a matching page\n * attaches; `close()` tears the relay family down.\n *\n * The factory holds the booted relay family in a closure so `close()` can stop\n * it. A second `open()` on the same factory boots a fresh family (the previous\n * one should have been `close()`d first).\n */\nexport function createRelayConnectionFactory(\n opts: RelayConnectionFactoryOptions,\n): RelayConnectionFactory {\n const projectRoot = opts.projectRoot ?? process.cwd();\n // Undefined/absent → Infinity (wait forever). An explicit finite value opts\n // into the old bounded behavior. Number.isFinite guards downstream\n // (renderAndMaybeWait / waitForFirstTarget) treat Infinity as \"no timer\".\n const timeoutMs = opts.timeoutMs ?? Number.POSITIVE_INFINITY;\n const headless = opts.headless === true;\n\n // Captured so close() can stop the family that open() booted.\n let family: { connection: CdpConnection; stop(): void } | undefined;\n // QR HTTP server — started during open() when not headless, closed in close().\n let qrServer: QrHttpServer | undefined;\n // devtools#772: target attach/detach poller (mirrors debug-server.ts's\n // startAttachWatcher wiring) — started alongside qrServer in open(), stopped\n // in close(). Optional-chained everywhere since qrServer startup can fail.\n let attachWatcher: { stop(): void } | undefined;\n // Session-phase state (#730) — drives the dashboard's `phase` field so the\n // CLI's run start/complete and final teardown push an immediate SSE update\n // instead of the dashboard just going dark when the process exits.\n let phase: DashboardState['phase'] = 'active';\n // Manual-blocking prompt state (devtools#741) — drives the dashboard's\n // `manualPrompt` field. `null` outside a manual step.\n let manualPrompt: DashboardState['manualPrompt'] = null;\n\n return {\n async open(): Promise<CdpConnection> {\n // Dynamic imports: keep the chii/cloudflared/debug-server graph OFF the\n // static import graph of this module (and the test-runner config barrel).\n const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import(\n '../mcp/attach-orchestrator.js'\n );\n const { injectDebugIndicator, injectGlobals } = await import('./cell.js');\n const { loadRelaySecretReadOnly } = await import('../mcp/relay-secret-store.js');\n const { bootRelayFamily, buildRelayVerifyAuth, startAttachWatcher } = await import(\n '../mcp/debug-server.js'\n );\n const { buildChiiInspectorUrl } = await import('../mcp/devtools-opener.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // Load the project-local .ait_relay secret into AIT_DEBUG_TOTP_SECRET\n // BEFORE booting the relay so assertRelayAuthConfigured()/buildRelayVerifyAuth()\n // at the boot site see it. Read-only — never mints. SECRET-HANDLING: the\n // value is never logged here.\n await loadRelaySecretReadOnly({ projectRoot });\n\n // PRIMARY FIX (devtools#714): bootRelayFamily starts the cloudflared tunnel\n // as a background promise and returns immediately with tunnel.up === false.\n // We must NOT call prepareAttach until the tunnel is up — it fails fast when\n // tunnel.up is false (attach-orchestrator.ts tunnel-down guard).\n //\n // Wire onWssUrl (mirroring the MCP daemon path in debug-server.ts) to:\n // 1. resolve a caller-held tunnel-ready promise so open() can await it, and\n // 2. re-push dashboard SSE on late tunnel-up events (notifyStateChange).\n //\n // SECRET-HANDLING: onWssUrl receives the relay wss URL — never log it.\n let resolveTunnelUp!: () => void;\n const tunnelReady = new Promise<void>((resolve) => {\n resolveTunnelUp = resolve;\n });\n\n const booted = await bootRelayFamily({\n verifyAuth: buildRelayVerifyAuth(),\n onWssUrl: () => {\n // Resolve the tunnel-ready gate so the prepareAttach call below is\n // unblocked. qrServer may not be set yet at call time (it's started\n // after bootRelayFamily), so we use optional chaining — if the tunnel\n // happens to come up after qrServer is started, notifyStateChange pushes\n // the freshly minted attachUrl to any waiting dashboard SSE clients.\n // SECRET-HANDLING: wssUrl is NOT forwarded here — the value travels only\n // inside the closure via getTunnelStatus().wssUrl (used by mintAttachUrl).\n resolveTunnelUp();\n qrServer?.notifyStateChange();\n },\n // #730: parity with the MCP daemon path (debug-server.ts's onTunnelDown\n // wiring) — without this, a permanent tunnel drop during a standalone\n // CLI run left the dashboard showing a dead-but-scannable QR until the\n // watchdog (up to ~210s) finally fired.\n onTunnelDown: () => {\n qrServer?.notifyStateChange();\n },\n });\n family = booted;\n\n // If the tunnel is already up (extremely fast boot or test double), resolve\n // immediately so we don't stall on a promise that will never fire.\n if (booted.getTunnelStatus?.().up) {\n resolveTunnelUp();\n }\n\n // Track the last-captured attach parts so getDashboardState can mint a\n // fresh attach URL on every dashboard request/SSE push (fresh TOTP at=).\n // SECRET-HANDLING: parts contain the relay wss + scheme URL — never logged.\n let lastAttachParts: AttachUrlParts | undefined;\n\n // Assemble AttachDeps. We set qrHttpServer and onAttachUrlBuilt below\n // (before prepareAttach) after optionally starting the web-QR server.\n const attachDeps: AttachDeps = {\n getTunnelStatus: booted.getTunnelStatus ?? (() => ({ up: false, wssUrl: null })),\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n qrHttpServer: undefined, // filled in below if web-QR server started\n onAttachUrlBuilt: undefined, // filled in below\n canOpenBrowser: () => !headless,\n };\n\n // Web-QR server: reuse the same loopback HTTP dashboard that the MCP\n // start_attach path uses (src/mcp/qr-http-server.ts). This makes the QR\n // scannable even when stdout is non-interactive (Claude Code `!` / CI),\n // because the browser is opened by URL — not via captured stdout.\n //\n // Headless decision: we start the server even in --headless mode so the\n // printed stderr URL can be opened manually. The existing\n // canOpenBrowser: () => !headless gate inside renderAndMaybeWait prevents\n // the auto-open; headless users see only the stderr URL.\n //\n // On failure we fall back gracefully to the text-QR path — do NOT crash.\n // SECRET-HANDLING: only http://127.0.0.1:<port>/ (no secrets) goes to stderr.\n try {\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n\n // devtools#772: parity with the MCP daemon path (debug-server.ts) —\n // read the live target list off the booted connection instead of the\n // `pages: null` placeholder. `Array.isArray(pages) && pages.length > 0`\n // is the SSE client's Inspector-active gate (qr-http-server.ts #544),\n // so a hardcoded `null` left the Inspector section stuck on the\n // waiting hint forever, independent of any actual attach state.\n const getDashboardState = (): DashboardState => ({\n tunnel: attachDeps.getTunnelStatus(),\n pages: booted.connection.listTargets().map((t) => ({ id: t.id, url: t.url })),\n attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,\n // devtools#774: label the dashboard chrome for the env being attached\n // — 'relay-mobile' (env 2 launcher/sandbox family) vs 'relay-dev'\n // (env 3 intoss family). Mirrors the MCP daemon's mode selection.\n mode: opts.attachLauncher === true ? ('relay-mobile' as const) : ('relay-dev' as const),\n phase, // #730 — CLI-only 'running'/'complete' transitions via onSessionPhase\n manualPrompt, // #741 — CLI-only --manual-blocking transitions via onManualPrompt\n });\n\n // devtools#772: getDirectInspectorUrl — mirrors debug-server.ts's\n // assembly (buildChiiInspectorUrl over the relay's local HTTP base +\n // a freshly-minted TOTP code) so the runner dashboard's `/inspector`\n // stable route (#530) is actually functional once a page attaches.\n // SECRET-HANDLING: `ok:true`'s `url` carries the relay host + TOTP\n // `at=` code — never logged; it only ever rides a Location header.\n const getDirectInspectorUrl = (): ReturnType<\n NonNullable<\n import('../mcp/qr-http-server.js').QrHttpServerOptions['getDirectInspectorUrl']\n >\n > => {\n if (!booted.relayHttpUrl) {\n return { ok: false, reason: 'relayDown' };\n }\n const targets = booted.connection.listTargets();\n if (targets.length === 0) {\n return { ok: false, reason: 'noTarget' };\n }\n const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;\n if (!totpSecret) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n const url = buildChiiInspectorUrl(booted.relayHttpUrl, targets[0].id, () =>\n generateTotp(totpSecret, Date.now()),\n );\n if (url === null) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n return { ok: true, url };\n };\n\n qrServer = await startQrHttpServer(getDashboardState, {\n dashboardPort: opts.dashboardPort,\n getDirectInspectorUrl,\n });\n\n // Wire the QR server into attachDeps BEFORE prepareAttach is called so\n // renderAndMaybeWait sees it and takes Path 2/3 (web-QR) instead of Path 4.\n attachDeps.qrHttpServer = qrServer;\n\n // Capture attach parts via onAttachUrlBuilt so getDashboardState can\n // mint a fresh URL (fresh TOTP at= code) on every dashboard render.\n attachDeps.onAttachUrlBuilt = (parts: AttachUrlParts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n };\n\n // devtools#772: parity with the MCP daemon path's onPageAttach/\n // onPageDetach wiring (debug-server.ts ~2348) — push an immediate SSE\n // update whenever the target set changes, so the dashboard's Inspector\n // section flips out of the waiting hint without waiting for the next\n // periodic SSE refresh. `server` is omitted (no MCP Server here — the\n // standalone runner has no tools/list to notify); startAttachWatcher\n // treats that as \"skip sendToolListChanged\" and runs the rest of its\n // signature-diff logic unchanged.\n attachWatcher = startAttachWatcher(\n booted.connection,\n undefined,\n 1_000,\n () => qrServer?.notifyStateChange(),\n () => qrServer?.notifyStateChange(),\n );\n\n // Print the loopback dashboard URL to stderr — it carries no secrets\n // (TOTP codes and relay wss live only in the in-memory HTTP response).\n process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\\n`);\n } catch {\n // startQrHttpServer failed (e.g. port conflict, import error). Fall back\n // to the existing text-QR path by leaving qrHttpServer: undefined.\n // qrServer remains undefined; close() handles that with optional chaining.\n }\n\n // PRIMARY FIX (devtools#714): await tunnel readiness before calling\n // prepareAttach. Race: a 15 s timeout is generous — cloudflared typically\n // comes up in < 5 s. On timeout we still call prepareAttach; it will hit\n // the tunnel-down guard and throw a secret-free error (same as before,\n // but now with a clear diagnostic instead of a silent WAITING freeze).\n //\n // Implementation: Promise.race against a 15 000 ms timeout signal. We do\n // NOT use a timer-based early-exit because the existing code already\n // surface-fails on tunnel-down inside prepareAttach with a secret-free\n // message. The timeout here is purely a \"give the tunnel a fair chance\"\n // gate — not a correctness boundary.\n const TUNNEL_BOOT_TIMEOUT_MS = 15_000;\n await Promise.race([\n tunnelReady,\n new Promise<void>((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS)),\n ]);\n\n // devtools#774: env 2 (launcher) vs env 3 (scheme) attach. The relay\n // boot / QR / dashboard / attach-wait above are identical for both — only\n // the attach-URL family differs. We reuse `prepareAttach`'s existing\n // `relay-mobile` branch (issue #378) rather than hand-rolling a second\n // assembly: it reads AIT_TUNNEL_BASE_URL, verifies the tunnel wss + TOTP\n // secret, and builds the launcher parts. We set that env var from\n // `opts.appUrl` here (scoped to this open()) so the same code path runs.\n // SECRET-HANDLING: appUrl is a tunnel host — set into the env only, never\n // logged; prepareAttach's relay-mobile branch never echoes it either.\n let prep: Awaited<ReturnType<typeof prepareAttach>>;\n if (opts.attachLauncher === true) {\n const priorTunnelBaseUrl = process.env.AIT_TUNNEL_BASE_URL;\n process.env.AIT_TUNNEL_BASE_URL = opts.appUrl ?? '';\n try {\n prep = await prepareAttach(attachDeps, 'relay-mobile', {}, booted.connection);\n } finally {\n // Restore the prior env value so we don't leak the tunnel host into\n // the process env past this attach (it is read only inside prepareAttach).\n if (priorTunnelBaseUrl === undefined) delete process.env.AIT_TUNNEL_BASE_URL;\n else process.env.AIT_TUNNEL_BASE_URL = priorTunnelBaseUrl;\n }\n } else {\n prep = await prepareAttach(\n attachDeps,\n 'relay-dev',\n { scheme_url: opts.schemeUrl },\n booted.connection,\n );\n }\n if (!prep.ok) {\n booted.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller alongside the QR server\n // on this failure path (mirrors the qrServer cleanup below).\n attachWatcher?.stop();\n attachWatcher = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the failure path\n // so the loopback port listener does not leak. The normal-exit path is\n // handled by close(); this mirrors that cleanup for the error path.\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: do NOT surface `prep.error.content` in the thrown\n // message. Some prep error paths build their text from attach\n // components, and the CLI catch writes `e.message` to stderr — embedding\n // that text risks leaking the scheme/relay wss URL. The detailed\n // diagnostic is the daemon/dashboard's job; the factory throws a\n // secret-free message only.\n throw new Error(\n 'createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up',\n );\n }\n\n // Render the QR + wait for the phone to attach. SECRET-HANDLING: this\n // function never logs scheme/wss/TOTP values.\n const waitResult = await renderAndMaybeWait(\n attachDeps,\n prep,\n true,\n timeoutMs,\n booted.connection,\n );\n if (waitResult.isError) {\n booted.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller alongside the QR server\n // on this failure path (mirrors the prep.ok failure path above).\n attachWatcher?.stop();\n attachWatcher = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the timeout path\n // (mirrors the prep.ok failure path above).\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING (BLOCKER fix): `waitResult.content` is the timeout\n // result built from `buildTimeoutError(baseText, ...)`, and `baseText`\n // is `JSON.stringify({ attachUrl, relayUrl, ... })` — `attachUrl` carries\n // the TOTP `at=` code and `relayUrl` is the relay `wss://` URL. The CLI\n // catch writes `e.message` to stderr, so extracting that text here would\n // leak the relay wss + TOTP code on every timeout. Throw a secret-free\n // message with only the timeout duration.\n const timeoutSec = Math.round(timeoutMs / 1000);\n throw new Error(\n `createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`,\n );\n }\n\n // Surface the QR/attach render content to the caller, which owns the\n // stdout decision (suppress on non-interactive stdout). There is no\n // fallback that prints here itself: `onQrContent` is a required option so\n // the secret-bearing block (attachUrl TOTP + relayUrl wss) can never be\n // emitted without an explicit caller decision. SECRET-HANDLING.\n const qrChunks = waitResult.content\n .filter((c): c is { type: 'text'; text: string } => c.type === 'text')\n .map((c) => c.text);\n opts.onQrContent(qrChunks);\n\n // PAGE-READY GATE (devtools#720, fix (a)+(b)):\n //\n // FIX (a) — ORDER: enableDomains() MUST run before injectDebugIndicator()\n // and injectGlobals(). Both inject calls use Runtime.evaluate via\n // sendCommand(), which guards with `if (!this.ws) reject(\"Call\n // enableDomains() first\")`. With the old ordering (inject → inject →\n // enableDomains) injectDebugIndicator's throw was swallowed by cell.ts\n // try/catch but injectGlobals (no try/catch) propagated fatally — when\n // --cell was set open() hard-threw before enableDomains ever ran and the\n // CLI exited with 0 files executed.\n //\n // FIX (b) — BOUNDED RETRY: the window between waitForFirstTarget (non-\n // empty /targets) and enableDomains (page-level WS open) is where a\n // Cloudflare edge idle-drop can disconnect the phone. enableDomains()\n // calls refreshTargets() internally; if the target list is empty at that\n // point it throws \"No mini-app page attached\". We absorb up to\n // PAGE_READY_RETRIES transient failures by waiting briefly and retrying\n // the refreshTargets+enableDomains sequence. enableDomains() is\n // idempotent (concurrent callers share the in-flight promise), so retries\n // are safe. Only after all retries fail do we surface a secret-free\n // error. This is the CLI equivalent of the MCP daemon's soft-fail\n // cushion in relay-worker.ts.\n //\n // Neither fix touches chii-connection.ts or the MCP daemon path.\n const PAGE_READY_RETRIES = 3;\n const PAGE_READY_RETRY_DELAY_MS = 1_500;\n\n let lastEnableError: Error | undefined;\n for (let attempt = 1; attempt <= PAGE_READY_RETRIES; attempt++) {\n try {\n // FIX (a): enableDomains first — opens the page-level CDP websocket.\n await booted.connection.enableDomains();\n lastEnableError = undefined;\n break;\n } catch (err) {\n lastEnableError = err instanceof Error ? err : new Error(String(err));\n if (attempt < PAGE_READY_RETRIES) {\n // FIX (b): brief pause then re-poll /targets before retrying\n // enableDomains. Use a non-leaking approach: wait, then fall\n // through to the next loop iteration.\n await new Promise<void>((resolve) => setTimeout(resolve, PAGE_READY_RETRY_DELAY_MS));\n }\n }\n }\n\n if (lastEnableError !== undefined) {\n booted.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller alongside the QR server\n // on this failure path (mirrors the two failure paths above).\n attachWatcher?.stop();\n attachWatcher = undefined;\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: message contains only a duration + attempt count.\n // No relay wss URL, scheme URL, or TOTP code is included.\n throw new Error(\n `createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round((PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS) / 1000)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`,\n );\n }\n\n // FIX (a): inject AFTER enableDomains so the page-level CDP websocket is\n // open and sendCommand() will not hit the ws===null guard.\n\n // Show the on-phone \"Debugger Connected\" badge. injectDebugIndicator\n // swallows its own errors (cell.ts try/catch), so a badge failure is\n // non-fatal — test execution proceeds regardless.\n await injectDebugIndicator(booted.connection);\n\n // Inject the cell globals before any test bundle runs (session-global).\n // injectGlobals() does NOT swallow errors — a genuine type/eval failure\n // here surfaces clearly instead of silently skipping cell injection.\n if (opts.cell !== undefined) {\n await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });\n }\n\n // devtools#740 (DT-2): inject the bridge-stub gate before any test\n // bundle runs, session-global like __AIT_CELL__ above. Only injected\n // when explicitly requested — omitted/false means the sdk-redirect\n // module's `isStubBlockingEnabled()` reads `undefined`, which is\n // treated identically to `false` (zero-diff-when-off).\n if (opts.stubBlocking === true) {\n await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });\n }\n\n // devtools#767: inject the page-side --pace pacing value, session-global\n // like __AIT_CELL__/__AIT_STUB_BLOCKING__ above. Only injected when\n // positive — omitted/0 means runtime.ts's own `__AIT_PACE_MS__` read\n // sees `undefined`, which its `typeof raw === 'number' && raw > 0` guard\n // treats identically to a literal `0` (zero-diff-when-off).\n if (opts.paceMs !== undefined && opts.paceMs > 0) {\n await injectGlobals(booted.connection, { __AIT_PACE_MS__: opts.paceMs });\n }\n\n // devtools#769: inject the page-side --pace-method pacing value,\n // session-global like __AIT_PACE_MS__ above. Only injected when\n // positive — omitted/0 means method-pace.ts's own\n // `__AIT_PACE_METHOD_MS__` read sees `undefined`, which its\n // `typeof raw === 'number' && raw > 0` guard treats identically to a\n // literal `0` (zero-diff-when-off).\n if (opts.paceMethodMs !== undefined && opts.paceMethodMs > 0) {\n await injectGlobals(booted.connection, { __AIT_PACE_METHOD_MS__: opts.paceMethodMs });\n }\n\n return booted.connection;\n },\n\n // #730: drives the dashboard's `phase` field so the CLI's run start/end\n // push an immediate SSE update instead of the dashboard just going dark.\n onSessionPhase(next: 'running' | 'complete'): void {\n phase = next;\n qrServer?.notifyStateChange();\n },\n\n // #741: drives the dashboard's `manualPrompt` field for --manual-blocking\n // — the CLI calls this immediately before injecting each manual file\n // (prompt set) and once more with `null` after the last one (prompt\n // cleared). Push is immediate (not waited on for ack) — v1 does not block\n // on dashboard acknowledgment, only on the file's own (long) evaluate.\n onManualPrompt(next: { file: string; index: number; total: number } | null): void {\n manualPrompt = next;\n qrServer?.notifyStateChange();\n },\n\n async close(connection: CdpConnection): Promise<void> {\n // #730: flip the on-phone badge to disconnected AND push the terminal\n // dashboard frame BEFORE tearing anything down, so neither surface goes\n // dark without explanation when the CLI exits (dog-food gaps #1 + #2).\n // Both are best-effort over the still-open channels — the ordering\n // (before family.stop()/qrServer.close()) is load-bearing:\n // - qrServer.notifyStateChange() writes synchronously to the still-open\n // SSE sockets, so the 'complete' frame is on the wire before the HTTP\n // server is closed below.\n // - injectDebugIndicator runs over the still-open CDP channel, so the\n // phone actually receives the disconnected-state update before the\n // relay/tunnel are torn down.\n if (phase !== 'complete') {\n phase = 'complete';\n qrServer?.notifyStateChange();\n }\n try {\n const { injectDebugIndicator } = await import('./cell.js');\n await injectDebugIndicator(connection, { state: 'disconnected' });\n } catch {\n // Channel may already be down (e.g. attach never completed) — non-fatal,\n // the badge is informational UI only.\n }\n // family.stop() is synchronous best-effort: closes the CDP connection and\n // shuts down the relay + cloudflared child.\n family?.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller before closing the QR\n // server it pushes into — idempotent via optional chaining.\n attachWatcher?.stop();\n attachWatcher = undefined;\n // Close the web-QR HTTP server if one was started during open().\n // close() is idempotent via optional chaining + reassignment to undefined.\n await qrServer?.close();\n qrServer = undefined;\n },\n };\n}\n","/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Production `CdpConnection` backed by the local Chii relay.\n *\n * Topology (debug mode):\n * phone target.js --WS--> Chii relay :9100 <--WS-- this connection\n *\n * The phone connects to the relay as a `target`; this module connects as a\n * `client` (the role a CDP frontend would take) so CDP events the page emits\n * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent\n * events in ring buffers the tool layer reads via `getBufferedEvents`.\n *\n * Node-only: imports `ws`. Never bundled into the browser/in-app entries.\n *\n * Attach reliability (#281):\n * `refreshTargets()` emits an internal 'target:attached' event whenever a\n * new target is added to the relay. `waitForFirstTarget()` awaits that event\n * (with a polling-interval fallback) so `start_attach`'s attach wait\n * resolves deterministically rather than racing between polling rounds.\n */\n\nimport { EventEmitter } from 'node:events';\nimport { WebSocket } from 'ws';\nimport { RELAY_AUTH_REJECT_CLOSE_CODE } from '../shared/relay-auth-close.js';\nimport type {\n CdpCommandMap,\n CdpCommandName,\n CdpConnection,\n CdpEventMap,\n CdpEventName,\n CdpTarget,\n} from './cdp-connection.js';\nimport { logInfo } from './log.js';\nimport { generateTotp } from './totp.js';\n\n/** Max events retained per domain ring buffer. */\nconst DEFAULT_BUFFER_SIZE = 500;\n\n/**\n * Substrings that mark a \"relay websocket is dead\" class error, as produced by\n * `handleDisconnect('relay WebSocket 연결이 끊겼습니다')` (ws close handler) and\n * the fail-fast `sendCommand` rejection (`relay에 연결되어 있지 않습니다 (...)`).\n *\n * Exported so callers outside this module (e.g. `test-runner/relay-worker.ts`)\n * can detect the same error class without hardcoding a fragile full-sentence\n * match — mirrors the `EVALUATE_TIMEOUT_MARKER` precedent in\n * `test-runner/relay-worker.ts`. Kept in sync with `classifyToolError`'s\n * `relayDisconnectError` branch in `mcp/errors.ts`.\n */\nexport const RELAY_DISCONNECT_MARKERS = [\n 'relay WebSocket',\n 'relay에 연결되어 있지 않습니다',\n] as const;\n\n/**\n * Returns true when `message` matches the relay-websocket-dead error class\n * (socket closed, socket errored, or fail-fast \"not connected\" rejection).\n */\nexport function isRelayDisconnectMessage(message: string): boolean {\n return RELAY_DISCONNECT_MARKERS.some((marker) => message.includes(marker));\n}\n\n/** A CDP message arriving over the relay websocket. */\ninterface CdpInboundMessage {\n id?: number;\n method?: string;\n params?: unknown;\n result?: unknown;\n error?: { message: string };\n}\n\n/**\n * Events emitted by `ChiiCdpConnection` for crash / lifecycle notifications.\n * Consumers (e.g. the tool layer) can subscribe with `.onLifecycle(cb)`.\n */\nexport interface TargetLifecycleEvent {\n /**\n * 'crashed' → Inspector.targetCrashed\n * 'destroyed' → Target.targetDestroyed\n * 'detached' → Target.detachedFromTarget\n * 'replaced' → evicted by single-attach model (last-attach wins)\n */\n kind: 'crashed' | 'destroyed' | 'detached' | 'replaced';\n targetId: string | null;\n /** ISO timestamp of detection. */\n detectedAt: string;\n}\n\nfunction isObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\nfunction parseInbound(raw: string): CdpInboundMessage | null {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!isObject(parsed)) return null;\n const message: CdpInboundMessage = {};\n if (typeof parsed.id === 'number') message.id = parsed.id;\n if (typeof parsed.method === 'string') message.method = parsed.method;\n if ('params' in parsed) message.params = parsed.params;\n if ('result' in parsed) message.result = parsed.result;\n if (isObject(parsed.error) && typeof parsed.error.message === 'string') {\n message.error = { message: parsed.error.message };\n }\n return message;\n}\n\nconst PHASE_1_EVENTS: readonly CdpEventName[] = [\n 'Runtime.consoleAPICalled',\n 'Network.requestWillBeSent',\n 'Network.responseReceived',\n];\n\n/**\n * Ring buffer size for `Runtime.exceptionThrown`.\n *\n * Exceptions are rarer than console messages but each is heavier (stack\n * trace). 50 is generous enough to cover a crash scenario while keeping\n * memory bounded.\n *\n * **Lifecycle note**: the exception buffer intentionally survives `replaced` /\n * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target\n * transitions. Rationale: an exception fired just before a crash is exactly\n * the signal we want to preserve for root-cause analysis. The buffer\n * represents \"exceptions seen in this MCP session\", not \"exceptions in the\n * current page\".\n */\nconst EXCEPTION_BUFFER_SIZE = 50;\n\nexport interface ChiiCdpConnectionOptions {\n /** Base URL of the local Chii relay HTTP/WS server, e.g. `http://127.0.0.1:9100`. */\n relayBaseUrl: string;\n /** Per-domain ring buffer size. */\n bufferSize?: number;\n /**\n * Default per-command timeout in milliseconds.\n * Override via env `AIT_CDP_COMMAND_TIMEOUT_MS`.\n * Defaults to 30 000 ms (30s).\n */\n commandTimeoutMs?: number;\n /**\n * Hex-encoded TOTP secret (the SECRET, never a code). When set, each client WS\n * (re)connect mints a fresh `at=` code so it lands inside the relay's 90s\n * acceptance window. Leave undefined when the relay has TOTP disabled.\n * SECRET-HANDLING: stored privately, never logged; the minted code rides only\n * in the WS URL query.\n */\n totpSecret?: string;\n}\n\n/** Default per-command timeout if neither option nor env var is set. */\nconst DEFAULT_COMMAND_TIMEOUT_MS = 30_000;\n\n/**\n * Production CDP connection. Polls the relay for the first attached target,\n * opens a client websocket to it, enables Phase 1 domains, and buffers events.\n */\nexport class ChiiCdpConnection implements CdpConnection {\n /** Authoritative connection kind (issue #348) — relay-backed. */\n readonly kind = 'relay' as const;\n\n private readonly relayBaseUrl: string;\n private readonly bufferSize: number;\n private readonly commandTimeoutMs: number;\n private readonly totpSecret: string | undefined;\n private readonly emitter = new EventEmitter();\n private readonly buffers = new Map<CdpEventName, unknown[]>();\n private readonly targets = new Map<string, CdpTarget>();\n\n private ws: WebSocket | null = null;\n private connectionState: 'idle' | 'connected' | 'disconnected' = 'idle';\n private nextCommandId = 1;\n /**\n * The single active target id under the single-attach model.\n * Updated by `refreshTargets()` whenever a non-null target is present.\n * Used to detect a new (different) target attach and evict the previous one.\n */\n private activeTargetId: string | null = null;\n /** In-flight enableDomains() promise — concurrent callers share it. */\n private enablingPromise: Promise<void> | null = null;\n /** Pending request→response commands keyed by CDP message id. */\n private readonly pending = new Map<\n number,\n { resolve: (result: unknown) => void; reject: (err: Error) => void }\n >();\n\n /**\n * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,\n * or `null` if no crash has been detected since the last `enableDomains()`.\n */\n private lastCrashDetectedAt: number | null = null;\n\n /**\n * Per-target last-seen timestamp (ms since epoch). Updated on any inbound\n * CDP message carrying data from a target. Keyed by target id.\n */\n private readonly targetLastSeenAt = new Map<string, number>();\n\n /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */\n private heartbeatHandle: ReturnType<typeof setInterval> | null = null;\n\n /** Lifecycle event listeners (crash / destroyed / detached). */\n private readonly lifecycleListeners: Array<(event: TargetLifecycleEvent) => void> = [];\n\n constructor(options: ChiiCdpConnectionOptions) {\n this.relayBaseUrl = options.relayBaseUrl.replace(/\\/$/, '');\n this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;\n this.totpSecret = options.totpSecret;\n const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS\n ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS)\n : undefined;\n this.commandTimeoutMs =\n (envMs !== undefined && Number.isFinite(envMs) && envMs > 0 ? envMs : undefined) ??\n options.commandTimeoutMs ??\n DEFAULT_COMMAND_TIMEOUT_MS;\n for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);\n // Exception buffer initialized separately — its per-event size cap\n // (EXCEPTION_BUFFER_SIZE=50) is enforced in handleMessage below.\n this.buffers.set('Runtime.exceptionThrown', []);\n // EventEmitter caps listeners at 10 by default; the tool layer may add\n // several short-lived subscriptions, so lift the cap.\n this.emitter.setMaxListeners(0);\n }\n\n /** Refresh the attached-target list from the relay's `GET /targets`. */\n async refreshTargets(): Promise<CdpTarget[]> {\n // When TOTP is active, append a freshly-minted code as `?at=<code>` so the\n // relay's /targets gate (issue #474) accepts this daemon poll. Mirrors the\n // existing /client WS pattern (~line 412). `generateTotp` defaults `when` to\n // Date.now() — rely on that default; never hand-compute the time.\n // SECRET-HANDLING: never log `code` or `this.totpSecret`; the code rides only\n // in the at= param.\n let targetsUrl = `${this.relayBaseUrl}/targets`;\n if (this.totpSecret) {\n const code = generateTotp(this.totpSecret);\n targetsUrl += `?at=${encodeURIComponent(code)}`;\n }\n const res = await fetch(targetsUrl);\n if (!res.ok) {\n throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);\n }\n const body: unknown = await res.json();\n const list = isObject(body) && Array.isArray(body.targets) ? body.targets : [];\n\n // Single-attach model: find the \"newest\" target id from the relay response.\n // The relay may return multiple targets if the previous session did not cleanly\n // detach. We keep only the last entry (last-attach wins) and evict the previous\n // active target if it differs.\n let newestTargetId: string | null = null;\n for (const item of list) {\n if (!isObject(item) || typeof item.id !== 'string') continue;\n newestTargetId = item.id; // last wins\n }\n\n // Evict previous active target when a genuinely new targetId arrives.\n if (\n newestTargetId !== null &&\n this.activeTargetId !== null &&\n newestTargetId !== this.activeTargetId\n ) {\n const prevId = this.activeTargetId;\n // SECRET-HANDLING: prevTargetId is a Chii internal ID (not a secret) but\n // keep it short — no URL or credentials logged here.\n logInfo('page.detached', { prevTargetId: prevId });\n this.evictTarget(prevId);\n }\n\n // Rebuild the targets map with at most the single newest target.\n this.targets.clear();\n for (const item of list) {\n if (!isObject(item) || typeof item.id !== 'string') continue;\n // Single-attach model: only register the newest target.\n if (item.id !== newestTargetId) continue;\n this.targets.set(item.id, {\n id: item.id,\n title: typeof item.title === 'string' ? item.title : '',\n url: typeof item.url === 'string' ? item.url : '',\n });\n }\n\n if (newestTargetId !== null) {\n this.activeTargetId = newestTargetId;\n } else {\n this.activeTargetId = null;\n }\n\n const result = [...this.targets.values()];\n\n // Emit 'target:attached' for every newly-seen target so waitForFirstTarget()\n // can race against the next refreshTargets() polling round.\n if (newestTargetId !== null) {\n this.emitter.emit('target:attached', result);\n }\n\n return result;\n }\n\n listTargets(): CdpTarget[] {\n return [...this.targets.values()];\n }\n\n /**\n * Waits until at least one target matching `filterFn` is attached, then\n * resolves with the full target list at that moment.\n *\n * Resolution happens on whichever comes first:\n * (a) a `'target:attached'` event from `refreshTargets()` (triggered by\n * the /targets poll finding a new target), OR\n * (b) a `'target:attached'` event from `handleMessage()` (triggered by\n * the first inbound CDP message from a target — confirms the relay\n * websocket has data from the phone, not just a target entry in the map).\n *\n * This dual-signal approach eliminates the polling race that previously\n * caused `wait_for_attach` to resolve before the first CDP message arrived.\n *\n * Falls back to checking `listTargets()` every `pollIntervalMs` in case the\n * EventEmitter is missed (defensive belt-and-suspenders).\n *\n * @param filterFn - Predicate that the returned targets must satisfy.\n * @param timeoutMs - Reject after this many ms (default 90 000). Pass a\n * non-finite value (`Infinity`) to disable the rejection timer entirely —\n * used by the test-runner's unbounded QR-attach wait (devtools#735). Node\n * clamps `setTimeout(fn, Infinity)` to ~1ms, so a non-finite value MUST\n * skip arming the timer rather than pass it through.\n * @param pollIntervalMs - Fallback poll interval (default 500ms).\n */\n waitForFirstTarget(\n filterFn: (targets: CdpTarget[]) => boolean,\n timeoutMs = 90_000,\n pollIntervalMs = 500,\n ): Promise<CdpTarget[]> {\n // Fast path: already attached.\n const current = this.listTargets();\n if (filterFn(current)) return Promise.resolve(current);\n\n return new Promise<CdpTarget[]>((resolve, reject) => {\n let settled = false;\n let pollHandle: ReturnType<typeof setInterval> | null = null;\n\n const settle = (targets: CdpTarget[]): void => {\n if (settled) return;\n settled = true;\n if (timeoutHandle !== null) clearTimeout(timeoutHandle);\n if (pollHandle !== null) {\n clearInterval(pollHandle);\n pollHandle = null;\n }\n this.emitter.off('target:attached', onAttach);\n resolve(targets);\n };\n\n const onAttach = (targets: CdpTarget[]): void => {\n if (filterFn(targets)) settle(targets);\n };\n\n // Non-finite timeoutMs (Infinity) means \"wait forever\" — do NOT arm the\n // rejection timer. Node clamps setTimeout(fn, Infinity) to ~1ms, which\n // would reject almost immediately if passed through unguarded.\n const timeoutHandle: ReturnType<typeof setTimeout> | null = Number.isFinite(timeoutMs)\n ? setTimeout(() => {\n if (settled) return;\n settled = true;\n if (pollHandle !== null) {\n clearInterval(pollHandle);\n pollHandle = null;\n }\n this.emitter.off('target:attached', onAttach);\n reject(\n new Error(\n `waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`,\n ),\n );\n }, timeoutMs)\n : null;\n\n // Primary: event-driven path.\n this.emitter.on('target:attached', onAttach);\n\n // Fallback: polling path — also calls refreshTargets() to keep the in-memory\n // target map up-to-date. This ensures the polling path works even without\n // a live WebSocket (pre-enableDomains) and catches targets that appear\n // between 'target:attached' events.\n pollHandle = setInterval(() => {\n // Refresh from relay, then check. Errors are ignored — we keep polling.\n this.refreshTargets().then(\n (targets) => {\n if (filterFn(targets)) settle(targets);\n },\n () => {\n // Relay temporarily unreachable — keep polling.\n },\n );\n }, pollIntervalMs);\n });\n }\n\n /**\n * Timestamp (ms since epoch) of the most recent crash/destroy/detach event\n * detected since the last `enableDomains()` call, or `null` if none.\n */\n getLastCrashDetectedAt(): number | null {\n return this.lastCrashDetectedAt;\n }\n\n /**\n * Last-seen timestamp (ms since epoch) for a given target id, or `null` if\n * the target is unknown / no message has been received from it yet.\n */\n getTargetLastSeenAt(targetId: string): number | null {\n return this.targetLastSeenAt.get(targetId) ?? null;\n }\n\n /** Subscribe to target lifecycle events (crash / destroyed / detached). */\n onLifecycle(listener: (event: TargetLifecycleEvent) => void): () => void {\n this.lifecycleListeners.push(listener);\n return () => {\n const idx = this.lifecycleListeners.indexOf(listener);\n if (idx !== -1) this.lifecycleListeners.splice(idx, 1);\n };\n }\n\n /**\n * Connect a client websocket to the first attached target and enable Phase 1\n * domains. Resolves once the socket is open and enable commands are sent.\n */\n async enableDomains(): Promise<void> {\n if (this.ws && this.ws.readyState === WebSocket.OPEN) return;\n // If a connect attempt is already in-flight, await it rather than racing\n // to open a second websocket that would overwrite `this.ws` and leak the first.\n if (this.enablingPromise) return this.enablingPromise;\n this.enablingPromise = this._doEnableDomains().finally(() => {\n this.enablingPromise = null;\n });\n return this.enablingPromise;\n }\n\n private async _doEnableDomains(): Promise<void> {\n const targets = await this.refreshTargets();\n const target = targets[0];\n if (!target) {\n throw new Error('No mini-app page attached to the Chii relay yet.');\n }\n\n const wsBase = this.relayBaseUrl.replace(/^http/, 'ws');\n const clientId = `devtools-mcp-${Date.now()}`;\n let clientUrl = `${wsBase}/client/${clientId}?target=${encodeURIComponent(target.id)}`;\n // Append a freshly-minted TOTP code so the relay's WS upgrade gate\n // (chii-relay.ts verifyAuth) accepts this /client upgrade. Minted per-connect\n // so reconnects stay inside the 90s acceptance window. generateTotp defaults\n // `when` to Date.now() — rely on that default; never hand-compute the time.\n // SECRET-HANDLING: never log `code` or `this.totpSecret`; the code rides only\n // in the URL query.\n if (this.totpSecret) {\n const code = generateTotp(this.totpSecret);\n clientUrl += `&at=${encodeURIComponent(code)}`;\n }\n const ws = new WebSocket(clientUrl);\n this.ws = ws;\n\n await new Promise<void>((resolve, reject) => {\n ws.once('open', () => resolve());\n ws.once('error', (err: Error) => reject(err));\n // Issue #478: the relay rejects auth with accept-then-close (4401)\n // instead of a raw 401 destroy, so a rejected dial no longer surfaces\n // as an 'error' event. 'open' always precedes the close frame, making\n // this reject a settled-promise no-op in practice — kept as a defensive\n // boundary (and for any relay that closes before open). The post-open\n // 4401 is recognised by the persistent close handler below.\n ws.once('close', (code: number) => {\n if (code === RELAY_AUTH_REJECT_CLOSE_CODE) {\n reject(\n new Error(\n 'relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다.',\n ),\n );\n }\n });\n });\n\n // Reset crash state when a new connection is established.\n this.lastCrashDetectedAt = null;\n this.targetLastSeenAt.clear();\n // activeTargetId is already set by refreshTargets() above; don't reset here.\n\n this.connectionState = 'connected';\n ws.on('message', (data: WebSocket.RawData) => this.handleMessage(data.toString()));\n // Issue #478: close 4401 is the relay's named TOTP rejection\n // (accept-then-close) — name it as an auth failure instead of a generic\n // drop. #439's per-connect fresh code mint means this should not happen in\n // practice; defensive alignment with the relay contract.\n ws.on('close', (code: number) =>\n this.handleDisconnect(\n code === RELAY_AUTH_REJECT_CLOSE_CODE\n ? 'relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)'\n : 'relay WebSocket 연결이 끊겼습니다',\n ),\n );\n ws.on('error', (err: Error) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));\n\n this.sendFireAndForget('Runtime.enable');\n this.sendFireAndForget('Network.enable');\n // DOM/Page domains back the Phase 2 command tools; Chii answers their\n // request→response commands once enabled.\n this.sendFireAndForget('DOM.enable');\n this.sendFireAndForget('Page.enable');\n // Subscribe to page-level crash and target lifecycle events.\n // Inspector.targetCrashed fires when a page OOM/JS-crash/native-bridge crash.\n // Target.setDiscoverTargets enables Target.targetDestroyed + Target.detachedFromTarget.\n this.sendFireAndForget('Inspector.enable');\n this.sendFireAndForget('Target.setDiscoverTargets', { discover: true });\n\n // Optional heartbeat: env AIT_CDP_HEARTBEAT_MS=N enables a ping loop.\n this.startHeartbeat(target.id);\n }\n\n /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */\n private sendFireAndForget(method: string, params: Record<string, unknown> = {}): void {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;\n const id = this.nextCommandId++;\n this.ws.send(JSON.stringify({ id, method, params }));\n }\n\n /**\n * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP\n * error frame or when no websocket is open (no page attached yet).\n *\n * @param opts.timeoutMs - Per-call override for this connection's command\n * watchdog (devtools#747) — see the `CdpConnection.send` docblock for the\n * contract callers racing their own longer timeout must follow.\n */\n send<M extends CdpCommandName>(\n method: M,\n params?: CdpCommandMap[M]['params'],\n opts?: { timeoutMs?: number },\n ): Promise<CdpCommandMap[M]['result']> {\n return this.sendCommand(method, (params ?? {}) as Record<string, unknown>, opts) as Promise<\n CdpCommandMap[M]['result']\n >;\n }\n\n /**\n * Issue an arbitrary request→response command over the relay and resolve with\n * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3\n * `AIT.*` methods, forwarded over the same Chii channel) build on this.\n *\n * Rejects immediately if the connection is disconnected (fail-fast — no\n * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to\n * reattach.\n *\n * Times out after `opts.timeoutMs` when given, else `commandTimeoutMs`\n * (default 30s, env `AIT_CDP_COMMAND_TIMEOUT_MS`) — see devtools#747: the\n * default 30s watchdog used to undercut the test-runner's own longer\n * file-evaluate race no matter what `--timeout` the caller asked for. On\n * timeout the pending entry is cleaned up and the promise rejects with a\n * descriptive Korean error. `Number.isFinite` guards against a non-finite\n * override (e.g. `Infinity`, mirroring `waitForFirstTarget`'s convention)\n * so an intentional \"no watchdog\" override doesn't get clamped by\n * `setTimeout`.\n */\n sendCommand(\n method: string,\n params: Record<string, unknown> = {},\n opts?: { timeoutMs?: number },\n ): Promise<unknown> {\n // Fail-fast: connection already known to be dead — don't write into a dead socket.\n if (this.connectionState === 'disconnected') {\n return Promise.reject(\n new Error(\n `relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`,\n ),\n );\n }\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n return Promise.reject(\n new Error('No mini-app page attached to the Chii relay yet. Call enableDomains() first.'),\n );\n }\n const id = this.nextCommandId++;\n const ws = this.ws;\n const timeoutMs =\n opts?.timeoutMs !== undefined && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0\n ? opts.timeoutMs\n : this.commandTimeoutMs;\n return new Promise<unknown>((resolve, reject) => {\n const handle = setTimeout(() => {\n this.pending.delete(id);\n reject(\n new Error(\n `CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). ` +\n '폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. ' +\n 'list_pages로 attach 상태를 확인하세요.',\n ),\n );\n }, timeoutMs);\n this.pending.set(id, {\n resolve: (v) => {\n clearTimeout(handle);\n resolve(v);\n },\n reject: (e) => {\n clearTimeout(handle);\n reject(e);\n },\n });\n ws.send(JSON.stringify({ id, method, params }));\n });\n }\n\n /**\n * Called on WebSocket `close` or `error` after a successful connection.\n * Rejects all pending commands and marks the connection as disconnected so\n * subsequent `sendCommand` calls fail fast (no auto-reconnect).\n */\n private handleDisconnect(reason: string): void {\n if (this.connectionState === 'disconnected') return; // already handled\n this.connectionState = 'disconnected';\n this.ws = null;\n this.stopHeartbeat();\n const err = new Error(\n `${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`,\n );\n for (const waiter of this.pending.values()) {\n waiter.reject(err);\n }\n this.pending.clear();\n }\n\n /**\n * Evict a previously active target under the single-attach model.\n * Rejects pending commands with a 'replaced-by-new-attach' reason and emits\n * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific\n * targetId. The caller is responsible for rebuilding the targets map afterwards.\n *\n * The error message uses 'replaced-by-new-attach' so test assertions can match it.\n */\n private evictTarget(targetId: string): void {\n const detectedAt = new Date().toISOString();\n this.targets.delete(targetId);\n this.targetLastSeenAt.delete(targetId);\n\n const err = new Error(\n `[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). ` +\n 'list_pages로 현재 attach 상태를 확인하세요.',\n );\n for (const waiter of this.pending.values()) {\n waiter.reject(err);\n }\n this.pending.clear();\n\n const event: TargetLifecycleEvent = { kind: 'replaced', targetId, detectedAt };\n for (const listener of this.lifecycleListeners) {\n try {\n listener(event);\n } catch {\n // Listeners must not crash the connection.\n }\n }\n }\n\n /**\n * Handle a page-level crash or target destruction event.\n * Removes the target from the in-memory map, rejects all pending commands,\n * and emits a lifecycle event.\n *\n * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'\n * @param targetId - The target ID from the event params (may be null for\n * Inspector.targetCrashed which has no targetId in the params).\n */\n private handleTargetGone(kind: TargetLifecycleEvent['kind'], targetId: string | null): void {\n const detectedAt = new Date().toISOString();\n this.lastCrashDetectedAt = Date.now();\n\n // Remove matching target(s) from the in-memory map.\n if (targetId !== null) {\n this.targets.delete(targetId);\n this.targetLastSeenAt.delete(targetId);\n // Also clear activeTargetId when the active target is gone.\n if (this.activeTargetId === targetId) {\n this.activeTargetId = null;\n }\n } else {\n // Inspector.targetCrashed carries no targetId — clear all targets.\n this.targets.clear();\n this.targetLastSeenAt.clear();\n this.activeTargetId = null;\n }\n\n // Reject pending commands with a descriptive Korean error.\n const label =\n kind === 'crashed'\n ? 'page crash (Inspector.targetCrashed)'\n : kind === 'destroyed'\n ? 'target 종료 (Target.targetDestroyed)'\n : 'target detach (Target.detachedFromTarget)';\n const err = new Error(\n `[ait-debug] ${label} 감지됨 — relay에서 제거됐습니다. ` +\n '새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).',\n );\n for (const waiter of this.pending.values()) {\n waiter.reject(err);\n }\n this.pending.clear();\n\n // Notify lifecycle listeners.\n const event: TargetLifecycleEvent = { kind, targetId, detectedAt };\n for (const listener of this.lifecycleListeners) {\n try {\n listener(event);\n } catch {\n // Listeners must not crash the connection.\n }\n }\n }\n\n /**\n * Start the optional CDP heartbeat loop.\n *\n * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval\n * we send `Runtime.evaluate({expression: '1'})` to each active target. If\n * the command times out (2 s hard deadline) or errors, we treat the target\n * as dead and call `handleTargetGone`.\n *\n * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws\n * even when the phone app has crashed, so the ws-level disconnect (#252) won't\n * fire. The heartbeat catches this gap.\n *\n * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.\n */\n private startHeartbeat(initialTargetId: string): void {\n this.stopHeartbeat(); // clear any previous interval\n\n const envMs = process.env.AIT_CDP_HEARTBEAT_MS\n ? Number(process.env.AIT_CDP_HEARTBEAT_MS)\n : undefined;\n if (envMs === undefined || !Number.isFinite(envMs) || envMs <= 0) return;\n\n const PING_TIMEOUT_MS = 2_000;\n\n this.heartbeatHandle = setInterval(() => {\n // Take a snapshot of current targets to avoid mutation during iteration.\n const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];\n for (const targetId of targetIds) {\n // Issue a lightweight eval with a 2 s deadline.\n const pingPromise = this.sendCommand('Runtime.evaluate', {\n expression: '1',\n returnByValue: true,\n timeout: PING_TIMEOUT_MS,\n });\n const timeoutPromise = new Promise<never>((_, reject) =>\n setTimeout(\n () => reject(new Error('heartbeat timeout')),\n PING_TIMEOUT_MS + 500, // slightly longer than the CDP timeout\n ),\n );\n Promise.race([pingPromise, timeoutPromise]).catch(() => {\n // Ping failed: mark target as dead if it still exists in the map.\n if (this.targets.has(targetId)) {\n this.handleTargetGone('destroyed', targetId);\n }\n });\n }\n }, envMs) as unknown as ReturnType<typeof setInterval>;\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatHandle !== null) {\n clearInterval(this.heartbeatHandle);\n this.heartbeatHandle = null;\n }\n }\n\n private handleMessage(raw: string): void {\n const message = parseInbound(raw);\n if (!message) return;\n\n // Command response (has an id matching a pending request).\n if (typeof message.id === 'number' && this.pending.has(message.id)) {\n const waiter = this.pending.get(message.id);\n this.pending.delete(message.id);\n if (waiter) {\n if (message.error) waiter.reject(new Error(message.error.message));\n else waiter.resolve(message.result);\n }\n return;\n }\n\n // Any inbound message implies the connection is active — update lastSeenAt\n // for whichever target we currently know about (single-target model).\n // Also emit 'target:attached' on the first inbound message from a target\n // (targetLastSeenAt unset) so waitForFirstTarget() resolves on first CDP\n // message, not just on the next /targets poll.\n const now = Date.now();\n let firstMessageSeen = false;\n for (const targetId of this.targets.keys()) {\n if (!this.targetLastSeenAt.has(targetId)) {\n firstMessageSeen = true;\n }\n this.targetLastSeenAt.set(targetId, now);\n }\n if (firstMessageSeen && this.targets.size > 0) {\n this.emitter.emit('target:attached', [...this.targets.values()]);\n }\n\n if (typeof message.method !== 'string') return;\n\n // --- Target lifecycle events ---\n\n // Inspector.targetCrashed: page OOM / JS exception / native bridge crash.\n // Params are usually empty; no targetId field in the event.\n if (message.method === 'Inspector.targetCrashed') {\n this.handleTargetGone('crashed', null);\n return;\n }\n\n // Target.targetDestroyed: params = { targetId: string }\n if (message.method === 'Target.targetDestroyed') {\n const targetId =\n isObject(message.params) && typeof message.params.targetId === 'string'\n ? message.params.targetId\n : null;\n this.handleTargetGone('destroyed', targetId);\n return;\n }\n\n // Target.detachedFromTarget: params = { sessionId, targetId? }\n if (message.method === 'Target.detachedFromTarget') {\n const targetId =\n isObject(message.params) && typeof message.params.targetId === 'string'\n ? message.params.targetId\n : null;\n this.handleTargetGone('detached', targetId);\n return;\n }\n\n // --- Phase 1 event stream (buffered ring-buffer) ---\n if (!this.buffers.has(message.method as CdpEventName)) return;\n const event = message.method as CdpEventName;\n const buffer = this.buffers.get(event);\n if (!buffer) return;\n buffer.push(message.params);\n // Runtime.exceptionThrown uses a dedicated smaller cap (50); all other\n // Phase 1 events use the default bufferSize (500).\n const cap = event === 'Runtime.exceptionThrown' ? EXCEPTION_BUFFER_SIZE : this.bufferSize;\n if (buffer.length > cap) buffer.shift();\n this.emitter.emit(event, message.params);\n }\n\n getBufferedEvents<E extends CdpEventName>(event: E): ReadonlyArray<CdpEventMap[E]> {\n const buffer = this.buffers.get(event);\n return (buffer ?? []) as ReadonlyArray<CdpEventMap[E]>;\n }\n\n on<E extends CdpEventName>(event: E, listener: (payload: CdpEventMap[E]) => void): () => void {\n this.emitter.on(event, listener as (payload: unknown) => void);\n return () => this.emitter.off(event, listener as (payload: unknown) => void);\n }\n\n /** Close the relay client websocket and reject any in-flight commands. */\n close(): void {\n const ws = this.ws;\n this.stopHeartbeat();\n // handleDisconnect clears this.ws and pending; call it first so the 'close'\n // event from ws.close() below is a no-op (already disconnected).\n this.handleDisconnect('Chii relay connection closed');\n ws?.close();\n }\n}\n","/**\n * esbuild-based bundler for user test files.\n *\n * Bundles a single test file into a self-contained IIFE string that can be\n * injected into a WebView via `Runtime.evaluate`. The bundle includes the\n * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and\n * the `runTestModule(factory)` entry point.\n *\n * ## How the wiring works\n *\n * The bundle exposes two exports on `globalThis.__testBundle`:\n * - `runTestModule` — the runtime's entry function.\n * - `__userFactory` — an async function whose body is the user's top-level\n * test registration code (describe/it/test calls).\n *\n * The Node-side RPC (`rpc.ts`) calls:\n * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`\n *\n * `runTestModule` then installs `describe/it/test/expect` as globals, invokes\n * the factory (which registers all tests), runs them, and returns a `RunReport`.\n *\n * ## Why a factory wrapper is needed\n *\n * Naively adding the runtime to `entryPoints` and bundling the user file would\n * fail for two reasons:\n * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE\n * scope. The user's top-level `describe(...)` calls expect them as globals —\n * they are not globals until `runTestModule` installs them.\n * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation\n * time, before the RPC layer calls `runTestModule` to reset state and start\n * the test clock.\n *\n * The factory approach solves both: the user's registration code is deferred\n * into a function that `runTestModule` calls AFTER installing the globals.\n *\n * ## Factory extraction algorithm\n *\n * The `userFactoryPlugin` reads the user file and splits lines into:\n * - **top-level**: `import …` and re-export lines — kept at module scope\n * (the only valid position for static `import` in ESM).\n * - **body**: all other statements — moved into the body of the exported\n * `__userFactory` async function.\n *\n * esbuild processes the re-generated module, following each static import\n * through the normal dependency graph (including the SDK-redirect plugin).\n *\n * ## SDK redirect\n *\n * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via\n * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that\n * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.\n *\n * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.\n */\n\nimport { accessSync } from 'node:fs';\nimport * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n// esbuild is imported for TYPES only at module scope; the runtime module is\n// loaded lazily inside `bundleTestFile` via dynamic import. esbuild runs a\n// startup invariant check (`TextEncoder().encode('') instanceof Uint8Array`)\n// that fails in a jsdom realm — a static import would break every MCP/test\n// module that merely *imports* this file's transitive graph (e.g. debug-server →\n// run_tests). Lazy load keeps esbuild off the import graph until a bundle is\n// actually built, and mirrors the cloudflared/chii dynamic-import precedent.\nimport type * as esbuild from 'esbuild';\n\n/** Options accepted by `bundleTestFile`. */\nexport interface BundleOptions {\n /**\n * Additional esbuild `external` patterns. The SDK package\n * (`@apps-in-toss/web-framework` and `@apps-in-toss/web-framework/*`) is\n * always handled by the SDK redirect plugin — callers may add more patterns\n * to be left as globals.\n */\n extraExternals?: string[];\n /**\n * Global name for the IIFE output object. Defaults to `__testBundle`.\n * The runtime entry uses this to call `__testBundle.runTestModule(__userFactory)`.\n */\n globalName?: string;\n}\n\n/**\n * The result of bundling a test file.\n * `code` is a self-contained IIFE string ready for `Runtime.evaluate`.\n */\nexport interface BundleResult {\n code: string;\n warnings: string[];\n}\n\n/** The SDK package name that mini-app test code imports from. */\nconst SDK_PACKAGE = '@apps-in-toss/web-framework';\n\n/**\n * Names the runtime installs as globals before invoking the user factory.\n * The `vitest` virtual module re-exports each as a lazy getter that reads from\n * `globalThis` at access time. Keep in sync with the globals installed in\n * `runtime.ts#runTestModule`.\n */\nconst VITEST_GLOBAL_NAMES = [\n 'describe',\n 'it',\n 'test',\n 'expect',\n 'beforeAll',\n 'afterAll',\n 'beforeEach',\n 'afterEach',\n 'vi',\n] as const;\n\n/**\n * Matches the bare SDK package and any sub-path import\n * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).\n * Built from {@link SDK_PACKAGE} so the package name has a single source.\n */\nconst SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}`);\n\n/**\n * esbuild plugin that intercepts SDK imports and redirects them to the\n * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.\n *\n * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),\n * esbuild resolves it to a virtual module that re-exports all named exports\n * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not\n * be available in the test environment) while still making named imports work.\n *\n * If `window.__sdk` is absent (non-dog-food build), every access throws a\n * descriptive error rather than returning `undefined` silently.\n *\n * Bridge-stub interception (devtools#740, DT-2): the virtual module runs the\n * resolved `window.__sdk` object through `wrapSdkWithStub` before exporting\n * it. `wrapSdkWithStub` is a pass-through (returns the same object,\n * unwrapped) unless `isStubBlockingEnabled()` reads `true` off\n * `globalThis.__AIT_STUB_BLOCKING__` — an opt-in flag injected the same way\n * `__AIT_CELL__` is (`cell.ts#injectGlobals`, wired from the CLI's\n * `--stub-blocking` flag). Default off => zero behavior change: every existing\n * (non-stub) test run resolves the exact same `window.__sdk` reference it did\n * before this plugin learned about stubbing.\n *\n * Per-method pacing (devtools#769): BEFORE the stub wrap, `window.__sdk` is\n * run through `wrapWithMethodPacing`, which enforces a minimum interval\n * between calls to the SAME named SDK function (`getPaceMethodMs()` reads the\n * `__AIT_PACE_METHOD_MS__` page global — see `method-pace.ts`). Composition\n * order is pacing INSIDE the stub (`wrapSdkWithStub(wrapWithMethodPacing(sdk,\n * gap), enabled)`): a stubbed name resolves instantly from\n * `bridge-stub.ts`'s fixtures without ever reaching the paced wrapper, and\n * only calls that fall through to the real bridge — the ones that can\n * actually trigger `APP_BRIDGE_THROTTLED` — pay the pacing cost. `gapMs <= 0`\n * (default, no `--pace-method` / non-2.x cell) is a no-op fast path — see\n * `method-pace.ts#wrapWithMethodPacing`'s doc for the full contract.\n */\n/**\n * Builds the virtual CommonJS-style module contents the `sdk-redirect`\n * esbuild plugin loads for every `@apps-in-toss/web-framework` import.\n *\n * Pulled out to a pure, exported function (rather than inlined at the\n * `build.onLoad` call site) so unit tests can assert the exact composition\n * order (pacing wraps the raw SDK; the stub wraps the paced result) as a\n * string-level contract, independent of esbuild — esbuild cannot run inside\n * this repo's jsdom vitest environment (see `bundleTestFile`'s module doc's\n * lazy-import note), so a real bundling round-trip is out of unit-test reach\n * here; `wrapSdkWithStub`/`wrapWithMethodPacing`'s own composed-call behavior\n * is covered directly (see `bundle.test.ts`).\n *\n * Generates a virtual CommonJS-style module so that esbuild does NOT perform\n * strict named-export matching. When `format:'iife'` bundles a CJS module, it\n * wraps it with its own `__toCommonJS` helper and satisfies named imports via\n * property access on the `module.exports` object — which is our Proxy. This\n * means `import { getPlatformOS } from '...'` becomes\n * `__proxy.getPlatformOS` at runtime, which correctly reads from\n * `window.__sdk`.\n *\n * The bridge-stub and method-pace imports are STATIC imports of the real\n * `bridge-stub.ts`/`method-pace.ts` modules (not inlined JS) so esbuild\n * bundles each once and the fixture registry / pacing registry have a single\n * source of truth shared with their own unit tests.\n *\n * Exported for unit testing.\n */\nexport function buildSdkRedirectModuleContents(): string {\n return `\nimport { wrapSdkWithStub, isStubBlockingEnabled } from ${JSON.stringify(getBridgeStubPath())};\nimport { wrapWithMethodPacing, getPaceMethodMs } from ${JSON.stringify(getMethodPacePath())};\nvar __rawSdk = (typeof window !== 'undefined' && window.__sdk)\n ? window.__sdk\n : new Proxy({}, {\n get: function(_t, p) {\n throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));\n }\n });\nvar __pacedSdk = wrapWithMethodPacing(__rawSdk, getPaceMethodMs());\nvar __proxy = wrapSdkWithStub(__pacedSdk, isStubBlockingEnabled());\nmodule.exports = __proxy;\n`;\n}\n\nfunction sdkRedirectPlugin(): esbuild.Plugin {\n return {\n name: 'sdk-redirect',\n setup(build) {\n // Match the bare package and any sub-path imports\n build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({\n path: args.path,\n namespace: 'sdk-redirect',\n }));\n\n build.onLoad({ filter: /.*/, namespace: 'sdk-redirect' }, () => ({\n contents: buildSdkRedirectModuleContents(),\n loader: 'js',\n // resolveDir is required so esbuild can resolve the absolute\n // getBridgeStubPath()/getMethodPacePath() imports above from a\n // filesystem base — a virtual module has no implicit directory of\n // its own. process.cwd() is a safe base for an ABSOLUTE import\n // specifier (it is never used to resolve anything relative).\n resolveDir: process.cwd(),\n }));\n },\n };\n}\n\n/**\n * esbuild plugin that intercepts `import … from 'vitest'` and replaces it with\n * a virtual module that delegates every named import to `globalThis` at ACCESS\n * time (not at bundle-evaluation time).\n *\n * The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/\n * afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle\n * IIFE is evaluated. A value-copy redirect (`export var describe =\n * globalThis.describe`) would therefore capture `undefined` at evaluation time\n * and the user's `describe(...)` calls would be no-ops — registering zero tests.\n *\n * The fix defers the lookup to call time using per-name **getter** exports.\n * We emit a CommonJS module that:\n * 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named\n * import directly to a property access on the module (NOT wrapped under a\n * `default` shim — which is what happens for a bare Proxy whose own-keys\n * are empty, leaving every named import `undefined`);\n * 2. defines each global name as a getter that reads `globalThis[name]` on\n * every access. So `import { describe } from 'vitest'` compiles to\n * `import_vitest.describe`, whose getter returns the real `describe` only\n * when the factory calls it — after `runTestModule` installs the globals.\n *\n * A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes\n * the virtual module through `__toESM`, which enumerates own-keys (none on an\n * empty Proxy target) and therefore exposes zero named exports. Explicit getter\n * properties give `__toESM` real keys to map while keeping access lazy.\n */\nfunction vitestRedirectPlugin(): esbuild.Plugin {\n return {\n name: 'vitest-redirect',\n setup(build) {\n build.onResolve({ filter: /^vitest$/ }, () => ({\n path: 'vitest',\n namespace: 'vitest-redirect',\n }));\n\n build.onLoad({ filter: /^vitest$/, namespace: 'vitest-redirect' }, () => {\n const getters = VITEST_GLOBAL_NAMES.map(\n (name) =>\n `Object.defineProperty(exports, ${JSON.stringify(name)}, { enumerable: true, get: function() { return globalThis[${JSON.stringify(name)}]; } });`,\n ).join('\\n');\n // __esModule lets esbuild __toESM map named imports to these getters\n // directly. Each getter reads globalThis lazily so the value resolves at\n // call time — after runTestModule installs the globals.\n return {\n contents: `Object.defineProperty(exports, '__esModule', { value: true });\\n${getters}\\n`,\n loader: 'js',\n };\n });\n },\n };\n}\n\n/**\n * esbuild plugin that transforms the user test file into a module that exports\n * an async `__userFactory` function. The factory defers the user's top-level\n * test registration code (describe/it/test calls) so it only runs when\n * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has\n * installed describe/it/test/expect as globals.\n *\n * Algorithm:\n * - Import declarations and re-export statements are kept at module top-level\n * (the only valid ESM position for static `import`). A statement that spans\n * multiple lines — e.g. a named import with one member per line:\n * import {\n * appLogin,\n * getAnonymousKey,\n * } from '@apps-in-toss/web-framework';\n * is tracked as a single block: every line from the opening `import {` /\n * `export {` through the closing `from '…'` (or side-effect `'…'`) line is\n * kept together at top-level. This prevents the member lines and the\n * closing `} from '…'` line from leaking into the factory body, which would\n * leave an unterminated `import {` at module scope (the #678 env3 failure:\n * esbuild threw `Expected \"as\" but found \"{\"` on multi-line SDK imports).\n * - All other lines (describe/it/test calls, local declarations, etc.) are\n * moved into the body of the exported async factory function.\n *\n * This preserves SDK import resolution (the sdk-redirect plugin processes\n * top-level imports normally) while deferring test registration to the factory.\n */\nfunction userFactoryPlugin(absPath: string): esbuild.Plugin {\n const NAMESPACE = 'user-test-factory';\n return {\n name: 'user-test-factory',\n setup(build) {\n // Resolve the virtual \"user-test-factory\" specifier to our namespace.\n build.onResolve({ filter: /^user-test-factory$/ }, () => ({\n path: absPath,\n namespace: NAMESPACE,\n }));\n\n // Load the user file, split imports from body, wrap body in the factory.\n build.onLoad({ filter: /.*/, namespace: NAMESPACE }, async (args) => {\n const source = await fs.readFile(args.path, 'utf8');\n const lines = source.split('\\n');\n\n const topLevelLines: string[] = [];\n const bodyLines: string[] = [];\n\n // Matches `export` value declarations that cannot appear inside a\n // function body. We strip the `export` keyword so they become plain\n // declarations inside the factory.\n const EXPORT_DECLARATION_RE =\n /^(export\\s+)(default\\s+|async\\s+function\\s+|function\\s+|class\\s+|const\\s+|let\\s+|var\\s+)/;\n\n // True when `trimmed` begins a static `import` statement.\n const isImportStart = (trimmed: string): boolean =>\n trimmed.startsWith('import ') ||\n trimmed.startsWith('import{') ||\n trimmed.startsWith(\"import'\") ||\n trimmed.startsWith('import\"');\n\n // A module-scope `import`/`export … from` statement is \"complete\" on a\n // single line when it ends with a quoted module specifier (optionally\n // followed by `;`/whitespace), e.g. `… from '@x';` or side-effect\n // `import './x';`. A line ending in `{` or `,` (the common multi-line\n // named-import shape) is therefore NOT complete and must accumulate\n // further lines until the closing `from '…'` line.\n const endsStatement = (trimmed: string): boolean =>\n /['\"]\\s*;?\\s*$/.test(trimmed.replace(/\\/\\/.*$/, '').trimEnd());\n\n // When set, we are inside an unterminated multi-line import/re-export\n // block: every subsequent line stays at top level until the block ends.\n let inImportBlock = false;\n\n for (const line of lines) {\n const trimmed = line.trimStart();\n const indent = line.slice(0, line.length - trimmed.length);\n\n // Continuation of a multi-line import / re-export block — keep at\n // top level. The block ends on the line that terminates the\n // statement (closing `from '…'` / side-effect `'…'`).\n if (inImportBlock) {\n topLevelLines.push(line);\n if (endsStatement(trimmed)) {\n inImportBlock = false;\n }\n continue;\n }\n\n // Static import declarations must stay at module top level\n // (the ESM spec forbids `import` inside a function body). If the\n // statement does not terminate on this line, open an import block so\n // the member lines and closing `} from '…'` line stay top-level too.\n if (isImportStart(trimmed)) {\n topLevelLines.push(line);\n if (!endsStatement(trimmed)) {\n inImportBlock = true;\n }\n } else if (trimmed.startsWith('export ')) {\n // Determine whether this is a re-export (stays top-level) or a value\n // declaration (goes into the factory, export keyword stripped).\n const m = trimmed.match(EXPORT_DECLARATION_RE);\n if (m) {\n // Value declaration — strip `export ` and move into factory body.\n // e.g. `export function hello()` → `function hello()`\n // `export const x = 1` → `const x = 1`\n bodyLines.push(indent + trimmed.slice('export '.length));\n } else {\n // Re-export or `export type { … }` — stays at top level. A\n // multi-line `export { … } from '…'` opens an import block too.\n topLevelLines.push(line);\n if (/\\bfrom\\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith('{')) {\n inImportBlock = true;\n }\n }\n } else {\n bodyLines.push(line);\n }\n }\n\n const factoryContent = [\n ...topLevelLines,\n '',\n '// biome-ignore lint: generated factory wrapper',\n 'export default async function __userFactory(): Promise<void> {',\n ...bodyLines.map((l) => ` ${l}`),\n '}',\n ].join('\\n');\n\n return {\n contents: factoryContent,\n loader: 'ts',\n resolveDir: path.dirname(absPath),\n };\n });\n },\n };\n}\n\n/**\n * Returns the absolute filesystem path to a page-side leaf module shipped\n * alongside `bundle.ts` in dist (e.g. `runtime.js`, `bridge-stub.js`) — a\n * fully self-contained module with no further page-side deps of its own.\n *\n * Rolldown code-splitting duplicates this bundling logic into shared chunks\n * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from\n * dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`\n * daemon (dist/mcp/cli.js) pulls it through a ROOT chunk\n * (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list\n * is therefore wrong from at least one chunk — the live #697 regression.\n *\n * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it\n * probes the co-located `<name>.js` and the nested `test-runner/<name>.js`,\n * then ascends one directory at a time (bounded) repeating both probes. The\n * nested probe catches dist/test-runner/<name>.js from the dist/ root level no\n * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future\n * relocation). The build always emits dist/test-runner/<name>.js (tsdown entry\n * `'test-runner/<name>'`; guarded by scripts/check-test-runner-dist.sh for\n * `runtime`).\n *\n * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file\n * read, bypassing Node module resolution entirely, so this works identically in\n * the npx-daemon context (its own dist tree) and the consumer-CLI context\n * (the mini-app's installed @ait-co/devtools dist) — neither needs the package\n * to be node-resolvable from the caller.\n *\n * @param moduleName - The leaf module's basename without extension, e.g.\n * `'runtime'` or `'bridge-stub'`.\n */\nfunction getPageSideModulePath(moduleName: string): string {\n const startDir = path.dirname(fileURLToPath(import.meta.url));\n // .js first so a SHIPPED chunk never feeds esbuild raw TypeScript; the .ts\n // probe is last, for the source-tree (tsx) dev case where the module sits\n // beside bundle.ts and no dist exists.\n const RELATIVE_PROBES: string[][] = [\n [`${moduleName}.js`],\n ['test-runner', `${moduleName}.js`],\n [`${moduleName}.ts`],\n ['test-runner', `${moduleName}.ts`],\n ];\n // Bounded ascent: a package's dist is only a few levels deep. 12 is far more\n // than any real layout (root chunk → dist is 0 hops; dist/mcp → dist is 1)\n // and terminates well before the filesystem root in every case.\n let dir = startDir;\n for (let i = 0; i < 12; i++) {\n for (const segs of RELATIVE_PROBES) {\n const candidate = path.join(dir, ...segs);\n try {\n accessSync(candidate);\n return candidate;\n } catch {\n // try next probe\n }\n }\n const parent = path.dirname(dir);\n if (parent === dir) break; // reached filesystem root\n dir = parent;\n }\n // Nothing matched — return the co-located path so esbuild emits a clear\n // \"Could not resolve\" against a concrete path rather than failing cryptically.\n return path.join(startDir, `${moduleName}.js`);\n}\n\n/** Absolute path to the test-runner page-side runtime (describe/it/test/expect). */\nfunction getRuntimePath(): string {\n return getPageSideModulePath('runtime');\n}\n\n/**\n * Absolute path to the bridge-stub interceptor (devtools#740, DT-2) —\n * `wrapSdkWithStub`/`isStubBlockingEnabled`, statically imported by the\n * `sdk-redirect` virtual module.\n */\nfunction getBridgeStubPath(): string {\n return getPageSideModulePath('bridge-stub');\n}\n\n/**\n * Absolute path to the per-method pacing wrapper (devtools#769) —\n * `wrapWithMethodPacing`/`getPaceMethodMs`, statically imported by the\n * `sdk-redirect` virtual module.\n */\nfunction getMethodPacePath(): string {\n return getPageSideModulePath('method-pace');\n}\n\n/**\n * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.\n *\n * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:\n * - `runTestModule` — the runtime entry (from `runtime.ts`).\n * - `__userFactory` — an async function wrapping the user's test registration\n * code so it runs AFTER `runTestModule` installs the globals.\n *\n * Callers (rpc.ts) invoke:\n * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`\n *\n * @param absPath - Absolute path to the user test file.\n * @param opts - Optional bundling overrides.\n */\nexport async function bundleTestFile(absPath: string, opts?: BundleOptions): Promise<BundleResult> {\n const globalName = opts?.globalName ?? '__testBundle';\n const extraExternals = opts?.extraExternals ?? [];\n\n // Lazy load esbuild at call time (see the module-scope import note).\n const esbuild = await import('esbuild');\n const runtimePath = getRuntimePath();\n\n // Stdin wrapper: import the runtime and the user factory, re-export both.\n // esbuild follows the static imports to include runtime.ts and the user file\n // (via the userFactoryPlugin) in the single IIFE output.\n const wrapperContent = [\n `import { runTestModule } from ${JSON.stringify(runtimePath)};`,\n `import __userFactory from \"user-test-factory\";`,\n `export { runTestModule, __userFactory };`,\n ].join('\\n');\n\n const result = await esbuild.build({\n stdin: {\n contents: wrapperContent,\n loader: 'ts',\n // resolveDir is used for relative imports from the wrapper. Since the\n // wrapper only imports absolute paths (runtimePath) and the virtual\n // \"user-test-factory\" specifier (resolved by plugin), the directory\n // doesn't matter — but we still provide a sensible default.\n resolveDir: path.dirname(absPath),\n },\n bundle: true,\n format: 'iife',\n globalName,\n platform: 'browser',\n target: 'es2022',\n write: false,\n plugins: [userFactoryPlugin(absPath), vitestRedirectPlugin(), sdkRedirectPlugin()],\n external: extraExternals,\n treeShaking: true,\n // Ensure the IIFE result is always reachable via globalThis regardless of\n // the evaluation context. esbuild's `globalName` emits:\n // var __testBundle = (() => { ... })();\n // When `Runtime.evaluate` runs this bundle code inside an outer wrapper\n // (rpc.ts's async IIFE), `var` creates a local variable — NOT a global\n // property — so `globalThis.__testBundle` stays `undefined`. The footer\n // explicitly assigns the local variable to `globalThis` to close that gap.\n footer: {\n js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};`,\n },\n });\n\n const warnings = result.warnings.map(\n (w) =>\n `${path.relative(process.cwd(), w.location?.file ?? '')}:${w.location?.line ?? '?'}: ${w.text}`,\n );\n\n const outputFile = result.outputFiles?.[0];\n if (!outputFile) {\n throw new Error('bundleTestFile: esbuild produced no output — check entryPoints');\n }\n\n return { code: outputFile.text, warnings };\n}\n","/**\n * `__AIT_CAPTURE__` console-line parser (devtools#696).\n *\n * env3 test runs inject sdk-example's `aitCapture.ts`, which — when running in a\n * WebView (no filesystem) — emits one `console.log` line per category in the\n * stable form:\n *\n * __AIT_CAPTURE__ <category> <json>\n *\n * The relay-worker harvests `Runtime.consoleAPICalled` events as plain text\n * lines (see `relay-worker.ts`) and hands them here. This module's only job is\n * to recognise the allowlisted prefix, split off the category, and keep the\n * remaining JSON string OPAQUE — devtools does NOT own or interpret the\n * `__AIT_CAPTURE__` record shape (that lives in sdk-example). We carry\n * `{ category, json }` through untouched so the 2.x↔3.0 line comparison stays a\n * downstream concern.\n *\n * Why a strict allowlist prefix (SECRET-HANDLING): the raw console stream may\n * also contain wss/scheme/relay noise lines. Only lines that EXACTLY start with\n * `'__AIT_CAPTURE__ '` pass; everything else is discarded, so a stray relay URL\n * can never be serialised into a capture artifact.\n *\n * react-free, dependency-free — safe to import from any test-runner entry\n * without dragging in the chii/cloudflared Node graph.\n */\n\n/**\n * One parsed capture line. `json` is the raw JSON string exactly as the page\n * emitted it (an array of sdk-example capture records). devtools treats it as\n * opaque — it is validated as parseable JSON but never inspected.\n */\nexport interface AitCaptureLine {\n /** The capture category (first whitespace-delimited token after the prefix). */\n category: string;\n /** The remaining JSON payload, verbatim. Opaque to devtools. */\n json: string;\n}\n\n/** The exact console-line prefix sdk-example's `flushCapture` emits. */\nconst CAPTURE_PREFIX = '__AIT_CAPTURE__ ';\n\n/**\n * Parses raw console line texts into {@link AitCaptureLine}s.\n *\n * Filtering rules (each independently drops a line — never throws):\n * - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);\n * - there must be a non-empty category token (up to the next space);\n * - the remaining payload must be valid JSON (`JSON.parse` succeeds).\n *\n * Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are\n * silently discarded — capture harvesting is best-effort and must never fail a\n * run or leak a malformed/secret-bearing line.\n *\n * @param raw - Console line objects (only `.text` is read).\n * @returns The captured lines, in input order.\n */\nexport function parseCaptureLines(raw: ReadonlyArray<{ text: string }>): AitCaptureLine[] {\n const out: AitCaptureLine[] = [];\n for (const { text } of raw) {\n // Allowlist: only the exact `__AIT_CAPTURE__ ` prefix passes. wss/scheme\n // noise lines are dropped here (SECRET-HANDLING).\n if (!text.startsWith(CAPTURE_PREFIX)) continue;\n\n const body = text.slice(CAPTURE_PREFIX.length);\n const spaceIdx = body.indexOf(' ');\n // No payload separator → malformed; drop.\n if (spaceIdx === -1) continue;\n\n const category = body.slice(0, spaceIdx);\n const json = body.slice(spaceIdx + 1);\n if (category === '' || json === '') continue;\n\n // Validate the payload is parseable JSON, but keep it as an OPAQUE string —\n // devtools does not own the record shape. Broken JSON is discarded (no throw).\n try {\n JSON.parse(json);\n } catch {\n continue;\n }\n\n out.push({ category, json });\n }\n return out;\n}\n","/**\n * Node-side RPC helpers for injecting and collecting test execution over CDP.\n *\n * Uses the same IIFE + JSON.stringify envelope pattern as `buildCallSdkExpression`\n * in `src/mcp/tools.ts` to reliably shuttle structured results through\n * `Runtime.evaluate`'s `returnByValue: true` boundary.\n *\n * SECRET-HANDLING: bundle code, relay URLs, and result values are NOT logged.\n */\n\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport type { RunReport } from './runtime.js';\n\n/** Maximum milliseconds to wait for a single evaluate round-trip. */\nconst DEFAULT_TIMEOUT_MS = 60_000;\n\n/**\n * Extra headroom (ms) added on top of the rpc-level `timeoutMs` when telling\n * the underlying `CdpConnection` its per-command watchdog budget (devtools#747).\n * The rpc-level race above is the authoritative file timeout — this margin\n * only ensures the connection's own (shorter-by-default) watchdog never fires\n * first and masks the rpc-level timeout error with a connection-level one.\n */\nconst CONNECTION_TIMEOUT_MARGIN_MS = 5_000;\n\n/**\n * Wraps bundle code in a self-executing IIFE that:\n * 1. Evaluates the bundle (registering describe/it/test).\n * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.\n * 3. Returns a JSON-serialised `RunReport` string.\n *\n * The double-serialisation (RunReport → JSON string → returnByValue string)\n * is intentional: CDP `returnByValue` reliably transports strings; deeply\n * nested objects can lose fidelity across the Chii relay.\n *\n * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.\n */\nexport function buildRunTestsExpression(bundleCode: string): string {\n // We trust bundleCode is already a self-contained IIFE that installs\n // `window.__testBundle` (or `globalThis.__testBundle`).\n // We then call `__testBundle.runTestModule()` and return a JSON string.\n return (\n `(async () => {` +\n // Step 1: evaluate the bundle to register tests\n ` try { ${bundleCode} } catch(e) {` +\n ` return JSON.stringify({ok:false,error:'bundle-eval: ' + String(e && e.message || e)});` +\n ` }` +\n // Step 2: check that the expected exports are present\n ` if (typeof globalThis.__testBundle !== 'object' || typeof globalThis.__testBundle.runTestModule !== 'function' || typeof globalThis.__testBundle.__userFactory !== 'function') {` +\n ` return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule or __userFactory is not a function'});` +\n ` }` +\n // Step 3: run tests — pass the factory so runTestModule installs globals\n // first, then invokes the factory to register describe/it/test blocks.\n ` try {` +\n ` const report = await globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory);` +\n ` return JSON.stringify({ok:true,value:report});` +\n ` } catch(e) {` +\n ` return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)});` +\n ` }` +\n `})()`\n );\n}\n\n/**\n * Result of `injectAndRunBundle`.\n */\nexport type RpcRunResult = { ok: true; report: RunReport } | { ok: false; error: string };\n\n/**\n * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`\n * evaluate call into a typed `RpcRunResult`.\n *\n * Throws only on parse failure — an `ok:false` envelope is a normal result.\n *\n * SECRET-HANDLING: `rawValue` is not included in error messages.\n */\nexport function parseRunTestsResult(rawValue: unknown): RpcRunResult {\n if (typeof rawValue !== 'string') {\n throw new Error(\n `rpc.parseRunTestsResult: unexpected return type \"${typeof rawValue}\" — expected JSON string`,\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(rawValue);\n } catch {\n // Do NOT include rawValue — could contain secrets.\n throw new Error('rpc.parseRunTestsResult: bridge returned non-JSON string');\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw new Error('rpc.parseRunTestsResult: parsed result is not an object');\n }\n const obj = parsed as Record<string, unknown>;\n if (obj.ok === true) {\n return { ok: true, report: obj.value as RunReport };\n }\n if (obj.ok === false) {\n return {\n ok: false,\n error: typeof obj.error === 'string' ? obj.error : String(obj.error),\n };\n }\n throw new Error('rpc.parseRunTestsResult: result missing \"ok\" field');\n}\n\n/**\n * Injects `bundleCode` into the attached page and awaits test execution.\n *\n * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the\n * async IIFE to settle. `timeoutMs` (default 60s) is the authoritative file\n * timeout — this function races it against the evaluate call AND (devtools#747)\n * threads it down to the underlying `CdpConnection`'s own per-command watchdog\n * (plus a small margin) so that watchdog can never undercut this race. Split\n * into smaller files if you hit the timeout.\n *\n * @param connection - Active CDP connection (relay or local).\n * @param bundleCode - IIFE bundle string from `bundleTestFile`.\n * @param timeoutMs - Override the default 60 s timeout.\n *\n * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.\n */\nexport async function injectAndRunBundle(\n connection: CdpConnection,\n bundleCode: string,\n timeoutMs = DEFAULT_TIMEOUT_MS,\n): Promise<RpcRunResult> {\n const expression = buildRunTestsExpression(bundleCode);\n\n // Use a tagged race so we can distinguish \"timeout won\" from \"evaluate won\"\n // without relying on exception identity. The timeout arm RETURNS a typed\n // RpcRunResult (ok:false + EVALUATE_TIMEOUT_MARKER) so the caller (relay-\n // worker's attempt()) can detect it via `.error.includes(EVALUATE_TIMEOUT_MARKER)`\n // and trigger the one-retry path.\n //\n // Genuine CDP `exceptionDetails` (engine exceptions thrown inside the page)\n // still THROW — those are deterministic failures that a retry cannot fix and\n // relay-worker's catch block finalises them correctly.\n //\n // DO NOT change the literal string below — relay-worker.ts imports\n // EVALUATE_TIMEOUT_MARKER = 'rpc: evaluate timed out after' and uses\n // `.includes()` to recognise this exact prefix.\n const TIMEOUT_SENTINEL = Symbol('timeout');\n const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) =>\n setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs),\n );\n\n // devtools#747: thread our own timeout budget down to the connection's\n // per-command watchdog (ChiiCdpConnection's default is 30s and used to fire\n // before this rpc-level race no matter how large `timeoutMs` was). A small\n // margin keeps the connection watchdog from firing a beat before our own\n // race settles — the rpc-level race above remains the authoritative file\n // timeout; this override only ensures the connection layer never undercuts\n // it. `LocalCdpConnection` ignores the option (no watchdog of its own).\n const evalPromise = connection.send(\n 'Runtime.evaluate',\n {\n expression,\n returnByValue: true,\n awaitPromise: true,\n },\n { timeoutMs: timeoutMs + CONNECTION_TIMEOUT_MARGIN_MS },\n );\n\n const raceResult = await Promise.race([\n evalPromise.then((v) => ({ tag: 'eval' as const, v })),\n timeoutPromise.then(() => ({ tag: 'timeout' as const })),\n ]);\n\n if (raceResult.tag === 'timeout') {\n return { ok: false, error: `rpc: evaluate timed out after ${timeoutMs}ms` };\n }\n\n const cdpResult = raceResult.v;\n\n if (cdpResult.exceptionDetails) {\n // Surface only the engine error string — not the expression or value.\n const msg =\n cdpResult.exceptionDetails.exception?.description ??\n cdpResult.exceptionDetails.text ??\n 'Runtime.evaluate threw an exception';\n throw new Error(`rpc.injectAndRunBundle: ${msg}`);\n }\n\n return parseRunTestsResult(cdpResult.result.value);\n}\n","/**\n * Orchestrator: runs a list of test files sequentially over a CDP relay.\n *\n * Each file goes through: bundle → inject → run → collect.\n * This is the transport layer: it does NOT integrate with Vitest's pool or the\n * MCP surface. The Vitest custom pool (`pool.ts`) and the `run_tests` MCP tool\n * are separate callers that build on this orchestrator.\n *\n * Single-attach constraint: only one page is active at a time. Files run\n * sequentially; parallel execution across targets is out of scope.\n *\n * The per-file timeout is inherited from `injectAndRunBundle` (default 60 s).\n * For suites that exceed it, split the file into smaller pieces.\n *\n * SECRET-HANDLING: file paths are surfaced in reports; relay URLs are not.\n */\n\nimport type { CdpConnection, ConsoleApiCalledEvent } from '../mcp/cdp-connection.js';\nimport { isRelayDisconnectMessage } from '../mcp/chii-connection.js';\nimport { type BundleOptions, bundleTestFile } from './bundle.js';\nimport { type AitCaptureLine, parseCaptureLines } from './capture.js';\nimport { PERMISSION_PREFLIGHT_TIMEOUT_MS, runPermissionPreflight } from './cell.js';\nimport { injectAndRunBundle } from './rpc.js';\nimport type { RunReport, TestResult } from './runtime.js';\n\n/**\n * Per-file evaluate timeout (ms) for manual-variant files (devtools#741) — a\n * human is expected to be tapping through a native sheet, so the timeout must\n * be far longer than the unattended default (60s, rpc.ts DEFAULT_TIMEOUT_MS).\n * Fixed constant (not configurable in v1) — documented here + CLI `--help`.\n */\nexport const MANUAL_FILE_TIMEOUT_MS = 5 * 60_000;\n\n/** Per-file result in the aggregate `RunReport`. */\nexport interface FileResult {\n /** Absolute or relative path to the test file. */\n file: string;\n /** Full run report for this file, or an error if bundling/injection failed. */\n result: RunReport | { error: string };\n /**\n * Present only for files run under `--manual-blocking` or `--stub-blocking`\n * — report provenance so an attended/stubbed run is never confused with an\n * unattended baseline. Absent (not `false`) for regular files —\n * absence-means-unattended is the contract (report.ts mirrors this).\n *\n * - `'manual'` — devtools#741, human-attended, real native envelopes.\n * - `'stubbed'` — devtools#740 (DT-2), unattended, blocking-UI calls\n * answered from `bridge-stub.ts` fixtures instead of native UI. A\n * HYBRID cell, not pure env3 — see `bridge-stub.ts`'s module doc for the\n * full honesty contract. Never diffed against `'manual'`/unattended as\n * if equivalent.\n */\n mode?: 'manual' | 'stubbed';\n}\n\n/** Aggregate report returned by `runTestFilesOverRelay`. */\nexport interface RelayRunReport {\n /** ISO timestamp of when the run started. */\n startedAt: string;\n /** Total elapsed wall-clock milliseconds. */\n duration: number;\n /** Per-file results in execution order. */\n files: FileResult[];\n /** Flattened totals across all files. */\n totals: {\n passed: number;\n failed: number;\n skipped: number;\n total: number;\n };\n /**\n * `__AIT_CAPTURE__` lines harvested from the page console during the run\n * (additive field, devtools#696). Empty unless `collectCaptures` was set.\n * Each entry is opaque (`{ category, json }`) — devtools does not interpret\n * the record shape, only forwards it for downstream 2.x↔3.0 diffing.\n *\n * SECRET-HANDLING: only lines matching the `__AIT_CAPTURE__ ` allowlist\n * prefix reach here; relay/wss/scheme noise lines are dropped in\n * `parseCaptureLines`.\n */\n captures: AitCaptureLine[];\n /**\n * Permission-state preflight result (devtools#739) — the same map exposed\n * to the page as `globalThis.__AIT_PERMS__`, collected once before the\n * first file runs. `undefined` when the preflight failed, timed out, or\n * `window.__sdk` was not yet installed — a non-fatal outcome that does NOT\n * fail the run. Report provenance only: 4-cell diffs correlate test\n * outcomes with device permission state via this field. No secrets — only\n * `'allowed'|'denied'|'notDetermined'|'unavailable'` strings per key.\n */\n preflight?: { permissions: Record<string, string> };\n}\n\n/** Options for `runTestFilesOverRelay`. */\nexport interface RelayRunOptions {\n /**\n * Options forwarded to `bundleTestFile` for each file.\n */\n bundleOptions?: BundleOptions;\n /**\n * Per-file evaluate timeout in milliseconds. Defaults to 60 000 (rpc.ts\n * DEFAULT_TIMEOUT_MS). Increase for long-running suites or split the file.\n */\n timeoutMs?: number;\n /**\n * When `true`, registers a live `Runtime.consoleAPICalled` listener for the\n * duration of the run and harvests `__AIT_CAPTURE__` lines into\n * `RelayRunReport.captures`. Defaults to **false** so the build-only\n * eval/e2e path (and the Vitest pool) pay no listener/state overhead —\n * `captures` is then an empty array.\n *\n * The listener is registered just BEFORE the first file runs and removed in a\n * `finally` so it never accumulates across runs (no ring-buffer drain — the\n * default 500-entry buffer would silently drop lines via `shift`).\n */\n collectCaptures?: boolean;\n /**\n * Absolute paths of files to run in MANUAL-VARIANT mode (devtools#741,\n * `--manual-blocking`). Callers pass the `manual` half of\n * `partitionManualTests(files)` — this set does not itself reorder `files`\n * (the caller is responsible for placing manual files last); it only\n * controls two per-file behaviors for members of the set:\n *\n * 1. the evaluate timeout is `MANUAL_FILE_TIMEOUT_MS` (5 min) instead of\n * `opts.timeoutMs` — a human is expected to be tapping through a\n * native sheet;\n * 2. `onManualFile` fires BEFORE the file is injected, and the file's\n * `FileResult.mode` is stamped `'manual'`.\n *\n * Absent/empty = no manual files in this run (default; zero-diff path).\n */\n manualFiles?: ReadonlySet<string>;\n /**\n * Called immediately before a manual file is injected (devtools#741) —\n * `(file, index, total)` where `index` is 1-based position among manual\n * files. The CLI uses this to push a dashboard prompt + print to stdout.\n * Never called for regular (non-manual) files OR for files in\n * `stubBlockingFiles` (devtools#740) — a stubbed file needs no human\n * prompt.\n */\n onManualFile?: (file: string, index: number, total: number) => void;\n /**\n * Absolute paths of files to run in STUB-BLOCKING mode (devtools#740,\n * DT-2, `--stub-blocking`). Typically the SAME set as `manualFiles` — the\n * whole point is to run the manual-tagged suite unattended — but kept as a\n * separate option so a caller could in principle stub a subset. Members of\n * this set:\n *\n * 1. use `opts.timeoutMs` (the REGULAR per-file timeout), never\n * `MANUAL_FILE_TIMEOUT_MS` — the fixture answers synchronously, no\n * human is tapping through anything;\n * 2. do NOT trigger `onManualFile` (no dashboard prompt / stdout banner —\n * there is no human to prompt);\n * 3. are stamped `FileResult.mode = 'stubbed'` instead of `'manual'`.\n *\n * A file present in BOTH `manualFiles` and `stubBlockingFiles` is treated\n * as stubbed (rule 1-3 above win) — `stubBlockingFiles` takes precedence.\n * The actual blocking-UI interception happens PAGE-SIDE\n * (`bridge-stub.ts`'s `wrapSdkWithStub`, gated by the\n * `__AIT_STUB_BLOCKING__` global the caller injects via\n * `relay-factory.ts`'s `stubBlocking` option) — this set only controls the\n * two Node-side behaviors above; it does not itself flip the page-side gate.\n *\n * Absent/empty = no stubbed files in this run (default; zero-diff path).\n */\n stubBlockingFiles?: ReadonlySet<string>;\n /**\n * Minimum delay in milliseconds inserted BEFORE every file after the first\n * one that actually runs (devtools#767, `--pace`). This is the RUNNER-side\n * (file-to-file) half of `--pace` — the PAGE-side (test-to-test) half is\n * `__AIT_PACE_MS__`, injected separately by `relay-factory.ts`'s `paceMs`\n * option and consumed by `runtime.ts`'s own test loop. Defaults to\n * `undefined`/`0` — no added delay, byte-for-byte today's behavior.\n */\n paceMs?: number;\n /**\n * SDK line of the cell under test (devtools#767 acceptance criteria 2). The\n * one-time permission preflight (`cell.ts#runPermissionPreflight`) paces its\n * probes (inter-probe delay + THROTTLED retry backoff) to dodge the 2.x-only\n * native `APP_BRIDGE_THROTTLED` bridge rate limit — that limiter does not\n * exist on 3.x, so pacing it too would be a pure, unconditional cost with no\n * corresponding benefit, violating \"3.x run duration이 유의미하게 늘지 않음\n * (pacing은 opt-in 또는 throttle-adaptive)\".\n *\n * `'3.x'` skips preflight pacing entirely (unpaced sequential probes, no\n * retry ladder). Any other value (including `undefined`/omitted, e.g. the\n * MCP `run_tests` auto-attach path which does not thread a typed cell\n * through this option) keeps today's paced behavior — the conservative\n * default for a cell that might be 2.x.\n */\n preflightSdkLine?: string;\n}\n\n/**\n * Sentinel string embedded in the error message by `injectAndRunBundle` when\n * the per-file evaluate race hits the timeout. Used by the retry guard so only\n * genuine timeouts get a second attempt — not bundle errors or parse failures.\n *\n * Exported for unit tests that assert the retry path is taken.\n */\nexport const EVALUATE_TIMEOUT_MARKER = 'rpc: evaluate timed out after';\n\n/**\n * Runs all `files` sequentially over the given CDP `connection`.\n *\n * For each file:\n * 1. Bundle with esbuild (includes SDK shim + runtime).\n * 2. Inject into the attached page via `Runtime.evaluate`.\n * 3. Await the `RunReport` JSON response.\n * 4. Accumulate results.\n *\n * Returns a `RelayRunReport` with per-file results and flattened totals.\n *\n * This function does NOT open or manage the relay connection — the caller\n * is responsible for attaching and closing it.\n *\n * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here\n * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.\n *\n * @param connection - Active CDP connection (relay or local kind).\n * @param files - Absolute paths to test files, run in order.\n * @param opts - Optional per-run overrides.\n */\nexport async function runTestFilesOverRelay(\n connection: CdpConnection,\n files: string[],\n opts?: RelayRunOptions,\n): Promise<RelayRunReport> {\n const wallStart = Date.now();\n const startedAt = new Date(wallStart).toISOString();\n const fileResults: FileResult[] = [];\n\n // Enable CDP domains ONCE up front. Without this the relay connection has not\n // opened its client websocket, so the very first `Runtime.evaluate` (in\n // `injectAndRunBundle`) explodes AND `Runtime.consoleAPICalled` never streams\n // — the console capture harvest would be structurally 0. `enableDomains()` is\n // idempotent (see chii-connection: early-returns when the ws is already OPEN,\n // and shares an in-flight promise), so calling it here is safe even when a\n // caller (the MCP `run_tests` path) already enabled it.\n //\n // Failure here (e.g. no target attached yet) must NOT throw the whole run: the\n // per-file inject below produces a structured error result instead. We warn on\n // stderr (secret-free) and continue. SECRET-HANDLING: the message names the\n // failure only — no relay/wss URL.\n let domainsEnabled = false;\n try {\n await connection.enableDomains();\n domainsEnabled = true;\n } catch (e) {\n process.stderr.write(\n `relay-worker: enableDomains() failed before run — console capture may be empty (${\n e instanceof Error ? e.message : String(e)\n })\\n`,\n );\n }\n\n // Live console capture (#696): when requested, accumulate every\n // `Runtime.consoleAPICalled` event into a local array via a LIVE listener\n // registered just before the run. We deliberately do NOT drain\n // `getBufferedEvents` after the fact — that ring buffer caps at 500 and\n // `shift()`s older entries, so a chatty run would silently lose capture lines.\n // The listener is removed in `finally` so it never bleeds into the next run.\n //\n // Gate the listener on `domainsEnabled`: if enableDomains() soft-failed, the\n // client ws never opened so `Runtime.consoleAPICalled` cannot stream —\n // registering would only add a dead listener that never fires. (The `finally`\n // unsubscribe is an undefined no-op in that case, so this stays safe.)\n const collectCaptures = opts?.collectCaptures === true;\n const liveConsole: ConsoleApiCalledEvent[] = [];\n let unsubscribeConsole: (() => void) | undefined;\n if (collectCaptures && domainsEnabled) {\n unsubscribeConsole = connection.on('Runtime.consoleAPICalled', (event) => {\n liveConsole.push(event);\n });\n }\n\n // Tracks whether the PREVIOUS file's final result was a WS-dead-class error\n // (relay disconnect). When true, we attempt one `enableDomains()` reconnect\n // before processing the NEXT file — a single dropped socket (e.g. Cloudflare\n // edge idle-drop mid-evaluate) should not cascade into every remaining file\n // failing instantly (#731). `enableDomains()` is idempotent (early-returns\n // when the ws is already OPEN, shares an in-flight promise), so calling it\n // speculatively here is safe.\n let pendingReconnectCheck = false;\n\n /**\n * Attempts one `enableDomains()` reconnect. Logs the attempt and outcome to\n * stderr. Never throws — a failed reconnect just means the caller proceeds\n * as today (each remaining file / the retry will fail-fast on the still-dead\n * socket).\n *\n * `context` selects the fixed log literal: 'next-file' for the between-files\n * case (a file's FINAL result was a WS-dead-class error — the primary #731\n * scenario), 'retry-precheck' for the defensive reconnect before retrying a\n * timed-out file (cheap no-op via `enableDomains()`'s idempotency when the\n * socket never actually died).\n *\n * SECRET-HANDLING: fixed literals only — no relay wss URL, TOTP code, or\n * tunnel host.\n */\n const attemptReconnect = async (context: 'next-file' | 'retry-precheck'): Promise<void> => {\n process.stderr.write(\n context === 'next-file'\n ? 'relay-worker: relay connection lost — attempting reconnect before next file\\n'\n : 'relay-worker: evaluate timed out — attempting reconnect before retry\\n',\n );\n try {\n await connection.enableDomains();\n process.stderr.write('relay-worker: reconnect succeeded\\n');\n } catch (e) {\n process.stderr.write(\n `relay-worker: reconnect failed (${\n e instanceof Error ? e.message : String(e)\n }) — continuing, remaining files may fail\\n`,\n );\n }\n };\n\n // Manual-variant bookkeeping (devtools#741): resolve the total up front so\n // `onManualFile`'s `(index, total)` is stable even if `files` is a mix of\n // regular + manual entries in a single call.\n const manualFiles = opts?.manualFiles;\n const manualTotal = manualFiles?.size ?? 0;\n let manualIndex = 0;\n // Stub-blocking bookkeeping (devtools#740, DT-2) — see stubBlockingFiles's\n // doc for why this takes precedence over manualFiles membership.\n const stubBlockingFiles = opts?.stubBlockingFiles;\n\n // Permission-state preflight (devtools#739): run ONCE per session, before\n // the FIRST file's bundle is injected — never per file. Non-fatal: any\n // failure is swallowed inside `runPermissionPreflight` (one stderr line),\n // so a broken/absent `window.__sdk` never blocks the run. Skipped entirely\n // when `files` is empty (no bundle will run either).\n //\n // devtools#767 acceptance criteria 2: preflight pacing (inter-probe delay +\n // THROTTLED retry backoff) is skipped for a known-3.x cell — see\n // `preflightSdkLine`'s doc for why any other value keeps pacing on.\n let preflightPermissions: Record<string, string> | undefined;\n if (files.length > 0) {\n const preflightPace = opts?.preflightSdkLine !== '3.x';\n preflightPermissions = await runPermissionPreflight(\n connection,\n PERMISSION_PREFLIGHT_TIMEOUT_MS,\n preflightPace,\n );\n }\n\n // devtools#767 --pace: runner-side (file-to-file) half of pacing. Positive\n // only when the caller explicitly opted in — 0/undefined skips the wait\n // entirely (no per-file branch cost added to the default path).\n const paceMs = opts?.paceMs;\n let ranFirstFile = false;\n\n try {\n for (const file of files) {\n if (pendingReconnectCheck) {\n await attemptReconnect('next-file');\n pendingReconnectCheck = false;\n }\n\n // --pace file-to-file spacing: wait BEFORE every file after the first\n // one that actually runs. Mirrors runtime.ts's own inter-test pacing\n // model (no wait before the first item).\n if (paceMs !== undefined && paceMs > 0 && ranFirstFile) {\n await new Promise<void>((resolve) => setTimeout(resolve, paceMs));\n }\n ranFirstFile = true;\n\n const isStubbed = stubBlockingFiles?.has(file) === true;\n // stubBlockingFiles takes precedence over manualFiles membership (see\n // that option's doc) — a stubbed file needs no human prompt and no\n // extended timeout even if it also appears in manualFiles (the normal\n // case: the CLI passes the same set to both when --stub-blocking is on).\n const isManual = !isStubbed && manualFiles?.has(file) === true;\n if (isManual) {\n manualIndex += 1;\n opts?.onManualFile?.(file, manualIndex, manualTotal);\n }\n // Manual files get a far longer evaluate timeout (a human is tapping\n // through a native sheet) — everything else (including stubbed files,\n // which resolve synchronously from a fixture) keeps opts.timeoutMs.\n const fileTimeoutMs = isManual ? MANUAL_FILE_TIMEOUT_MS : opts?.timeoutMs;\n\n let fileEntry: FileResult;\n try {\n const { code } = await bundleTestFile(file, opts?.bundleOptions);\n\n /**\n * Runs one evaluate attempt and returns a FileResult, or `null` when the\n * result is a genuine timeout and the caller should retry.\n *\n * We need to distinguish:\n * - `rpcResult.ok = false` + timeout error → retry candidate (`return null`)\n * - `rpcResult.ok = false` + other error → final error, no retry\n * - `injectAndRunBundle` throws → CDP exceptionDetails OR a\n * relay-disconnect rejection from `connection.send()` (page engine\n * threw, or the ws died mid-evaluate); treated as a final\n * (non-retryable) error either way.\n *\n * The Promise.race timeout in rpc.ts RETURNS `{ok:false, error: '…'}` (it\n * does NOT throw/reject). Only genuine CDP `exceptionDetails` (or a dead\n * `connection.send()`) cause a throw. This distinction is what makes the\n * EVALUATE_TIMEOUT_MARKER gate below reachable — the timeout result\n * surfaces as `rpcResult.ok=false` with the marker string, not as a\n * caught exception.\n */\n const attempt = async (): Promise<FileResult | null> => {\n let rpcResult: Awaited<ReturnType<typeof injectAndRunBundle>>;\n try {\n rpcResult = await injectAndRunBundle(connection, code, fileTimeoutMs);\n } catch (e) {\n // injectAndRunBundle throws for CDP exceptionDetails OR a relay ws\n // death mid-evaluate (connection.send() fail-fast rejection) —\n // either way this attempt is final (non-retryable). The\n // WS-dead-class check below (after `fileEntry` is assigned)\n // arranges the between-files reconnect (#731).\n return {\n file,\n result: { error: e instanceof Error ? e.message : String(e) },\n };\n }\n\n if (rpcResult.ok) {\n return { file, result: rpcResult.report };\n }\n\n // Timed-out evaluates get one retry; other errors are final.\n if (rpcResult.error.includes(EVALUATE_TIMEOUT_MARKER)) {\n return null; // signal \"retry\"\n }\n return { file, result: { error: rpcResult.error } };\n };\n\n const firstResult = await attempt();\n if (firstResult !== null) {\n fileEntry = firstResult;\n } else {\n // First attempt timed out — the retry-precheck case (#731): the\n // dead-air during a long timeout is exactly what let the Cloudflare\n // edge idle-drop the relay ws in the observed run (permissions:\n // attempt 1 timed out, the retry then hit an already-dead socket).\n // `enableDomains()` is idempotent (no-op when the ws is already\n // OPEN), so a defensive reconnect attempt here is safe even when\n // the socket never actually died — it only costs a wasted round\n // trip on the common \"genuinely just slow\" case, and it's the only\n // way to catch this class since `CdpConnection` does not expose a\n // public \"is the socket alive\" probe.\n await attemptReconnect('retry-precheck');\n // First attempt timed out — retry once. A transient native dialog\n // (camera picker, location permission sheet, GPS cold-fix) may have\n // cleared by now.\n process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\\n`);\n const retryResult = await attempt();\n if (retryResult !== null) {\n fileEntry = retryResult;\n } else {\n // Second timeout: build a final error entry.\n fileEntry = {\n file,\n result: {\n // This fallback must equal rpc.ts DEFAULT_TIMEOUT_MS (60_000)\n // so the displayed budget matches what was actually used when\n // the caller omitted opts.timeoutMs (#731).\n error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 60_000}ms (after retry)`,\n },\n };\n }\n }\n } catch (e) {\n // Capture bundle errors per-file so subsequent files still run.\n fileEntry = {\n file,\n result: { error: e instanceof Error ? e.message : String(e) },\n };\n }\n\n // If this file's FINAL result is a WS-dead-class error, arrange for a\n // reconnect attempt before the NEXT file (#731) — do not abort the loop.\n if ('error' in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) {\n pendingReconnectCheck = true;\n }\n\n // Report provenance (devtools#741 / devtools#740): stamp mode once,\n // here, on whatever fileEntry ended up being (success, error, or\n // timeout) — simpler and less error-prone than threading the flag\n // through every return site inside `attempt()`. Absent for regular\n // files (the contract is absence-means-unattended, not `mode:\n // undefined`). isStubbed takes precedence — see stubBlockingFiles's doc.\n if (isStubbed) {\n fileEntry.mode = 'stubbed';\n } else if (isManual) {\n fileEntry.mode = 'manual';\n }\n\n fileResults.push(fileEntry);\n }\n } finally {\n // Always remove the live listener — leaking it would accumulate across runs\n // on a reused connection (the Vitest pool keeps one connection for the whole\n // run; the MCP daemon keeps one for the session).\n unsubscribeConsole?.();\n }\n\n // Convert the accumulated console events to `__AIT_CAPTURE__` lines. Each\n // event's args are rendered to a single line text (inlined console rendering —\n // no tools.ts import, to keep this module off the heavy MCP graph), then the\n // allowlist-prefix parser keeps only genuine capture lines.\n const captures = collectCaptures\n ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) })))\n : [];\n\n const totals = fileResults.reduce(\n (acc, { result }) => {\n if ('error' in result) {\n // Treat whole-file errors as a single failure.\n acc.failed += 1;\n acc.total += 1;\n } else {\n acc.passed += result.passed;\n acc.failed += result.failed;\n acc.skipped += result.skipped;\n acc.total += result.passed + result.failed + result.skipped;\n }\n return acc;\n },\n { passed: 0, failed: 0, skipped: 0, total: 0 },\n );\n\n return {\n startedAt,\n duration: Date.now() - wallStart,\n files: fileResults,\n totals,\n captures,\n ...(preflightPermissions ? { preflight: { permissions: preflightPermissions } } : {}),\n };\n}\n\n/**\n * Renders one `Runtime.consoleAPICalled` event to a single line of text, the\n * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-\n * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,\n * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)\n * onto the test-runner entry.\n *\n * SECRET-HANDLING: this only stringifies console args; the caller's\n * allowlist-prefix parser then discards everything that is not a genuine\n * `__AIT_CAPTURE__` line.\n */\nfunction renderConsoleLineText(event: ConsoleApiCalledEvent): string {\n return event.args\n .map((arg) => {\n if (arg.value !== undefined) {\n if (typeof arg.value === 'string') return arg.value;\n try {\n return JSON.stringify(arg.value);\n } catch {\n return String(arg.value);\n }\n }\n if (arg.description !== undefined) return arg.description;\n if (arg.className !== undefined) return arg.className;\n return arg.subtype ?? arg.type;\n })\n .join(' ');\n}\n\n/**\n * Flattens all test results from a `RelayRunReport` into a single array.\n * Files that errored during bundle/inject produce a synthetic failed entry.\n */\nexport function flattenResults(report: RelayRunReport): Array<TestResult & { file: string }> {\n const out: Array<TestResult & { file: string }> = [];\n for (const { file, result } of report.files) {\n if ('error' in result) {\n out.push({\n file,\n name: `<bundle/inject error>`,\n status: 'fail',\n duration: 0,\n error: result.error,\n });\n } else {\n for (const t of result.tests) {\n out.push({ ...t, file });\n }\n }\n }\n return out;\n}\n","/**\n * Runner-agnostic report serialisation for env3 test runs (devtools#696).\n *\n * Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the\n * standalone `devtools-test` CLI (`cli.ts`) — call the same core\n * `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This\n * module is the single, runner-neutral place that turns that in-memory report\n * into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed\n * cell-by-cell after the fact.\n *\n * The serialised schema is deliberately MINIMAL and secret-free:\n *\n * - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`\n * leakage — see {@link RunnerAgnosticReport.files});\n * - the cell metadata (sdkLine/platform) is baked INTO the body, not only the\n * filename, so a moved artifact never loses its provenance;\n * - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all\n * (enforced by the type + this comment) — error strings are the matcher\n * message only, inherited from rpc.ts which already strips expression/value.\n *\n * react-free — depends only on the type-level `RelayRunReport` and `node:fs` /\n * `node:path`. Safe to bundle without pulling the chii/cloudflared graph.\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport type { AitCaptureLine } from './capture.js';\nimport type { RelayRunReport } from './relay-worker.js';\nimport type { TestResult } from './runtime.js';\n\n/** The cell axes a report is stamped with — the test-matrix coordinates. */\nexport interface ReportCellMeta {\n /** SDK line under test (e.g. `'2.x'` / `'3.x'`). */\n sdkLine: string;\n /** Platform under test (e.g. `'ios'` / `'android'` / `'mock'`). */\n platform: string;\n /**\n * Project root the run was launched from. Used ONLY to relativise file paths\n * out of the serialised report — never stored in the output. SECRET-HANDLING:\n * absolute project paths must not leak into artifacts.\n */\n projectRoot: string;\n}\n\n/** Per-file slice of a {@link RunnerAgnosticReport}. */\nexport interface RunnerAgnosticFileReport {\n /**\n * Test file path, RELATIVE to `projectRoot`. Never absolute — `path.relative`\n * strips the machine-specific prefix so the artifact is portable and leaks no\n * local filesystem layout.\n */\n file: string;\n /** Whole-file bundle/inject error (matcher message only), when the file failed. */\n error?: string;\n /** In-page run duration (ms) for this file, when it ran. */\n duration?: number;\n passed?: number;\n failed?: number;\n skipped?: number;\n /** Per-test results, when the file ran. Error strings are matcher messages only. */\n tests?: TestResult[];\n /**\n * Present only when this file ran under `--manual-blocking` or\n * `--stub-blocking`. Absent (never `false`) for unattended files:\n * absence-means-unattended is the contract, mirroring `FileResult.mode` in\n * relay-worker.ts.\n *\n * - `'manual'` — devtools#741, human-attended, real native envelopes.\n * - `'stubbed'` — devtools#740 (DT-2), unattended, blocking-UI calls\n * answered from fixtures. A manual/stubbed-stamped record must never be\n * diffed against an unattended (or each other's) baseline as if\n * equivalent.\n */\n mode?: 'manual' | 'stubbed';\n}\n\n/**\n * The runner-neutral, secret-free report written to disk. There are\n * intentionally NO wss/scheme/TOTP/relayUrl fields on this type — the absence is\n * load-bearing (SECRET-HANDLING) and must not be \"completed\" by a future edit.\n */\nexport interface RunnerAgnosticReport {\n /**\n * Cell axes this run belongs to — baked into the body for portability.\n * `bridgeStub` is present + `true` ONLY on the artifact written for\n * stub-blocking files (devtools#740, DT-2) — see `writeReportArtifact`'s\n * doc for why this is a SEPARATE `.stubbed.json` artifact rather than a\n * flag inside the standard/`.manual.json` report. Absent (never `false`)\n * on every other artifact — this is the mandatory HYBRID-cell provenance\n * stamp the issue calls for: a stubbed run must never be silently mixed\n * into the real-device baseline report.\n */\n cell: { sdkLine: string; platform: string; bridgeStub?: true };\n /** ISO timestamp of when the run started (from the core report). */\n startedAt: string;\n /** Total wall-clock ms (bundling + sequential injection). */\n duration: number;\n /** Flattened totals across all files. */\n totals: RelayRunReport['totals'];\n /** Per-file results with projectRoot-relative paths. */\n files: RunnerAgnosticFileReport[];\n /**\n * Permission-state preflight result (devtools#739), mirrored verbatim from\n * `RelayRunReport.preflight` — see that field's docblock. Absent when the\n * preflight did not complete (non-fatal; the run still succeeds). No\n * secrets — permission-state strings only.\n */\n preflight?: { permissions: Record<string, string> };\n}\n\n/**\n * Converts an absolute (or already-relative) file path to a projectRoot-relative\n * one. `path.relative` returns `''` when the paths are equal — guard that to the\n * basename so the field is never empty.\n *\n * SECRET-HANDLING: this is the single choke point that strips absolute project\n * paths from the artifact.\n */\nfunction relativise(projectRoot: string, file: string): string {\n const rel = path.relative(projectRoot, file);\n if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {\n // Outside the project root (or equal) — fall back to the basename rather\n // than emitting an absolute or `../../..` traversal path.\n return path.basename(file);\n }\n return rel;\n}\n\n/**\n * Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free\n * on-disk shape. Pure — no IO; testable with a plain report + meta.\n *\n * @param report - The core relay run report.\n * @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).\n */\nexport function serializeRelayReport(\n report: RelayRunReport,\n meta: ReportCellMeta,\n): RunnerAgnosticReport {\n return {\n cell: { sdkLine: meta.sdkLine, platform: meta.platform },\n startedAt: report.startedAt,\n duration: report.duration,\n totals: report.totals,\n ...(report.preflight ? { preflight: report.preflight } : {}),\n files: report.files.map((f): RunnerAgnosticFileReport => {\n const file = relativise(meta.projectRoot, f.file);\n const modeField =\n f.mode === 'manual' || f.mode === 'stubbed' ? ({ mode: f.mode } as const) : {};\n if ('error' in f.result) {\n // Matcher/inject error message only — rpc.ts already stripped the\n // expression/value upstream.\n return { file, error: f.result.error, ...modeField };\n }\n return {\n file,\n duration: f.result.duration,\n passed: f.result.passed,\n failed: f.result.failed,\n skipped: f.result.skipped,\n tests: f.result.tests,\n ...modeField,\n };\n }),\n };\n}\n\n/**\n * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating\n * `dir` if needed. Returns the absolute path(s) written.\n *\n * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as\n * distinct artifacts in the same directory; the same cell metadata is also baked\n * into the body so a renamed/moved file still carries its provenance.\n *\n * Manual-run provenance (devtools#741): when `report.files` contains ANY\n * `mode: 'manual'` entry (i.e. this run included `--manual-blocking` files),\n * the manual-tagged files are written to a SEPARATE\n * `<dir>/<sdkLine>.<platform>.manual.json` artifact instead of the standard\n * one — the standard `<sdkLine>.<platform>.json` filename is reserved for the\n * regular (unattended) files only, so a manual run's presence never mutates\n * what the unattended-baseline filename means.\n *\n * Stub-blocking provenance (devtools#740, DT-2): `mode: 'stubbed'` files are\n * written to a THIRD, separate `<dir>/<sdkLine>.<platform>.stubbed.json`\n * artifact — never merged into the standard file (it is unattended, like the\n * standard file, so a naive filename split could conflate the two) and never\n * merged into `.manual.json` (it did NOT have a human present). Its body is\n * ALSO stamped `cell.bridgeStub: true` — the mandatory HYBRID-cell provenance\n * from the issue: a stubbed result must never be silently mixed into the\n * real-device baseline report.\n *\n * If ALL files in the run are regular, only the standard artifact is written\n * (today's behavior, unchanged). Any combination of the three modes present\n * in a single run writes ONLY the corresponding artifacts — each is additive,\n * never replacing another.\n *\n * SECRET-HANDLING: the written body contains no relay/secret fields (the schema\n * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.\n *\n * @param report - The core relay run report.\n * @param dir - Output directory (created recursively if missing).\n * @param meta - Cell axes + projectRoot.\n * @returns The absolute path(s) written, in order: standard first (if any\n * regular files ran), then manual (if any manual files ran), then stubbed\n * (if any stubbed files ran). At least one path is always returned when\n * `report.files` is non-empty.\n */\nexport async function writeReportArtifact(\n report: RelayRunReport,\n dir: string,\n meta: ReportCellMeta,\n): Promise<string[]> {\n const serialised = serializeRelayReport(report, meta);\n await mkdir(dir, { recursive: true });\n\n const regularFiles = serialised.files.filter((f) => f.mode === undefined);\n const manualFiles = serialised.files.filter((f) => f.mode === 'manual');\n const stubbedFiles = serialised.files.filter((f) => f.mode === 'stubbed');\n\n const written: string[] = [];\n\n // Standard artifact: written whenever there are regular files, OR when the\n // whole run is empty (preserves the pre-#741 \"always write one file\"\n // behavior for a run with zero files, e.g. an empty glob match).\n if (regularFiles.length > 0 || serialised.files.length === 0) {\n const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);\n await writeFile(\n outFile,\n `${JSON.stringify({ ...serialised, files: regularFiles }, null, 2)}\\n`,\n 'utf8',\n );\n written.push(outFile);\n }\n\n if (manualFiles.length > 0) {\n const manualOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.manual.json`);\n await writeFile(\n manualOutFile,\n `${JSON.stringify({ ...serialised, files: manualFiles }, null, 2)}\\n`,\n 'utf8',\n );\n written.push(manualOutFile);\n }\n\n if (stubbedFiles.length > 0) {\n const stubbedOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.stubbed.json`);\n await writeFile(\n stubbedOutFile,\n `${JSON.stringify(\n {\n ...serialised,\n cell: { ...serialised.cell, bridgeStub: true as const },\n files: stubbedFiles,\n },\n null,\n 2,\n )}\\n`,\n 'utf8',\n );\n written.push(stubbedOutFile);\n }\n\n return written;\n}\n\n/**\n * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,\n * named `<category>.<sdkLine>.<platform>.json` — the SAME convention\n * sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3\n * capture artifacts line up for diffing.\n *\n * Each line's `json` payload is an opaque JSON array of capture records. Lines\n * sharing a category are concatenated into one array, in harvest order.\n *\n * SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser\n * dropped wss/scheme noise); the `json` payload is written verbatim but is a\n * capture record array, not a relay/secret.\n *\n * @param captures - Parsed capture lines (from `RelayRunReport.captures`).\n * @param dir - Output directory (created recursively if missing).\n * @param cell - Cell axes for the filename suffix.\n * @returns The absolute paths written (one per category), in category order.\n */\nexport async function writeCaptureArtifacts(\n captures: ReadonlyArray<AitCaptureLine>,\n dir: string,\n cell: { sdkLine: string; platform: string },\n): Promise<string[]> {\n if (captures.length === 0) return [];\n\n // Group payloads by category, preserving harvest order. Each payload is an\n // opaque JSON array string; concatenate parsed arrays under the same category.\n const byCategory = new Map<string, unknown[]>();\n for (const { category, json } of captures) {\n let merged = byCategory.get(category);\n if (!merged) {\n merged = [];\n byCategory.set(category, merged);\n }\n // The parser already validated `json` parses; an array payload is expected.\n const parsed = JSON.parse(json) as unknown;\n if (Array.isArray(parsed)) {\n merged.push(...parsed);\n } else {\n merged.push(parsed);\n }\n }\n\n await mkdir(dir, { recursive: true });\n const written: string[] = [];\n for (const [category, records] of byCategory) {\n const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);\n await writeFile(outFile, `${JSON.stringify(records, null, 2)}\\n`, 'utf8');\n written.push(outFile);\n }\n return written;\n}\n","/**\n * Bounded teardown orchestrator for the `devtools-test` CLI (devtools#755).\n *\n * ## Symptom (run7~run10, 4 consecutive real-device reproductions)\n * The CLI writes the report + capture artifacts (\"devtools-test: wrote report\n * …\", \"wrote N capture file(s)\") and the QR dashboard HTTP server goes down\n * (port no longer listening), but the Node process stays alive indefinitely —\n * a human had to `SIGTERM`/`pkill` it every run.\n *\n * ## Root cause (confirmed via `process.getActiveResourcesInfo()`, not guessed)\n * Two independent `http.Server#close()` call sites never resolved their\n * callback while a socket was still attached — verified with a standalone\n * repro script before writing this module (see PR body for the diagnostic\n * transcript, not committed here since it's throwaway):\n *\n * 1. `qr-http-server.ts`'s dashboard HTTP server: a still-open browser tab\n * on the QR dashboard holds a `GET /events` SSE connection\n * (`Connection: keep-alive`, no `res.end()` — that's by design, it's a\n * live push stream). `server.close(cb)` only stops accepting NEW\n * connections; it does not touch already-open sockets, so `cb` never\n * fired while that tab stayed open. Fixed by calling\n * `server.closeAllConnections()` immediately before `close()`.\n * 2. `chii-relay.ts`'s relay HTTP+WS server: once a WebSocket connection\n * completes the `upgrade` handshake, Node removes that socket from the\n * HTTP server's own connection tracking — `closeAllConnections()` does\n * NOT reach it (verified: still hangs even with that call added). An\n * open CDP WS leg (phone target or relay-worker client) therefore also\n * blocked `close(cb)` forever. Fixed by terminating every live socket\n * in chii's captured `_wss.clients` set before calling `close()`.\n *\n * Both are fixed at the source in those two files — this module is NOT a\n * workaround for either leak; with both source fixes in place the backstop\n * below never fires in practice (asserted by the \"process exits on its own\"\n * e2e-style test in `teardown.test.ts`). This module exists as the CLI's\n * last-mile safety net for any OTHER handle outside our control (a\n * third-party dependency — `cloudflared`, `chii`, `ws` — holding something\n * we don't own the lifecycle of) and as a single, testable teardown\n * sequence with per-step timeouts so one hung close() cannot prevent the\n * rest from running.\n */\n\n/** A single teardown action — e.g. \"close the CDP connection\", \"stop the relay family\". */\nexport interface TeardownStep {\n /** Short label used only in the returned report (never logged with secrets). */\n name: string;\n /** Performs the close. Must be idempotent — may be invoked defensively more than once. */\n close(): Promise<void> | void;\n}\n\n/** Outcome of a single step, as reported by {@link runTeardownSteps}. */\nexport interface TeardownStepResult {\n name: string;\n /** `'ok'` = resolved within its slice. `'error'` = threw/rejected. `'timeout'` = did not settle in time. */\n status: 'ok' | 'error' | 'timeout';\n /** Present when status is 'error'. */\n error?: string;\n}\n\n/** Options for {@link runTeardownSteps}. */\nexport interface RunTeardownStepsOptions {\n /** Per-step timeout in ms — a hung step is abandoned (marked 'timeout') so later steps still run. Default 5000. */\n perStepTimeoutMs?: number;\n /** Injectable clock for tests — defaults to the real `setTimeout`/`clearTimeout`. */\n setTimeoutFn?: typeof setTimeout;\n clearTimeoutFn?: typeof clearTimeout;\n}\n\n/**\n * Runs `steps` in strict order, each bounded by `perStepTimeoutMs` so a\n * single hung `close()` cannot block the rest — a later step (e.g. \"close\n * the QR HTTP server\") still runs even if an earlier one (e.g. \"flip the\n * on-phone badge over CDP\") times out.\n *\n * Order is load-bearing: `relay-factory.ts`'s existing `close()` already\n * encodes \"badge over still-open channel, THEN close that channel\" — this\n * function does not reorder or parallelize, it just bounds+reports whatever\n * sequence the caller provides.\n */\nexport async function runTeardownSteps(\n steps: TeardownStep[],\n options: RunTeardownStepsOptions = {},\n): Promise<TeardownStepResult[]> {\n const {\n perStepTimeoutMs = 5_000,\n setTimeoutFn = setTimeout,\n clearTimeoutFn = clearTimeout,\n } = options;\n\n const results: TeardownStepResult[] = [];\n for (const step of steps) {\n results.push(await runOneStep(step, perStepTimeoutMs, setTimeoutFn, clearTimeoutFn));\n }\n return results;\n}\n\nasync function runOneStep(\n step: TeardownStep,\n perStepTimeoutMs: number,\n setTimeoutFn: typeof setTimeout,\n clearTimeoutFn: typeof clearTimeout,\n): Promise<TeardownStepResult> {\n let timeoutHandle: ReturnType<typeof setTimeout> | undefined;\n const timeoutPromise = new Promise<'timeout'>((resolve) => {\n timeoutHandle = setTimeoutFn(() => resolve('timeout'), perStepTimeoutMs);\n });\n\n try {\n const outcome = await Promise.race([\n Promise.resolve()\n .then(() => step.close())\n .then(() => 'ok' as const),\n timeoutPromise,\n ]);\n if (timeoutHandle !== undefined) clearTimeoutFn(timeoutHandle);\n return { name: step.name, status: outcome };\n } catch (e) {\n if (timeoutHandle !== undefined) clearTimeoutFn(timeoutHandle);\n return { name: step.name, status: 'error', error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/** Options for {@link armExitBackstop}. */\nexport interface ArmExitBackstopOptions {\n /**\n * Grace period (ms) after teardown completes before forcing exit if the\n * process has not already exited on its own. This is a LAST resort for\n * handles outside `runTeardownSteps`'s enumerated steps — with the\n * upstream leaks fixed (see module doc), this should never fire in\n * practice. Default 3000.\n */\n graceMs?: number;\n /** The exit code to pass to `process.exit`. */\n exitCode: number;\n /** Injectable clock for tests. */\n setTimeoutFn?: typeof setTimeout;\n /** Injectable exit for tests — defaults to `process.exit`. Never call this directly in a test assertion without overriding it. */\n exitFn?: (code: number) => void;\n}\n\n/** Handle returned by {@link armExitBackstop} so the happy path can disarm it. */\nexport interface ExitBackstop {\n /** Cancels the pending forced exit — call this once the process is expected to drain naturally. */\n disarm(): void;\n /** True once the backstop has fired (only meaningful in tests — production calls `process.exit`). */\n readonly fired: boolean;\n}\n\n/**\n * Arms a grace-period timer that force-exits the process if it has not\n * already exited on its own by the time the timer fires. The timer is\n * deliberately NOT `.unref()`'d — its entire purpose is to hold the event\n * loop open long enough to fire if nothing else does, so unref'ing it would\n * defeat the backstop. `disarm()` clears it — the CLI calls `disarm()`\n * immediately once `main()`'s own teardown (Step 6, cli.ts) has finished,\n * so on the happy path the timer never fires (Node drains the loop and\n * exits naturally at whatever `process.exitCode` was already set to).\n *\n * SECRET-HANDLING: this function never touches stdout/stderr content — the\n * caller (cli.ts) is responsible for flushing any final output before the\n * backstop's `exitFn` runs.\n */\nexport function armExitBackstop(options: ArmExitBackstopOptions): ExitBackstop {\n const {\n graceMs = 3_000,\n exitCode,\n setTimeoutFn = setTimeout,\n exitFn = (code: number) => process.exit(code),\n } = options;\n\n let fired = false;\n const handle = setTimeoutFn(() => {\n fired = true;\n exitFn(exitCode);\n }, graceMs);\n\n return {\n disarm(): void {\n clearTimeout(handle);\n },\n get fired(): boolean {\n return fired;\n },\n };\n}\n","/**\n * `devtools-test` CLI.\n *\n * Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)\n * and exposes `runWithConnection` — the pure run core that bundles, injects, and\n * collects each file over a CDP connection. The CLI's `main()` performs a\n * standalone relay attach (boot relay → QR → phone scan → cell inject → run).\n *\n * NOTE: no shebang in this source file — the tsdown entry's `banner` option\n * injects `#!/usr/bin/env node` into the compiled output (same pattern as\n * `src/mcp/cli.ts`).\n */\n\nimport { basename } from 'node:path';\nimport { parseArgs } from 'node:util';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport { discoverTestFiles, MANUAL_TEST_SUFFIX, partitionManualTests } from './discover.js';\nimport { createRelayConnectionFactory } from './relay-factory.js';\nimport type { RelayRunOptions, RelayRunReport } from './relay-worker.js';\nimport { runTestFilesOverRelay } from './relay-worker.js';\nimport { writeCaptureArtifacts, writeReportArtifact } from './report.js';\nimport { armExitBackstop, runTeardownSteps } from './teardown.js';\n\n/* -------------------------------------------------------------------------- */\n/* CLI help */\n/* -------------------------------------------------------------------------- */\n\nconst USAGE = `\ndevtools-test — run mini-app tests on a real device WebView over the CDP relay\n\nUSAGE\n devtools-test <glob> [<glob> ...] [options]\n\nOPTIONS\n --scheme-url <url> intoss-private:// URL from \\`ait deploy --scheme-only\\`\n (required for standalone relay attach / env3). Unused\n and ignored when --attach-launcher is set.\n --attach-launcher Attach over the env-2 AITC Sandbox PWA launcher\n (real-device WebKit) instead of the env-3 intoss\n scheme deep-link. The relay/QR/dashboard/attach-wait\n are identical; only the QR is a launcher deep-link.\n Requires --app-url. The SDK still hits the mock in\n env 2, so this measures the mock + standard Web API\n layer's engine-attributable behavior (env1↔env2\n equivalence), NOT native-bridge fidelity (that is env3).\n --app-url <url> The consumer dev server's HTTP tunnel URL (e.g. the\n *.trycloudflare.com URL from \\`pnpm dev:phone:cdp\\`)\n that the launcher PWA frames. REQUIRED with\n --attach-launcher; ignored otherwise. SECRET: a tunnel\n host — never printed; it rides only inside the QR.\n --timeout <ms> Per-file evaluate timeout in ms (default: 60000).\n Controls how long a single test file is allowed to run\n before it is considered hung. Does NOT affect how long\n the CLI waits for a human to scan the QR code — use\n --attach-timeout for that.\n --attach-timeout <ms> How long to wait for a human to scan the QR code with\n their phone. Omit (default) to wait indefinitely — the\n runner stays up until you stop it (Ctrl-C/SIGTERM).\n Pass a value to bound the wait for CI/headless runs.\n --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)\n --cell-platform <plat> Platform to inject as __AIT_CELL__.platform\n (mock|ios|android|ios-pwa, default: AIT_CELL_PLATFORM\n env). Use ios-pwa for env-2 (--attach-launcher) runs\n so env1(mock@desktop) and env2(mock@WebKit) captures\n stay distinguishable. An unknown value is rejected.\n --report-dir <dir> Persist a runner-agnostic report + captures to <dir>\n (report: <sdkLine>.<platform>.json; captures:\n <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).\n Omitted = nothing saved. Enables console capture.\n --dashboard-port <port> Base port for the QR dashboard HTTP server. On\n EADDRINUSE it increments (+1, up to 20 tries) before\n falling back to an ephemeral port. Omit to use\n AIT_DEBUG_HTTP_PORT env or the built-in default\n (8317) — pass 0 to force a random ephemeral port.\n --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for\n non-interactive stdout / CI / AIT_NO_QR_STDOUT)\n --headless Disable browser auto-open (text QR only)\n --project-root <dir> Project root for .ait_relay secret lookup\n (default: current working directory)\n --pace <ms> Minimum delay in ms between test-to-test AND\n file-to-file bridge calls (default: 0, i.e. no\n added delay — today's behavior byte-for-byte).\n Falls back to the AIT_PACE env var when omitted\n (--pace takes precedence over the env var when both\n are given). Use on a 2.x cell scan when the native\n per-method bridge rate limit (APP_BRIDGE_THROTTLED,\n devtools#767) is rejecting rapid same-method calls —\n 3.x cells are unaffected by that limiter and do not\n need this flag.\n --pace-method <ms> Minimum delay in ms BETWEEN calls to the SAME named\n SDK function — paces a same-method burst WITHIN a\n single test body (e.g. a clipboard happy-path loop\n calling setClipboardText/getClipboardText 8 times\n back to back), which --pace's test/file spacing\n cannot reach (devtools#769). Falls back to the\n AIT_PACE_METHOD env var when omitted (--pace-method\n takes precedence over the env var when both are\n given). Default: 250ms when --cell-sdk-line is 2.x\n (unset --cell-sdk-line also defaults to 2.x — see\n --cell-sdk-line), 0 (no added delay) otherwise. Pass\n --pace-method 0 to opt out even on a 2.x cell.\n --manual-blocking Run manual-tagged test files (*.manual.ait.test.ts)\n LAST, after all regular files, with a human present.\n Before each manual file, the QR dashboard is pushed\n a step-by-step Korean prompt naming the file + its\n progress (k/n), and the same line is printed to\n stdout. Manual files get a 5-minute per-file evaluate\n timeout (vs. --timeout for everything else) since a\n human is expected to tap through a native sheet\n (photo picker, permission dialog, fullscreen ad).\n Without this flag (default off), *.manual.ait.test.ts\n files are EXCLUDED from the glob expansion entirely —\n existing unattended runs are byte-for-byte unaffected.\n With --report-dir, a run that included manual files\n ALSO writes <sdkLine>.<platform>.manual.json\n alongside (never replacing) the standard report, and\n each manual file's report entry is stamped\n mode: 'manual' — never diff a manual run against an\n unattended baseline as if they were equivalent.\n --stub-blocking Run manual-tagged test files (*.manual.ait.test.ts)\n UNATTENDED (devtools#740, DT-2) by intercepting a\n fixed allowlist of blocking-UI SDK calls (ads\n show*, openPermissionDialog/requestPermission,\n saveBase64Data) in the page and answering them from\n fixtures captured by a real --manual-blocking run,\n instead of forwarding them to native UI. Implies\n --manual-blocking (manual files are included in the\n run); no human presence or QR-dashboard prompt is\n needed for them. HYBRID cell, not pure env3 — every\n other SDK call in the same run still hits the real\n native bridge. With --report-dir, files that ran\n under the stub are written to a SEPARATE\n <sdkLine>.<platform>.stubbed.json artifact (never\n merged into the standard or .manual.json report) and\n the report body is stamped cell.bridgeStub: true —\n never diff a stubbed run against a real device\n baseline (manual or unattended) as if equivalent.\n --help, -h Show this help message\n\nDESCRIPTION\n Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real\n device to scan and attach, injects the cell globals (__AIT_CELL__), bundles\n each matched test file with esbuild (SDK imports redirected to window.__sdk),\n injects the bundle into the attached WebView via Runtime.evaluate, and prints\n a summary.\n\n With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a\n runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be\n compared offline.\n\n The test files run against the live relay connection started by this process;\n no separate MCP daemon is required.\n\nEXAMPLE (env 3 — intoss-private scheme)\n devtools-test 'src/**/*.ait.test.ts' \\\\\n --scheme-url \"intoss-private://...\" \\\\\n --cell-sdk-line 3.x \\\\\n --cell-platform ios \\\\\n --report-dir .ait-report \\\\\n --timeout 60000\n\nEXAMPLE (env 2 — AITC Sandbox PWA launcher)\n devtools-test 'src/**/*.ait.test.ts' \\\\\n --attach-launcher \\\\\n --app-url \"https://<subdomain>.trycloudflare.com\" \\\\\n --cell-sdk-line 3.x \\\\\n --cell-platform ios-pwa \\\\\n --report-dir .ait-report\n\n`.trimStart();\n\n/**\n * Grace period (ms) for the exit backstop armed around Step 6 teardown\n * (devtools#755). See `teardown.ts`'s module doc for the root-cause writeup\n * — the two upstream `http.Server#close()` hangs are fixed at the source, so\n * this backstop is a last-mile safety net that should never fire in\n * practice. Exported so tests can assert the backstop is armed with the\n * expected value without hardcoding the literal twice.\n */\nexport const EXIT_BACKSTOP_GRACE_MS = 3_000;\n\n/* -------------------------------------------------------------------------- */\n/* Timeout resolution (exported for unit tests) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Resolved timeout values derived from raw CLI flag strings.\n *\n * Exported so unit tests can assert the two clocks in isolation without\n * spawning a subprocess.\n */\nexport interface ResolvedTimeouts {\n /** Per-file evaluate timeout (ms). From --timeout; default 60 000. */\n evaluateTimeoutMs: number;\n /**\n * QR-scan wait timeout (ms), or `undefined` when --attach-timeout was not\n * supplied. `undefined` signals \"let relay-factory.ts's default govern\" —\n * which is an UNBOUNDED wait (devtools#735): the runner stays up until the\n * user stops it (Ctrl-C/SIGTERM). We intentionally do not inline that\n * default here so the factory remains the single source of truth for it.\n */\n attachTimeoutMs: number | undefined;\n}\n\n/**\n * Parses --timeout and --attach-timeout raw string values into the two\n * distinct clocks.\n *\n * Returns an error string on invalid input, or the resolved timeouts on\n * success. The caller (main) writes the error to stderr and exits 1.\n *\n * Exported for unit testing — main() is the only other caller.\n */\nexport function resolveTimeouts(\n rawTimeout: string | undefined,\n rawAttachTimeout: string | undefined,\n): ResolvedTimeouts | string {\n // Default must match rpc.ts DEFAULT_TIMEOUT_MS (60_000) — the CLI's own\n // fallback used to be 30_000, which silently overrode rpc.ts's 60s bump\n // (#726) on every CLI invocation that omitted --timeout (#731).\n const evaluateTimeoutMs = rawTimeout !== undefined ? parseInt(rawTimeout, 10) : 60_000;\n if (Number.isNaN(evaluateTimeoutMs) || evaluateTimeoutMs <= 0) {\n return '--timeout must be a positive integer';\n }\n\n const attachTimeoutMs =\n rawAttachTimeout !== undefined ? parseInt(rawAttachTimeout, 10) : undefined;\n if (attachTimeoutMs !== undefined && (Number.isNaN(attachTimeoutMs) || attachTimeoutMs <= 0)) {\n return '--attach-timeout must be a positive integer';\n }\n\n return { evaluateTimeoutMs, attachTimeoutMs };\n}\n\n/**\n * Parses the `--dashboard-port` raw string value into a validated port\n * number, or `undefined` when the flag was omitted (letting relay-factory /\n * qr-http-server resolve their own default — env then the built-in fixed\n * default, devtools#752).\n *\n * `0` is a valid, meaningful value (explicit opt-out to pure ephemeral) and\n * is passed through as-is — it must NOT be confused with \"omitted\".\n *\n * Returns an error string on invalid input (non-integer, negative, or\n * >65535), or the resolved port on success. Exported for unit testing.\n */\nexport function resolveDashboardPort(raw: string | undefined): number | undefined | string {\n if (raw === undefined) return undefined;\n const port = parseInt(raw, 10);\n if (Number.isNaN(port) || port < 0 || port > 65535) {\n return '--dashboard-port must be an integer between 0 and 65535';\n }\n return port;\n}\n\n/**\n * Rewrites a lone `--pace <value>` or `--pace-method <value>` pair from space\n * syntax to `=` syntax (`--pace=<value>` / `--pace-method=<value>`) when\n * `<value>` starts with `-` (devtools#768 review; extended to `--pace-method`\n * in devtools#769).\n *\n * Node's `util.parseArgs` treats a `type: 'string'` option's value as\n * \"ambiguous\" and throws its OWN parser error whenever that value starts with\n * a dash and was passed via the space form (`--pace -1`) — it never reaches\n * this module's `resolvePace`/`resolvePaceMethod`, so the friendly\n * `'--pace must be a non-negative integer'` message (and its unit-tested path)\n * was unreachable from the real CLI entry point for the single most natural\n * way a user would try a negative value, even though every other flag in\n * {@link USAGE} is documented in space syntax.\n *\n * Scoped narrowly to `--pace`/`--pace=...` and `--pace-method`/\n * `--pace-method=...` tokens only — every other flag is untouched, and\n * positionals/other options keep flowing through `parseArgs` unchanged. Only\n * rewrites the space form; `--pace=-1`/`--pace-method=-1` already parse fine\n * and are passed through untouched.\n *\n * Exported for unit testing.\n */\nexport function normalizePaceArgv(argv: string[]): string[] {\n const out: string[] = [];\n for (let i = 0; i < argv.length; i++) {\n const arg = argv[i];\n const next = argv[i + 1];\n if (\n (arg === '--pace' || arg === '--pace-method') &&\n next !== undefined &&\n next.startsWith('-') &&\n next !== '--'\n ) {\n out.push(`${arg}=${next}`);\n i++; // consume the value token too\n continue;\n }\n out.push(arg);\n }\n return out;\n}\n\n/**\n * Parses `--pace` (falling back to the `AIT_PACE` env var when the flag is\n * omitted) into a validated millisecond delay (devtools#767).\n *\n * Opt-in, zero-diff-when-absent: BOTH omitted resolves to `0` — no pacing —\n * which is byte-for-byte today's behavior (`runtime.ts` treats `0`/absent\n * `__AIT_PACE_MS__` identically, and `relay-worker.ts`'s file-to-file gap is\n * skipped entirely when the resolved value is 0). `--pace` takes precedence\n * over `AIT_PACE` when both are present, mirroring `--cell-platform` /\n * `AIT_CELL_PLATFORM`'s existing flag-over-env precedent.\n *\n * Returns an error string on invalid input (non-integer or negative), or the\n * resolved delay (`>= 0`) on success. Exported for unit testing.\n */\nexport function resolvePace(\n rawFlag: string | undefined,\n rawEnv: string | undefined,\n): number | string {\n const raw = rawFlag ?? rawEnv;\n if (raw === undefined) return 0;\n const ms = parseInt(raw, 10);\n if (Number.isNaN(ms) || ms < 0) {\n return '--pace must be a non-negative integer';\n }\n return ms;\n}\n\n/**\n * The sdkLine-aware default for `--pace-method` when the flag AND\n * `AIT_PACE_METHOD` are both omitted (devtools#769 acceptance criteria):\n * `250` for a 2.x cell, `0` (no added delay) for everything else. This is the\n * SAME 250ms preflight-proven-safe value `cell.ts#PROBE_INTER_CALL_DELAY_MS`\n * uses — not re-derived, just referenced by value, since `cell.ts` is not\n * imported here to avoid pulling its heavier CDP-typed graph onto this\n * string-only resolver. `resolvedCellSdkLine` mirrors the same '2.x' fallback\n * `main()` applies when `--cell-sdk-line` is omitted (`cell.sdkLine`, below)\n * — an unspecified cell is treated as 2.x, the conservative default that also\n * governs the permission preflight's own pacing (`cell.ts`'s\n * `preflightSdkLine` doc).\n *\n * Exported for unit testing.\n */\nexport const DEFAULT_PACE_METHOD_MS_2X = 250;\n\n/**\n * Parses `--pace-method` (falling back to the `AIT_PACE_METHOD` env var when\n * the flag is omitted) into a validated millisecond per-method minimum\n * interval (devtools#769).\n *\n * Precedence, highest first: `--pace-method` flag > `AIT_PACE_METHOD` env >\n * sdkLine-aware default (`{@link DEFAULT_PACE_METHOD_MS_2X}` for a 2.x cell,\n * `0` otherwise) — an EXPLICIT flag or env value always wins, including\n * `--pace-method 0` to opt out on a 2.x cell. Mirrors `resolvePace`'s\n * flag-over-env precedent, with the sdkLine default spliced in only when\n * BOTH are absent.\n *\n * Returns an error string on invalid input (non-integer or negative), or the\n * resolved delay (`>= 0`) on success. Exported for unit testing.\n *\n * @param rawFlag - The raw `--pace-method` string value, if passed.\n * @param rawEnv - The raw `AIT_PACE_METHOD` env value, if set.\n * @param resolvedCellSdkLine - The effective `cell.sdkLine` value (already\n * defaulted to '2.x' by the caller when `--cell-sdk-line` was omitted — see\n * `main()`'s `cell` construction) — used ONLY to pick the sdkLine-aware\n * default when both `rawFlag` and `rawEnv` are absent.\n */\nexport function resolvePaceMethod(\n rawFlag: string | undefined,\n rawEnv: string | undefined,\n resolvedCellSdkLine: string,\n): number | string {\n const raw = rawFlag ?? rawEnv;\n if (raw === undefined) {\n return resolvedCellSdkLine === '2.x' ? DEFAULT_PACE_METHOD_MS_2X : 0;\n }\n const ms = parseInt(raw, 10);\n if (Number.isNaN(ms) || ms < 0) {\n return '--pace-method must be a non-negative integer';\n }\n return ms;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Cell platform validation (exported for unit tests) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The set of `__AIT_CELL__.platform` values the runner accepts (devtools#774).\n *\n * `mock`/`ios`/`android` mirror sdk-example `aitCapture`'s existing `Platform`\n * union; `ios-pwa` is the new env-2 axis value (issue #774) — env 1 is\n * mock@desktop-Chromium and env 2 is mock@real-device-WebKit, so the two share\n * `sdkLine`/`ios` but must be DISTINGUISHABLE in the capture cell to diff\n * engine-attributable differences. `ios-pwa` names \"mock SDK, real-device iOS\n * WebKit via the launcher PWA\" — it is NOT `ios` (which is env 3's native\n * bridge). The consuming `aitCapture` type extension is a separate sdk-example\n * PR (out of scope here); the runner only needs to inject + validate the label.\n *\n * Kept as a validated allowlist (rather than a free string) purely as a\n * typo guard: `platform` flows into report/capture FILENAMES\n * (`<sdkLine>.<platform>.json`), so a silent typo would fork the artifact set\n * and break offline diffing. This is the first validation on `--cell-platform`\n * — before #774 it was an unvalidated free string defaulting to `mock`.\n */\nexport const ALLOWED_CELL_PLATFORMS = ['mock', 'ios', 'android', 'ios-pwa'] as const;\n\n/**\n * Validates a resolved `--cell-platform` value against {@link ALLOWED_CELL_PLATFORMS}.\n *\n * `undefined` (flag + `AIT_CELL_PLATFORM` env both absent) passes through as\n * `{ ok: true, value: undefined }` — the caller applies its own `'mock'`\n * default, so an omitted platform is never an error. A present-but-unknown\n * value returns `{ ok: false, error }` naming the allowed set.\n *\n * Uses a tagged result (not the `string`-is-error convention of\n * `resolveDashboardPort`/`resolvePace`) because BOTH the success value and the\n * error message are strings here — a bare `string` return would be ambiguous.\n * Exported for unit testing.\n */\nexport function resolveCellPlatform(\n raw: string | undefined,\n): { ok: true; value: string | undefined } | { ok: false; error: string } {\n if (raw === undefined) return { ok: true, value: undefined };\n if ((ALLOWED_CELL_PLATFORMS as readonly string[]).includes(raw)) {\n return { ok: true, value: raw };\n }\n return {\n ok: false,\n error: `--cell-platform must be one of ${ALLOWED_CELL_PLATFORMS.join('|')} (got \"${raw}\")`,\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Per-file summary rendering (exported for unit tests) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Renders per-file result lines and the aggregate totals line to a string.\n *\n * Each file gets one line:\n * - Error/timeout: `FAIL <basename>: <error-class>`\n * - Pass (0 tests): `OK <basename>: 0 passed (empty file)`\n * - Pass: `OK <basename>: N passed[, M failed][, K skipped]`\n *\n * The aggregate totals line always follows.\n *\n * SECRET-HANDLING: only `basename(file)` is used — no absolute paths, relay\n * URLs, wss URLs, scheme URLs, or TOTP codes appear in the output. The error\n * string comes from `result.error` which is already secret-free (relay-worker\n * produces only error-class messages like \"rpc: evaluate timed out after\n * 30000ms\").\n *\n * Exported so unit tests can assert the per-file lines without spawning a\n * subprocess or going through the full relay attach flow.\n */\nexport function renderSummary(report: RelayRunReport): string {\n const lines: string[] = [];\n\n for (const { file, result } of report.files) {\n const name = basename(file);\n if ('error' in result) {\n // Timed-out or errored file — the error string is already secret-free\n // (relay-worker only surfaces error-class text, never URLs or codes).\n lines.push(`FAIL ${name}: ${result.error}`);\n } else {\n const parts: string[] = [`${result.passed} passed`];\n if (result.failed > 0) parts.push(`${result.failed} failed`);\n if (result.skipped > 0) parts.push(`${result.skipped} skipped`);\n const suffix = result.passed + result.failed + result.skipped === 0 ? ' (empty file)' : '';\n lines.push(`OK ${name}: ${parts.join(', ')}${suffix}`);\n }\n }\n\n const { totals, duration } = report;\n lines.push(\n `\\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${duration}ms)`,\n );\n\n return lines.join('\\n');\n}\n\n/* -------------------------------------------------------------------------- */\n/* Pure run function (testable without a real relay) */\n/* -------------------------------------------------------------------------- */\n\n/** Options for `runWithConnection`. */\nexport interface RunWithConnectionOptions extends RelayRunOptions {\n /** If true, print a summary to stdout. Defaults to false in tests. */\n printSummary?: boolean;\n}\n\n/**\n * Runs `files` over `connection` and returns the aggregate report.\n * This pure function is the testable core of the CLI (and is what the\n * `run_tests` MCP tool calls against the daemon's attached connection); it is\n * separate from `main()` so tests can call it without spawning a subprocess.\n */\nexport async function runWithConnection(\n connection: CdpConnection,\n files: string[],\n opts?: RunWithConnectionOptions,\n): Promise<RelayRunReport> {\n const report = await runTestFilesOverRelay(connection, files, opts);\n\n if (opts?.printSummary) {\n process.stdout.write(`\\n${renderSummary(report)}\\n`);\n }\n\n return report;\n}\n\n/* -------------------------------------------------------------------------- */\n/* main() — CLI entry point */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Decides whether to suppress the QR/attach block on stdout.\n *\n * Suppress when EITHER the user passed `--no-qr-stdout`, OR stdout is not a TTY\n * / `CI` is set / `AIT_NO_QR_STDOUT` is set (non-interactive — a captured stdout\n * must not leak the relay wss + TOTP `at=` code that the QR block encodes). The\n * suppression is whole-chunk: `attachUrl` AND `relayUrl` ride in the same block.\n *\n * Exported for unit testing.\n */\nexport function shouldSuppressQr(noQrFlag: boolean): boolean {\n return (\n noQrFlag ||\n !process.stdout.isTTY ||\n process.env.CI !== undefined ||\n process.env.AIT_NO_QR_STDOUT !== undefined\n );\n}\n\n/**\n * CLI entry point.\n *\n * Performs a standalone relay attach → run lifecycle, sharing the attach\n * assembly with the Vitest pool via `createRelayConnectionFactory` (single\n * source — no drift):\n *\n * 1. Parse args: globs, --timeout (per-file evaluate), --attach-timeout (QR\n * scan wait), --cell-sdk-line, --cell-platform, --scheme-url (required for\n * env3) OR --attach-launcher + --app-url (env2, devtools#774), --report-dir,\n * --dashboard-port, --no-qr-stdout, --headless, --project-root.\n * 2. Discover test files; exit 1 if none.\n * 3. factory.open() — boot relay → render QR (suppressed on non-interactive\n * stdout) → wait for phone (up to attachTimeoutMs) → inject cell →\n * enableDomains. Returns the conn.\n * 4. runWithConnection(conn, files, { evaluateTimeoutMs, collectCaptures,\n * printSummary }).\n * 5. With --report-dir: write the runner-agnostic report + capture files.\n * 6. factory.close(); process.exitCode = failed > 0 ? 1 : 0.\n *\n * The CLI is not a daemon — no lock, router, SSE, or tools_list is needed.\n * Attach timeout exits with code 1; test failures exit with code 1.\n *\n * SECRET-HANDLING: scheme_url / relay wssUrl / TOTP codes are never written to\n * stdout/stderr directly. The QR block (which encodes the TOTP `at=` code) is\n * printed only when stdout is interactive AND not suppressed.\n */\nexport async function main(argv: string[] = process.argv.slice(2)): Promise<void> {\n // ── Step 1: parse arguments ───────────────────────────────────────────────\n let parsed: ReturnType<typeof parseArgs>;\n try {\n parsed = parseArgs({\n // devtools#768 review: rewrite `--pace -1` (space syntax) to\n // `--pace=-1` before parseArgs sees it — otherwise Node's parser throws\n // its own \"ambiguous option\" error for a leading-dash value passed via\n // space syntax, and resolvePace's friendly validation message (below)\n // is never reached for that natural invocation. See normalizePaceArgv.\n args: normalizePaceArgv(argv),\n options: {\n help: { type: 'boolean', short: 'h' },\n timeout: { type: 'string' },\n 'attach-timeout': { type: 'string' },\n 'scheme-url': { type: 'string' },\n 'attach-launcher': { type: 'boolean' },\n 'app-url': { type: 'string' },\n 'cell-sdk-line': { type: 'string' },\n 'cell-platform': { type: 'string' },\n 'report-dir': { type: 'string' },\n 'dashboard-port': { type: 'string' },\n 'no-qr-stdout': { type: 'boolean' },\n headless: { type: 'boolean' },\n 'project-root': { type: 'string' },\n pace: { type: 'string' },\n 'pace-method': { type: 'string' },\n 'manual-blocking': { type: 'boolean' },\n 'stub-blocking': { type: 'boolean' },\n } as const,\n allowPositionals: true,\n });\n } catch (e) {\n process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\\n`);\n process.exitCode = 1;\n return;\n }\n\n if (parsed.values.help || argv.length === 0) {\n process.stdout.write(USAGE);\n return;\n }\n\n // Extract string-typed flags explicitly — parseArgs returns `string | boolean | (string | boolean)[]`\n // for the union `values` type when options have mixed `type` fields, so we\n // narrow each flag here.\n const vals = parsed.values;\n\n // Resolve the two timeout clocks via the exported pure helper (unit-tested).\n const timeouts = resolveTimeouts(\n typeof vals.timeout === 'string' ? vals.timeout : undefined,\n typeof vals['attach-timeout'] === 'string' ? vals['attach-timeout'] : undefined,\n );\n if (typeof timeouts === 'string') {\n process.stderr.write(`devtools-test: ${timeouts}\\n`);\n process.exitCode = 1;\n return;\n }\n const { evaluateTimeoutMs, attachTimeoutMs } = timeouts;\n\n // env-2 (launcher, devtools#774) vs env-3 (scheme) attach selection. In\n // launcher mode --scheme-url is unused/ignored and --app-url (the consumer\n // dev server's tunnel URL) is required; otherwise --scheme-url is required.\n const attachLauncher = vals['attach-launcher'] === true;\n const schemeUrl = typeof vals['scheme-url'] === 'string' ? vals['scheme-url'] : '';\n const appUrl = typeof vals['app-url'] === 'string' ? vals['app-url'] : '';\n if (attachLauncher) {\n if (appUrl === '') {\n process.stderr.write(\n `devtools-test: --app-url is required with --attach-launcher (env 2).\\n` +\n ` Pass the consumer dev server's tunnel URL (e.g. the HTTP *.trycloudflare.com\\n` +\n ` URL printed by \\`pnpm dev:phone:cdp\\`). --scheme-url is not used in this mode.\\n`,\n );\n process.exitCode = 1;\n return;\n }\n } else if (schemeUrl === '') {\n process.stderr.write(\n `devtools-test: --scheme-url is required for standalone relay attach.\\n` +\n ` Pass the intoss-private:// URL from \\`ait deploy --scheme-only\\`.\\n` +\n ` (For env 2 / AITC Sandbox PWA, use --attach-launcher --app-url <tunnel-url> instead.)\\n`,\n );\n process.exitCode = 1;\n return;\n }\n\n const headless = vals.headless === true;\n const projectRoot =\n typeof vals['project-root'] === 'string' ? vals['project-root'] : process.cwd();\n const reportDir = typeof vals['report-dir'] === 'string' ? vals['report-dir'] : undefined;\n const dashboardPort = resolveDashboardPort(\n typeof vals['dashboard-port'] === 'string' ? vals['dashboard-port'] : undefined,\n );\n if (typeof dashboardPort === 'string') {\n process.stderr.write(`devtools-test: ${dashboardPort}\\n`);\n process.exitCode = 1;\n return;\n }\n const paceMs = resolvePace(\n typeof vals.pace === 'string' ? vals.pace : undefined,\n process.env.AIT_PACE,\n );\n if (typeof paceMs === 'string') {\n process.stderr.write(`devtools-test: ${paceMs}\\n`);\n process.exitCode = 1;\n return;\n }\n const suppressQr = shouldSuppressQr(vals['no-qr-stdout'] === true);\n // devtools#740 (DT-2): --stub-blocking IMPLIES --manual-blocking (manual\n // files must be discovered/included for there to be anything to stub) —\n // a user who passes --stub-blocking alone should not have to also\n // remember --manual-blocking for it to do anything.\n const stubBlocking = vals['stub-blocking'] === true;\n const manualBlocking = vals['manual-blocking'] === true || stubBlocking;\n\n // Cell: --cell-sdk-line and --cell-platform (fall back to AIT_CELL_PLATFORM env).\n const cellSdkLine = typeof vals['cell-sdk-line'] === 'string' ? vals['cell-sdk-line'] : undefined;\n // devtools#774: validate --cell-platform against the allowed set (typo guard —\n // platform flows into report/capture filenames). 'ios-pwa' is the new env-2\n // value. An omitted platform stays undefined and gets the 'mock' default below.\n const cellPlatformResult = resolveCellPlatform(\n typeof vals['cell-platform'] === 'string'\n ? vals['cell-platform']\n : process.env.AIT_CELL_PLATFORM,\n );\n if (!cellPlatformResult.ok) {\n process.stderr.write(`devtools-test: ${cellPlatformResult.error}\\n`);\n process.exitCode = 1;\n return;\n }\n const cellPlatform = cellPlatformResult.value;\n const hasCell = cellSdkLine !== undefined || cellPlatform !== undefined;\n // The cell injected onto the page and the report/capture filename suffix.\n // sdk-example's own fallbacks are '2.x'/'mock'; mirror them when a flag is\n // absent so artifacts still get a stable, meaningful cell suffix.\n const cell = { sdkLine: cellSdkLine ?? '2.x', platform: cellPlatform ?? 'mock' };\n\n // devtools#769: sdkLine-aware default (250ms on a 2.x cell, 0 otherwise) —\n // resolved AFTER `cell` above so `cell.sdkLine`'s own '2.x' fallback (when\n // --cell-sdk-line is omitted) feeds the default. An explicit --pace-method\n // flag or AIT_PACE_METHOD env always takes precedence over that default,\n // including --pace-method 0 to opt out on a 2.x cell.\n const paceMethodMs = resolvePaceMethod(\n typeof vals['pace-method'] === 'string' ? vals['pace-method'] : undefined,\n process.env.AIT_PACE_METHOD,\n cell.sdkLine,\n );\n if (typeof paceMethodMs === 'string') {\n process.stderr.write(`devtools-test: ${paceMethodMs}\\n`);\n process.exitCode = 1;\n return;\n }\n\n // ── Step 2: discover test files ───────────────────────────────────────────\n const globs = parsed.positionals;\n if (globs.length === 0) {\n process.stderr.write(`devtools-test: at least one glob pattern is required\\n`);\n process.stdout.write(USAGE);\n process.exitCode = 1;\n return;\n }\n\n // Tagging contract (devtools#741): *.manual.ait.test.ts files are excluded\n // from discovery unless --manual-blocking is passed, in which case they are\n // included and then scheduled strictly AFTER every regular file below —\n // this is the entire \"manual-variant\" ordering guarantee.\n const discovered = await discoverTestFiles(globs, process.cwd(), {\n includeManual: manualBlocking,\n });\n if (discovered.length === 0) {\n process.stderr.write(`devtools-test: no test files matched ${globs.join(', ')}\\n`);\n process.exitCode = 1;\n return;\n }\n const { regular, manual } = partitionManualTests(discovered);\n // Manual files always run LAST, regardless of the discovery/glob order —\n // `files` below is what's actually injected, in run order.\n const files = manualBlocking ? [...regular, ...manual] : regular;\n const manualFileSet = new Set(manual);\n process.stderr.write(\n manualBlocking && manual.length > 0\n ? `devtools-test: found ${regular.length} regular + ${manual.length} manual (${MANUAL_TEST_SUFFIX}) test file(s)\\n`\n : `devtools-test: found ${files.length} test file(s)\\n`,\n );\n\n // ── Step 3: open the relay connection via the shared factory ──────────────\n // The factory boots the relay, renders the QR, waits for the phone, injects\n // the cell, and enables CDP domains — the same assembly the Vitest pool uses.\n // We pass `onQrContent` so the CLI owns the stdout decision: suppress the whole\n // QR block on non-interactive stdout (it encodes the relay wss + TOTP code).\n // SECRET-HANDLING: scheme_url / wss / TOTP are never logged by the factory.\n if (hasCell) {\n process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\\n`);\n }\n if (stubBlocking) {\n process.stderr.write(\n 'devtools-test: --stub-blocking enabled — blocking-UI calls in manual-tagged ' +\n 'files will be answered from fixtures (devtools#740), not forwarded to native UI\\n',\n );\n }\n if (paceMs > 0) {\n process.stderr.write(`devtools-test: --pace ${paceMs}ms enabled (devtools#767)\\n`);\n }\n if (paceMethodMs > 0) {\n process.stderr.write(`devtools-test: --pace-method ${paceMethodMs}ms enabled (devtools#769)\\n`);\n }\n const factory = createRelayConnectionFactory({\n schemeUrl,\n // devtools#774: env 2 (launcher) attach. When set, the factory builds a\n // launcher deep-link from appUrl instead of an intoss-private scheme URL.\n // SECRET-HANDLING: appUrl is a tunnel host — passed to the factory only,\n // never logged here or by the factory.\n ...(attachLauncher ? { attachLauncher: true, appUrl } : {}),\n projectRoot,\n // attachTimeoutMs is only forwarded when the user explicitly passed\n // --attach-timeout; otherwise we omit it so relay-factory.ts's built-in\n // UNBOUNDED default governs (devtools#735) — single source of truth.\n ...(attachTimeoutMs !== undefined ? { timeoutMs: attachTimeoutMs } : {}),\n // dashboardPort is only forwarded when the user explicitly passed\n // --dashboard-port; otherwise omit so relay-factory/qr-http-server's own\n // default resolution governs (env → fixed default, devtools#752).\n ...(dashboardPort !== undefined ? { dashboardPort } : {}),\n headless,\n cell: hasCell ? cell : undefined,\n stubBlocking,\n paceMs,\n paceMethodMs,\n onQrContent: (chunks) => {\n if (suppressQr) {\n process.stdout.write('QR suppressed (non-interactive)\\n');\n return;\n }\n for (const chunk of chunks) process.stdout.write(`${chunk}\\n`);\n },\n });\n\n let connection: CdpConnection;\n try {\n connection = await factory.open();\n } catch (e) {\n process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\\n`);\n process.exitCode = 1;\n return;\n }\n\n // Ensure cleanup on early exit.\n let exitCode = 0;\n try {\n // ── Step 4: run test files ──────────────────────────────────────────────\n // #730: mark the dashboard as 'running' before the run starts so the QR\n // page reflects an in-progress session rather than staying 'active' the\n // whole time. collectCaptures is enabled only when a report dir is given\n // (the only sink for captures) — keeps the no-report path free of\n // listener overhead.\n factory.onSessionPhase?.('running');\n const report = await runWithConnection(connection, files, {\n timeoutMs: evaluateTimeoutMs,\n printSummary: true,\n collectCaptures: reportDir !== undefined,\n // devtools#767: runner-side (file-to-file) half of --pace. 0 (default)\n // is forwarded as-is — relay-worker.ts's own `paceMs > 0` guard treats\n // it identically to omitted.\n paceMs,\n // devtools#767 acceptance criteria 2: tells the permission preflight\n // whether to pace its probes. `cell.sdkLine` defaults to '2.x' (see\n // above) when the user didn't pass --cell-sdk-line, which keeps pacing\n // ON by default — only an explicit '3.x' cell skips it.\n preflightSdkLine: cell.sdkLine,\n manualFiles: manualBlocking && manualFileSet.size > 0 ? manualFileSet : undefined,\n // devtools#740 (DT-2): same file set as manualFiles when --stub-blocking\n // is on — stubBlockingFiles takes precedence in relay-worker.ts, so\n // these files run unattended (regular timeout, no dashboard prompt,\n // mode: 'stubbed') even though they are also present in manualFileSet.\n stubBlockingFiles: stubBlocking && manualFileSet.size > 0 ? manualFileSet : undefined,\n // #741: before each manual file, push the dashboard prompt AND print\n // the same Korean instruction line to stdout (human may be watching\n // either surface). Clearing the prompt (dashboard only) happens once\n // the whole run ends, in the finally block below — a manual file is\n // always the tail of `files`, so there is no \"next regular file\" to\n // clear it for mid-run.\n onManualFile: (file, index, total) => {\n const name = basename(file);\n process.stdout.write(\n `수동 단계: ${name} — 폰에서 네이티브 시트가 뜨면 안내에 따라 조작하세요 (${index}/${total})\\n`,\n );\n factory.onManualPrompt?.({ file: name, index, total });\n },\n });\n\n // Clear the dashboard's manual prompt now that the run (including any\n // manual tail) has finished — leaves no stale \"수동 단계\" banner up once\n // the human is done.\n if (manualBlocking && manualFileSet.size > 0) {\n factory.onManualPrompt?.(null);\n }\n\n // ── Step 5: persist artifacts (only with --report-dir) ──────────────────\n if (reportDir !== undefined) {\n try {\n const reportPaths = await writeReportArtifact(report, reportDir, {\n sdkLine: cell.sdkLine,\n platform: cell.platform,\n projectRoot,\n });\n for (const reportPath of reportPaths) {\n process.stderr.write(`devtools-test: wrote report ${reportPath}\\n`);\n }\n const capturePaths = await writeCaptureArtifacts(\n report.captures,\n `${reportDir}/.ait-capture`,\n cell,\n );\n if (capturePaths.length > 0) {\n process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\\n`);\n }\n } catch (e) {\n // Artifact write failure must not mask the test result.\n process.stderr.write(\n `devtools-test: failed to write report artifacts: ${e instanceof Error ? e.message : String(e)}\\n`,\n );\n }\n }\n\n exitCode = report.totals.failed > 0 ? 1 : 0;\n } finally {\n // ── Step 6: teardown (devtools#755) ─────────────────────────────────────\n // #730: mark the dashboard 'complete' BEFORE close() so the terminal SSE\n // frame reaches any open dashboard tab before the HTTP server is closed.\n // Redundant-but-safe with close()'s own internal push (belt-and-suspenders\n // so the frame flushes even if the two ever run in a different order).\n factory.onSessionPhase?.('complete');\n\n // Bounded backstop (devtools#755): arm BEFORE the teardown steps run, so\n // even a step that hangs past its own per-step timeout cannot leave the\n // process running forever. The two known root causes of the run7~10 hang\n // (dashboard SSE tab + relay WS leg blocking http.Server#close()) are\n // fixed at the source (qr-http-server.ts, chii-relay.ts) — this backstop\n // exists for any OTHER handle outside those two files' control and\n // should not fire in normal operation.\n const backstop = armExitBackstop({ graceMs: EXIT_BACKSTOP_GRACE_MS, exitCode });\n\n // factory.close() stops the relay family (closes the CDP connection +\n // shuts down the relay + cloudflared child) and closes the QR HTTP\n // server. Wrapped in a single named step so a hang here cannot silently\n // block process exit — runTeardownSteps bounds it and the outer backstop\n // covers anything runTeardownSteps itself cannot enumerate.\n await runTeardownSteps([\n {\n name: 'factory.close',\n close: () => factory.close(connection),\n },\n ]);\n\n // Happy path: teardown finished within its own bounds — disarm the\n // backstop so the process exits naturally at process.exitCode instead of\n // waiting out the full grace period.\n backstop.disarm();\n process.exitCode = exitCode;\n }\n}\n","/**\n * `devtools-test` bin entry point — export-free by design.\n *\n * This file is intentionally export-free so that Rolldown (tsdown) emits the\n * main() call directly into the output bundle rather than hoisting the module\n * body into a shared chunk and replacing this file with a re-export wrapper.\n *\n * Background (#711): cli.ts exports `main`, `runWithConnection`, and\n * `shouldSuppressQr` for use by tests and relay-factory. When cli.ts was also\n * the bin entry, Rolldown split the module body into a shared chunk\n * (`cli-<hash>.js`) and reduced the bin output to a 3-line re-export wrapper.\n * The self-invoke guard (`import.meta.url === process.argv[1]`) evaluated\n * inside the shared chunk where `import.meta.url` is the chunk path, not the\n * bin path — a structural permanent mismatch — so main() was never called and\n * every `devtools-test` / `pnpm test:env3` invocation exited 0 as a silent\n * no-op.\n *\n * NOTE: no shebang in this source file — the tsdown entry's `banner` option\n * injects `#!/usr/bin/env node` into the compiled output (same pattern as\n * other Node bin entries in tsdown.config.ts).\n */\n\nimport { main } from './cli.js';\n\nmain().catch((e: unknown) => {\n process.stderr.write(\n `devtools-test: unexpected error: ${e instanceof Error ? e.message : String(e)}\\n`,\n );\n process.exitCode = 1;\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,MAAa,qBAAqB;;AAGlC,SAAgB,iBAAiB,MAAuB;AACtD,QAAO,SAAS,KAAK,CAAC,SAAS,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;AA0BpD,eAAsB,kBACpB,UACA,KACA,MACmB;CACnB,MAAM,sBAAM,IAAI,KAAa;AAC7B,YAAW,MAAM,SAAS,KAAK,UAAU,EAAE,KAAK,CAAC,CAC/C,KAAI,IAAI,WAAW,MAAM,GAAG,QAAQ,QAAQ,KAAK,MAAM,CAAC;CAE1D,MAAM,SAAS,CAAC,GAAG,IAAI,CAAC,MAAM;AAC9B,KAAI,MAAM,cAAe,QAAO;AAChC,QAAO,OAAO,QAAQ,MAAM,CAAC,iBAAiB,EAAE,CAAC;;;;;;;;;;;;;AAcnD,SAAgB,qBAAqB,OAA0D;CAC7F,MAAM,UAAoB,EAAE;CAC5B,MAAM,SAAmB,EAAE;AAC3B,MAAK,MAAM,KAAK,MACd,EAAC,iBAAiB,EAAE,GAAG,SAAS,SAAS,KAAK,EAAE;AAElD,QAAO;EAAE;EAAS;EAAQ;;;;;;;;;;;;;;;;;;;;;;ACkG5B,SAAgB,6BACd,MACwB;CACxB,MAAM,cAAc,KAAK,eAAe,QAAQ,KAAK;CAIrD,MAAM,YAAY,KAAK,aAAa,OAAO;CAC3C,MAAM,WAAW,KAAK,aAAa;CAGnC,IAAI;CAEJ,IAAI;CAIJ,IAAI;CAIJ,IAAI,QAAiC;CAGrC,IAAI,eAA+C;AAEnD,QAAO;EACL,MAAM,OAA+B;GAGnC,MAAM,EAAE,eAAe,oBAAoB,kBAAkB,MAAM,OACjE,sCAAA,MAAA,MAAA,EAAA,EAAA;GAEF,MAAM,EAAE,sBAAsB,kBAAkB,MAAM,OAAO,uBAAA,MAAA,MAAA,EAAA,EAAA;GAC7D,MAAM,EAAE,4BAA4B,MAAM,OAAO;GACjD,MAAM,EAAE,iBAAiB,sBAAsB,uBAAuB,MAAM,OAC1E;GAEF,MAAM,EAAE,0BAA0B,MAAM,OAAO;GAC/C,MAAM,EAAE,iBAAiB,MAAM,OAAO,uBAAA,MAAA,MAAA,EAAA,EAAA;AAMtC,SAAM,wBAAwB,EAAE,aAAa,CAAC;GAY9C,IAAI;GACJ,MAAM,cAAc,IAAI,SAAe,YAAY;AACjD,sBAAkB;KAClB;GAEF,MAAM,SAAS,MAAM,gBAAgB;IACnC,YAAY,sBAAsB;IAClC,gBAAgB;AAQd,sBAAiB;AACjB,eAAU,mBAAmB;;IAM/B,oBAAoB;AAClB,eAAU,mBAAmB;;IAEhC,CAAC;AACF,YAAS;AAIT,OAAI,OAAO,mBAAmB,CAAC,GAC7B,kBAAiB;GAMnB,IAAI;GAIJ,MAAM,aAAyB;IAC7B,iBAAiB,OAAO,2BAA2B;KAAE,IAAI;KAAO,QAAQ;KAAM;IAC9E,qBAAqB,QAAQ,IAAI;IACjC,cAAc,KAAA;IACd,kBAAkB,KAAA;IAClB,sBAAsB,CAAC;IACxB;AAcD,OAAI;IACF,MAAM,EAAE,sBAAsB,MAAM,OAAO;IAQ3C,MAAM,2BAA2C;KAC/C,QAAQ,WAAW,iBAAiB;KACpC,OAAO,OAAO,WAAW,aAAa,CAAC,KAAK,OAAO;MAAE,IAAI,EAAE;MAAI,KAAK,EAAE;MAAK,EAAE;KAC7E,WAAW,kBAAkB,cAAc,YAAY,gBAAgB,GAAG;KAI1E,MAAM,KAAK,mBAAmB,OAAQ,iBAA4B;KAClE;KACA;KACD;IAQD,MAAM,8BAID;AACH,SAAI,CAAC,OAAO,aACV,QAAO;MAAE,IAAI;MAAO,QAAQ;MAAa;KAE3C,MAAM,UAAU,OAAO,WAAW,aAAa;AAC/C,SAAI,QAAQ,WAAW,EACrB,QAAO;MAAE,IAAI;MAAO,QAAQ;MAAY;KAE1C,MAAM,aAAa,QAAQ,IAAI;AAC/B,SAAI,CAAC,WACH,QAAO;MAAE,IAAI;MAAO,QAAQ;MAAmB;KAEjD,MAAM,MAAM,sBAAsB,OAAO,cAAc,QAAQ,GAAG,UAChE,aAAa,YAAY,KAAK,KAAK,CAAC,CACrC;AACD,SAAI,QAAQ,KACV,QAAO;MAAE,IAAI;MAAO,QAAQ;MAAmB;AAEjD,YAAO;MAAE,IAAI;MAAM;MAAK;;AAG1B,eAAW,MAAM,kBAAkB,mBAAmB;KACpD,eAAe,KAAK;KACpB;KACD,CAAC;AAIF,eAAW,eAAe;AAI1B,eAAW,oBAAoB,UAA0B;AACvD,uBAAkB;AAClB,eAAU,mBAAmB;;AAW/B,oBAAgB,mBACd,OAAO,YACP,KAAA,GACA,WACM,UAAU,mBAAmB,QAC7B,UAAU,mBAAmB,CACpC;AAID,YAAQ,OAAO,MAAM,iDAAiD,SAAS,KAAK,KAAK;WACnF;GAiBR,MAAM,yBAAyB;AAC/B,SAAM,QAAQ,KAAK,CACjB,aACA,IAAI,SAAe,YAAY,WAAW,SAAS,uBAAuB,CAAC,CAC5E,CAAC;GAWF,IAAI;AACJ,OAAI,KAAK,mBAAmB,MAAM;IAChC,MAAM,qBAAqB,QAAQ,IAAI;AACvC,YAAQ,IAAI,sBAAsB,KAAK,UAAU;AACjD,QAAI;AACF,YAAO,MAAM,cAAc,YAAY,gBAAgB,EAAE,EAAE,OAAO,WAAW;cACrE;AAGR,SAAI,uBAAuB,KAAA,EAAW,QAAO,QAAQ,IAAI;SACpD,SAAQ,IAAI,sBAAsB;;SAGzC,QAAO,MAAM,cACX,YACA,aACA,EAAE,YAAY,KAAK,WAAW,EAC9B,OAAO,WACR;AAEH,OAAI,CAAC,KAAK,IAAI;AACZ,WAAO,MAAM;AACb,aAAS,KAAA;AAGT,mBAAe,MAAM;AACrB,oBAAgB,KAAA;AAIhB,UAAM,UAAU,OAAO;AACvB,eAAW,KAAA;AAOX,UAAM,IAAI,MACR,iHACD;;GAKH,MAAM,aAAa,MAAM,mBACvB,YACA,MACA,MACA,WACA,OAAO,WACR;AACD,OAAI,WAAW,SAAS;AACtB,WAAO,MAAM;AACb,aAAS,KAAA;AAGT,mBAAe,MAAM;AACrB,oBAAgB,KAAA;AAGhB,UAAM,UAAU,OAAO;AACvB,eAAW,KAAA;IAQX,MAAM,aAAa,KAAK,MAAM,YAAY,IAAK;AAC/C,UAAM,IAAI,MACR,wDAAwD,WAAW,kDACpE;;GAQH,MAAM,WAAW,WAAW,QACzB,QAAQ,MAA2C,EAAE,SAAS,OAAO,CACrE,KAAK,MAAM,EAAE,KAAK;AACrB,QAAK,YAAY,SAAS;GA0B1B,MAAM,qBAAqB;GAC3B,MAAM,4BAA4B;GAElC,IAAI;AACJ,QAAK,IAAI,UAAU,GAAG,WAAW,oBAAoB,UACnD,KAAI;AAEF,UAAM,OAAO,WAAW,eAAe;AACvC,sBAAkB,KAAA;AAClB;YACO,KAAK;AACZ,sBAAkB,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,IAAI,CAAC;AACrE,QAAI,UAAU,mBAIZ,OAAM,IAAI,SAAe,YAAY,WAAW,SAAS,0BAA0B,CAAC;;AAK1F,OAAI,oBAAoB,KAAA,GAAW;AACjC,WAAO,MAAM;AACb,aAAS,KAAA;AAGT,mBAAe,MAAM;AACrB,oBAAgB,KAAA;AAChB,UAAM,UAAU,OAAO;AACvB,eAAW,KAAA;AAGX,UAAM,IAAI,MACR,iEAAiE,mBAAmB,aAAa,KAAK,MAAO,qBAAqB,4BAA6B,IAAK,CAAC,kGACtK;;AASH,SAAM,qBAAqB,OAAO,WAAW;AAK7C,OAAI,KAAK,SAAS,KAAA,EAChB,OAAM,cAAc,OAAO,YAAY,EAAE,cAAc,KAAK,MAAM,CAAC;AAQrE,OAAI,KAAK,iBAAiB,KACxB,OAAM,cAAc,OAAO,YAAY,EAAE,uBAAuB,MAAM,CAAC;AAQzE,OAAI,KAAK,WAAW,KAAA,KAAa,KAAK,SAAS,EAC7C,OAAM,cAAc,OAAO,YAAY,EAAE,iBAAiB,KAAK,QAAQ,CAAC;AAS1E,OAAI,KAAK,iBAAiB,KAAA,KAAa,KAAK,eAAe,EACzD,OAAM,cAAc,OAAO,YAAY,EAAE,wBAAwB,KAAK,cAAc,CAAC;AAGvF,UAAO,OAAO;;EAKhB,eAAe,MAAoC;AACjD,WAAQ;AACR,aAAU,mBAAmB;;EAQ/B,eAAe,MAAmE;AAChF,kBAAe;AACf,aAAU,mBAAmB;;EAG/B,MAAM,MAAM,YAA0C;AAYpD,OAAI,UAAU,YAAY;AACxB,YAAQ;AACR,cAAU,mBAAmB;;AAE/B,OAAI;IACF,MAAM,EAAE,yBAAyB,MAAM,OAAO,uBAAA,MAAA,MAAA,EAAA,EAAA;AAC9C,UAAM,qBAAqB,YAAY,EAAE,OAAO,gBAAgB,CAAC;WAC3D;AAMR,WAAQ,MAAM;AACd,YAAS,KAAA;AAGT,kBAAe,MAAM;AACrB,mBAAgB,KAAA;AAGhB,SAAM,UAAU,OAAO;AACvB,cAAW,KAAA;;EAEd;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC5nBH,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;ACLxC,MAAM,sBAAsB;;;;;;;;;;;;AAa5B,MAAa,2BAA2B,CACtC,mBACA,sBACD;;;;;AAMD,SAAgB,yBAAyB,SAA0B;AACjE,QAAO,yBAAyB,MAAM,WAAW,QAAQ,SAAS,OAAO,CAAC;;AA6B5E,SAAS,SAAS,OAAkD;AAClE,QAAO,OAAO,UAAU,YAAY,UAAU;;AAGhD,SAAS,aAAa,KAAuC;CAC3D,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;SAClB;AACN,SAAO;;AAET,KAAI,CAAC,SAAS,OAAO,CAAE,QAAO;CAC9B,MAAM,UAA6B,EAAE;AACrC,KAAI,OAAO,OAAO,OAAO,SAAU,SAAQ,KAAK,OAAO;AACvD,KAAI,OAAO,OAAO,WAAW,SAAU,SAAQ,SAAS,OAAO;AAC/D,KAAI,YAAY,OAAQ,SAAQ,SAAS,OAAO;AAChD,KAAI,YAAY,OAAQ,SAAQ,SAAS,OAAO;AAChD,KAAI,SAAS,OAAO,MAAM,IAAI,OAAO,OAAO,MAAM,YAAY,SAC5D,SAAQ,QAAQ,EAAE,SAAS,OAAO,MAAM,SAAS;AAEnD,QAAO;;AAGT,MAAM,iBAA0C;CAC9C;CACA;CACA;CACD;;;;;;;;;;;;;;;AAgBD,MAAM,wBAAwB;;AAwB9B,MAAM,6BAA6B;;;;;AAMnC,IAAa,oBAAb,MAAwD;;CAEtD,OAAgB;CAEhB;CACA;CACA;CACA;CACA,UAA2B,IAAI,cAAc;CAC7C,0BAA2B,IAAI,KAA8B;CAC7D,0BAA2B,IAAI,KAAwB;CAEvD,KAA+B;CAC/B,kBAAiE;CACjE,gBAAwB;;;;;;CAMxB,iBAAwC;;CAExC,kBAAgD;;CAEhD,0BAA2B,IAAI,KAG5B;;;;;CAMH,sBAA6C;;;;;CAM7C,mCAAoC,IAAI,KAAqB;;CAG7D,kBAAiE;;CAGjE,qBAAoF,EAAE;CAEtF,YAAY,SAAmC;AAC7C,OAAK,eAAe,QAAQ,aAAa,QAAQ,OAAO,GAAG;AAC3D,OAAK,aAAa,QAAQ,cAAc;AACxC,OAAK,aAAa,QAAQ;EAC1B,MAAM,QAAQ,QAAQ,IAAI,6BACtB,OAAO,QAAQ,IAAI,2BAA2B,GAC9C,KAAA;AACJ,OAAK,oBACF,UAAU,KAAA,KAAa,OAAO,SAAS,MAAM,IAAI,QAAQ,IAAI,QAAQ,KAAA,MACtE,QAAQ,oBACR;AACF,OAAK,MAAM,SAAS,eAAgB,MAAK,QAAQ,IAAI,OAAO,EAAE,CAAC;AAG/D,OAAK,QAAQ,IAAI,2BAA2B,EAAE,CAAC;AAG/C,OAAK,QAAQ,gBAAgB,EAAE;;;CAIjC,MAAM,iBAAuC;EAO3C,IAAI,aAAa,GAAG,KAAK,aAAa;AACtC,MAAI,KAAK,YAAY;GACnB,MAAM,OAAO,aAAa,KAAK,WAAW;AAC1C,iBAAc,OAAO,mBAAmB,KAAK;;EAE/C,MAAM,MAAM,MAAM,MAAM,WAAW;AACnC,MAAI,CAAC,IAAI,GACP,OAAM,IAAI,MAAM,qCAAqC,IAAI,OAAO,GAAG,IAAI,aAAa;EAEtF,MAAM,OAAgB,MAAM,IAAI,MAAM;EACtC,MAAM,OAAO,SAAS,KAAK,IAAI,MAAM,QAAQ,KAAK,QAAQ,GAAG,KAAK,UAAU,EAAE;EAM9E,IAAI,iBAAgC;AACpC,OAAK,MAAM,QAAQ,MAAM;AACvB,OAAI,CAAC,SAAS,KAAK,IAAI,OAAO,KAAK,OAAO,SAAU;AACpD,oBAAiB,KAAK;;AAIxB,MACE,mBAAmB,QACnB,KAAK,mBAAmB,QACxB,mBAAmB,KAAK,gBACxB;GACA,MAAM,SAAS,KAAK;AAGpB,WAAQ,iBAAiB,EAAE,cAAc,QAAQ,CAAC;AAClD,QAAK,YAAY,OAAO;;AAI1B,OAAK,QAAQ,OAAO;AACpB,OAAK,MAAM,QAAQ,MAAM;AACvB,OAAI,CAAC,SAAS,KAAK,IAAI,OAAO,KAAK,OAAO,SAAU;AAEpD,OAAI,KAAK,OAAO,eAAgB;AAChC,QAAK,QAAQ,IAAI,KAAK,IAAI;IACxB,IAAI,KAAK;IACT,OAAO,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ;IACrD,KAAK,OAAO,KAAK,QAAQ,WAAW,KAAK,MAAM;IAChD,CAAC;;AAGJ,MAAI,mBAAmB,KACrB,MAAK,iBAAiB;MAEtB,MAAK,iBAAiB;EAGxB,MAAM,SAAS,CAAC,GAAG,KAAK,QAAQ,QAAQ,CAAC;AAIzC,MAAI,mBAAmB,KACrB,MAAK,QAAQ,KAAK,mBAAmB,OAAO;AAG9C,SAAO;;CAGT,cAA2B;AACzB,SAAO,CAAC,GAAG,KAAK,QAAQ,QAAQ,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BnC,mBACE,UACA,YAAY,KACZ,iBAAiB,KACK;EAEtB,MAAM,UAAU,KAAK,aAAa;AAClC,MAAI,SAAS,QAAQ,CAAE,QAAO,QAAQ,QAAQ,QAAQ;AAEtD,SAAO,IAAI,SAAsB,SAAS,WAAW;GACnD,IAAI,UAAU;GACd,IAAI,aAAoD;GAExD,MAAM,UAAU,YAA+B;AAC7C,QAAI,QAAS;AACb,cAAU;AACV,QAAI,kBAAkB,KAAM,cAAa,cAAc;AACvD,QAAI,eAAe,MAAM;AACvB,mBAAc,WAAW;AACzB,kBAAa;;AAEf,SAAK,QAAQ,IAAI,mBAAmB,SAAS;AAC7C,YAAQ,QAAQ;;GAGlB,MAAM,YAAY,YAA+B;AAC/C,QAAI,SAAS,QAAQ,CAAE,QAAO,QAAQ;;GAMxC,MAAM,gBAAsD,OAAO,SAAS,UAAU,GAClF,iBAAiB;AACf,QAAI,QAAS;AACb,cAAU;AACV,QAAI,eAAe,MAAM;AACvB,mBAAc,WAAW;AACzB,kBAAa;;AAEf,SAAK,QAAQ,IAAI,mBAAmB,SAAS;AAC7C,2BACE,IAAI,MACF,6BAA6B,UAAU,iCACxC,CACF;MACA,UAAU,GACb;AAGJ,QAAK,QAAQ,GAAG,mBAAmB,SAAS;AAM5C,gBAAa,kBAAkB;AAE7B,SAAK,gBAAgB,CAAC,MACnB,YAAY;AACX,SAAI,SAAS,QAAQ,CAAE,QAAO,QAAQ;aAElC,GAGP;MACA,eAAe;IAClB;;;;;;CAOJ,yBAAwC;AACtC,SAAO,KAAK;;;;;;CAOd,oBAAoB,UAAiC;AACnD,SAAO,KAAK,iBAAiB,IAAI,SAAS,IAAI;;;CAIhD,YAAY,UAA6D;AACvE,OAAK,mBAAmB,KAAK,SAAS;AACtC,eAAa;GACX,MAAM,MAAM,KAAK,mBAAmB,QAAQ,SAAS;AACrD,OAAI,QAAQ,GAAI,MAAK,mBAAmB,OAAO,KAAK,EAAE;;;;;;;CAQ1D,MAAM,gBAA+B;AACnC,MAAI,KAAK,MAAM,KAAK,GAAG,eAAe,UAAU,KAAM;AAGtD,MAAI,KAAK,gBAAiB,QAAO,KAAK;AACtC,OAAK,kBAAkB,KAAK,kBAAkB,CAAC,cAAc;AAC3D,QAAK,kBAAkB;IACvB;AACF,SAAO,KAAK;;CAGd,MAAc,mBAAkC;EAE9C,MAAM,UADU,MAAM,KAAK,gBAAgB,EACpB;AACvB,MAAI,CAAC,OACH,OAAM,IAAI,MAAM,mDAAmD;EAKrE,IAAI,YAAY,GAFD,KAAK,aAAa,QAAQ,SAAS,KAAK,CAE7B,UADT,gBAAgB,KAAK,KAAK,GACE,UAAU,mBAAmB,OAAO,GAAG;AAOpF,MAAI,KAAK,YAAY;GACnB,MAAM,OAAO,aAAa,KAAK,WAAW;AAC1C,gBAAa,OAAO,mBAAmB,KAAK;;EAE9C,MAAM,KAAK,IAAI,UAAU,UAAU;AACnC,OAAK,KAAK;AAEV,QAAM,IAAI,SAAe,SAAS,WAAW;AAC3C,MAAG,KAAK,cAAc,SAAS,CAAC;AAChC,MAAG,KAAK,UAAU,QAAe,OAAO,IAAI,CAAC;AAO7C,MAAG,KAAK,UAAU,SAAiB;AACjC,QAAI,SAAA,KACF,wBACE,IAAI,MACF,4EACD,CACF;KAEH;IACF;AAGF,OAAK,sBAAsB;AAC3B,OAAK,iBAAiB,OAAO;AAG7B,OAAK,kBAAkB;AACvB,KAAG,GAAG,YAAY,SAA4B,KAAK,cAAc,KAAK,UAAU,CAAC,CAAC;AAKlF,KAAG,GAAG,UAAU,SACd,KAAK,iBACH,SAAA,OACI,gDACA,4BACL,CACF;AACD,KAAG,GAAG,UAAU,QAAe,KAAK,iBAAiB,uBAAuB,IAAI,UAAU,CAAC;AAE3F,OAAK,kBAAkB,iBAAiB;AACxC,OAAK,kBAAkB,iBAAiB;AAGxC,OAAK,kBAAkB,aAAa;AACpC,OAAK,kBAAkB,cAAc;AAIrC,OAAK,kBAAkB,mBAAmB;AAC1C,OAAK,kBAAkB,6BAA6B,EAAE,UAAU,MAAM,CAAC;AAGvE,OAAK,eAAe,OAAO,GAAG;;;CAIhC,kBAA0B,QAAgB,SAAkC,EAAE,EAAQ;AACpF,MAAI,CAAC,KAAK,MAAM,KAAK,GAAG,eAAe,UAAU,KAAM;EACvD,MAAM,KAAK,KAAK;AAChB,OAAK,GAAG,KAAK,KAAK,UAAU;GAAE;GAAI;GAAQ;GAAQ,CAAC,CAAC;;;;;;;;;;CAWtD,KACE,QACA,QACA,MACqC;AACrC,SAAO,KAAK,YAAY,QAAS,UAAU,EAAE,EAA8B,KAAK;;;;;;;;;;;;;;;;;;;;;CAwBlF,YACE,QACA,SAAkC,EAAE,EACpC,MACkB;AAElB,MAAI,KAAK,oBAAoB,eAC3B,QAAO,QAAQ,uBACb,IAAI,MACF,wBAAwB,OAAO,yDAChC,CACF;AAEH,MAAI,CAAC,KAAK,MAAM,KAAK,GAAG,eAAe,UAAU,KAC/C,QAAO,QAAQ,uBACb,IAAI,MAAM,+EAA+E,CAC1F;EAEH,MAAM,KAAK,KAAK;EAChB,MAAM,KAAK,KAAK;EAChB,MAAM,YACJ,MAAM,cAAc,KAAA,KAAa,OAAO,SAAS,KAAK,UAAU,IAAI,KAAK,YAAY,IACjF,KAAK,YACL,KAAK;AACX,SAAO,IAAI,SAAkB,SAAS,WAAW;GAC/C,MAAM,SAAS,iBAAiB;AAC9B,SAAK,QAAQ,OAAO,GAAG;AACvB,2BACE,IAAI,MACF,qBAAqB,OAAO,IAAI,UAAU,iFAG3C,CACF;MACA,UAAU;AACb,QAAK,QAAQ,IAAI,IAAI;IACnB,UAAU,MAAM;AACd,kBAAa,OAAO;AACpB,aAAQ,EAAE;;IAEZ,SAAS,MAAM;AACb,kBAAa,OAAO;AACpB,YAAO,EAAE;;IAEZ,CAAC;AACF,MAAG,KAAK,KAAK,UAAU;IAAE;IAAI;IAAQ;IAAQ,CAAC,CAAC;IAC/C;;;;;;;CAQJ,iBAAyB,QAAsB;AAC7C,MAAI,KAAK,oBAAoB,eAAgB;AAC7C,OAAK,kBAAkB;AACvB,OAAK,KAAK;AACV,OAAK,eAAe;EACpB,MAAM,sBAAM,IAAI,MACd,GAAG,OAAO,wDACX;AACD,OAAK,MAAM,UAAU,KAAK,QAAQ,QAAQ,CACxC,QAAO,OAAO,IAAI;AAEpB,OAAK,QAAQ,OAAO;;;;;;;;;;CAWtB,YAAoB,UAAwB;EAC1C,MAAM,8BAAa,IAAI,MAAM,EAAC,aAAa;AAC3C,OAAK,QAAQ,OAAO,SAAS;AAC7B,OAAK,iBAAiB,OAAO,SAAS;EAEtC,MAAM,sBAAM,IAAI,MACd,+EAA+E,SAAS,qCAEzF;AACD,OAAK,MAAM,UAAU,KAAK,QAAQ,QAAQ,CACxC,QAAO,OAAO,IAAI;AAEpB,OAAK,QAAQ,OAAO;EAEpB,MAAM,QAA8B;GAAE,MAAM;GAAY;GAAU;GAAY;AAC9E,OAAK,MAAM,YAAY,KAAK,mBAC1B,KAAI;AACF,YAAS,MAAM;UACT;;;;;;;;;;;CAeZ,iBAAyB,MAAoC,UAA+B;EAC1F,MAAM,8BAAa,IAAI,MAAM,EAAC,aAAa;AAC3C,OAAK,sBAAsB,KAAK,KAAK;AAGrC,MAAI,aAAa,MAAM;AACrB,QAAK,QAAQ,OAAO,SAAS;AAC7B,QAAK,iBAAiB,OAAO,SAAS;AAEtC,OAAI,KAAK,mBAAmB,SAC1B,MAAK,iBAAiB;SAEnB;AAEL,QAAK,QAAQ,OAAO;AACpB,QAAK,iBAAiB,OAAO;AAC7B,QAAK,iBAAiB;;EAUxB,MAAM,sBAAM,IAAI,MACd,eANA,SAAS,YACL,yCACA,SAAS,cACP,uCACA,4CAEe,iFAEtB;AACD,OAAK,MAAM,UAAU,KAAK,QAAQ,QAAQ,CACxC,QAAO,OAAO,IAAI;AAEpB,OAAK,QAAQ,OAAO;EAGpB,MAAM,QAA8B;GAAE;GAAM;GAAU;GAAY;AAClE,OAAK,MAAM,YAAY,KAAK,mBAC1B,KAAI;AACF,YAAS,MAAM;UACT;;;;;;;;;;;;;;;;CAoBZ,eAAuB,iBAA+B;AACpD,OAAK,eAAe;EAEpB,MAAM,QAAQ,QAAQ,IAAI,uBACtB,OAAO,QAAQ,IAAI,qBAAqB,GACxC,KAAA;AACJ,MAAI,UAAU,KAAA,KAAa,CAAC,OAAO,SAAS,MAAM,IAAI,SAAS,EAAG;EAElE,MAAM,kBAAkB;AAExB,OAAK,kBAAkB,kBAAkB;GAEvC,MAAM,YAAY,KAAK,QAAQ,OAAO,IAAI,CAAC,GAAG,KAAK,QAAQ,MAAM,CAAC,GAAG,CAAC,gBAAgB;AACtF,QAAK,MAAM,YAAY,WAAW;IAEhC,MAAM,cAAc,KAAK,YAAY,oBAAoB;KACvD,YAAY;KACZ,eAAe;KACf,SAAS;KACV,CAAC;IACF,MAAM,iBAAiB,IAAI,SAAgB,GAAG,WAC5C,iBACQ,uBAAO,IAAI,MAAM,oBAAoB,CAAC,EAC5C,kBAAkB,IACnB,CACF;AACD,YAAQ,KAAK,CAAC,aAAa,eAAe,CAAC,CAAC,YAAY;AAEtD,SAAI,KAAK,QAAQ,IAAI,SAAS,CAC5B,MAAK,iBAAiB,aAAa,SAAS;MAE9C;;KAEH,MAAM;;CAGX,gBAA8B;AAC5B,MAAI,KAAK,oBAAoB,MAAM;AACjC,iBAAc,KAAK,gBAAgB;AACnC,QAAK,kBAAkB;;;CAI3B,cAAsB,KAAmB;EACvC,MAAM,UAAU,aAAa,IAAI;AACjC,MAAI,CAAC,QAAS;AAGd,MAAI,OAAO,QAAQ,OAAO,YAAY,KAAK,QAAQ,IAAI,QAAQ,GAAG,EAAE;GAClE,MAAM,SAAS,KAAK,QAAQ,IAAI,QAAQ,GAAG;AAC3C,QAAK,QAAQ,OAAO,QAAQ,GAAG;AAC/B,OAAI,OACF,KAAI,QAAQ,MAAO,QAAO,OAAO,IAAI,MAAM,QAAQ,MAAM,QAAQ,CAAC;OAC7D,QAAO,QAAQ,QAAQ,OAAO;AAErC;;EAQF,MAAM,MAAM,KAAK,KAAK;EACtB,IAAI,mBAAmB;AACvB,OAAK,MAAM,YAAY,KAAK,QAAQ,MAAM,EAAE;AAC1C,OAAI,CAAC,KAAK,iBAAiB,IAAI,SAAS,CACtC,oBAAmB;AAErB,QAAK,iBAAiB,IAAI,UAAU,IAAI;;AAE1C,MAAI,oBAAoB,KAAK,QAAQ,OAAO,EAC1C,MAAK,QAAQ,KAAK,mBAAmB,CAAC,GAAG,KAAK,QAAQ,QAAQ,CAAC,CAAC;AAGlE,MAAI,OAAO,QAAQ,WAAW,SAAU;AAMxC,MAAI,QAAQ,WAAW,2BAA2B;AAChD,QAAK,iBAAiB,WAAW,KAAK;AACtC;;AAIF,MAAI,QAAQ,WAAW,0BAA0B;GAC/C,MAAM,WACJ,SAAS,QAAQ,OAAO,IAAI,OAAO,QAAQ,OAAO,aAAa,WAC3D,QAAQ,OAAO,WACf;AACN,QAAK,iBAAiB,aAAa,SAAS;AAC5C;;AAIF,MAAI,QAAQ,WAAW,6BAA6B;GAClD,MAAM,WACJ,SAAS,QAAQ,OAAO,IAAI,OAAO,QAAQ,OAAO,aAAa,WAC3D,QAAQ,OAAO,WACf;AACN,QAAK,iBAAiB,YAAY,SAAS;AAC3C;;AAIF,MAAI,CAAC,KAAK,QAAQ,IAAI,QAAQ,OAAuB,CAAE;EACvD,MAAM,QAAQ,QAAQ;EACtB,MAAM,SAAS,KAAK,QAAQ,IAAI,MAAM;AACtC,MAAI,CAAC,OAAQ;AACb,SAAO,KAAK,QAAQ,OAAO;EAG3B,MAAM,MAAM,UAAU,4BAA4B,wBAAwB,KAAK;AAC/E,MAAI,OAAO,SAAS,IAAK,QAAO,OAAO;AACvC,OAAK,QAAQ,KAAK,OAAO,QAAQ,OAAO;;CAG1C,kBAA0C,OAAyC;AAEjF,SADe,KAAK,QAAQ,IAAI,MAAM,IACpB,EAAE;;CAGtB,GAA2B,OAAU,UAAyD;AAC5F,OAAK,QAAQ,GAAG,OAAO,SAAuC;AAC9D,eAAa,KAAK,QAAQ,IAAI,OAAO,SAAuC;;;CAI9E,QAAc;EACZ,MAAM,KAAK,KAAK;AAChB,OAAK,eAAe;AAGpB,OAAK,iBAAiB,+BAA+B;AACrD,MAAI,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACrwBf,MAAM,cAAc;;;;;;;AAQpB,MAAM,sBAAsB;CAC1B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;;;;;;AAOD,MAAM,oBAAoB,IAAI,OAAO,IAAI,YAAY,QAAQ,uBAAuB,OAAO,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgE9F,SAAgB,iCAAyC;AACvD,QAAO;yDACgD,KAAK,UAAU,mBAAmB,CAAC,CAAC;wDACrC,KAAK,UAAU,mBAAmB,CAAC,CAAC;;;;;;;;;;;;;AAc5F,SAAS,oBAAoC;AAC3C,QAAO;EACL,MAAM;EACN,MAAM,OAAO;AAEX,SAAM,UAAU,EAAE,QAAQ,mBAAmB,GAAG,UAAU;IACxD,MAAM,KAAK;IACX,WAAW;IACZ,EAAE;AAEH,SAAM,OAAO;IAAE,QAAQ;IAAM,WAAW;IAAgB,SAAS;IAC/D,UAAU,gCAAgC;IAC1C,QAAQ;IAMR,YAAY,QAAQ,KAAK;IAC1B,EAAE;;EAEN;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BH,SAAS,uBAAuC;AAC9C,QAAO;EACL,MAAM;EACN,MAAM,OAAO;AACX,SAAM,UAAU,EAAE,QAAQ,YAAY,SAAS;IAC7C,MAAM;IACN,WAAW;IACZ,EAAE;AAEH,SAAM,OAAO;IAAE,QAAQ;IAAY,WAAW;IAAmB,QAAQ;AAQvE,WAAO;KACL,UAAU,mEARI,oBAAoB,KACjC,SACC,kCAAkC,KAAK,UAAU,KAAK,CAAC,4DAA4D,KAAK,UAAU,KAAK,CAAC,UAC3I,CAAC,KAAK,KAAK,CAK2E;KACrF,QAAQ;KACT;KACD;;EAEL;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BH,SAAS,kBAAkB,SAAiC;CAC1D,MAAM,YAAY;AAClB,QAAO;EACL,MAAM;EACN,MAAM,OAAO;AAEX,SAAM,UAAU,EAAE,QAAQ,uBAAuB,SAAS;IACxD,MAAM;IACN,WAAW;IACZ,EAAE;AAGH,SAAM,OAAO;IAAE,QAAQ;IAAM,WAAW;IAAW,EAAE,OAAO,SAAS;IAEnE,MAAM,SADS,MAAM,GAAG,SAAS,KAAK,MAAM,OAAO,EAC9B,MAAM,KAAK;IAEhC,MAAM,gBAA0B,EAAE;IAClC,MAAM,YAAsB,EAAE;IAK9B,MAAM,wBACJ;IAGF,MAAM,iBAAiB,YACrB,QAAQ,WAAW,UAAU,IAC7B,QAAQ,WAAW,UAAU,IAC7B,QAAQ,WAAW,UAAU,IAC7B,QAAQ,WAAW,WAAU;IAQ/B,MAAM,iBAAiB,YACrB,gBAAgB,KAAK,QAAQ,QAAQ,WAAW,GAAG,CAAC,SAAS,CAAC;IAIhE,IAAI,gBAAgB;AAEpB,SAAK,MAAM,QAAQ,OAAO;KACxB,MAAM,UAAU,KAAK,WAAW;KAChC,MAAM,SAAS,KAAK,MAAM,GAAG,KAAK,SAAS,QAAQ,OAAO;AAK1D,SAAI,eAAe;AACjB,oBAAc,KAAK,KAAK;AACxB,UAAI,cAAc,QAAQ,CACxB,iBAAgB;AAElB;;AAOF,SAAI,cAAc,QAAQ,EAAE;AAC1B,oBAAc,KAAK,KAAK;AACxB,UAAI,CAAC,cAAc,QAAQ,CACzB,iBAAgB;gBAET,QAAQ,WAAW,UAAU,CAItC,KADU,QAAQ,MAAM,sBAAsB,CAK5C,WAAU,KAAK,SAAS,QAAQ,MAAM,EAAiB,CAAC;UACnD;AAGL,oBAAc,KAAK,KAAK;AACxB,UAAI,WAAW,KAAK,QAAQ,GAAG,CAAC,cAAc,QAAQ,GAAG,QAAQ,SAAS,IAAI,CAC5E,iBAAgB;;SAIpB,WAAU,KAAK,KAAK;;AAaxB,WAAO;KACL,UAVqB;MACrB,GAAG;MACH;MACA;MACA;MACA,GAAG,UAAU,KAAK,MAAM,KAAK,IAAI;MACjC;MACD,CAAC,KAAK,KAAK;KAIV,QAAQ;KACR,YAAYA,OAAK,QAAQ,QAAQ;KAClC;KACD;;EAEL;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCH,SAAS,sBAAsB,YAA4B;CACzD,MAAM,WAAWA,OAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC;CAI7D,MAAM,kBAA8B;EAClC,CAAC,GAAG,WAAW,KAAK;EACpB,CAAC,eAAe,GAAG,WAAW,KAAK;EACnC,CAAC,GAAG,WAAW,KAAK;EACpB,CAAC,eAAe,GAAG,WAAW,KAAK;EACpC;CAID,IAAI,MAAM;AACV,MAAK,IAAI,IAAI,GAAG,IAAI,IAAI,KAAK;AAC3B,OAAK,MAAM,QAAQ,iBAAiB;GAClC,MAAM,YAAYA,OAAK,KAAK,KAAK,GAAG,KAAK;AACzC,OAAI;AACF,eAAW,UAAU;AACrB,WAAO;WACD;;EAIV,MAAM,SAASA,OAAK,QAAQ,IAAI;AAChC,MAAI,WAAW,IAAK;AACpB,QAAM;;AAIR,QAAOA,OAAK,KAAK,UAAU,GAAG,WAAW,KAAK;;;AAIhD,SAAS,iBAAyB;AAChC,QAAO,sBAAsB,UAAU;;;;;;;AAQzC,SAAS,oBAA4B;AACnC,QAAO,sBAAsB,cAAc;;;;;;;AAQ7C,SAAS,oBAA4B;AACnC,QAAO,sBAAsB,cAAc;;;;;;;;;;;;;;;;AAiB7C,eAAsB,eAAe,SAAiB,MAA6C;CACjG,MAAM,aAAa,MAAM,cAAc;CACvC,MAAM,iBAAiB,MAAM,kBAAkB,EAAE;CAGjD,MAAM,UAAU,MAAM,OAAO;CAC7B,MAAM,cAAc,gBAAgB;CAKpC,MAAM,iBAAiB;EACrB,iCAAiC,KAAK,UAAU,YAAY,CAAC;EAC7D;EACA;EACD,CAAC,KAAK,KAAK;CAEZ,MAAM,SAAS,MAAM,QAAQ,MAAM;EACjC,OAAO;GACL,UAAU;GACV,QAAQ;GAKR,YAAYA,OAAK,QAAQ,QAAQ;GAClC;EACD,QAAQ;EACR,QAAQ;EACR;EACA,UAAU;EACV,QAAQ;EACR,OAAO;EACP,SAAS;GAAC,kBAAkB,QAAQ;GAAE,sBAAsB;GAAE,mBAAmB;GAAC;EAClF,UAAU;EACV,aAAa;EAQb,QAAQ,EACN,IAAI,cAAc,KAAK,UAAU,WAAW,CAAC,MAAM,WAAW,IAC/D;EACF,CAAC;CAEF,MAAM,WAAW,OAAO,SAAS,KAC9B,MACC,GAAGA,OAAK,SAAS,QAAQ,KAAK,EAAE,EAAE,UAAU,QAAQ,GAAG,CAAC,GAAG,EAAE,UAAU,QAAQ,IAAI,IAAI,EAAE,OAC5F;CAED,MAAM,aAAa,OAAO,cAAc;AACxC,KAAI,CAAC,WACH,OAAM,IAAI,MAAM,iEAAiE;AAGnF,QAAO;EAAE,MAAM,WAAW;EAAM;EAAU;;;;;ACthB5C,MAAM,iBAAiB;;;;;;;;;;;;;;;;AAiBvB,SAAgB,kBAAkB,KAAwD;CACxF,MAAM,MAAwB,EAAE;AAChC,MAAK,MAAM,EAAE,UAAU,KAAK;AAG1B,MAAI,CAAC,KAAK,WAAW,eAAe,CAAE;EAEtC,MAAM,OAAO,KAAK,MAAM,GAAsB;EAC9C,MAAM,WAAW,KAAK,QAAQ,IAAI;AAElC,MAAI,aAAa,GAAI;EAErB,MAAM,WAAW,KAAK,MAAM,GAAG,SAAS;EACxC,MAAM,OAAO,KAAK,MAAM,WAAW,EAAE;AACrC,MAAI,aAAa,MAAM,SAAS,GAAI;AAIpC,MAAI;AACF,QAAK,MAAM,KAAK;UACV;AACN;;AAGF,MAAI,KAAK;GAAE;GAAU;GAAM,CAAC;;AAE9B,QAAO;;;;;ACpET,MAAM,qBAAqB;;;;;;;;AAS3B,MAAM,+BAA+B;;;;;;;;;;;;;AAcrC,SAAgB,wBAAwB,YAA4B;AAIlE,QACE,yBAEW,WAAW;;;;;;;;;;AAgC1B,SAAgB,oBAAoB,UAAiC;AACnE,KAAI,OAAO,aAAa,SACtB,OAAM,IAAI,MACR,oDAAoD,OAAO,SAAS,0BACrE;CAEH,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,SAAS;SACvB;AAEN,QAAM,IAAI,MAAM,2DAA2D;;AAE7E,KAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,OAAO,CACxE,OAAM,IAAI,MAAM,0DAA0D;CAE5E,MAAM,MAAM;AACZ,KAAI,IAAI,OAAO,KACb,QAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAoB;AAErD,KAAI,IAAI,OAAO,MACb,QAAO;EACL,IAAI;EACJ,OAAO,OAAO,IAAI,UAAU,WAAW,IAAI,QAAQ,OAAO,IAAI,MAAM;EACrE;AAEH,OAAM,IAAI,MAAM,uDAAqD;;;;;;;;;;;;;;;;;;AAmBvE,eAAsB,mBACpB,YACA,YACA,YAAY,oBACW;CACvB,MAAM,aAAa,wBAAwB,WAAW;CAetD,MAAM,mBAAmB,OAAO,UAAU;CAC1C,MAAM,iBAAiB,IAAI,SAAkC,YAC3D,iBAAiB,QAAQ,iBAAiB,EAAE,UAAU,CACvD;CASD,MAAM,cAAc,WAAW,KAC7B,oBACA;EACE;EACA,eAAe;EACf,cAAc;EACf,EACD,EAAE,WAAW,YAAY,8BAA8B,CACxD;CAED,MAAM,aAAa,MAAM,QAAQ,KAAK,CACpC,YAAY,MAAM,OAAO;EAAE,KAAK;EAAiB;EAAG,EAAE,EACtD,eAAe,YAAY,EAAE,KAAK,WAAoB,EAAE,CACzD,CAAC;AAEF,KAAI,WAAW,QAAQ,UACrB,QAAO;EAAE,IAAI;EAAO,OAAO,iCAAiC,UAAU;EAAK;CAG7E,MAAM,YAAY,WAAW;AAE7B,KAAI,UAAU,kBAAkB;EAE9B,MAAM,MACJ,UAAU,iBAAiB,WAAW,eACtC,UAAU,iBAAiB,QAC3B;AACF,QAAM,IAAI,MAAM,2BAA2B,MAAM;;AAGnD,QAAO,oBAAoB,UAAU,OAAO,MAAM;;;;;;;;;;ACxJpD,MAAa,yBAAyB,IAAI;;;;;;;;AAyK1C,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;AAuBvC,eAAsB,sBACpB,YACA,OACA,MACyB;CACzB,MAAM,YAAY,KAAK,KAAK;CAC5B,MAAM,YAAY,IAAI,KAAK,UAAU,CAAC,aAAa;CACnD,MAAM,cAA4B,EAAE;CAcpC,IAAI,iBAAiB;AACrB,KAAI;AACF,QAAM,WAAW,eAAe;AAChC,mBAAiB;UACV,GAAG;AACV,UAAQ,OAAO,MACb,mFACE,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,CAC3C,KACF;;CAcH,MAAM,kBAAkB,MAAM,oBAAoB;CAClD,MAAM,cAAuC,EAAE;CAC/C,IAAI;AACJ,KAAI,mBAAmB,eACrB,sBAAqB,WAAW,GAAG,6BAA6B,UAAU;AACxE,cAAY,KAAK,MAAM;GACvB;CAUJ,IAAI,wBAAwB;;;;;;;;;;;;;;;;CAiB5B,MAAM,mBAAmB,OAAO,YAA2D;AACzF,UAAQ,OAAO,MACb,YAAY,cACR,kFACA,yEACL;AACD,MAAI;AACF,SAAM,WAAW,eAAe;AAChC,WAAQ,OAAO,MAAM,sCAAsC;WACpD,GAAG;AACV,WAAQ,OAAO,MACb,mCACE,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,CAC3C,4CACF;;;CAOL,MAAM,cAAc,MAAM;CAC1B,MAAM,cAAc,aAAa,QAAQ;CACzC,IAAI,cAAc;CAGlB,MAAM,oBAAoB,MAAM;CAWhC,IAAI;AACJ,KAAI,MAAM,SAAS,EAEjB,wBAAuB,MAAM,uBAC3B,YACA,iCAHoB,MAAM,qBAAqB,MAKhD;CAMH,MAAM,SAAS,MAAM;CACrB,IAAI,eAAe;AAEnB,KAAI;AACF,OAAK,MAAM,QAAQ,OAAO;AACxB,OAAI,uBAAuB;AACzB,UAAM,iBAAiB,YAAY;AACnC,4BAAwB;;AAM1B,OAAI,WAAW,KAAA,KAAa,SAAS,KAAK,aACxC,OAAM,IAAI,SAAe,YAAY,WAAW,SAAS,OAAO,CAAC;AAEnE,kBAAe;GAEf,MAAM,YAAY,mBAAmB,IAAI,KAAK,KAAK;GAKnD,MAAM,WAAW,CAAC,aAAa,aAAa,IAAI,KAAK,KAAK;AAC1D,OAAI,UAAU;AACZ,mBAAe;AACf,UAAM,eAAe,MAAM,aAAa,YAAY;;GAKtD,MAAM,gBAAgB,WAAW,yBAAyB,MAAM;GAEhE,IAAI;AACJ,OAAI;IACF,MAAM,EAAE,SAAS,MAAM,eAAe,MAAM,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;IAqBhE,MAAM,UAAU,YAAwC;KACtD,IAAI;AACJ,SAAI;AACF,kBAAY,MAAM,mBAAmB,YAAY,MAAM,cAAc;cAC9D,GAAG;AAMV,aAAO;OACL;OACA,QAAQ,EAAE,OAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,EAAE;OAC9D;;AAGH,SAAI,UAAU,GACZ,QAAO;MAAE;MAAM,QAAQ,UAAU;MAAQ;AAI3C,SAAI,UAAU,MAAM,SAAA,gCAAiC,CACnD,QAAO;AAET,YAAO;MAAE;MAAM,QAAQ,EAAE,OAAO,UAAU,OAAO;MAAE;;IAGrD,MAAM,cAAc,MAAM,SAAS;AACnC,QAAI,gBAAgB,KAClB,aAAY;SACP;AAWL,WAAM,iBAAiB,iBAAiB;AAIxC,aAAQ,OAAO,MAAM,wCAAwC,KAAK,oBAAoB;KACtF,MAAM,cAAc,MAAM,SAAS;AACnC,SAAI,gBAAgB,KAClB,aAAY;SAGZ,aAAY;MACV;MACA,QAAQ,EAIN,OAAO,GAAG,wBAAwB,GAAG,MAAM,aAAa,IAAO,mBAChE;MACF;;YAGE,GAAG;AAEV,gBAAY;KACV;KACA,QAAQ,EAAE,OAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,EAAE;KAC9D;;AAKH,OAAI,WAAW,UAAU,UAAU,yBAAyB,UAAU,OAAO,MAAM,CACjF,yBAAwB;AAS1B,OAAI,UACF,WAAU,OAAO;YACR,SACT,WAAU,OAAO;AAGnB,eAAY,KAAK,UAAU;;WAErB;AAIR,wBAAsB;;CAOxB,MAAM,WAAW,kBACb,kBAAkB,YAAY,KAAK,OAAO,EAAE,MAAM,sBAAsB,EAAE,EAAE,EAAE,CAAC,GAC/E,EAAE;CAEN,MAAM,SAAS,YAAY,QACxB,KAAK,EAAE,aAAa;AACnB,MAAI,WAAW,QAAQ;AAErB,OAAI,UAAU;AACd,OAAI,SAAS;SACR;AACL,OAAI,UAAU,OAAO;AACrB,OAAI,UAAU,OAAO;AACrB,OAAI,WAAW,OAAO;AACtB,OAAI,SAAS,OAAO,SAAS,OAAO,SAAS,OAAO;;AAEtD,SAAO;IAET;EAAE,QAAQ;EAAG,QAAQ;EAAG,SAAS;EAAG,OAAO;EAAG,CAC/C;AAED,QAAO;EACL;EACA,UAAU,KAAK,KAAK,GAAG;EACvB,OAAO;EACP;EACA;EACA,GAAI,uBAAuB,EAAE,WAAW,EAAE,aAAa,sBAAsB,EAAE,GAAG,EAAE;EACrF;;;;;;;;;;;;;AAcH,SAAS,sBAAsB,OAAsC;AACnE,QAAO,MAAM,KACV,KAAK,QAAQ;AACZ,MAAI,IAAI,UAAU,KAAA,GAAW;AAC3B,OAAI,OAAO,IAAI,UAAU,SAAU,QAAO,IAAI;AAC9C,OAAI;AACF,WAAO,KAAK,UAAU,IAAI,MAAM;WAC1B;AACN,WAAO,OAAO,IAAI,MAAM;;;AAG5B,MAAI,IAAI,gBAAgB,KAAA,EAAW,QAAO,IAAI;AAC9C,MAAI,IAAI,cAAc,KAAA,EAAW,QAAO,IAAI;AAC5C,SAAO,IAAI,WAAW,IAAI;GAC1B,CACD,KAAK,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC9bd,SAAS,WAAW,aAAqB,MAAsB;CAC7D,MAAM,MAAM,KAAK,SAAS,aAAa,KAAK;AAC5C,KAAI,QAAQ,MAAM,IAAI,WAAW,KAAK,IAAI,KAAK,WAAW,IAAI,CAG5D,QAAO,KAAK,SAAS,KAAK;AAE5B,QAAO;;;;;;;;;AAUT,SAAgB,qBACd,QACA,MACsB;AACtB,QAAO;EACL,MAAM;GAAE,SAAS,KAAK;GAAS,UAAU,KAAK;GAAU;EACxD,WAAW,OAAO;EAClB,UAAU,OAAO;EACjB,QAAQ,OAAO;EACf,GAAI,OAAO,YAAY,EAAE,WAAW,OAAO,WAAW,GAAG,EAAE;EAC3D,OAAO,OAAO,MAAM,KAAK,MAAgC;GACvD,MAAM,OAAO,WAAW,KAAK,aAAa,EAAE,KAAK;GACjD,MAAM,YACJ,EAAE,SAAS,YAAY,EAAE,SAAS,YAAa,EAAE,MAAM,EAAE,MAAM,GAAa,EAAE;AAChF,OAAI,WAAW,EAAE,OAGf,QAAO;IAAE;IAAM,OAAO,EAAE,OAAO;IAAO,GAAG;IAAW;AAEtD,UAAO;IACL;IACA,UAAU,EAAE,OAAO;IACnB,QAAQ,EAAE,OAAO;IACjB,QAAQ,EAAE,OAAO;IACjB,SAAS,EAAE,OAAO;IAClB,OAAO,EAAE,OAAO;IAChB,GAAG;IACJ;IACD;EACH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CH,eAAsB,oBACpB,QACA,KACA,MACmB;CACnB,MAAM,aAAa,qBAAqB,QAAQ,KAAK;AACrD,OAAM,MAAM,KAAK,EAAE,WAAW,MAAM,CAAC;CAErC,MAAM,eAAe,WAAW,MAAM,QAAQ,MAAM,EAAE,SAAS,KAAA,EAAU;CACzE,MAAM,cAAc,WAAW,MAAM,QAAQ,MAAM,EAAE,SAAS,SAAS;CACvE,MAAM,eAAe,WAAW,MAAM,QAAQ,MAAM,EAAE,SAAS,UAAU;CAEzE,MAAM,UAAoB,EAAE;AAK5B,KAAI,aAAa,SAAS,KAAK,WAAW,MAAM,WAAW,GAAG;EAC5D,MAAM,UAAU,KAAK,KAAK,KAAK,GAAG,KAAK,QAAQ,GAAG,KAAK,SAAS,OAAO;AACvE,QAAM,UACJ,SACA,GAAG,KAAK,UAAU;GAAE,GAAG;GAAY,OAAO;GAAc,EAAE,MAAM,EAAE,CAAC,KACnE,OACD;AACD,UAAQ,KAAK,QAAQ;;AAGvB,KAAI,YAAY,SAAS,GAAG;EAC1B,MAAM,gBAAgB,KAAK,KAAK,KAAK,GAAG,KAAK,QAAQ,GAAG,KAAK,SAAS,cAAc;AACpF,QAAM,UACJ,eACA,GAAG,KAAK,UAAU;GAAE,GAAG;GAAY,OAAO;GAAa,EAAE,MAAM,EAAE,CAAC,KAClE,OACD;AACD,UAAQ,KAAK,cAAc;;AAG7B,KAAI,aAAa,SAAS,GAAG;EAC3B,MAAM,iBAAiB,KAAK,KAAK,KAAK,GAAG,KAAK,QAAQ,GAAG,KAAK,SAAS,eAAe;AACtF,QAAM,UACJ,gBACA,GAAG,KAAK,UACN;GACE,GAAG;GACH,MAAM;IAAE,GAAG,WAAW;IAAM,YAAY;IAAe;GACvD,OAAO;GACR,EACD,MACA,EACD,CAAC,KACF,OACD;AACD,UAAQ,KAAK,eAAe;;AAG9B,QAAO;;;;;;;;;;;;;;;;;;;;AAqBT,eAAsB,sBACpB,UACA,KACA,MACmB;AACnB,KAAI,SAAS,WAAW,EAAG,QAAO,EAAE;CAIpC,MAAM,6BAAa,IAAI,KAAwB;AAC/C,MAAK,MAAM,EAAE,UAAU,UAAU,UAAU;EACzC,IAAI,SAAS,WAAW,IAAI,SAAS;AACrC,MAAI,CAAC,QAAQ;AACX,YAAS,EAAE;AACX,cAAW,IAAI,UAAU,OAAO;;EAGlC,MAAM,SAAS,KAAK,MAAM,KAAK;AAC/B,MAAI,MAAM,QAAQ,OAAO,CACvB,QAAO,KAAK,GAAG,OAAO;MAEtB,QAAO,KAAK,OAAO;;AAIvB,OAAM,MAAM,KAAK,EAAE,WAAW,MAAM,CAAC;CACrC,MAAM,UAAoB,EAAE;AAC5B,MAAK,MAAM,CAAC,UAAU,YAAY,YAAY;EAC5C,MAAM,UAAU,KAAK,KAAK,KAAK,GAAG,SAAS,GAAG,KAAK,QAAQ,GAAG,KAAK,SAAS,OAAO;AACnF,QAAM,UAAU,SAAS,GAAG,KAAK,UAAU,SAAS,MAAM,EAAE,CAAC,KAAK,OAAO;AACzE,UAAQ,KAAK,QAAQ;;AAEvB,QAAO;;;;;;;;;;;;;;;AC9OT,eAAsB,iBACpB,OACA,UAAmC,EAAE,EACN;CAC/B,MAAM,EACJ,mBAAmB,KACnB,eAAe,YACf,iBAAiB,iBACf;CAEJ,MAAM,UAAgC,EAAE;AACxC,MAAK,MAAM,QAAQ,MACjB,SAAQ,KAAK,MAAM,WAAW,MAAM,kBAAkB,cAAc,eAAe,CAAC;AAEtF,QAAO;;AAGT,eAAe,WACb,MACA,kBACA,cACA,gBAC6B;CAC7B,IAAI;CACJ,MAAM,iBAAiB,IAAI,SAAoB,YAAY;AACzD,kBAAgB,mBAAmB,QAAQ,UAAU,EAAE,iBAAiB;GACxE;AAEF,KAAI;EACF,MAAM,UAAU,MAAM,QAAQ,KAAK,CACjC,QAAQ,SAAS,CACd,WAAW,KAAK,OAAO,CAAC,CACxB,WAAW,KAAc,EAC5B,eACD,CAAC;AACF,MAAI,kBAAkB,KAAA,EAAW,gBAAe,cAAc;AAC9D,SAAO;GAAE,MAAM,KAAK;GAAM,QAAQ;GAAS;UACpC,GAAG;AACV,MAAI,kBAAkB,KAAA,EAAW,gBAAe,cAAc;AAC9D,SAAO;GAAE,MAAM,KAAK;GAAM,QAAQ;GAAS,OAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE;GAAE;;;;;;;;;;;;;;;;;AA4ClG,SAAgB,gBAAgB,SAA+C;CAC7E,MAAM,EACJ,UAAU,KACV,UACA,eAAe,YACf,UAAU,SAAiB,QAAQ,KAAK,KAAK,KAC3C;CAEJ,IAAI,QAAQ;CACZ,MAAM,SAAS,mBAAmB;AAChC,UAAQ;AACR,SAAO,SAAS;IACf,QAAQ;AAEX,QAAO;EACL,SAAe;AACb,gBAAa,OAAO;;EAEtB,IAAI,QAAiB;AACnB,UAAO;;EAEV;;;;;;;;;;;;;;;;AC3JH,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8IZ,WAAW;;;;;;;;;AAUb,MAAa,yBAAyB;;;;;;;;;;AAkCtC,SAAgB,gBACd,YACA,kBAC2B;CAI3B,MAAM,oBAAoB,eAAe,KAAA,IAAY,SAAS,YAAY,GAAG,GAAG;AAChF,KAAI,OAAO,MAAM,kBAAkB,IAAI,qBAAqB,EAC1D,QAAO;CAGT,MAAM,kBACJ,qBAAqB,KAAA,IAAY,SAAS,kBAAkB,GAAG,GAAG,KAAA;AACpE,KAAI,oBAAoB,KAAA,MAAc,OAAO,MAAM,gBAAgB,IAAI,mBAAmB,GACxF,QAAO;AAGT,QAAO;EAAE;EAAmB;EAAiB;;;;;;;;;;;;;;AAe/C,SAAgB,qBAAqB,KAAsD;AACzF,KAAI,QAAQ,KAAA,EAAW,QAAO,KAAA;CAC9B,MAAM,OAAO,SAAS,KAAK,GAAG;AAC9B,KAAI,OAAO,MAAM,KAAK,IAAI,OAAO,KAAK,OAAO,MAC3C,QAAO;AAET,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0BT,SAAgB,kBAAkB,MAA0B;CAC1D,MAAM,MAAgB,EAAE;AACxB,MAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;EACpC,MAAM,MAAM,KAAK;EACjB,MAAM,OAAO,KAAK,IAAI;AACtB,OACG,QAAQ,YAAY,QAAQ,oBAC7B,SAAS,KAAA,KACT,KAAK,WAAW,IAAI,IACpB,SAAS,MACT;AACA,OAAI,KAAK,GAAG,IAAI,GAAG,OAAO;AAC1B;AACA;;AAEF,MAAI,KAAK,IAAI;;AAEf,QAAO;;;;;;;;;;;;;;;;AAiBT,SAAgB,YACd,SACA,QACiB;CACjB,MAAM,MAAM,WAAW;AACvB,KAAI,QAAQ,KAAA,EAAW,QAAO;CAC9B,MAAM,KAAK,SAAS,KAAK,GAAG;AAC5B,KAAI,OAAO,MAAM,GAAG,IAAI,KAAK,EAC3B,QAAO;AAET,QAAO;;;;;;;;;;;;;;;;;;;;;;;;AA0CT,SAAgB,kBACd,SACA,QACA,qBACiB;CACjB,MAAM,MAAM,WAAW;AACvB,KAAI,QAAQ,KAAA,EACV,QAAO,wBAAwB,QAAA,MAAoC;CAErE,MAAM,KAAK,SAAS,KAAK,GAAG;AAC5B,KAAI,OAAO,MAAM,GAAG,IAAI,KAAK,EAC3B,QAAO;AAET,QAAO;;;;;;;;;;;;;;;;;;;;AAyBT,MAAa,yBAAyB;CAAC;CAAQ;CAAO;CAAW;CAAU;;;;;;;;;;;;;;AAe3E,SAAgB,oBACd,KACwE;AACxE,KAAI,QAAQ,KAAA,EAAW,QAAO;EAAE,IAAI;EAAM,OAAO,KAAA;EAAW;AAC5D,KAAK,uBAA6C,SAAS,IAAI,CAC7D,QAAO;EAAE,IAAI;EAAM,OAAO;EAAK;AAEjC,QAAO;EACL,IAAI;EACJ,OAAO,kCAAkC,uBAAuB,KAAK,IAAI,CAAC,SAAS,IAAI;EACxF;;;;;;;;;;;;;;;;;;;;;AA0BH,SAAgB,cAAc,QAAgC;CAC5D,MAAM,QAAkB,EAAE;AAE1B,MAAK,MAAM,EAAE,MAAM,YAAY,OAAO,OAAO;EAC3C,MAAM,OAAO,SAAS,KAAK;AAC3B,MAAI,WAAW,OAGb,OAAM,KAAK,QAAQ,KAAK,IAAI,OAAO,QAAQ;OACtC;GACL,MAAM,QAAkB,CAAC,GAAG,OAAO,OAAO,SAAS;AACnD,OAAI,OAAO,SAAS,EAAG,OAAM,KAAK,GAAG,OAAO,OAAO,SAAS;AAC5D,OAAI,OAAO,UAAU,EAAG,OAAM,KAAK,GAAG,OAAO,QAAQ,UAAU;GAC/D,MAAM,SAAS,OAAO,SAAS,OAAO,SAAS,OAAO,YAAY,IAAI,kBAAkB;AACxF,SAAM,KAAK,QAAQ,KAAK,IAAI,MAAM,KAAK,KAAK,GAAG,SAAS;;;CAI5D,MAAM,EAAE,QAAQ,aAAa;AAC7B,OAAM,KACJ,oBAAoB,OAAO,OAAO,WAAW,OAAO,OAAO,WAAW,OAAO,QAAQ,YAAY,SAAS,KAC3G;AAED,QAAO,MAAM,KAAK,KAAK;;;;;;;;AAmBzB,eAAsB,kBACpB,YACA,OACA,MACyB;CACzB,MAAM,SAAS,MAAM,sBAAsB,YAAY,OAAO,KAAK;AAEnE,KAAI,MAAM,aACR,SAAQ,OAAO,MAAM,KAAK,cAAc,OAAO,CAAC,IAAI;AAGtD,QAAO;;;;;;;;;;;;AAiBT,SAAgB,iBAAiB,UAA4B;AAC3D,QACE,YACA,CAAC,QAAQ,OAAO,SAChB,QAAQ,IAAI,OAAO,KAAA,KACnB,QAAQ,IAAI,qBAAqB,KAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BrC,eAAsB,KAAK,OAAiB,QAAQ,KAAK,MAAM,EAAE,EAAiB;CAEhF,IAAI;AACJ,KAAI;AACF,WAAS,UAAU;GAMjB,MAAM,kBAAkB,KAAK;GAC7B,SAAS;IACP,MAAM;KAAE,MAAM;KAAW,OAAO;KAAK;IACrC,SAAS,EAAE,MAAM,UAAU;IAC3B,kBAAkB,EAAE,MAAM,UAAU;IACpC,cAAc,EAAE,MAAM,UAAU;IAChC,mBAAmB,EAAE,MAAM,WAAW;IACtC,WAAW,EAAE,MAAM,UAAU;IAC7B,iBAAiB,EAAE,MAAM,UAAU;IACnC,iBAAiB,EAAE,MAAM,UAAU;IACnC,cAAc,EAAE,MAAM,UAAU;IAChC,kBAAkB,EAAE,MAAM,UAAU;IACpC,gBAAgB,EAAE,MAAM,WAAW;IACnC,UAAU,EAAE,MAAM,WAAW;IAC7B,gBAAgB,EAAE,MAAM,UAAU;IAClC,MAAM,EAAE,MAAM,UAAU;IACxB,eAAe,EAAE,MAAM,UAAU;IACjC,mBAAmB,EAAE,MAAM,WAAW;IACtC,iBAAiB,EAAE,MAAM,WAAW;IACrC;GACD,kBAAkB;GACnB,CAAC;UACK,GAAG;AACV,UAAQ,OAAO,MAAM,kBAAkB,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,CAAC,IAAI;AACtF,UAAQ,WAAW;AACnB;;AAGF,KAAI,OAAO,OAAO,QAAQ,KAAK,WAAW,GAAG;AAC3C,UAAQ,OAAO,MAAM,MAAM;AAC3B;;CAMF,MAAM,OAAO,OAAO;CAGpB,MAAM,WAAW,gBACf,OAAO,KAAK,YAAY,WAAW,KAAK,UAAU,KAAA,GAClD,OAAO,KAAK,sBAAsB,WAAW,KAAK,oBAAoB,KAAA,EACvE;AACD,KAAI,OAAO,aAAa,UAAU;AAChC,UAAQ,OAAO,MAAM,kBAAkB,SAAS,IAAI;AACpD,UAAQ,WAAW;AACnB;;CAEF,MAAM,EAAE,mBAAmB,oBAAoB;CAK/C,MAAM,iBAAiB,KAAK,uBAAuB;CACnD,MAAM,YAAY,OAAO,KAAK,kBAAkB,WAAW,KAAK,gBAAgB;CAChF,MAAM,SAAS,OAAO,KAAK,eAAe,WAAW,KAAK,aAAa;AACvE,KAAI;MACE,WAAW,IAAI;AACjB,WAAQ,OAAO,MACb,yOAGD;AACD,WAAQ,WAAW;AACnB;;YAEO,cAAc,IAAI;AAC3B,UAAQ,OAAO,MACb,qOAGD;AACD,UAAQ,WAAW;AACnB;;CAGF,MAAM,WAAW,KAAK,aAAa;CACnC,MAAM,cACJ,OAAO,KAAK,oBAAoB,WAAW,KAAK,kBAAkB,QAAQ,KAAK;CACjF,MAAM,YAAY,OAAO,KAAK,kBAAkB,WAAW,KAAK,gBAAgB,KAAA;CAChF,MAAM,gBAAgB,qBACpB,OAAO,KAAK,sBAAsB,WAAW,KAAK,oBAAoB,KAAA,EACvE;AACD,KAAI,OAAO,kBAAkB,UAAU;AACrC,UAAQ,OAAO,MAAM,kBAAkB,cAAc,IAAI;AACzD,UAAQ,WAAW;AACnB;;CAEF,MAAM,SAAS,YACb,OAAO,KAAK,SAAS,WAAW,KAAK,OAAO,KAAA,GAC5C,QAAQ,IAAI,SACb;AACD,KAAI,OAAO,WAAW,UAAU;AAC9B,UAAQ,OAAO,MAAM,kBAAkB,OAAO,IAAI;AAClD,UAAQ,WAAW;AACnB;;CAEF,MAAM,aAAa,iBAAiB,KAAK,oBAAoB,KAAK;CAKlE,MAAM,eAAe,KAAK,qBAAqB;CAC/C,MAAM,iBAAiB,KAAK,uBAAuB,QAAQ;CAG3D,MAAM,cAAc,OAAO,KAAK,qBAAqB,WAAW,KAAK,mBAAmB,KAAA;CAIxF,MAAM,qBAAqB,oBACzB,OAAO,KAAK,qBAAqB,WAC7B,KAAK,mBACL,QAAQ,IAAI,kBACjB;AACD,KAAI,CAAC,mBAAmB,IAAI;AAC1B,UAAQ,OAAO,MAAM,kBAAkB,mBAAmB,MAAM,IAAI;AACpE,UAAQ,WAAW;AACnB;;CAEF,MAAM,eAAe,mBAAmB;CACxC,MAAM,UAAU,gBAAgB,KAAA,KAAa,iBAAiB,KAAA;CAI9D,MAAM,OAAO;EAAE,SAAS,eAAe;EAAO,UAAU,gBAAgB;EAAQ;CAOhF,MAAM,eAAe,kBACnB,OAAO,KAAK,mBAAmB,WAAW,KAAK,iBAAiB,KAAA,GAChE,QAAQ,IAAI,iBACZ,KAAK,QACN;AACD,KAAI,OAAO,iBAAiB,UAAU;AACpC,UAAQ,OAAO,MAAM,kBAAkB,aAAa,IAAI;AACxD,UAAQ,WAAW;AACnB;;CAIF,MAAM,QAAQ,OAAO;AACrB,KAAI,MAAM,WAAW,GAAG;AACtB,UAAQ,OAAO,MAAM,yDAAyD;AAC9E,UAAQ,OAAO,MAAM,MAAM;AAC3B,UAAQ,WAAW;AACnB;;CAOF,MAAM,aAAa,MAAM,kBAAkB,OAAO,QAAQ,KAAK,EAAE,EAC/D,eAAe,gBAChB,CAAC;AACF,KAAI,WAAW,WAAW,GAAG;AAC3B,UAAQ,OAAO,MAAM,wCAAwC,MAAM,KAAK,KAAK,CAAC,IAAI;AAClF,UAAQ,WAAW;AACnB;;CAEF,MAAM,EAAE,SAAS,WAAW,qBAAqB,WAAW;CAG5D,MAAM,QAAQ,iBAAiB,CAAC,GAAG,SAAS,GAAG,OAAO,GAAG;CACzD,MAAM,gBAAgB,IAAI,IAAI,OAAO;AACrC,SAAQ,OAAO,MACb,kBAAkB,OAAO,SAAS,IAC9B,wBAAwB,QAAQ,OAAO,aAAa,OAAO,OAAO,WAAW,mBAAmB,oBAChG,wBAAwB,MAAM,OAAO,iBAC1C;AAQD,KAAI,QACF,SAAQ,OAAO,MAAM,2CAA2C,KAAK,UAAU,KAAK,CAAC,IAAI;AAE3F,KAAI,aACF,SAAQ,OAAO,MACb,gKAED;AAEH,KAAI,SAAS,EACX,SAAQ,OAAO,MAAM,yBAAyB,OAAO,6BAA6B;AAEpF,KAAI,eAAe,EACjB,SAAQ,OAAO,MAAM,gCAAgC,aAAa,6BAA6B;CAEjG,MAAM,UAAU,6BAA6B;EAC3C;EAKA,GAAI,iBAAiB;GAAE,gBAAgB;GAAM;GAAQ,GAAG,EAAE;EAC1D;EAIA,GAAI,oBAAoB,KAAA,IAAY,EAAE,WAAW,iBAAiB,GAAG,EAAE;EAIvE,GAAI,kBAAkB,KAAA,IAAY,EAAE,eAAe,GAAG,EAAE;EACxD;EACA,MAAM,UAAU,OAAO,KAAA;EACvB;EACA;EACA;EACA,cAAc,WAAW;AACvB,OAAI,YAAY;AACd,YAAQ,OAAO,MAAM,oCAAoC;AACzD;;AAEF,QAAK,MAAM,SAAS,OAAQ,SAAQ,OAAO,MAAM,GAAG,MAAM,IAAI;;EAEjE,CAAC;CAEF,IAAI;AACJ,KAAI;AACF,eAAa,MAAM,QAAQ,MAAM;UAC1B,GAAG;AACV,UAAQ,OAAO,MAAM,kBAAkB,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,CAAC,IAAI;AACtF,UAAQ,WAAW;AACnB;;CAIF,IAAI,WAAW;AACf,KAAI;AAOF,UAAQ,iBAAiB,UAAU;EACnC,MAAM,SAAS,MAAM,kBAAkB,YAAY,OAAO;GACxD,WAAW;GACX,cAAc;GACd,iBAAiB,cAAc,KAAA;GAI/B;GAKA,kBAAkB,KAAK;GACvB,aAAa,kBAAkB,cAAc,OAAO,IAAI,gBAAgB,KAAA;GAKxE,mBAAmB,gBAAgB,cAAc,OAAO,IAAI,gBAAgB,KAAA;GAO5E,eAAe,MAAM,OAAO,UAAU;IACpC,MAAM,OAAO,SAAS,KAAK;AAC3B,YAAQ,OAAO,MACb,UAAU,KAAK,mCAAmC,MAAM,GAAG,MAAM,KAClE;AACD,YAAQ,iBAAiB;KAAE,MAAM;KAAM;KAAO;KAAO,CAAC;;GAEzD,CAAC;AAKF,MAAI,kBAAkB,cAAc,OAAO,EACzC,SAAQ,iBAAiB,KAAK;AAIhC,MAAI,cAAc,KAAA,EAChB,KAAI;GACF,MAAM,cAAc,MAAM,oBAAoB,QAAQ,WAAW;IAC/D,SAAS,KAAK;IACd,UAAU,KAAK;IACf;IACD,CAAC;AACF,QAAK,MAAM,cAAc,YACvB,SAAQ,OAAO,MAAM,+BAA+B,WAAW,IAAI;GAErE,MAAM,eAAe,MAAM,sBACzB,OAAO,UACP,GAAG,UAAU,gBACb,KACD;AACD,OAAI,aAAa,SAAS,EACxB,SAAQ,OAAO,MAAM,wBAAwB,aAAa,OAAO,oBAAoB;WAEhF,GAAG;AAEV,WAAQ,OAAO,MACb,oDAAoD,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,CAAC,IAChG;;AAIL,aAAW,OAAO,OAAO,SAAS,IAAI,IAAI;WAClC;AAMR,UAAQ,iBAAiB,WAAW;EASpC,MAAM,WAAW,gBAAgB;GAAE,SAAS;GAAwB;GAAU,CAAC;AAO/E,QAAM,iBAAiB,CACrB;GACE,MAAM;GACN,aAAa,QAAQ,MAAM,WAAW;GACvC,CACF,CAAC;AAKF,WAAS,QAAQ;AACjB,UAAQ,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;AC53BvB,MAAM,CAAC,OAAO,MAAe;AAC3B,SAAQ,OAAO,MACb,oCAAoC,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,CAAC,IAChF;AACD,SAAQ,WAAW;EACnB"}