@ait-co/devtools 0.1.128 → 0.1.131
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.
- package/dist/{attach-orchestrator-CPMQWkGV.js → attach-orchestrator-BiaYiNOr.js} +34 -107
- package/dist/attach-orchestrator-BiaYiNOr.js.map +1 -0
- package/dist/{attach-orchestrator-RhOdO3DS.js → attach-orchestrator-Cjdhir2U.js} +31 -13
- package/dist/{attach-orchestrator-RhOdO3DS.js.map → attach-orchestrator-Cjdhir2U.js.map} +1 -1
- package/dist/{attach-orchestrator-CpE0dMew.js → attach-orchestrator-ca34r46y.js} +33 -106
- package/dist/attach-orchestrator-ca34r46y.js.map +1 -0
- package/dist/{bundle-Cb3GFuw6.d.ts → bundle-BKqyhEK9.d.ts} +1 -1
- package/dist/{bundle-Cb3GFuw6.d.ts.map → bundle-BKqyhEK9.d.ts.map} +1 -1
- package/dist/{capture-B1zfGyTo.d.ts → capture-ltuV0gZa.d.ts} +1 -1
- package/dist/{capture-B1zfGyTo.d.ts.map → capture-ltuV0gZa.d.ts.map} +1 -1
- package/dist/{cdp-connection-Dxw_EY8s.d.ts → cdp-connection-D7AYQck2.d.ts} +1 -1
- package/dist/{cdp-connection-Dxw_EY8s.d.ts.map → cdp-connection-D7AYQck2.d.ts.map} +1 -1
- package/dist/{cell-D0dJSGHi.js → cell-Bi8MAPA8.js} +11 -7
- package/dist/cell-Bi8MAPA8.js.map +1 -0
- package/dist/{cell-Ba9lCI3B.js → cell-Cw4fVOl2.js} +11 -7
- package/dist/cell-Cw4fVOl2.js.map +1 -0
- package/dist/{cell-CX92lDI0.js → cell-rr6gL9AT.js} +11 -7
- package/dist/cell-rr6gL9AT.js.map +1 -0
- package/dist/debug-server-BSbfnFR5.js +436 -0
- package/dist/debug-server-BSbfnFR5.js.map +1 -0
- package/dist/debug-server-CD4zYBq4.js +379 -0
- package/dist/{debug-server-BzzNkETg.js.map → debug-server-CD4zYBq4.js.map} +1 -1
- package/dist/{debug-server-Dctc9GAh.js → debug-server-qRSRG7YZ.js} +68 -65
- package/dist/debug-server-qRSRG7YZ.js.map +1 -0
- package/dist/in-app/auto.js +15 -0
- package/dist/in-app/auto.js.map +1 -1
- package/dist/in-app/index.d.ts +27 -0
- package/dist/in-app/index.d.ts.map +1 -1
- package/dist/in-app/index.js +15 -0
- package/dist/in-app/index.js.map +1 -1
- package/dist/log-CUik_tHp.js +95 -0
- package/dist/log-CUik_tHp.js.map +1 -0
- package/dist/mcp/cli.js +1272 -1111
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +11 -1
- package/dist/panel/index.js.map +1 -1
- package/dist/{pool-DBLKNeRC.d.ts → pool-BaSCZALv.d.ts} +12 -4
- package/dist/{pool-DBLKNeRC.d.ts.map → pool-BaSCZALv.d.ts.map} +1 -1
- package/dist/{qr-http-server-Bo8LMOJQ.js → qr-http-server-BdNGPRoL.js} +76 -6
- package/dist/qr-http-server-BdNGPRoL.js.map +1 -0
- package/dist/{qr-http-server-CrGohOwC.js → qr-http-server-Bq9zR7-z.js} +76 -6
- package/dist/qr-http-server-Bq9zR7-z.js.map +1 -0
- package/dist/{qr-http-server-Bt_RAxFn.cjs → qr-http-server-BxoRp2w7.cjs} +76 -6
- package/dist/qr-http-server-BxoRp2w7.cjs.map +1 -0
- package/dist/{qr-http-server-DM2R25jP.cjs → qr-http-server-CV6TA9ZN.cjs} +76 -6
- package/dist/qr-http-server-CV6TA9ZN.cjs.map +1 -0
- package/dist/{qr-http-server-s1iW_5VQ.js → qr-http-server-DCbjORFa.js} +76 -6
- package/dist/qr-http-server-DCbjORFa.js.map +1 -0
- package/dist/{qr-http-server-BH0UFeUn.js → qr-http-server-cKbciY5k.js} +76 -6
- package/dist/qr-http-server-cKbciY5k.js.map +1 -0
- package/dist/{relay-factory-Dug7W6Ao.js → relay-factory-Cloog1jb.js} +34 -14
- package/dist/relay-factory-Cloog1jb.js.map +1 -0
- package/dist/{relay-secret-store-DKxs7zwq.js → relay-secret-store-DzxDsK6S.js} +1 -1
- package/dist/{relay-secret-store-DKxs7zwq.js.map → relay-secret-store-DzxDsK6S.js.map} +1 -1
- package/dist/{relay-url-store-xmUuTjXA.js → relay-url-store-D3rY-GJJ.js} +2 -2
- package/dist/{relay-url-store-xmUuTjXA.js.map → relay-url-store-D3rY-GJJ.js.map} +1 -1
- package/dist/{debug-server-BzzNkETg.js → relay-worker-CCAHLC4a.js} +220 -350
- package/dist/relay-worker-CCAHLC4a.js.map +1 -0
- package/dist/{relay-worker-TReZtzGl.d.ts → relay-worker-o0M2ndyJ.d.ts} +7 -7
- package/dist/relay-worker-o0M2ndyJ.d.ts.map +1 -0
- package/dist/{runtime-ozyPnRbU.d.ts → runtime-9xhN9pr8.d.ts} +1 -1
- package/dist/{runtime-ozyPnRbU.d.ts.map → runtime-9xhN9pr8.d.ts.map} +1 -1
- package/dist/test-runner/bin.js +744 -25
- package/dist/test-runner/bin.js.map +1 -1
- package/dist/test-runner/bundle.d.ts +1 -1
- package/dist/test-runner/capture.d.ts +1 -1
- package/dist/test-runner/config.d.ts +15 -9
- package/dist/test-runner/config.d.ts.map +1 -1
- package/dist/test-runner/config.js +1 -1
- package/dist/test-runner/pool.d.ts +1 -1
- package/dist/test-runner/pool.js +1 -1
- package/dist/test-runner/pool.js.map +1 -1
- package/dist/test-runner/relay-factory.d.ts +23 -9
- package/dist/test-runner/relay-factory.d.ts.map +1 -1
- package/dist/test-runner/relay-factory.js +32 -12
- package/dist/test-runner/relay-factory.js.map +1 -1
- package/dist/test-runner/relay-worker.d.ts +1 -1
- package/dist/test-runner/relay-worker.js +1 -187
- package/dist/test-runner/report.d.ts +3 -3
- package/dist/test-runner/rpc.d.ts +2 -2
- package/dist/test-runner/runtime.d.ts +1 -1
- package/dist/test-runner/task-graph.d.ts +1 -1
- package/dist/{tunnel-DyEV6WwL.cjs → tunnel-B2UCKAc7.cjs} +4 -3
- package/dist/{tunnel-DyEV6WwL.cjs.map → tunnel-B2UCKAc7.cjs.map} +1 -1
- package/dist/{tunnel-BFwOSwT4.js → tunnel-C-rQ34dM.js} +4 -3
- package/dist/{tunnel-BFwOSwT4.js.map → tunnel-C-rQ34dM.js.map} +1 -1
- package/dist/unplugin/index.cjs +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/unplugin/tunnel.cjs +3 -2
- package/dist/unplugin/tunnel.cjs.map +1 -1
- package/dist/unplugin/tunnel.d.cts.map +1 -1
- package/dist/unplugin/tunnel.d.ts.map +1 -1
- package/dist/unplugin/tunnel.js +3 -2
- package/dist/unplugin/tunnel.js.map +1 -1
- package/package.json +1 -1
- package/dist/attach-orchestrator-CPMQWkGV.js.map +0 -1
- package/dist/attach-orchestrator-CpE0dMew.js.map +0 -1
- package/dist/cell-Ba9lCI3B.js.map +0 -1
- package/dist/cell-CX92lDI0.js.map +0 -1
- package/dist/cell-D0dJSGHi.js.map +0 -1
- package/dist/debug-server-1dhDA3aw.js +0 -980
- package/dist/debug-server-1dhDA3aw.js.map +0 -1
- package/dist/debug-server-Dctc9GAh.js.map +0 -1
- package/dist/qr-http-server-BH0UFeUn.js.map +0 -1
- package/dist/qr-http-server-Bo8LMOJQ.js.map +0 -1
- package/dist/qr-http-server-Bt_RAxFn.cjs.map +0 -1
- package/dist/qr-http-server-CrGohOwC.js.map +0 -1
- package/dist/qr-http-server-DM2R25jP.cjs.map +0 -1
- package/dist/qr-http-server-s1iW_5VQ.js.map +0 -1
- package/dist/relay-factory-Dug7W6Ao.js.map +0 -1
- package/dist/relay-worker-TReZtzGl.d.ts.map +0 -1
- 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.
|
|
2027
|
+
return "0.1.131";
|
|
2028
2028
|
}
|
|
2029
2029
|
/**
|
|
2030
2030
|
* Derives the next recommended action from a completed diagnostics snapshot.
|
|
@@ -2864,32 +2864,50 @@ async function renderAndMaybeWait(deps, prep, waitForAttach, callTimeoutMs, conn
|
|
|
2864
2864
|
}, null, 2)}\n\n${qr}`);
|
|
2865
2865
|
}
|
|
2866
2866
|
/**
|
|
2867
|
-
* Builds a self-contained IIFE DOM expression that renders a
|
|
2868
|
-
* "Debugger Connected" badge on the bottom-left of
|
|
2867
|
+
* Builds a self-contained IIFE DOM expression that renders a LIVE
|
|
2868
|
+
* "Debugger Connected" / "Debugger Disconnected" badge on the bottom-left of
|
|
2869
|
+
* the phone screen (#730).
|
|
2869
2870
|
*
|
|
2870
2871
|
* **Pure function** — returns a JS expression string; does NOT inject it.
|
|
2871
2872
|
* Injection is performed by {@link injectDebugIndicator} in `cell.ts`.
|
|
2872
2873
|
*
|
|
2873
2874
|
* The expression, when evaluated on the page, does the following:
|
|
2874
|
-
* 1.
|
|
2875
|
-
*
|
|
2876
|
-
*
|
|
2877
|
-
*
|
|
2878
|
-
*
|
|
2879
|
-
*
|
|
2875
|
+
* 1. Idempotent controller — a single `window.__ait_indicator` controller
|
|
2876
|
+
* object is created once (keyed on its presence, not on the DOM node).
|
|
2877
|
+
* Re-injection (e.g. explicit disconnect notice from `close()`) UPDATES
|
|
2878
|
+
* the existing badge's state/label in place — it never duplicates the
|
|
2879
|
+
* `<div>` or stacks a second observer.
|
|
2880
|
+
* 2. Renders a fixed-position `<div id="__ait_debug_indicator">` at the
|
|
2881
|
+
* bottom-left, accounting for safe-area insets. Background/text flip
|
|
2882
|
+
* between the attached (red) and disconnected (grey) visuals.
|
|
2883
|
+
* 3. A `pointerdown` listener dismisses (hides) the badge on tap — dismiss
|
|
2884
|
+
* is NON-terminal: a later state transition un-dismisses it, so a
|
|
2885
|
+
* genuine disconnect after a dismissed tap is still visible.
|
|
2886
|
+
* 4. Observes relay-socket lifecycle WITHOUT opening any new connection:
|
|
2887
|
+
* - Preferred path: if the in-app module (`src/in-app/attach.ts`) has
|
|
2888
|
+
* already installed its relay-WS observer (`window.__ait_relay_ws_observed`),
|
|
2889
|
+
* subscribe to the `ait:relay-ws-state` CustomEvent it broadcasts.
|
|
2890
|
+
* - Fallback path: wrap `window.WebSocket` in a `Proxy` and match dials
|
|
2891
|
+
* by PATHNAME SHAPE only (`/target/`) — never by host/wss value — so a
|
|
2892
|
+
* bare CDP-injected badge (no in-app bundle) still reacts to the
|
|
2893
|
+
* chii target socket's own open/close lifecycle.
|
|
2880
2894
|
*
|
|
2881
2895
|
* The expression intentionally contains NO relay URLs, wss addresses, TOTP
|
|
2882
|
-
* codes, or any other secrets. It is pure DOM UI text only.
|
|
2896
|
+
* codes, or any other secrets. It is pure DOM UI text + enum state only.
|
|
2883
2897
|
*
|
|
2884
2898
|
* SECRET-HANDLING: this expression contains no secrets, relay URLs, wss
|
|
2885
|
-
* addresses, or TOTP codes whatsoever — DOM label text
|
|
2899
|
+
* addresses, or TOTP codes whatsoever — DOM label text + a structural
|
|
2900
|
+
* pathname match (`/target/`) only.
|
|
2886
2901
|
*
|
|
2887
|
-
* @param opts.label -
|
|
2902
|
+
* @param opts.label - Attached-state badge text (default: `'Debugger Connected'`).
|
|
2903
|
+
* @param opts.disconnectedLabel - Disconnected-state badge text (default: `'Debugger Disconnected'`).
|
|
2904
|
+
* @param opts.state - Initial/forced state for THIS injection call (default: `'attached'`).
|
|
2888
2905
|
* @returns A JS expression string suitable for `Runtime.evaluate`.
|
|
2889
2906
|
*/
|
|
2890
2907
|
function buildIndicatorExpression(opts) {
|
|
2891
2908
|
const label = opts?.label ?? "Debugger Connected";
|
|
2892
|
-
|
|
2909
|
+
const disconnectedLabel = opts?.disconnectedLabel ?? "Debugger Disconnected";
|
|
2910
|
+
return `(() => {var W = window;function render(c) {if (c.dismissed) { c.el.style.display = 'none'; return; }c.el.style.display = 'block';var up = c.state !== 'disconnected';c.el.style.background = up ? '#e5484d' : '#8a8f98';c.el.textContent = up ? ${JSON.stringify(label)} : ${JSON.stringify(disconnectedLabel)};}function setState(c, next) { c.state = next; c.dismissed = false; render(c); }var c = W.__ait_indicator;if (!c) {var el = document.createElement('div');el.id = '__ait_debug_indicator';el.style.cssText = ['position:fixed','left:max(12px,calc(env(safe-area-inset-left,0px) + 8px))','bottom:max(12px,calc(env(safe-area-inset-bottom,0px) + 8px))','z-index:2147483647','color:#fff','font:bold 11px/1 system-ui,sans-serif','padding:5px 9px','border-radius:6px','pointer-events:auto','user-select:none',].join(';');document.body.appendChild(el);c = { el: el, state: 'attached', dismissed: false };el.addEventListener('pointerdown', function () { c.dismissed = true; render(c); }, { passive: true });W.__ait_indicator = c;if (W.__ait_relay_ws_observed) {W.addEventListener('ait:relay-ws-state', function (e) {setState(c, e.detail && e.detail.state === 'open' ? 'attached' : 'disconnected');});} else {try {var Native = W.WebSocket;W.WebSocket = new Proxy(Native, {construct: function (t, a) {var ws = Reflect.construct(t, a);try {var u = new URL(String(a[0]), location.href);if (/\\/target\\//.test(u.pathname)) {ws.addEventListener('open', function () { setState(c, 'attached'); });ws.addEventListener('close', function () { setState(c, 'disconnected'); });ws.addEventListener('error', function () { setState(c, 'disconnected'); });}} catch (_) {}return ws;},});} catch (_) {}}}setState(c, ${JSON.stringify(opts?.state ?? "attached")});})()`;
|
|
2893
2911
|
}
|
|
2894
2912
|
//#endregion
|
|
2895
2913
|
//#region src/test-runner/cell.ts
|
|
@@ -2935,12 +2953,16 @@ async function injectGlobals(conn, globals) {
|
|
|
2935
2953
|
}
|
|
2936
2954
|
}
|
|
2937
2955
|
/**
|
|
2938
|
-
* Injects the "Debugger Connected"
|
|
2956
|
+
* Injects (or updates) the "Debugger Connected"/"Debugger Disconnected"
|
|
2957
|
+
* on-phone indicator via `Runtime.evaluate` (#730).
|
|
2939
2958
|
*
|
|
2940
2959
|
* Uses the same CDP mechanism as {@link injectGlobals} — a single
|
|
2941
2960
|
* `Runtime.evaluate` round-trip with the expression built by
|
|
2942
|
-
* {@link buildIndicatorExpression}. The indicator is a
|
|
2943
|
-
*
|
|
2961
|
+
* {@link buildIndicatorExpression}. The indicator is a LIVE badge rendered at
|
|
2962
|
+
* the bottom-left of the page (position:fixed, safe-area-aware): the badge's
|
|
2963
|
+
* controller is idempotent, so calling this again (e.g. right before
|
|
2964
|
+
* `close()` with `{ state: 'disconnected' }`) UPDATES the existing badge in
|
|
2965
|
+
* place instead of injecting a duplicate.
|
|
2944
2966
|
*
|
|
2945
2967
|
* **Isolation**: unlike {@link injectGlobals}, this function NEVER throws to
|
|
2946
2968
|
* its caller. Injection failure (e.g. page detached during inject, CSS not
|
|
@@ -2950,12 +2972,12 @@ async function injectGlobals(conn, globals) {
|
|
|
2950
2972
|
* `src/in-app/attach.ts`.
|
|
2951
2973
|
*
|
|
2952
2974
|
* Call this ONLY on the manual debug paths (start_attach MCP, devtools-test
|
|
2953
|
-
* CLI). Do NOT call on the `run_tests` auto-attach path — the
|
|
2975
|
+
* CLI). Do NOT call on the `run_tests` auto-attach path — the badge
|
|
2954
2976
|
* would contaminate screenshots, measure_safe_area probes, and DOM snapshots
|
|
2955
2977
|
* taken during automated measurement runs.
|
|
2956
2978
|
*
|
|
2957
2979
|
* SECRET-HANDLING: the injected expression contains no secrets, relay URLs,
|
|
2958
|
-
* wss addresses, or TOTP codes — it is pure DOM UI text built by
|
|
2980
|
+
* wss addresses, or TOTP codes — it is pure DOM UI text + enum state built by
|
|
2959
2981
|
* {@link buildIndicatorExpression}.
|
|
2960
2982
|
*
|
|
2961
2983
|
* @param conn - The live CDP connection (relay-attached page).
|
|
@@ -3003,880 +3025,125 @@ async function discoverTestFiles(patterns, cwd) {
|
|
|
3003
3025
|
return [...out].sort();
|
|
3004
3026
|
}
|
|
3005
3027
|
//#endregion
|
|
3006
|
-
//#region src/
|
|
3028
|
+
//#region src/shared/relay-auth-close.ts
|
|
3007
3029
|
/**
|
|
3008
|
-
*
|
|
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.
|
|
3030
|
+
* Shared constants for the relay's named TOTP-auth rejection (issue #478).
|
|
3049
3031
|
*
|
|
3050
|
-
*
|
|
3051
|
-
*
|
|
3032
|
+
* Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
|
|
3033
|
+
* raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
|
|
3034
|
+
* indistinguishable from a network failure on the browser side — the
|
|
3035
|
+
* WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
|
|
3036
|
+
* could not tell "stale TOTP code" apart from "tunnel down" and stayed
|
|
3037
|
+
* silent. The fix is accept-then-close: complete the handshake, then close
|
|
3038
|
+
* with an application close code that NAMES the rejection.
|
|
3052
3039
|
*
|
|
3053
|
-
*
|
|
3040
|
+
* Three parties share this contract:
|
|
3041
|
+
* - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
|
|
3042
|
+
* - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
|
|
3043
|
+
* surfaces the code to the launcher shell;
|
|
3044
|
+
* - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
|
|
3045
|
+
* as an auth failure on its own `/client` dial (defensive — #439's fresh
|
|
3046
|
+
* code mint means it should not normally hit this).
|
|
3054
3047
|
*
|
|
3055
|
-
*
|
|
3056
|
-
*
|
|
3057
|
-
*
|
|
3048
|
+
* This module is intentionally dependency-free (no Node, no DOM) so it is
|
|
3049
|
+
* safe to import from both the browser in-app bundle and the MCP daemon
|
|
3050
|
+
* bundle.
|
|
3058
3051
|
*
|
|
3059
|
-
* SECRET-HANDLING:
|
|
3052
|
+
* SECRET-HANDLING: these are fixed enum values. The close reason / error body
|
|
3053
|
+
* must never grow to carry a secret, a TOTP code, or a host.
|
|
3060
3054
|
*/
|
|
3061
|
-
/** The SDK package name that mini-app test code imports from. */
|
|
3062
|
-
const SDK_PACKAGE = "@apps-in-toss/web-framework";
|
|
3063
3055
|
/**
|
|
3064
|
-
*
|
|
3065
|
-
*
|
|
3066
|
-
*
|
|
3067
|
-
*
|
|
3056
|
+
* WebSocket close code sent by the relay when TOTP auth is rejected.
|
|
3057
|
+
*
|
|
3058
|
+
* 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
|
|
3059
|
+
* HTTP 401 so it reads as "unauthorized" at a glance.
|
|
3068
3060
|
*/
|
|
3069
|
-
const
|
|
3070
|
-
"describe",
|
|
3071
|
-
"it",
|
|
3072
|
-
"test",
|
|
3073
|
-
"expect",
|
|
3074
|
-
"beforeAll",
|
|
3075
|
-
"afterAll",
|
|
3076
|
-
"beforeEach",
|
|
3077
|
-
"afterEach",
|
|
3078
|
-
"vi"
|
|
3079
|
-
];
|
|
3061
|
+
const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
|
|
3080
3062
|
/**
|
|
3081
|
-
*
|
|
3082
|
-
*
|
|
3083
|
-
*
|
|
3063
|
+
* Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
|
|
3064
|
+
* the `error` value of the relay's HTTP 401 JSON body. Enum string only —
|
|
3065
|
+
* never interpolated with request data.
|
|
3084
3066
|
*/
|
|
3085
|
-
const
|
|
3067
|
+
const RELAY_AUTH_REJECT_REASON = "totp-rejected";
|
|
3068
|
+
//#endregion
|
|
3069
|
+
//#region src/mcp/chii-connection.ts
|
|
3086
3070
|
/**
|
|
3087
|
-
*
|
|
3088
|
-
* `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
|
|
3071
|
+
* Production `CdpConnection` backed by the local Chii relay.
|
|
3089
3072
|
*
|
|
3090
|
-
*
|
|
3091
|
-
*
|
|
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.
|
|
3073
|
+
* Topology (debug mode):
|
|
3074
|
+
* phone target.js --WS--> Chii relay :9100 <--WS-- this connection
|
|
3094
3075
|
*
|
|
3095
|
-
*
|
|
3096
|
-
*
|
|
3097
|
-
|
|
3098
|
-
|
|
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).
|
|
3076
|
+
* The phone connects to the relay as a `target`; this module connects as a
|
|
3077
|
+
* `client` (the role a CDP frontend would take) so CDP events the page emits
|
|
3078
|
+
* (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
|
|
3079
|
+
* events in ring buffers the tool layer reads via `getBufferedEvents`.
|
|
3129
3080
|
*
|
|
3130
|
-
*
|
|
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.
|
|
3081
|
+
* Node-only: imports `ws`. Never bundled into the browser/in-app entries.
|
|
3135
3082
|
*
|
|
3136
|
-
*
|
|
3137
|
-
*
|
|
3138
|
-
*
|
|
3139
|
-
*
|
|
3140
|
-
*
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
*
|
|
3083
|
+
* Attach reliability (#281):
|
|
3084
|
+
* `refreshTargets()` emits an internal 'target:attached' event whenever a
|
|
3085
|
+
* new target is added to the relay. `waitForFirstTarget()` awaits that event
|
|
3086
|
+
* (with a polling-interval fallback) so `start_attach`'s attach wait
|
|
3087
|
+
* resolves deterministically rather than racing between polling rounds.
|
|
3088
|
+
*/
|
|
3089
|
+
/** Max events retained per domain ring buffer. */
|
|
3090
|
+
const DEFAULT_BUFFER_SIZE$1 = 500;
|
|
3091
|
+
/**
|
|
3092
|
+
* Substrings that mark a "relay websocket is dead" class error, as produced by
|
|
3093
|
+
* `handleDisconnect('relay WebSocket 연결이 끊겼습니다')` (ws close handler) and
|
|
3094
|
+
* the fail-fast `sendCommand` rejection (`relay에 연결되어 있지 않습니다 (...)`).
|
|
3146
3095
|
*
|
|
3147
|
-
*
|
|
3148
|
-
*
|
|
3149
|
-
*
|
|
3150
|
-
*
|
|
3096
|
+
* Exported so callers outside this module (e.g. `test-runner/relay-worker.ts`)
|
|
3097
|
+
* can detect the same error class without hardcoding a fragile full-sentence
|
|
3098
|
+
* match — mirrors the `EVALUATE_TIMEOUT_MARKER` precedent in
|
|
3099
|
+
* `test-runner/relay-worker.ts`. Kept in sync with `classifyToolError`'s
|
|
3100
|
+
* `relayDisconnectError` branch in `mcp/errors.ts`.
|
|
3151
3101
|
*/
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3102
|
+
const RELAY_DISCONNECT_MARKERS = ["relay WebSocket", "relay에 연결되어 있지 않습니다"];
|
|
3103
|
+
/**
|
|
3104
|
+
* Returns true when `message` matches the relay-websocket-dead error class
|
|
3105
|
+
* (socket closed, socket errored, or fail-fast "not connected" rejection).
|
|
3106
|
+
*/
|
|
3107
|
+
function isRelayDisconnectMessage(message) {
|
|
3108
|
+
return RELAY_DISCONNECT_MARKERS.some((marker) => message.includes(marker));
|
|
3109
|
+
}
|
|
3110
|
+
function isObject$3(value) {
|
|
3111
|
+
return typeof value === "object" && value !== null;
|
|
3112
|
+
}
|
|
3113
|
+
function parseInbound$1(raw) {
|
|
3114
|
+
let parsed;
|
|
3115
|
+
try {
|
|
3116
|
+
parsed = JSON.parse(raw);
|
|
3117
|
+
} catch {
|
|
3118
|
+
return null;
|
|
3119
|
+
}
|
|
3120
|
+
if (!isObject$3(parsed)) return null;
|
|
3121
|
+
const message = {};
|
|
3122
|
+
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
3123
|
+
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
3124
|
+
if ("params" in parsed) message.params = parsed.params;
|
|
3125
|
+
if ("result" in parsed) message.result = parsed.result;
|
|
3126
|
+
if (isObject$3(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
3127
|
+
return message;
|
|
3171
3128
|
}
|
|
3129
|
+
const PHASE_1_EVENTS$1 = [
|
|
3130
|
+
"Runtime.consoleAPICalled",
|
|
3131
|
+
"Network.requestWillBeSent",
|
|
3132
|
+
"Network.responseReceived"
|
|
3133
|
+
];
|
|
3172
3134
|
/**
|
|
3173
|
-
*
|
|
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.
|
|
3135
|
+
* Ring buffer size for `Runtime.exceptionThrown`.
|
|
3178
3136
|
*
|
|
3179
|
-
*
|
|
3180
|
-
*
|
|
3181
|
-
*
|
|
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.
|
|
3137
|
+
* Exceptions are rarer than console messages but each is heavier (stack
|
|
3138
|
+
* trace). 50 is generous enough to cover a crash scenario while keeping
|
|
3139
|
+
* memory bounded.
|
|
3195
3140
|
*
|
|
3196
|
-
*
|
|
3197
|
-
*
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
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
|
-
}
|
|
3253
|
-
/**
|
|
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.
|
|
3277
|
-
*/
|
|
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;
|
|
3298
|
-
}
|
|
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;
|
|
3387
|
-
}
|
|
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 = 6e4;
|
|
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 TIMEOUT_SENTINEL = Symbol("timeout");
|
|
3458
|
-
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs));
|
|
3459
|
-
const evalPromise = connection.send("Runtime.evaluate", {
|
|
3460
|
-
expression,
|
|
3461
|
-
returnByValue: true,
|
|
3462
|
-
awaitPromise: true
|
|
3463
|
-
});
|
|
3464
|
-
const raceResult = await Promise.race([evalPromise.then((v) => ({
|
|
3465
|
-
tag: "eval",
|
|
3466
|
-
v
|
|
3467
|
-
})), timeoutPromise.then(() => ({ tag: "timeout" }))]);
|
|
3468
|
-
if (raceResult.tag === "timeout") return {
|
|
3469
|
-
ok: false,
|
|
3470
|
-
error: `rpc: evaluate timed out after ${timeoutMs}ms`
|
|
3471
|
-
};
|
|
3472
|
-
const cdpResult = raceResult.v;
|
|
3473
|
-
if (cdpResult.exceptionDetails) {
|
|
3474
|
-
const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
3475
|
-
throw new Error(`rpc.injectAndRunBundle: ${msg}`);
|
|
3476
|
-
}
|
|
3477
|
-
return parseRunTestsResult(cdpResult.result.value);
|
|
3478
|
-
}
|
|
3479
|
-
//#endregion
|
|
3480
|
-
//#region src/test-runner/relay-worker.ts
|
|
3481
|
-
/**
|
|
3482
|
-
* Sentinel string embedded in the error message by `injectAndRunBundle` when
|
|
3483
|
-
* the per-file evaluate race hits the timeout. Used by the retry guard so only
|
|
3484
|
-
* genuine timeouts get a second attempt — not bundle errors or parse failures.
|
|
3485
|
-
*
|
|
3486
|
-
* Exported for unit tests that assert the retry path is taken.
|
|
3487
|
-
*/
|
|
3488
|
-
const EVALUATE_TIMEOUT_MARKER = "rpc: evaluate timed out after";
|
|
3489
|
-
/**
|
|
3490
|
-
* Runs all `files` sequentially over the given CDP `connection`.
|
|
3491
|
-
*
|
|
3492
|
-
* For each file:
|
|
3493
|
-
* 1. Bundle with esbuild (includes SDK shim + runtime).
|
|
3494
|
-
* 2. Inject into the attached page via `Runtime.evaluate`.
|
|
3495
|
-
* 3. Await the `RunReport` JSON response.
|
|
3496
|
-
* 4. Accumulate results.
|
|
3497
|
-
*
|
|
3498
|
-
* Returns a `RelayRunReport` with per-file results and flattened totals.
|
|
3499
|
-
*
|
|
3500
|
-
* This function does NOT open or manage the relay connection — the caller
|
|
3501
|
-
* is responsible for attaching and closing it.
|
|
3502
|
-
*
|
|
3503
|
-
* TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
|
|
3504
|
-
* so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
|
|
3505
|
-
*
|
|
3506
|
-
* @param connection - Active CDP connection (relay or local kind).
|
|
3507
|
-
* @param files - Absolute paths to test files, run in order.
|
|
3508
|
-
* @param opts - Optional per-run overrides.
|
|
3509
|
-
*/
|
|
3510
|
-
async function runTestFilesOverRelay(connection, files, opts) {
|
|
3511
|
-
const wallStart = Date.now();
|
|
3512
|
-
const startedAt = new Date(wallStart).toISOString();
|
|
3513
|
-
const fileResults = [];
|
|
3514
|
-
let domainsEnabled = false;
|
|
3515
|
-
try {
|
|
3516
|
-
await connection.enableDomains();
|
|
3517
|
-
domainsEnabled = true;
|
|
3518
|
-
} catch (e) {
|
|
3519
|
-
process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
|
|
3520
|
-
}
|
|
3521
|
-
const collectCaptures = opts?.collectCaptures === true;
|
|
3522
|
-
const liveConsole = [];
|
|
3523
|
-
let unsubscribeConsole;
|
|
3524
|
-
if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
|
|
3525
|
-
liveConsole.push(event);
|
|
3526
|
-
});
|
|
3527
|
-
try {
|
|
3528
|
-
for (const file of files) {
|
|
3529
|
-
let fileEntry;
|
|
3530
|
-
try {
|
|
3531
|
-
const { code } = await bundleTestFile(file, opts?.bundleOptions);
|
|
3532
|
-
/**
|
|
3533
|
-
* Runs one evaluate attempt and returns a FileResult, or `null` when the
|
|
3534
|
-
* result is a genuine timeout and the caller should retry.
|
|
3535
|
-
*
|
|
3536
|
-
* We need to distinguish:
|
|
3537
|
-
* - `rpcResult.ok = false` + timeout error → retry candidate (`return null`)
|
|
3538
|
-
* - `rpcResult.ok = false` + other error → final error, no retry
|
|
3539
|
-
* - `injectAndRunBundle` throws → CDP exceptionDetails (page
|
|
3540
|
-
* engine threw); treated as a final (non-retryable) error.
|
|
3541
|
-
*
|
|
3542
|
-
* The Promise.race timeout in rpc.ts RETURNS `{ok:false, error: '…'}` (it
|
|
3543
|
-
* does NOT throw/reject). Only genuine CDP `exceptionDetails` cause a throw.
|
|
3544
|
-
* This distinction is what makes the EVALUATE_TIMEOUT_MARKER gate below
|
|
3545
|
-
* reachable — the timeout result surfaces as `rpcResult.ok=false` with the
|
|
3546
|
-
* marker string, not as a caught exception.
|
|
3547
|
-
*/
|
|
3548
|
-
const attempt = async () => {
|
|
3549
|
-
let rpcResult;
|
|
3550
|
-
try {
|
|
3551
|
-
rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
|
|
3552
|
-
} catch (e) {
|
|
3553
|
-
return {
|
|
3554
|
-
file,
|
|
3555
|
-
result: { error: e instanceof Error ? e.message : String(e) }
|
|
3556
|
-
};
|
|
3557
|
-
}
|
|
3558
|
-
if (rpcResult.ok) return {
|
|
3559
|
-
file,
|
|
3560
|
-
result: rpcResult.report
|
|
3561
|
-
};
|
|
3562
|
-
if (rpcResult.error.includes("rpc: evaluate timed out after")) return null;
|
|
3563
|
-
return {
|
|
3564
|
-
file,
|
|
3565
|
-
result: { error: rpcResult.error }
|
|
3566
|
-
};
|
|
3567
|
-
};
|
|
3568
|
-
const firstResult = await attempt();
|
|
3569
|
-
if (firstResult !== null) fileEntry = firstResult;
|
|
3570
|
-
else {
|
|
3571
|
-
process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\n`);
|
|
3572
|
-
const retryResult = await attempt();
|
|
3573
|
-
if (retryResult !== null) fileEntry = retryResult;
|
|
3574
|
-
else fileEntry = {
|
|
3575
|
-
file,
|
|
3576
|
-
result: { error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 3e4}ms (after retry)` }
|
|
3577
|
-
};
|
|
3578
|
-
}
|
|
3579
|
-
} catch (e) {
|
|
3580
|
-
fileEntry = {
|
|
3581
|
-
file,
|
|
3582
|
-
result: { error: e instanceof Error ? e.message : String(e) }
|
|
3583
|
-
};
|
|
3584
|
-
}
|
|
3585
|
-
fileResults.push(fileEntry);
|
|
3586
|
-
}
|
|
3587
|
-
} finally {
|
|
3588
|
-
unsubscribeConsole?.();
|
|
3589
|
-
}
|
|
3590
|
-
const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
|
|
3591
|
-
const totals = fileResults.reduce((acc, { result }) => {
|
|
3592
|
-
if ("error" in result) {
|
|
3593
|
-
acc.failed += 1;
|
|
3594
|
-
acc.total += 1;
|
|
3595
|
-
} else {
|
|
3596
|
-
acc.passed += result.passed;
|
|
3597
|
-
acc.failed += result.failed;
|
|
3598
|
-
acc.skipped += result.skipped;
|
|
3599
|
-
acc.total += result.passed + result.failed + result.skipped;
|
|
3600
|
-
}
|
|
3601
|
-
return acc;
|
|
3602
|
-
}, {
|
|
3603
|
-
passed: 0,
|
|
3604
|
-
failed: 0,
|
|
3605
|
-
skipped: 0,
|
|
3606
|
-
total: 0
|
|
3607
|
-
});
|
|
3608
|
-
return {
|
|
3609
|
-
startedAt,
|
|
3610
|
-
duration: Date.now() - wallStart,
|
|
3611
|
-
files: fileResults,
|
|
3612
|
-
totals,
|
|
3613
|
-
captures
|
|
3614
|
-
};
|
|
3615
|
-
}
|
|
3616
|
-
/**
|
|
3617
|
-
* Renders one `Runtime.consoleAPICalled` event to a single line of text, the
|
|
3618
|
-
* same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
|
|
3619
|
-
* joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
|
|
3620
|
-
* which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
|
|
3621
|
-
* onto the test-runner entry.
|
|
3622
|
-
*
|
|
3623
|
-
* SECRET-HANDLING: this only stringifies console args; the caller's
|
|
3624
|
-
* allowlist-prefix parser then discards everything that is not a genuine
|
|
3625
|
-
* `__AIT_CAPTURE__` line.
|
|
3626
|
-
*/
|
|
3627
|
-
function renderConsoleLineText(event) {
|
|
3628
|
-
return event.args.map((arg) => {
|
|
3629
|
-
if (arg.value !== void 0) {
|
|
3630
|
-
if (typeof arg.value === "string") return arg.value;
|
|
3631
|
-
try {
|
|
3632
|
-
return JSON.stringify(arg.value);
|
|
3633
|
-
} catch {
|
|
3634
|
-
return String(arg.value);
|
|
3635
|
-
}
|
|
3636
|
-
}
|
|
3637
|
-
if (arg.description !== void 0) return arg.description;
|
|
3638
|
-
if (arg.className !== void 0) return arg.className;
|
|
3639
|
-
return arg.subtype ?? arg.type;
|
|
3640
|
-
}).join(" ");
|
|
3641
|
-
}
|
|
3642
|
-
`
|
|
3643
|
-
devtools-test — run mini-app tests on a real device WebView over the CDP relay
|
|
3644
|
-
|
|
3645
|
-
USAGE
|
|
3646
|
-
devtools-test <glob> [<glob> ...] [options]
|
|
3647
|
-
|
|
3648
|
-
OPTIONS
|
|
3649
|
-
--scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
|
|
3650
|
-
(required for standalone relay attach / env3)
|
|
3651
|
-
--timeout <ms> Per-file evaluate timeout in ms (default: 30000).
|
|
3652
|
-
Controls how long a single test file is allowed to run
|
|
3653
|
-
before it is considered hung. Does NOT affect how long
|
|
3654
|
-
the CLI waits for a human to scan the QR code — use
|
|
3655
|
-
--attach-timeout for that.
|
|
3656
|
-
--attach-timeout <ms> How long to wait for a human to scan the QR code with
|
|
3657
|
-
their phone (default: 600000 — 10 minutes). Omit to
|
|
3658
|
-
use the relay factory's generous default. Decrease for
|
|
3659
|
-
CI environments where a scan should arrive quickly.
|
|
3660
|
-
--cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
|
|
3661
|
-
--cell-platform <plat> Platform to inject as __AIT_CELL__.platform
|
|
3662
|
-
(mock|ios|android, default: AIT_CELL_PLATFORM env)
|
|
3663
|
-
--report-dir <dir> Persist a runner-agnostic report + captures to <dir>
|
|
3664
|
-
(report: <sdkLine>.<platform>.json; captures:
|
|
3665
|
-
<dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
|
|
3666
|
-
Omitted = nothing saved. Enables console capture.
|
|
3667
|
-
--no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
|
|
3668
|
-
non-interactive stdout / CI / AIT_NO_QR_STDOUT)
|
|
3669
|
-
--headless Disable browser auto-open (text QR only)
|
|
3670
|
-
--project-root <dir> Project root for .ait_relay secret lookup
|
|
3671
|
-
(default: current working directory)
|
|
3672
|
-
--help, -h Show this help message
|
|
3673
|
-
|
|
3674
|
-
DESCRIPTION
|
|
3675
|
-
Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
|
|
3676
|
-
device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
|
|
3677
|
-
each matched test file with esbuild (SDK imports redirected to window.__sdk),
|
|
3678
|
-
injects the bundle into the attached WebView via Runtime.evaluate, and prints
|
|
3679
|
-
a summary.
|
|
3680
|
-
|
|
3681
|
-
With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
|
|
3682
|
-
runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
|
|
3683
|
-
compared offline.
|
|
3684
|
-
|
|
3685
|
-
The test files run against the live relay connection started by this process;
|
|
3686
|
-
no separate MCP daemon is required.
|
|
3687
|
-
|
|
3688
|
-
EXAMPLE
|
|
3689
|
-
devtools-test 'src/**/*.ait.test.ts' \\
|
|
3690
|
-
--scheme-url "intoss-private://..." \\
|
|
3691
|
-
--cell-sdk-line 3.x \\
|
|
3692
|
-
--cell-platform ios \\
|
|
3693
|
-
--report-dir .ait-report \\
|
|
3694
|
-
--timeout 60000
|
|
3695
|
-
|
|
3696
|
-
`.trimStart();
|
|
3697
|
-
/**
|
|
3698
|
-
* Renders per-file result lines and the aggregate totals line to a string.
|
|
3699
|
-
*
|
|
3700
|
-
* Each file gets one line:
|
|
3701
|
-
* - Error/timeout: `FAIL <basename>: <error-class>`
|
|
3702
|
-
* - Pass (0 tests): `OK <basename>: 0 passed (empty file)`
|
|
3703
|
-
* - Pass: `OK <basename>: N passed[, M failed][, K skipped]`
|
|
3704
|
-
*
|
|
3705
|
-
* The aggregate totals line always follows.
|
|
3706
|
-
*
|
|
3707
|
-
* SECRET-HANDLING: only `basename(file)` is used — no absolute paths, relay
|
|
3708
|
-
* URLs, wss URLs, scheme URLs, or TOTP codes appear in the output. The error
|
|
3709
|
-
* string comes from `result.error` which is already secret-free (relay-worker
|
|
3710
|
-
* produces only error-class messages like "rpc: evaluate timed out after
|
|
3711
|
-
* 30000ms").
|
|
3712
|
-
*
|
|
3713
|
-
* Exported so unit tests can assert the per-file lines without spawning a
|
|
3714
|
-
* subprocess or going through the full relay attach flow.
|
|
3715
|
-
*/
|
|
3716
|
-
function renderSummary(report) {
|
|
3717
|
-
const lines = [];
|
|
3718
|
-
for (const { file, result } of report.files) {
|
|
3719
|
-
const name = basename(file);
|
|
3720
|
-
if ("error" in result) lines.push(`FAIL ${name}: ${result.error}`);
|
|
3721
|
-
else {
|
|
3722
|
-
const parts = [`${result.passed} passed`];
|
|
3723
|
-
if (result.failed > 0) parts.push(`${result.failed} failed`);
|
|
3724
|
-
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
|
|
3725
|
-
const suffix = result.passed + result.failed + result.skipped === 0 ? " (empty file)" : "";
|
|
3726
|
-
lines.push(`OK ${name}: ${parts.join(", ")}${suffix}`);
|
|
3727
|
-
}
|
|
3728
|
-
}
|
|
3729
|
-
const { totals, duration } = report;
|
|
3730
|
-
lines.push(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${duration}ms)`);
|
|
3731
|
-
return lines.join("\n");
|
|
3732
|
-
}
|
|
3733
|
-
/**
|
|
3734
|
-
* Runs `files` over `connection` and returns the aggregate report.
|
|
3735
|
-
* This pure function is the testable core of the CLI (and is what the
|
|
3736
|
-
* `run_tests` MCP tool calls against the daemon's attached connection); it is
|
|
3737
|
-
* separate from `main()` so tests can call it without spawning a subprocess.
|
|
3738
|
-
*/
|
|
3739
|
-
async function runWithConnection(connection, files, opts) {
|
|
3740
|
-
const report = await runTestFilesOverRelay(connection, files, opts);
|
|
3741
|
-
if (opts?.printSummary) process.stdout.write(`\n${renderSummary(report)}\n`);
|
|
3742
|
-
return report;
|
|
3743
|
-
}
|
|
3744
|
-
//#endregion
|
|
3745
|
-
//#region src/mcp/ait-chii-source.ts
|
|
3746
|
-
function isObject$3(value) {
|
|
3747
|
-
return typeof value === "object" && value !== null;
|
|
3748
|
-
}
|
|
3749
|
-
/** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
|
|
3750
|
-
function asSdkCallHistory(raw) {
|
|
3751
|
-
if (isObject$3(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
|
|
3752
|
-
return { calls: [] };
|
|
3753
|
-
}
|
|
3754
|
-
/** Narrows an `AIT.getMockState` response to an opaque record. */
|
|
3755
|
-
function asMockState(raw) {
|
|
3756
|
-
return isObject$3(raw) ? raw : {};
|
|
3757
|
-
}
|
|
3758
|
-
/** Narrows an `AIT.getOperationalEnvironment` response. */
|
|
3759
|
-
function asOperationalEnvironment(raw) {
|
|
3760
|
-
return {
|
|
3761
|
-
environment: isObject$3(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
|
|
3762
|
-
sdkVersion: isObject$3(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
|
|
3763
|
-
};
|
|
3764
|
-
}
|
|
3765
|
-
var ChiiAitSource = class {
|
|
3766
|
-
constructor(sender) {
|
|
3767
|
-
this.sender = sender;
|
|
3768
|
-
}
|
|
3769
|
-
async get(method) {
|
|
3770
|
-
const raw = await this.sender.sendCommand(method);
|
|
3771
|
-
switch (method) {
|
|
3772
|
-
case "AIT.getSdkCallHistory": return asSdkCallHistory(raw);
|
|
3773
|
-
case "AIT.getMockState": return asMockState(raw);
|
|
3774
|
-
case "AIT.getOperationalEnvironment": return asOperationalEnvironment(raw);
|
|
3775
|
-
default: throw new Error(`Unknown AIT method: ${String(method)}`);
|
|
3776
|
-
}
|
|
3777
|
-
}
|
|
3778
|
-
};
|
|
3779
|
-
//#endregion
|
|
3780
|
-
//#region src/shared/relay-auth-close.ts
|
|
3781
|
-
/**
|
|
3782
|
-
* Shared constants for the relay's named TOTP-auth rejection (issue #478).
|
|
3783
|
-
*
|
|
3784
|
-
* Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
|
|
3785
|
-
* raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
|
|
3786
|
-
* indistinguishable from a network failure on the browser side — the
|
|
3787
|
-
* WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
|
|
3788
|
-
* could not tell "stale TOTP code" apart from "tunnel down" and stayed
|
|
3789
|
-
* silent. The fix is accept-then-close: complete the handshake, then close
|
|
3790
|
-
* with an application close code that NAMES the rejection.
|
|
3791
|
-
*
|
|
3792
|
-
* Three parties share this contract:
|
|
3793
|
-
* - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
|
|
3794
|
-
* - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
|
|
3795
|
-
* surfaces the code to the launcher shell;
|
|
3796
|
-
* - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
|
|
3797
|
-
* as an auth failure on its own `/client` dial (defensive — #439's fresh
|
|
3798
|
-
* code mint means it should not normally hit this).
|
|
3799
|
-
*
|
|
3800
|
-
* This module is intentionally dependency-free (no Node, no DOM) so it is
|
|
3801
|
-
* safe to import from both the browser in-app bundle and the MCP daemon
|
|
3802
|
-
* bundle.
|
|
3803
|
-
*
|
|
3804
|
-
* SECRET-HANDLING: these are fixed enum values. The close reason / error body
|
|
3805
|
-
* must never grow to carry a secret, a TOTP code, or a host.
|
|
3806
|
-
*/
|
|
3807
|
-
/**
|
|
3808
|
-
* WebSocket close code sent by the relay when TOTP auth is rejected.
|
|
3809
|
-
*
|
|
3810
|
-
* 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
|
|
3811
|
-
* HTTP 401 so it reads as "unauthorized" at a glance.
|
|
3812
|
-
*/
|
|
3813
|
-
const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
|
|
3814
|
-
/**
|
|
3815
|
-
* Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
|
|
3816
|
-
* the `error` value of the relay's HTTP 401 JSON body. Enum string only —
|
|
3817
|
-
* never interpolated with request data.
|
|
3818
|
-
*/
|
|
3819
|
-
const RELAY_AUTH_REJECT_REASON = "totp-rejected";
|
|
3820
|
-
//#endregion
|
|
3821
|
-
//#region src/mcp/chii-connection.ts
|
|
3822
|
-
/**
|
|
3823
|
-
* Production `CdpConnection` backed by the local Chii relay.
|
|
3824
|
-
*
|
|
3825
|
-
* Topology (debug mode):
|
|
3826
|
-
* phone target.js --WS--> Chii relay :9100 <--WS-- this connection
|
|
3827
|
-
*
|
|
3828
|
-
* The phone connects to the relay as a `target`; this module connects as a
|
|
3829
|
-
* `client` (the role a CDP frontend would take) so CDP events the page emits
|
|
3830
|
-
* (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
|
|
3831
|
-
* events in ring buffers the tool layer reads via `getBufferedEvents`.
|
|
3832
|
-
*
|
|
3833
|
-
* Node-only: imports `ws`. Never bundled into the browser/in-app entries.
|
|
3834
|
-
*
|
|
3835
|
-
* Attach reliability (#281):
|
|
3836
|
-
* `refreshTargets()` emits an internal 'target:attached' event whenever a
|
|
3837
|
-
* new target is added to the relay. `waitForFirstTarget()` awaits that event
|
|
3838
|
-
* (with a polling-interval fallback) so `start_attach`'s attach wait
|
|
3839
|
-
* resolves deterministically rather than racing between polling rounds.
|
|
3840
|
-
*/
|
|
3841
|
-
/** Max events retained per domain ring buffer. */
|
|
3842
|
-
const DEFAULT_BUFFER_SIZE$1 = 500;
|
|
3843
|
-
function isObject$2(value) {
|
|
3844
|
-
return typeof value === "object" && value !== null;
|
|
3845
|
-
}
|
|
3846
|
-
function parseInbound$1(raw) {
|
|
3847
|
-
let parsed;
|
|
3848
|
-
try {
|
|
3849
|
-
parsed = JSON.parse(raw);
|
|
3850
|
-
} catch {
|
|
3851
|
-
return null;
|
|
3852
|
-
}
|
|
3853
|
-
if (!isObject$2(parsed)) return null;
|
|
3854
|
-
const message = {};
|
|
3855
|
-
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
3856
|
-
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
3857
|
-
if ("params" in parsed) message.params = parsed.params;
|
|
3858
|
-
if ("result" in parsed) message.result = parsed.result;
|
|
3859
|
-
if (isObject$2(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
3860
|
-
return message;
|
|
3861
|
-
}
|
|
3862
|
-
const PHASE_1_EVENTS$1 = [
|
|
3863
|
-
"Runtime.consoleAPICalled",
|
|
3864
|
-
"Network.requestWillBeSent",
|
|
3865
|
-
"Network.responseReceived"
|
|
3866
|
-
];
|
|
3867
|
-
/**
|
|
3868
|
-
* Ring buffer size for `Runtime.exceptionThrown`.
|
|
3869
|
-
*
|
|
3870
|
-
* Exceptions are rarer than console messages but each is heavier (stack
|
|
3871
|
-
* trace). 50 is generous enough to cover a crash scenario while keeping
|
|
3872
|
-
* memory bounded.
|
|
3873
|
-
*
|
|
3874
|
-
* **Lifecycle note**: the exception buffer intentionally survives `replaced` /
|
|
3875
|
-
* `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
|
|
3876
|
-
* transitions. Rationale: an exception fired just before a crash is exactly
|
|
3877
|
-
* the signal we want to preserve for root-cause analysis. The buffer
|
|
3878
|
-
* represents "exceptions seen in this MCP session", not "exceptions in the
|
|
3879
|
-
* current page".
|
|
3141
|
+
* **Lifecycle note**: the exception buffer intentionally survives `replaced` /
|
|
3142
|
+
* `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
|
|
3143
|
+
* transitions. Rationale: an exception fired just before a crash is exactly
|
|
3144
|
+
* the signal we want to preserve for root-cause analysis. The buffer
|
|
3145
|
+
* represents "exceptions seen in this MCP session", not "exceptions in the
|
|
3146
|
+
* current page".
|
|
3880
3147
|
*/
|
|
3881
3148
|
const EXCEPTION_BUFFER_SIZE = 50;
|
|
3882
3149
|
/** Default per-command timeout if neither option nor env var is set. */
|
|
@@ -3942,10 +3209,10 @@ var ChiiCdpConnection = class {
|
|
|
3942
3209
|
const res = await fetch(targetsUrl);
|
|
3943
3210
|
if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
|
|
3944
3211
|
const body = await res.json();
|
|
3945
|
-
const list = isObject$
|
|
3212
|
+
const list = isObject$3(body) && Array.isArray(body.targets) ? body.targets : [];
|
|
3946
3213
|
let newestTargetId = null;
|
|
3947
3214
|
for (const item of list) {
|
|
3948
|
-
if (!isObject$
|
|
3215
|
+
if (!isObject$3(item) || typeof item.id !== "string") continue;
|
|
3949
3216
|
newestTargetId = item.id;
|
|
3950
3217
|
}
|
|
3951
3218
|
if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
|
|
@@ -3955,7 +3222,7 @@ var ChiiCdpConnection = class {
|
|
|
3955
3222
|
}
|
|
3956
3223
|
this.targets.clear();
|
|
3957
3224
|
for (const item of list) {
|
|
3958
|
-
if (!isObject$
|
|
3225
|
+
if (!isObject$3(item) || typeof item.id !== "string") continue;
|
|
3959
3226
|
if (item.id !== newestTargetId) continue;
|
|
3960
3227
|
this.targets.set(item.id, {
|
|
3961
3228
|
id: item.id,
|
|
@@ -3990,7 +3257,11 @@ var ChiiCdpConnection = class {
|
|
|
3990
3257
|
* EventEmitter is missed (defensive belt-and-suspenders).
|
|
3991
3258
|
*
|
|
3992
3259
|
* @param filterFn - Predicate that the returned targets must satisfy.
|
|
3993
|
-
* @param timeoutMs - Reject after this many ms (default 90 000).
|
|
3260
|
+
* @param timeoutMs - Reject after this many ms (default 90 000). Pass a
|
|
3261
|
+
* non-finite value (`Infinity`) to disable the rejection timer entirely —
|
|
3262
|
+
* used by the test-runner's unbounded QR-attach wait (devtools#735). Node
|
|
3263
|
+
* clamps `setTimeout(fn, Infinity)` to ~1ms, so a non-finite value MUST
|
|
3264
|
+
* skip arming the timer rather than pass it through.
|
|
3994
3265
|
* @param pollIntervalMs - Fallback poll interval (default 500ms).
|
|
3995
3266
|
*/
|
|
3996
3267
|
waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
|
|
@@ -4002,7 +3273,7 @@ var ChiiCdpConnection = class {
|
|
|
4002
3273
|
const settle = (targets) => {
|
|
4003
3274
|
if (settled) return;
|
|
4004
3275
|
settled = true;
|
|
4005
|
-
clearTimeout(timeoutHandle);
|
|
3276
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
4006
3277
|
if (pollHandle !== null) {
|
|
4007
3278
|
clearInterval(pollHandle);
|
|
4008
3279
|
pollHandle = null;
|
|
@@ -4013,7 +3284,7 @@ var ChiiCdpConnection = class {
|
|
|
4013
3284
|
const onAttach = (targets) => {
|
|
4014
3285
|
if (filterFn(targets)) settle(targets);
|
|
4015
3286
|
};
|
|
4016
|
-
const timeoutHandle = setTimeout(() => {
|
|
3287
|
+
const timeoutHandle = Number.isFinite(timeoutMs) ? setTimeout(() => {
|
|
4017
3288
|
if (settled) return;
|
|
4018
3289
|
settled = true;
|
|
4019
3290
|
if (pollHandle !== null) {
|
|
@@ -4022,7 +3293,7 @@ var ChiiCdpConnection = class {
|
|
|
4022
3293
|
}
|
|
4023
3294
|
this.emitter.off("target:attached", onAttach);
|
|
4024
3295
|
reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
|
|
4025
|
-
}, timeoutMs);
|
|
3296
|
+
}, timeoutMs) : null;
|
|
4026
3297
|
this.emitter.on("target:attached", onAttach);
|
|
4027
3298
|
pollHandle = setInterval(() => {
|
|
4028
3299
|
this.refreshTargets().then((targets) => {
|
|
@@ -4073,252 +3344,1060 @@ var ChiiCdpConnection = class {
|
|
|
4073
3344
|
const code = generateTotp(this.totpSecret);
|
|
4074
3345
|
clientUrl += `&at=${encodeURIComponent(code)}`;
|
|
4075
3346
|
}
|
|
4076
|
-
const ws = new WebSocket(clientUrl);
|
|
4077
|
-
this.ws = ws;
|
|
4078
|
-
await new Promise((resolve, reject) => {
|
|
4079
|
-
ws.once("open", () => resolve());
|
|
4080
|
-
ws.once("error", (err) => reject(err));
|
|
4081
|
-
ws.once("close", (code) => {
|
|
4082
|
-
if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
|
|
4083
|
-
});
|
|
4084
|
-
});
|
|
4085
|
-
this.lastCrashDetectedAt = null;
|
|
4086
|
-
this.targetLastSeenAt.clear();
|
|
4087
|
-
this.connectionState = "connected";
|
|
4088
|
-
ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
4089
|
-
ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
|
|
4090
|
-
ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
|
|
4091
|
-
this.sendFireAndForget("Runtime.enable");
|
|
4092
|
-
this.sendFireAndForget("Network.enable");
|
|
4093
|
-
this.sendFireAndForget("DOM.enable");
|
|
4094
|
-
this.sendFireAndForget("Page.enable");
|
|
4095
|
-
this.sendFireAndForget("Inspector.enable");
|
|
4096
|
-
this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
|
|
4097
|
-
this.startHeartbeat(target.id);
|
|
3347
|
+
const ws = new WebSocket(clientUrl);
|
|
3348
|
+
this.ws = ws;
|
|
3349
|
+
await new Promise((resolve, reject) => {
|
|
3350
|
+
ws.once("open", () => resolve());
|
|
3351
|
+
ws.once("error", (err) => reject(err));
|
|
3352
|
+
ws.once("close", (code) => {
|
|
3353
|
+
if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
|
|
3354
|
+
});
|
|
3355
|
+
});
|
|
3356
|
+
this.lastCrashDetectedAt = null;
|
|
3357
|
+
this.targetLastSeenAt.clear();
|
|
3358
|
+
this.connectionState = "connected";
|
|
3359
|
+
ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
3360
|
+
ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
|
|
3361
|
+
ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
|
|
3362
|
+
this.sendFireAndForget("Runtime.enable");
|
|
3363
|
+
this.sendFireAndForget("Network.enable");
|
|
3364
|
+
this.sendFireAndForget("DOM.enable");
|
|
3365
|
+
this.sendFireAndForget("Page.enable");
|
|
3366
|
+
this.sendFireAndForget("Inspector.enable");
|
|
3367
|
+
this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
|
|
3368
|
+
this.startHeartbeat(target.id);
|
|
3369
|
+
}
|
|
3370
|
+
/** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
|
|
3371
|
+
sendFireAndForget(method, params = {}) {
|
|
3372
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
3373
|
+
const id = this.nextCommandId++;
|
|
3374
|
+
this.ws.send(JSON.stringify({
|
|
3375
|
+
id,
|
|
3376
|
+
method,
|
|
3377
|
+
params
|
|
3378
|
+
}));
|
|
3379
|
+
}
|
|
3380
|
+
/**
|
|
3381
|
+
* Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
|
|
3382
|
+
* error frame or when no websocket is open (no page attached yet).
|
|
3383
|
+
*/
|
|
3384
|
+
send(method, params) {
|
|
3385
|
+
return this.sendCommand(method, params ?? {});
|
|
3386
|
+
}
|
|
3387
|
+
/**
|
|
3388
|
+
* Issue an arbitrary request→response command over the relay and resolve with
|
|
3389
|
+
* its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
|
|
3390
|
+
* `AIT.*` methods, forwarded over the same Chii channel) build on this.
|
|
3391
|
+
*
|
|
3392
|
+
* Rejects immediately if the connection is disconnected (fail-fast — no
|
|
3393
|
+
* auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
|
|
3394
|
+
* reattach.
|
|
3395
|
+
*
|
|
3396
|
+
* Times out after `commandTimeoutMs` (default 30s, env
|
|
3397
|
+
* `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
|
|
3398
|
+
* and the promise rejects with a descriptive Korean error.
|
|
3399
|
+
*/
|
|
3400
|
+
sendCommand(method, params = {}) {
|
|
3401
|
+
if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
|
|
3402
|
+
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."));
|
|
3403
|
+
const id = this.nextCommandId++;
|
|
3404
|
+
const ws = this.ws;
|
|
3405
|
+
const timeoutMs = this.commandTimeoutMs;
|
|
3406
|
+
return new Promise((resolve, reject) => {
|
|
3407
|
+
const handle = setTimeout(() => {
|
|
3408
|
+
this.pending.delete(id);
|
|
3409
|
+
reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
|
|
3410
|
+
}, timeoutMs);
|
|
3411
|
+
this.pending.set(id, {
|
|
3412
|
+
resolve: (v) => {
|
|
3413
|
+
clearTimeout(handle);
|
|
3414
|
+
resolve(v);
|
|
3415
|
+
},
|
|
3416
|
+
reject: (e) => {
|
|
3417
|
+
clearTimeout(handle);
|
|
3418
|
+
reject(e);
|
|
3419
|
+
}
|
|
3420
|
+
});
|
|
3421
|
+
ws.send(JSON.stringify({
|
|
3422
|
+
id,
|
|
3423
|
+
method,
|
|
3424
|
+
params
|
|
3425
|
+
}));
|
|
3426
|
+
});
|
|
3427
|
+
}
|
|
3428
|
+
/**
|
|
3429
|
+
* Called on WebSocket `close` or `error` after a successful connection.
|
|
3430
|
+
* Rejects all pending commands and marks the connection as disconnected so
|
|
3431
|
+
* subsequent `sendCommand` calls fail fast (no auto-reconnect).
|
|
3432
|
+
*/
|
|
3433
|
+
handleDisconnect(reason) {
|
|
3434
|
+
if (this.connectionState === "disconnected") return;
|
|
3435
|
+
this.connectionState = "disconnected";
|
|
3436
|
+
this.ws = null;
|
|
3437
|
+
this.stopHeartbeat();
|
|
3438
|
+
const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
|
|
3439
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
3440
|
+
this.pending.clear();
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* Evict a previously active target under the single-attach model.
|
|
3444
|
+
* Rejects pending commands with a 'replaced-by-new-attach' reason and emits
|
|
3445
|
+
* a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
|
|
3446
|
+
* targetId. The caller is responsible for rebuilding the targets map afterwards.
|
|
3447
|
+
*
|
|
3448
|
+
* The error message uses 'replaced-by-new-attach' so test assertions can match it.
|
|
3449
|
+
*/
|
|
3450
|
+
evictTarget(targetId) {
|
|
3451
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3452
|
+
this.targets.delete(targetId);
|
|
3453
|
+
this.targetLastSeenAt.delete(targetId);
|
|
3454
|
+
const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
|
|
3455
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
3456
|
+
this.pending.clear();
|
|
3457
|
+
const event = {
|
|
3458
|
+
kind: "replaced",
|
|
3459
|
+
targetId,
|
|
3460
|
+
detectedAt
|
|
3461
|
+
};
|
|
3462
|
+
for (const listener of this.lifecycleListeners) try {
|
|
3463
|
+
listener(event);
|
|
3464
|
+
} catch {}
|
|
3465
|
+
}
|
|
3466
|
+
/**
|
|
3467
|
+
* Handle a page-level crash or target destruction event.
|
|
3468
|
+
* Removes the target from the in-memory map, rejects all pending commands,
|
|
3469
|
+
* and emits a lifecycle event.
|
|
3470
|
+
*
|
|
3471
|
+
* @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
|
|
3472
|
+
* @param targetId - The target ID from the event params (may be null for
|
|
3473
|
+
* Inspector.targetCrashed which has no targetId in the params).
|
|
3474
|
+
*/
|
|
3475
|
+
handleTargetGone(kind, targetId) {
|
|
3476
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3477
|
+
this.lastCrashDetectedAt = Date.now();
|
|
3478
|
+
if (targetId !== null) {
|
|
3479
|
+
this.targets.delete(targetId);
|
|
3480
|
+
this.targetLastSeenAt.delete(targetId);
|
|
3481
|
+
if (this.activeTargetId === targetId) this.activeTargetId = null;
|
|
3482
|
+
} else {
|
|
3483
|
+
this.targets.clear();
|
|
3484
|
+
this.targetLastSeenAt.clear();
|
|
3485
|
+
this.activeTargetId = null;
|
|
3486
|
+
}
|
|
3487
|
+
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()로 재연결).`);
|
|
3488
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
3489
|
+
this.pending.clear();
|
|
3490
|
+
const event = {
|
|
3491
|
+
kind,
|
|
3492
|
+
targetId,
|
|
3493
|
+
detectedAt
|
|
3494
|
+
};
|
|
3495
|
+
for (const listener of this.lifecycleListeners) try {
|
|
3496
|
+
listener(event);
|
|
3497
|
+
} catch {}
|
|
3498
|
+
}
|
|
3499
|
+
/**
|
|
3500
|
+
* Start the optional CDP heartbeat loop.
|
|
3501
|
+
*
|
|
3502
|
+
* When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
|
|
3503
|
+
* we send `Runtime.evaluate({expression: '1'})` to each active target. If
|
|
3504
|
+
* the command times out (2 s hard deadline) or errors, we treat the target
|
|
3505
|
+
* as dead and call `handleTargetGone`.
|
|
3506
|
+
*
|
|
3507
|
+
* This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
|
|
3508
|
+
* even when the phone app has crashed, so the ws-level disconnect (#252) won't
|
|
3509
|
+
* fire. The heartbeat catches this gap.
|
|
3510
|
+
*
|
|
3511
|
+
* Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
|
|
3512
|
+
*/
|
|
3513
|
+
startHeartbeat(initialTargetId) {
|
|
3514
|
+
this.stopHeartbeat();
|
|
3515
|
+
const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
|
|
3516
|
+
if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
|
|
3517
|
+
const PING_TIMEOUT_MS = 2e3;
|
|
3518
|
+
this.heartbeatHandle = setInterval(() => {
|
|
3519
|
+
const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
|
|
3520
|
+
for (const targetId of targetIds) {
|
|
3521
|
+
const pingPromise = this.sendCommand("Runtime.evaluate", {
|
|
3522
|
+
expression: "1",
|
|
3523
|
+
returnByValue: true,
|
|
3524
|
+
timeout: PING_TIMEOUT_MS
|
|
3525
|
+
});
|
|
3526
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
|
|
3527
|
+
Promise.race([pingPromise, timeoutPromise]).catch(() => {
|
|
3528
|
+
if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
|
|
3529
|
+
});
|
|
3530
|
+
}
|
|
3531
|
+
}, envMs);
|
|
3532
|
+
}
|
|
3533
|
+
stopHeartbeat() {
|
|
3534
|
+
if (this.heartbeatHandle !== null) {
|
|
3535
|
+
clearInterval(this.heartbeatHandle);
|
|
3536
|
+
this.heartbeatHandle = null;
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
handleMessage(raw) {
|
|
3540
|
+
const message = parseInbound$1(raw);
|
|
3541
|
+
if (!message) return;
|
|
3542
|
+
if (typeof message.id === "number" && this.pending.has(message.id)) {
|
|
3543
|
+
const waiter = this.pending.get(message.id);
|
|
3544
|
+
this.pending.delete(message.id);
|
|
3545
|
+
if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
|
|
3546
|
+
else waiter.resolve(message.result);
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
3549
|
+
const now = Date.now();
|
|
3550
|
+
let firstMessageSeen = false;
|
|
3551
|
+
for (const targetId of this.targets.keys()) {
|
|
3552
|
+
if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
|
|
3553
|
+
this.targetLastSeenAt.set(targetId, now);
|
|
3554
|
+
}
|
|
3555
|
+
if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
|
|
3556
|
+
if (typeof message.method !== "string") return;
|
|
3557
|
+
if (message.method === "Inspector.targetCrashed") {
|
|
3558
|
+
this.handleTargetGone("crashed", null);
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
if (message.method === "Target.targetDestroyed") {
|
|
3562
|
+
const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
3563
|
+
this.handleTargetGone("destroyed", targetId);
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
if (message.method === "Target.detachedFromTarget") {
|
|
3567
|
+
const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
3568
|
+
this.handleTargetGone("detached", targetId);
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
if (!this.buffers.has(message.method)) return;
|
|
3572
|
+
const event = message.method;
|
|
3573
|
+
const buffer = this.buffers.get(event);
|
|
3574
|
+
if (!buffer) return;
|
|
3575
|
+
buffer.push(message.params);
|
|
3576
|
+
const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
|
|
3577
|
+
if (buffer.length > cap) buffer.shift();
|
|
3578
|
+
this.emitter.emit(event, message.params);
|
|
4098
3579
|
}
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
4102
|
-
const id = this.nextCommandId++;
|
|
4103
|
-
this.ws.send(JSON.stringify({
|
|
4104
|
-
id,
|
|
4105
|
-
method,
|
|
4106
|
-
params
|
|
4107
|
-
}));
|
|
3580
|
+
getBufferedEvents(event) {
|
|
3581
|
+
return this.buffers.get(event) ?? [];
|
|
4108
3582
|
}
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
*/
|
|
4113
|
-
send(method, params) {
|
|
4114
|
-
return this.sendCommand(method, params ?? {});
|
|
3583
|
+
on(event, listener) {
|
|
3584
|
+
this.emitter.on(event, listener);
|
|
3585
|
+
return () => this.emitter.off(event, listener);
|
|
4115
3586
|
}
|
|
4116
|
-
/**
|
|
4117
|
-
|
|
4118
|
-
* its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
|
|
4119
|
-
* `AIT.*` methods, forwarded over the same Chii channel) build on this.
|
|
4120
|
-
*
|
|
4121
|
-
* Rejects immediately if the connection is disconnected (fail-fast — no
|
|
4122
|
-
* auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
|
|
4123
|
-
* reattach.
|
|
4124
|
-
*
|
|
4125
|
-
* Times out after `commandTimeoutMs` (default 30s, env
|
|
4126
|
-
* `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
|
|
4127
|
-
* and the promise rejects with a descriptive Korean error.
|
|
4128
|
-
*/
|
|
4129
|
-
sendCommand(method, params = {}) {
|
|
4130
|
-
if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
|
|
4131
|
-
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."));
|
|
4132
|
-
const id = this.nextCommandId++;
|
|
3587
|
+
/** Close the relay client websocket and reject any in-flight commands. */
|
|
3588
|
+
close() {
|
|
4133
3589
|
const ws = this.ws;
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
3590
|
+
this.stopHeartbeat();
|
|
3591
|
+
this.handleDisconnect("Chii relay connection closed");
|
|
3592
|
+
ws?.close();
|
|
3593
|
+
}
|
|
3594
|
+
};
|
|
3595
|
+
//#endregion
|
|
3596
|
+
//#region src/test-runner/bundle.ts
|
|
3597
|
+
/**
|
|
3598
|
+
* esbuild-based bundler for user test files.
|
|
3599
|
+
*
|
|
3600
|
+
* Bundles a single test file into a self-contained IIFE string that can be
|
|
3601
|
+
* injected into a WebView via `Runtime.evaluate`. The bundle includes the
|
|
3602
|
+
* test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
|
|
3603
|
+
* the `runTestModule(factory)` entry point.
|
|
3604
|
+
*
|
|
3605
|
+
* ## How the wiring works
|
|
3606
|
+
*
|
|
3607
|
+
* The bundle exposes two exports on `globalThis.__testBundle`:
|
|
3608
|
+
* - `runTestModule` — the runtime's entry function.
|
|
3609
|
+
* - `__userFactory` — an async function whose body is the user's top-level
|
|
3610
|
+
* test registration code (describe/it/test calls).
|
|
3611
|
+
*
|
|
3612
|
+
* The Node-side RPC (`rpc.ts`) calls:
|
|
3613
|
+
* `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
|
|
3614
|
+
*
|
|
3615
|
+
* `runTestModule` then installs `describe/it/test/expect` as globals, invokes
|
|
3616
|
+
* the factory (which registers all tests), runs them, and returns a `RunReport`.
|
|
3617
|
+
*
|
|
3618
|
+
* ## Why a factory wrapper is needed
|
|
3619
|
+
*
|
|
3620
|
+
* Naively adding the runtime to `entryPoints` and bundling the user file would
|
|
3621
|
+
* fail for two reasons:
|
|
3622
|
+
* 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
|
|
3623
|
+
* scope. The user's top-level `describe(...)` calls expect them as globals —
|
|
3624
|
+
* they are not globals until `runTestModule` installs them.
|
|
3625
|
+
* 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
|
|
3626
|
+
* time, before the RPC layer calls `runTestModule` to reset state and start
|
|
3627
|
+
* the test clock.
|
|
3628
|
+
*
|
|
3629
|
+
* The factory approach solves both: the user's registration code is deferred
|
|
3630
|
+
* into a function that `runTestModule` calls AFTER installing the globals.
|
|
3631
|
+
*
|
|
3632
|
+
* ## Factory extraction algorithm
|
|
3633
|
+
*
|
|
3634
|
+
* The `userFactoryPlugin` reads the user file and splits lines into:
|
|
3635
|
+
* - **top-level**: `import …` and re-export lines — kept at module scope
|
|
3636
|
+
* (the only valid position for static `import` in ESM).
|
|
3637
|
+
* - **body**: all other statements — moved into the body of the exported
|
|
3638
|
+
* `__userFactory` async function.
|
|
3639
|
+
*
|
|
3640
|
+
* esbuild processes the re-generated module, following each static import
|
|
3641
|
+
* through the normal dependency graph (including the SDK-redirect plugin).
|
|
3642
|
+
*
|
|
3643
|
+
* ## SDK redirect
|
|
3644
|
+
*
|
|
3645
|
+
* Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
|
|
3646
|
+
* the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
|
|
3647
|
+
* `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
|
|
3648
|
+
*
|
|
3649
|
+
* SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
|
|
3650
|
+
*/
|
|
3651
|
+
/** The SDK package name that mini-app test code imports from. */
|
|
3652
|
+
const SDK_PACKAGE = "@apps-in-toss/web-framework";
|
|
3653
|
+
/**
|
|
3654
|
+
* Names the runtime installs as globals before invoking the user factory.
|
|
3655
|
+
* The `vitest` virtual module re-exports each as a lazy getter that reads from
|
|
3656
|
+
* `globalThis` at access time. Keep in sync with the globals installed in
|
|
3657
|
+
* `runtime.ts#runTestModule`.
|
|
3658
|
+
*/
|
|
3659
|
+
const VITEST_GLOBAL_NAMES = [
|
|
3660
|
+
"describe",
|
|
3661
|
+
"it",
|
|
3662
|
+
"test",
|
|
3663
|
+
"expect",
|
|
3664
|
+
"beforeAll",
|
|
3665
|
+
"afterAll",
|
|
3666
|
+
"beforeEach",
|
|
3667
|
+
"afterEach",
|
|
3668
|
+
"vi"
|
|
3669
|
+
];
|
|
3670
|
+
/**
|
|
3671
|
+
* Matches the bare SDK package and any sub-path import
|
|
3672
|
+
* (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
|
|
3673
|
+
* Built from {@link SDK_PACKAGE} so the package name has a single source.
|
|
3674
|
+
*/
|
|
3675
|
+
const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
|
3676
|
+
/**
|
|
3677
|
+
* esbuild plugin that intercepts SDK imports and redirects them to the
|
|
3678
|
+
* `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
|
|
3679
|
+
*
|
|
3680
|
+
* Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
|
|
3681
|
+
* esbuild resolves it to a virtual module that re-exports all named exports
|
|
3682
|
+
* via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
|
|
3683
|
+
* be available in the test environment) while still making named imports work.
|
|
3684
|
+
*
|
|
3685
|
+
* If `window.__sdk` is absent (non-dog-food build), every access throws a
|
|
3686
|
+
* descriptive error rather than returning `undefined` silently.
|
|
3687
|
+
*/
|
|
3688
|
+
function sdkRedirectPlugin() {
|
|
3689
|
+
return {
|
|
3690
|
+
name: "sdk-redirect",
|
|
3691
|
+
setup(build) {
|
|
3692
|
+
build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
|
|
3693
|
+
path: args.path,
|
|
3694
|
+
namespace: "sdk-redirect"
|
|
3695
|
+
}));
|
|
3696
|
+
build.onLoad({
|
|
3697
|
+
filter: /.*/,
|
|
3698
|
+
namespace: "sdk-redirect"
|
|
3699
|
+
}, () => ({
|
|
3700
|
+
contents: `
|
|
3701
|
+
var __proxy = (typeof window !== 'undefined' && window.__sdk)
|
|
3702
|
+
? window.__sdk
|
|
3703
|
+
: new Proxy({}, {
|
|
3704
|
+
get: function(_t, p) {
|
|
3705
|
+
throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
|
|
3706
|
+
}
|
|
3707
|
+
});
|
|
3708
|
+
module.exports = __proxy;
|
|
3709
|
+
`,
|
|
3710
|
+
loader: "js"
|
|
3711
|
+
}));
|
|
3712
|
+
}
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* esbuild plugin that intercepts `import … from 'vitest'` and replaces it with
|
|
3717
|
+
* a virtual module that delegates every named import to `globalThis` at ACCESS
|
|
3718
|
+
* time (not at bundle-evaluation time).
|
|
3719
|
+
*
|
|
3720
|
+
* The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/
|
|
3721
|
+
* afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle
|
|
3722
|
+
* IIFE is evaluated. A value-copy redirect (`export var describe =
|
|
3723
|
+
* globalThis.describe`) would therefore capture `undefined` at evaluation time
|
|
3724
|
+
* and the user's `describe(...)` calls would be no-ops — registering zero tests.
|
|
3725
|
+
*
|
|
3726
|
+
* The fix defers the lookup to call time using per-name **getter** exports.
|
|
3727
|
+
* We emit a CommonJS module that:
|
|
3728
|
+
* 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named
|
|
3729
|
+
* import directly to a property access on the module (NOT wrapped under a
|
|
3730
|
+
* `default` shim — which is what happens for a bare Proxy whose own-keys
|
|
3731
|
+
* are empty, leaving every named import `undefined`);
|
|
3732
|
+
* 2. defines each global name as a getter that reads `globalThis[name]` on
|
|
3733
|
+
* every access. So `import { describe } from 'vitest'` compiles to
|
|
3734
|
+
* `import_vitest.describe`, whose getter returns the real `describe` only
|
|
3735
|
+
* when the factory calls it — after `runTestModule` installs the globals.
|
|
3736
|
+
*
|
|
3737
|
+
* A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes
|
|
3738
|
+
* the virtual module through `__toESM`, which enumerates own-keys (none on an
|
|
3739
|
+
* empty Proxy target) and therefore exposes zero named exports. Explicit getter
|
|
3740
|
+
* properties give `__toESM` real keys to map while keeping access lazy.
|
|
3741
|
+
*/
|
|
3742
|
+
function vitestRedirectPlugin() {
|
|
3743
|
+
return {
|
|
3744
|
+
name: "vitest-redirect",
|
|
3745
|
+
setup(build) {
|
|
3746
|
+
build.onResolve({ filter: /^vitest$/ }, () => ({
|
|
3747
|
+
path: "vitest",
|
|
3748
|
+
namespace: "vitest-redirect"
|
|
3749
|
+
}));
|
|
3750
|
+
build.onLoad({
|
|
3751
|
+
filter: /^vitest$/,
|
|
3752
|
+
namespace: "vitest-redirect"
|
|
3753
|
+
}, () => {
|
|
3754
|
+
return {
|
|
3755
|
+
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`,
|
|
3756
|
+
loader: "js"
|
|
3757
|
+
};
|
|
4149
3758
|
});
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
3759
|
+
}
|
|
3760
|
+
};
|
|
3761
|
+
}
|
|
3762
|
+
/**
|
|
3763
|
+
* esbuild plugin that transforms the user test file into a module that exports
|
|
3764
|
+
* an async `__userFactory` function. The factory defers the user's top-level
|
|
3765
|
+
* test registration code (describe/it/test calls) so it only runs when
|
|
3766
|
+
* `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
|
|
3767
|
+
* installed describe/it/test/expect as globals.
|
|
3768
|
+
*
|
|
3769
|
+
* Algorithm:
|
|
3770
|
+
* - Import declarations and re-export statements are kept at module top-level
|
|
3771
|
+
* (the only valid ESM position for static `import`). A statement that spans
|
|
3772
|
+
* multiple lines — e.g. a named import with one member per line:
|
|
3773
|
+
* import {
|
|
3774
|
+
* appLogin,
|
|
3775
|
+
* getAnonymousKey,
|
|
3776
|
+
* } from '@apps-in-toss/web-framework';
|
|
3777
|
+
* is tracked as a single block: every line from the opening `import {` /
|
|
3778
|
+
* `export {` through the closing `from '…'` (or side-effect `'…'`) line is
|
|
3779
|
+
* kept together at top-level. This prevents the member lines and the
|
|
3780
|
+
* closing `} from '…'` line from leaking into the factory body, which would
|
|
3781
|
+
* leave an unterminated `import {` at module scope (the #678 env3 failure:
|
|
3782
|
+
* esbuild threw `Expected "as" but found "{"` on multi-line SDK imports).
|
|
3783
|
+
* - All other lines (describe/it/test calls, local declarations, etc.) are
|
|
3784
|
+
* moved into the body of the exported async factory function.
|
|
3785
|
+
*
|
|
3786
|
+
* This preserves SDK import resolution (the sdk-redirect plugin processes
|
|
3787
|
+
* top-level imports normally) while deferring test registration to the factory.
|
|
3788
|
+
*/
|
|
3789
|
+
function userFactoryPlugin(absPath) {
|
|
3790
|
+
const NAMESPACE = "user-test-factory";
|
|
3791
|
+
return {
|
|
3792
|
+
name: "user-test-factory",
|
|
3793
|
+
setup(build) {
|
|
3794
|
+
build.onResolve({ filter: /^user-test-factory$/ }, () => ({
|
|
3795
|
+
path: absPath,
|
|
3796
|
+
namespace: NAMESPACE
|
|
4154
3797
|
}));
|
|
3798
|
+
build.onLoad({
|
|
3799
|
+
filter: /.*/,
|
|
3800
|
+
namespace: NAMESPACE
|
|
3801
|
+
}, async (args) => {
|
|
3802
|
+
const lines = (await fs.readFile(args.path, "utf8")).split("\n");
|
|
3803
|
+
const topLevelLines = [];
|
|
3804
|
+
const bodyLines = [];
|
|
3805
|
+
const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
|
|
3806
|
+
const isImportStart = (trimmed) => trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"");
|
|
3807
|
+
const endsStatement = (trimmed) => /['"]\s*;?\s*$/.test(trimmed.replace(/\/\/.*$/, "").trimEnd());
|
|
3808
|
+
let inImportBlock = false;
|
|
3809
|
+
for (const line of lines) {
|
|
3810
|
+
const trimmed = line.trimStart();
|
|
3811
|
+
const indent = line.slice(0, line.length - trimmed.length);
|
|
3812
|
+
if (inImportBlock) {
|
|
3813
|
+
topLevelLines.push(line);
|
|
3814
|
+
if (endsStatement(trimmed)) inImportBlock = false;
|
|
3815
|
+
continue;
|
|
3816
|
+
}
|
|
3817
|
+
if (isImportStart(trimmed)) {
|
|
3818
|
+
topLevelLines.push(line);
|
|
3819
|
+
if (!endsStatement(trimmed)) inImportBlock = true;
|
|
3820
|
+
} else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
|
|
3821
|
+
else {
|
|
3822
|
+
topLevelLines.push(line);
|
|
3823
|
+
if (/\bfrom\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith("{")) inImportBlock = true;
|
|
3824
|
+
}
|
|
3825
|
+
else bodyLines.push(line);
|
|
3826
|
+
}
|
|
3827
|
+
return {
|
|
3828
|
+
contents: [
|
|
3829
|
+
...topLevelLines,
|
|
3830
|
+
"",
|
|
3831
|
+
"// biome-ignore lint: generated factory wrapper",
|
|
3832
|
+
"export default async function __userFactory(): Promise<void> {",
|
|
3833
|
+
...bodyLines.map((l) => ` ${l}`),
|
|
3834
|
+
"}"
|
|
3835
|
+
].join("\n"),
|
|
3836
|
+
loader: "ts",
|
|
3837
|
+
resolveDir: path.dirname(absPath)
|
|
3838
|
+
};
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3841
|
+
};
|
|
3842
|
+
}
|
|
3843
|
+
/**
|
|
3844
|
+
* Returns the absolute filesystem path to the test-runner runtime module
|
|
3845
|
+
* (dist/test-runner/runtime.js — a fully self-contained page-side bundle).
|
|
3846
|
+
*
|
|
3847
|
+
* Rolldown code-splitting duplicates this bundling logic into shared chunks
|
|
3848
|
+
* emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
|
|
3849
|
+
* dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`
|
|
3850
|
+
* daemon (dist/mcp/cli.js) pulls it through a ROOT chunk
|
|
3851
|
+
* (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list
|
|
3852
|
+
* is therefore wrong from at least one chunk — the live #697 regression.
|
|
3853
|
+
*
|
|
3854
|
+
* This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
|
|
3855
|
+
* probes the co-located `runtime.js` and the nested `test-runner/runtime.js`,
|
|
3856
|
+
* then ascends one directory at a time (bounded) repeating both probes. The
|
|
3857
|
+
* nested probe catches dist/test-runner/runtime.js from the dist/ root level no
|
|
3858
|
+
* matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
|
|
3859
|
+
* relocation). The build always emits dist/test-runner/runtime.js (tsdown entry
|
|
3860
|
+
* `'test-runner/runtime'`; guarded by scripts/check-test-runner-dist.sh).
|
|
3861
|
+
*
|
|
3862
|
+
* An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
|
|
3863
|
+
* read, bypassing Node module resolution entirely, so this works identically in
|
|
3864
|
+
* the npx-daemon context (its own dist tree) and the consumer-CLI context
|
|
3865
|
+
* (the mini-app's installed @ait-co/devtools dist) — neither needs the package
|
|
3866
|
+
* to be node-resolvable from the caller.
|
|
3867
|
+
*/
|
|
3868
|
+
function getRuntimePath() {
|
|
3869
|
+
const startDir = path.dirname(fileURLToPath(import.meta.url));
|
|
3870
|
+
const RELATIVE_PROBES = [
|
|
3871
|
+
["runtime.js"],
|
|
3872
|
+
["test-runner", "runtime.js"],
|
|
3873
|
+
["runtime.ts"],
|
|
3874
|
+
["test-runner", "runtime.ts"]
|
|
3875
|
+
];
|
|
3876
|
+
let dir = startDir;
|
|
3877
|
+
for (let i = 0; i < 12; i++) {
|
|
3878
|
+
for (const segs of RELATIVE_PROBES) {
|
|
3879
|
+
const candidate = path.join(dir, ...segs);
|
|
3880
|
+
try {
|
|
3881
|
+
accessSync(candidate);
|
|
3882
|
+
return candidate;
|
|
3883
|
+
} catch {}
|
|
3884
|
+
}
|
|
3885
|
+
const parent = path.dirname(dir);
|
|
3886
|
+
if (parent === dir) break;
|
|
3887
|
+
dir = parent;
|
|
3888
|
+
}
|
|
3889
|
+
return path.join(startDir, "runtime.js");
|
|
3890
|
+
}
|
|
3891
|
+
/**
|
|
3892
|
+
* Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
|
|
3893
|
+
*
|
|
3894
|
+
* The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
|
|
3895
|
+
* - `runTestModule` — the runtime entry (from `runtime.ts`).
|
|
3896
|
+
* - `__userFactory` — an async function wrapping the user's test registration
|
|
3897
|
+
* code so it runs AFTER `runTestModule` installs the globals.
|
|
3898
|
+
*
|
|
3899
|
+
* Callers (rpc.ts) invoke:
|
|
3900
|
+
* `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
|
|
3901
|
+
*
|
|
3902
|
+
* @param absPath - Absolute path to the user test file.
|
|
3903
|
+
* @param opts - Optional bundling overrides.
|
|
3904
|
+
*/
|
|
3905
|
+
async function bundleTestFile(absPath, opts) {
|
|
3906
|
+
const globalName = opts?.globalName ?? "__testBundle";
|
|
3907
|
+
const extraExternals = opts?.extraExternals ?? [];
|
|
3908
|
+
const esbuild = await import("esbuild");
|
|
3909
|
+
const runtimePath = getRuntimePath();
|
|
3910
|
+
const wrapperContent = [
|
|
3911
|
+
`import { runTestModule } from ${JSON.stringify(runtimePath)};`,
|
|
3912
|
+
`import __userFactory from "user-test-factory";`,
|
|
3913
|
+
`export { runTestModule, __userFactory };`
|
|
3914
|
+
].join("\n");
|
|
3915
|
+
const result = await esbuild.build({
|
|
3916
|
+
stdin: {
|
|
3917
|
+
contents: wrapperContent,
|
|
3918
|
+
loader: "ts",
|
|
3919
|
+
resolveDir: path.dirname(absPath)
|
|
3920
|
+
},
|
|
3921
|
+
bundle: true,
|
|
3922
|
+
format: "iife",
|
|
3923
|
+
globalName,
|
|
3924
|
+
platform: "browser",
|
|
3925
|
+
target: "es2022",
|
|
3926
|
+
write: false,
|
|
3927
|
+
plugins: [
|
|
3928
|
+
userFactoryPlugin(absPath),
|
|
3929
|
+
vitestRedirectPlugin(),
|
|
3930
|
+
sdkRedirectPlugin()
|
|
3931
|
+
],
|
|
3932
|
+
external: extraExternals,
|
|
3933
|
+
treeShaking: true,
|
|
3934
|
+
footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
|
|
3935
|
+
});
|
|
3936
|
+
const warnings = result.warnings.map((w) => `${path.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
|
|
3937
|
+
const outputFile = result.outputFiles?.[0];
|
|
3938
|
+
if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
|
|
3939
|
+
return {
|
|
3940
|
+
code: outputFile.text,
|
|
3941
|
+
warnings
|
|
3942
|
+
};
|
|
3943
|
+
}
|
|
3944
|
+
//#endregion
|
|
3945
|
+
//#region src/test-runner/capture.ts
|
|
3946
|
+
/** The exact console-line prefix sdk-example's `flushCapture` emits. */
|
|
3947
|
+
const CAPTURE_PREFIX = "__AIT_CAPTURE__ ";
|
|
3948
|
+
/**
|
|
3949
|
+
* Parses raw console line texts into {@link AitCaptureLine}s.
|
|
3950
|
+
*
|
|
3951
|
+
* Filtering rules (each independently drops a line — never throws):
|
|
3952
|
+
* - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);
|
|
3953
|
+
* - there must be a non-empty category token (up to the next space);
|
|
3954
|
+
* - the remaining payload must be valid JSON (`JSON.parse` succeeds).
|
|
3955
|
+
*
|
|
3956
|
+
* Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are
|
|
3957
|
+
* silently discarded — capture harvesting is best-effort and must never fail a
|
|
3958
|
+
* run or leak a malformed/secret-bearing line.
|
|
3959
|
+
*
|
|
3960
|
+
* @param raw - Console line objects (only `.text` is read).
|
|
3961
|
+
* @returns The captured lines, in input order.
|
|
3962
|
+
*/
|
|
3963
|
+
function parseCaptureLines(raw) {
|
|
3964
|
+
const out = [];
|
|
3965
|
+
for (const { text } of raw) {
|
|
3966
|
+
if (!text.startsWith(CAPTURE_PREFIX)) continue;
|
|
3967
|
+
const body = text.slice(16);
|
|
3968
|
+
const spaceIdx = body.indexOf(" ");
|
|
3969
|
+
if (spaceIdx === -1) continue;
|
|
3970
|
+
const category = body.slice(0, spaceIdx);
|
|
3971
|
+
const json = body.slice(spaceIdx + 1);
|
|
3972
|
+
if (category === "" || json === "") continue;
|
|
3973
|
+
try {
|
|
3974
|
+
JSON.parse(json);
|
|
3975
|
+
} catch {
|
|
3976
|
+
continue;
|
|
3977
|
+
}
|
|
3978
|
+
out.push({
|
|
3979
|
+
category,
|
|
3980
|
+
json
|
|
4155
3981
|
});
|
|
4156
3982
|
}
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
3983
|
+
return out;
|
|
3984
|
+
}
|
|
3985
|
+
//#endregion
|
|
3986
|
+
//#region src/test-runner/rpc.ts
|
|
3987
|
+
/** Maximum milliseconds to wait for a single evaluate round-trip. */
|
|
3988
|
+
const DEFAULT_TIMEOUT_MS = 6e4;
|
|
3989
|
+
/**
|
|
3990
|
+
* Wraps bundle code in a self-executing IIFE that:
|
|
3991
|
+
* 1. Evaluates the bundle (registering describe/it/test).
|
|
3992
|
+
* 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
|
|
3993
|
+
* 3. Returns a JSON-serialised `RunReport` string.
|
|
3994
|
+
*
|
|
3995
|
+
* The double-serialisation (RunReport → JSON string → returnByValue string)
|
|
3996
|
+
* is intentional: CDP `returnByValue` reliably transports strings; deeply
|
|
3997
|
+
* nested objects can lose fidelity across the Chii relay.
|
|
3998
|
+
*
|
|
3999
|
+
* SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
|
|
4000
|
+
*/
|
|
4001
|
+
function buildRunTestsExpression(bundleCode) {
|
|
4002
|
+
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)}); }})()`;
|
|
4003
|
+
}
|
|
4004
|
+
/**
|
|
4005
|
+
* Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
|
|
4006
|
+
* evaluate call into a typed `RpcRunResult`.
|
|
4007
|
+
*
|
|
4008
|
+
* Throws only on parse failure — an `ok:false` envelope is a normal result.
|
|
4009
|
+
*
|
|
4010
|
+
* SECRET-HANDLING: `rawValue` is not included in error messages.
|
|
4011
|
+
*/
|
|
4012
|
+
function parseRunTestsResult(rawValue) {
|
|
4013
|
+
if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
|
|
4014
|
+
let parsed;
|
|
4015
|
+
try {
|
|
4016
|
+
parsed = JSON.parse(rawValue);
|
|
4017
|
+
} catch {
|
|
4018
|
+
throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
|
|
4170
4019
|
}
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4020
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
|
|
4021
|
+
const obj = parsed;
|
|
4022
|
+
if (obj.ok === true) return {
|
|
4023
|
+
ok: true,
|
|
4024
|
+
report: obj.value
|
|
4025
|
+
};
|
|
4026
|
+
if (obj.ok === false) return {
|
|
4027
|
+
ok: false,
|
|
4028
|
+
error: typeof obj.error === "string" ? obj.error : String(obj.error)
|
|
4029
|
+
};
|
|
4030
|
+
throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
|
|
4031
|
+
}
|
|
4032
|
+
/**
|
|
4033
|
+
* Injects `bundleCode` into the attached page and awaits test execution.
|
|
4034
|
+
*
|
|
4035
|
+
* Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
|
|
4036
|
+
* async IIFE to settle. The 30-second CDP command timeout covers even
|
|
4037
|
+
* long-running test suites; split into smaller files if you hit it.
|
|
4038
|
+
*
|
|
4039
|
+
* @param connection - Active CDP connection (relay or local).
|
|
4040
|
+
* @param bundleCode - IIFE bundle string from `bundleTestFile`.
|
|
4041
|
+
* @param timeoutMs - Override the default 30 s timeout.
|
|
4042
|
+
*
|
|
4043
|
+
* SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
|
|
4044
|
+
*/
|
|
4045
|
+
async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
4046
|
+
const expression = buildRunTestsExpression(bundleCode);
|
|
4047
|
+
const TIMEOUT_SENTINEL = Symbol("timeout");
|
|
4048
|
+
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs));
|
|
4049
|
+
const evalPromise = connection.send("Runtime.evaluate", {
|
|
4050
|
+
expression,
|
|
4051
|
+
returnByValue: true,
|
|
4052
|
+
awaitPromise: true
|
|
4053
|
+
});
|
|
4054
|
+
const raceResult = await Promise.race([evalPromise.then((v) => ({
|
|
4055
|
+
tag: "eval",
|
|
4056
|
+
v
|
|
4057
|
+
})), timeoutPromise.then(() => ({ tag: "timeout" }))]);
|
|
4058
|
+
if (raceResult.tag === "timeout") return {
|
|
4059
|
+
ok: false,
|
|
4060
|
+
error: `rpc: evaluate timed out after ${timeoutMs}ms`
|
|
4061
|
+
};
|
|
4062
|
+
const cdpResult = raceResult.v;
|
|
4063
|
+
if (cdpResult.exceptionDetails) {
|
|
4064
|
+
const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
4065
|
+
throw new Error(`rpc.injectAndRunBundle: ${msg}`);
|
|
4194
4066
|
}
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4067
|
+
return parseRunTestsResult(cdpResult.result.value);
|
|
4068
|
+
}
|
|
4069
|
+
//#endregion
|
|
4070
|
+
//#region src/test-runner/relay-worker.ts
|
|
4071
|
+
/**
|
|
4072
|
+
* Sentinel string embedded in the error message by `injectAndRunBundle` when
|
|
4073
|
+
* the per-file evaluate race hits the timeout. Used by the retry guard so only
|
|
4074
|
+
* genuine timeouts get a second attempt — not bundle errors or parse failures.
|
|
4075
|
+
*
|
|
4076
|
+
* Exported for unit tests that assert the retry path is taken.
|
|
4077
|
+
*/
|
|
4078
|
+
const EVALUATE_TIMEOUT_MARKER = "rpc: evaluate timed out after";
|
|
4079
|
+
/**
|
|
4080
|
+
* Runs all `files` sequentially over the given CDP `connection`.
|
|
4081
|
+
*
|
|
4082
|
+
* For each file:
|
|
4083
|
+
* 1. Bundle with esbuild (includes SDK shim + runtime).
|
|
4084
|
+
* 2. Inject into the attached page via `Runtime.evaluate`.
|
|
4085
|
+
* 3. Await the `RunReport` JSON response.
|
|
4086
|
+
* 4. Accumulate results.
|
|
4087
|
+
*
|
|
4088
|
+
* Returns a `RelayRunReport` with per-file results and flattened totals.
|
|
4089
|
+
*
|
|
4090
|
+
* This function does NOT open or manage the relay connection — the caller
|
|
4091
|
+
* is responsible for attaching and closing it.
|
|
4092
|
+
*
|
|
4093
|
+
* TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
|
|
4094
|
+
* so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
|
|
4095
|
+
*
|
|
4096
|
+
* @param connection - Active CDP connection (relay or local kind).
|
|
4097
|
+
* @param files - Absolute paths to test files, run in order.
|
|
4098
|
+
* @param opts - Optional per-run overrides.
|
|
4099
|
+
*/
|
|
4100
|
+
async function runTestFilesOverRelay(connection, files, opts) {
|
|
4101
|
+
const wallStart = Date.now();
|
|
4102
|
+
const startedAt = new Date(wallStart).toISOString();
|
|
4103
|
+
const fileResults = [];
|
|
4104
|
+
let domainsEnabled = false;
|
|
4105
|
+
try {
|
|
4106
|
+
await connection.enableDomains();
|
|
4107
|
+
domainsEnabled = true;
|
|
4108
|
+
} catch (e) {
|
|
4109
|
+
process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
|
|
4227
4110
|
}
|
|
4111
|
+
const collectCaptures = opts?.collectCaptures === true;
|
|
4112
|
+
const liveConsole = [];
|
|
4113
|
+
let unsubscribeConsole;
|
|
4114
|
+
if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
|
|
4115
|
+
liveConsole.push(event);
|
|
4116
|
+
});
|
|
4117
|
+
let pendingReconnectCheck = false;
|
|
4228
4118
|
/**
|
|
4229
|
-
*
|
|
4230
|
-
*
|
|
4231
|
-
*
|
|
4232
|
-
*
|
|
4233
|
-
* the command times out (2 s hard deadline) or errors, we treat the target
|
|
4234
|
-
* as dead and call `handleTargetGone`.
|
|
4119
|
+
* Attempts one `enableDomains()` reconnect. Logs the attempt and outcome to
|
|
4120
|
+
* stderr. Never throws — a failed reconnect just means the caller proceeds
|
|
4121
|
+
* as today (each remaining file / the retry will fail-fast on the still-dead
|
|
4122
|
+
* socket).
|
|
4235
4123
|
*
|
|
4236
|
-
*
|
|
4237
|
-
*
|
|
4238
|
-
*
|
|
4124
|
+
* `context` selects the fixed log literal: 'next-file' for the between-files
|
|
4125
|
+
* case (a file's FINAL result was a WS-dead-class error — the primary #731
|
|
4126
|
+
* scenario), 'retry-precheck' for the defensive reconnect before retrying a
|
|
4127
|
+
* timed-out file (cheap no-op via `enableDomains()`'s idempotency when the
|
|
4128
|
+
* socket never actually died).
|
|
4239
4129
|
*
|
|
4240
|
-
*
|
|
4130
|
+
* SECRET-HANDLING: fixed literals only — no relay wss URL, TOTP code, or
|
|
4131
|
+
* tunnel host.
|
|
4241
4132
|
*/
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4133
|
+
const attemptReconnect = async (context) => {
|
|
4134
|
+
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");
|
|
4135
|
+
try {
|
|
4136
|
+
await connection.enableDomains();
|
|
4137
|
+
process.stderr.write("relay-worker: reconnect succeeded\n");
|
|
4138
|
+
} catch (e) {
|
|
4139
|
+
process.stderr.write(`relay-worker: reconnect failed (${e instanceof Error ? e.message : String(e)}) — continuing, remaining files may fail\n`);
|
|
4140
|
+
}
|
|
4141
|
+
};
|
|
4142
|
+
try {
|
|
4143
|
+
for (const file of files) {
|
|
4144
|
+
if (pendingReconnectCheck) {
|
|
4145
|
+
await attemptReconnect("next-file");
|
|
4146
|
+
pendingReconnectCheck = false;
|
|
4147
|
+
}
|
|
4148
|
+
let fileEntry;
|
|
4149
|
+
try {
|
|
4150
|
+
const { code } = await bundleTestFile(file, opts?.bundleOptions);
|
|
4151
|
+
/**
|
|
4152
|
+
* Runs one evaluate attempt and returns a FileResult, or `null` when the
|
|
4153
|
+
* result is a genuine timeout and the caller should retry.
|
|
4154
|
+
*
|
|
4155
|
+
* We need to distinguish:
|
|
4156
|
+
* - `rpcResult.ok = false` + timeout error → retry candidate (`return null`)
|
|
4157
|
+
* - `rpcResult.ok = false` + other error → final error, no retry
|
|
4158
|
+
* - `injectAndRunBundle` throws → CDP exceptionDetails OR a
|
|
4159
|
+
* relay-disconnect rejection from `connection.send()` (page engine
|
|
4160
|
+
* threw, or the ws died mid-evaluate); treated as a final
|
|
4161
|
+
* (non-retryable) error either way.
|
|
4162
|
+
*
|
|
4163
|
+
* The Promise.race timeout in rpc.ts RETURNS `{ok:false, error: '…'}` (it
|
|
4164
|
+
* does NOT throw/reject). Only genuine CDP `exceptionDetails` (or a dead
|
|
4165
|
+
* `connection.send()`) cause a throw. This distinction is what makes the
|
|
4166
|
+
* EVALUATE_TIMEOUT_MARKER gate below reachable — the timeout result
|
|
4167
|
+
* surfaces as `rpcResult.ok=false` with the marker string, not as a
|
|
4168
|
+
* caught exception.
|
|
4169
|
+
*/
|
|
4170
|
+
const attempt = async () => {
|
|
4171
|
+
let rpcResult;
|
|
4172
|
+
try {
|
|
4173
|
+
rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
|
|
4174
|
+
} catch (e) {
|
|
4175
|
+
return {
|
|
4176
|
+
file,
|
|
4177
|
+
result: { error: e instanceof Error ? e.message : String(e) }
|
|
4178
|
+
};
|
|
4179
|
+
}
|
|
4180
|
+
if (rpcResult.ok) return {
|
|
4181
|
+
file,
|
|
4182
|
+
result: rpcResult.report
|
|
4183
|
+
};
|
|
4184
|
+
if (rpcResult.error.includes("rpc: evaluate timed out after")) return null;
|
|
4185
|
+
return {
|
|
4186
|
+
file,
|
|
4187
|
+
result: { error: rpcResult.error }
|
|
4188
|
+
};
|
|
4189
|
+
};
|
|
4190
|
+
const firstResult = await attempt();
|
|
4191
|
+
if (firstResult !== null) fileEntry = firstResult;
|
|
4192
|
+
else {
|
|
4193
|
+
await attemptReconnect("retry-precheck");
|
|
4194
|
+
process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\n`);
|
|
4195
|
+
const retryResult = await attempt();
|
|
4196
|
+
if (retryResult !== null) fileEntry = retryResult;
|
|
4197
|
+
else fileEntry = {
|
|
4198
|
+
file,
|
|
4199
|
+
result: { error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 6e4}ms (after retry)` }
|
|
4200
|
+
};
|
|
4201
|
+
}
|
|
4202
|
+
} catch (e) {
|
|
4203
|
+
fileEntry = {
|
|
4204
|
+
file,
|
|
4205
|
+
result: { error: e instanceof Error ? e.message : String(e) }
|
|
4206
|
+
};
|
|
4259
4207
|
}
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
stopHeartbeat() {
|
|
4263
|
-
if (this.heartbeatHandle !== null) {
|
|
4264
|
-
clearInterval(this.heartbeatHandle);
|
|
4265
|
-
this.heartbeatHandle = null;
|
|
4208
|
+
if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
|
|
4209
|
+
fileResults.push(fileEntry);
|
|
4266
4210
|
}
|
|
4211
|
+
} finally {
|
|
4212
|
+
unsubscribeConsole?.();
|
|
4267
4213
|
}
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
if (
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
const now = Date.now();
|
|
4279
|
-
let firstMessageSeen = false;
|
|
4280
|
-
for (const targetId of this.targets.keys()) {
|
|
4281
|
-
if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
|
|
4282
|
-
this.targetLastSeenAt.set(targetId, now);
|
|
4283
|
-
}
|
|
4284
|
-
if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
|
|
4285
|
-
if (typeof message.method !== "string") return;
|
|
4286
|
-
if (message.method === "Inspector.targetCrashed") {
|
|
4287
|
-
this.handleTargetGone("crashed", null);
|
|
4288
|
-
return;
|
|
4214
|
+
const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
|
|
4215
|
+
const totals = fileResults.reduce((acc, { result }) => {
|
|
4216
|
+
if ("error" in result) {
|
|
4217
|
+
acc.failed += 1;
|
|
4218
|
+
acc.total += 1;
|
|
4219
|
+
} else {
|
|
4220
|
+
acc.passed += result.passed;
|
|
4221
|
+
acc.failed += result.failed;
|
|
4222
|
+
acc.skipped += result.skipped;
|
|
4223
|
+
acc.total += result.passed + result.failed + result.skipped;
|
|
4289
4224
|
}
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4225
|
+
return acc;
|
|
4226
|
+
}, {
|
|
4227
|
+
passed: 0,
|
|
4228
|
+
failed: 0,
|
|
4229
|
+
skipped: 0,
|
|
4230
|
+
total: 0
|
|
4231
|
+
});
|
|
4232
|
+
return {
|
|
4233
|
+
startedAt,
|
|
4234
|
+
duration: Date.now() - wallStart,
|
|
4235
|
+
files: fileResults,
|
|
4236
|
+
totals,
|
|
4237
|
+
captures
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
/**
|
|
4241
|
+
* Renders one `Runtime.consoleAPICalled` event to a single line of text, the
|
|
4242
|
+
* same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
|
|
4243
|
+
* joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
|
|
4244
|
+
* which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
|
|
4245
|
+
* onto the test-runner entry.
|
|
4246
|
+
*
|
|
4247
|
+
* SECRET-HANDLING: this only stringifies console args; the caller's
|
|
4248
|
+
* allowlist-prefix parser then discards everything that is not a genuine
|
|
4249
|
+
* `__AIT_CAPTURE__` line.
|
|
4250
|
+
*/
|
|
4251
|
+
function renderConsoleLineText(event) {
|
|
4252
|
+
return event.args.map((arg) => {
|
|
4253
|
+
if (arg.value !== void 0) {
|
|
4254
|
+
if (typeof arg.value === "string") return arg.value;
|
|
4255
|
+
try {
|
|
4256
|
+
return JSON.stringify(arg.value);
|
|
4257
|
+
} catch {
|
|
4258
|
+
return String(arg.value);
|
|
4259
|
+
}
|
|
4294
4260
|
}
|
|
4295
|
-
if (
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4261
|
+
if (arg.description !== void 0) return arg.description;
|
|
4262
|
+
if (arg.className !== void 0) return arg.className;
|
|
4263
|
+
return arg.subtype ?? arg.type;
|
|
4264
|
+
}).join(" ");
|
|
4265
|
+
}
|
|
4266
|
+
`
|
|
4267
|
+
devtools-test — run mini-app tests on a real device WebView over the CDP relay
|
|
4268
|
+
|
|
4269
|
+
USAGE
|
|
4270
|
+
devtools-test <glob> [<glob> ...] [options]
|
|
4271
|
+
|
|
4272
|
+
OPTIONS
|
|
4273
|
+
--scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
|
|
4274
|
+
(required for standalone relay attach / env3)
|
|
4275
|
+
--timeout <ms> Per-file evaluate timeout in ms (default: 60000).
|
|
4276
|
+
Controls how long a single test file is allowed to run
|
|
4277
|
+
before it is considered hung. Does NOT affect how long
|
|
4278
|
+
the CLI waits for a human to scan the QR code — use
|
|
4279
|
+
--attach-timeout for that.
|
|
4280
|
+
--attach-timeout <ms> How long to wait for a human to scan the QR code with
|
|
4281
|
+
their phone. Omit (default) to wait indefinitely — the
|
|
4282
|
+
runner stays up until you stop it (Ctrl-C/SIGTERM).
|
|
4283
|
+
Pass a value to bound the wait for CI/headless runs.
|
|
4284
|
+
--cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
|
|
4285
|
+
--cell-platform <plat> Platform to inject as __AIT_CELL__.platform
|
|
4286
|
+
(mock|ios|android, default: AIT_CELL_PLATFORM env)
|
|
4287
|
+
--report-dir <dir> Persist a runner-agnostic report + captures to <dir>
|
|
4288
|
+
(report: <sdkLine>.<platform>.json; captures:
|
|
4289
|
+
<dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
|
|
4290
|
+
Omitted = nothing saved. Enables console capture.
|
|
4291
|
+
--no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
|
|
4292
|
+
non-interactive stdout / CI / AIT_NO_QR_STDOUT)
|
|
4293
|
+
--headless Disable browser auto-open (text QR only)
|
|
4294
|
+
--project-root <dir> Project root for .ait_relay secret lookup
|
|
4295
|
+
(default: current working directory)
|
|
4296
|
+
--help, -h Show this help message
|
|
4297
|
+
|
|
4298
|
+
DESCRIPTION
|
|
4299
|
+
Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
|
|
4300
|
+
device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
|
|
4301
|
+
each matched test file with esbuild (SDK imports redirected to window.__sdk),
|
|
4302
|
+
injects the bundle into the attached WebView via Runtime.evaluate, and prints
|
|
4303
|
+
a summary.
|
|
4304
|
+
|
|
4305
|
+
With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
|
|
4306
|
+
runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
|
|
4307
|
+
compared offline.
|
|
4308
|
+
|
|
4309
|
+
The test files run against the live relay connection started by this process;
|
|
4310
|
+
no separate MCP daemon is required.
|
|
4311
|
+
|
|
4312
|
+
EXAMPLE
|
|
4313
|
+
devtools-test 'src/**/*.ait.test.ts' \\
|
|
4314
|
+
--scheme-url "intoss-private://..." \\
|
|
4315
|
+
--cell-sdk-line 3.x \\
|
|
4316
|
+
--cell-platform ios \\
|
|
4317
|
+
--report-dir .ait-report \\
|
|
4318
|
+
--timeout 60000
|
|
4319
|
+
|
|
4320
|
+
`.trimStart();
|
|
4321
|
+
/**
|
|
4322
|
+
* Renders per-file result lines and the aggregate totals line to a string.
|
|
4323
|
+
*
|
|
4324
|
+
* Each file gets one line:
|
|
4325
|
+
* - Error/timeout: `FAIL <basename>: <error-class>`
|
|
4326
|
+
* - Pass (0 tests): `OK <basename>: 0 passed (empty file)`
|
|
4327
|
+
* - Pass: `OK <basename>: N passed[, M failed][, K skipped]`
|
|
4328
|
+
*
|
|
4329
|
+
* The aggregate totals line always follows.
|
|
4330
|
+
*
|
|
4331
|
+
* SECRET-HANDLING: only `basename(file)` is used — no absolute paths, relay
|
|
4332
|
+
* URLs, wss URLs, scheme URLs, or TOTP codes appear in the output. The error
|
|
4333
|
+
* string comes from `result.error` which is already secret-free (relay-worker
|
|
4334
|
+
* produces only error-class messages like "rpc: evaluate timed out after
|
|
4335
|
+
* 30000ms").
|
|
4336
|
+
*
|
|
4337
|
+
* Exported so unit tests can assert the per-file lines without spawning a
|
|
4338
|
+
* subprocess or going through the full relay attach flow.
|
|
4339
|
+
*/
|
|
4340
|
+
function renderSummary(report) {
|
|
4341
|
+
const lines = [];
|
|
4342
|
+
for (const { file, result } of report.files) {
|
|
4343
|
+
const name = basename(file);
|
|
4344
|
+
if ("error" in result) lines.push(`FAIL ${name}: ${result.error}`);
|
|
4345
|
+
else {
|
|
4346
|
+
const parts = [`${result.passed} passed`];
|
|
4347
|
+
if (result.failed > 0) parts.push(`${result.failed} failed`);
|
|
4348
|
+
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
|
|
4349
|
+
const suffix = result.passed + result.failed + result.skipped === 0 ? " (empty file)" : "";
|
|
4350
|
+
lines.push(`OK ${name}: ${parts.join(", ")}${suffix}`);
|
|
4299
4351
|
}
|
|
4300
|
-
if (!this.buffers.has(message.method)) return;
|
|
4301
|
-
const event = message.method;
|
|
4302
|
-
const buffer = this.buffers.get(event);
|
|
4303
|
-
if (!buffer) return;
|
|
4304
|
-
buffer.push(message.params);
|
|
4305
|
-
const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
|
|
4306
|
-
if (buffer.length > cap) buffer.shift();
|
|
4307
|
-
this.emitter.emit(event, message.params);
|
|
4308
|
-
}
|
|
4309
|
-
getBufferedEvents(event) {
|
|
4310
|
-
return this.buffers.get(event) ?? [];
|
|
4311
4352
|
}
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4353
|
+
const { totals, duration } = report;
|
|
4354
|
+
lines.push(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${duration}ms)`);
|
|
4355
|
+
return lines.join("\n");
|
|
4356
|
+
}
|
|
4357
|
+
/**
|
|
4358
|
+
* Runs `files` over `connection` and returns the aggregate report.
|
|
4359
|
+
* This pure function is the testable core of the CLI (and is what the
|
|
4360
|
+
* `run_tests` MCP tool calls against the daemon's attached connection); it is
|
|
4361
|
+
* separate from `main()` so tests can call it without spawning a subprocess.
|
|
4362
|
+
*/
|
|
4363
|
+
async function runWithConnection(connection, files, opts) {
|
|
4364
|
+
const report = await runTestFilesOverRelay(connection, files, opts);
|
|
4365
|
+
if (opts?.printSummary) process.stdout.write(`\n${renderSummary(report)}\n`);
|
|
4366
|
+
return report;
|
|
4367
|
+
}
|
|
4368
|
+
//#endregion
|
|
4369
|
+
//#region src/mcp/ait-chii-source.ts
|
|
4370
|
+
function isObject$2(value) {
|
|
4371
|
+
return typeof value === "object" && value !== null;
|
|
4372
|
+
}
|
|
4373
|
+
/** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
|
|
4374
|
+
function asSdkCallHistory(raw) {
|
|
4375
|
+
if (isObject$2(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
|
|
4376
|
+
return { calls: [] };
|
|
4377
|
+
}
|
|
4378
|
+
/** Narrows an `AIT.getMockState` response to an opaque record. */
|
|
4379
|
+
function asMockState(raw) {
|
|
4380
|
+
return isObject$2(raw) ? raw : {};
|
|
4381
|
+
}
|
|
4382
|
+
/** Narrows an `AIT.getOperationalEnvironment` response. */
|
|
4383
|
+
function asOperationalEnvironment(raw) {
|
|
4384
|
+
return {
|
|
4385
|
+
environment: isObject$2(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
|
|
4386
|
+
sdkVersion: isObject$2(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
|
|
4387
|
+
};
|
|
4388
|
+
}
|
|
4389
|
+
var ChiiAitSource = class {
|
|
4390
|
+
constructor(sender) {
|
|
4391
|
+
this.sender = sender;
|
|
4315
4392
|
}
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4393
|
+
async get(method) {
|
|
4394
|
+
const raw = await this.sender.sendCommand(method);
|
|
4395
|
+
switch (method) {
|
|
4396
|
+
case "AIT.getSdkCallHistory": return asSdkCallHistory(raw);
|
|
4397
|
+
case "AIT.getMockState": return asMockState(raw);
|
|
4398
|
+
case "AIT.getOperationalEnvironment": return asOperationalEnvironment(raw);
|
|
4399
|
+
default: throw new Error(`Unknown AIT method: ${String(method)}`);
|
|
4400
|
+
}
|
|
4322
4401
|
}
|
|
4323
4402
|
};
|
|
4324
4403
|
//#endregion
|
|
@@ -5344,6 +5423,11 @@ const en = {
|
|
|
5344
5423
|
"dashboard.watchdog.title": "Server has shut down",
|
|
5345
5424
|
"dashboard.watchdog.body": "The MCP server has stopped. This session is no longer active. You may close this tab.",
|
|
5346
5425
|
"dashboard.watchdog.close": "Close tab",
|
|
5426
|
+
"dashboard.conn.lost": "Connection lost — reconnecting…",
|
|
5427
|
+
"dashboard.session.completeTitle": "Test run complete",
|
|
5428
|
+
"dashboard.session.completeBody": "The runner exited cleanly. See your terminal for results.",
|
|
5429
|
+
"dashboard.session.shutdownTitle": "Debug server stopped",
|
|
5430
|
+
"dashboard.session.shutdownBody": "The MCP server has shut down; this session is no longer live. You can close this tab.",
|
|
5347
5431
|
"attach.title": "AIT Debug Session — QR Scan",
|
|
5348
5432
|
"attach.deployment": "deployment: {label}",
|
|
5349
5433
|
"attach.steps.section": "How to scan",
|
|
@@ -5582,6 +5666,11 @@ const tables = {
|
|
|
5582
5666
|
"dashboard.watchdog.title": "서버가 종료되었습니다",
|
|
5583
5667
|
"dashboard.watchdog.body": "MCP server가 종료되어 이 세션이 더 이상 유효하지 않습니다. 이 탭을 닫아도 됩니다.",
|
|
5584
5668
|
"dashboard.watchdog.close": "탭 닫기",
|
|
5669
|
+
"dashboard.conn.lost": "연결 끊김 — 재연결 시도 중…",
|
|
5670
|
+
"dashboard.session.completeTitle": "테스트 실행 완료",
|
|
5671
|
+
"dashboard.session.completeBody": "러너가 정상 종료되었습니다. 결과는 터미널에서 확인하세요.",
|
|
5672
|
+
"dashboard.session.shutdownTitle": "디버그 서버가 종료되었습니다",
|
|
5673
|
+
"dashboard.session.shutdownBody": "MCP server가 종료되어 이 세션이 더 이상 유효하지 않습니다. 이 탭을 닫아도 됩니다.",
|
|
5585
5674
|
"attach.title": "AIT 디버그 세션 — QR 스캔",
|
|
5586
5675
|
"attach.deployment": "deployment: {label}",
|
|
5587
5676
|
"attach.steps.section": "스캔 절차",
|
|
@@ -6163,7 +6252,12 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
|
|
|
6163
6252
|
watchdogTimeoutMs,
|
|
6164
6253
|
watchdogTitle: JSON.stringify(s("dashboard.watchdog.title")),
|
|
6165
6254
|
watchdogBody: JSON.stringify(s("dashboard.watchdog.body")),
|
|
6166
|
-
watchdogCloseLabel: JSON.stringify(s("dashboard.watchdog.close"))
|
|
6255
|
+
watchdogCloseLabel: JSON.stringify(s("dashboard.watchdog.close")),
|
|
6256
|
+
connectionLost: JSON.stringify(s("dashboard.conn.lost")),
|
|
6257
|
+
completeTitle: JSON.stringify(s("dashboard.session.completeTitle")),
|
|
6258
|
+
completeBody: JSON.stringify(s("dashboard.session.completeBody")),
|
|
6259
|
+
shutdownTitle: JSON.stringify(s("dashboard.session.shutdownTitle")),
|
|
6260
|
+
shutdownBody: JSON.stringify(s("dashboard.session.shutdownBody"))
|
|
6167
6261
|
};
|
|
6168
6262
|
const langSwitcher = buildLangSwitcher(path, params, locale, s);
|
|
6169
6263
|
const filled = dashboardChromeByLocale[locale].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__NOW__", escapeHtml(now)).replaceAll("__TUNNEL_CLASS__", tunnelClass).replaceAll("__TUNNEL_STATUS__", escapeHtml(tunnelStatus)).replaceAll("__ATTACH_SECTION__", attachSection).replaceAll("__INSPECTOR_SECTION__", inspectorSection).replaceAll("__PAGES_SECTION__", pagesSection);
|
|
@@ -6220,6 +6314,14 @@ function buildSseScript(strings) {
|
|
|
6220
6314
|
var WATCHDOG_BODY = ${strings.watchdogBody};
|
|
6221
6315
|
var WATCHDOG_CLOSE_LABEL = ${strings.watchdogCloseLabel};
|
|
6222
6316
|
|
|
6317
|
+
// ── 실시간 상태 표면 상수 (#730) ───────────────────────────────────────
|
|
6318
|
+
var CONN_LOST = ${strings.connectionLost};
|
|
6319
|
+
var COMPLETE_TITLE = ${strings.completeTitle};
|
|
6320
|
+
var COMPLETE_BODY = ${strings.completeBody};
|
|
6321
|
+
var SHUTDOWN_TITLE = ${strings.shutdownTitle};
|
|
6322
|
+
var SHUTDOWN_BODY = ${strings.shutdownBody};
|
|
6323
|
+
var disconnectedShown = false;
|
|
6324
|
+
|
|
6223
6325
|
// ── 클립보드 복사 헬퍼 ────────────────────────────────────────────────
|
|
6224
6326
|
function copyText(text) {
|
|
6225
6327
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
@@ -6297,6 +6399,36 @@ function buildSseScript(strings) {
|
|
|
6297
6399
|
}
|
|
6298
6400
|
}
|
|
6299
6401
|
|
|
6402
|
+
// ── Terminal 상태 배너 (#730) ─────────────────────────────────────────
|
|
6403
|
+
// "완료"/"종료"는 watchdog(최대 210s 지연)을 기다리지 않고 close() 직전에
|
|
6404
|
+
// 서버가 직접 push하는 마지막 SSE 프레임으로 즉시 렌더한다. watchdogFired를
|
|
6405
|
+
// 함께 세워 뒤이은 onerror의 showConnLost가 이 배너를 덮어쓰지 않게 한다.
|
|
6406
|
+
function showTerminalBanner(title, body) {
|
|
6407
|
+
if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; }
|
|
6408
|
+
watchdogFired = true; // 이후 watchdog/onerror 발화를 억제
|
|
6409
|
+
if (document.body) {
|
|
6410
|
+
document.body.innerHTML =
|
|
6411
|
+
'<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;' +
|
|
6412
|
+
'height:100vh;font-family:sans-serif;text-align:center;padding:2rem;box-sizing:border-box;">' +
|
|
6413
|
+
'<p style="font-size:1.4rem;font-weight:bold;margin-bottom:1rem;">' + title + '</p>' +
|
|
6414
|
+
'<p style="color:#555;">' + body + '</p></div>';
|
|
6415
|
+
}
|
|
6416
|
+
}
|
|
6417
|
+
|
|
6418
|
+
// ── SSE onerror 즉시 신호 (#730) ──────────────────────────────────────
|
|
6419
|
+
// EventSource는 "재시도 중"과 "서버 종료"를 구분하지 못하므로, onerror에서
|
|
6420
|
+
// 즉시 구분되는 '연결 끊김' 상태를 tunnel-status 배지에 표시한다. 이미
|
|
6421
|
+
// terminal 배너가 뜬 뒤(watchdogFired)라면 덮어쓰지 않는다.
|
|
6422
|
+
function showConnLost() {
|
|
6423
|
+
if (disconnectedShown || watchdogFired) return;
|
|
6424
|
+
disconnectedShown = true;
|
|
6425
|
+
var el = document.getElementById('tunnel-status');
|
|
6426
|
+
if (el) { el.textContent = CONN_LOST; el.className = 'status status-down'; }
|
|
6427
|
+
}
|
|
6428
|
+
function clearConnLost() {
|
|
6429
|
+
disconnectedShown = false;
|
|
6430
|
+
}
|
|
6431
|
+
|
|
6300
6432
|
function fireWatchdog() {
|
|
6301
6433
|
if (watchdogFired) return;
|
|
6302
6434
|
watchdogFired = true;
|
|
@@ -6327,7 +6459,7 @@ function buildSseScript(strings) {
|
|
|
6327
6459
|
src.onopen = function () {
|
|
6328
6460
|
// 연결 복구 시 lastSeen 갱신 — 일시적 끊김이 watchdog을 발화시키지 않게.
|
|
6329
6461
|
// watchdog이 이미 발화했으면 갱신하지 않는다(폴백 UI를 그대로 유지).
|
|
6330
|
-
if (!watchdogFired) { lastSeen = Date.now(); }
|
|
6462
|
+
if (!watchdogFired) { lastSeen = Date.now(); clearConnLost(); }
|
|
6331
6463
|
};
|
|
6332
6464
|
src.onmessage = function (e) {
|
|
6333
6465
|
// SSE 메시지 수신 — lastSeen 갱신으로 watchdog 리셋.
|
|
@@ -6335,6 +6467,13 @@ function buildSseScript(strings) {
|
|
|
6335
6467
|
if (!watchdogFired) { lastSeen = Date.now(); }
|
|
6336
6468
|
try {
|
|
6337
6469
|
var s = JSON.parse(e.data);
|
|
6470
|
+
// 어떤 프레임이든 성공적으로 수신됐다는 건 서버가 살아있다는 뜻 —
|
|
6471
|
+
// onerror가 세운 '연결 끊김' 표시를 해제한다 (#730).
|
|
6472
|
+
clearConnLost();
|
|
6473
|
+
// phase gate (#730) — 'complete'/'shutdown'은 terminal 상태이므로
|
|
6474
|
+
// 나머지 필드 렌더보다 먼저 처리하고 즉시 반환한다.
|
|
6475
|
+
if (s.phase === 'complete') { showTerminalBanner(COMPLETE_TITLE, COMPLETE_BODY); return; }
|
|
6476
|
+
if (s.phase === 'shutdown') { showTerminalBanner(SHUTDOWN_TITLE, SHUTDOWN_BODY); return; }
|
|
6338
6477
|
// 터널 상태 갱신
|
|
6339
6478
|
var el = document.getElementById('tunnel-status');
|
|
6340
6479
|
if (el) {
|
|
@@ -6413,7 +6552,11 @@ function buildSseScript(strings) {
|
|
|
6413
6552
|
} catch (_) { /* 파싱 오류 무시 */ }
|
|
6414
6553
|
};
|
|
6415
6554
|
src.onerror = function () {
|
|
6416
|
-
//
|
|
6555
|
+
// EventSource는 "재시도 중"과 "서버 종료"를 구분하지 못하므로 즉시
|
|
6556
|
+
// 구분되는 "연결 끊김"을 표시한다(#730). 네이티브 auto-reconnect는 그대로
|
|
6557
|
+
// 진행되며 onopen/onmessage가 성공 즉시 이 상태를 해제한다. watchdog은
|
|
6558
|
+
// 여전히 2차 fallback으로 남는다(최종 폴백 UI 전환).
|
|
6559
|
+
showConnLost();
|
|
6417
6560
|
};
|
|
6418
6561
|
})();
|
|
6419
6562
|
<\/script>`;
|
|
@@ -6462,7 +6605,12 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/a
|
|
|
6462
6605
|
watchdogTimeoutMs,
|
|
6463
6606
|
watchdogTitle: JSON.stringify(s("dashboard.watchdog.title")),
|
|
6464
6607
|
watchdogBody: JSON.stringify(s("dashboard.watchdog.body")),
|
|
6465
|
-
watchdogCloseLabel: JSON.stringify(s("dashboard.watchdog.close"))
|
|
6608
|
+
watchdogCloseLabel: JSON.stringify(s("dashboard.watchdog.close")),
|
|
6609
|
+
connectionLost: JSON.stringify(s("dashboard.conn.lost")),
|
|
6610
|
+
completeTitle: JSON.stringify(s("dashboard.session.completeTitle")),
|
|
6611
|
+
completeBody: JSON.stringify(s("dashboard.session.completeBody")),
|
|
6612
|
+
shutdownTitle: JSON.stringify(s("dashboard.session.shutdownTitle")),
|
|
6613
|
+
shutdownBody: JSON.stringify(s("dashboard.session.shutdownBody"))
|
|
6466
6614
|
});
|
|
6467
6615
|
return filled.replace("</body>", `${sseScript}\n</body>`);
|
|
6468
6616
|
}
|
|
@@ -6489,7 +6637,8 @@ async function startQrHttpServer(getDashboardState, options) {
|
|
|
6489
6637
|
},
|
|
6490
6638
|
pages: state.pages,
|
|
6491
6639
|
attachUrl: state.attachUrl,
|
|
6492
|
-
inspectorUrl: state.inspectorUrl ?? null
|
|
6640
|
+
inspectorUrl: state.inspectorUrl ?? null,
|
|
6641
|
+
phase: state.phase ?? "active"
|
|
6493
6642
|
});
|
|
6494
6643
|
res.write(`data: ${payload}\n\n`);
|
|
6495
6644
|
}
|
|
@@ -6798,7 +6947,7 @@ function createDebugServer(deps) {
|
|
|
6798
6947
|
};
|
|
6799
6948
|
const server = new Server({
|
|
6800
6949
|
name: "ait-debug",
|
|
6801
|
-
version: "0.1.
|
|
6950
|
+
version: "0.1.131"
|
|
6802
6951
|
}, { capabilities: { tools: { listChanged: true } } });
|
|
6803
6952
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
6804
6953
|
const conn = router.active;
|
|
@@ -7760,6 +7909,7 @@ async function runDebugServer(options = {}) {
|
|
|
7760
7909
|
return router.active;
|
|
7761
7910
|
});
|
|
7762
7911
|
let lastAttachParts = null;
|
|
7912
|
+
let currentPhase = "active";
|
|
7763
7913
|
const getDashboardState = () => {
|
|
7764
7914
|
const targets = router.active.listTargets();
|
|
7765
7915
|
const inspectorUrl = qrServer?.inspectorStableUrl ?? null;
|
|
@@ -7774,7 +7924,8 @@ async function runDebugServer(options = {}) {
|
|
|
7774
7924
|
})),
|
|
7775
7925
|
attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
|
|
7776
7926
|
inspectorUrl,
|
|
7777
|
-
mode: deriveEnvironment(router.active.kind, router.activeRelayOrigin)
|
|
7927
|
+
mode: deriveEnvironment(router.active.kind, router.activeRelayOrigin),
|
|
7928
|
+
phase: currentPhase
|
|
7778
7929
|
};
|
|
7779
7930
|
};
|
|
7780
7931
|
const getDirectInspectorUrl = () => {
|
|
@@ -7838,6 +7989,8 @@ async function runDebugServer(options = {}) {
|
|
|
7838
7989
|
const shutdown = () => {
|
|
7839
7990
|
if (closed) return;
|
|
7840
7991
|
closed = true;
|
|
7992
|
+
currentPhase = "shutdown";
|
|
7993
|
+
qrServer?.notifyStateChange();
|
|
7841
7994
|
parentWatcher?.stop();
|
|
7842
7995
|
maxAgeWatchdog?.stop();
|
|
7843
7996
|
if (totpRefreshHandle) clearInterval(totpRefreshHandle);
|
|
@@ -7975,6 +8128,7 @@ async function runLocalDebugServer(options = {}) {
|
|
|
7975
8128
|
return router.active;
|
|
7976
8129
|
});
|
|
7977
8130
|
let lastAttachParts = null;
|
|
8131
|
+
let currentPhase = "active";
|
|
7978
8132
|
const getDashboardState = () => {
|
|
7979
8133
|
const targets = router.active.listTargets();
|
|
7980
8134
|
const inspectorUrl = qrServer?.inspectorStableUrl ?? null;
|
|
@@ -7988,7 +8142,8 @@ async function runLocalDebugServer(options = {}) {
|
|
|
7988
8142
|
url: t.url
|
|
7989
8143
|
})),
|
|
7990
8144
|
attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
|
|
7991
|
-
inspectorUrl
|
|
8145
|
+
inspectorUrl,
|
|
8146
|
+
phase: currentPhase
|
|
7992
8147
|
};
|
|
7993
8148
|
};
|
|
7994
8149
|
const getDirectInspectorUrl = () => {
|
|
@@ -8052,6 +8207,8 @@ async function runLocalDebugServer(options = {}) {
|
|
|
8052
8207
|
const shutdown = () => {
|
|
8053
8208
|
if (closed) return;
|
|
8054
8209
|
closed = true;
|
|
8210
|
+
currentPhase = "shutdown";
|
|
8211
|
+
qrServer?.notifyStateChange();
|
|
8055
8212
|
parentWatcher?.stop();
|
|
8056
8213
|
maxAgeWatchdog?.stop();
|
|
8057
8214
|
if (totpRefreshHandle) clearInterval(totpRefreshHandle);
|
|
@@ -8172,6 +8329,7 @@ async function runMobileDebugServer(options = {}) {
|
|
|
8172
8329
|
return router.active;
|
|
8173
8330
|
});
|
|
8174
8331
|
let lastAttachParts = null;
|
|
8332
|
+
let currentPhase = "active";
|
|
8175
8333
|
const getDashboardState = () => {
|
|
8176
8334
|
const targets = router.active.listTargets();
|
|
8177
8335
|
const inspectorUrl = qrServer?.inspectorStableUrl ?? null;
|
|
@@ -8185,7 +8343,8 @@ async function runMobileDebugServer(options = {}) {
|
|
|
8185
8343
|
url: t.url
|
|
8186
8344
|
})),
|
|
8187
8345
|
attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
|
|
8188
|
-
inspectorUrl
|
|
8346
|
+
inspectorUrl,
|
|
8347
|
+
phase: currentPhase
|
|
8189
8348
|
};
|
|
8190
8349
|
};
|
|
8191
8350
|
const getDirectInspectorUrl = () => {
|
|
@@ -8249,6 +8408,8 @@ async function runMobileDebugServer(options = {}) {
|
|
|
8249
8408
|
const shutdown = () => {
|
|
8250
8409
|
if (closed) return;
|
|
8251
8410
|
closed = true;
|
|
8411
|
+
currentPhase = "shutdown";
|
|
8412
|
+
qrServer?.notifyStateChange();
|
|
8252
8413
|
parentWatcher?.stop();
|
|
8253
8414
|
maxAgeWatchdog?.stop();
|
|
8254
8415
|
if (totpRefreshHandle) clearInterval(totpRefreshHandle);
|
|
@@ -8770,7 +8931,7 @@ function createDevServer(deps = {}) {
|
|
|
8770
8931
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
8771
8932
|
const server = new Server({
|
|
8772
8933
|
name: "ait-devtools",
|
|
8773
|
-
version: "0.1.
|
|
8934
|
+
version: "0.1.131"
|
|
8774
8935
|
}, { capabilities: { tools: {} } });
|
|
8775
8936
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
8776
8937
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|