@ait-co/devtools 0.1.137 → 0.1.138
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{attach-orchestrator-Cjdhir2U.js → attach-orchestrator-6kz_ZdWL.js} +5 -3
- package/dist/{attach-orchestrator-Cjdhir2U.js.map → attach-orchestrator-6kz_ZdWL.js.map} +1 -1
- package/dist/{attach-orchestrator-CE0S09YU.js → attach-orchestrator-BEiz3wAx.js} +5 -3
- package/dist/{attach-orchestrator-CMoDuG2A.js.map → attach-orchestrator-BEiz3wAx.js.map} +1 -1
- package/dist/{attach-orchestrator-CMoDuG2A.js → attach-orchestrator-BG8TGJOZ.js} +5 -3
- package/dist/{attach-orchestrator-CE0S09YU.js.map → attach-orchestrator-BG8TGJOZ.js.map} +1 -1
- package/dist/{bundle-BKqyhEK9.d.ts → bundle-C796JIwG.d.ts} +65 -2
- package/dist/bundle-C796JIwG.d.ts.map +1 -0
- package/dist/{capture-ltuV0gZa.d.ts → capture-DsP525OZ.d.ts} +1 -1
- package/dist/{capture-ltuV0gZa.d.ts.map → capture-DsP525OZ.d.ts.map} +1 -1
- package/dist/{cdp-connection-BYE9meXe.d.ts → cdp-connection-rP1WdnH5.d.ts} +1 -1
- package/dist/{cdp-connection-BYE9meXe.d.ts.map → cdp-connection-rP1WdnH5.d.ts.map} +1 -1
- package/dist/{cell-D1y4shoV.js → cell-BMlFh08P.js} +2 -2
- package/dist/cell-BMlFh08P.js.map +1 -0
- package/dist/{cell-DHA578lX.js → cell-Dtgtusog.js} +90 -16
- package/dist/cell-Dtgtusog.js.map +1 -0
- package/dist/{cell-BIdSQb9T.js → cell-KsRXFdDs.js} +122 -16
- package/dist/cell-KsRXFdDs.js.map +1 -0
- package/dist/{debug-server-CBK7dn8O.js → debug-server-6c22qSWa.js} +132 -9
- package/dist/debug-server-6c22qSWa.js.map +1 -0
- package/dist/{debug-server-BQplCzf8.js → debug-server-BQHbaKPT.js} +134 -11
- package/dist/debug-server-BQHbaKPT.js.map +1 -0
- package/dist/{debug-server-D2wpPK3_.js → debug-server-DWat7d-9.js} +82 -6
- package/dist/debug-server-DWat7d-9.js.map +1 -0
- package/dist/devtools-opener-3Drge_RJ.js +75 -0
- package/dist/devtools-opener-3Drge_RJ.js.map +1 -0
- package/dist/devtools-opener-CJpEsXXQ.js +76 -0
- package/dist/devtools-opener-CJpEsXXQ.js.map +1 -0
- package/dist/devtools-opener-CxtryS8c.js +75 -0
- package/dist/devtools-opener-CxtryS8c.js.map +1 -0
- package/dist/in-app/auto.js +59 -12
- package/dist/in-app/auto.js.map +1 -1
- package/dist/in-app/index.d.ts +21 -2
- package/dist/in-app/index.d.ts.map +1 -1
- package/dist/in-app/index.js +60 -13
- package/dist/in-app/index.js.map +1 -1
- package/dist/mcp/cli.js +299 -60
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mock/index.d.ts +64 -1
- package/dist/mock/index.d.ts.map +1 -1
- package/dist/mock/index.js +221 -14
- package/dist/mock/index.js.map +1 -1
- package/dist/panel/index.js +178 -23
- package/dist/panel/index.js.map +1 -1
- package/dist/{pool-CmfvXnwh.d.ts → pool-CrP5CPvU.d.ts} +3 -3
- package/dist/{pool-CmfvXnwh.d.ts.map → pool-CrP5CPvU.d.ts.map} +1 -1
- package/dist/{qr-http-server-ZkT6F7is.js → qr-http-server-D1hpoyBF.js} +1 -1
- package/dist/{qr-http-server-ZkT6F7is.js.map → qr-http-server-D1hpoyBF.js.map} +1 -1
- package/dist/qr-http-server-D4rGz8M7.js +1640 -0
- package/dist/qr-http-server-D4rGz8M7.js.map +1 -0
- package/dist/{qr-http-server-C7q8Gn04.js → qr-http-server-DIjCV7h_.js} +1 -1
- package/dist/{qr-http-server-C7q8Gn04.js.map → qr-http-server-DIjCV7h_.js.map} +1 -1
- package/dist/{relay-factory-DVzpBRxz.js → relay-factory-C1X3G3WY.js} +71 -15
- package/dist/relay-factory-C1X3G3WY.js.map +1 -0
- package/dist/{relay-secret-store-CLEGyHou.js → relay-secret-store-Bmyleu0A.js} +1 -1
- package/dist/{relay-secret-store-CLEGyHou.js.map → relay-secret-store-Bmyleu0A.js.map} +1 -1
- package/dist/{relay-secret-store-DhzAnnj-.js → relay-secret-store-CQenfcSL.js} +2 -2
- package/dist/{relay-secret-store-DhzAnnj-.js.map → relay-secret-store-CQenfcSL.js.map} +1 -1
- package/dist/{relay-secret-store-BcVrWwTq.js → relay-secret-store-DKxs7zwq.js} +1 -1
- package/dist/{relay-secret-store-BcVrWwTq.js.map → relay-secret-store-DKxs7zwq.js.map} +1 -1
- package/dist/{relay-secret-store-DGduVJhs.js → relay-secret-store-WJ8EGkIl.js} +1 -1
- package/dist/{relay-secret-store-DGduVJhs.js.map → relay-secret-store-WJ8EGkIl.js.map} +1 -1
- package/dist/{relay-url-store-CKW8RQzf.js → relay-url-store-BR2XodiO.js} +2 -2
- package/dist/{relay-url-store-CKW8RQzf.js.map → relay-url-store-BR2XodiO.js.map} +1 -1
- package/dist/{relay-url-store-CdA58fgw.js → relay-url-store-CH63fVCm.js} +2 -2
- package/dist/{relay-url-store-B0X8TsGr.js.map → relay-url-store-CH63fVCm.js.map} +1 -1
- package/dist/{relay-url-store-CwKT7i04.js → relay-url-store-DaY1QPes.js} +2 -2
- package/dist/{relay-url-store-CwKT7i04.js.map → relay-url-store-DaY1QPes.js.map} +1 -1
- package/dist/{relay-url-store-B0X8TsGr.js → relay-url-store-xmUuTjXA.js} +2 -2
- package/dist/{relay-url-store-CdA58fgw.js.map → relay-url-store-xmUuTjXA.js.map} +1 -1
- package/dist/{relay-worker-6fy1eaIo.js → relay-worker-BUXI0K0b.js} +8 -4
- package/dist/{relay-worker-6fy1eaIo.js.map → relay-worker-BUXI0K0b.js.map} +1 -1
- package/dist/{relay-worker-DJnZkXza.d.ts → relay-worker-DcyboK43.d.ts} +30 -5
- package/dist/relay-worker-DcyboK43.d.ts.map +1 -0
- package/dist/{runtime-DfHHZms2.d.ts → runtime-BiigOuvb.d.ts} +30 -3
- package/dist/runtime-BiigOuvb.d.ts.map +1 -0
- package/dist/test-runner/bin.js +376 -38
- package/dist/test-runner/bin.js.map +1 -1
- package/dist/test-runner/bundle.d.ts +2 -2
- package/dist/test-runner/bundle.js +66 -13
- package/dist/test-runner/bundle.js.map +1 -1
- package/dist/test-runner/capture.d.ts +1 -1
- package/dist/test-runner/config.d.ts +58 -5
- package/dist/test-runner/config.d.ts.map +1 -1
- package/dist/test-runner/config.js +1 -1
- package/dist/test-runner/method-pace.d.ts +82 -0
- package/dist/test-runner/method-pace.d.ts.map +1 -0
- package/dist/test-runner/method-pace.js +120 -0
- package/dist/test-runner/method-pace.js.map +1 -0
- package/dist/test-runner/pool.d.ts +1 -1
- package/dist/test-runner/pool.js +1 -1
- package/dist/test-runner/relay-factory.d.ts +57 -4
- package/dist/test-runner/relay-factory.d.ts.map +1 -1
- package/dist/test-runner/relay-factory.js +70 -14
- 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 -1
- 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 +2 -2
- package/dist/test-runner/runtime.js +83 -6
- package/dist/test-runner/runtime.js.map +1 -1
- package/dist/test-runner/task-graph.d.ts +1 -1
- package/dist/throttle-DKKzX1qC.js +59 -0
- package/dist/throttle-DKKzX1qC.js.map +1 -0
- package/dist/totp-Dwft0Kz7.js +3 -0
- package/package.json +1 -1
- package/dist/bundle-BKqyhEK9.d.ts.map +0 -1
- package/dist/cell-BIdSQb9T.js.map +0 -1
- package/dist/cell-D1y4shoV.js.map +0 -1
- package/dist/cell-DHA578lX.js.map +0 -1
- package/dist/debug-server-BQplCzf8.js.map +0 -1
- package/dist/debug-server-CBK7dn8O.js.map +0 -1
- package/dist/debug-server-D2wpPK3_.js.map +0 -1
- package/dist/relay-factory-DVzpBRxz.js.map +0 -1
- package/dist/relay-worker-DJnZkXza.d.ts.map +0 -1
- package/dist/runtime-DfHHZms2.d.ts.map +0 -1
- package/dist/totp-D1pulXLa.js +0 -3
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
//#region src/test-runner/relay-factory.ts
|
|
2
2
|
/**
|
|
3
|
-
* Builds a {@link RelayConnectionFactory} that opens a standalone
|
|
4
|
-
* connection
|
|
3
|
+
* Builds a {@link RelayConnectionFactory} that opens a standalone relay
|
|
4
|
+
* connection for env 3 (intoss-private scheme, default) or env 2 (AITC Sandbox
|
|
5
|
+
* PWA launcher deep-link, when `opts.attachLauncher` is set — devtools#774).
|
|
5
6
|
*
|
|
6
7
|
* `open()` performs the full attach lifecycle and BLOCKS while a human scans
|
|
7
|
-
* the rendered QR with their phone — there is no way around the manual scan
|
|
8
|
-
*
|
|
8
|
+
* the rendered QR with their phone — there is no way around the manual scan.
|
|
9
|
+
* By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the
|
|
9
10
|
* runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is
|
|
10
11
|
* a human-paced action with no sound default bound (devtools#735). Passing an
|
|
11
12
|
* explicit `timeoutMs` opts into the old bounded behavior (CI/headless
|
|
@@ -22,14 +23,17 @@ function createRelayConnectionFactory(opts) {
|
|
|
22
23
|
const headless = opts.headless === true;
|
|
23
24
|
let family;
|
|
24
25
|
let qrServer;
|
|
26
|
+
let attachWatcher;
|
|
25
27
|
let phase = "active";
|
|
26
28
|
let manualPrompt = null;
|
|
27
29
|
return {
|
|
28
30
|
async open() {
|
|
29
|
-
const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-
|
|
30
|
-
const { injectDebugIndicator, injectGlobals } = await import("../cell-
|
|
31
|
-
const { loadRelaySecretReadOnly } = await import("../relay-secret-store-
|
|
32
|
-
const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-
|
|
31
|
+
const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-6kz_ZdWL.js");
|
|
32
|
+
const { injectDebugIndicator, injectGlobals } = await import("../cell-BMlFh08P.js");
|
|
33
|
+
const { loadRelaySecretReadOnly } = await import("../relay-secret-store-DKxs7zwq.js");
|
|
34
|
+
const { bootRelayFamily, buildRelayVerifyAuth, startAttachWatcher } = await import("../debug-server-6c22qSWa.js");
|
|
35
|
+
const { buildChiiInspectorUrl } = await import("../devtools-opener-3Drge_RJ.js");
|
|
36
|
+
const { generateTotp } = await import("../totp-DfekTBk3.js").then((n) => n.i);
|
|
33
37
|
await loadRelaySecretReadOnly({ projectRoot });
|
|
34
38
|
let resolveTunnelUp;
|
|
35
39
|
const tunnelReady = new Promise((resolve) => {
|
|
@@ -59,29 +63,73 @@ function createRelayConnectionFactory(opts) {
|
|
|
59
63
|
canOpenBrowser: () => !headless
|
|
60
64
|
};
|
|
61
65
|
try {
|
|
62
|
-
const { startQrHttpServer } = await import("../qr-http-server-
|
|
66
|
+
const { startQrHttpServer } = await import("../qr-http-server-D1hpoyBF.js");
|
|
63
67
|
const getDashboardState = () => ({
|
|
64
68
|
tunnel: attachDeps.getTunnelStatus(),
|
|
65
|
-
pages:
|
|
69
|
+
pages: booted.connection.listTargets().map((t) => ({
|
|
70
|
+
id: t.id,
|
|
71
|
+
url: t.url
|
|
72
|
+
})),
|
|
66
73
|
attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,
|
|
67
|
-
mode: "relay-dev",
|
|
74
|
+
mode: opts.attachLauncher === true ? "relay-mobile" : "relay-dev",
|
|
68
75
|
phase,
|
|
69
76
|
manualPrompt
|
|
70
77
|
});
|
|
71
|
-
|
|
78
|
+
const getDirectInspectorUrl = () => {
|
|
79
|
+
if (!booted.relayHttpUrl) return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: "relayDown"
|
|
82
|
+
};
|
|
83
|
+
const targets = booted.connection.listTargets();
|
|
84
|
+
if (targets.length === 0) return {
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: "noTarget"
|
|
87
|
+
};
|
|
88
|
+
const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
89
|
+
if (!totpSecret) return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: "totpUnavailable"
|
|
92
|
+
};
|
|
93
|
+
const url = buildChiiInspectorUrl(booted.relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
|
|
94
|
+
if (url === null) return {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason: "totpUnavailable"
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
url
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
qrServer = await startQrHttpServer(getDashboardState, {
|
|
104
|
+
dashboardPort: opts.dashboardPort,
|
|
105
|
+
getDirectInspectorUrl
|
|
106
|
+
});
|
|
72
107
|
attachDeps.qrHttpServer = qrServer;
|
|
73
108
|
attachDeps.onAttachUrlBuilt = (parts) => {
|
|
74
109
|
lastAttachParts = parts;
|
|
75
110
|
qrServer?.notifyStateChange();
|
|
76
111
|
};
|
|
112
|
+
attachWatcher = startAttachWatcher(booted.connection, void 0, 1e3, () => qrServer?.notifyStateChange(), () => qrServer?.notifyStateChange());
|
|
77
113
|
process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\n`);
|
|
78
114
|
} catch {}
|
|
79
115
|
const TUNNEL_BOOT_TIMEOUT_MS = 15e3;
|
|
80
116
|
await Promise.race([tunnelReady, new Promise((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS))]);
|
|
81
|
-
|
|
117
|
+
let prep;
|
|
118
|
+
if (opts.attachLauncher === true) {
|
|
119
|
+
const priorTunnelBaseUrl = process.env.AIT_TUNNEL_BASE_URL;
|
|
120
|
+
process.env.AIT_TUNNEL_BASE_URL = opts.appUrl ?? "";
|
|
121
|
+
try {
|
|
122
|
+
prep = await prepareAttach(attachDeps, "relay-mobile", {}, booted.connection);
|
|
123
|
+
} finally {
|
|
124
|
+
if (priorTunnelBaseUrl === void 0) delete process.env.AIT_TUNNEL_BASE_URL;
|
|
125
|
+
else process.env.AIT_TUNNEL_BASE_URL = priorTunnelBaseUrl;
|
|
126
|
+
}
|
|
127
|
+
} else prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
|
|
82
128
|
if (!prep.ok) {
|
|
83
129
|
booted.stop();
|
|
84
130
|
family = void 0;
|
|
131
|
+
attachWatcher?.stop();
|
|
132
|
+
attachWatcher = void 0;
|
|
85
133
|
await qrServer?.close();
|
|
86
134
|
qrServer = void 0;
|
|
87
135
|
throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
|
|
@@ -90,6 +138,8 @@ function createRelayConnectionFactory(opts) {
|
|
|
90
138
|
if (waitResult.isError) {
|
|
91
139
|
booted.stop();
|
|
92
140
|
family = void 0;
|
|
141
|
+
attachWatcher?.stop();
|
|
142
|
+
attachWatcher = void 0;
|
|
93
143
|
await qrServer?.close();
|
|
94
144
|
qrServer = void 0;
|
|
95
145
|
const timeoutSec = Math.round(timeoutMs / 1e3);
|
|
@@ -111,6 +161,8 @@ function createRelayConnectionFactory(opts) {
|
|
|
111
161
|
if (lastEnableError !== void 0) {
|
|
112
162
|
booted.stop();
|
|
113
163
|
family = void 0;
|
|
164
|
+
attachWatcher?.stop();
|
|
165
|
+
attachWatcher = void 0;
|
|
114
166
|
await qrServer?.close();
|
|
115
167
|
qrServer = void 0;
|
|
116
168
|
throw new Error(`createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round(PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS / 1e3)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`);
|
|
@@ -118,6 +170,8 @@ function createRelayConnectionFactory(opts) {
|
|
|
118
170
|
await injectDebugIndicator(booted.connection);
|
|
119
171
|
if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
|
|
120
172
|
if (opts.stubBlocking === true) await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });
|
|
173
|
+
if (opts.paceMs !== void 0 && opts.paceMs > 0) await injectGlobals(booted.connection, { __AIT_PACE_MS__: opts.paceMs });
|
|
174
|
+
if (opts.paceMethodMs !== void 0 && opts.paceMethodMs > 0) await injectGlobals(booted.connection, { __AIT_PACE_METHOD_MS__: opts.paceMethodMs });
|
|
121
175
|
return booted.connection;
|
|
122
176
|
},
|
|
123
177
|
onSessionPhase(next) {
|
|
@@ -134,11 +188,13 @@ function createRelayConnectionFactory(opts) {
|
|
|
134
188
|
qrServer?.notifyStateChange();
|
|
135
189
|
}
|
|
136
190
|
try {
|
|
137
|
-
const { injectDebugIndicator } = await import("../cell-
|
|
191
|
+
const { injectDebugIndicator } = await import("../cell-BMlFh08P.js");
|
|
138
192
|
await injectDebugIndicator(connection, { state: "disconnected" });
|
|
139
193
|
} catch {}
|
|
140
194
|
family?.stop();
|
|
141
195
|
family = void 0;
|
|
196
|
+
attachWatcher?.stop();
|
|
197
|
+
attachWatcher = void 0;
|
|
142
198
|
await qrServer?.close();
|
|
143
199
|
qrServer = void 0;
|
|
144
200
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-factory.js","names":[],"sources":["../../src/test-runner/relay-factory.ts"],"sourcesContent":["/**\n * Relay connection factory for the Vitest custom pool (devtools#696).\n *\n * The Vitest pool (`pool.ts`) takes a {@link RelayConnectionFactory} that knows\n * how to `open()` a live CDP relay connection (boot relay → QR → phone scan →\n * cell inject → enableDomains) and `close()` it. Before this module that exact\n * assembly lived only inside the `devtools-test` CLI's `main()`; the standalone\n * CLI and the Vitest pool would otherwise each hand-roll it and drift.\n *\n * This is the single source of that assembly. `cli.ts` is refactored to call\n * `createRelayConnectionFactory(...).open()`, and `definePhoneVitestConfig`\n * (config.ts) exposes it so a downstream `vitest.config.ts` can wire the pool:\n *\n * import { createRelayConnectionFactory } from '@ait-co/devtools/test-runner';\n * const connection = createRelayConnectionFactory({\n * schemeUrl,\n * cell,\n * // REQUIRED — own the stdout decision (the chunks carry the relay wss +\n * // TOTP code). Suppress on non-interactive stdout; print otherwise.\n * onQrContent: (chunks) => {\n * if (!process.stdout.isTTY) return;\n * for (const c of chunks) process.stdout.write(`${c}\\n`);\n * },\n * });\n * export default defineConfig({ test: definePhoneVitestConfig({ connection }) });\n *\n * The heavy boot graph (chii relay, cloudflared, ws, debug-server) is pulled via\n * DYNAMIC import inside `open()` — so merely importing this module (or the\n * `@ait-co/devtools/test-runner` barrel) does NOT statically drag that graph in.\n *\n * SECRET-HANDLING: relay wss URLs, scheme URLs, and the TOTP secret/code are\n * never logged. `open()` reads the project-local `.ait_relay` secret read-only\n * (never mints) and the minted TOTP code rides only inside the QR `at=` param.\n *\n * Node-only. react-free (CdpConnection + lazily-imported MCP boot helpers only).\n */\n\nimport type { AttachDeps, AttachUrlParts } from '../mcp/attach-orchestrator.js';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport type { DashboardState, QrHttpServer } from '../mcp/qr-http-server.js';\nimport type { RelayConnectionFactory } from './pool.js';\n\n// NOTE: every value import below is a DYNAMIC import inside `open()`. This module\n// keeps ONLY type-level static imports so that re-exporting it from the\n// `@ait-co/devtools/test-runner` barrel (config.ts) does NOT statically drag the\n// heavy MCP graph (cell.ts → attach-orchestrator.ts → tools.ts → server-lock,\n// plus chii/cloudflared via debug-server) onto that Node-config entry.\n\n/** Options for {@link createRelayConnectionFactory}. */\nexport interface RelayConnectionFactoryOptions {\n /**\n * intoss-private:// scheme URL from `ait deploy --scheme-only` (env3). The\n * phone cold-loads the candidate bundle this URL points at. SECRET-HANDLING:\n * never logged.\n */\n schemeUrl: string;\n /**\n * Project root for the `.ait_relay` secret lookup (read-only). Defaults to\n * `process.cwd()`.\n */\n projectRoot?: string;\n /**\n * Attach wait timeout in ms — how long `open()` waits for the phone to scan\n * the QR and attach. Omitted (the default) means **wait indefinitely** —\n * the runner stays up until the user stops it (Ctrl-C/SIGTERM). QR-scan\n * wait is a human-paced action; there is no sound default bound for it\n * (devtools#735). Pass an explicit value to opt into a bounded wait (CI/\n * headless callers — `--attach-timeout` on the CLI). (The per-file evaluate\n * timeout is separate, passed via the pool's `run` options.)\n */\n timeoutMs?: number;\n /** Disable browser auto-open of the QR dashboard (text QR only). */\n headless?: boolean;\n /**\n * Cell axes injected as `__AIT_CELL__` before the first test bundle runs, so\n * sdk-example's capture picks up the correct sdkLine/platform. Optional — when\n * omitted no cell is injected. The values are not secrets.\n */\n cell?: { sdkLine: string; platform: string };\n /**\n * When `true`, injects `__AIT_STUB_BLOCKING__ = true` before the first test\n * bundle runs (devtools#740, DT-2) — `bundle.ts`'s sdk-redirect module reads\n * this flag via `isStubBlockingEnabled()` and answers the blocking-UI APIs\n * in `bridge-stub.ts`'s `STUB_REGISTRY` from fixtures instead of forwarding\n * them to native. Optional — omitted/false means no flag is injected, which\n * is byte-for-byte the pre-#740 behavior (the sdk-redirect module treats\n * absence the same as `false`). Not a secret.\n */\n stubBlocking?: boolean;\n /**\n * Overrides the dashboard HTTP server's bind base port (devtools#752).\n * Threaded straight through to `startQrHttpServer`'s `dashboardPort`\n * option — see that option's doc for the increment-on-EADDRINUSE scan\n * behaviour and the `AIT_DEBUG_HTTP_PORT` env fallback. Omitted means\n * `startQrHttpServer` resolves its own default (env → fixed default).\n */\n dashboardPort?: number;\n /**\n * Receives the QR/attach render content (text chunks) from\n * `renderAndMaybeWait`, so the caller decides whether to print them — e.g.\n * suppress on non-interactive stdout.\n *\n * REQUIRED (not optional) on purpose: these chunks contain the QR payload\n * (which encodes the relay wss + TOTP `at=` code) and the attach JSON block.\n * Making the hook mandatory means the factory never falls back to printing\n * them itself — a downstream `vitest.config.ts` consumer that wires this via\n * the `@ait-co/devtools/test-runner` barrel is forced to make an explicit\n * stdout decision rather than silently leaking the secret-bearing block.\n *\n * SECRET-HANDLING: when a non-interactive caller is detected the hook MUST\n * suppress the WHOLE chunk (not just `attachUrl`), since `relayUrl` rides in\n * the same block.\n */\n onQrContent: (textChunks: string[]) => void;\n}\n\n/**\n * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay\n * connection.\n *\n * `open()` performs the full attach lifecycle and BLOCKS while a human scans\n * the rendered QR with their phone — there is no way around the manual scan\n * for env3. By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the\n * runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is\n * a human-paced action with no sound default bound (devtools#735). Passing an\n * explicit `timeoutMs` opts into the old bounded behavior (CI/headless\n * callers). It resolves with the live `CdpConnection` once a matching page\n * attaches; `close()` tears the relay family down.\n *\n * The factory holds the booted relay family in a closure so `close()` can stop\n * it. A second `open()` on the same factory boots a fresh family (the previous\n * one should have been `close()`d first).\n */\nexport function createRelayConnectionFactory(\n opts: RelayConnectionFactoryOptions,\n): RelayConnectionFactory {\n const projectRoot = opts.projectRoot ?? process.cwd();\n // Undefined/absent → Infinity (wait forever). An explicit finite value opts\n // into the old bounded behavior. Number.isFinite guards downstream\n // (renderAndMaybeWait / waitForFirstTarget) treat Infinity as \"no timer\".\n const timeoutMs = opts.timeoutMs ?? Number.POSITIVE_INFINITY;\n const headless = opts.headless === true;\n\n // Captured so close() can stop the family that open() booted.\n let family: { connection: CdpConnection; stop(): void } | undefined;\n // QR HTTP server — started during open() when not headless, closed in close().\n let qrServer: QrHttpServer | undefined;\n // Session-phase state (#730) — drives the dashboard's `phase` field so the\n // CLI's run start/complete and final teardown push an immediate SSE update\n // instead of the dashboard just going dark when the process exits.\n let phase: DashboardState['phase'] = 'active';\n // Manual-blocking prompt state (devtools#741) — drives the dashboard's\n // `manualPrompt` field. `null` outside a manual step.\n let manualPrompt: DashboardState['manualPrompt'] = null;\n\n return {\n async open(): Promise<CdpConnection> {\n // Dynamic imports: keep the chii/cloudflared/debug-server graph OFF the\n // static import graph of this module (and the test-runner config barrel).\n const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import(\n '../mcp/attach-orchestrator.js'\n );\n const { injectDebugIndicator, injectGlobals } = await import('./cell.js');\n const { loadRelaySecretReadOnly } = await import('../mcp/relay-secret-store.js');\n const { bootRelayFamily, buildRelayVerifyAuth } = await import('../mcp/debug-server.js');\n\n // Load the project-local .ait_relay secret into AIT_DEBUG_TOTP_SECRET\n // BEFORE booting the relay so assertRelayAuthConfigured()/buildRelayVerifyAuth()\n // at the boot site see it. Read-only — never mints. SECRET-HANDLING: the\n // value is never logged here.\n await loadRelaySecretReadOnly({ projectRoot });\n\n // PRIMARY FIX (devtools#714): bootRelayFamily starts the cloudflared tunnel\n // as a background promise and returns immediately with tunnel.up === false.\n // We must NOT call prepareAttach until the tunnel is up — it fails fast when\n // tunnel.up is false (attach-orchestrator.ts tunnel-down guard).\n //\n // Wire onWssUrl (mirroring the MCP daemon path in debug-server.ts) to:\n // 1. resolve a caller-held tunnel-ready promise so open() can await it, and\n // 2. re-push dashboard SSE on late tunnel-up events (notifyStateChange).\n //\n // SECRET-HANDLING: onWssUrl receives the relay wss URL — never log it.\n let resolveTunnelUp!: () => void;\n const tunnelReady = new Promise<void>((resolve) => {\n resolveTunnelUp = resolve;\n });\n\n const booted = await bootRelayFamily({\n verifyAuth: buildRelayVerifyAuth(),\n onWssUrl: () => {\n // Resolve the tunnel-ready gate so the prepareAttach call below is\n // unblocked. qrServer may not be set yet at call time (it's started\n // after bootRelayFamily), so we use optional chaining — if the tunnel\n // happens to come up after qrServer is started, notifyStateChange pushes\n // the freshly minted attachUrl to any waiting dashboard SSE clients.\n // SECRET-HANDLING: wssUrl is NOT forwarded here — the value travels only\n // inside the closure via getTunnelStatus().wssUrl (used by mintAttachUrl).\n resolveTunnelUp();\n qrServer?.notifyStateChange();\n },\n // #730: parity with the MCP daemon path (debug-server.ts's onTunnelDown\n // wiring) — without this, a permanent tunnel drop during a standalone\n // CLI run left the dashboard showing a dead-but-scannable QR until the\n // watchdog (up to ~210s) finally fired.\n onTunnelDown: () => {\n qrServer?.notifyStateChange();\n },\n });\n family = booted;\n\n // If the tunnel is already up (extremely fast boot or test double), resolve\n // immediately so we don't stall on a promise that will never fire.\n if (booted.getTunnelStatus?.().up) {\n resolveTunnelUp();\n }\n\n // Track the last-captured attach parts so getDashboardState can mint a\n // fresh attach URL on every dashboard request/SSE push (fresh TOTP at=).\n // SECRET-HANDLING: parts contain the relay wss + scheme URL — never logged.\n let lastAttachParts: AttachUrlParts | undefined;\n\n // Assemble AttachDeps. We set qrHttpServer and onAttachUrlBuilt below\n // (before prepareAttach) after optionally starting the web-QR server.\n const attachDeps: AttachDeps = {\n getTunnelStatus: booted.getTunnelStatus ?? (() => ({ up: false, wssUrl: null })),\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n qrHttpServer: undefined, // filled in below if web-QR server started\n onAttachUrlBuilt: undefined, // filled in below\n canOpenBrowser: () => !headless,\n };\n\n // Web-QR server: reuse the same loopback HTTP dashboard that the MCP\n // start_attach path uses (src/mcp/qr-http-server.ts). This makes the QR\n // scannable even when stdout is non-interactive (Claude Code `!` / CI),\n // because the browser is opened by URL — not via captured stdout.\n //\n // Headless decision: we start the server even in --headless mode so the\n // printed stderr URL can be opened manually. The existing\n // canOpenBrowser: () => !headless gate inside renderAndMaybeWait prevents\n // the auto-open; headless users see only the stderr URL.\n //\n // On failure we fall back gracefully to the text-QR path — do NOT crash.\n // SECRET-HANDLING: only http://127.0.0.1:<port>/ (no secrets) goes to stderr.\n try {\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n\n const getDashboardState = (): DashboardState => ({\n tunnel: attachDeps.getTunnelStatus(),\n pages: null, // CLI/pool: no page-list introspection needed\n attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,\n mode: 'relay-dev' as const,\n phase, // #730 — CLI-only 'running'/'complete' transitions via onSessionPhase\n manualPrompt, // #741 — CLI-only --manual-blocking transitions via onManualPrompt\n });\n\n qrServer = await startQrHttpServer(getDashboardState, {\n dashboardPort: opts.dashboardPort,\n });\n\n // Wire the QR server into attachDeps BEFORE prepareAttach is called so\n // renderAndMaybeWait sees it and takes Path 2/3 (web-QR) instead of Path 4.\n attachDeps.qrHttpServer = qrServer;\n\n // Capture attach parts via onAttachUrlBuilt so getDashboardState can\n // mint a fresh URL (fresh TOTP at= code) on every dashboard render.\n attachDeps.onAttachUrlBuilt = (parts: AttachUrlParts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n };\n\n // Print the loopback dashboard URL to stderr — it carries no secrets\n // (TOTP codes and relay wss live only in the in-memory HTTP response).\n process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\\n`);\n } catch {\n // startQrHttpServer failed (e.g. port conflict, import error). Fall back\n // to the existing text-QR path by leaving qrHttpServer: undefined.\n // qrServer remains undefined; close() handles that with optional chaining.\n }\n\n // PRIMARY FIX (devtools#714): await tunnel readiness before calling\n // prepareAttach. Race: a 15 s timeout is generous — cloudflared typically\n // comes up in < 5 s. On timeout we still call prepareAttach; it will hit\n // the tunnel-down guard and throw a secret-free error (same as before,\n // but now with a clear diagnostic instead of a silent WAITING freeze).\n //\n // Implementation: Promise.race against a 15 000 ms timeout signal. We do\n // NOT use a timer-based early-exit because the existing code already\n // surface-fails on tunnel-down inside prepareAttach with a secret-free\n // message. The timeout here is purely a \"give the tunnel a fair chance\"\n // gate — not a correctness boundary.\n const TUNNEL_BOOT_TIMEOUT_MS = 15_000;\n await Promise.race([\n tunnelReady,\n new Promise<void>((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS)),\n ]);\n\n const prep = await prepareAttach(\n attachDeps,\n 'relay-dev',\n { scheme_url: opts.schemeUrl },\n booted.connection,\n );\n if (!prep.ok) {\n booted.stop();\n family = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the failure path\n // so the loopback port listener does not leak. The normal-exit path is\n // handled by close(); this mirrors that cleanup for the error path.\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: do NOT surface `prep.error.content` in the thrown\n // message. Some prep error paths build their text from attach\n // components, and the CLI catch writes `e.message` to stderr — embedding\n // that text risks leaking the scheme/relay wss URL. The detailed\n // diagnostic is the daemon/dashboard's job; the factory throws a\n // secret-free message only.\n throw new Error(\n 'createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up',\n );\n }\n\n // Render the QR + wait for the phone to attach. SECRET-HANDLING: this\n // function never logs scheme/wss/TOTP values.\n const waitResult = await renderAndMaybeWait(\n attachDeps,\n prep,\n true,\n timeoutMs,\n booted.connection,\n );\n if (waitResult.isError) {\n booted.stop();\n family = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the timeout path\n // (mirrors the prep.ok failure path above).\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING (BLOCKER fix): `waitResult.content` is the timeout\n // result built from `buildTimeoutError(baseText, ...)`, and `baseText`\n // is `JSON.stringify({ attachUrl, relayUrl, ... })` — `attachUrl` carries\n // the TOTP `at=` code and `relayUrl` is the relay `wss://` URL. The CLI\n // catch writes `e.message` to stderr, so extracting that text here would\n // leak the relay wss + TOTP code on every timeout. Throw a secret-free\n // message with only the timeout duration.\n const timeoutSec = Math.round(timeoutMs / 1000);\n throw new Error(\n `createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`,\n );\n }\n\n // Surface the QR/attach render content to the caller, which owns the\n // stdout decision (suppress on non-interactive stdout). There is no\n // fallback that prints here itself: `onQrContent` is a required option so\n // the secret-bearing block (attachUrl TOTP + relayUrl wss) can never be\n // emitted without an explicit caller decision. SECRET-HANDLING.\n const qrChunks = waitResult.content\n .filter((c): c is { type: 'text'; text: string } => c.type === 'text')\n .map((c) => c.text);\n opts.onQrContent(qrChunks);\n\n // PAGE-READY GATE (devtools#720, fix (a)+(b)):\n //\n // FIX (a) — ORDER: enableDomains() MUST run before injectDebugIndicator()\n // and injectGlobals(). Both inject calls use Runtime.evaluate via\n // sendCommand(), which guards with `if (!this.ws) reject(\"Call\n // enableDomains() first\")`. With the old ordering (inject → inject →\n // enableDomains) injectDebugIndicator's throw was swallowed by cell.ts\n // try/catch but injectGlobals (no try/catch) propagated fatally — when\n // --cell was set open() hard-threw before enableDomains ever ran and the\n // CLI exited with 0 files executed.\n //\n // FIX (b) — BOUNDED RETRY: the window between waitForFirstTarget (non-\n // empty /targets) and enableDomains (page-level WS open) is where a\n // Cloudflare edge idle-drop can disconnect the phone. enableDomains()\n // calls refreshTargets() internally; if the target list is empty at that\n // point it throws \"No mini-app page attached\". We absorb up to\n // PAGE_READY_RETRIES transient failures by waiting briefly and retrying\n // the refreshTargets+enableDomains sequence. enableDomains() is\n // idempotent (concurrent callers share the in-flight promise), so retries\n // are safe. Only after all retries fail do we surface a secret-free\n // error. This is the CLI equivalent of the MCP daemon's soft-fail\n // cushion in relay-worker.ts.\n //\n // Neither fix touches chii-connection.ts or the MCP daemon path.\n const PAGE_READY_RETRIES = 3;\n const PAGE_READY_RETRY_DELAY_MS = 1_500;\n\n let lastEnableError: Error | undefined;\n for (let attempt = 1; attempt <= PAGE_READY_RETRIES; attempt++) {\n try {\n // FIX (a): enableDomains first — opens the page-level CDP websocket.\n await booted.connection.enableDomains();\n lastEnableError = undefined;\n break;\n } catch (err) {\n lastEnableError = err instanceof Error ? err : new Error(String(err));\n if (attempt < PAGE_READY_RETRIES) {\n // FIX (b): brief pause then re-poll /targets before retrying\n // enableDomains. Use a non-leaking approach: wait, then fall\n // through to the next loop iteration.\n await new Promise<void>((resolve) => setTimeout(resolve, PAGE_READY_RETRY_DELAY_MS));\n }\n }\n }\n\n if (lastEnableError !== undefined) {\n booted.stop();\n family = undefined;\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: message contains only a duration + attempt count.\n // No relay wss URL, scheme URL, or TOTP code is included.\n throw new Error(\n `createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round((PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS) / 1000)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`,\n );\n }\n\n // FIX (a): inject AFTER enableDomains so the page-level CDP websocket is\n // open and sendCommand() will not hit the ws===null guard.\n\n // Show the on-phone \"Debugger Connected\" badge. injectDebugIndicator\n // swallows its own errors (cell.ts try/catch), so a badge failure is\n // non-fatal — test execution proceeds regardless.\n await injectDebugIndicator(booted.connection);\n\n // Inject the cell globals before any test bundle runs (session-global).\n // injectGlobals() does NOT swallow errors — a genuine type/eval failure\n // here surfaces clearly instead of silently skipping cell injection.\n if (opts.cell !== undefined) {\n await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });\n }\n\n // devtools#740 (DT-2): inject the bridge-stub gate before any test\n // bundle runs, session-global like __AIT_CELL__ above. Only injected\n // when explicitly requested — omitted/false means the sdk-redirect\n // module's `isStubBlockingEnabled()` reads `undefined`, which is\n // treated identically to `false` (zero-diff-when-off).\n if (opts.stubBlocking === true) {\n await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });\n }\n\n return booted.connection;\n },\n\n // #730: drives the dashboard's `phase` field so the CLI's run start/end\n // push an immediate SSE update instead of the dashboard just going dark.\n onSessionPhase(next: 'running' | 'complete'): void {\n phase = next;\n qrServer?.notifyStateChange();\n },\n\n // #741: drives the dashboard's `manualPrompt` field for --manual-blocking\n // — the CLI calls this immediately before injecting each manual file\n // (prompt set) and once more with `null` after the last one (prompt\n // cleared). Push is immediate (not waited on for ack) — v1 does not block\n // on dashboard acknowledgment, only on the file's own (long) evaluate.\n onManualPrompt(next: { file: string; index: number; total: number } | null): void {\n manualPrompt = next;\n qrServer?.notifyStateChange();\n },\n\n async close(connection: CdpConnection): Promise<void> {\n // #730: flip the on-phone badge to disconnected AND push the terminal\n // dashboard frame BEFORE tearing anything down, so neither surface goes\n // dark without explanation when the CLI exits (dog-food gaps #1 + #2).\n // Both are best-effort over the still-open channels — the ordering\n // (before family.stop()/qrServer.close()) is load-bearing:\n // - qrServer.notifyStateChange() writes synchronously to the still-open\n // SSE sockets, so the 'complete' frame is on the wire before the HTTP\n // server is closed below.\n // - injectDebugIndicator runs over the still-open CDP channel, so the\n // phone actually receives the disconnected-state update before the\n // relay/tunnel are torn down.\n if (phase !== 'complete') {\n phase = 'complete';\n qrServer?.notifyStateChange();\n }\n try {\n const { injectDebugIndicator } = await import('./cell.js');\n await injectDebugIndicator(connection, { state: 'disconnected' });\n } catch {\n // Channel may already be down (e.g. attach never completed) — non-fatal,\n // the badge is informational UI only.\n }\n // family.stop() is synchronous best-effort: closes the CDP connection and\n // shuts down the relay + cloudflared child.\n family?.stop();\n family = undefined;\n // Close the web-QR HTTP server if one was started during open().\n // close() is idempotent via optional chaining + reassignment to undefined.\n await qrServer?.close();\n qrServer = undefined;\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"relay-factory.js","names":[],"sources":["../../src/test-runner/relay-factory.ts"],"sourcesContent":["/**\n * Relay connection factory for the Vitest custom pool (devtools#696).\n *\n * The Vitest pool (`pool.ts`) takes a {@link RelayConnectionFactory} that knows\n * how to `open()` a live CDP relay connection (boot relay → QR → phone scan →\n * cell inject → enableDomains) and `close()` it. Before this module that exact\n * assembly lived only inside the `devtools-test` CLI's `main()`; the standalone\n * CLI and the Vitest pool would otherwise each hand-roll it and drift.\n *\n * This is the single source of that assembly. `cli.ts` is refactored to call\n * `createRelayConnectionFactory(...).open()`, and `definePhoneVitestConfig`\n * (config.ts) exposes it so a downstream `vitest.config.ts` can wire the pool:\n *\n * import { createRelayConnectionFactory } from '@ait-co/devtools/test-runner';\n * const connection = createRelayConnectionFactory({\n * schemeUrl,\n * cell,\n * // REQUIRED — own the stdout decision (the chunks carry the relay wss +\n * // TOTP code). Suppress on non-interactive stdout; print otherwise.\n * onQrContent: (chunks) => {\n * if (!process.stdout.isTTY) return;\n * for (const c of chunks) process.stdout.write(`${c}\\n`);\n * },\n * });\n * export default defineConfig({ test: definePhoneVitestConfig({ connection }) });\n *\n * The heavy boot graph (chii relay, cloudflared, ws, debug-server) is pulled via\n * DYNAMIC import inside `open()` — so merely importing this module (or the\n * `@ait-co/devtools/test-runner` barrel) does NOT statically drag that graph in.\n *\n * SECRET-HANDLING: relay wss URLs, scheme URLs, and the TOTP secret/code are\n * never logged. `open()` reads the project-local `.ait_relay` secret read-only\n * (never mints) and the minted TOTP code rides only inside the QR `at=` param.\n *\n * Node-only. react-free (CdpConnection + lazily-imported MCP boot helpers only).\n */\n\nimport type { AttachDeps, AttachUrlParts } from '../mcp/attach-orchestrator.js';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport type { DashboardState, QrHttpServer } from '../mcp/qr-http-server.js';\nimport type { RelayConnectionFactory } from './pool.js';\n\n// NOTE: every value import below is a DYNAMIC import inside `open()`. This module\n// keeps ONLY type-level static imports so that re-exporting it from the\n// `@ait-co/devtools/test-runner` barrel (config.ts) does NOT statically drag the\n// heavy MCP graph (cell.ts → attach-orchestrator.ts → tools.ts → server-lock,\n// plus chii/cloudflared via debug-server) onto that Node-config entry.\n\n/** Options for {@link createRelayConnectionFactory}. */\nexport interface RelayConnectionFactoryOptions {\n /**\n * intoss-private:// scheme URL from `ait deploy --scheme-only` (env3). The\n * phone cold-loads the candidate bundle this URL points at. SECRET-HANDLING:\n * never logged.\n *\n * Ignored when {@link attachLauncher} is `true` — the env-2 launcher path\n * builds its attach URL from {@link appUrl} + the relay wss, not a scheme URL.\n * Pass `''` in that case.\n */\n schemeUrl: string;\n /**\n * env-2 (AITC Sandbox PWA) attach mode (devtools#774). When `true`, `open()`\n * builds the attach URL as a **launcher deep-link**\n * (`buildLauncherAttachUrl(appUrl, wssUrl, totp)` — the same env-2 path the\n * MCP daemon takes via `prepareAttach(deps, 'relay-mobile', …)`, issue #378)\n * instead of an intoss-private scheme deep-link. The relay boot, QR\n * rendering, dashboard, and attach-wait are byte-for-byte identical to env 3;\n * only the URL family differs. Omitted/false keeps the env-3 scheme path\n * unchanged (zero behavior change).\n */\n attachLauncher?: boolean;\n /**\n * The consumer dev server's HTTP tunnel URL (e.g. an sdk-example\n * `dev:phone:cdp` `*.trycloudflare.com` URL) that the launcher PWA frames.\n * REQUIRED when {@link attachLauncher} is `true`; ignored otherwise.\n *\n * Threaded to the `relay-mobile` `prepareAttach` branch via the\n * `AIT_TUNNEL_BASE_URL` env var (the same key the MCP daemon reads), so the\n * exact same env-2 assembly runs — no divergent code path.\n *\n * SECRET-HANDLING: this is a tunnel host at the same sensitivity as the relay\n * wss — NEVER logged. It rides only inside the QR/dashboard attach capsule.\n */\n appUrl?: string;\n /**\n * Project root for the `.ait_relay` secret lookup (read-only). Defaults to\n * `process.cwd()`.\n */\n projectRoot?: string;\n /**\n * Attach wait timeout in ms — how long `open()` waits for the phone to scan\n * the QR and attach. Omitted (the default) means **wait indefinitely** —\n * the runner stays up until the user stops it (Ctrl-C/SIGTERM). QR-scan\n * wait is a human-paced action; there is no sound default bound for it\n * (devtools#735). Pass an explicit value to opt into a bounded wait (CI/\n * headless callers — `--attach-timeout` on the CLI). (The per-file evaluate\n * timeout is separate, passed via the pool's `run` options.)\n */\n timeoutMs?: number;\n /** Disable browser auto-open of the QR dashboard (text QR only). */\n headless?: boolean;\n /**\n * Cell axes injected as `__AIT_CELL__` before the first test bundle runs, so\n * sdk-example's capture picks up the correct sdkLine/platform. Optional — when\n * omitted no cell is injected. The values are not secrets.\n */\n cell?: { sdkLine: string; platform: string };\n /**\n * When `true`, injects `__AIT_STUB_BLOCKING__ = true` before the first test\n * bundle runs (devtools#740, DT-2) — `bundle.ts`'s sdk-redirect module reads\n * this flag via `isStubBlockingEnabled()` and answers the blocking-UI APIs\n * in `bridge-stub.ts`'s `STUB_REGISTRY` from fixtures instead of forwarding\n * them to native. Optional — omitted/false means no flag is injected, which\n * is byte-for-byte the pre-#740 behavior (the sdk-redirect module treats\n * absence the same as `false`). Not a secret.\n */\n stubBlocking?: boolean;\n /**\n * Injects `__AIT_PACE_MS__ = paceMs` before the first test bundle runs\n * (devtools#767) — `runtime.ts`'s test-execution loop reads this page\n * global and waits `paceMs` before every test after the first one that\n * actually runs. Optional — omitted/0 means no flag is injected, which is\n * byte-for-byte the pre-#767 behavior (the loop treats an absent/non-\n * positive value as \"no pacing\"). Not a secret. This is the PAGE-side half\n * of `--pace`; the RUNNER-side (file-to-file) half is a plain `opts.paceMs`\n * forwarded to `runTestFilesOverRelay` by the caller (cli.ts) — this\n * factory only owns the page-global injection.\n */\n paceMs?: number;\n /**\n * Injects `__AIT_PACE_METHOD_MS__ = paceMethodMs` before the first test\n * bundle runs (devtools#769) — `bundle.ts`'s sdk-redirect virtual module\n * reads this page global via `method-pace.ts#getPaceMethodMs()` and paces\n * every SDK call to a minimum interval BETWEEN calls to the SAME named\n * function, independent of `--pace`'s test-to-test/file-to-file spacing.\n * Optional — omitted/0 means no flag is injected, which is byte-for-byte\n * the pre-#769 behavior (`getPaceMethodMs()` treats an absent/non-positive\n * value as \"no pacing\", and `wrapWithMethodPacing` returns the SDK object\n * untouched in that case). Not a secret.\n */\n paceMethodMs?: number;\n /**\n * Overrides the dashboard HTTP server's bind base port (devtools#752).\n * Threaded straight through to `startQrHttpServer`'s `dashboardPort`\n * option — see that option's doc for the increment-on-EADDRINUSE scan\n * behaviour and the `AIT_DEBUG_HTTP_PORT` env fallback. Omitted means\n * `startQrHttpServer` resolves its own default (env → fixed default).\n */\n dashboardPort?: number;\n /**\n * Receives the QR/attach render content (text chunks) from\n * `renderAndMaybeWait`, so the caller decides whether to print them — e.g.\n * suppress on non-interactive stdout.\n *\n * REQUIRED (not optional) on purpose: these chunks contain the QR payload\n * (which encodes the relay wss + TOTP `at=` code) and the attach JSON block.\n * Making the hook mandatory means the factory never falls back to printing\n * them itself — a downstream `vitest.config.ts` consumer that wires this via\n * the `@ait-co/devtools/test-runner` barrel is forced to make an explicit\n * stdout decision rather than silently leaking the secret-bearing block.\n *\n * SECRET-HANDLING: when a non-interactive caller is detected the hook MUST\n * suppress the WHOLE chunk (not just `attachUrl`), since `relayUrl` rides in\n * the same block.\n */\n onQrContent: (textChunks: string[]) => void;\n}\n\n/**\n * Builds a {@link RelayConnectionFactory} that opens a standalone relay\n * connection for env 3 (intoss-private scheme, default) or env 2 (AITC Sandbox\n * PWA launcher deep-link, when `opts.attachLauncher` is set — devtools#774).\n *\n * `open()` performs the full attach lifecycle and BLOCKS while a human scans\n * the rendered QR with their phone — there is no way around the manual scan.\n * By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the\n * runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is\n * a human-paced action with no sound default bound (devtools#735). Passing an\n * explicit `timeoutMs` opts into the old bounded behavior (CI/headless\n * callers). It resolves with the live `CdpConnection` once a matching page\n * attaches; `close()` tears the relay family down.\n *\n * The factory holds the booted relay family in a closure so `close()` can stop\n * it. A second `open()` on the same factory boots a fresh family (the previous\n * one should have been `close()`d first).\n */\nexport function createRelayConnectionFactory(\n opts: RelayConnectionFactoryOptions,\n): RelayConnectionFactory {\n const projectRoot = opts.projectRoot ?? process.cwd();\n // Undefined/absent → Infinity (wait forever). An explicit finite value opts\n // into the old bounded behavior. Number.isFinite guards downstream\n // (renderAndMaybeWait / waitForFirstTarget) treat Infinity as \"no timer\".\n const timeoutMs = opts.timeoutMs ?? Number.POSITIVE_INFINITY;\n const headless = opts.headless === true;\n\n // Captured so close() can stop the family that open() booted.\n let family: { connection: CdpConnection; stop(): void } | undefined;\n // QR HTTP server — started during open() when not headless, closed in close().\n let qrServer: QrHttpServer | undefined;\n // devtools#772: target attach/detach poller (mirrors debug-server.ts's\n // startAttachWatcher wiring) — started alongside qrServer in open(), stopped\n // in close(). Optional-chained everywhere since qrServer startup can fail.\n let attachWatcher: { stop(): void } | undefined;\n // Session-phase state (#730) — drives the dashboard's `phase` field so the\n // CLI's run start/complete and final teardown push an immediate SSE update\n // instead of the dashboard just going dark when the process exits.\n let phase: DashboardState['phase'] = 'active';\n // Manual-blocking prompt state (devtools#741) — drives the dashboard's\n // `manualPrompt` field. `null` outside a manual step.\n let manualPrompt: DashboardState['manualPrompt'] = null;\n\n return {\n async open(): Promise<CdpConnection> {\n // Dynamic imports: keep the chii/cloudflared/debug-server graph OFF the\n // static import graph of this module (and the test-runner config barrel).\n const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import(\n '../mcp/attach-orchestrator.js'\n );\n const { injectDebugIndicator, injectGlobals } = await import('./cell.js');\n const { loadRelaySecretReadOnly } = await import('../mcp/relay-secret-store.js');\n const { bootRelayFamily, buildRelayVerifyAuth, startAttachWatcher } = await import(\n '../mcp/debug-server.js'\n );\n const { buildChiiInspectorUrl } = await import('../mcp/devtools-opener.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // Load the project-local .ait_relay secret into AIT_DEBUG_TOTP_SECRET\n // BEFORE booting the relay so assertRelayAuthConfigured()/buildRelayVerifyAuth()\n // at the boot site see it. Read-only — never mints. SECRET-HANDLING: the\n // value is never logged here.\n await loadRelaySecretReadOnly({ projectRoot });\n\n // PRIMARY FIX (devtools#714): bootRelayFamily starts the cloudflared tunnel\n // as a background promise and returns immediately with tunnel.up === false.\n // We must NOT call prepareAttach until the tunnel is up — it fails fast when\n // tunnel.up is false (attach-orchestrator.ts tunnel-down guard).\n //\n // Wire onWssUrl (mirroring the MCP daemon path in debug-server.ts) to:\n // 1. resolve a caller-held tunnel-ready promise so open() can await it, and\n // 2. re-push dashboard SSE on late tunnel-up events (notifyStateChange).\n //\n // SECRET-HANDLING: onWssUrl receives the relay wss URL — never log it.\n let resolveTunnelUp!: () => void;\n const tunnelReady = new Promise<void>((resolve) => {\n resolveTunnelUp = resolve;\n });\n\n const booted = await bootRelayFamily({\n verifyAuth: buildRelayVerifyAuth(),\n onWssUrl: () => {\n // Resolve the tunnel-ready gate so the prepareAttach call below is\n // unblocked. qrServer may not be set yet at call time (it's started\n // after bootRelayFamily), so we use optional chaining — if the tunnel\n // happens to come up after qrServer is started, notifyStateChange pushes\n // the freshly minted attachUrl to any waiting dashboard SSE clients.\n // SECRET-HANDLING: wssUrl is NOT forwarded here — the value travels only\n // inside the closure via getTunnelStatus().wssUrl (used by mintAttachUrl).\n resolveTunnelUp();\n qrServer?.notifyStateChange();\n },\n // #730: parity with the MCP daemon path (debug-server.ts's onTunnelDown\n // wiring) — without this, a permanent tunnel drop during a standalone\n // CLI run left the dashboard showing a dead-but-scannable QR until the\n // watchdog (up to ~210s) finally fired.\n onTunnelDown: () => {\n qrServer?.notifyStateChange();\n },\n });\n family = booted;\n\n // If the tunnel is already up (extremely fast boot or test double), resolve\n // immediately so we don't stall on a promise that will never fire.\n if (booted.getTunnelStatus?.().up) {\n resolveTunnelUp();\n }\n\n // Track the last-captured attach parts so getDashboardState can mint a\n // fresh attach URL on every dashboard request/SSE push (fresh TOTP at=).\n // SECRET-HANDLING: parts contain the relay wss + scheme URL — never logged.\n let lastAttachParts: AttachUrlParts | undefined;\n\n // Assemble AttachDeps. We set qrHttpServer and onAttachUrlBuilt below\n // (before prepareAttach) after optionally starting the web-QR server.\n const attachDeps: AttachDeps = {\n getTunnelStatus: booted.getTunnelStatus ?? (() => ({ up: false, wssUrl: null })),\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n qrHttpServer: undefined, // filled in below if web-QR server started\n onAttachUrlBuilt: undefined, // filled in below\n canOpenBrowser: () => !headless,\n };\n\n // Web-QR server: reuse the same loopback HTTP dashboard that the MCP\n // start_attach path uses (src/mcp/qr-http-server.ts). This makes the QR\n // scannable even when stdout is non-interactive (Claude Code `!` / CI),\n // because the browser is opened by URL — not via captured stdout.\n //\n // Headless decision: we start the server even in --headless mode so the\n // printed stderr URL can be opened manually. The existing\n // canOpenBrowser: () => !headless gate inside renderAndMaybeWait prevents\n // the auto-open; headless users see only the stderr URL.\n //\n // On failure we fall back gracefully to the text-QR path — do NOT crash.\n // SECRET-HANDLING: only http://127.0.0.1:<port>/ (no secrets) goes to stderr.\n try {\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n\n // devtools#772: parity with the MCP daemon path (debug-server.ts) —\n // read the live target list off the booted connection instead of the\n // `pages: null` placeholder. `Array.isArray(pages) && pages.length > 0`\n // is the SSE client's Inspector-active gate (qr-http-server.ts #544),\n // so a hardcoded `null` left the Inspector section stuck on the\n // waiting hint forever, independent of any actual attach state.\n const getDashboardState = (): DashboardState => ({\n tunnel: attachDeps.getTunnelStatus(),\n pages: booted.connection.listTargets().map((t) => ({ id: t.id, url: t.url })),\n attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,\n // devtools#774: label the dashboard chrome for the env being attached\n // — 'relay-mobile' (env 2 launcher/sandbox family) vs 'relay-dev'\n // (env 3 intoss family). Mirrors the MCP daemon's mode selection.\n mode: opts.attachLauncher === true ? ('relay-mobile' as const) : ('relay-dev' as const),\n phase, // #730 — CLI-only 'running'/'complete' transitions via onSessionPhase\n manualPrompt, // #741 — CLI-only --manual-blocking transitions via onManualPrompt\n });\n\n // devtools#772: getDirectInspectorUrl — mirrors debug-server.ts's\n // assembly (buildChiiInspectorUrl over the relay's local HTTP base +\n // a freshly-minted TOTP code) so the runner dashboard's `/inspector`\n // stable route (#530) is actually functional once a page attaches.\n // SECRET-HANDLING: `ok:true`'s `url` carries the relay host + TOTP\n // `at=` code — never logged; it only ever rides a Location header.\n const getDirectInspectorUrl = (): ReturnType<\n NonNullable<\n import('../mcp/qr-http-server.js').QrHttpServerOptions['getDirectInspectorUrl']\n >\n > => {\n if (!booted.relayHttpUrl) {\n return { ok: false, reason: 'relayDown' };\n }\n const targets = booted.connection.listTargets();\n if (targets.length === 0) {\n return { ok: false, reason: 'noTarget' };\n }\n const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;\n if (!totpSecret) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n const url = buildChiiInspectorUrl(booted.relayHttpUrl, targets[0].id, () =>\n generateTotp(totpSecret, Date.now()),\n );\n if (url === null) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n return { ok: true, url };\n };\n\n qrServer = await startQrHttpServer(getDashboardState, {\n dashboardPort: opts.dashboardPort,\n getDirectInspectorUrl,\n });\n\n // Wire the QR server into attachDeps BEFORE prepareAttach is called so\n // renderAndMaybeWait sees it and takes Path 2/3 (web-QR) instead of Path 4.\n attachDeps.qrHttpServer = qrServer;\n\n // Capture attach parts via onAttachUrlBuilt so getDashboardState can\n // mint a fresh URL (fresh TOTP at= code) on every dashboard render.\n attachDeps.onAttachUrlBuilt = (parts: AttachUrlParts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n };\n\n // devtools#772: parity with the MCP daemon path's onPageAttach/\n // onPageDetach wiring (debug-server.ts ~2348) — push an immediate SSE\n // update whenever the target set changes, so the dashboard's Inspector\n // section flips out of the waiting hint without waiting for the next\n // periodic SSE refresh. `server` is omitted (no MCP Server here — the\n // standalone runner has no tools/list to notify); startAttachWatcher\n // treats that as \"skip sendToolListChanged\" and runs the rest of its\n // signature-diff logic unchanged.\n attachWatcher = startAttachWatcher(\n booted.connection,\n undefined,\n 1_000,\n () => qrServer?.notifyStateChange(),\n () => qrServer?.notifyStateChange(),\n );\n\n // Print the loopback dashboard URL to stderr — it carries no secrets\n // (TOTP codes and relay wss live only in the in-memory HTTP response).\n process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\\n`);\n } catch {\n // startQrHttpServer failed (e.g. port conflict, import error). Fall back\n // to the existing text-QR path by leaving qrHttpServer: undefined.\n // qrServer remains undefined; close() handles that with optional chaining.\n }\n\n // PRIMARY FIX (devtools#714): await tunnel readiness before calling\n // prepareAttach. Race: a 15 s timeout is generous — cloudflared typically\n // comes up in < 5 s. On timeout we still call prepareAttach; it will hit\n // the tunnel-down guard and throw a secret-free error (same as before,\n // but now with a clear diagnostic instead of a silent WAITING freeze).\n //\n // Implementation: Promise.race against a 15 000 ms timeout signal. We do\n // NOT use a timer-based early-exit because the existing code already\n // surface-fails on tunnel-down inside prepareAttach with a secret-free\n // message. The timeout here is purely a \"give the tunnel a fair chance\"\n // gate — not a correctness boundary.\n const TUNNEL_BOOT_TIMEOUT_MS = 15_000;\n await Promise.race([\n tunnelReady,\n new Promise<void>((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS)),\n ]);\n\n // devtools#774: env 2 (launcher) vs env 3 (scheme) attach. The relay\n // boot / QR / dashboard / attach-wait above are identical for both — only\n // the attach-URL family differs. We reuse `prepareAttach`'s existing\n // `relay-mobile` branch (issue #378) rather than hand-rolling a second\n // assembly: it reads AIT_TUNNEL_BASE_URL, verifies the tunnel wss + TOTP\n // secret, and builds the launcher parts. We set that env var from\n // `opts.appUrl` here (scoped to this open()) so the same code path runs.\n // SECRET-HANDLING: appUrl is a tunnel host — set into the env only, never\n // logged; prepareAttach's relay-mobile branch never echoes it either.\n let prep: Awaited<ReturnType<typeof prepareAttach>>;\n if (opts.attachLauncher === true) {\n const priorTunnelBaseUrl = process.env.AIT_TUNNEL_BASE_URL;\n process.env.AIT_TUNNEL_BASE_URL = opts.appUrl ?? '';\n try {\n prep = await prepareAttach(attachDeps, 'relay-mobile', {}, booted.connection);\n } finally {\n // Restore the prior env value so we don't leak the tunnel host into\n // the process env past this attach (it is read only inside prepareAttach).\n if (priorTunnelBaseUrl === undefined) delete process.env.AIT_TUNNEL_BASE_URL;\n else process.env.AIT_TUNNEL_BASE_URL = priorTunnelBaseUrl;\n }\n } else {\n prep = await prepareAttach(\n attachDeps,\n 'relay-dev',\n { scheme_url: opts.schemeUrl },\n booted.connection,\n );\n }\n if (!prep.ok) {\n booted.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller alongside the QR server\n // on this failure path (mirrors the qrServer cleanup below).\n attachWatcher?.stop();\n attachWatcher = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the failure path\n // so the loopback port listener does not leak. The normal-exit path is\n // handled by close(); this mirrors that cleanup for the error path.\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: do NOT surface `prep.error.content` in the thrown\n // message. Some prep error paths build their text from attach\n // components, and the CLI catch writes `e.message` to stderr — embedding\n // that text risks leaking the scheme/relay wss URL. The detailed\n // diagnostic is the daemon/dashboard's job; the factory throws a\n // secret-free message only.\n throw new Error(\n 'createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up',\n );\n }\n\n // Render the QR + wait for the phone to attach. SECRET-HANDLING: this\n // function never logs scheme/wss/TOTP values.\n const waitResult = await renderAndMaybeWait(\n attachDeps,\n prep,\n true,\n timeoutMs,\n booted.connection,\n );\n if (waitResult.isError) {\n booted.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller alongside the QR server\n // on this failure path (mirrors the prep.ok failure path above).\n attachWatcher?.stop();\n attachWatcher = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the timeout path\n // (mirrors the prep.ok failure path above).\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING (BLOCKER fix): `waitResult.content` is the timeout\n // result built from `buildTimeoutError(baseText, ...)`, and `baseText`\n // is `JSON.stringify({ attachUrl, relayUrl, ... })` — `attachUrl` carries\n // the TOTP `at=` code and `relayUrl` is the relay `wss://` URL. The CLI\n // catch writes `e.message` to stderr, so extracting that text here would\n // leak the relay wss + TOTP code on every timeout. Throw a secret-free\n // message with only the timeout duration.\n const timeoutSec = Math.round(timeoutMs / 1000);\n throw new Error(\n `createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`,\n );\n }\n\n // Surface the QR/attach render content to the caller, which owns the\n // stdout decision (suppress on non-interactive stdout). There is no\n // fallback that prints here itself: `onQrContent` is a required option so\n // the secret-bearing block (attachUrl TOTP + relayUrl wss) can never be\n // emitted without an explicit caller decision. SECRET-HANDLING.\n const qrChunks = waitResult.content\n .filter((c): c is { type: 'text'; text: string } => c.type === 'text')\n .map((c) => c.text);\n opts.onQrContent(qrChunks);\n\n // PAGE-READY GATE (devtools#720, fix (a)+(b)):\n //\n // FIX (a) — ORDER: enableDomains() MUST run before injectDebugIndicator()\n // and injectGlobals(). Both inject calls use Runtime.evaluate via\n // sendCommand(), which guards with `if (!this.ws) reject(\"Call\n // enableDomains() first\")`. With the old ordering (inject → inject →\n // enableDomains) injectDebugIndicator's throw was swallowed by cell.ts\n // try/catch but injectGlobals (no try/catch) propagated fatally — when\n // --cell was set open() hard-threw before enableDomains ever ran and the\n // CLI exited with 0 files executed.\n //\n // FIX (b) — BOUNDED RETRY: the window between waitForFirstTarget (non-\n // empty /targets) and enableDomains (page-level WS open) is where a\n // Cloudflare edge idle-drop can disconnect the phone. enableDomains()\n // calls refreshTargets() internally; if the target list is empty at that\n // point it throws \"No mini-app page attached\". We absorb up to\n // PAGE_READY_RETRIES transient failures by waiting briefly and retrying\n // the refreshTargets+enableDomains sequence. enableDomains() is\n // idempotent (concurrent callers share the in-flight promise), so retries\n // are safe. Only after all retries fail do we surface a secret-free\n // error. This is the CLI equivalent of the MCP daemon's soft-fail\n // cushion in relay-worker.ts.\n //\n // Neither fix touches chii-connection.ts or the MCP daemon path.\n const PAGE_READY_RETRIES = 3;\n const PAGE_READY_RETRY_DELAY_MS = 1_500;\n\n let lastEnableError: Error | undefined;\n for (let attempt = 1; attempt <= PAGE_READY_RETRIES; attempt++) {\n try {\n // FIX (a): enableDomains first — opens the page-level CDP websocket.\n await booted.connection.enableDomains();\n lastEnableError = undefined;\n break;\n } catch (err) {\n lastEnableError = err instanceof Error ? err : new Error(String(err));\n if (attempt < PAGE_READY_RETRIES) {\n // FIX (b): brief pause then re-poll /targets before retrying\n // enableDomains. Use a non-leaking approach: wait, then fall\n // through to the next loop iteration.\n await new Promise<void>((resolve) => setTimeout(resolve, PAGE_READY_RETRY_DELAY_MS));\n }\n }\n }\n\n if (lastEnableError !== undefined) {\n booted.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller alongside the QR server\n // on this failure path (mirrors the two failure paths above).\n attachWatcher?.stop();\n attachWatcher = undefined;\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: message contains only a duration + attempt count.\n // No relay wss URL, scheme URL, or TOTP code is included.\n throw new Error(\n `createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round((PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS) / 1000)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`,\n );\n }\n\n // FIX (a): inject AFTER enableDomains so the page-level CDP websocket is\n // open and sendCommand() will not hit the ws===null guard.\n\n // Show the on-phone \"Debugger Connected\" badge. injectDebugIndicator\n // swallows its own errors (cell.ts try/catch), so a badge failure is\n // non-fatal — test execution proceeds regardless.\n await injectDebugIndicator(booted.connection);\n\n // Inject the cell globals before any test bundle runs (session-global).\n // injectGlobals() does NOT swallow errors — a genuine type/eval failure\n // here surfaces clearly instead of silently skipping cell injection.\n if (opts.cell !== undefined) {\n await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });\n }\n\n // devtools#740 (DT-2): inject the bridge-stub gate before any test\n // bundle runs, session-global like __AIT_CELL__ above. Only injected\n // when explicitly requested — omitted/false means the sdk-redirect\n // module's `isStubBlockingEnabled()` reads `undefined`, which is\n // treated identically to `false` (zero-diff-when-off).\n if (opts.stubBlocking === true) {\n await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });\n }\n\n // devtools#767: inject the page-side --pace pacing value, session-global\n // like __AIT_CELL__/__AIT_STUB_BLOCKING__ above. Only injected when\n // positive — omitted/0 means runtime.ts's own `__AIT_PACE_MS__` read\n // sees `undefined`, which its `typeof raw === 'number' && raw > 0` guard\n // treats identically to a literal `0` (zero-diff-when-off).\n if (opts.paceMs !== undefined && opts.paceMs > 0) {\n await injectGlobals(booted.connection, { __AIT_PACE_MS__: opts.paceMs });\n }\n\n // devtools#769: inject the page-side --pace-method pacing value,\n // session-global like __AIT_PACE_MS__ above. Only injected when\n // positive — omitted/0 means method-pace.ts's own\n // `__AIT_PACE_METHOD_MS__` read sees `undefined`, which its\n // `typeof raw === 'number' && raw > 0` guard treats identically to a\n // literal `0` (zero-diff-when-off).\n if (opts.paceMethodMs !== undefined && opts.paceMethodMs > 0) {\n await injectGlobals(booted.connection, { __AIT_PACE_METHOD_MS__: opts.paceMethodMs });\n }\n\n return booted.connection;\n },\n\n // #730: drives the dashboard's `phase` field so the CLI's run start/end\n // push an immediate SSE update instead of the dashboard just going dark.\n onSessionPhase(next: 'running' | 'complete'): void {\n phase = next;\n qrServer?.notifyStateChange();\n },\n\n // #741: drives the dashboard's `manualPrompt` field for --manual-blocking\n // — the CLI calls this immediately before injecting each manual file\n // (prompt set) and once more with `null` after the last one (prompt\n // cleared). Push is immediate (not waited on for ack) — v1 does not block\n // on dashboard acknowledgment, only on the file's own (long) evaluate.\n onManualPrompt(next: { file: string; index: number; total: number } | null): void {\n manualPrompt = next;\n qrServer?.notifyStateChange();\n },\n\n async close(connection: CdpConnection): Promise<void> {\n // #730: flip the on-phone badge to disconnected AND push the terminal\n // dashboard frame BEFORE tearing anything down, so neither surface goes\n // dark without explanation when the CLI exits (dog-food gaps #1 + #2).\n // Both are best-effort over the still-open channels — the ordering\n // (before family.stop()/qrServer.close()) is load-bearing:\n // - qrServer.notifyStateChange() writes synchronously to the still-open\n // SSE sockets, so the 'complete' frame is on the wire before the HTTP\n // server is closed below.\n // - injectDebugIndicator runs over the still-open CDP channel, so the\n // phone actually receives the disconnected-state update before the\n // relay/tunnel are torn down.\n if (phase !== 'complete') {\n phase = 'complete';\n qrServer?.notifyStateChange();\n }\n try {\n const { injectDebugIndicator } = await import('./cell.js');\n await injectDebugIndicator(connection, { state: 'disconnected' });\n } catch {\n // Channel may already be down (e.g. attach never completed) — non-fatal,\n // the badge is informational UI only.\n }\n // family.stop() is synchronous best-effort: closes the CDP connection and\n // shuts down the relay + cloudflared child.\n family?.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller before closing the QR\n // server it pushes into — idempotent via optional chaining.\n attachWatcher?.stop();\n attachWatcher = undefined;\n // Close the web-QR HTTP server if one was started during open().\n // close() is idempotent via optional chaining + reassignment to undefined.\n await qrServer?.close();\n qrServer = undefined;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as RelayRunReport, i as RelayRunOptions, n as FileResult, o as flattenResults, r as MANUAL_FILE_TIMEOUT_MS, s as runTestFilesOverRelay, t as EVALUATE_TIMEOUT_MARKER } from "../relay-worker-
|
|
1
|
+
import { a as RelayRunReport, i as RelayRunOptions, n as FileResult, o as flattenResults, r as MANUAL_FILE_TIMEOUT_MS, s as runTestFilesOverRelay, t as EVALUATE_TIMEOUT_MARKER } from "../relay-worker-DcyboK43.js";
|
|
2
2
|
export { EVALUATE_TIMEOUT_MARKER, FileResult, MANUAL_FILE_TIMEOUT_MS, RelayRunOptions, RelayRunReport, flattenResults, runTestFilesOverRelay };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { i as runTestFilesOverRelay, n as MANUAL_FILE_TIMEOUT_MS, r as flattenResults, t as EVALUATE_TIMEOUT_MARKER } from "../relay-worker-
|
|
1
|
+
import { i as runTestFilesOverRelay, n as MANUAL_FILE_TIMEOUT_MS, r as flattenResults, t as EVALUATE_TIMEOUT_MARKER } from "../relay-worker-BUXI0K0b.js";
|
|
2
2
|
export { EVALUATE_TIMEOUT_MARKER, MANUAL_FILE_TIMEOUT_MS, flattenResults, runTestFilesOverRelay };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { t as AitCaptureLine } from "../capture-
|
|
2
|
-
import {
|
|
3
|
-
import { a as RelayRunReport } from "../relay-worker-
|
|
1
|
+
import { t as AitCaptureLine } from "../capture-DsP525OZ.js";
|
|
2
|
+
import { i as TestResult } from "../runtime-BiigOuvb.js";
|
|
3
|
+
import { a as RelayRunReport } from "../relay-worker-DcyboK43.js";
|
|
4
4
|
|
|
5
5
|
//#region src/test-runner/report.d.ts
|
|
6
6
|
/** The cell axes a report is stamped with — the test-matrix coordinates. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as CdpConnection } from "../cdp-connection-
|
|
2
|
-
import { t as RunReport } from "../runtime-
|
|
1
|
+
import { t as CdpConnection } from "../cdp-connection-rP1WdnH5.js";
|
|
2
|
+
import { t as RunReport } from "../runtime-BiigOuvb.js";
|
|
3
3
|
|
|
4
4
|
//#region src/test-runner/rpc.d.ts
|
|
5
5
|
/**
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { RunReport, TestContext, TestResult, runTestModule, runtimeGlobals };
|
|
1
|
+
import { a as runTestModule, i as TestResult, n as RunTestModuleOptions, o as runtimeGlobals, r as TestContext, t as RunReport } from "../runtime-BiigOuvb.js";
|
|
2
|
+
export { RunReport, RunTestModuleOptions, TestContext, TestResult, runTestModule, runtimeGlobals };
|
|
@@ -1,5 +1,55 @@
|
|
|
1
|
+
import { r as isThrottledError } from "../throttle-DKKzX1qC.js";
|
|
1
2
|
//#region src/test-runner/runtime.ts
|
|
2
3
|
/**
|
|
4
|
+
* Thin test runtime for WebView execution.
|
|
5
|
+
*
|
|
6
|
+
* This file is bundled by `bundle.ts` together with the user's test file
|
|
7
|
+
* into a single IIFE injected into the WebView via `Runtime.evaluate`.
|
|
8
|
+
* It MUST stay browser-compatible — no Node.js APIs allowed.
|
|
9
|
+
*
|
|
10
|
+
* Design: rather than shipping @vitest/runner verbatim into a WebView (where
|
|
11
|
+
* its Node-side internals cause issues), this runtime provides a minimal
|
|
12
|
+
* compatible API surface — describe/it/test/expect globals — collects results
|
|
13
|
+
* into a plain JSON-safe object, and exports `runTestModule` as the entry point.
|
|
14
|
+
*
|
|
15
|
+
* @vitest/runner and @vitest/expect are listed as dependencies so that the
|
|
16
|
+
* package's type contracts are available and so the browser-compatible subsets
|
|
17
|
+
* can be referenced. The Vitest custom pool that drives this runtime through
|
|
18
|
+
* Vitest's `PoolRunnerInitializer` lives in `pool.ts`.
|
|
19
|
+
*
|
|
20
|
+
* NOTE: this file is imported by type from Node-side code (rpc.ts / relay-worker.ts)
|
|
21
|
+
* for the RunReport / TestResult type shapes. The runtime ITSELF is not imported
|
|
22
|
+
* at runtime on the Node side — only the types are used.
|
|
23
|
+
*/
|
|
24
|
+
/** Backoff delays (ms) between throttle-retry attempts: 1s, then 2s. */
|
|
25
|
+
const TEST_RETRY_BACKOFF_MS = [1e3, 2e3];
|
|
26
|
+
/** Max retry attempts per test after the initial run (devtools#767: "최대 2회 재시도"). */
|
|
27
|
+
const TEST_MAX_RETRIES = TEST_RETRY_BACKOFF_MS.length;
|
|
28
|
+
/** Real `setTimeout`-backed sleep — the production default for {@link RunTestModuleOptions.sleep}. */
|
|
29
|
+
const DEFAULT_SLEEP = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
/**
|
|
31
|
+
* Renders a thrown value into a `TestResult.error` string. Handles three
|
|
32
|
+
* shapes:
|
|
33
|
+
* - a real `Error` (or subclass) — its `.message`;
|
|
34
|
+
* - an error-LIKE plain object with a string `.message` (the 2.x native
|
|
35
|
+
* bridge envelope this test-runner observes, e.g.
|
|
36
|
+
* `{name, code, userInfo, moduleName, __isError}` — devtools#767's
|
|
37
|
+
* `APP_BRIDGE_THROTTLED` rejections are exactly this shape, not an
|
|
38
|
+
* `Error` instance) — its `.message` field;
|
|
39
|
+
* - anything else — `String(e)` (the pre-#767 fallback, unchanged).
|
|
40
|
+
*
|
|
41
|
+
* Without the middle case, a native envelope's `.message` (the only useful
|
|
42
|
+
* diagnostic text) was discarded in favor of the useless `"[object Object]"`.
|
|
43
|
+
*/
|
|
44
|
+
function stringifyThrown(e) {
|
|
45
|
+
if (e instanceof Error) return e.message;
|
|
46
|
+
if (typeof e === "object" && e !== null) {
|
|
47
|
+
const rec = e;
|
|
48
|
+
if (typeof rec.message === "string") return rec.message;
|
|
49
|
+
}
|
|
50
|
+
return String(e);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
3
53
|
* Brand property that tags asymmetric matcher markers (`expect.any(String)`,
|
|
4
54
|
* `expect.objectContaining(...)`, etc). A reserved string key is used as the
|
|
5
55
|
* brand so the guard stays a plain own-property check — markers are only ever
|
|
@@ -441,8 +491,14 @@ const runtimeGlobals = {
|
|
|
441
491
|
* @param moduleFactory - A zero-argument function that contains the user's
|
|
442
492
|
* top-level test code (describe/it/test calls). The bundler wraps the entire
|
|
443
493
|
* test module so that its top-level statements become the body of this factory.
|
|
494
|
+
* @param opts - Test-only overrides (see {@link RunTestModuleOptions}). Never
|
|
495
|
+
* passed by the generated bundle (`rpc.ts#buildRunTestsExpression` calls
|
|
496
|
+
* `runTestModule(__userFactory)` with exactly one argument) — this second
|
|
497
|
+
* parameter exists so unit tests can inject a fake sleep and assert the
|
|
498
|
+
* THROTTLED-retry timing without real delays.
|
|
444
499
|
*/
|
|
445
|
-
async function runTestModule(moduleFactory) {
|
|
500
|
+
async function runTestModule(moduleFactory, opts) {
|
|
501
|
+
const sleep = opts?.sleep ?? DEFAULT_SLEEP;
|
|
446
502
|
_pendingTests.length = 0;
|
|
447
503
|
_suiteStack.length = 0;
|
|
448
504
|
_hooks.length = 0;
|
|
@@ -503,6 +559,11 @@ async function runTestModule(moduleFactory) {
|
|
|
503
559
|
};
|
|
504
560
|
}
|
|
505
561
|
}
|
|
562
|
+
const paceMs = (() => {
|
|
563
|
+
const raw = globalThis.__AIT_PACE_MS__;
|
|
564
|
+
return typeof raw === "number" && raw > 0 ? raw : 0;
|
|
565
|
+
})();
|
|
566
|
+
let ranFirstTest = false;
|
|
506
567
|
for (let i = 0; i < _pendingTests.length; i++) {
|
|
507
568
|
const pending = _pendingTests[i];
|
|
508
569
|
const fullName = [...pending.suitePath, pending.name].join(" > ");
|
|
@@ -514,6 +575,8 @@ async function runTestModule(moduleFactory) {
|
|
|
514
575
|
});
|
|
515
576
|
continue;
|
|
516
577
|
}
|
|
578
|
+
if (paceMs > 0 && ranFirstTest) await sleep(paceMs);
|
|
579
|
+
ranFirstTest = true;
|
|
517
580
|
for (let depth = 1; depth <= pending.suitePath.length; depth++) {
|
|
518
581
|
const scopeKey = pending.suitePath.slice(0, depth).join("\0");
|
|
519
582
|
if (!firedBeforeAll.has(scopeKey)) {
|
|
@@ -531,12 +594,24 @@ async function runTestModule(moduleFactory) {
|
|
|
531
594
|
const tStart = Date.now();
|
|
532
595
|
let testErr;
|
|
533
596
|
let inPageSkip;
|
|
597
|
+
let throttleRetries = 0;
|
|
534
598
|
if (beErr) testErr = `beforeEach failed: ${beErr.message}`;
|
|
535
|
-
else try {
|
|
599
|
+
else for (let attempt = 0;; attempt++) try {
|
|
536
600
|
await pending.fn(_makeTestContext(fullName));
|
|
601
|
+
testErr = void 0;
|
|
602
|
+
break;
|
|
537
603
|
} catch (e) {
|
|
538
|
-
if (e instanceof InPageSkipSentinel)
|
|
539
|
-
|
|
604
|
+
if (e instanceof InPageSkipSentinel) {
|
|
605
|
+
inPageSkip = e;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
testErr = stringifyThrown(e);
|
|
609
|
+
if (isThrottledError(e) && attempt < TEST_MAX_RETRIES) {
|
|
610
|
+
throttleRetries += 1;
|
|
611
|
+
await sleep(TEST_RETRY_BACKOFF_MS[attempt]);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
break;
|
|
540
615
|
}
|
|
541
616
|
const aeErr = await _runHooks(_hooksFor("afterEach", pending.suitePath));
|
|
542
617
|
if (aeErr && !testErr && !inPageSkip) testErr = `afterEach failed: ${aeErr.message}`;
|
|
@@ -550,12 +625,14 @@ async function runTestModule(moduleFactory) {
|
|
|
550
625
|
name: fullName,
|
|
551
626
|
status: "fail",
|
|
552
627
|
duration: Date.now() - tStart,
|
|
553
|
-
error: testErr
|
|
628
|
+
error: testErr,
|
|
629
|
+
...throttleRetries > 0 ? { throttleRetries } : {}
|
|
554
630
|
});
|
|
555
631
|
else results.push({
|
|
556
632
|
name: fullName,
|
|
557
633
|
status: "pass",
|
|
558
|
-
duration: Date.now() - tStart
|
|
634
|
+
duration: Date.now() - tStart,
|
|
635
|
+
...throttleRetries > 0 ? { throttleRetries } : {}
|
|
559
636
|
});
|
|
560
637
|
for (let depth = pending.suitePath.length; depth >= 1; depth--) {
|
|
561
638
|
const scopeKey = pending.suitePath.slice(0, depth).join("\0");
|