@ait-co/devtools 0.1.131 → 0.1.132

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 (62) hide show
  1. package/dist/{debug-server-CD4zYBq4.js → debug-server-BBUzxZSf.js} +2 -2
  2. package/dist/{debug-server-CD4zYBq4.js.map → debug-server-BBUzxZSf.js.map} +1 -1
  3. package/dist/{debug-server-qRSRG7YZ.js → debug-server-Ch5p0ryv.js} +20 -2
  4. package/dist/debug-server-Ch5p0ryv.js.map +1 -0
  5. package/dist/{debug-server-BSbfnFR5.js → debug-server-DoWAtTNj.js} +22 -4
  6. package/dist/debug-server-DoWAtTNj.js.map +1 -0
  7. package/dist/mcp/cli.js +72 -7
  8. package/dist/mcp/cli.js.map +1 -1
  9. package/dist/mcp/server.js +1 -1
  10. package/dist/panel/index.js +1 -1
  11. package/dist/{pool-BaSCZALv.d.ts → pool-BtLh7U1y.d.ts} +16 -2
  12. package/dist/{pool-BaSCZALv.d.ts.map → pool-BtLh7U1y.d.ts.map} +1 -1
  13. package/dist/{qr-http-server-BxoRp2w7.cjs → qr-http-server-DX_9UA-p.cjs} +3 -2
  14. package/dist/qr-http-server-DX_9UA-p.cjs.map +1 -0
  15. package/dist/{qr-http-server-BdNGPRoL.js → qr-http-server-DeRrKp_R.js} +3 -2
  16. package/dist/{qr-http-server-cKbciY5k.js.map → qr-http-server-DeRrKp_R.js.map} +1 -1
  17. package/dist/{qr-http-server-DCbjORFa.js → qr-http-server-FVn61xAq.js} +3 -3
  18. package/dist/{qr-http-server-BdNGPRoL.js.map → qr-http-server-FVn61xAq.js.map} +1 -1
  19. package/dist/{qr-http-server-cKbciY5k.js → qr-http-server-KB147M-S.js} +4 -2
  20. package/dist/{qr-http-server-DCbjORFa.js.map → qr-http-server-KB147M-S.js.map} +1 -1
  21. package/dist/{qr-http-server-Bq9zR7-z.js → qr-http-server-V0xuU5TJ.js} +3 -2
  22. package/dist/{qr-http-server-Bq9zR7-z.js.map → qr-http-server-V0xuU5TJ.js.map} +1 -1
  23. package/dist/{qr-http-server-CV6TA9ZN.cjs → qr-http-server-iH_Hh_si.cjs} +3 -2
  24. package/dist/qr-http-server-iH_Hh_si.cjs.map +1 -0
  25. package/dist/{relay-factory-Cloog1jb.js → relay-factory-cNgPfrWg.js} +10 -4
  26. package/dist/relay-factory-cNgPfrWg.js.map +1 -0
  27. package/dist/{relay-worker-o0M2ndyJ.d.ts → relay-worker-BMSnlyls.d.ts} +39 -2
  28. package/dist/relay-worker-BMSnlyls.d.ts.map +1 -0
  29. package/dist/{relay-worker-CCAHLC4a.js → relay-worker-l34xNuci.js} +20 -3
  30. package/dist/{relay-worker-CCAHLC4a.js.map → relay-worker-l34xNuci.js.map} +1 -1
  31. package/dist/test-runner/bin.js +160 -20
  32. package/dist/test-runner/bin.js.map +1 -1
  33. package/dist/test-runner/config.d.ts +1 -1
  34. package/dist/test-runner/config.js +1 -1
  35. package/dist/test-runner/pool.d.ts +1 -1
  36. package/dist/test-runner/pool.js +1 -1
  37. package/dist/test-runner/pool.js.map +1 -1
  38. package/dist/test-runner/relay-factory.d.ts +14 -0
  39. package/dist/test-runner/relay-factory.d.ts.map +1 -1
  40. package/dist/test-runner/relay-factory.js +9 -3
  41. package/dist/test-runner/relay-factory.js.map +1 -1
  42. package/dist/test-runner/relay-worker.d.ts +2 -2
  43. package/dist/test-runner/relay-worker.js +2 -2
  44. package/dist/test-runner/report.d.ts +26 -4
  45. package/dist/test-runner/report.d.ts.map +1 -1
  46. package/dist/test-runner/report.js +41 -7
  47. package/dist/test-runner/report.js.map +1 -1
  48. package/dist/{tunnel-B2UCKAc7.cjs → tunnel-DP453L4e.cjs} +2 -2
  49. package/dist/{tunnel-B2UCKAc7.cjs.map → tunnel-DP453L4e.cjs.map} +1 -1
  50. package/dist/{tunnel-C-rQ34dM.js → tunnel-Djf-zmjJ.js} +2 -2
  51. package/dist/{tunnel-C-rQ34dM.js.map → tunnel-Djf-zmjJ.js.map} +1 -1
  52. package/dist/unplugin/index.cjs +1 -1
  53. package/dist/unplugin/index.js +1 -1
  54. package/dist/unplugin/tunnel.cjs +1 -1
  55. package/dist/unplugin/tunnel.js +1 -1
  56. package/package.json +1 -1
  57. package/dist/debug-server-BSbfnFR5.js.map +0 -1
  58. package/dist/debug-server-qRSRG7YZ.js.map +0 -1
  59. package/dist/qr-http-server-BxoRp2w7.cjs.map +0 -1
  60. package/dist/qr-http-server-CV6TA9ZN.cjs.map +0 -1
  61. package/dist/relay-factory-Cloog1jb.js.map +0 -1
  62. package/dist/relay-worker-o0M2ndyJ.d.ts.map +0 -1
@@ -575,6 +575,13 @@ var ChiiCdpConnection = class {
575
575
  //#endregion
576
576
  //#region src/test-runner/relay-worker.ts
577
577
  /**
578
+ * Per-file evaluate timeout (ms) for manual-variant files (devtools#741) — a
579
+ * human is expected to be tapping through a native sheet, so the timeout must
580
+ * be far longer than the unattended default (60s, rpc.ts DEFAULT_TIMEOUT_MS).
581
+ * Fixed constant (not configurable in v1) — documented here + CLI `--help`.
582
+ */
583
+ const MANUAL_FILE_TIMEOUT_MS = 5 * 6e4;
584
+ /**
578
585
  * Sentinel string embedded in the error message by `injectAndRunBundle` when
579
586
  * the per-file evaluate race hits the timeout. Used by the retry guard so only
580
587
  * genuine timeouts get a second attempt — not bundle errors or parse failures.
@@ -645,12 +652,21 @@ async function runTestFilesOverRelay(connection, files, opts) {
645
652
  process.stderr.write(`relay-worker: reconnect failed (${e instanceof Error ? e.message : String(e)}) — continuing, remaining files may fail\n`);
646
653
  }
647
654
  };
655
+ const manualFiles = opts?.manualFiles;
656
+ const manualTotal = manualFiles?.size ?? 0;
657
+ let manualIndex = 0;
648
658
  try {
649
659
  for (const file of files) {
650
660
  if (pendingReconnectCheck) {
651
661
  await attemptReconnect("next-file");
652
662
  pendingReconnectCheck = false;
653
663
  }
664
+ const isManual = manualFiles?.has(file) === true;
665
+ if (isManual) {
666
+ manualIndex += 1;
667
+ opts?.onManualFile?.(file, manualIndex, manualTotal);
668
+ }
669
+ const fileTimeoutMs = isManual ? MANUAL_FILE_TIMEOUT_MS : opts?.timeoutMs;
654
670
  let fileEntry;
655
671
  try {
656
672
  const { code } = await bundleTestFile(file, opts?.bundleOptions);
@@ -676,7 +692,7 @@ async function runTestFilesOverRelay(connection, files, opts) {
676
692
  const attempt = async () => {
677
693
  let rpcResult;
678
694
  try {
679
- rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
695
+ rpcResult = await injectAndRunBundle(connection, code, fileTimeoutMs);
680
696
  } catch (e) {
681
697
  return {
682
698
  file,
@@ -712,6 +728,7 @@ async function runTestFilesOverRelay(connection, files, opts) {
712
728
  };
713
729
  }
714
730
  if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
731
+ if (isManual) fileEntry.mode = "manual";
715
732
  fileResults.push(fileEntry);
716
733
  }
717
734
  } finally {
@@ -789,6 +806,6 @@ function flattenResults(report) {
789
806
  return out;
790
807
  }
791
808
  //#endregion
792
- export { RELAY_AUTH_REJECT_CLOSE_CODE as a, ChiiCdpConnection as i, flattenResults as n, RELAY_AUTH_REJECT_REASON as o, runTestFilesOverRelay as r, EVALUATE_TIMEOUT_MARKER as t };
809
+ export { ChiiCdpConnection as a, runTestFilesOverRelay as i, MANUAL_FILE_TIMEOUT_MS as n, RELAY_AUTH_REJECT_CLOSE_CODE as o, flattenResults as r, RELAY_AUTH_REJECT_REASON as s, EVALUATE_TIMEOUT_MARKER as t };
793
810
 
794
- //# sourceMappingURL=relay-worker-CCAHLC4a.js.map
811
+ //# sourceMappingURL=relay-worker-l34xNuci.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"relay-worker-CCAHLC4a.js","names":[],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-connection.ts","../src/test-runner/relay-worker.ts"],"sourcesContent":["/**\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 send<M extends CdpCommandName>(\n method: M,\n params?: CdpCommandMap[M]['params'],\n ): Promise<CdpCommandMap[M]['result']> {\n return this.sendCommand(method, (params ?? {}) as Record<string, unknown>) 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 `commandTimeoutMs` (default 30s, env\n * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up\n * and the promise rejects with a descriptive Korean error.\n */\n sendCommand(method: string, params: Record<string, unknown> = {}): 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 = 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 * 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 { injectAndRunBundle } from './rpc.js';\nimport type { RunReport, TestResult } from './runtime.js';\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\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\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\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 try {\n for (const file of files) {\n if (pendingReconnectCheck) {\n await attemptReconnect('next-file');\n pendingReconnectCheck = false;\n }\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, opts?.timeoutMs);\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 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 };\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"],"mappings":"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"}
1
+ {"version":3,"file":"relay-worker-l34xNuci.js","names":[],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-connection.ts","../src/test-runner/relay-worker.ts"],"sourcesContent":["/**\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 send<M extends CdpCommandName>(\n method: M,\n params?: CdpCommandMap[M]['params'],\n ): Promise<CdpCommandMap[M]['result']> {\n return this.sendCommand(method, (params ?? {}) as Record<string, unknown>) 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 `commandTimeoutMs` (default 30s, env\n * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up\n * and the promise rejects with a descriptive Korean error.\n */\n sendCommand(method: string, params: Record<string, unknown> = {}): 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 = 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 * 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 { 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 + `'manual'` only for files run under `--manual-blocking`\n * (devtools#741) — report provenance so a human-attended run is never\n * confused with an unattended baseline. Absent (not `false`) for regular\n * files — absence-means-unattended is the contract (report.ts mirrors this).\n */\n mode?: 'manual';\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\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.\n */\n onManualFile?: (file: string, index: number, total: number) => void;\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\n try {\n for (const file of files) {\n if (pendingReconnectCheck) {\n await attemptReconnect('next-file');\n pendingReconnectCheck = false;\n }\n\n const isManual = 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 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): stamp mode='manual' once, here, on\n // whatever fileEntry ended up being (success, error, or timeout) —\n // simpler and less error-prone than threading the flag through every\n // return site inside `attempt()`. Absent for regular files (the\n // contract is absence-means-unattended, not `mode: undefined`).\n 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 };\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"],"mappings":"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"}