@ait-co/devtools 0.1.127 → 0.1.129

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 (68) hide show
  1. package/dist/{attach-orchestrator-CpE0dMew.js → attach-orchestrator-Bdt5Zdo9.js} +3 -94
  2. package/dist/attach-orchestrator-Bdt5Zdo9.js.map +1 -0
  3. package/dist/{attach-orchestrator-CPMQWkGV.js → attach-orchestrator-DvrgUIx7.js} +4 -95
  4. package/dist/attach-orchestrator-DvrgUIx7.js.map +1 -0
  5. package/dist/{bundle-Cb3GFuw6.d.ts → bundle-BKqyhEK9.d.ts} +1 -1
  6. package/dist/{bundle-Cb3GFuw6.d.ts.map → bundle-BKqyhEK9.d.ts.map} +1 -1
  7. package/dist/{capture-B1zfGyTo.d.ts → capture-ltuV0gZa.d.ts} +1 -1
  8. package/dist/{capture-B1zfGyTo.d.ts.map → capture-ltuV0gZa.d.ts.map} +1 -1
  9. package/dist/{cdp-connection-Dxw_EY8s.d.ts → cdp-connection-D7AYQck2.d.ts} +1 -1
  10. package/dist/{cdp-connection-Dxw_EY8s.d.ts.map → cdp-connection-D7AYQck2.d.ts.map} +1 -1
  11. package/dist/{cell-D0dJSGHi.js → cell-DA_lcpDI.js} +2 -2
  12. package/dist/{cell-D0dJSGHi.js.map → cell-DA_lcpDI.js.map} +1 -1
  13. package/dist/{cell-CX92lDI0.js → cell-DLRv4iG6.js} +2 -2
  14. package/dist/{cell-CX92lDI0.js.map → cell-DLRv4iG6.js.map} +1 -1
  15. package/dist/{debug-server-Dctc9GAh.js → debug-server--Y0QZVJS.js} +57 -58
  16. package/dist/debug-server--Y0QZVJS.js.map +1 -0
  17. package/dist/debug-server-Bu3pCG9c.js +436 -0
  18. package/dist/debug-server-Bu3pCG9c.js.map +1 -0
  19. package/dist/debug-server-e1qPe0ZB.js +379 -0
  20. package/dist/{debug-server-BzzNkETg.js.map → debug-server-e1qPe0ZB.js.map} +1 -1
  21. package/dist/log-CUik_tHp.js +95 -0
  22. package/dist/log-CUik_tHp.js.map +1 -0
  23. package/dist/mcp/cli.js +1296 -1230
  24. package/dist/mcp/cli.js.map +1 -1
  25. package/dist/mcp/server.js +1 -1
  26. package/dist/panel/index.js +1 -1
  27. package/dist/{pool-DBLKNeRC.d.ts → pool-C0pc7T2p.d.ts} +4 -4
  28. package/dist/{pool-DBLKNeRC.d.ts.map → pool-C0pc7T2p.d.ts.map} +1 -1
  29. package/dist/{qr-http-server-Bo8LMOJQ.js → qr-http-server-HANhOFwI.js} +1 -1
  30. package/dist/{qr-http-server-Bo8LMOJQ.js.map → qr-http-server-HANhOFwI.js.map} +1 -1
  31. package/dist/{relay-factory-Dug7W6Ao.js → relay-factory-Ck3vBonr.js} +6 -6
  32. package/dist/{relay-factory-Dug7W6Ao.js.map → relay-factory-Ck3vBonr.js.map} +1 -1
  33. package/dist/{relay-secret-store-DKxs7zwq.js → relay-secret-store-DzxDsK6S.js} +1 -1
  34. package/dist/{relay-secret-store-DKxs7zwq.js.map → relay-secret-store-DzxDsK6S.js.map} +1 -1
  35. package/dist/{relay-url-store-xmUuTjXA.js → relay-url-store-D3rY-GJJ.js} +2 -2
  36. package/dist/{relay-url-store-xmUuTjXA.js.map → relay-url-store-D3rY-GJJ.js.map} +1 -1
  37. package/dist/{debug-server-BzzNkETg.js → relay-worker-Bt0QYKQH.js} +212 -346
  38. package/dist/relay-worker-Bt0QYKQH.js.map +1 -0
  39. package/dist/{relay-worker-TReZtzGl.d.ts → relay-worker-o0M2ndyJ.d.ts} +7 -7
  40. package/dist/relay-worker-o0M2ndyJ.d.ts.map +1 -0
  41. package/dist/{runtime-ozyPnRbU.d.ts → runtime-9xhN9pr8.d.ts} +1 -1
  42. package/dist/{runtime-ozyPnRbU.d.ts.map → runtime-9xhN9pr8.d.ts.map} +1 -1
  43. package/dist/test-runner/bin.js +721 -15
  44. package/dist/test-runner/bin.js.map +1 -1
  45. package/dist/test-runner/bundle.d.ts +1 -1
  46. package/dist/test-runner/capture.d.ts +1 -1
  47. package/dist/test-runner/config.d.ts +1 -1
  48. package/dist/test-runner/config.js +1 -1
  49. package/dist/test-runner/pool.d.ts +1 -1
  50. package/dist/test-runner/pool.js +1 -1
  51. package/dist/test-runner/relay-factory.d.ts +1 -1
  52. package/dist/test-runner/relay-factory.js +1 -1
  53. package/dist/test-runner/relay-worker.d.ts +1 -1
  54. package/dist/test-runner/relay-worker.js +1 -183
  55. package/dist/test-runner/report.d.ts +3 -3
  56. package/dist/test-runner/rpc.d.ts +2 -2
  57. package/dist/test-runner/rpc.js +12 -3
  58. package/dist/test-runner/rpc.js.map +1 -1
  59. package/dist/test-runner/runtime.d.ts +1 -1
  60. package/dist/test-runner/task-graph.d.ts +1 -1
  61. package/package.json +1 -1
  62. package/dist/attach-orchestrator-CPMQWkGV.js.map +0 -1
  63. package/dist/attach-orchestrator-CpE0dMew.js.map +0 -1
  64. package/dist/debug-server-1dhDA3aw.js +0 -980
  65. package/dist/debug-server-1dhDA3aw.js.map +0 -1
  66. package/dist/debug-server-Dctc9GAh.js.map +0 -1
  67. package/dist/relay-worker-TReZtzGl.d.ts.map +0 -1
  68. package/dist/test-runner/relay-worker.js.map +0 -1
package/dist/mcp/cli.js CHANGED
@@ -2024,7 +2024,7 @@ async function readMcpSdkVersion() {
2024
2024
  * some test environments that skip the build step).
2025
2025
  */
2026
2026
  function readDevtoolsVersion() {
2027
- return "0.1.127";
2027
+ return "0.1.129";
2028
2028
  }
2029
2029
  /**
2030
2030
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -3003,1309 +3003,1375 @@ async function discoverTestFiles(patterns, cwd) {
3003
3003
  return [...out].sort();
3004
3004
  }
3005
3005
  //#endregion
3006
- //#region src/test-runner/bundle.ts
3006
+ //#region src/shared/relay-auth-close.ts
3007
3007
  /**
3008
- * esbuild-based bundler for user test files.
3009
- *
3010
- * Bundles a single test file into a self-contained IIFE string that can be
3011
- * injected into a WebView via `Runtime.evaluate`. The bundle includes the
3012
- * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
3013
- * the `runTestModule(factory)` entry point.
3014
- *
3015
- * ## How the wiring works
3016
- *
3017
- * The bundle exposes two exports on `globalThis.__testBundle`:
3018
- * - `runTestModule` — the runtime's entry function.
3019
- * - `__userFactory` — an async function whose body is the user's top-level
3020
- * test registration code (describe/it/test calls).
3021
- *
3022
- * The Node-side RPC (`rpc.ts`) calls:
3023
- * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
3024
- *
3025
- * `runTestModule` then installs `describe/it/test/expect` as globals, invokes
3026
- * the factory (which registers all tests), runs them, and returns a `RunReport`.
3027
- *
3028
- * ## Why a factory wrapper is needed
3029
- *
3030
- * Naively adding the runtime to `entryPoints` and bundling the user file would
3031
- * fail for two reasons:
3032
- * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
3033
- * scope. The user's top-level `describe(...)` calls expect them as globals —
3034
- * they are not globals until `runTestModule` installs them.
3035
- * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
3036
- * time, before the RPC layer calls `runTestModule` to reset state and start
3037
- * the test clock.
3038
- *
3039
- * The factory approach solves both: the user's registration code is deferred
3040
- * into a function that `runTestModule` calls AFTER installing the globals.
3041
- *
3042
- * ## Factory extraction algorithm
3043
- *
3044
- * The `userFactoryPlugin` reads the user file and splits lines into:
3045
- * - **top-level**: `import …` and re-export lines — kept at module scope
3046
- * (the only valid position for static `import` in ESM).
3047
- * - **body**: all other statements — moved into the body of the exported
3048
- * `__userFactory` async function.
3008
+ * Shared constants for the relay's named TOTP-auth rejection (issue #478).
3049
3009
  *
3050
- * esbuild processes the re-generated module, following each static import
3051
- * through the normal dependency graph (including the SDK-redirect plugin).
3010
+ * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
3011
+ * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
3012
+ * indistinguishable from a network failure on the browser side — the
3013
+ * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
3014
+ * could not tell "stale TOTP code" apart from "tunnel down" and stayed
3015
+ * silent. The fix is accept-then-close: complete the handshake, then close
3016
+ * with an application close code that NAMES the rejection.
3052
3017
  *
3053
- * ## SDK redirect
3018
+ * Three parties share this contract:
3019
+ * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
3020
+ * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
3021
+ * surfaces the code to the launcher shell;
3022
+ * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
3023
+ * as an auth failure on its own `/client` dial (defensive — #439's fresh
3024
+ * code mint means it should not normally hit this).
3054
3025
  *
3055
- * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
3056
- * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
3057
- * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
3026
+ * This module is intentionally dependency-free (no Node, no DOM) so it is
3027
+ * safe to import from both the browser in-app bundle and the MCP daemon
3028
+ * bundle.
3058
3029
  *
3059
- * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
3030
+ * SECRET-HANDLING: these are fixed enum values. The close reason / error body
3031
+ * must never grow to carry a secret, a TOTP code, or a host.
3060
3032
  */
3061
- /** The SDK package name that mini-app test code imports from. */
3062
- const SDK_PACKAGE = "@apps-in-toss/web-framework";
3063
3033
  /**
3064
- * Names the runtime installs as globals before invoking the user factory.
3065
- * The `vitest` virtual module re-exports each as a lazy getter that reads from
3066
- * `globalThis` at access time. Keep in sync with the globals installed in
3067
- * `runtime.ts#runTestModule`.
3034
+ * WebSocket close code sent by the relay when TOTP auth is rejected.
3035
+ *
3036
+ * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
3037
+ * HTTP 401 so it reads as "unauthorized" at a glance.
3068
3038
  */
3069
- const VITEST_GLOBAL_NAMES = [
3070
- "describe",
3071
- "it",
3072
- "test",
3073
- "expect",
3074
- "beforeAll",
3075
- "afterAll",
3076
- "beforeEach",
3077
- "afterEach",
3078
- "vi"
3079
- ];
3039
+ const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
3080
3040
  /**
3081
- * Matches the bare SDK package and any sub-path import
3082
- * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
3083
- * Built from {@link SDK_PACKAGE} so the package name has a single source.
3041
+ * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
3042
+ * the `error` value of the relay's HTTP 401 JSON body. Enum string only —
3043
+ * never interpolated with request data.
3084
3044
  */
3085
- const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
3045
+ const RELAY_AUTH_REJECT_REASON = "totp-rejected";
3046
+ //#endregion
3047
+ //#region src/mcp/chii-connection.ts
3086
3048
  /**
3087
- * esbuild plugin that intercepts SDK imports and redirects them to the
3088
- * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
3049
+ * Production `CdpConnection` backed by the local Chii relay.
3089
3050
  *
3090
- * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
3091
- * esbuild resolves it to a virtual module that re-exports all named exports
3092
- * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
3093
- * be available in the test environment) while still making named imports work.
3051
+ * Topology (debug mode):
3052
+ * phone target.js --WS--> Chii relay :9100 <--WS-- this connection
3094
3053
  *
3095
- * If `window.__sdk` is absent (non-dog-food build), every access throws a
3096
- * descriptive error rather than returning `undefined` silently.
3097
- */
3098
- function sdkRedirectPlugin() {
3099
- return {
3100
- name: "sdk-redirect",
3101
- setup(build) {
3102
- build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
3103
- path: args.path,
3104
- namespace: "sdk-redirect"
3105
- }));
3106
- build.onLoad({
3107
- filter: /.*/,
3108
- namespace: "sdk-redirect"
3109
- }, () => ({
3110
- contents: `
3111
- var __proxy = (typeof window !== 'undefined' && window.__sdk)
3112
- ? window.__sdk
3113
- : new Proxy({}, {
3114
- get: function(_t, p) {
3115
- throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
3116
- }
3117
- });
3118
- module.exports = __proxy;
3119
- `,
3120
- loader: "js"
3121
- }));
3122
- }
3123
- };
3124
- }
3125
- /**
3126
- * esbuild plugin that intercepts `import … from 'vitest'` and replaces it with
3127
- * a virtual module that delegates every named import to `globalThis` at ACCESS
3128
- * time (not at bundle-evaluation time).
3054
+ * The phone connects to the relay as a `target`; this module connects as a
3055
+ * `client` (the role a CDP frontend would take) so CDP events the page emits
3056
+ * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
3057
+ * events in ring buffers the tool layer reads via `getBufferedEvents`.
3129
3058
  *
3130
- * The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/
3131
- * afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle
3132
- * IIFE is evaluated. A value-copy redirect (`export var describe =
3133
- * globalThis.describe`) would therefore capture `undefined` at evaluation time
3134
- * and the user's `describe(...)` calls would be no-ops — registering zero tests.
3059
+ * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
3135
3060
  *
3136
- * The fix defers the lookup to call time using per-name **getter** exports.
3137
- * We emit a CommonJS module that:
3138
- * 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named
3139
- * import directly to a property access on the module (NOT wrapped under a
3140
- * `default` shim which is what happens for a bare Proxy whose own-keys
3141
- * are empty, leaving every named import `undefined`);
3142
- * 2. defines each global name as a getter that reads `globalThis[name]` on
3143
- * every access. So `import { describe } from 'vitest'` compiles to
3144
- * `import_vitest.describe`, whose getter returns the real `describe` only
3145
- * when the factory calls it after `runTestModule` installs the globals.
3061
+ * Attach reliability (#281):
3062
+ * `refreshTargets()` emits an internal 'target:attached' event whenever a
3063
+ * new target is added to the relay. `waitForFirstTarget()` awaits that event
3064
+ * (with a polling-interval fallback) so `start_attach`'s attach wait
3065
+ * resolves deterministically rather than racing between polling rounds.
3066
+ */
3067
+ /** Max events retained per domain ring buffer. */
3068
+ const DEFAULT_BUFFER_SIZE$1 = 500;
3069
+ /**
3070
+ * Substrings that mark a "relay websocket is dead" class error, as produced by
3071
+ * `handleDisconnect('relay WebSocket 연결이 끊겼습니다')` (ws close handler) and
3072
+ * the fail-fast `sendCommand` rejection (`relay에 연결되어 있지 않습니다 (...)`).
3146
3073
  *
3147
- * A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes
3148
- * the virtual module through `__toESM`, which enumerates own-keys (none on an
3149
- * empty Proxy target) and therefore exposes zero named exports. Explicit getter
3150
- * properties give `__toESM` real keys to map while keeping access lazy.
3074
+ * Exported so callers outside this module (e.g. `test-runner/relay-worker.ts`)
3075
+ * can detect the same error class without hardcoding a fragile full-sentence
3076
+ * match mirrors the `EVALUATE_TIMEOUT_MARKER` precedent in
3077
+ * `test-runner/relay-worker.ts`. Kept in sync with `classifyToolError`'s
3078
+ * `relayDisconnectError` branch in `mcp/errors.ts`.
3151
3079
  */
3152
- function vitestRedirectPlugin() {
3153
- return {
3154
- name: "vitest-redirect",
3155
- setup(build) {
3156
- build.onResolve({ filter: /^vitest$/ }, () => ({
3157
- path: "vitest",
3158
- namespace: "vitest-redirect"
3159
- }));
3160
- build.onLoad({
3161
- filter: /^vitest$/,
3162
- namespace: "vitest-redirect"
3163
- }, () => {
3164
- return {
3165
- contents: `Object.defineProperty(exports, '__esModule', { value: true });\n${VITEST_GLOBAL_NAMES.map((name) => `Object.defineProperty(exports, ${JSON.stringify(name)}, { enumerable: true, get: function() { return globalThis[${JSON.stringify(name)}]; } });`).join("\n")}\n`,
3166
- loader: "js"
3167
- };
3168
- });
3169
- }
3170
- };
3080
+ const RELAY_DISCONNECT_MARKERS = ["relay WebSocket", "relay에 연결되어 있지 않습니다"];
3081
+ /**
3082
+ * Returns true when `message` matches the relay-websocket-dead error class
3083
+ * (socket closed, socket errored, or fail-fast "not connected" rejection).
3084
+ */
3085
+ function isRelayDisconnectMessage(message) {
3086
+ return RELAY_DISCONNECT_MARKERS.some((marker) => message.includes(marker));
3087
+ }
3088
+ function isObject$3(value) {
3089
+ return typeof value === "object" && value !== null;
3090
+ }
3091
+ function parseInbound$1(raw) {
3092
+ let parsed;
3093
+ try {
3094
+ parsed = JSON.parse(raw);
3095
+ } catch {
3096
+ return null;
3097
+ }
3098
+ if (!isObject$3(parsed)) return null;
3099
+ const message = {};
3100
+ if (typeof parsed.id === "number") message.id = parsed.id;
3101
+ if (typeof parsed.method === "string") message.method = parsed.method;
3102
+ if ("params" in parsed) message.params = parsed.params;
3103
+ if ("result" in parsed) message.result = parsed.result;
3104
+ if (isObject$3(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
3105
+ return message;
3171
3106
  }
3107
+ const PHASE_1_EVENTS$1 = [
3108
+ "Runtime.consoleAPICalled",
3109
+ "Network.requestWillBeSent",
3110
+ "Network.responseReceived"
3111
+ ];
3172
3112
  /**
3173
- * esbuild plugin that transforms the user test file into a module that exports
3174
- * an async `__userFactory` function. The factory defers the user's top-level
3175
- * test registration code (describe/it/test calls) so it only runs when
3176
- * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
3177
- * installed describe/it/test/expect as globals.
3113
+ * Ring buffer size for `Runtime.exceptionThrown`.
3178
3114
  *
3179
- * Algorithm:
3180
- * - Import declarations and re-export statements are kept at module top-level
3181
- * (the only valid ESM position for static `import`). A statement that spans
3182
- * multiple lines — e.g. a named import with one member per line:
3183
- * import {
3184
- * appLogin,
3185
- * getAnonymousKey,
3186
- * } from '@apps-in-toss/web-framework';
3187
- * is tracked as a single block: every line from the opening `import {` /
3188
- * `export {` through the closing `from '…'` (or side-effect `'…'`) line is
3189
- * kept together at top-level. This prevents the member lines and the
3190
- * closing `} from '…'` line from leaking into the factory body, which would
3191
- * leave an unterminated `import {` at module scope (the #678 env3 failure:
3192
- * esbuild threw `Expected "as" but found "{"` on multi-line SDK imports).
3193
- * - All other lines (describe/it/test calls, local declarations, etc.) are
3194
- * moved into the body of the exported async factory function.
3115
+ * Exceptions are rarer than console messages but each is heavier (stack
3116
+ * trace). 50 is generous enough to cover a crash scenario while keeping
3117
+ * memory bounded.
3195
3118
  *
3196
- * This preserves SDK import resolution (the sdk-redirect plugin processes
3197
- * top-level imports normally) while deferring test registration to the factory.
3119
+ * **Lifecycle note**: the exception buffer intentionally survives `replaced` /
3120
+ * `crashed` / `destroyed` lifecycle events it is NOT cleared on target
3121
+ * transitions. Rationale: an exception fired just before a crash is exactly
3122
+ * the signal we want to preserve for root-cause analysis. The buffer
3123
+ * represents "exceptions seen in this MCP session", not "exceptions in the
3124
+ * current page".
3198
3125
  */
3199
- function userFactoryPlugin(absPath) {
3200
- const NAMESPACE = "user-test-factory";
3201
- return {
3202
- name: "user-test-factory",
3203
- setup(build) {
3204
- build.onResolve({ filter: /^user-test-factory$/ }, () => ({
3205
- path: absPath,
3206
- namespace: NAMESPACE
3207
- }));
3208
- build.onLoad({
3209
- filter: /.*/,
3210
- namespace: NAMESPACE
3211
- }, async (args) => {
3212
- const lines = (await fs.readFile(args.path, "utf8")).split("\n");
3213
- const topLevelLines = [];
3214
- const bodyLines = [];
3215
- const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
3216
- const isImportStart = (trimmed) => trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"");
3217
- const endsStatement = (trimmed) => /['"]\s*;?\s*$/.test(trimmed.replace(/\/\/.*$/, "").trimEnd());
3218
- let inImportBlock = false;
3219
- for (const line of lines) {
3220
- const trimmed = line.trimStart();
3221
- const indent = line.slice(0, line.length - trimmed.length);
3222
- if (inImportBlock) {
3223
- topLevelLines.push(line);
3224
- if (endsStatement(trimmed)) inImportBlock = false;
3225
- continue;
3226
- }
3227
- if (isImportStart(trimmed)) {
3228
- topLevelLines.push(line);
3229
- if (!endsStatement(trimmed)) inImportBlock = true;
3230
- } else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
3231
- else {
3232
- topLevelLines.push(line);
3233
- if (/\bfrom\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith("{")) inImportBlock = true;
3234
- }
3235
- else bodyLines.push(line);
3236
- }
3237
- return {
3238
- contents: [
3239
- ...topLevelLines,
3240
- "",
3241
- "// biome-ignore lint: generated factory wrapper",
3242
- "export default async function __userFactory(): Promise<void> {",
3243
- ...bodyLines.map((l) => ` ${l}`),
3244
- "}"
3245
- ].join("\n"),
3246
- loader: "ts",
3247
- resolveDir: path.dirname(absPath)
3248
- };
3249
- });
3250
- }
3251
- };
3252
- }
3126
+ const EXCEPTION_BUFFER_SIZE = 50;
3127
+ /** Default per-command timeout if neither option nor env var is set. */
3128
+ const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
3253
3129
  /**
3254
- * Returns the absolute filesystem path to the test-runner runtime module
3255
- * (dist/test-runner/runtime.js a fully self-contained page-side bundle).
3256
- *
3257
- * Rolldown code-splitting duplicates this bundling logic into shared chunks
3258
- * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
3259
- * dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`
3260
- * daemon (dist/mcp/cli.js) pulls it through a ROOT chunk
3261
- * (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list
3262
- * is therefore wrong from at least one chunk — the live #697 regression.
3263
- *
3264
- * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
3265
- * probes the co-located `runtime.js` and the nested `test-runner/runtime.js`,
3266
- * then ascends one directory at a time (bounded) repeating both probes. The
3267
- * nested probe catches dist/test-runner/runtime.js from the dist/ root level no
3268
- * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
3269
- * relocation). The build always emits dist/test-runner/runtime.js (tsdown entry
3270
- * `'test-runner/runtime'`; guarded by scripts/check-test-runner-dist.sh).
3271
- *
3272
- * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
3273
- * read, bypassing Node module resolution entirely, so this works identically in
3274
- * the npx-daemon context (its own dist tree) and the consumer-CLI context
3275
- * (the mini-app's installed @ait-co/devtools dist) — neither needs the package
3276
- * to be node-resolvable from the caller.
3130
+ * Production CDP connection. Polls the relay for the first attached target,
3131
+ * opens a client websocket to it, enables Phase 1 domains, and buffers events.
3277
3132
  */
3278
- function getRuntimePath() {
3279
- const startDir = path.dirname(fileURLToPath(import.meta.url));
3280
- const RELATIVE_PROBES = [
3281
- ["runtime.js"],
3282
- ["test-runner", "runtime.js"],
3283
- ["runtime.ts"],
3284
- ["test-runner", "runtime.ts"]
3285
- ];
3286
- let dir = startDir;
3287
- for (let i = 0; i < 12; i++) {
3288
- for (const segs of RELATIVE_PROBES) {
3289
- const candidate = path.join(dir, ...segs);
3290
- try {
3291
- accessSync(candidate);
3292
- return candidate;
3293
- } catch {}
3294
- }
3295
- const parent = path.dirname(dir);
3296
- if (parent === dir) break;
3297
- dir = parent;
3133
+ var ChiiCdpConnection = class {
3134
+ /** Authoritative connection kind (issue #348) — relay-backed. */
3135
+ kind = "relay";
3136
+ relayBaseUrl;
3137
+ bufferSize;
3138
+ commandTimeoutMs;
3139
+ totpSecret;
3140
+ emitter = new EventEmitter();
3141
+ buffers = /* @__PURE__ */ new Map();
3142
+ targets = /* @__PURE__ */ new Map();
3143
+ ws = null;
3144
+ connectionState = "idle";
3145
+ nextCommandId = 1;
3146
+ /**
3147
+ * The single active target id under the single-attach model.
3148
+ * Updated by `refreshTargets()` whenever a non-null target is present.
3149
+ * Used to detect a new (different) target attach and evict the previous one.
3150
+ */
3151
+ activeTargetId = null;
3152
+ /** In-flight enableDomains() promise — concurrent callers share it. */
3153
+ enablingPromise = null;
3154
+ /** Pending request→response commands keyed by CDP message id. */
3155
+ pending = /* @__PURE__ */ new Map();
3156
+ /**
3157
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
3158
+ * or `null` if no crash has been detected since the last `enableDomains()`.
3159
+ */
3160
+ lastCrashDetectedAt = null;
3161
+ /**
3162
+ * Per-target last-seen timestamp (ms since epoch). Updated on any inbound
3163
+ * CDP message carrying data from a target. Keyed by target id.
3164
+ */
3165
+ targetLastSeenAt = /* @__PURE__ */ new Map();
3166
+ /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
3167
+ heartbeatHandle = null;
3168
+ /** Lifecycle event listeners (crash / destroyed / detached). */
3169
+ lifecycleListeners = [];
3170
+ constructor(options) {
3171
+ this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
3172
+ this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE$1;
3173
+ this.totpSecret = options.totpSecret;
3174
+ const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
3175
+ this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
3176
+ for (const event of PHASE_1_EVENTS$1) this.buffers.set(event, []);
3177
+ this.buffers.set("Runtime.exceptionThrown", []);
3178
+ this.emitter.setMaxListeners(0);
3298
3179
  }
3299
- return path.join(startDir, "runtime.js");
3300
- }
3301
- /**
3302
- * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
3303
- *
3304
- * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
3305
- * - `runTestModule` — the runtime entry (from `runtime.ts`).
3306
- * - `__userFactory` — an async function wrapping the user's test registration
3307
- * code so it runs AFTER `runTestModule` installs the globals.
3308
- *
3309
- * Callers (rpc.ts) invoke:
3310
- * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
3311
- *
3312
- * @param absPath - Absolute path to the user test file.
3313
- * @param opts - Optional bundling overrides.
3314
- */
3315
- async function bundleTestFile(absPath, opts) {
3316
- const globalName = opts?.globalName ?? "__testBundle";
3317
- const extraExternals = opts?.extraExternals ?? [];
3318
- const esbuild = await import("esbuild");
3319
- const runtimePath = getRuntimePath();
3320
- const wrapperContent = [
3321
- `import { runTestModule } from ${JSON.stringify(runtimePath)};`,
3322
- `import __userFactory from "user-test-factory";`,
3323
- `export { runTestModule, __userFactory };`
3324
- ].join("\n");
3325
- const result = await esbuild.build({
3326
- stdin: {
3327
- contents: wrapperContent,
3328
- loader: "ts",
3329
- resolveDir: path.dirname(absPath)
3330
- },
3331
- bundle: true,
3332
- format: "iife",
3333
- globalName,
3334
- platform: "browser",
3335
- target: "es2022",
3336
- write: false,
3337
- plugins: [
3338
- userFactoryPlugin(absPath),
3339
- vitestRedirectPlugin(),
3340
- sdkRedirectPlugin()
3341
- ],
3342
- external: extraExternals,
3343
- treeShaking: true,
3344
- footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
3345
- });
3346
- const warnings = result.warnings.map((w) => `${path.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
3347
- const outputFile = result.outputFiles?.[0];
3348
- if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
3349
- return {
3350
- code: outputFile.text,
3351
- warnings
3352
- };
3353
- }
3354
- //#endregion
3355
- //#region src/test-runner/capture.ts
3356
- /** The exact console-line prefix sdk-example's `flushCapture` emits. */
3357
- const CAPTURE_PREFIX = "__AIT_CAPTURE__ ";
3358
- /**
3359
- * Parses raw console line texts into {@link AitCaptureLine}s.
3360
- *
3361
- * Filtering rules (each independently drops a line — never throws):
3362
- * - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);
3363
- * - there must be a non-empty category token (up to the next space);
3364
- * - the remaining payload must be valid JSON (`JSON.parse` succeeds).
3365
- *
3366
- * Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are
3367
- * silently discarded — capture harvesting is best-effort and must never fail a
3368
- * run or leak a malformed/secret-bearing line.
3369
- *
3370
- * @param raw - Console line objects (only `.text` is read).
3371
- * @returns The captured lines, in input order.
3372
- */
3373
- function parseCaptureLines(raw) {
3374
- const out = [];
3375
- for (const { text } of raw) {
3376
- if (!text.startsWith(CAPTURE_PREFIX)) continue;
3377
- const body = text.slice(16);
3378
- const spaceIdx = body.indexOf(" ");
3379
- if (spaceIdx === -1) continue;
3380
- const category = body.slice(0, spaceIdx);
3381
- const json = body.slice(spaceIdx + 1);
3382
- if (category === "" || json === "") continue;
3383
- try {
3384
- JSON.parse(json);
3385
- } catch {
3386
- continue;
3180
+ /** Refresh the attached-target list from the relay's `GET /targets`. */
3181
+ async refreshTargets() {
3182
+ let targetsUrl = `${this.relayBaseUrl}/targets`;
3183
+ if (this.totpSecret) {
3184
+ const code = generateTotp(this.totpSecret);
3185
+ targetsUrl += `?at=${encodeURIComponent(code)}`;
3387
3186
  }
3388
- out.push({
3389
- category,
3390
- json
3391
- });
3392
- }
3393
- return out;
3394
- }
3395
- //#endregion
3396
- //#region src/test-runner/rpc.ts
3397
- /** Maximum milliseconds to wait for a single evaluate round-trip. */
3398
- const DEFAULT_TIMEOUT_MS = 3e4;
3399
- /**
3400
- * Wraps bundle code in a self-executing IIFE that:
3401
- * 1. Evaluates the bundle (registering describe/it/test).
3402
- * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
3403
- * 3. Returns a JSON-serialised `RunReport` string.
3404
- *
3405
- * The double-serialisation (RunReport JSON string → returnByValue string)
3406
- * is intentional: CDP `returnByValue` reliably transports strings; deeply
3407
- * nested objects can lose fidelity across the Chii relay.
3408
- *
3409
- * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
3410
- */
3411
- function buildRunTestsExpression(bundleCode) {
3412
- return `(async () => { try { ${bundleCode} } catch(e) { return JSON.stringify({ok:false,error:'bundle-eval: ' + String(e && e.message || e)}); } if (typeof globalThis.__testBundle !== 'object' || typeof globalThis.__testBundle.runTestModule !== 'function' || typeof globalThis.__testBundle.__userFactory !== 'function') { return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule or __userFactory is not a function'}); } try { const report = await globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory); return JSON.stringify({ok:true,value:report}); } catch(e) { return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)}); }})()`;
3413
- }
3414
- /**
3415
- * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
3416
- * evaluate call into a typed `RpcRunResult`.
3417
- *
3418
- * Throws only on parse failure — an `ok:false` envelope is a normal result.
3419
- *
3420
- * SECRET-HANDLING: `rawValue` is not included in error messages.
3421
- */
3422
- function parseRunTestsResult(rawValue) {
3423
- if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
3424
- let parsed;
3425
- try {
3426
- parsed = JSON.parse(rawValue);
3427
- } catch {
3428
- throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
3429
- }
3430
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
3431
- const obj = parsed;
3432
- if (obj.ok === true) return {
3433
- ok: true,
3434
- report: obj.value
3435
- };
3436
- if (obj.ok === false) return {
3437
- ok: false,
3438
- error: typeof obj.error === "string" ? obj.error : String(obj.error)
3439
- };
3440
- throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
3441
- }
3442
- /**
3443
- * Injects `bundleCode` into the attached page and awaits test execution.
3444
- *
3445
- * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
3446
- * async IIFE to settle. The 30-second CDP command timeout covers even
3447
- * long-running test suites; split into smaller files if you hit it.
3448
- *
3449
- * @param connection - Active CDP connection (relay or local).
3450
- * @param bundleCode - IIFE bundle string from `bundleTestFile`.
3451
- * @param timeoutMs - Override the default 30 s timeout.
3452
- *
3453
- * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
3454
- */
3455
- async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
3456
- const expression = buildRunTestsExpression(bundleCode);
3457
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`rpc: evaluate timed out after ${timeoutMs}ms`)), timeoutMs));
3458
- const evalPromise = connection.send("Runtime.evaluate", {
3459
- expression,
3460
- returnByValue: true,
3461
- awaitPromise: true
3462
- });
3463
- const cdpResult = await Promise.race([evalPromise, timeoutPromise]);
3464
- if (cdpResult.exceptionDetails) {
3465
- const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
3466
- throw new Error(`rpc.injectAndRunBundle: ${msg}`);
3187
+ const res = await fetch(targetsUrl);
3188
+ if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
3189
+ const body = await res.json();
3190
+ const list = isObject$3(body) && Array.isArray(body.targets) ? body.targets : [];
3191
+ let newestTargetId = null;
3192
+ for (const item of list) {
3193
+ if (!isObject$3(item) || typeof item.id !== "string") continue;
3194
+ newestTargetId = item.id;
3195
+ }
3196
+ if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
3197
+ const prevId = this.activeTargetId;
3198
+ logInfo("page.detached", { prevTargetId: prevId });
3199
+ this.evictTarget(prevId);
3200
+ }
3201
+ this.targets.clear();
3202
+ for (const item of list) {
3203
+ if (!isObject$3(item) || typeof item.id !== "string") continue;
3204
+ if (item.id !== newestTargetId) continue;
3205
+ this.targets.set(item.id, {
3206
+ id: item.id,
3207
+ title: typeof item.title === "string" ? item.title : "",
3208
+ url: typeof item.url === "string" ? item.url : ""
3209
+ });
3210
+ }
3211
+ if (newestTargetId !== null) this.activeTargetId = newestTargetId;
3212
+ else this.activeTargetId = null;
3213
+ const result = [...this.targets.values()];
3214
+ if (newestTargetId !== null) this.emitter.emit("target:attached", result);
3215
+ return result;
3467
3216
  }
3468
- return parseRunTestsResult(cdpResult.result.value);
3469
- }
3470
- //#endregion
3471
- //#region src/test-runner/relay-worker.ts
3472
- /**
3473
- * Sentinel string embedded in the error message by `injectAndRunBundle` when
3474
- * the per-file evaluate race hits the timeout. Used by the retry guard so only
3475
- * genuine timeouts get a second attempt — not bundle errors or parse failures.
3476
- *
3477
- * Exported for unit tests that assert the retry path is taken.
3478
- */
3479
- const EVALUATE_TIMEOUT_MARKER = "rpc: evaluate timed out after";
3480
- /**
3481
- * Runs all `files` sequentially over the given CDP `connection`.
3482
- *
3483
- * For each file:
3484
- * 1. Bundle with esbuild (includes SDK shim + runtime).
3485
- * 2. Inject into the attached page via `Runtime.evaluate`.
3486
- * 3. Await the `RunReport` JSON response.
3487
- * 4. Accumulate results.
3488
- *
3489
- * Returns a `RelayRunReport` with per-file results and flattened totals.
3490
- *
3491
- * This function does NOT open or manage the relay connection — the caller
3492
- * is responsible for attaching and closing it.
3493
- *
3494
- * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
3495
- * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
3496
- *
3497
- * @param connection - Active CDP connection (relay or local kind).
3498
- * @param files - Absolute paths to test files, run in order.
3499
- * @param opts - Optional per-run overrides.
3500
- */
3501
- async function runTestFilesOverRelay(connection, files, opts) {
3502
- const wallStart = Date.now();
3503
- const startedAt = new Date(wallStart).toISOString();
3504
- const fileResults = [];
3505
- let domainsEnabled = false;
3506
- try {
3507
- await connection.enableDomains();
3508
- domainsEnabled = true;
3509
- } catch (e) {
3510
- process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
3217
+ listTargets() {
3218
+ return [...this.targets.values()];
3511
3219
  }
3512
- const collectCaptures = opts?.collectCaptures === true;
3513
- const liveConsole = [];
3514
- let unsubscribeConsole;
3515
- if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
3516
- liveConsole.push(event);
3517
- });
3518
- try {
3519
- for (const file of files) {
3520
- let fileEntry;
3521
- try {
3522
- const { code } = await bundleTestFile(file, opts?.bundleOptions);
3523
- /**
3524
- * Runs one evaluate attempt and returns a FileResult, or `null` when the
3525
- * result is a genuine timeout and the caller should retry.
3526
- *
3527
- * We need to distinguish:
3528
- * - `rpcResult.ok = false` + timeout error → retry candidate
3529
- * - `rpcResult.ok = false` + other error → final error, no retry
3530
- * - `injectAndRunBundle` throws → treated like a final error
3531
- * (throws happen on CDP exceptionDetails, not on the Promise.race
3532
- * timeout — the timeout produces `rpcResult.ok=false` with the
3533
- * EVALUATE_TIMEOUT_MARKER message)
3534
- */
3535
- const attempt = async () => {
3536
- let rpcResult;
3537
- try {
3538
- rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
3539
- } catch (e) {
3540
- return {
3541
- file,
3542
- result: { error: e instanceof Error ? e.message : String(e) }
3543
- };
3544
- }
3545
- if (rpcResult.ok) return {
3546
- file,
3547
- result: rpcResult.report
3548
- };
3549
- if (rpcResult.error.includes("rpc: evaluate timed out after")) return null;
3550
- return {
3551
- file,
3552
- result: { error: rpcResult.error }
3553
- };
3554
- };
3555
- const firstResult = await attempt();
3556
- if (firstResult !== null) fileEntry = firstResult;
3557
- else {
3558
- process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\n`);
3559
- const retryResult = await attempt();
3560
- if (retryResult !== null) fileEntry = retryResult;
3561
- else fileEntry = {
3562
- file,
3563
- result: { error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 3e4}ms (after retry)` }
3564
- };
3220
+ /**
3221
+ * Waits until at least one target matching `filterFn` is attached, then
3222
+ * resolves with the full target list at that moment.
3223
+ *
3224
+ * Resolution happens on whichever comes first:
3225
+ * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
3226
+ * the /targets poll finding a new target), OR
3227
+ * (b) a `'target:attached'` event from `handleMessage()` (triggered by
3228
+ * the first inbound CDP message from a target — confirms the relay
3229
+ * websocket has data from the phone, not just a target entry in the map).
3230
+ *
3231
+ * This dual-signal approach eliminates the polling race that previously
3232
+ * caused `wait_for_attach` to resolve before the first CDP message arrived.
3233
+ *
3234
+ * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
3235
+ * EventEmitter is missed (defensive belt-and-suspenders).
3236
+ *
3237
+ * @param filterFn - Predicate that the returned targets must satisfy.
3238
+ * @param timeoutMs - Reject after this many ms (default 90 000).
3239
+ * @param pollIntervalMs - Fallback poll interval (default 500ms).
3240
+ */
3241
+ waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
3242
+ const current = this.listTargets();
3243
+ if (filterFn(current)) return Promise.resolve(current);
3244
+ return new Promise((resolve, reject) => {
3245
+ let settled = false;
3246
+ let pollHandle = null;
3247
+ const settle = (targets) => {
3248
+ if (settled) return;
3249
+ settled = true;
3250
+ clearTimeout(timeoutHandle);
3251
+ if (pollHandle !== null) {
3252
+ clearInterval(pollHandle);
3253
+ pollHandle = null;
3565
3254
  }
3566
- } catch (e) {
3567
- fileEntry = {
3568
- file,
3569
- result: { error: e instanceof Error ? e.message : String(e) }
3570
- };
3571
- }
3572
- fileResults.push(fileEntry);
3573
- }
3574
- } finally {
3575
- unsubscribeConsole?.();
3255
+ this.emitter.off("target:attached", onAttach);
3256
+ resolve(targets);
3257
+ };
3258
+ const onAttach = (targets) => {
3259
+ if (filterFn(targets)) settle(targets);
3260
+ };
3261
+ const timeoutHandle = setTimeout(() => {
3262
+ if (settled) return;
3263
+ settled = true;
3264
+ if (pollHandle !== null) {
3265
+ clearInterval(pollHandle);
3266
+ pollHandle = null;
3267
+ }
3268
+ this.emitter.off("target:attached", onAttach);
3269
+ reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
3270
+ }, timeoutMs);
3271
+ this.emitter.on("target:attached", onAttach);
3272
+ pollHandle = setInterval(() => {
3273
+ this.refreshTargets().then((targets) => {
3274
+ if (filterFn(targets)) settle(targets);
3275
+ }, () => {});
3276
+ }, pollIntervalMs);
3277
+ });
3576
3278
  }
3577
- const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
3578
- const totals = fileResults.reduce((acc, { result }) => {
3579
- if ("error" in result) {
3580
- acc.failed += 1;
3581
- acc.total += 1;
3582
- } else {
3583
- acc.passed += result.passed;
3584
- acc.failed += result.failed;
3585
- acc.skipped += result.skipped;
3586
- acc.total += result.passed + result.failed + result.skipped;
3587
- }
3588
- return acc;
3589
- }, {
3590
- passed: 0,
3591
- failed: 0,
3592
- skipped: 0,
3593
- total: 0
3594
- });
3595
- return {
3596
- startedAt,
3597
- duration: Date.now() - wallStart,
3598
- files: fileResults,
3599
- totals,
3600
- captures
3601
- };
3602
- }
3603
- /**
3604
- * Renders one `Runtime.consoleAPICalled` event to a single line of text, the
3605
- * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
3606
- * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
3607
- * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
3608
- * onto the test-runner entry.
3609
- *
3610
- * SECRET-HANDLING: this only stringifies console args; the caller's
3611
- * allowlist-prefix parser then discards everything that is not a genuine
3612
- * `__AIT_CAPTURE__` line.
3613
- */
3614
- function renderConsoleLineText(event) {
3615
- return event.args.map((arg) => {
3616
- if (arg.value !== void 0) {
3617
- if (typeof arg.value === "string") return arg.value;
3618
- try {
3619
- return JSON.stringify(arg.value);
3620
- } catch {
3621
- return String(arg.value);
3622
- }
3623
- }
3624
- if (arg.description !== void 0) return arg.description;
3625
- if (arg.className !== void 0) return arg.className;
3626
- return arg.subtype ?? arg.type;
3627
- }).join(" ");
3628
- }
3629
- `
3630
- devtools-test — run mini-app tests on a real device WebView over the CDP relay
3631
-
3632
- USAGE
3633
- devtools-test <glob> [<glob> ...] [options]
3634
-
3635
- OPTIONS
3636
- --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
3637
- (required for standalone relay attach / env3)
3638
- --timeout <ms> Per-file evaluate timeout in ms (default: 30000).
3639
- Controls how long a single test file is allowed to run
3640
- before it is considered hung. Does NOT affect how long
3641
- the CLI waits for a human to scan the QR code — use
3642
- --attach-timeout for that.
3643
- --attach-timeout <ms> How long to wait for a human to scan the QR code with
3644
- their phone (default: 600000 — 10 minutes). Omit to
3645
- use the relay factory's generous default. Decrease for
3646
- CI environments where a scan should arrive quickly.
3647
- --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
3648
- --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
3649
- (mock|ios|android, default: AIT_CELL_PLATFORM env)
3650
- --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
3651
- (report: <sdkLine>.<platform>.json; captures:
3652
- <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
3653
- Omitted = nothing saved. Enables console capture.
3654
- --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
3655
- non-interactive stdout / CI / AIT_NO_QR_STDOUT)
3656
- --headless Disable browser auto-open (text QR only)
3657
- --project-root <dir> Project root for .ait_relay secret lookup
3658
- (default: current working directory)
3659
- --help, -h Show this help message
3660
-
3661
- DESCRIPTION
3662
- Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
3663
- device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
3664
- each matched test file with esbuild (SDK imports redirected to window.__sdk),
3665
- injects the bundle into the attached WebView via Runtime.evaluate, and prints
3666
- a summary.
3667
-
3668
- With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
3669
- runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
3670
- compared offline.
3671
-
3672
- The test files run against the live relay connection started by this process;
3673
- no separate MCP daemon is required.
3674
-
3675
- EXAMPLE
3676
- devtools-test 'src/**/*.ait.test.ts' \\
3677
- --scheme-url "intoss-private://..." \\
3678
- --cell-sdk-line 3.x \\
3679
- --cell-platform ios \\
3680
- --report-dir .ait-report \\
3681
- --timeout 60000
3682
-
3683
- `.trimStart();
3684
- /**
3685
- * Renders per-file result lines and the aggregate totals line to a string.
3686
- *
3687
- * Each file gets one line:
3688
- * - Error/timeout: `FAIL <basename>: <error-class>`
3689
- * - Pass (0 tests): `OK <basename>: 0 passed (empty file)`
3690
- * - Pass: `OK <basename>: N passed[, M failed][, K skipped]`
3691
- *
3692
- * The aggregate totals line always follows.
3693
- *
3694
- * SECRET-HANDLING: only `basename(file)` is used — no absolute paths, relay
3695
- * URLs, wss URLs, scheme URLs, or TOTP codes appear in the output. The error
3696
- * string comes from `result.error` which is already secret-free (relay-worker
3697
- * produces only error-class messages like "rpc: evaluate timed out after
3698
- * 30000ms").
3699
- *
3700
- * Exported so unit tests can assert the per-file lines without spawning a
3701
- * subprocess or going through the full relay attach flow.
3702
- */
3703
- function renderSummary(report) {
3704
- const lines = [];
3705
- for (const { file, result } of report.files) {
3706
- const name = basename(file);
3707
- if ("error" in result) lines.push(`FAIL ${name}: ${result.error}`);
3708
- else {
3709
- const parts = [`${result.passed} passed`];
3710
- if (result.failed > 0) parts.push(`${result.failed} failed`);
3711
- if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
3712
- const suffix = result.passed + result.failed + result.skipped === 0 ? " (empty file)" : "";
3713
- lines.push(`OK ${name}: ${parts.join(", ")}${suffix}`);
3714
- }
3279
+ /**
3280
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
3281
+ * detected since the last `enableDomains()` call, or `null` if none.
3282
+ */
3283
+ getLastCrashDetectedAt() {
3284
+ return this.lastCrashDetectedAt;
3715
3285
  }
3716
- const { totals, duration } = report;
3717
- lines.push(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${duration}ms)`);
3718
- return lines.join("\n");
3719
- }
3720
- /**
3721
- * Runs `files` over `connection` and returns the aggregate report.
3722
- * This pure function is the testable core of the CLI (and is what the
3723
- * `run_tests` MCP tool calls against the daemon's attached connection); it is
3724
- * separate from `main()` so tests can call it without spawning a subprocess.
3725
- */
3726
- async function runWithConnection(connection, files, opts) {
3727
- const report = await runTestFilesOverRelay(connection, files, opts);
3728
- if (opts?.printSummary) process.stdout.write(`\n${renderSummary(report)}\n`);
3729
- return report;
3730
- }
3731
- //#endregion
3732
- //#region src/mcp/ait-chii-source.ts
3733
- function isObject$3(value) {
3734
- return typeof value === "object" && value !== null;
3735
- }
3736
- /** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
3737
- function asSdkCallHistory(raw) {
3738
- if (isObject$3(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
3739
- return { calls: [] };
3740
- }
3741
- /** Narrows an `AIT.getMockState` response to an opaque record. */
3742
- function asMockState(raw) {
3743
- return isObject$3(raw) ? raw : {};
3744
- }
3745
- /** Narrows an `AIT.getOperationalEnvironment` response. */
3746
- function asOperationalEnvironment(raw) {
3747
- return {
3748
- environment: isObject$3(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
3749
- sdkVersion: isObject$3(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
3750
- };
3751
- }
3752
- var ChiiAitSource = class {
3753
- constructor(sender) {
3754
- this.sender = sender;
3286
+ /**
3287
+ * Last-seen timestamp (ms since epoch) for a given target id, or `null` if
3288
+ * the target is unknown / no message has been received from it yet.
3289
+ */
3290
+ getTargetLastSeenAt(targetId) {
3291
+ return this.targetLastSeenAt.get(targetId) ?? null;
3755
3292
  }
3756
- async get(method) {
3757
- const raw = await this.sender.sendCommand(method);
3758
- switch (method) {
3759
- case "AIT.getSdkCallHistory": return asSdkCallHistory(raw);
3760
- case "AIT.getMockState": return asMockState(raw);
3761
- case "AIT.getOperationalEnvironment": return asOperationalEnvironment(raw);
3762
- default: throw new Error(`Unknown AIT method: ${String(method)}`);
3293
+ /** Subscribe to target lifecycle events (crash / destroyed / detached). */
3294
+ onLifecycle(listener) {
3295
+ this.lifecycleListeners.push(listener);
3296
+ return () => {
3297
+ const idx = this.lifecycleListeners.indexOf(listener);
3298
+ if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
3299
+ };
3300
+ }
3301
+ /**
3302
+ * Connect a client websocket to the first attached target and enable Phase 1
3303
+ * domains. Resolves once the socket is open and enable commands are sent.
3304
+ */
3305
+ async enableDomains() {
3306
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
3307
+ if (this.enablingPromise) return this.enablingPromise;
3308
+ this.enablingPromise = this._doEnableDomains().finally(() => {
3309
+ this.enablingPromise = null;
3310
+ });
3311
+ return this.enablingPromise;
3312
+ }
3313
+ async _doEnableDomains() {
3314
+ const target = (await this.refreshTargets())[0];
3315
+ if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
3316
+ let clientUrl = `${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`;
3317
+ if (this.totpSecret) {
3318
+ const code = generateTotp(this.totpSecret);
3319
+ clientUrl += `&at=${encodeURIComponent(code)}`;
3763
3320
  }
3321
+ const ws = new WebSocket(clientUrl);
3322
+ this.ws = ws;
3323
+ await new Promise((resolve, reject) => {
3324
+ ws.once("open", () => resolve());
3325
+ ws.once("error", (err) => reject(err));
3326
+ ws.once("close", (code) => {
3327
+ if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
3328
+ });
3329
+ });
3330
+ this.lastCrashDetectedAt = null;
3331
+ this.targetLastSeenAt.clear();
3332
+ this.connectionState = "connected";
3333
+ ws.on("message", (data) => this.handleMessage(data.toString()));
3334
+ ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
3335
+ ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
3336
+ this.sendFireAndForget("Runtime.enable");
3337
+ this.sendFireAndForget("Network.enable");
3338
+ this.sendFireAndForget("DOM.enable");
3339
+ this.sendFireAndForget("Page.enable");
3340
+ this.sendFireAndForget("Inspector.enable");
3341
+ this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
3342
+ this.startHeartbeat(target.id);
3764
3343
  }
3765
- };
3766
- //#endregion
3767
- //#region src/shared/relay-auth-close.ts
3768
- /**
3769
- * Shared constants for the relay's named TOTP-auth rejection (issue #478).
3770
- *
3771
- * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
3772
- * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
3773
- * indistinguishable from a network failure on the browser side — the
3774
- * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
3775
- * could not tell "stale TOTP code" apart from "tunnel down" and stayed
3776
- * silent. The fix is accept-then-close: complete the handshake, then close
3777
- * with an application close code that NAMES the rejection.
3778
- *
3779
- * Three parties share this contract:
3780
- * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
3781
- * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
3782
- * surfaces the code to the launcher shell;
3783
- * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
3784
- * as an auth failure on its own `/client` dial (defensive — #439's fresh
3785
- * code mint means it should not normally hit this).
3786
- *
3787
- * This module is intentionally dependency-free (no Node, no DOM) so it is
3788
- * safe to import from both the browser in-app bundle and the MCP daemon
3789
- * bundle.
3790
- *
3791
- * SECRET-HANDLING: these are fixed enum values. The close reason / error body
3792
- * must never grow to carry a secret, a TOTP code, or a host.
3793
- */
3794
- /**
3795
- * WebSocket close code sent by the relay when TOTP auth is rejected.
3796
- *
3797
- * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
3798
- * HTTP 401 so it reads as "unauthorized" at a glance.
3799
- */
3800
- const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
3801
- /**
3802
- * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
3803
- * the `error` value of the relay's HTTP 401 JSON body. Enum string only —
3804
- * never interpolated with request data.
3805
- */
3806
- const RELAY_AUTH_REJECT_REASON = "totp-rejected";
3807
- //#endregion
3808
- //#region src/mcp/chii-connection.ts
3809
- /**
3810
- * Production `CdpConnection` backed by the local Chii relay.
3811
- *
3812
- * Topology (debug mode):
3813
- * phone target.js --WS--> Chii relay :9100 <--WS-- this connection
3814
- *
3815
- * The phone connects to the relay as a `target`; this module connects as a
3816
- * `client` (the role a CDP frontend would take) so CDP events the page emits
3817
- * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
3818
- * events in ring buffers the tool layer reads via `getBufferedEvents`.
3819
- *
3820
- * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
3821
- *
3822
- * Attach reliability (#281):
3823
- * `refreshTargets()` emits an internal 'target:attached' event whenever a
3824
- * new target is added to the relay. `waitForFirstTarget()` awaits that event
3825
- * (with a polling-interval fallback) so `start_attach`'s attach wait
3826
- * resolves deterministically rather than racing between polling rounds.
3827
- */
3828
- /** Max events retained per domain ring buffer. */
3829
- const DEFAULT_BUFFER_SIZE$1 = 500;
3830
- function isObject$2(value) {
3831
- return typeof value === "object" && value !== null;
3832
- }
3833
- function parseInbound$1(raw) {
3834
- let parsed;
3835
- try {
3836
- parsed = JSON.parse(raw);
3837
- } catch {
3838
- return null;
3344
+ /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
3345
+ sendFireAndForget(method, params = {}) {
3346
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
3347
+ const id = this.nextCommandId++;
3348
+ this.ws.send(JSON.stringify({
3349
+ id,
3350
+ method,
3351
+ params
3352
+ }));
3839
3353
  }
3840
- if (!isObject$2(parsed)) return null;
3841
- const message = {};
3842
- if (typeof parsed.id === "number") message.id = parsed.id;
3843
- if (typeof parsed.method === "string") message.method = parsed.method;
3844
- if ("params" in parsed) message.params = parsed.params;
3845
- if ("result" in parsed) message.result = parsed.result;
3846
- if (isObject$2(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
3847
- return message;
3848
- }
3849
- const PHASE_1_EVENTS$1 = [
3850
- "Runtime.consoleAPICalled",
3851
- "Network.requestWillBeSent",
3852
- "Network.responseReceived"
3853
- ];
3854
- /**
3855
- * Ring buffer size for `Runtime.exceptionThrown`.
3856
- *
3857
- * Exceptions are rarer than console messages but each is heavier (stack
3858
- * trace). 50 is generous enough to cover a crash scenario while keeping
3859
- * memory bounded.
3860
- *
3861
- * **Lifecycle note**: the exception buffer intentionally survives `replaced` /
3862
- * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
3863
- * transitions. Rationale: an exception fired just before a crash is exactly
3864
- * the signal we want to preserve for root-cause analysis. The buffer
3865
- * represents "exceptions seen in this MCP session", not "exceptions in the
3866
- * current page".
3867
- */
3868
- const EXCEPTION_BUFFER_SIZE = 50;
3869
- /** Default per-command timeout if neither option nor env var is set. */
3870
- const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
3871
- /**
3872
- * Production CDP connection. Polls the relay for the first attached target,
3873
- * opens a client websocket to it, enables Phase 1 domains, and buffers events.
3874
- */
3875
- var ChiiCdpConnection = class {
3876
- /** Authoritative connection kind (issue #348) — relay-backed. */
3877
- kind = "relay";
3878
- relayBaseUrl;
3879
- bufferSize;
3880
- commandTimeoutMs;
3881
- totpSecret;
3882
- emitter = new EventEmitter();
3883
- buffers = /* @__PURE__ */ new Map();
3884
- targets = /* @__PURE__ */ new Map();
3885
- ws = null;
3886
- connectionState = "idle";
3887
- nextCommandId = 1;
3888
- /**
3889
- * The single active target id under the single-attach model.
3890
- * Updated by `refreshTargets()` whenever a non-null target is present.
3891
- * Used to detect a new (different) target attach and evict the previous one.
3892
- */
3893
- activeTargetId = null;
3894
- /** In-flight enableDomains() promise — concurrent callers share it. */
3895
- enablingPromise = null;
3896
- /** Pending request→response commands keyed by CDP message id. */
3897
- pending = /* @__PURE__ */ new Map();
3898
3354
  /**
3899
- * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
3900
- * or `null` if no crash has been detected since the last `enableDomains()`.
3355
+ * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
3356
+ * error frame or when no websocket is open (no page attached yet).
3901
3357
  */
3902
- lastCrashDetectedAt = null;
3358
+ send(method, params) {
3359
+ return this.sendCommand(method, params ?? {});
3360
+ }
3903
3361
  /**
3904
- * Per-target last-seen timestamp (ms since epoch). Updated on any inbound
3905
- * CDP message carrying data from a target. Keyed by target id.
3362
+ * Issue an arbitrary request→response command over the relay and resolve with
3363
+ * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
3364
+ * `AIT.*` methods, forwarded over the same Chii channel) build on this.
3365
+ *
3366
+ * Rejects immediately if the connection is disconnected (fail-fast — no
3367
+ * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
3368
+ * reattach.
3369
+ *
3370
+ * Times out after `commandTimeoutMs` (default 30s, env
3371
+ * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
3372
+ * and the promise rejects with a descriptive Korean error.
3906
3373
  */
3907
- targetLastSeenAt = /* @__PURE__ */ new Map();
3908
- /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
3909
- heartbeatHandle = null;
3910
- /** Lifecycle event listeners (crash / destroyed / detached). */
3911
- lifecycleListeners = [];
3912
- constructor(options) {
3913
- this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
3914
- this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE$1;
3915
- this.totpSecret = options.totpSecret;
3916
- const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
3917
- this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
3918
- for (const event of PHASE_1_EVENTS$1) this.buffers.set(event, []);
3919
- this.buffers.set("Runtime.exceptionThrown", []);
3920
- this.emitter.setMaxListeners(0);
3921
- }
3922
- /** Refresh the attached-target list from the relay's `GET /targets`. */
3923
- async refreshTargets() {
3924
- let targetsUrl = `${this.relayBaseUrl}/targets`;
3925
- if (this.totpSecret) {
3926
- const code = generateTotp(this.totpSecret);
3927
- targetsUrl += `?at=${encodeURIComponent(code)}`;
3928
- }
3929
- const res = await fetch(targetsUrl);
3930
- if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
3931
- const body = await res.json();
3932
- const list = isObject$2(body) && Array.isArray(body.targets) ? body.targets : [];
3933
- let newestTargetId = null;
3934
- for (const item of list) {
3935
- if (!isObject$2(item) || typeof item.id !== "string") continue;
3936
- newestTargetId = item.id;
3937
- }
3938
- if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
3939
- const prevId = this.activeTargetId;
3940
- logInfo("page.detached", { prevTargetId: prevId });
3941
- this.evictTarget(prevId);
3942
- }
3943
- this.targets.clear();
3944
- for (const item of list) {
3945
- if (!isObject$2(item) || typeof item.id !== "string") continue;
3946
- if (item.id !== newestTargetId) continue;
3947
- this.targets.set(item.id, {
3948
- id: item.id,
3949
- title: typeof item.title === "string" ? item.title : "",
3950
- url: typeof item.url === "string" ? item.url : ""
3374
+ sendCommand(method, params = {}) {
3375
+ if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
3376
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No mini-app page attached to the Chii relay yet. Call enableDomains() first."));
3377
+ const id = this.nextCommandId++;
3378
+ const ws = this.ws;
3379
+ const timeoutMs = this.commandTimeoutMs;
3380
+ return new Promise((resolve, reject) => {
3381
+ const handle = setTimeout(() => {
3382
+ this.pending.delete(id);
3383
+ reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
3384
+ }, timeoutMs);
3385
+ this.pending.set(id, {
3386
+ resolve: (v) => {
3387
+ clearTimeout(handle);
3388
+ resolve(v);
3389
+ },
3390
+ reject: (e) => {
3391
+ clearTimeout(handle);
3392
+ reject(e);
3393
+ }
3951
3394
  });
3952
- }
3953
- if (newestTargetId !== null) this.activeTargetId = newestTargetId;
3954
- else this.activeTargetId = null;
3955
- const result = [...this.targets.values()];
3956
- if (newestTargetId !== null) this.emitter.emit("target:attached", result);
3957
- return result;
3395
+ ws.send(JSON.stringify({
3396
+ id,
3397
+ method,
3398
+ params
3399
+ }));
3400
+ });
3958
3401
  }
3959
- listTargets() {
3960
- return [...this.targets.values()];
3402
+ /**
3403
+ * Called on WebSocket `close` or `error` after a successful connection.
3404
+ * Rejects all pending commands and marks the connection as disconnected so
3405
+ * subsequent `sendCommand` calls fail fast (no auto-reconnect).
3406
+ */
3407
+ handleDisconnect(reason) {
3408
+ if (this.connectionState === "disconnected") return;
3409
+ this.connectionState = "disconnected";
3410
+ this.ws = null;
3411
+ this.stopHeartbeat();
3412
+ const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
3413
+ for (const waiter of this.pending.values()) waiter.reject(err);
3414
+ this.pending.clear();
3961
3415
  }
3962
3416
  /**
3963
- * Waits until at least one target matching `filterFn` is attached, then
3964
- * resolves with the full target list at that moment.
3965
- *
3966
- * Resolution happens on whichever comes first:
3967
- * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
3968
- * the /targets poll finding a new target), OR
3969
- * (b) a `'target:attached'` event from `handleMessage()` (triggered by
3970
- * the first inbound CDP message from a target — confirms the relay
3971
- * websocket has data from the phone, not just a target entry in the map).
3972
- *
3973
- * This dual-signal approach eliminates the polling race that previously
3974
- * caused `wait_for_attach` to resolve before the first CDP message arrived.
3975
- *
3976
- * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
3977
- * EventEmitter is missed (defensive belt-and-suspenders).
3417
+ * Evict a previously active target under the single-attach model.
3418
+ * Rejects pending commands with a 'replaced-by-new-attach' reason and emits
3419
+ * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
3420
+ * targetId. The caller is responsible for rebuilding the targets map afterwards.
3978
3421
  *
3979
- * @param filterFn - Predicate that the returned targets must satisfy.
3980
- * @param timeoutMs - Reject after this many ms (default 90 000).
3981
- * @param pollIntervalMs - Fallback poll interval (default 500ms).
3422
+ * The error message uses 'replaced-by-new-attach' so test assertions can match it.
3982
3423
  */
3983
- waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
3984
- const current = this.listTargets();
3985
- if (filterFn(current)) return Promise.resolve(current);
3986
- return new Promise((resolve, reject) => {
3987
- let settled = false;
3988
- let pollHandle = null;
3989
- const settle = (targets) => {
3990
- if (settled) return;
3991
- settled = true;
3992
- clearTimeout(timeoutHandle);
3993
- if (pollHandle !== null) {
3994
- clearInterval(pollHandle);
3995
- pollHandle = null;
3996
- }
3997
- this.emitter.off("target:attached", onAttach);
3998
- resolve(targets);
3999
- };
4000
- const onAttach = (targets) => {
4001
- if (filterFn(targets)) settle(targets);
4002
- };
4003
- const timeoutHandle = setTimeout(() => {
4004
- if (settled) return;
4005
- settled = true;
4006
- if (pollHandle !== null) {
4007
- clearInterval(pollHandle);
4008
- pollHandle = null;
4009
- }
4010
- this.emitter.off("target:attached", onAttach);
4011
- reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
4012
- }, timeoutMs);
4013
- this.emitter.on("target:attached", onAttach);
4014
- pollHandle = setInterval(() => {
4015
- this.refreshTargets().then((targets) => {
4016
- if (filterFn(targets)) settle(targets);
4017
- }, () => {});
4018
- }, pollIntervalMs);
4019
- });
4020
- }
4021
- /**
4022
- * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
4023
- * detected since the last `enableDomains()` call, or `null` if none.
4024
- */
4025
- getLastCrashDetectedAt() {
4026
- return this.lastCrashDetectedAt;
3424
+ evictTarget(targetId) {
3425
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
3426
+ this.targets.delete(targetId);
3427
+ this.targetLastSeenAt.delete(targetId);
3428
+ const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
3429
+ for (const waiter of this.pending.values()) waiter.reject(err);
3430
+ this.pending.clear();
3431
+ const event = {
3432
+ kind: "replaced",
3433
+ targetId,
3434
+ detectedAt
3435
+ };
3436
+ for (const listener of this.lifecycleListeners) try {
3437
+ listener(event);
3438
+ } catch {}
4027
3439
  }
4028
3440
  /**
4029
- * Last-seen timestamp (ms since epoch) for a given target id, or `null` if
4030
- * the target is unknown / no message has been received from it yet.
3441
+ * Handle a page-level crash or target destruction event.
3442
+ * Removes the target from the in-memory map, rejects all pending commands,
3443
+ * and emits a lifecycle event.
3444
+ *
3445
+ * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
3446
+ * @param targetId - The target ID from the event params (may be null for
3447
+ * Inspector.targetCrashed which has no targetId in the params).
4031
3448
  */
4032
- getTargetLastSeenAt(targetId) {
4033
- return this.targetLastSeenAt.get(targetId) ?? null;
4034
- }
4035
- /** Subscribe to target lifecycle events (crash / destroyed / detached). */
4036
- onLifecycle(listener) {
4037
- this.lifecycleListeners.push(listener);
4038
- return () => {
4039
- const idx = this.lifecycleListeners.indexOf(listener);
4040
- if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
3449
+ handleTargetGone(kind, targetId) {
3450
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
3451
+ this.lastCrashDetectedAt = Date.now();
3452
+ if (targetId !== null) {
3453
+ this.targets.delete(targetId);
3454
+ this.targetLastSeenAt.delete(targetId);
3455
+ if (this.activeTargetId === targetId) this.activeTargetId = null;
3456
+ } else {
3457
+ this.targets.clear();
3458
+ this.targetLastSeenAt.clear();
3459
+ this.activeTargetId = null;
3460
+ }
3461
+ const err = /* @__PURE__ */ new Error(`[ait-debug] ${kind === "crashed" ? "page crash (Inspector.targetCrashed)" : kind === "destroyed" ? "target 종료 (Target.targetDestroyed)" : "target detach (Target.detachedFromTarget)"} 감지됨 — relay에서 제거됐습니다. 새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).`);
3462
+ for (const waiter of this.pending.values()) waiter.reject(err);
3463
+ this.pending.clear();
3464
+ const event = {
3465
+ kind,
3466
+ targetId,
3467
+ detectedAt
4041
3468
  };
3469
+ for (const listener of this.lifecycleListeners) try {
3470
+ listener(event);
3471
+ } catch {}
4042
3472
  }
4043
3473
  /**
4044
- * Connect a client websocket to the first attached target and enable Phase 1
4045
- * domains. Resolves once the socket is open and enable commands are sent.
3474
+ * Start the optional CDP heartbeat loop.
3475
+ *
3476
+ * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
3477
+ * we send `Runtime.evaluate({expression: '1'})` to each active target. If
3478
+ * the command times out (2 s hard deadline) or errors, we treat the target
3479
+ * as dead and call `handleTargetGone`.
3480
+ *
3481
+ * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
3482
+ * even when the phone app has crashed, so the ws-level disconnect (#252) won't
3483
+ * fire. The heartbeat catches this gap.
3484
+ *
3485
+ * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
4046
3486
  */
4047
- async enableDomains() {
4048
- if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
4049
- if (this.enablingPromise) return this.enablingPromise;
4050
- this.enablingPromise = this._doEnableDomains().finally(() => {
4051
- this.enablingPromise = null;
4052
- });
4053
- return this.enablingPromise;
3487
+ startHeartbeat(initialTargetId) {
3488
+ this.stopHeartbeat();
3489
+ const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
3490
+ if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
3491
+ const PING_TIMEOUT_MS = 2e3;
3492
+ this.heartbeatHandle = setInterval(() => {
3493
+ const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
3494
+ for (const targetId of targetIds) {
3495
+ const pingPromise = this.sendCommand("Runtime.evaluate", {
3496
+ expression: "1",
3497
+ returnByValue: true,
3498
+ timeout: PING_TIMEOUT_MS
3499
+ });
3500
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
3501
+ Promise.race([pingPromise, timeoutPromise]).catch(() => {
3502
+ if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
3503
+ });
3504
+ }
3505
+ }, envMs);
4054
3506
  }
4055
- async _doEnableDomains() {
4056
- const target = (await this.refreshTargets())[0];
4057
- if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
4058
- let clientUrl = `${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`;
4059
- if (this.totpSecret) {
4060
- const code = generateTotp(this.totpSecret);
4061
- clientUrl += `&at=${encodeURIComponent(code)}`;
3507
+ stopHeartbeat() {
3508
+ if (this.heartbeatHandle !== null) {
3509
+ clearInterval(this.heartbeatHandle);
3510
+ this.heartbeatHandle = null;
4062
3511
  }
4063
- const ws = new WebSocket(clientUrl);
4064
- this.ws = ws;
4065
- await new Promise((resolve, reject) => {
4066
- ws.once("open", () => resolve());
4067
- ws.once("error", (err) => reject(err));
4068
- ws.once("close", (code) => {
4069
- if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
4070
- });
4071
- });
4072
- this.lastCrashDetectedAt = null;
4073
- this.targetLastSeenAt.clear();
4074
- this.connectionState = "connected";
4075
- ws.on("message", (data) => this.handleMessage(data.toString()));
4076
- ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
4077
- ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
4078
- this.sendFireAndForget("Runtime.enable");
4079
- this.sendFireAndForget("Network.enable");
4080
- this.sendFireAndForget("DOM.enable");
4081
- this.sendFireAndForget("Page.enable");
4082
- this.sendFireAndForget("Inspector.enable");
4083
- this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
4084
- this.startHeartbeat(target.id);
4085
3512
  }
4086
- /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
4087
- sendFireAndForget(method, params = {}) {
4088
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
4089
- const id = this.nextCommandId++;
4090
- this.ws.send(JSON.stringify({
4091
- id,
4092
- method,
4093
- params
4094
- }));
3513
+ handleMessage(raw) {
3514
+ const message = parseInbound$1(raw);
3515
+ if (!message) return;
3516
+ if (typeof message.id === "number" && this.pending.has(message.id)) {
3517
+ const waiter = this.pending.get(message.id);
3518
+ this.pending.delete(message.id);
3519
+ if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
3520
+ else waiter.resolve(message.result);
3521
+ return;
3522
+ }
3523
+ const now = Date.now();
3524
+ let firstMessageSeen = false;
3525
+ for (const targetId of this.targets.keys()) {
3526
+ if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
3527
+ this.targetLastSeenAt.set(targetId, now);
3528
+ }
3529
+ if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
3530
+ if (typeof message.method !== "string") return;
3531
+ if (message.method === "Inspector.targetCrashed") {
3532
+ this.handleTargetGone("crashed", null);
3533
+ return;
3534
+ }
3535
+ if (message.method === "Target.targetDestroyed") {
3536
+ const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
3537
+ this.handleTargetGone("destroyed", targetId);
3538
+ return;
3539
+ }
3540
+ if (message.method === "Target.detachedFromTarget") {
3541
+ const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
3542
+ this.handleTargetGone("detached", targetId);
3543
+ return;
3544
+ }
3545
+ if (!this.buffers.has(message.method)) return;
3546
+ const event = message.method;
3547
+ const buffer = this.buffers.get(event);
3548
+ if (!buffer) return;
3549
+ buffer.push(message.params);
3550
+ const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
3551
+ if (buffer.length > cap) buffer.shift();
3552
+ this.emitter.emit(event, message.params);
4095
3553
  }
4096
- /**
4097
- * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
4098
- * error frame or when no websocket is open (no page attached yet).
4099
- */
4100
- send(method, params) {
4101
- return this.sendCommand(method, params ?? {});
3554
+ getBufferedEvents(event) {
3555
+ return this.buffers.get(event) ?? [];
4102
3556
  }
4103
- /**
4104
- * Issue an arbitrary request→response command over the relay and resolve with
4105
- * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
4106
- * `AIT.*` methods, forwarded over the same Chii channel) build on this.
4107
- *
4108
- * Rejects immediately if the connection is disconnected (fail-fast — no
4109
- * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
4110
- * reattach.
4111
- *
4112
- * Times out after `commandTimeoutMs` (default 30s, env
4113
- * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
4114
- * and the promise rejects with a descriptive Korean error.
4115
- */
4116
- sendCommand(method, params = {}) {
4117
- if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
4118
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No mini-app page attached to the Chii relay yet. Call enableDomains() first."));
4119
- const id = this.nextCommandId++;
3557
+ on(event, listener) {
3558
+ this.emitter.on(event, listener);
3559
+ return () => this.emitter.off(event, listener);
3560
+ }
3561
+ /** Close the relay client websocket and reject any in-flight commands. */
3562
+ close() {
4120
3563
  const ws = this.ws;
4121
- const timeoutMs = this.commandTimeoutMs;
4122
- return new Promise((resolve, reject) => {
4123
- const handle = setTimeout(() => {
4124
- this.pending.delete(id);
4125
- reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
4126
- }, timeoutMs);
4127
- this.pending.set(id, {
4128
- resolve: (v) => {
4129
- clearTimeout(handle);
4130
- resolve(v);
4131
- },
4132
- reject: (e) => {
4133
- clearTimeout(handle);
4134
- reject(e);
4135
- }
4136
- });
4137
- ws.send(JSON.stringify({
4138
- id,
4139
- method,
4140
- params
3564
+ this.stopHeartbeat();
3565
+ this.handleDisconnect("Chii relay connection closed");
3566
+ ws?.close();
3567
+ }
3568
+ };
3569
+ //#endregion
3570
+ //#region src/test-runner/bundle.ts
3571
+ /**
3572
+ * esbuild-based bundler for user test files.
3573
+ *
3574
+ * Bundles a single test file into a self-contained IIFE string that can be
3575
+ * injected into a WebView via `Runtime.evaluate`. The bundle includes the
3576
+ * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
3577
+ * the `runTestModule(factory)` entry point.
3578
+ *
3579
+ * ## How the wiring works
3580
+ *
3581
+ * The bundle exposes two exports on `globalThis.__testBundle`:
3582
+ * - `runTestModule` — the runtime's entry function.
3583
+ * - `__userFactory` — an async function whose body is the user's top-level
3584
+ * test registration code (describe/it/test calls).
3585
+ *
3586
+ * The Node-side RPC (`rpc.ts`) calls:
3587
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
3588
+ *
3589
+ * `runTestModule` then installs `describe/it/test/expect` as globals, invokes
3590
+ * the factory (which registers all tests), runs them, and returns a `RunReport`.
3591
+ *
3592
+ * ## Why a factory wrapper is needed
3593
+ *
3594
+ * Naively adding the runtime to `entryPoints` and bundling the user file would
3595
+ * fail for two reasons:
3596
+ * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
3597
+ * scope. The user's top-level `describe(...)` calls expect them as globals —
3598
+ * they are not globals until `runTestModule` installs them.
3599
+ * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
3600
+ * time, before the RPC layer calls `runTestModule` to reset state and start
3601
+ * the test clock.
3602
+ *
3603
+ * The factory approach solves both: the user's registration code is deferred
3604
+ * into a function that `runTestModule` calls AFTER installing the globals.
3605
+ *
3606
+ * ## Factory extraction algorithm
3607
+ *
3608
+ * The `userFactoryPlugin` reads the user file and splits lines into:
3609
+ * - **top-level**: `import …` and re-export lines — kept at module scope
3610
+ * (the only valid position for static `import` in ESM).
3611
+ * - **body**: all other statements — moved into the body of the exported
3612
+ * `__userFactory` async function.
3613
+ *
3614
+ * esbuild processes the re-generated module, following each static import
3615
+ * through the normal dependency graph (including the SDK-redirect plugin).
3616
+ *
3617
+ * ## SDK redirect
3618
+ *
3619
+ * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
3620
+ * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
3621
+ * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
3622
+ *
3623
+ * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
3624
+ */
3625
+ /** The SDK package name that mini-app test code imports from. */
3626
+ const SDK_PACKAGE = "@apps-in-toss/web-framework";
3627
+ /**
3628
+ * Names the runtime installs as globals before invoking the user factory.
3629
+ * The `vitest` virtual module re-exports each as a lazy getter that reads from
3630
+ * `globalThis` at access time. Keep in sync with the globals installed in
3631
+ * `runtime.ts#runTestModule`.
3632
+ */
3633
+ const VITEST_GLOBAL_NAMES = [
3634
+ "describe",
3635
+ "it",
3636
+ "test",
3637
+ "expect",
3638
+ "beforeAll",
3639
+ "afterAll",
3640
+ "beforeEach",
3641
+ "afterEach",
3642
+ "vi"
3643
+ ];
3644
+ /**
3645
+ * Matches the bare SDK package and any sub-path import
3646
+ * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
3647
+ * Built from {@link SDK_PACKAGE} so the package name has a single source.
3648
+ */
3649
+ const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
3650
+ /**
3651
+ * esbuild plugin that intercepts SDK imports and redirects them to the
3652
+ * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
3653
+ *
3654
+ * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
3655
+ * esbuild resolves it to a virtual module that re-exports all named exports
3656
+ * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
3657
+ * be available in the test environment) while still making named imports work.
3658
+ *
3659
+ * If `window.__sdk` is absent (non-dog-food build), every access throws a
3660
+ * descriptive error rather than returning `undefined` silently.
3661
+ */
3662
+ function sdkRedirectPlugin() {
3663
+ return {
3664
+ name: "sdk-redirect",
3665
+ setup(build) {
3666
+ build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
3667
+ path: args.path,
3668
+ namespace: "sdk-redirect"
3669
+ }));
3670
+ build.onLoad({
3671
+ filter: /.*/,
3672
+ namespace: "sdk-redirect"
3673
+ }, () => ({
3674
+ contents: `
3675
+ var __proxy = (typeof window !== 'undefined' && window.__sdk)
3676
+ ? window.__sdk
3677
+ : new Proxy({}, {
3678
+ get: function(_t, p) {
3679
+ throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
3680
+ }
3681
+ });
3682
+ module.exports = __proxy;
3683
+ `,
3684
+ loader: "js"
4141
3685
  }));
3686
+ }
3687
+ };
3688
+ }
3689
+ /**
3690
+ * esbuild plugin that intercepts `import … from 'vitest'` and replaces it with
3691
+ * a virtual module that delegates every named import to `globalThis` at ACCESS
3692
+ * time (not at bundle-evaluation time).
3693
+ *
3694
+ * The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/
3695
+ * afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle
3696
+ * IIFE is evaluated. A value-copy redirect (`export var describe =
3697
+ * globalThis.describe`) would therefore capture `undefined` at evaluation time
3698
+ * and the user's `describe(...)` calls would be no-ops — registering zero tests.
3699
+ *
3700
+ * The fix defers the lookup to call time using per-name **getter** exports.
3701
+ * We emit a CommonJS module that:
3702
+ * 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named
3703
+ * import directly to a property access on the module (NOT wrapped under a
3704
+ * `default` shim — which is what happens for a bare Proxy whose own-keys
3705
+ * are empty, leaving every named import `undefined`);
3706
+ * 2. defines each global name as a getter that reads `globalThis[name]` on
3707
+ * every access. So `import { describe } from 'vitest'` compiles to
3708
+ * `import_vitest.describe`, whose getter returns the real `describe` only
3709
+ * when the factory calls it — after `runTestModule` installs the globals.
3710
+ *
3711
+ * A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes
3712
+ * the virtual module through `__toESM`, which enumerates own-keys (none on an
3713
+ * empty Proxy target) and therefore exposes zero named exports. Explicit getter
3714
+ * properties give `__toESM` real keys to map while keeping access lazy.
3715
+ */
3716
+ function vitestRedirectPlugin() {
3717
+ return {
3718
+ name: "vitest-redirect",
3719
+ setup(build) {
3720
+ build.onResolve({ filter: /^vitest$/ }, () => ({
3721
+ path: "vitest",
3722
+ namespace: "vitest-redirect"
3723
+ }));
3724
+ build.onLoad({
3725
+ filter: /^vitest$/,
3726
+ namespace: "vitest-redirect"
3727
+ }, () => {
3728
+ return {
3729
+ contents: `Object.defineProperty(exports, '__esModule', { value: true });\n${VITEST_GLOBAL_NAMES.map((name) => `Object.defineProperty(exports, ${JSON.stringify(name)}, { enumerable: true, get: function() { return globalThis[${JSON.stringify(name)}]; } });`).join("\n")}\n`,
3730
+ loader: "js"
3731
+ };
3732
+ });
3733
+ }
3734
+ };
3735
+ }
3736
+ /**
3737
+ * esbuild plugin that transforms the user test file into a module that exports
3738
+ * an async `__userFactory` function. The factory defers the user's top-level
3739
+ * test registration code (describe/it/test calls) so it only runs when
3740
+ * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
3741
+ * installed describe/it/test/expect as globals.
3742
+ *
3743
+ * Algorithm:
3744
+ * - Import declarations and re-export statements are kept at module top-level
3745
+ * (the only valid ESM position for static `import`). A statement that spans
3746
+ * multiple lines — e.g. a named import with one member per line:
3747
+ * import {
3748
+ * appLogin,
3749
+ * getAnonymousKey,
3750
+ * } from '@apps-in-toss/web-framework';
3751
+ * is tracked as a single block: every line from the opening `import {` /
3752
+ * `export {` through the closing `from '…'` (or side-effect `'…'`) line is
3753
+ * kept together at top-level. This prevents the member lines and the
3754
+ * closing `} from '…'` line from leaking into the factory body, which would
3755
+ * leave an unterminated `import {` at module scope (the #678 env3 failure:
3756
+ * esbuild threw `Expected "as" but found "{"` on multi-line SDK imports).
3757
+ * - All other lines (describe/it/test calls, local declarations, etc.) are
3758
+ * moved into the body of the exported async factory function.
3759
+ *
3760
+ * This preserves SDK import resolution (the sdk-redirect plugin processes
3761
+ * top-level imports normally) while deferring test registration to the factory.
3762
+ */
3763
+ function userFactoryPlugin(absPath) {
3764
+ const NAMESPACE = "user-test-factory";
3765
+ return {
3766
+ name: "user-test-factory",
3767
+ setup(build) {
3768
+ build.onResolve({ filter: /^user-test-factory$/ }, () => ({
3769
+ path: absPath,
3770
+ namespace: NAMESPACE
3771
+ }));
3772
+ build.onLoad({
3773
+ filter: /.*/,
3774
+ namespace: NAMESPACE
3775
+ }, async (args) => {
3776
+ const lines = (await fs.readFile(args.path, "utf8")).split("\n");
3777
+ const topLevelLines = [];
3778
+ const bodyLines = [];
3779
+ const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
3780
+ const isImportStart = (trimmed) => trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"");
3781
+ const endsStatement = (trimmed) => /['"]\s*;?\s*$/.test(trimmed.replace(/\/\/.*$/, "").trimEnd());
3782
+ let inImportBlock = false;
3783
+ for (const line of lines) {
3784
+ const trimmed = line.trimStart();
3785
+ const indent = line.slice(0, line.length - trimmed.length);
3786
+ if (inImportBlock) {
3787
+ topLevelLines.push(line);
3788
+ if (endsStatement(trimmed)) inImportBlock = false;
3789
+ continue;
3790
+ }
3791
+ if (isImportStart(trimmed)) {
3792
+ topLevelLines.push(line);
3793
+ if (!endsStatement(trimmed)) inImportBlock = true;
3794
+ } else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
3795
+ else {
3796
+ topLevelLines.push(line);
3797
+ if (/\bfrom\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith("{")) inImportBlock = true;
3798
+ }
3799
+ else bodyLines.push(line);
3800
+ }
3801
+ return {
3802
+ contents: [
3803
+ ...topLevelLines,
3804
+ "",
3805
+ "// biome-ignore lint: generated factory wrapper",
3806
+ "export default async function __userFactory(): Promise<void> {",
3807
+ ...bodyLines.map((l) => ` ${l}`),
3808
+ "}"
3809
+ ].join("\n"),
3810
+ loader: "ts",
3811
+ resolveDir: path.dirname(absPath)
3812
+ };
3813
+ });
3814
+ }
3815
+ };
3816
+ }
3817
+ /**
3818
+ * Returns the absolute filesystem path to the test-runner runtime module
3819
+ * (dist/test-runner/runtime.js — a fully self-contained page-side bundle).
3820
+ *
3821
+ * Rolldown code-splitting duplicates this bundling logic into shared chunks
3822
+ * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
3823
+ * dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`
3824
+ * daemon (dist/mcp/cli.js) pulls it through a ROOT chunk
3825
+ * (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list
3826
+ * is therefore wrong from at least one chunk — the live #697 regression.
3827
+ *
3828
+ * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
3829
+ * probes the co-located `runtime.js` and the nested `test-runner/runtime.js`,
3830
+ * then ascends one directory at a time (bounded) repeating both probes. The
3831
+ * nested probe catches dist/test-runner/runtime.js from the dist/ root level no
3832
+ * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
3833
+ * relocation). The build always emits dist/test-runner/runtime.js (tsdown entry
3834
+ * `'test-runner/runtime'`; guarded by scripts/check-test-runner-dist.sh).
3835
+ *
3836
+ * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
3837
+ * read, bypassing Node module resolution entirely, so this works identically in
3838
+ * the npx-daemon context (its own dist tree) and the consumer-CLI context
3839
+ * (the mini-app's installed @ait-co/devtools dist) — neither needs the package
3840
+ * to be node-resolvable from the caller.
3841
+ */
3842
+ function getRuntimePath() {
3843
+ const startDir = path.dirname(fileURLToPath(import.meta.url));
3844
+ const RELATIVE_PROBES = [
3845
+ ["runtime.js"],
3846
+ ["test-runner", "runtime.js"],
3847
+ ["runtime.ts"],
3848
+ ["test-runner", "runtime.ts"]
3849
+ ];
3850
+ let dir = startDir;
3851
+ for (let i = 0; i < 12; i++) {
3852
+ for (const segs of RELATIVE_PROBES) {
3853
+ const candidate = path.join(dir, ...segs);
3854
+ try {
3855
+ accessSync(candidate);
3856
+ return candidate;
3857
+ } catch {}
3858
+ }
3859
+ const parent = path.dirname(dir);
3860
+ if (parent === dir) break;
3861
+ dir = parent;
3862
+ }
3863
+ return path.join(startDir, "runtime.js");
3864
+ }
3865
+ /**
3866
+ * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
3867
+ *
3868
+ * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
3869
+ * - `runTestModule` — the runtime entry (from `runtime.ts`).
3870
+ * - `__userFactory` — an async function wrapping the user's test registration
3871
+ * code so it runs AFTER `runTestModule` installs the globals.
3872
+ *
3873
+ * Callers (rpc.ts) invoke:
3874
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
3875
+ *
3876
+ * @param absPath - Absolute path to the user test file.
3877
+ * @param opts - Optional bundling overrides.
3878
+ */
3879
+ async function bundleTestFile(absPath, opts) {
3880
+ const globalName = opts?.globalName ?? "__testBundle";
3881
+ const extraExternals = opts?.extraExternals ?? [];
3882
+ const esbuild = await import("esbuild");
3883
+ const runtimePath = getRuntimePath();
3884
+ const wrapperContent = [
3885
+ `import { runTestModule } from ${JSON.stringify(runtimePath)};`,
3886
+ `import __userFactory from "user-test-factory";`,
3887
+ `export { runTestModule, __userFactory };`
3888
+ ].join("\n");
3889
+ const result = await esbuild.build({
3890
+ stdin: {
3891
+ contents: wrapperContent,
3892
+ loader: "ts",
3893
+ resolveDir: path.dirname(absPath)
3894
+ },
3895
+ bundle: true,
3896
+ format: "iife",
3897
+ globalName,
3898
+ platform: "browser",
3899
+ target: "es2022",
3900
+ write: false,
3901
+ plugins: [
3902
+ userFactoryPlugin(absPath),
3903
+ vitestRedirectPlugin(),
3904
+ sdkRedirectPlugin()
3905
+ ],
3906
+ external: extraExternals,
3907
+ treeShaking: true,
3908
+ footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
3909
+ });
3910
+ const warnings = result.warnings.map((w) => `${path.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
3911
+ const outputFile = result.outputFiles?.[0];
3912
+ if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
3913
+ return {
3914
+ code: outputFile.text,
3915
+ warnings
3916
+ };
3917
+ }
3918
+ //#endregion
3919
+ //#region src/test-runner/capture.ts
3920
+ /** The exact console-line prefix sdk-example's `flushCapture` emits. */
3921
+ const CAPTURE_PREFIX = "__AIT_CAPTURE__ ";
3922
+ /**
3923
+ * Parses raw console line texts into {@link AitCaptureLine}s.
3924
+ *
3925
+ * Filtering rules (each independently drops a line — never throws):
3926
+ * - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);
3927
+ * - there must be a non-empty category token (up to the next space);
3928
+ * - the remaining payload must be valid JSON (`JSON.parse` succeeds).
3929
+ *
3930
+ * Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are
3931
+ * silently discarded — capture harvesting is best-effort and must never fail a
3932
+ * run or leak a malformed/secret-bearing line.
3933
+ *
3934
+ * @param raw - Console line objects (only `.text` is read).
3935
+ * @returns The captured lines, in input order.
3936
+ */
3937
+ function parseCaptureLines(raw) {
3938
+ const out = [];
3939
+ for (const { text } of raw) {
3940
+ if (!text.startsWith(CAPTURE_PREFIX)) continue;
3941
+ const body = text.slice(16);
3942
+ const spaceIdx = body.indexOf(" ");
3943
+ if (spaceIdx === -1) continue;
3944
+ const category = body.slice(0, spaceIdx);
3945
+ const json = body.slice(spaceIdx + 1);
3946
+ if (category === "" || json === "") continue;
3947
+ try {
3948
+ JSON.parse(json);
3949
+ } catch {
3950
+ continue;
3951
+ }
3952
+ out.push({
3953
+ category,
3954
+ json
4142
3955
  });
4143
3956
  }
4144
- /**
4145
- * Called on WebSocket `close` or `error` after a successful connection.
4146
- * Rejects all pending commands and marks the connection as disconnected so
4147
- * subsequent `sendCommand` calls fail fast (no auto-reconnect).
4148
- */
4149
- handleDisconnect(reason) {
4150
- if (this.connectionState === "disconnected") return;
4151
- this.connectionState = "disconnected";
4152
- this.ws = null;
4153
- this.stopHeartbeat();
4154
- const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
4155
- for (const waiter of this.pending.values()) waiter.reject(err);
4156
- this.pending.clear();
3957
+ return out;
3958
+ }
3959
+ //#endregion
3960
+ //#region src/test-runner/rpc.ts
3961
+ /** Maximum milliseconds to wait for a single evaluate round-trip. */
3962
+ const DEFAULT_TIMEOUT_MS = 6e4;
3963
+ /**
3964
+ * Wraps bundle code in a self-executing IIFE that:
3965
+ * 1. Evaluates the bundle (registering describe/it/test).
3966
+ * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
3967
+ * 3. Returns a JSON-serialised `RunReport` string.
3968
+ *
3969
+ * The double-serialisation (RunReport → JSON string → returnByValue string)
3970
+ * is intentional: CDP `returnByValue` reliably transports strings; deeply
3971
+ * nested objects can lose fidelity across the Chii relay.
3972
+ *
3973
+ * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
3974
+ */
3975
+ function buildRunTestsExpression(bundleCode) {
3976
+ return `(async () => { try { ${bundleCode} } catch(e) { return JSON.stringify({ok:false,error:'bundle-eval: ' + String(e && e.message || e)}); } if (typeof globalThis.__testBundle !== 'object' || typeof globalThis.__testBundle.runTestModule !== 'function' || typeof globalThis.__testBundle.__userFactory !== 'function') { return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule or __userFactory is not a function'}); } try { const report = await globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory); return JSON.stringify({ok:true,value:report}); } catch(e) { return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)}); }})()`;
3977
+ }
3978
+ /**
3979
+ * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
3980
+ * evaluate call into a typed `RpcRunResult`.
3981
+ *
3982
+ * Throws only on parse failure — an `ok:false` envelope is a normal result.
3983
+ *
3984
+ * SECRET-HANDLING: `rawValue` is not included in error messages.
3985
+ */
3986
+ function parseRunTestsResult(rawValue) {
3987
+ if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
3988
+ let parsed;
3989
+ try {
3990
+ parsed = JSON.parse(rawValue);
3991
+ } catch {
3992
+ throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
4157
3993
  }
4158
- /**
4159
- * Evict a previously active target under the single-attach model.
4160
- * Rejects pending commands with a 'replaced-by-new-attach' reason and emits
4161
- * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
4162
- * targetId. The caller is responsible for rebuilding the targets map afterwards.
4163
- *
4164
- * The error message uses 'replaced-by-new-attach' so test assertions can match it.
4165
- */
4166
- evictTarget(targetId) {
4167
- const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
4168
- this.targets.delete(targetId);
4169
- this.targetLastSeenAt.delete(targetId);
4170
- const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
4171
- for (const waiter of this.pending.values()) waiter.reject(err);
4172
- this.pending.clear();
4173
- const event = {
4174
- kind: "replaced",
4175
- targetId,
4176
- detectedAt
4177
- };
4178
- for (const listener of this.lifecycleListeners) try {
4179
- listener(event);
4180
- } catch {}
3994
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
3995
+ const obj = parsed;
3996
+ if (obj.ok === true) return {
3997
+ ok: true,
3998
+ report: obj.value
3999
+ };
4000
+ if (obj.ok === false) return {
4001
+ ok: false,
4002
+ error: typeof obj.error === "string" ? obj.error : String(obj.error)
4003
+ };
4004
+ throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
4005
+ }
4006
+ /**
4007
+ * Injects `bundleCode` into the attached page and awaits test execution.
4008
+ *
4009
+ * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
4010
+ * async IIFE to settle. The 30-second CDP command timeout covers even
4011
+ * long-running test suites; split into smaller files if you hit it.
4012
+ *
4013
+ * @param connection - Active CDP connection (relay or local).
4014
+ * @param bundleCode - IIFE bundle string from `bundleTestFile`.
4015
+ * @param timeoutMs - Override the default 30 s timeout.
4016
+ *
4017
+ * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
4018
+ */
4019
+ async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
4020
+ const expression = buildRunTestsExpression(bundleCode);
4021
+ const TIMEOUT_SENTINEL = Symbol("timeout");
4022
+ const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs));
4023
+ const evalPromise = connection.send("Runtime.evaluate", {
4024
+ expression,
4025
+ returnByValue: true,
4026
+ awaitPromise: true
4027
+ });
4028
+ const raceResult = await Promise.race([evalPromise.then((v) => ({
4029
+ tag: "eval",
4030
+ v
4031
+ })), timeoutPromise.then(() => ({ tag: "timeout" }))]);
4032
+ if (raceResult.tag === "timeout") return {
4033
+ ok: false,
4034
+ error: `rpc: evaluate timed out after ${timeoutMs}ms`
4035
+ };
4036
+ const cdpResult = raceResult.v;
4037
+ if (cdpResult.exceptionDetails) {
4038
+ const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
4039
+ throw new Error(`rpc.injectAndRunBundle: ${msg}`);
4181
4040
  }
4182
- /**
4183
- * Handle a page-level crash or target destruction event.
4184
- * Removes the target from the in-memory map, rejects all pending commands,
4185
- * and emits a lifecycle event.
4186
- *
4187
- * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
4188
- * @param targetId - The target ID from the event params (may be null for
4189
- * Inspector.targetCrashed which has no targetId in the params).
4190
- */
4191
- handleTargetGone(kind, targetId) {
4192
- const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
4193
- this.lastCrashDetectedAt = Date.now();
4194
- if (targetId !== null) {
4195
- this.targets.delete(targetId);
4196
- this.targetLastSeenAt.delete(targetId);
4197
- if (this.activeTargetId === targetId) this.activeTargetId = null;
4198
- } else {
4199
- this.targets.clear();
4200
- this.targetLastSeenAt.clear();
4201
- this.activeTargetId = null;
4202
- }
4203
- const err = /* @__PURE__ */ new Error(`[ait-debug] ${kind === "crashed" ? "page crash (Inspector.targetCrashed)" : kind === "destroyed" ? "target 종료 (Target.targetDestroyed)" : "target detach (Target.detachedFromTarget)"} 감지됨 — relay에서 제거됐습니다. 새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).`);
4204
- for (const waiter of this.pending.values()) waiter.reject(err);
4205
- this.pending.clear();
4206
- const event = {
4207
- kind,
4208
- targetId,
4209
- detectedAt
4210
- };
4211
- for (const listener of this.lifecycleListeners) try {
4212
- listener(event);
4213
- } catch {}
4041
+ return parseRunTestsResult(cdpResult.result.value);
4042
+ }
4043
+ //#endregion
4044
+ //#region src/test-runner/relay-worker.ts
4045
+ /**
4046
+ * Sentinel string embedded in the error message by `injectAndRunBundle` when
4047
+ * the per-file evaluate race hits the timeout. Used by the retry guard so only
4048
+ * genuine timeouts get a second attempt — not bundle errors or parse failures.
4049
+ *
4050
+ * Exported for unit tests that assert the retry path is taken.
4051
+ */
4052
+ const EVALUATE_TIMEOUT_MARKER = "rpc: evaluate timed out after";
4053
+ /**
4054
+ * Runs all `files` sequentially over the given CDP `connection`.
4055
+ *
4056
+ * For each file:
4057
+ * 1. Bundle with esbuild (includes SDK shim + runtime).
4058
+ * 2. Inject into the attached page via `Runtime.evaluate`.
4059
+ * 3. Await the `RunReport` JSON response.
4060
+ * 4. Accumulate results.
4061
+ *
4062
+ * Returns a `RelayRunReport` with per-file results and flattened totals.
4063
+ *
4064
+ * This function does NOT open or manage the relay connection — the caller
4065
+ * is responsible for attaching and closing it.
4066
+ *
4067
+ * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
4068
+ * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
4069
+ *
4070
+ * @param connection - Active CDP connection (relay or local kind).
4071
+ * @param files - Absolute paths to test files, run in order.
4072
+ * @param opts - Optional per-run overrides.
4073
+ */
4074
+ async function runTestFilesOverRelay(connection, files, opts) {
4075
+ const wallStart = Date.now();
4076
+ const startedAt = new Date(wallStart).toISOString();
4077
+ const fileResults = [];
4078
+ let domainsEnabled = false;
4079
+ try {
4080
+ await connection.enableDomains();
4081
+ domainsEnabled = true;
4082
+ } catch (e) {
4083
+ process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
4214
4084
  }
4085
+ const collectCaptures = opts?.collectCaptures === true;
4086
+ const liveConsole = [];
4087
+ let unsubscribeConsole;
4088
+ if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
4089
+ liveConsole.push(event);
4090
+ });
4091
+ let pendingReconnectCheck = false;
4215
4092
  /**
4216
- * Start the optional CDP heartbeat loop.
4217
- *
4218
- * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
4219
- * we send `Runtime.evaluate({expression: '1'})` to each active target. If
4220
- * the command times out (2 s hard deadline) or errors, we treat the target
4221
- * as dead and call `handleTargetGone`.
4093
+ * Attempts one `enableDomains()` reconnect. Logs the attempt and outcome to
4094
+ * stderr. Never throws — a failed reconnect just means the caller proceeds
4095
+ * as today (each remaining file / the retry will fail-fast on the still-dead
4096
+ * socket).
4222
4097
  *
4223
- * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
4224
- * even when the phone app has crashed, so the ws-level disconnect (#252) won't
4225
- * fire. The heartbeat catches this gap.
4098
+ * `context` selects the fixed log literal: 'next-file' for the between-files
4099
+ * case (a file's FINAL result was a WS-dead-class error — the primary #731
4100
+ * scenario), 'retry-precheck' for the defensive reconnect before retrying a
4101
+ * timed-out file (cheap no-op via `enableDomains()`'s idempotency when the
4102
+ * socket never actually died).
4226
4103
  *
4227
- * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
4104
+ * SECRET-HANDLING: fixed literals only no relay wss URL, TOTP code, or
4105
+ * tunnel host.
4228
4106
  */
4229
- startHeartbeat(initialTargetId) {
4230
- this.stopHeartbeat();
4231
- const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
4232
- if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
4233
- const PING_TIMEOUT_MS = 2e3;
4234
- this.heartbeatHandle = setInterval(() => {
4235
- const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
4236
- for (const targetId of targetIds) {
4237
- const pingPromise = this.sendCommand("Runtime.evaluate", {
4238
- expression: "1",
4239
- returnByValue: true,
4240
- timeout: PING_TIMEOUT_MS
4241
- });
4242
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
4243
- Promise.race([pingPromise, timeoutPromise]).catch(() => {
4244
- if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
4245
- });
4107
+ const attemptReconnect = async (context) => {
4108
+ process.stderr.write(context === "next-file" ? "relay-worker: relay connection lost — attempting reconnect before next file\n" : "relay-worker: evaluate timed out — attempting reconnect before retry\n");
4109
+ try {
4110
+ await connection.enableDomains();
4111
+ process.stderr.write("relay-worker: reconnect succeeded\n");
4112
+ } catch (e) {
4113
+ process.stderr.write(`relay-worker: reconnect failed (${e instanceof Error ? e.message : String(e)}) continuing, remaining files may fail\n`);
4114
+ }
4115
+ };
4116
+ try {
4117
+ for (const file of files) {
4118
+ if (pendingReconnectCheck) {
4119
+ await attemptReconnect("next-file");
4120
+ pendingReconnectCheck = false;
4121
+ }
4122
+ let fileEntry;
4123
+ try {
4124
+ const { code } = await bundleTestFile(file, opts?.bundleOptions);
4125
+ /**
4126
+ * Runs one evaluate attempt and returns a FileResult, or `null` when the
4127
+ * result is a genuine timeout and the caller should retry.
4128
+ *
4129
+ * We need to distinguish:
4130
+ * - `rpcResult.ok = false` + timeout error → retry candidate (`return null`)
4131
+ * - `rpcResult.ok = false` + other error → final error, no retry
4132
+ * - `injectAndRunBundle` throws → CDP exceptionDetails OR a
4133
+ * relay-disconnect rejection from `connection.send()` (page engine
4134
+ * threw, or the ws died mid-evaluate); treated as a final
4135
+ * (non-retryable) error either way.
4136
+ *
4137
+ * The Promise.race timeout in rpc.ts RETURNS `{ok:false, error: '…'}` (it
4138
+ * does NOT throw/reject). Only genuine CDP `exceptionDetails` (or a dead
4139
+ * `connection.send()`) cause a throw. This distinction is what makes the
4140
+ * EVALUATE_TIMEOUT_MARKER gate below reachable — the timeout result
4141
+ * surfaces as `rpcResult.ok=false` with the marker string, not as a
4142
+ * caught exception.
4143
+ */
4144
+ const attempt = async () => {
4145
+ let rpcResult;
4146
+ try {
4147
+ rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
4148
+ } catch (e) {
4149
+ return {
4150
+ file,
4151
+ result: { error: e instanceof Error ? e.message : String(e) }
4152
+ };
4153
+ }
4154
+ if (rpcResult.ok) return {
4155
+ file,
4156
+ result: rpcResult.report
4157
+ };
4158
+ if (rpcResult.error.includes("rpc: evaluate timed out after")) return null;
4159
+ return {
4160
+ file,
4161
+ result: { error: rpcResult.error }
4162
+ };
4163
+ };
4164
+ const firstResult = await attempt();
4165
+ if (firstResult !== null) fileEntry = firstResult;
4166
+ else {
4167
+ await attemptReconnect("retry-precheck");
4168
+ process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\n`);
4169
+ const retryResult = await attempt();
4170
+ if (retryResult !== null) fileEntry = retryResult;
4171
+ else fileEntry = {
4172
+ file,
4173
+ result: { error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 6e4}ms (after retry)` }
4174
+ };
4175
+ }
4176
+ } catch (e) {
4177
+ fileEntry = {
4178
+ file,
4179
+ result: { error: e instanceof Error ? e.message : String(e) }
4180
+ };
4246
4181
  }
4247
- }, envMs);
4248
- }
4249
- stopHeartbeat() {
4250
- if (this.heartbeatHandle !== null) {
4251
- clearInterval(this.heartbeatHandle);
4252
- this.heartbeatHandle = null;
4182
+ if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
4183
+ fileResults.push(fileEntry);
4253
4184
  }
4185
+ } finally {
4186
+ unsubscribeConsole?.();
4254
4187
  }
4255
- handleMessage(raw) {
4256
- const message = parseInbound$1(raw);
4257
- if (!message) return;
4258
- if (typeof message.id === "number" && this.pending.has(message.id)) {
4259
- const waiter = this.pending.get(message.id);
4260
- this.pending.delete(message.id);
4261
- if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
4262
- else waiter.resolve(message.result);
4263
- return;
4264
- }
4265
- const now = Date.now();
4266
- let firstMessageSeen = false;
4267
- for (const targetId of this.targets.keys()) {
4268
- if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
4269
- this.targetLastSeenAt.set(targetId, now);
4270
- }
4271
- if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
4272
- if (typeof message.method !== "string") return;
4273
- if (message.method === "Inspector.targetCrashed") {
4274
- this.handleTargetGone("crashed", null);
4275
- return;
4188
+ const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
4189
+ const totals = fileResults.reduce((acc, { result }) => {
4190
+ if ("error" in result) {
4191
+ acc.failed += 1;
4192
+ acc.total += 1;
4193
+ } else {
4194
+ acc.passed += result.passed;
4195
+ acc.failed += result.failed;
4196
+ acc.skipped += result.skipped;
4197
+ acc.total += result.passed + result.failed + result.skipped;
4276
4198
  }
4277
- if (message.method === "Target.targetDestroyed") {
4278
- const targetId = isObject$2(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
4279
- this.handleTargetGone("destroyed", targetId);
4280
- return;
4199
+ return acc;
4200
+ }, {
4201
+ passed: 0,
4202
+ failed: 0,
4203
+ skipped: 0,
4204
+ total: 0
4205
+ });
4206
+ return {
4207
+ startedAt,
4208
+ duration: Date.now() - wallStart,
4209
+ files: fileResults,
4210
+ totals,
4211
+ captures
4212
+ };
4213
+ }
4214
+ /**
4215
+ * Renders one `Runtime.consoleAPICalled` event to a single line of text, the
4216
+ * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
4217
+ * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
4218
+ * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
4219
+ * onto the test-runner entry.
4220
+ *
4221
+ * SECRET-HANDLING: this only stringifies console args; the caller's
4222
+ * allowlist-prefix parser then discards everything that is not a genuine
4223
+ * `__AIT_CAPTURE__` line.
4224
+ */
4225
+ function renderConsoleLineText(event) {
4226
+ return event.args.map((arg) => {
4227
+ if (arg.value !== void 0) {
4228
+ if (typeof arg.value === "string") return arg.value;
4229
+ try {
4230
+ return JSON.stringify(arg.value);
4231
+ } catch {
4232
+ return String(arg.value);
4233
+ }
4281
4234
  }
4282
- if (message.method === "Target.detachedFromTarget") {
4283
- const targetId = isObject$2(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
4284
- this.handleTargetGone("detached", targetId);
4285
- return;
4235
+ if (arg.description !== void 0) return arg.description;
4236
+ if (arg.className !== void 0) return arg.className;
4237
+ return arg.subtype ?? arg.type;
4238
+ }).join(" ");
4239
+ }
4240
+ `
4241
+ devtools-test — run mini-app tests on a real device WebView over the CDP relay
4242
+
4243
+ USAGE
4244
+ devtools-test <glob> [<glob> ...] [options]
4245
+
4246
+ OPTIONS
4247
+ --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
4248
+ (required for standalone relay attach / env3)
4249
+ --timeout <ms> Per-file evaluate timeout in ms (default: 60000).
4250
+ Controls how long a single test file is allowed to run
4251
+ before it is considered hung. Does NOT affect how long
4252
+ the CLI waits for a human to scan the QR code — use
4253
+ --attach-timeout for that.
4254
+ --attach-timeout <ms> How long to wait for a human to scan the QR code with
4255
+ their phone (default: 600000 — 10 minutes). Omit to
4256
+ use the relay factory's generous default. Decrease for
4257
+ CI environments where a scan should arrive quickly.
4258
+ --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
4259
+ --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
4260
+ (mock|ios|android, default: AIT_CELL_PLATFORM env)
4261
+ --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
4262
+ (report: <sdkLine>.<platform>.json; captures:
4263
+ <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
4264
+ Omitted = nothing saved. Enables console capture.
4265
+ --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
4266
+ non-interactive stdout / CI / AIT_NO_QR_STDOUT)
4267
+ --headless Disable browser auto-open (text QR only)
4268
+ --project-root <dir> Project root for .ait_relay secret lookup
4269
+ (default: current working directory)
4270
+ --help, -h Show this help message
4271
+
4272
+ DESCRIPTION
4273
+ Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
4274
+ device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
4275
+ each matched test file with esbuild (SDK imports redirected to window.__sdk),
4276
+ injects the bundle into the attached WebView via Runtime.evaluate, and prints
4277
+ a summary.
4278
+
4279
+ With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
4280
+ runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
4281
+ compared offline.
4282
+
4283
+ The test files run against the live relay connection started by this process;
4284
+ no separate MCP daemon is required.
4285
+
4286
+ EXAMPLE
4287
+ devtools-test 'src/**/*.ait.test.ts' \\
4288
+ --scheme-url "intoss-private://..." \\
4289
+ --cell-sdk-line 3.x \\
4290
+ --cell-platform ios \\
4291
+ --report-dir .ait-report \\
4292
+ --timeout 60000
4293
+
4294
+ `.trimStart();
4295
+ /**
4296
+ * Renders per-file result lines and the aggregate totals line to a string.
4297
+ *
4298
+ * Each file gets one line:
4299
+ * - Error/timeout: `FAIL <basename>: <error-class>`
4300
+ * - Pass (0 tests): `OK <basename>: 0 passed (empty file)`
4301
+ * - Pass: `OK <basename>: N passed[, M failed][, K skipped]`
4302
+ *
4303
+ * The aggregate totals line always follows.
4304
+ *
4305
+ * SECRET-HANDLING: only `basename(file)` is used — no absolute paths, relay
4306
+ * URLs, wss URLs, scheme URLs, or TOTP codes appear in the output. The error
4307
+ * string comes from `result.error` which is already secret-free (relay-worker
4308
+ * produces only error-class messages like "rpc: evaluate timed out after
4309
+ * 30000ms").
4310
+ *
4311
+ * Exported so unit tests can assert the per-file lines without spawning a
4312
+ * subprocess or going through the full relay attach flow.
4313
+ */
4314
+ function renderSummary(report) {
4315
+ const lines = [];
4316
+ for (const { file, result } of report.files) {
4317
+ const name = basename(file);
4318
+ if ("error" in result) lines.push(`FAIL ${name}: ${result.error}`);
4319
+ else {
4320
+ const parts = [`${result.passed} passed`];
4321
+ if (result.failed > 0) parts.push(`${result.failed} failed`);
4322
+ if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
4323
+ const suffix = result.passed + result.failed + result.skipped === 0 ? " (empty file)" : "";
4324
+ lines.push(`OK ${name}: ${parts.join(", ")}${suffix}`);
4286
4325
  }
4287
- if (!this.buffers.has(message.method)) return;
4288
- const event = message.method;
4289
- const buffer = this.buffers.get(event);
4290
- if (!buffer) return;
4291
- buffer.push(message.params);
4292
- const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
4293
- if (buffer.length > cap) buffer.shift();
4294
- this.emitter.emit(event, message.params);
4295
- }
4296
- getBufferedEvents(event) {
4297
- return this.buffers.get(event) ?? [];
4298
4326
  }
4299
- on(event, listener) {
4300
- this.emitter.on(event, listener);
4301
- return () => this.emitter.off(event, listener);
4327
+ const { totals, duration } = report;
4328
+ lines.push(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${duration}ms)`);
4329
+ return lines.join("\n");
4330
+ }
4331
+ /**
4332
+ * Runs `files` over `connection` and returns the aggregate report.
4333
+ * This pure function is the testable core of the CLI (and is what the
4334
+ * `run_tests` MCP tool calls against the daemon's attached connection); it is
4335
+ * separate from `main()` so tests can call it without spawning a subprocess.
4336
+ */
4337
+ async function runWithConnection(connection, files, opts) {
4338
+ const report = await runTestFilesOverRelay(connection, files, opts);
4339
+ if (opts?.printSummary) process.stdout.write(`\n${renderSummary(report)}\n`);
4340
+ return report;
4341
+ }
4342
+ //#endregion
4343
+ //#region src/mcp/ait-chii-source.ts
4344
+ function isObject$2(value) {
4345
+ return typeof value === "object" && value !== null;
4346
+ }
4347
+ /** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
4348
+ function asSdkCallHistory(raw) {
4349
+ if (isObject$2(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
4350
+ return { calls: [] };
4351
+ }
4352
+ /** Narrows an `AIT.getMockState` response to an opaque record. */
4353
+ function asMockState(raw) {
4354
+ return isObject$2(raw) ? raw : {};
4355
+ }
4356
+ /** Narrows an `AIT.getOperationalEnvironment` response. */
4357
+ function asOperationalEnvironment(raw) {
4358
+ return {
4359
+ environment: isObject$2(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
4360
+ sdkVersion: isObject$2(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
4361
+ };
4362
+ }
4363
+ var ChiiAitSource = class {
4364
+ constructor(sender) {
4365
+ this.sender = sender;
4302
4366
  }
4303
- /** Close the relay client websocket and reject any in-flight commands. */
4304
- close() {
4305
- const ws = this.ws;
4306
- this.stopHeartbeat();
4307
- this.handleDisconnect("Chii relay connection closed");
4308
- ws?.close();
4367
+ async get(method) {
4368
+ const raw = await this.sender.sendCommand(method);
4369
+ switch (method) {
4370
+ case "AIT.getSdkCallHistory": return asSdkCallHistory(raw);
4371
+ case "AIT.getMockState": return asMockState(raw);
4372
+ case "AIT.getOperationalEnvironment": return asOperationalEnvironment(raw);
4373
+ default: throw new Error(`Unknown AIT method: ${String(method)}`);
4374
+ }
4309
4375
  }
4310
4376
  };
4311
4377
  //#endregion
@@ -6785,7 +6851,7 @@ function createDebugServer(deps) {
6785
6851
  };
6786
6852
  const server = new Server({
6787
6853
  name: "ait-debug",
6788
- version: "0.1.127"
6854
+ version: "0.1.129"
6789
6855
  }, { capabilities: { tools: { listChanged: true } } });
6790
6856
  server.setRequestHandler(ListToolsRequestSchema, () => {
6791
6857
  const conn = router.active;
@@ -8757,7 +8823,7 @@ function createDevServer(deps = {}) {
8757
8823
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
8758
8824
  const server = new Server({
8759
8825
  name: "ait-devtools",
8760
- version: "0.1.127"
8826
+ version: "0.1.129"
8761
8827
  }, { capabilities: { tools: {} } });
8762
8828
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
8763
8829
  server.setRequestHandler(CallToolRequestSchema, async (request) => {