@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/test-runner/bin.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { r as generateTotp } from "../totp-DvOYkYim.js";
|
|
2
3
|
import * as path$1 from "node:path";
|
|
3
4
|
import path, { basename, isAbsolute, resolve } from "node:path";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
6
|
import * as fs from "node:fs/promises";
|
|
6
7
|
import { glob, mkdir, writeFile } from "node:fs/promises";
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
9
|
+
import { WebSocket } from "ws";
|
|
7
10
|
import { accessSync } from "node:fs";
|
|
8
11
|
import { fileURLToPath } from "node:url";
|
|
9
12
|
//#region src/test-runner/discover.ts
|
|
@@ -42,11 +45,14 @@ async function discoverTestFiles(patterns, cwd) {
|
|
|
42
45
|
* Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay
|
|
43
46
|
* connection.
|
|
44
47
|
*
|
|
45
|
-
* `open()` performs the full attach lifecycle and BLOCKS
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* `open()` performs the full attach lifecycle and BLOCKS while a human scans
|
|
49
|
+
* the rendered QR with their phone — there is no way around the manual scan
|
|
50
|
+
* for env3. By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the
|
|
51
|
+
* runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is
|
|
52
|
+
* a human-paced action with no sound default bound (devtools#735). Passing an
|
|
53
|
+
* explicit `timeoutMs` opts into the old bounded behavior (CI/headless
|
|
54
|
+
* callers). It resolves with the live `CdpConnection` once a matching page
|
|
55
|
+
* attaches; `close()` tears the relay family down.
|
|
50
56
|
*
|
|
51
57
|
* The factory holds the booted relay family in a closure so `close()` can stop
|
|
52
58
|
* it. A second `open()` on the same factory boots a fresh family (the previous
|
|
@@ -54,16 +60,17 @@ async function discoverTestFiles(patterns, cwd) {
|
|
|
54
60
|
*/
|
|
55
61
|
function createRelayConnectionFactory(opts) {
|
|
56
62
|
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
57
|
-
const timeoutMs = opts.timeoutMs ??
|
|
63
|
+
const timeoutMs = opts.timeoutMs ?? Number.POSITIVE_INFINITY;
|
|
58
64
|
const headless = opts.headless === true;
|
|
59
65
|
let family;
|
|
60
66
|
let qrServer;
|
|
67
|
+
let phase = "active";
|
|
61
68
|
return {
|
|
62
69
|
async open() {
|
|
63
|
-
const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-
|
|
64
|
-
const { injectDebugIndicator, injectGlobals } = await import("../cell-
|
|
70
|
+
const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-ca34r46y.js");
|
|
71
|
+
const { injectDebugIndicator, injectGlobals } = await import("../cell-rr6gL9AT.js");
|
|
65
72
|
const { loadRelaySecretReadOnly } = await import("../relay-secret-store-CFc9n0OA.js");
|
|
66
|
-
const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-
|
|
73
|
+
const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-CD4zYBq4.js");
|
|
67
74
|
await loadRelaySecretReadOnly({ projectRoot });
|
|
68
75
|
let resolveTunnelUp;
|
|
69
76
|
const tunnelReady = new Promise((resolve) => {
|
|
@@ -74,6 +81,9 @@ function createRelayConnectionFactory(opts) {
|
|
|
74
81
|
onWssUrl: () => {
|
|
75
82
|
resolveTunnelUp();
|
|
76
83
|
qrServer?.notifyStateChange();
|
|
84
|
+
},
|
|
85
|
+
onTunnelDown: () => {
|
|
86
|
+
qrServer?.notifyStateChange();
|
|
77
87
|
}
|
|
78
88
|
});
|
|
79
89
|
family = booted;
|
|
@@ -90,12 +100,13 @@ function createRelayConnectionFactory(opts) {
|
|
|
90
100
|
canOpenBrowser: () => !headless
|
|
91
101
|
};
|
|
92
102
|
try {
|
|
93
|
-
const { startQrHttpServer } = await import("../qr-http-server-
|
|
103
|
+
const { startQrHttpServer } = await import("../qr-http-server-DCbjORFa.js");
|
|
94
104
|
const getDashboardState = () => ({
|
|
95
105
|
tunnel: attachDeps.getTunnelStatus(),
|
|
96
106
|
pages: null,
|
|
97
107
|
attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,
|
|
98
|
-
mode: "relay-dev"
|
|
108
|
+
mode: "relay-dev",
|
|
109
|
+
phase
|
|
99
110
|
});
|
|
100
111
|
qrServer = await startQrHttpServer(getDashboardState);
|
|
101
112
|
attachDeps.qrHttpServer = qrServer;
|
|
@@ -148,7 +159,19 @@ function createRelayConnectionFactory(opts) {
|
|
|
148
159
|
if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
|
|
149
160
|
return booted.connection;
|
|
150
161
|
},
|
|
151
|
-
|
|
162
|
+
onSessionPhase(next) {
|
|
163
|
+
phase = next;
|
|
164
|
+
qrServer?.notifyStateChange();
|
|
165
|
+
},
|
|
166
|
+
async close(connection) {
|
|
167
|
+
if (phase !== "complete") {
|
|
168
|
+
phase = "complete";
|
|
169
|
+
qrServer?.notifyStateChange();
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const { injectDebugIndicator } = await import("../cell-rr6gL9AT.js");
|
|
173
|
+
await injectDebugIndicator(connection, { state: "disconnected" });
|
|
174
|
+
} catch {}
|
|
152
175
|
family?.stop();
|
|
153
176
|
family = void 0;
|
|
154
177
|
await qrServer?.close();
|
|
@@ -157,6 +180,666 @@ function createRelayConnectionFactory(opts) {
|
|
|
157
180
|
};
|
|
158
181
|
}
|
|
159
182
|
//#endregion
|
|
183
|
+
//#region src/shared/relay-auth-close.ts
|
|
184
|
+
/**
|
|
185
|
+
* Shared constants for the relay's named TOTP-auth rejection (issue #478).
|
|
186
|
+
*
|
|
187
|
+
* Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
|
|
188
|
+
* raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
|
|
189
|
+
* indistinguishable from a network failure on the browser side — the
|
|
190
|
+
* WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
|
|
191
|
+
* could not tell "stale TOTP code" apart from "tunnel down" and stayed
|
|
192
|
+
* silent. The fix is accept-then-close: complete the handshake, then close
|
|
193
|
+
* with an application close code that NAMES the rejection.
|
|
194
|
+
*
|
|
195
|
+
* Three parties share this contract:
|
|
196
|
+
* - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
|
|
197
|
+
* - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
|
|
198
|
+
* surfaces the code to the launcher shell;
|
|
199
|
+
* - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
|
|
200
|
+
* as an auth failure on its own `/client` dial (defensive — #439's fresh
|
|
201
|
+
* code mint means it should not normally hit this).
|
|
202
|
+
*
|
|
203
|
+
* This module is intentionally dependency-free (no Node, no DOM) so it is
|
|
204
|
+
* safe to import from both the browser in-app bundle and the MCP daemon
|
|
205
|
+
* bundle.
|
|
206
|
+
*
|
|
207
|
+
* SECRET-HANDLING: these are fixed enum values. The close reason / error body
|
|
208
|
+
* must never grow to carry a secret, a TOTP code, or a host.
|
|
209
|
+
*/
|
|
210
|
+
/**
|
|
211
|
+
* WebSocket close code sent by the relay when TOTP auth is rejected.
|
|
212
|
+
*
|
|
213
|
+
* 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
|
|
214
|
+
* HTTP 401 so it reads as "unauthorized" at a glance.
|
|
215
|
+
*/
|
|
216
|
+
const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
|
|
217
|
+
/**
|
|
218
|
+
* Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
|
|
219
|
+
* the `error` value of the relay's HTTP 401 JSON body. Enum string only —
|
|
220
|
+
* never interpolated with request data.
|
|
221
|
+
*/
|
|
222
|
+
const RELAY_AUTH_REJECT_REASON = "totp-rejected";
|
|
223
|
+
//#endregion
|
|
224
|
+
//#region src/mcp/log.ts
|
|
225
|
+
/**
|
|
226
|
+
* Allowed field keys that may pass through to a log line.
|
|
227
|
+
* Unknown keys are dropped. Values are still redact-scanned.
|
|
228
|
+
*/
|
|
229
|
+
const ALLOWED_KEYS = new Set([
|
|
230
|
+
"ts",
|
|
231
|
+
"level",
|
|
232
|
+
"event",
|
|
233
|
+
"msg",
|
|
234
|
+
"port",
|
|
235
|
+
"totpEnabled",
|
|
236
|
+
"env",
|
|
237
|
+
"tool",
|
|
238
|
+
"deploymentId",
|
|
239
|
+
"errorKind",
|
|
240
|
+
"reason",
|
|
241
|
+
"prevTargetId",
|
|
242
|
+
"mode",
|
|
243
|
+
"fileCount",
|
|
244
|
+
"passed",
|
|
245
|
+
"failed",
|
|
246
|
+
"skipped"
|
|
247
|
+
]);
|
|
248
|
+
/**
|
|
249
|
+
* Patterns that match secret values.
|
|
250
|
+
* Match order matters — more-specific patterns first.
|
|
251
|
+
*
|
|
252
|
+
* #268 redact script covers: relay=wss://…, at=<TOTP>, _deploymentId=<uuid>.
|
|
253
|
+
* Here we extend to in-process value-level patterns used in server logs.
|
|
254
|
+
*/
|
|
255
|
+
const SECRET_PATTERNS = [
|
|
256
|
+
/^\d{6}$/,
|
|
257
|
+
/^(aitcc_|AITCC_)/i,
|
|
258
|
+
/^[A-Za-z0-9_-]+=.{4,}/,
|
|
259
|
+
/^wss:\/\//,
|
|
260
|
+
/(?:^|[?&])at=[A-Z0-9]{6}/i
|
|
261
|
+
];
|
|
262
|
+
/**
|
|
263
|
+
* Returns `true` when the string value matches any known-secret pattern.
|
|
264
|
+
* Only string values are tested — numbers/booleans are always safe.
|
|
265
|
+
*/
|
|
266
|
+
function isSecretValue(value) {
|
|
267
|
+
return SECRET_PATTERNS.some((re) => re.test(value));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Redacts a single scalar value.
|
|
271
|
+
* - strings: return "***" if the value matches a secret pattern.
|
|
272
|
+
* - other: return as-is.
|
|
273
|
+
*/
|
|
274
|
+
function redactValue(value) {
|
|
275
|
+
if (typeof value === "string" && isSecretValue(value)) return "***";
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Builds a safe log payload from raw fields.
|
|
280
|
+
*
|
|
281
|
+
* - Only keys in `ALLOWED_KEYS` are included.
|
|
282
|
+
* - String values are scanned for secret patterns and replaced with "***".
|
|
283
|
+
* - `ts` and `level` and `event` are always included (they are injected by the
|
|
284
|
+
* logger functions below, not by callers).
|
|
285
|
+
*/
|
|
286
|
+
function buildPayload(level, event, fields) {
|
|
287
|
+
const out = {
|
|
288
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
289
|
+
level,
|
|
290
|
+
event
|
|
291
|
+
};
|
|
292
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
293
|
+
if (!ALLOWED_KEYS.has(key)) continue;
|
|
294
|
+
if (key === "ts" || key === "level" || key === "event") continue;
|
|
295
|
+
out[key] = redactValue(value);
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Writes a single JSON log line to stderr.
|
|
301
|
+
* MCP stdio transport uses stdout; all diagnostics go to stderr.
|
|
302
|
+
*/
|
|
303
|
+
function writeLog(level, event, fields = {}) {
|
|
304
|
+
const payload = buildPayload(level, event, fields);
|
|
305
|
+
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
|
306
|
+
}
|
|
307
|
+
/** Log an informational structured event. */
|
|
308
|
+
function logInfo(event, fields = {}) {
|
|
309
|
+
writeLog("info", event, fields);
|
|
310
|
+
}
|
|
311
|
+
/** Log an error structured event. */
|
|
312
|
+
function logError(event, fields = {}) {
|
|
313
|
+
writeLog("error", event, fields);
|
|
314
|
+
}
|
|
315
|
+
//#endregion
|
|
316
|
+
//#region src/mcp/chii-connection.ts
|
|
317
|
+
/**
|
|
318
|
+
* Production `CdpConnection` backed by the local Chii relay.
|
|
319
|
+
*
|
|
320
|
+
* Topology (debug mode):
|
|
321
|
+
* phone target.js --WS--> Chii relay :9100 <--WS-- this connection
|
|
322
|
+
*
|
|
323
|
+
* The phone connects to the relay as a `target`; this module connects as a
|
|
324
|
+
* `client` (the role a CDP frontend would take) so CDP events the page emits
|
|
325
|
+
* (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
|
|
326
|
+
* events in ring buffers the tool layer reads via `getBufferedEvents`.
|
|
327
|
+
*
|
|
328
|
+
* Node-only: imports `ws`. Never bundled into the browser/in-app entries.
|
|
329
|
+
*
|
|
330
|
+
* Attach reliability (#281):
|
|
331
|
+
* `refreshTargets()` emits an internal 'target:attached' event whenever a
|
|
332
|
+
* new target is added to the relay. `waitForFirstTarget()` awaits that event
|
|
333
|
+
* (with a polling-interval fallback) so `start_attach`'s attach wait
|
|
334
|
+
* resolves deterministically rather than racing between polling rounds.
|
|
335
|
+
*/
|
|
336
|
+
/** Max events retained per domain ring buffer. */
|
|
337
|
+
const DEFAULT_BUFFER_SIZE = 500;
|
|
338
|
+
/**
|
|
339
|
+
* Substrings that mark a "relay websocket is dead" class error, as produced by
|
|
340
|
+
* `handleDisconnect('relay WebSocket 연결이 끊겼습니다')` (ws close handler) and
|
|
341
|
+
* the fail-fast `sendCommand` rejection (`relay에 연결되어 있지 않습니다 (...)`).
|
|
342
|
+
*
|
|
343
|
+
* Exported so callers outside this module (e.g. `test-runner/relay-worker.ts`)
|
|
344
|
+
* can detect the same error class without hardcoding a fragile full-sentence
|
|
345
|
+
* match — mirrors the `EVALUATE_TIMEOUT_MARKER` precedent in
|
|
346
|
+
* `test-runner/relay-worker.ts`. Kept in sync with `classifyToolError`'s
|
|
347
|
+
* `relayDisconnectError` branch in `mcp/errors.ts`.
|
|
348
|
+
*/
|
|
349
|
+
const RELAY_DISCONNECT_MARKERS = ["relay WebSocket", "relay에 연결되어 있지 않습니다"];
|
|
350
|
+
/**
|
|
351
|
+
* Returns true when `message` matches the relay-websocket-dead error class
|
|
352
|
+
* (socket closed, socket errored, or fail-fast "not connected" rejection).
|
|
353
|
+
*/
|
|
354
|
+
function isRelayDisconnectMessage(message) {
|
|
355
|
+
return RELAY_DISCONNECT_MARKERS.some((marker) => message.includes(marker));
|
|
356
|
+
}
|
|
357
|
+
function isObject(value) {
|
|
358
|
+
return typeof value === "object" && value !== null;
|
|
359
|
+
}
|
|
360
|
+
function parseInbound(raw) {
|
|
361
|
+
let parsed;
|
|
362
|
+
try {
|
|
363
|
+
parsed = JSON.parse(raw);
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
if (!isObject(parsed)) return null;
|
|
368
|
+
const message = {};
|
|
369
|
+
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
370
|
+
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
371
|
+
if ("params" in parsed) message.params = parsed.params;
|
|
372
|
+
if ("result" in parsed) message.result = parsed.result;
|
|
373
|
+
if (isObject(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
374
|
+
return message;
|
|
375
|
+
}
|
|
376
|
+
const PHASE_1_EVENTS = [
|
|
377
|
+
"Runtime.consoleAPICalled",
|
|
378
|
+
"Network.requestWillBeSent",
|
|
379
|
+
"Network.responseReceived"
|
|
380
|
+
];
|
|
381
|
+
/**
|
|
382
|
+
* Ring buffer size for `Runtime.exceptionThrown`.
|
|
383
|
+
*
|
|
384
|
+
* Exceptions are rarer than console messages but each is heavier (stack
|
|
385
|
+
* trace). 50 is generous enough to cover a crash scenario while keeping
|
|
386
|
+
* memory bounded.
|
|
387
|
+
*
|
|
388
|
+
* **Lifecycle note**: the exception buffer intentionally survives `replaced` /
|
|
389
|
+
* `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
|
|
390
|
+
* transitions. Rationale: an exception fired just before a crash is exactly
|
|
391
|
+
* the signal we want to preserve for root-cause analysis. The buffer
|
|
392
|
+
* represents "exceptions seen in this MCP session", not "exceptions in the
|
|
393
|
+
* current page".
|
|
394
|
+
*/
|
|
395
|
+
const EXCEPTION_BUFFER_SIZE = 50;
|
|
396
|
+
/** Default per-command timeout if neither option nor env var is set. */
|
|
397
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
|
|
398
|
+
/**
|
|
399
|
+
* Production CDP connection. Polls the relay for the first attached target,
|
|
400
|
+
* opens a client websocket to it, enables Phase 1 domains, and buffers events.
|
|
401
|
+
*/
|
|
402
|
+
var ChiiCdpConnection = class {
|
|
403
|
+
/** Authoritative connection kind (issue #348) — relay-backed. */
|
|
404
|
+
kind = "relay";
|
|
405
|
+
relayBaseUrl;
|
|
406
|
+
bufferSize;
|
|
407
|
+
commandTimeoutMs;
|
|
408
|
+
totpSecret;
|
|
409
|
+
emitter = new EventEmitter();
|
|
410
|
+
buffers = /* @__PURE__ */ new Map();
|
|
411
|
+
targets = /* @__PURE__ */ new Map();
|
|
412
|
+
ws = null;
|
|
413
|
+
connectionState = "idle";
|
|
414
|
+
nextCommandId = 1;
|
|
415
|
+
/**
|
|
416
|
+
* The single active target id under the single-attach model.
|
|
417
|
+
* Updated by `refreshTargets()` whenever a non-null target is present.
|
|
418
|
+
* Used to detect a new (different) target attach and evict the previous one.
|
|
419
|
+
*/
|
|
420
|
+
activeTargetId = null;
|
|
421
|
+
/** In-flight enableDomains() promise — concurrent callers share it. */
|
|
422
|
+
enablingPromise = null;
|
|
423
|
+
/** Pending request→response commands keyed by CDP message id. */
|
|
424
|
+
pending = /* @__PURE__ */ new Map();
|
|
425
|
+
/**
|
|
426
|
+
* Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
|
|
427
|
+
* or `null` if no crash has been detected since the last `enableDomains()`.
|
|
428
|
+
*/
|
|
429
|
+
lastCrashDetectedAt = null;
|
|
430
|
+
/**
|
|
431
|
+
* Per-target last-seen timestamp (ms since epoch). Updated on any inbound
|
|
432
|
+
* CDP message carrying data from a target. Keyed by target id.
|
|
433
|
+
*/
|
|
434
|
+
targetLastSeenAt = /* @__PURE__ */ new Map();
|
|
435
|
+
/** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
|
|
436
|
+
heartbeatHandle = null;
|
|
437
|
+
/** Lifecycle event listeners (crash / destroyed / detached). */
|
|
438
|
+
lifecycleListeners = [];
|
|
439
|
+
constructor(options) {
|
|
440
|
+
this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
|
|
441
|
+
this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
|
|
442
|
+
this.totpSecret = options.totpSecret;
|
|
443
|
+
const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
|
|
444
|
+
this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
445
|
+
for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
|
|
446
|
+
this.buffers.set("Runtime.exceptionThrown", []);
|
|
447
|
+
this.emitter.setMaxListeners(0);
|
|
448
|
+
}
|
|
449
|
+
/** Refresh the attached-target list from the relay's `GET /targets`. */
|
|
450
|
+
async refreshTargets() {
|
|
451
|
+
let targetsUrl = `${this.relayBaseUrl}/targets`;
|
|
452
|
+
if (this.totpSecret) {
|
|
453
|
+
const code = generateTotp(this.totpSecret);
|
|
454
|
+
targetsUrl += `?at=${encodeURIComponent(code)}`;
|
|
455
|
+
}
|
|
456
|
+
const res = await fetch(targetsUrl);
|
|
457
|
+
if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
|
|
458
|
+
const body = await res.json();
|
|
459
|
+
const list = isObject(body) && Array.isArray(body.targets) ? body.targets : [];
|
|
460
|
+
let newestTargetId = null;
|
|
461
|
+
for (const item of list) {
|
|
462
|
+
if (!isObject(item) || typeof item.id !== "string") continue;
|
|
463
|
+
newestTargetId = item.id;
|
|
464
|
+
}
|
|
465
|
+
if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
|
|
466
|
+
const prevId = this.activeTargetId;
|
|
467
|
+
logInfo("page.detached", { prevTargetId: prevId });
|
|
468
|
+
this.evictTarget(prevId);
|
|
469
|
+
}
|
|
470
|
+
this.targets.clear();
|
|
471
|
+
for (const item of list) {
|
|
472
|
+
if (!isObject(item) || typeof item.id !== "string") continue;
|
|
473
|
+
if (item.id !== newestTargetId) continue;
|
|
474
|
+
this.targets.set(item.id, {
|
|
475
|
+
id: item.id,
|
|
476
|
+
title: typeof item.title === "string" ? item.title : "",
|
|
477
|
+
url: typeof item.url === "string" ? item.url : ""
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (newestTargetId !== null) this.activeTargetId = newestTargetId;
|
|
481
|
+
else this.activeTargetId = null;
|
|
482
|
+
const result = [...this.targets.values()];
|
|
483
|
+
if (newestTargetId !== null) this.emitter.emit("target:attached", result);
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
listTargets() {
|
|
487
|
+
return [...this.targets.values()];
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Waits until at least one target matching `filterFn` is attached, then
|
|
491
|
+
* resolves with the full target list at that moment.
|
|
492
|
+
*
|
|
493
|
+
* Resolution happens on whichever comes first:
|
|
494
|
+
* (a) a `'target:attached'` event from `refreshTargets()` (triggered by
|
|
495
|
+
* the /targets poll finding a new target), OR
|
|
496
|
+
* (b) a `'target:attached'` event from `handleMessage()` (triggered by
|
|
497
|
+
* the first inbound CDP message from a target — confirms the relay
|
|
498
|
+
* websocket has data from the phone, not just a target entry in the map).
|
|
499
|
+
*
|
|
500
|
+
* This dual-signal approach eliminates the polling race that previously
|
|
501
|
+
* caused `wait_for_attach` to resolve before the first CDP message arrived.
|
|
502
|
+
*
|
|
503
|
+
* Falls back to checking `listTargets()` every `pollIntervalMs` in case the
|
|
504
|
+
* EventEmitter is missed (defensive belt-and-suspenders).
|
|
505
|
+
*
|
|
506
|
+
* @param filterFn - Predicate that the returned targets must satisfy.
|
|
507
|
+
* @param timeoutMs - Reject after this many ms (default 90 000). Pass a
|
|
508
|
+
* non-finite value (`Infinity`) to disable the rejection timer entirely —
|
|
509
|
+
* used by the test-runner's unbounded QR-attach wait (devtools#735). Node
|
|
510
|
+
* clamps `setTimeout(fn, Infinity)` to ~1ms, so a non-finite value MUST
|
|
511
|
+
* skip arming the timer rather than pass it through.
|
|
512
|
+
* @param pollIntervalMs - Fallback poll interval (default 500ms).
|
|
513
|
+
*/
|
|
514
|
+
waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
|
|
515
|
+
const current = this.listTargets();
|
|
516
|
+
if (filterFn(current)) return Promise.resolve(current);
|
|
517
|
+
return new Promise((resolve, reject) => {
|
|
518
|
+
let settled = false;
|
|
519
|
+
let pollHandle = null;
|
|
520
|
+
const settle = (targets) => {
|
|
521
|
+
if (settled) return;
|
|
522
|
+
settled = true;
|
|
523
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
524
|
+
if (pollHandle !== null) {
|
|
525
|
+
clearInterval(pollHandle);
|
|
526
|
+
pollHandle = null;
|
|
527
|
+
}
|
|
528
|
+
this.emitter.off("target:attached", onAttach);
|
|
529
|
+
resolve(targets);
|
|
530
|
+
};
|
|
531
|
+
const onAttach = (targets) => {
|
|
532
|
+
if (filterFn(targets)) settle(targets);
|
|
533
|
+
};
|
|
534
|
+
const timeoutHandle = Number.isFinite(timeoutMs) ? setTimeout(() => {
|
|
535
|
+
if (settled) return;
|
|
536
|
+
settled = true;
|
|
537
|
+
if (pollHandle !== null) {
|
|
538
|
+
clearInterval(pollHandle);
|
|
539
|
+
pollHandle = null;
|
|
540
|
+
}
|
|
541
|
+
this.emitter.off("target:attached", onAttach);
|
|
542
|
+
reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
|
|
543
|
+
}, timeoutMs) : null;
|
|
544
|
+
this.emitter.on("target:attached", onAttach);
|
|
545
|
+
pollHandle = setInterval(() => {
|
|
546
|
+
this.refreshTargets().then((targets) => {
|
|
547
|
+
if (filterFn(targets)) settle(targets);
|
|
548
|
+
}, () => {});
|
|
549
|
+
}, pollIntervalMs);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Timestamp (ms since epoch) of the most recent crash/destroy/detach event
|
|
554
|
+
* detected since the last `enableDomains()` call, or `null` if none.
|
|
555
|
+
*/
|
|
556
|
+
getLastCrashDetectedAt() {
|
|
557
|
+
return this.lastCrashDetectedAt;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Last-seen timestamp (ms since epoch) for a given target id, or `null` if
|
|
561
|
+
* the target is unknown / no message has been received from it yet.
|
|
562
|
+
*/
|
|
563
|
+
getTargetLastSeenAt(targetId) {
|
|
564
|
+
return this.targetLastSeenAt.get(targetId) ?? null;
|
|
565
|
+
}
|
|
566
|
+
/** Subscribe to target lifecycle events (crash / destroyed / detached). */
|
|
567
|
+
onLifecycle(listener) {
|
|
568
|
+
this.lifecycleListeners.push(listener);
|
|
569
|
+
return () => {
|
|
570
|
+
const idx = this.lifecycleListeners.indexOf(listener);
|
|
571
|
+
if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Connect a client websocket to the first attached target and enable Phase 1
|
|
576
|
+
* domains. Resolves once the socket is open and enable commands are sent.
|
|
577
|
+
*/
|
|
578
|
+
async enableDomains() {
|
|
579
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
|
580
|
+
if (this.enablingPromise) return this.enablingPromise;
|
|
581
|
+
this.enablingPromise = this._doEnableDomains().finally(() => {
|
|
582
|
+
this.enablingPromise = null;
|
|
583
|
+
});
|
|
584
|
+
return this.enablingPromise;
|
|
585
|
+
}
|
|
586
|
+
async _doEnableDomains() {
|
|
587
|
+
const target = (await this.refreshTargets())[0];
|
|
588
|
+
if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
|
|
589
|
+
let clientUrl = `${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`;
|
|
590
|
+
if (this.totpSecret) {
|
|
591
|
+
const code = generateTotp(this.totpSecret);
|
|
592
|
+
clientUrl += `&at=${encodeURIComponent(code)}`;
|
|
593
|
+
}
|
|
594
|
+
const ws = new WebSocket(clientUrl);
|
|
595
|
+
this.ws = ws;
|
|
596
|
+
await new Promise((resolve, reject) => {
|
|
597
|
+
ws.once("open", () => resolve());
|
|
598
|
+
ws.once("error", (err) => reject(err));
|
|
599
|
+
ws.once("close", (code) => {
|
|
600
|
+
if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
this.lastCrashDetectedAt = null;
|
|
604
|
+
this.targetLastSeenAt.clear();
|
|
605
|
+
this.connectionState = "connected";
|
|
606
|
+
ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
607
|
+
ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
|
|
608
|
+
ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
|
|
609
|
+
this.sendFireAndForget("Runtime.enable");
|
|
610
|
+
this.sendFireAndForget("Network.enable");
|
|
611
|
+
this.sendFireAndForget("DOM.enable");
|
|
612
|
+
this.sendFireAndForget("Page.enable");
|
|
613
|
+
this.sendFireAndForget("Inspector.enable");
|
|
614
|
+
this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
|
|
615
|
+
this.startHeartbeat(target.id);
|
|
616
|
+
}
|
|
617
|
+
/** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
|
|
618
|
+
sendFireAndForget(method, params = {}) {
|
|
619
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
620
|
+
const id = this.nextCommandId++;
|
|
621
|
+
this.ws.send(JSON.stringify({
|
|
622
|
+
id,
|
|
623
|
+
method,
|
|
624
|
+
params
|
|
625
|
+
}));
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
|
|
629
|
+
* error frame or when no websocket is open (no page attached yet).
|
|
630
|
+
*/
|
|
631
|
+
send(method, params) {
|
|
632
|
+
return this.sendCommand(method, params ?? {});
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Issue an arbitrary request→response command over the relay and resolve with
|
|
636
|
+
* its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
|
|
637
|
+
* `AIT.*` methods, forwarded over the same Chii channel) build on this.
|
|
638
|
+
*
|
|
639
|
+
* Rejects immediately if the connection is disconnected (fail-fast — no
|
|
640
|
+
* auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
|
|
641
|
+
* reattach.
|
|
642
|
+
*
|
|
643
|
+
* Times out after `commandTimeoutMs` (default 30s, env
|
|
644
|
+
* `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
|
|
645
|
+
* and the promise rejects with a descriptive Korean error.
|
|
646
|
+
*/
|
|
647
|
+
sendCommand(method, params = {}) {
|
|
648
|
+
if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
|
|
649
|
+
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."));
|
|
650
|
+
const id = this.nextCommandId++;
|
|
651
|
+
const ws = this.ws;
|
|
652
|
+
const timeoutMs = this.commandTimeoutMs;
|
|
653
|
+
return new Promise((resolve, reject) => {
|
|
654
|
+
const handle = setTimeout(() => {
|
|
655
|
+
this.pending.delete(id);
|
|
656
|
+
reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
|
|
657
|
+
}, timeoutMs);
|
|
658
|
+
this.pending.set(id, {
|
|
659
|
+
resolve: (v) => {
|
|
660
|
+
clearTimeout(handle);
|
|
661
|
+
resolve(v);
|
|
662
|
+
},
|
|
663
|
+
reject: (e) => {
|
|
664
|
+
clearTimeout(handle);
|
|
665
|
+
reject(e);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
ws.send(JSON.stringify({
|
|
669
|
+
id,
|
|
670
|
+
method,
|
|
671
|
+
params
|
|
672
|
+
}));
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Called on WebSocket `close` or `error` after a successful connection.
|
|
677
|
+
* Rejects all pending commands and marks the connection as disconnected so
|
|
678
|
+
* subsequent `sendCommand` calls fail fast (no auto-reconnect).
|
|
679
|
+
*/
|
|
680
|
+
handleDisconnect(reason) {
|
|
681
|
+
if (this.connectionState === "disconnected") return;
|
|
682
|
+
this.connectionState = "disconnected";
|
|
683
|
+
this.ws = null;
|
|
684
|
+
this.stopHeartbeat();
|
|
685
|
+
const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
|
|
686
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
687
|
+
this.pending.clear();
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Evict a previously active target under the single-attach model.
|
|
691
|
+
* Rejects pending commands with a 'replaced-by-new-attach' reason and emits
|
|
692
|
+
* a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
|
|
693
|
+
* targetId. The caller is responsible for rebuilding the targets map afterwards.
|
|
694
|
+
*
|
|
695
|
+
* The error message uses 'replaced-by-new-attach' so test assertions can match it.
|
|
696
|
+
*/
|
|
697
|
+
evictTarget(targetId) {
|
|
698
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
699
|
+
this.targets.delete(targetId);
|
|
700
|
+
this.targetLastSeenAt.delete(targetId);
|
|
701
|
+
const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
|
|
702
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
703
|
+
this.pending.clear();
|
|
704
|
+
const event = {
|
|
705
|
+
kind: "replaced",
|
|
706
|
+
targetId,
|
|
707
|
+
detectedAt
|
|
708
|
+
};
|
|
709
|
+
for (const listener of this.lifecycleListeners) try {
|
|
710
|
+
listener(event);
|
|
711
|
+
} catch {}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Handle a page-level crash or target destruction event.
|
|
715
|
+
* Removes the target from the in-memory map, rejects all pending commands,
|
|
716
|
+
* and emits a lifecycle event.
|
|
717
|
+
*
|
|
718
|
+
* @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
|
|
719
|
+
* @param targetId - The target ID from the event params (may be null for
|
|
720
|
+
* Inspector.targetCrashed which has no targetId in the params).
|
|
721
|
+
*/
|
|
722
|
+
handleTargetGone(kind, targetId) {
|
|
723
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
724
|
+
this.lastCrashDetectedAt = Date.now();
|
|
725
|
+
if (targetId !== null) {
|
|
726
|
+
this.targets.delete(targetId);
|
|
727
|
+
this.targetLastSeenAt.delete(targetId);
|
|
728
|
+
if (this.activeTargetId === targetId) this.activeTargetId = null;
|
|
729
|
+
} else {
|
|
730
|
+
this.targets.clear();
|
|
731
|
+
this.targetLastSeenAt.clear();
|
|
732
|
+
this.activeTargetId = null;
|
|
733
|
+
}
|
|
734
|
+
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()로 재연결).`);
|
|
735
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
736
|
+
this.pending.clear();
|
|
737
|
+
const event = {
|
|
738
|
+
kind,
|
|
739
|
+
targetId,
|
|
740
|
+
detectedAt
|
|
741
|
+
};
|
|
742
|
+
for (const listener of this.lifecycleListeners) try {
|
|
743
|
+
listener(event);
|
|
744
|
+
} catch {}
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Start the optional CDP heartbeat loop.
|
|
748
|
+
*
|
|
749
|
+
* When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
|
|
750
|
+
* we send `Runtime.evaluate({expression: '1'})` to each active target. If
|
|
751
|
+
* the command times out (2 s hard deadline) or errors, we treat the target
|
|
752
|
+
* as dead and call `handleTargetGone`.
|
|
753
|
+
*
|
|
754
|
+
* This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
|
|
755
|
+
* even when the phone app has crashed, so the ws-level disconnect (#252) won't
|
|
756
|
+
* fire. The heartbeat catches this gap.
|
|
757
|
+
*
|
|
758
|
+
* Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
|
|
759
|
+
*/
|
|
760
|
+
startHeartbeat(initialTargetId) {
|
|
761
|
+
this.stopHeartbeat();
|
|
762
|
+
const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
|
|
763
|
+
if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
|
|
764
|
+
const PING_TIMEOUT_MS = 2e3;
|
|
765
|
+
this.heartbeatHandle = setInterval(() => {
|
|
766
|
+
const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
|
|
767
|
+
for (const targetId of targetIds) {
|
|
768
|
+
const pingPromise = this.sendCommand("Runtime.evaluate", {
|
|
769
|
+
expression: "1",
|
|
770
|
+
returnByValue: true,
|
|
771
|
+
timeout: PING_TIMEOUT_MS
|
|
772
|
+
});
|
|
773
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
|
|
774
|
+
Promise.race([pingPromise, timeoutPromise]).catch(() => {
|
|
775
|
+
if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}, envMs);
|
|
779
|
+
}
|
|
780
|
+
stopHeartbeat() {
|
|
781
|
+
if (this.heartbeatHandle !== null) {
|
|
782
|
+
clearInterval(this.heartbeatHandle);
|
|
783
|
+
this.heartbeatHandle = null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
handleMessage(raw) {
|
|
787
|
+
const message = parseInbound(raw);
|
|
788
|
+
if (!message) return;
|
|
789
|
+
if (typeof message.id === "number" && this.pending.has(message.id)) {
|
|
790
|
+
const waiter = this.pending.get(message.id);
|
|
791
|
+
this.pending.delete(message.id);
|
|
792
|
+
if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
|
|
793
|
+
else waiter.resolve(message.result);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const now = Date.now();
|
|
797
|
+
let firstMessageSeen = false;
|
|
798
|
+
for (const targetId of this.targets.keys()) {
|
|
799
|
+
if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
|
|
800
|
+
this.targetLastSeenAt.set(targetId, now);
|
|
801
|
+
}
|
|
802
|
+
if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
|
|
803
|
+
if (typeof message.method !== "string") return;
|
|
804
|
+
if (message.method === "Inspector.targetCrashed") {
|
|
805
|
+
this.handleTargetGone("crashed", null);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (message.method === "Target.targetDestroyed") {
|
|
809
|
+
const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
810
|
+
this.handleTargetGone("destroyed", targetId);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (message.method === "Target.detachedFromTarget") {
|
|
814
|
+
const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
815
|
+
this.handleTargetGone("detached", targetId);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (!this.buffers.has(message.method)) return;
|
|
819
|
+
const event = message.method;
|
|
820
|
+
const buffer = this.buffers.get(event);
|
|
821
|
+
if (!buffer) return;
|
|
822
|
+
buffer.push(message.params);
|
|
823
|
+
const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
|
|
824
|
+
if (buffer.length > cap) buffer.shift();
|
|
825
|
+
this.emitter.emit(event, message.params);
|
|
826
|
+
}
|
|
827
|
+
getBufferedEvents(event) {
|
|
828
|
+
return this.buffers.get(event) ?? [];
|
|
829
|
+
}
|
|
830
|
+
on(event, listener) {
|
|
831
|
+
this.emitter.on(event, listener);
|
|
832
|
+
return () => this.emitter.off(event, listener);
|
|
833
|
+
}
|
|
834
|
+
/** Close the relay client websocket and reject any in-flight commands. */
|
|
835
|
+
close() {
|
|
836
|
+
const ws = this.ws;
|
|
837
|
+
this.stopHeartbeat();
|
|
838
|
+
this.handleDisconnect("Chii relay connection closed");
|
|
839
|
+
ws?.close();
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
//#endregion
|
|
160
843
|
//#region src/test-runner/bundle.ts
|
|
161
844
|
/**
|
|
162
845
|
* esbuild-based bundler for user test files.
|
|
@@ -678,8 +1361,37 @@ async function runTestFilesOverRelay(connection, files, opts) {
|
|
|
678
1361
|
if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
|
|
679
1362
|
liveConsole.push(event);
|
|
680
1363
|
});
|
|
1364
|
+
let pendingReconnectCheck = false;
|
|
1365
|
+
/**
|
|
1366
|
+
* Attempts one `enableDomains()` reconnect. Logs the attempt and outcome to
|
|
1367
|
+
* stderr. Never throws — a failed reconnect just means the caller proceeds
|
|
1368
|
+
* as today (each remaining file / the retry will fail-fast on the still-dead
|
|
1369
|
+
* socket).
|
|
1370
|
+
*
|
|
1371
|
+
* `context` selects the fixed log literal: 'next-file' for the between-files
|
|
1372
|
+
* case (a file's FINAL result was a WS-dead-class error — the primary #731
|
|
1373
|
+
* scenario), 'retry-precheck' for the defensive reconnect before retrying a
|
|
1374
|
+
* timed-out file (cheap no-op via `enableDomains()`'s idempotency when the
|
|
1375
|
+
* socket never actually died).
|
|
1376
|
+
*
|
|
1377
|
+
* SECRET-HANDLING: fixed literals only — no relay wss URL, TOTP code, or
|
|
1378
|
+
* tunnel host.
|
|
1379
|
+
*/
|
|
1380
|
+
const attemptReconnect = async (context) => {
|
|
1381
|
+
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");
|
|
1382
|
+
try {
|
|
1383
|
+
await connection.enableDomains();
|
|
1384
|
+
process.stderr.write("relay-worker: reconnect succeeded\n");
|
|
1385
|
+
} catch (e) {
|
|
1386
|
+
process.stderr.write(`relay-worker: reconnect failed (${e instanceof Error ? e.message : String(e)}) — continuing, remaining files may fail\n`);
|
|
1387
|
+
}
|
|
1388
|
+
};
|
|
681
1389
|
try {
|
|
682
1390
|
for (const file of files) {
|
|
1391
|
+
if (pendingReconnectCheck) {
|
|
1392
|
+
await attemptReconnect("next-file");
|
|
1393
|
+
pendingReconnectCheck = false;
|
|
1394
|
+
}
|
|
683
1395
|
let fileEntry;
|
|
684
1396
|
try {
|
|
685
1397
|
const { code } = await bundleTestFile(file, opts?.bundleOptions);
|
|
@@ -690,14 +1402,17 @@ async function runTestFilesOverRelay(connection, files, opts) {
|
|
|
690
1402
|
* We need to distinguish:
|
|
691
1403
|
* - `rpcResult.ok = false` + timeout error → retry candidate (`return null`)
|
|
692
1404
|
* - `rpcResult.ok = false` + other error → final error, no retry
|
|
693
|
-
* - `injectAndRunBundle` throws → CDP exceptionDetails
|
|
694
|
-
*
|
|
1405
|
+
* - `injectAndRunBundle` throws → CDP exceptionDetails OR a
|
|
1406
|
+
* relay-disconnect rejection from `connection.send()` (page engine
|
|
1407
|
+
* threw, or the ws died mid-evaluate); treated as a final
|
|
1408
|
+
* (non-retryable) error either way.
|
|
695
1409
|
*
|
|
696
1410
|
* The Promise.race timeout in rpc.ts RETURNS `{ok:false, error: '…'}` (it
|
|
697
|
-
* does NOT throw/reject). Only genuine CDP `exceptionDetails`
|
|
698
|
-
* This distinction is what makes the
|
|
699
|
-
* reachable — the timeout result
|
|
700
|
-
* marker string, not as a
|
|
1411
|
+
* does NOT throw/reject). Only genuine CDP `exceptionDetails` (or a dead
|
|
1412
|
+
* `connection.send()`) cause a throw. This distinction is what makes the
|
|
1413
|
+
* EVALUATE_TIMEOUT_MARKER gate below reachable — the timeout result
|
|
1414
|
+
* surfaces as `rpcResult.ok=false` with the marker string, not as a
|
|
1415
|
+
* caught exception.
|
|
701
1416
|
*/
|
|
702
1417
|
const attempt = async () => {
|
|
703
1418
|
let rpcResult;
|
|
@@ -722,12 +1437,13 @@ async function runTestFilesOverRelay(connection, files, opts) {
|
|
|
722
1437
|
const firstResult = await attempt();
|
|
723
1438
|
if (firstResult !== null) fileEntry = firstResult;
|
|
724
1439
|
else {
|
|
1440
|
+
await attemptReconnect("retry-precheck");
|
|
725
1441
|
process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\n`);
|
|
726
1442
|
const retryResult = await attempt();
|
|
727
1443
|
if (retryResult !== null) fileEntry = retryResult;
|
|
728
1444
|
else fileEntry = {
|
|
729
1445
|
file,
|
|
730
|
-
result: { error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ??
|
|
1446
|
+
result: { error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 6e4}ms (after retry)` }
|
|
731
1447
|
};
|
|
732
1448
|
}
|
|
733
1449
|
} catch (e) {
|
|
@@ -736,6 +1452,7 @@ async function runTestFilesOverRelay(connection, files, opts) {
|
|
|
736
1452
|
result: { error: e instanceof Error ? e.message : String(e) }
|
|
737
1453
|
};
|
|
738
1454
|
}
|
|
1455
|
+
if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
|
|
739
1456
|
fileResults.push(fileEntry);
|
|
740
1457
|
}
|
|
741
1458
|
} finally {
|
|
@@ -950,15 +1667,15 @@ USAGE
|
|
|
950
1667
|
OPTIONS
|
|
951
1668
|
--scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
|
|
952
1669
|
(required for standalone relay attach / env3)
|
|
953
|
-
--timeout <ms> Per-file evaluate timeout in ms (default:
|
|
1670
|
+
--timeout <ms> Per-file evaluate timeout in ms (default: 60000).
|
|
954
1671
|
Controls how long a single test file is allowed to run
|
|
955
1672
|
before it is considered hung. Does NOT affect how long
|
|
956
1673
|
the CLI waits for a human to scan the QR code — use
|
|
957
1674
|
--attach-timeout for that.
|
|
958
1675
|
--attach-timeout <ms> How long to wait for a human to scan the QR code with
|
|
959
|
-
their phone (default
|
|
960
|
-
|
|
961
|
-
|
|
1676
|
+
their phone. Omit (default) to wait indefinitely — the
|
|
1677
|
+
runner stays up until you stop it (Ctrl-C/SIGTERM).
|
|
1678
|
+
Pass a value to bound the wait for CI/headless runs.
|
|
962
1679
|
--cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
|
|
963
1680
|
--cell-platform <plat> Platform to inject as __AIT_CELL__.platform
|
|
964
1681
|
(mock|ios|android, default: AIT_CELL_PLATFORM env)
|
|
@@ -1006,7 +1723,7 @@ EXAMPLE
|
|
|
1006
1723
|
* Exported for unit testing — main() is the only other caller.
|
|
1007
1724
|
*/
|
|
1008
1725
|
function resolveTimeouts(rawTimeout, rawAttachTimeout) {
|
|
1009
|
-
const evaluateTimeoutMs = rawTimeout !== void 0 ? parseInt(rawTimeout, 10) :
|
|
1726
|
+
const evaluateTimeoutMs = rawTimeout !== void 0 ? parseInt(rawTimeout, 10) : 6e4;
|
|
1010
1727
|
if (Number.isNaN(evaluateTimeoutMs) || evaluateTimeoutMs <= 0) return "--timeout must be a positive integer";
|
|
1011
1728
|
const attachTimeoutMs = rawAttachTimeout !== void 0 ? parseInt(rawAttachTimeout, 10) : void 0;
|
|
1012
1729
|
if (attachTimeoutMs !== void 0 && (Number.isNaN(attachTimeoutMs) || attachTimeoutMs <= 0)) return "--attach-timeout must be a positive integer";
|
|
@@ -1196,6 +1913,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1196
1913
|
}
|
|
1197
1914
|
let exitCode = 0;
|
|
1198
1915
|
try {
|
|
1916
|
+
factory.onSessionPhase?.("running");
|
|
1199
1917
|
const report = await runWithConnection(connection, files, {
|
|
1200
1918
|
timeoutMs: evaluateTimeoutMs,
|
|
1201
1919
|
printSummary: true,
|
|
@@ -1215,6 +1933,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1215
1933
|
}
|
|
1216
1934
|
exitCode = report.totals.failed > 0 ? 1 : 0;
|
|
1217
1935
|
} finally {
|
|
1936
|
+
factory.onSessionPhase?.("complete");
|
|
1218
1937
|
await factory.close(connection);
|
|
1219
1938
|
process.exitCode = exitCode;
|
|
1220
1939
|
}
|
|
@@ -1247,6 +1966,6 @@ main().catch((e) => {
|
|
|
1247
1966
|
process.exitCode = 1;
|
|
1248
1967
|
});
|
|
1249
1968
|
//#endregion
|
|
1250
|
-
export {};
|
|
1969
|
+
export { RELAY_AUTH_REJECT_REASON as a, RELAY_AUTH_REJECT_CLOSE_CODE as i, logError as n, logInfo as r, ChiiCdpConnection as t };
|
|
1251
1970
|
|
|
1252
1971
|
//# sourceMappingURL=bin.js.map
|