@ait-co/devtools 0.1.131 → 0.1.132
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{debug-server-CD4zYBq4.js → debug-server-BBUzxZSf.js} +2 -2
- package/dist/{debug-server-CD4zYBq4.js.map → debug-server-BBUzxZSf.js.map} +1 -1
- package/dist/{debug-server-qRSRG7YZ.js → debug-server-Ch5p0ryv.js} +20 -2
- package/dist/debug-server-Ch5p0ryv.js.map +1 -0
- package/dist/{debug-server-BSbfnFR5.js → debug-server-DoWAtTNj.js} +22 -4
- package/dist/debug-server-DoWAtTNj.js.map +1 -0
- package/dist/mcp/cli.js +72 -7
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +1 -1
- package/dist/{pool-BaSCZALv.d.ts → pool-BtLh7U1y.d.ts} +16 -2
- package/dist/{pool-BaSCZALv.d.ts.map → pool-BtLh7U1y.d.ts.map} +1 -1
- package/dist/{qr-http-server-BxoRp2w7.cjs → qr-http-server-DX_9UA-p.cjs} +3 -2
- package/dist/qr-http-server-DX_9UA-p.cjs.map +1 -0
- package/dist/{qr-http-server-BdNGPRoL.js → qr-http-server-DeRrKp_R.js} +3 -2
- package/dist/{qr-http-server-cKbciY5k.js.map → qr-http-server-DeRrKp_R.js.map} +1 -1
- package/dist/{qr-http-server-DCbjORFa.js → qr-http-server-FVn61xAq.js} +3 -3
- package/dist/{qr-http-server-BdNGPRoL.js.map → qr-http-server-FVn61xAq.js.map} +1 -1
- package/dist/{qr-http-server-cKbciY5k.js → qr-http-server-KB147M-S.js} +4 -2
- package/dist/{qr-http-server-DCbjORFa.js.map → qr-http-server-KB147M-S.js.map} +1 -1
- package/dist/{qr-http-server-Bq9zR7-z.js → qr-http-server-V0xuU5TJ.js} +3 -2
- package/dist/{qr-http-server-Bq9zR7-z.js.map → qr-http-server-V0xuU5TJ.js.map} +1 -1
- package/dist/{qr-http-server-CV6TA9ZN.cjs → qr-http-server-iH_Hh_si.cjs} +3 -2
- package/dist/qr-http-server-iH_Hh_si.cjs.map +1 -0
- package/dist/{relay-factory-Cloog1jb.js → relay-factory-cNgPfrWg.js} +10 -4
- package/dist/relay-factory-cNgPfrWg.js.map +1 -0
- package/dist/{relay-worker-o0M2ndyJ.d.ts → relay-worker-BMSnlyls.d.ts} +39 -2
- package/dist/relay-worker-BMSnlyls.d.ts.map +1 -0
- package/dist/{relay-worker-CCAHLC4a.js → relay-worker-l34xNuci.js} +20 -3
- package/dist/{relay-worker-CCAHLC4a.js.map → relay-worker-l34xNuci.js.map} +1 -1
- package/dist/test-runner/bin.js +160 -20
- package/dist/test-runner/bin.js.map +1 -1
- package/dist/test-runner/config.d.ts +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 +14 -0
- package/dist/test-runner/relay-factory.d.ts.map +1 -1
- package/dist/test-runner/relay-factory.js +9 -3
- package/dist/test-runner/relay-factory.js.map +1 -1
- package/dist/test-runner/relay-worker.d.ts +2 -2
- package/dist/test-runner/relay-worker.js +2 -2
- package/dist/test-runner/report.d.ts +26 -4
- package/dist/test-runner/report.d.ts.map +1 -1
- package/dist/test-runner/report.js +41 -7
- package/dist/test-runner/report.js.map +1 -1
- package/dist/{tunnel-B2UCKAc7.cjs → tunnel-DP453L4e.cjs} +2 -2
- package/dist/{tunnel-B2UCKAc7.cjs.map → tunnel-DP453L4e.cjs.map} +1 -1
- package/dist/{tunnel-C-rQ34dM.js → tunnel-Djf-zmjJ.js} +2 -2
- package/dist/{tunnel-C-rQ34dM.js.map → tunnel-Djf-zmjJ.js.map} +1 -1
- package/dist/unplugin/index.cjs +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/unplugin/tunnel.cjs +1 -1
- package/dist/unplugin/tunnel.js +1 -1
- package/package.json +1 -1
- package/dist/debug-server-BSbfnFR5.js.map +0 -1
- package/dist/debug-server-qRSRG7YZ.js.map +0 -1
- package/dist/qr-http-server-BxoRp2w7.cjs.map +0 -1
- package/dist/qr-http-server-CV6TA9ZN.cjs.map +0 -1
- package/dist/relay-factory-Cloog1jb.js.map +0 -1
- package/dist/relay-worker-o0M2ndyJ.d.ts.map +0 -1
|
@@ -23,12 +23,13 @@ function createRelayConnectionFactory(opts) {
|
|
|
23
23
|
let family;
|
|
24
24
|
let qrServer;
|
|
25
25
|
let phase = "active";
|
|
26
|
+
let manualPrompt = null;
|
|
26
27
|
return {
|
|
27
28
|
async open() {
|
|
28
29
|
const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-Cjdhir2U.js");
|
|
29
30
|
const { injectDebugIndicator, injectGlobals } = await import("../cell-Cw4fVOl2.js");
|
|
30
31
|
const { loadRelaySecretReadOnly } = await import("../relay-secret-store-BcVrWwTq.js");
|
|
31
|
-
const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-
|
|
32
|
+
const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-Ch5p0ryv.js");
|
|
32
33
|
await loadRelaySecretReadOnly({ projectRoot });
|
|
33
34
|
let resolveTunnelUp;
|
|
34
35
|
const tunnelReady = new Promise((resolve) => {
|
|
@@ -58,13 +59,14 @@ function createRelayConnectionFactory(opts) {
|
|
|
58
59
|
canOpenBrowser: () => !headless
|
|
59
60
|
};
|
|
60
61
|
try {
|
|
61
|
-
const { startQrHttpServer } = await import("../qr-http-server-
|
|
62
|
+
const { startQrHttpServer } = await import("../qr-http-server-DeRrKp_R.js");
|
|
62
63
|
const getDashboardState = () => ({
|
|
63
64
|
tunnel: attachDeps.getTunnelStatus(),
|
|
64
65
|
pages: null,
|
|
65
66
|
attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,
|
|
66
67
|
mode: "relay-dev",
|
|
67
|
-
phase
|
|
68
|
+
phase,
|
|
69
|
+
manualPrompt
|
|
68
70
|
});
|
|
69
71
|
qrServer = await startQrHttpServer(getDashboardState);
|
|
70
72
|
attachDeps.qrHttpServer = qrServer;
|
|
@@ -121,6 +123,10 @@ function createRelayConnectionFactory(opts) {
|
|
|
121
123
|
phase = next;
|
|
122
124
|
qrServer?.notifyStateChange();
|
|
123
125
|
},
|
|
126
|
+
onManualPrompt(next) {
|
|
127
|
+
manualPrompt = next;
|
|
128
|
+
qrServer?.notifyStateChange();
|
|
129
|
+
},
|
|
124
130
|
async close(connection) {
|
|
125
131
|
if (phase !== "complete") {
|
|
126
132
|
phase = "complete";
|
|
@@ -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 * 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\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 });\n\n qrServer = await startQrHttpServer(getDashboardState);\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 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 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 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 * 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\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 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,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { EVALUATE_TIMEOUT_MARKER, FileResult, RelayRunOptions, RelayRunReport, flattenResults, runTestFilesOverRelay };
|
|
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-BMSnlyls.js";
|
|
2
|
+
export { EVALUATE_TIMEOUT_MARKER, FileResult, MANUAL_FILE_TIMEOUT_MS, RelayRunOptions, RelayRunReport, flattenResults, runTestFilesOverRelay };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as
|
|
2
|
-
export { EVALUATE_TIMEOUT_MARKER, flattenResults, runTestFilesOverRelay };
|
|
1
|
+
import { i as runTestFilesOverRelay, n as MANUAL_FILE_TIMEOUT_MS, r as flattenResults, t as EVALUATE_TIMEOUT_MARKER } from "../relay-worker-l34xNuci.js";
|
|
2
|
+
export { EVALUATE_TIMEOUT_MARKER, MANUAL_FILE_TIMEOUT_MS, flattenResults, runTestFilesOverRelay };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as AitCaptureLine } from "../capture-ltuV0gZa.js";
|
|
2
2
|
import { n as TestResult } from "../runtime-9xhN9pr8.js";
|
|
3
|
-
import {
|
|
3
|
+
import { a as RelayRunReport } from "../relay-worker-BMSnlyls.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. */
|
|
@@ -33,6 +33,14 @@ interface RunnerAgnosticFileReport {
|
|
|
33
33
|
skipped?: number;
|
|
34
34
|
/** Per-test results, when the file ran. Error strings are matcher messages only. */
|
|
35
35
|
tests?: TestResult[];
|
|
36
|
+
/**
|
|
37
|
+
* Present + `'manual'` only when this file ran under `--manual-blocking`
|
|
38
|
+
* (devtools#741) — human-attended, real native envelopes. Absent (never
|
|
39
|
+
* `false`) for unattended files: absence-means-unattended is the contract,
|
|
40
|
+
* mirroring `FileResult.mode` in relay-worker.ts. A manual-stamped record
|
|
41
|
+
* must never be diffed against an unattended one as if equivalent.
|
|
42
|
+
*/
|
|
43
|
+
mode?: 'manual';
|
|
36
44
|
}
|
|
37
45
|
/**
|
|
38
46
|
* The runner-neutral, secret-free report written to disk. There are
|
|
@@ -64,21 +72,35 @@ interface RunnerAgnosticReport {
|
|
|
64
72
|
declare function serializeRelayReport(report: RelayRunReport, meta: ReportCellMeta): RunnerAgnosticReport;
|
|
65
73
|
/**
|
|
66
74
|
* Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating
|
|
67
|
-
* `dir` if needed. Returns the absolute path written.
|
|
75
|
+
* `dir` if needed. Returns the absolute path(s) written.
|
|
68
76
|
*
|
|
69
77
|
* The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as
|
|
70
78
|
* distinct artifacts in the same directory; the same cell metadata is also baked
|
|
71
79
|
* into the body so a renamed/moved file still carries its provenance.
|
|
72
80
|
*
|
|
81
|
+
* Manual-run provenance (devtools#741): when `report.files` contains ANY
|
|
82
|
+
* `mode: 'manual'` entry (i.e. this run included `--manual-blocking` files),
|
|
83
|
+
* the manual-tagged files are written to a SEPARATE
|
|
84
|
+
* `<dir>/<sdkLine>.<platform>.manual.json` artifact instead of the standard
|
|
85
|
+
* one — the standard `<sdkLine>.<platform>.json` filename is reserved for the
|
|
86
|
+
* regular (unattended) files only, so a manual run's presence never mutates
|
|
87
|
+
* what the unattended-baseline filename means. If ALL files in the run are
|
|
88
|
+
* regular, only the standard artifact is written (today's behavior,
|
|
89
|
+
* unchanged). If a run mixes both (regular files + `--manual-blocking`
|
|
90
|
+
* scheduled last), BOTH artifacts are written — the manual one ALONGSIDE, not
|
|
91
|
+
* replacing, the standard one.
|
|
92
|
+
*
|
|
73
93
|
* SECRET-HANDLING: the written body contains no relay/secret fields (the schema
|
|
74
94
|
* has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
|
|
75
95
|
*
|
|
76
96
|
* @param report - The core relay run report.
|
|
77
97
|
* @param dir - Output directory (created recursively if missing).
|
|
78
98
|
* @param meta - Cell axes + projectRoot.
|
|
79
|
-
* @returns The absolute path
|
|
99
|
+
* @returns The absolute path(s) written, in order: standard first (if any
|
|
100
|
+
* regular files ran), then manual (if any manual files ran). At least one
|
|
101
|
+
* path is always returned when `report.files` is non-empty.
|
|
80
102
|
*/
|
|
81
|
-
declare function writeReportArtifact(report: RelayRunReport, dir: string, meta: ReportCellMeta): Promise<string>;
|
|
103
|
+
declare function writeReportArtifact(report: RelayRunReport, dir: string, meta: ReportCellMeta): Promise<string[]>;
|
|
82
104
|
/**
|
|
83
105
|
* Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,
|
|
84
106
|
* named `<category>.<sdkLine>.<platform>.json` — the SAME convention
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"report.d.ts","names":[],"sources":["../../src/test-runner/report.ts"],"mappings":";;;;;;UA+BiB,cAAA;
|
|
1
|
+
{"version":3,"file":"report.d.ts","names":[],"sources":["../../src/test-runner/report.ts"],"mappings":";;;;;;UA+BiB,cAAA;EAqCX;EAnCJ,OAAA;EA2CmC;EAzCnC,QAAA;EAmD+B;;;;;EA7C/B,WAAA;AAAA;;UAIe,wBAAA;EAyCR;;;AA4BT;;EA/DE,IAAA;EAgEQ;EA9DR,KAAA;EAgEC;EA9DD,QAAA;EACA,MAAA;EACA,MAAA;EACA,OAAA;EA0DM;EAxDN,KAAA,GAAQ,UAAA;EAyDP;;;AAyDH;;;;EA1GE,IAAA;AAAA;;;;;;UAQe,oBAAA;EAqGf;EAnGA,IAAA;IAAQ,OAAA;IAAiB,QAAA;EAAA;EAyJgB;EAvJzC,SAAA;EAwJwB;EAtJxB,QAAA;EAyJC;EAvJD,MAAA,EAAQ,cAAA;EAuJA;EArJR,KAAA,EAAO,wBAAA;AAAA;;;;;;;;iBA4BO,oBAAA,CACd,MAAA,EAAQ,cAAA,EACR,IAAA,EAAM,cAAA,GACL,oBAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyDmB,mBAAA,CACpB,MAAA,EAAQ,cAAA,EACR,GAAA,UACA,IAAA,EAAM,cAAA,GACL,OAAA;;;;;;;;;;;;;;;;;;;iBAqDmB,qBAAA,CACpB,QAAA,EAAU,aAAA,CAAc,cAAA,GACxB,GAAA,UACA,IAAA;EAAQ,OAAA;EAAiB,QAAA;AAAA,IACxB,OAAA"}
|
|
@@ -55,9 +55,11 @@ function serializeRelayReport(report, meta) {
|
|
|
55
55
|
totals: report.totals,
|
|
56
56
|
files: report.files.map((f) => {
|
|
57
57
|
const file = relativise(meta.projectRoot, f.file);
|
|
58
|
+
const modeField = f.mode === "manual" ? { mode: "manual" } : {};
|
|
58
59
|
if ("error" in f.result) return {
|
|
59
60
|
file,
|
|
60
|
-
error: f.result.error
|
|
61
|
+
error: f.result.error,
|
|
62
|
+
...modeField
|
|
61
63
|
};
|
|
62
64
|
return {
|
|
63
65
|
file,
|
|
@@ -65,33 +67,65 @@ function serializeRelayReport(report, meta) {
|
|
|
65
67
|
passed: f.result.passed,
|
|
66
68
|
failed: f.result.failed,
|
|
67
69
|
skipped: f.result.skipped,
|
|
68
|
-
tests: f.result.tests
|
|
70
|
+
tests: f.result.tests,
|
|
71
|
+
...modeField
|
|
69
72
|
};
|
|
70
73
|
})
|
|
71
74
|
};
|
|
72
75
|
}
|
|
73
76
|
/**
|
|
74
77
|
* Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating
|
|
75
|
-
* `dir` if needed. Returns the absolute path written.
|
|
78
|
+
* `dir` if needed. Returns the absolute path(s) written.
|
|
76
79
|
*
|
|
77
80
|
* The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as
|
|
78
81
|
* distinct artifacts in the same directory; the same cell metadata is also baked
|
|
79
82
|
* into the body so a renamed/moved file still carries its provenance.
|
|
80
83
|
*
|
|
84
|
+
* Manual-run provenance (devtools#741): when `report.files` contains ANY
|
|
85
|
+
* `mode: 'manual'` entry (i.e. this run included `--manual-blocking` files),
|
|
86
|
+
* the manual-tagged files are written to a SEPARATE
|
|
87
|
+
* `<dir>/<sdkLine>.<platform>.manual.json` artifact instead of the standard
|
|
88
|
+
* one — the standard `<sdkLine>.<platform>.json` filename is reserved for the
|
|
89
|
+
* regular (unattended) files only, so a manual run's presence never mutates
|
|
90
|
+
* what the unattended-baseline filename means. If ALL files in the run are
|
|
91
|
+
* regular, only the standard artifact is written (today's behavior,
|
|
92
|
+
* unchanged). If a run mixes both (regular files + `--manual-blocking`
|
|
93
|
+
* scheduled last), BOTH artifacts are written — the manual one ALONGSIDE, not
|
|
94
|
+
* replacing, the standard one.
|
|
95
|
+
*
|
|
81
96
|
* SECRET-HANDLING: the written body contains no relay/secret fields (the schema
|
|
82
97
|
* has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
|
|
83
98
|
*
|
|
84
99
|
* @param report - The core relay run report.
|
|
85
100
|
* @param dir - Output directory (created recursively if missing).
|
|
86
101
|
* @param meta - Cell axes + projectRoot.
|
|
87
|
-
* @returns The absolute path
|
|
102
|
+
* @returns The absolute path(s) written, in order: standard first (if any
|
|
103
|
+
* regular files ran), then manual (if any manual files ran). At least one
|
|
104
|
+
* path is always returned when `report.files` is non-empty.
|
|
88
105
|
*/
|
|
89
106
|
async function writeReportArtifact(report, dir, meta) {
|
|
90
107
|
const serialised = serializeRelayReport(report, meta);
|
|
91
108
|
await mkdir(dir, { recursive: true });
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
const regularFiles = serialised.files.filter((f) => f.mode !== "manual");
|
|
110
|
+
const manualFiles = serialised.files.filter((f) => f.mode === "manual");
|
|
111
|
+
const written = [];
|
|
112
|
+
if (regularFiles.length > 0 || serialised.files.length === 0) {
|
|
113
|
+
const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
|
|
114
|
+
await writeFile(outFile, `${JSON.stringify({
|
|
115
|
+
...serialised,
|
|
116
|
+
files: regularFiles
|
|
117
|
+
}, null, 2)}\n`, "utf8");
|
|
118
|
+
written.push(outFile);
|
|
119
|
+
}
|
|
120
|
+
if (manualFiles.length > 0) {
|
|
121
|
+
const manualOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.manual.json`);
|
|
122
|
+
await writeFile(manualOutFile, `${JSON.stringify({
|
|
123
|
+
...serialised,
|
|
124
|
+
files: manualFiles
|
|
125
|
+
}, null, 2)}\n`, "utf8");
|
|
126
|
+
written.push(manualOutFile);
|
|
127
|
+
}
|
|
128
|
+
return written;
|
|
95
129
|
}
|
|
96
130
|
/**
|
|
97
131
|
* Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"report.js","names":[],"sources":["../../src/test-runner/report.ts"],"sourcesContent":["/**\n * Runner-agnostic report serialisation for env3 test runs (devtools#696).\n *\n * Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the\n * standalone `devtools-test` CLI (`cli.ts`) — call the same core\n * `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This\n * module is the single, runner-neutral place that turns that in-memory report\n * into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed\n * cell-by-cell after the fact.\n *\n * The serialised schema is deliberately MINIMAL and secret-free:\n *\n * - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`\n * leakage — see {@link RunnerAgnosticReport.files});\n * - the cell metadata (sdkLine/platform) is baked INTO the body, not only the\n * filename, so a moved artifact never loses its provenance;\n * - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all\n * (enforced by the type + this comment) — error strings are the matcher\n * message only, inherited from rpc.ts which already strips expression/value.\n *\n * react-free — depends only on the type-level `RelayRunReport` and `node:fs` /\n * `node:path`. Safe to bundle without pulling the chii/cloudflared graph.\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport type { AitCaptureLine } from './capture.js';\nimport type { RelayRunReport } from './relay-worker.js';\nimport type { TestResult } from './runtime.js';\n\n/** The cell axes a report is stamped with — the test-matrix coordinates. */\nexport interface ReportCellMeta {\n /** SDK line under test (e.g. `'2.x'` / `'3.x'`). */\n sdkLine: string;\n /** Platform under test (e.g. `'ios'` / `'android'` / `'mock'`). */\n platform: string;\n /**\n * Project root the run was launched from. Used ONLY to relativise file paths\n * out of the serialised report — never stored in the output. SECRET-HANDLING:\n * absolute project paths must not leak into artifacts.\n */\n projectRoot: string;\n}\n\n/** Per-file slice of a {@link RunnerAgnosticReport}. */\nexport interface RunnerAgnosticFileReport {\n /**\n * Test file path, RELATIVE to `projectRoot`. Never absolute — `path.relative`\n * strips the machine-specific prefix so the artifact is portable and leaks no\n * local filesystem layout.\n */\n file: string;\n /** Whole-file bundle/inject error (matcher message only), when the file failed. */\n error?: string;\n /** In-page run duration (ms) for this file, when it ran. */\n duration?: number;\n passed?: number;\n failed?: number;\n skipped?: number;\n /** Per-test results, when the file ran. Error strings are matcher messages only. */\n tests?: TestResult[];\n}\n\n/**\n * The runner-neutral, secret-free report written to disk. There are\n * intentionally NO wss/scheme/TOTP/relayUrl fields on this type — the absence is\n * load-bearing (SECRET-HANDLING) and must not be \"completed\" by a future edit.\n */\nexport interface RunnerAgnosticReport {\n /** Cell axes this run belongs to — baked into the body for portability. */\n cell: { sdkLine: string; platform: string };\n /** ISO timestamp of when the run started (from the core report). */\n startedAt: string;\n /** Total wall-clock ms (bundling + sequential injection). */\n duration: number;\n /** Flattened totals across all files. */\n totals: RelayRunReport['totals'];\n /** Per-file results with projectRoot-relative paths. */\n files: RunnerAgnosticFileReport[];\n}\n\n/**\n * Converts an absolute (or already-relative) file path to a projectRoot-relative\n * one. `path.relative` returns `''` when the paths are equal — guard that to the\n * basename so the field is never empty.\n *\n * SECRET-HANDLING: this is the single choke point that strips absolute project\n * paths from the artifact.\n */\nfunction relativise(projectRoot: string, file: string): string {\n const rel = path.relative(projectRoot, file);\n if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {\n // Outside the project root (or equal) — fall back to the basename rather\n // than emitting an absolute or `../../..` traversal path.\n return path.basename(file);\n }\n return rel;\n}\n\n/**\n * Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free\n * on-disk shape. Pure — no IO; testable with a plain report + meta.\n *\n * @param report - The core relay run report.\n * @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).\n */\nexport function serializeRelayReport(\n report: RelayRunReport,\n meta: ReportCellMeta,\n): RunnerAgnosticReport {\n return {\n cell: { sdkLine: meta.sdkLine, platform: meta.platform },\n startedAt: report.startedAt,\n duration: report.duration,\n totals: report.totals,\n files: report.files.map((f): RunnerAgnosticFileReport => {\n const file = relativise(meta.projectRoot, f.file);\n if ('error' in f.result) {\n // Matcher/inject error message only — rpc.ts already stripped the\n // expression/value upstream.\n return { file, error: f.result.error };\n }\n return {\n file,\n duration: f.result.duration,\n passed: f.result.passed,\n failed: f.result.failed,\n skipped: f.result.skipped,\n tests: f.result.tests,\n };\n }),\n };\n}\n\n/**\n * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating\n * `dir` if needed. Returns the absolute path written.\n *\n * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as\n * distinct artifacts in the same directory; the same cell metadata is also baked\n * into the body so a renamed/moved file still carries its provenance.\n *\n * SECRET-HANDLING: the written body contains no relay/secret fields (the schema\n * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.\n *\n * @param report - The core relay run report.\n * @param dir - Output directory (created recursively if missing).\n * @param meta - Cell axes + projectRoot.\n * @returns The absolute path of the written file.\n */\nexport async function writeReportArtifact(\n report: RelayRunReport,\n dir: string,\n meta: ReportCellMeta,\n): Promise<string> {\n const serialised = serializeRelayReport(report, meta);\n await mkdir(dir, { recursive: true });\n const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);\n await writeFile(outFile, `${JSON.stringify(serialised, null, 2)}\\n`, 'utf8');\n return outFile;\n}\n\n/**\n * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,\n * named `<category>.<sdkLine>.<platform>.json` — the SAME convention\n * sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3\n * capture artifacts line up for diffing.\n *\n * Each line's `json` payload is an opaque JSON array of capture records. Lines\n * sharing a category are concatenated into one array, in harvest order.\n *\n * SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser\n * dropped wss/scheme noise); the `json` payload is written verbatim but is a\n * capture record array, not a relay/secret.\n *\n * @param captures - Parsed capture lines (from `RelayRunReport.captures`).\n * @param dir - Output directory (created recursively if missing).\n * @param cell - Cell axes for the filename suffix.\n * @returns The absolute paths written (one per category), in category order.\n */\nexport async function writeCaptureArtifacts(\n captures: ReadonlyArray<AitCaptureLine>,\n dir: string,\n cell: { sdkLine: string; platform: string },\n): Promise<string[]> {\n if (captures.length === 0) return [];\n\n // Group payloads by category, preserving harvest order. Each payload is an\n // opaque JSON array string; concatenate parsed arrays under the same category.\n const byCategory = new Map<string, unknown[]>();\n for (const { category, json } of captures) {\n let merged = byCategory.get(category);\n if (!merged) {\n merged = [];\n byCategory.set(category, merged);\n }\n // The parser already validated `json` parses; an array payload is expected.\n const parsed = JSON.parse(json) as unknown;\n if (Array.isArray(parsed)) {\n merged.push(...parsed);\n } else {\n merged.push(parsed);\n }\n }\n\n await mkdir(dir, { recursive: true });\n const written: string[] = [];\n for (const [category, records] of byCategory) {\n const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);\n await writeFile(outFile, `${JSON.stringify(records, null, 2)}\\n`, 'utf8');\n written.push(outFile);\n }\n return written;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyFA,SAAS,WAAW,aAAqB,MAAsB;CAC7D,MAAM,MAAM,KAAK,SAAS,aAAa,KAAK;AAC5C,KAAI,QAAQ,MAAM,IAAI,WAAW,KAAK,IAAI,KAAK,WAAW,IAAI,CAG5D,QAAO,KAAK,SAAS,KAAK;AAE5B,QAAO;;;;;;;;;AAUT,SAAgB,qBACd,QACA,MACsB;AACtB,QAAO;EACL,MAAM;GAAE,SAAS,KAAK;GAAS,UAAU,KAAK;GAAU;EACxD,WAAW,OAAO;EAClB,UAAU,OAAO;EACjB,QAAQ,OAAO;EACf,OAAO,OAAO,MAAM,KAAK,MAAgC;GACvD,MAAM,OAAO,WAAW,KAAK,aAAa,EAAE,KAAK;AACjD,OAAI,WAAW,EAAE,OAGf,QAAO;IAAE;IAAM,OAAO,EAAE,OAAO;IAAO;AAExC,UAAO;IACL;IACA,UAAU,EAAE,OAAO;IACnB,QAAQ,EAAE,OAAO;IACjB,QAAQ,EAAE,OAAO;IACjB,SAAS,EAAE,OAAO;IAClB,OAAO,EAAE,OAAO;IACjB;IACD;EACH;;;;;;;;;;;;;;;;;;AAmBH,eAAsB,oBACpB,QACA,KACA,MACiB;CACjB,MAAM,aAAa,qBAAqB,QAAQ,KAAK;AACrD,OAAM,MAAM,KAAK,EAAE,WAAW,MAAM,CAAC;CACrC,MAAM,UAAU,KAAK,KAAK,KAAK,GAAG,KAAK,QAAQ,GAAG,KAAK,SAAS,OAAO;AACvE,OAAM,UAAU,SAAS,GAAG,KAAK,UAAU,YAAY,MAAM,EAAE,CAAC,KAAK,OAAO;AAC5E,QAAO;;;;;;;;;;;;;;;;;;;;AAqBT,eAAsB,sBACpB,UACA,KACA,MACmB;AACnB,KAAI,SAAS,WAAW,EAAG,QAAO,EAAE;CAIpC,MAAM,6BAAa,IAAI,KAAwB;AAC/C,MAAK,MAAM,EAAE,UAAU,UAAU,UAAU;EACzC,IAAI,SAAS,WAAW,IAAI,SAAS;AACrC,MAAI,CAAC,QAAQ;AACX,YAAS,EAAE;AACX,cAAW,IAAI,UAAU,OAAO;;EAGlC,MAAM,SAAS,KAAK,MAAM,KAAK;AAC/B,MAAI,MAAM,QAAQ,OAAO,CACvB,QAAO,KAAK,GAAG,OAAO;MAEtB,QAAO,KAAK,OAAO;;AAIvB,OAAM,MAAM,KAAK,EAAE,WAAW,MAAM,CAAC;CACrC,MAAM,UAAoB,EAAE;AAC5B,MAAK,MAAM,CAAC,UAAU,YAAY,YAAY;EAC5C,MAAM,UAAU,KAAK,KAAK,KAAK,GAAG,SAAS,GAAG,KAAK,QAAQ,GAAG,KAAK,SAAS,OAAO;AACnF,QAAM,UAAU,SAAS,GAAG,KAAK,UAAU,SAAS,MAAM,EAAE,CAAC,KAAK,OAAO;AACzE,UAAQ,KAAK,QAAQ;;AAEvB,QAAO"}
|
|
1
|
+
{"version":3,"file":"report.js","names":[],"sources":["../../src/test-runner/report.ts"],"sourcesContent":["/**\n * Runner-agnostic report serialisation for env3 test runs (devtools#696).\n *\n * Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the\n * standalone `devtools-test` CLI (`cli.ts`) — call the same core\n * `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This\n * module is the single, runner-neutral place that turns that in-memory report\n * into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed\n * cell-by-cell after the fact.\n *\n * The serialised schema is deliberately MINIMAL and secret-free:\n *\n * - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`\n * leakage — see {@link RunnerAgnosticReport.files});\n * - the cell metadata (sdkLine/platform) is baked INTO the body, not only the\n * filename, so a moved artifact never loses its provenance;\n * - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all\n * (enforced by the type + this comment) — error strings are the matcher\n * message only, inherited from rpc.ts which already strips expression/value.\n *\n * react-free — depends only on the type-level `RelayRunReport` and `node:fs` /\n * `node:path`. Safe to bundle without pulling the chii/cloudflared graph.\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport type { AitCaptureLine } from './capture.js';\nimport type { RelayRunReport } from './relay-worker.js';\nimport type { TestResult } from './runtime.js';\n\n/** The cell axes a report is stamped with — the test-matrix coordinates. */\nexport interface ReportCellMeta {\n /** SDK line under test (e.g. `'2.x'` / `'3.x'`). */\n sdkLine: string;\n /** Platform under test (e.g. `'ios'` / `'android'` / `'mock'`). */\n platform: string;\n /**\n * Project root the run was launched from. Used ONLY to relativise file paths\n * out of the serialised report — never stored in the output. SECRET-HANDLING:\n * absolute project paths must not leak into artifacts.\n */\n projectRoot: string;\n}\n\n/** Per-file slice of a {@link RunnerAgnosticReport}. */\nexport interface RunnerAgnosticFileReport {\n /**\n * Test file path, RELATIVE to `projectRoot`. Never absolute — `path.relative`\n * strips the machine-specific prefix so the artifact is portable and leaks no\n * local filesystem layout.\n */\n file: string;\n /** Whole-file bundle/inject error (matcher message only), when the file failed. */\n error?: string;\n /** In-page run duration (ms) for this file, when it ran. */\n duration?: number;\n passed?: number;\n failed?: number;\n skipped?: number;\n /** Per-test results, when the file ran. Error strings are matcher messages only. */\n tests?: TestResult[];\n /**\n * Present + `'manual'` only when this file ran under `--manual-blocking`\n * (devtools#741) — human-attended, real native envelopes. Absent (never\n * `false`) for unattended files: absence-means-unattended is the contract,\n * mirroring `FileResult.mode` in relay-worker.ts. A manual-stamped record\n * must never be diffed against an unattended one as if equivalent.\n */\n mode?: 'manual';\n}\n\n/**\n * The runner-neutral, secret-free report written to disk. There are\n * intentionally NO wss/scheme/TOTP/relayUrl fields on this type — the absence is\n * load-bearing (SECRET-HANDLING) and must not be \"completed\" by a future edit.\n */\nexport interface RunnerAgnosticReport {\n /** Cell axes this run belongs to — baked into the body for portability. */\n cell: { sdkLine: string; platform: string };\n /** ISO timestamp of when the run started (from the core report). */\n startedAt: string;\n /** Total wall-clock ms (bundling + sequential injection). */\n duration: number;\n /** Flattened totals across all files. */\n totals: RelayRunReport['totals'];\n /** Per-file results with projectRoot-relative paths. */\n files: RunnerAgnosticFileReport[];\n}\n\n/**\n * Converts an absolute (or already-relative) file path to a projectRoot-relative\n * one. `path.relative` returns `''` when the paths are equal — guard that to the\n * basename so the field is never empty.\n *\n * SECRET-HANDLING: this is the single choke point that strips absolute project\n * paths from the artifact.\n */\nfunction relativise(projectRoot: string, file: string): string {\n const rel = path.relative(projectRoot, file);\n if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {\n // Outside the project root (or equal) — fall back to the basename rather\n // than emitting an absolute or `../../..` traversal path.\n return path.basename(file);\n }\n return rel;\n}\n\n/**\n * Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free\n * on-disk shape. Pure — no IO; testable with a plain report + meta.\n *\n * @param report - The core relay run report.\n * @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).\n */\nexport function serializeRelayReport(\n report: RelayRunReport,\n meta: ReportCellMeta,\n): RunnerAgnosticReport {\n return {\n cell: { sdkLine: meta.sdkLine, platform: meta.platform },\n startedAt: report.startedAt,\n duration: report.duration,\n totals: report.totals,\n files: report.files.map((f): RunnerAgnosticFileReport => {\n const file = relativise(meta.projectRoot, f.file);\n const modeField = f.mode === 'manual' ? ({ mode: 'manual' } as const) : {};\n if ('error' in f.result) {\n // Matcher/inject error message only — rpc.ts already stripped the\n // expression/value upstream.\n return { file, error: f.result.error, ...modeField };\n }\n return {\n file,\n duration: f.result.duration,\n passed: f.result.passed,\n failed: f.result.failed,\n skipped: f.result.skipped,\n tests: f.result.tests,\n ...modeField,\n };\n }),\n };\n}\n\n/**\n * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating\n * `dir` if needed. Returns the absolute path(s) written.\n *\n * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as\n * distinct artifacts in the same directory; the same cell metadata is also baked\n * into the body so a renamed/moved file still carries its provenance.\n *\n * Manual-run provenance (devtools#741): when `report.files` contains ANY\n * `mode: 'manual'` entry (i.e. this run included `--manual-blocking` files),\n * the manual-tagged files are written to a SEPARATE\n * `<dir>/<sdkLine>.<platform>.manual.json` artifact instead of the standard\n * one — the standard `<sdkLine>.<platform>.json` filename is reserved for the\n * regular (unattended) files only, so a manual run's presence never mutates\n * what the unattended-baseline filename means. If ALL files in the run are\n * regular, only the standard artifact is written (today's behavior,\n * unchanged). If a run mixes both (regular files + `--manual-blocking`\n * scheduled last), BOTH artifacts are written — the manual one ALONGSIDE, not\n * replacing, the standard one.\n *\n * SECRET-HANDLING: the written body contains no relay/secret fields (the schema\n * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.\n *\n * @param report - The core relay run report.\n * @param dir - Output directory (created recursively if missing).\n * @param meta - Cell axes + projectRoot.\n * @returns The absolute path(s) written, in order: standard first (if any\n * regular files ran), then manual (if any manual files ran). At least one\n * path is always returned when `report.files` is non-empty.\n */\nexport async function writeReportArtifact(\n report: RelayRunReport,\n dir: string,\n meta: ReportCellMeta,\n): Promise<string[]> {\n const serialised = serializeRelayReport(report, meta);\n await mkdir(dir, { recursive: true });\n\n const regularFiles = serialised.files.filter((f) => f.mode !== 'manual');\n const manualFiles = serialised.files.filter((f) => f.mode === 'manual');\n\n const written: string[] = [];\n\n // Standard artifact: written whenever there are regular files, OR when the\n // whole run is empty (preserves the pre-#741 \"always write one file\"\n // behavior for a run with zero files, e.g. an empty glob match).\n if (regularFiles.length > 0 || serialised.files.length === 0) {\n const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);\n await writeFile(\n outFile,\n `${JSON.stringify({ ...serialised, files: regularFiles }, null, 2)}\\n`,\n 'utf8',\n );\n written.push(outFile);\n }\n\n if (manualFiles.length > 0) {\n const manualOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.manual.json`);\n await writeFile(\n manualOutFile,\n `${JSON.stringify({ ...serialised, files: manualFiles }, null, 2)}\\n`,\n 'utf8',\n );\n written.push(manualOutFile);\n }\n\n return written;\n}\n\n/**\n * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,\n * named `<category>.<sdkLine>.<platform>.json` — the SAME convention\n * sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3\n * capture artifacts line up for diffing.\n *\n * Each line's `json` payload is an opaque JSON array of capture records. Lines\n * sharing a category are concatenated into one array, in harvest order.\n *\n * SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser\n * dropped wss/scheme noise); the `json` payload is written verbatim but is a\n * capture record array, not a relay/secret.\n *\n * @param captures - Parsed capture lines (from `RelayRunReport.captures`).\n * @param dir - Output directory (created recursively if missing).\n * @param cell - Cell axes for the filename suffix.\n * @returns The absolute paths written (one per category), in category order.\n */\nexport async function writeCaptureArtifacts(\n captures: ReadonlyArray<AitCaptureLine>,\n dir: string,\n cell: { sdkLine: string; platform: string },\n): Promise<string[]> {\n if (captures.length === 0) return [];\n\n // Group payloads by category, preserving harvest order. Each payload is an\n // opaque JSON array string; concatenate parsed arrays under the same category.\n const byCategory = new Map<string, unknown[]>();\n for (const { category, json } of captures) {\n let merged = byCategory.get(category);\n if (!merged) {\n merged = [];\n byCategory.set(category, merged);\n }\n // The parser already validated `json` parses; an array payload is expected.\n const parsed = JSON.parse(json) as unknown;\n if (Array.isArray(parsed)) {\n merged.push(...parsed);\n } else {\n merged.push(parsed);\n }\n }\n\n await mkdir(dir, { recursive: true });\n const written: string[] = [];\n for (const [category, records] of byCategory) {\n const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);\n await writeFile(outFile, `${JSON.stringify(records, null, 2)}\\n`, 'utf8');\n written.push(outFile);\n }\n return written;\n}\n"],"mappings":"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"}
|
|
@@ -154,7 +154,7 @@ async function startTunnelDashboard(opts) {
|
|
|
154
154
|
if (opts.qr === false) return void 0;
|
|
155
155
|
const { isAutoDevtoolsDisabled } = await Promise.resolve().then(() => require("./devtools-opener-iv1OwfJN.cjs"));
|
|
156
156
|
if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
|
|
157
|
-
const { startQrHttpServer } = await Promise.resolve().then(() => require("./qr-http-server-
|
|
157
|
+
const { startQrHttpServer } = await Promise.resolve().then(() => require("./qr-http-server-iH_Hh_si.cjs"));
|
|
158
158
|
const { buildLauncherAttachUrl } = await Promise.resolve().then(() => require("./deeplink-BzdbA1gV.cjs"));
|
|
159
159
|
const { generateTotp } = await Promise.resolve().then(() => require("./totp-Df252ZdA.cjs"));
|
|
160
160
|
const getDashboardState = () => {
|
|
@@ -291,4 +291,4 @@ exports.printTunnelBanner = printTunnelBanner;
|
|
|
291
291
|
exports.startQuickTunnel = startQuickTunnel;
|
|
292
292
|
exports.startTunnelDashboard = startTunnelDashboard;
|
|
293
293
|
|
|
294
|
-
//# sourceMappingURL=tunnel-
|
|
294
|
+
//# sourceMappingURL=tunnel-DP453L4e.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tunnel-B2UCKAc7.cjs","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, the deep-link carries `&navBarType=game`\n * so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the default) is the launcher's implicit default — not added to\n * keep the URL clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, the deep-link carries `&navBarTransparent=1` so the launcher\n * partner bar renders with a transparent background (content shows through).\n * `false` / omitted → not added (URL clean, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (`granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, the deep-link carries\n * `&navBarTheme=<v>` so the launcher partner bar uses the matching foreground\n * colour. Omitted / other values → not added (URL clean, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Options for {@link buildLauncherDeepLink}.\n */\nexport interface BuildLauncherDeepLinkOptions {\n /**\n * `wss://` relay URL for env-2 CDP wiring. When present the deep-link carries\n * `&debug=1&relay=<wss>`.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param, #498).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, adds `&navBarType=game` to the\n * deep-link so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the launcher's implicit default) is not added to keep the URL\n * clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, adds `&navBarTransparent=1` to the deep-link so the launcher\n * partner bar renders with a transparent background. Omitted when `false` /\n * undefined to keep the URL clean (back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, adds `&navBarTheme=<v>` to\n * the deep-link so the launcher partner bar uses the matching foreground colour.\n * Omitted when undefined / other values to keep the URL clean (back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `opts.relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the launcher\n * partner bar shows the app name instead of the generic default (#498).\n *\n * When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the\n * launcher enters game nav chrome (floating capsule, no full bar) automatically\n * on scan. `'partner'` is the launcher's implicit default and is not added to\n * keep the URL clean (#584).\n *\n * When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended\n * so the launcher partner bar renders with a transparent background (#587).\n *\n * When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is\n * appended so the launcher partner bar uses the matching foreground colour (#587).\n *\n * Back-compat: the second argument may also be a plain string (`relayWssUrl`)\n * for callers that haven't migrated to the options object yet.\n */\nexport function buildLauncherDeepLink(\n tunnelUrl: string,\n optsOrRelay?: string | BuildLauncherDeepLinkOptions,\n): string {\n // Normalise the overloaded second argument.\n const opts: BuildLauncherDeepLinkOptions =\n typeof optsOrRelay === 'string' ? { relayWssUrl: optsOrRelay } : (optsOrRelay ?? {});\n\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n let url = base;\n if (opts.relayWssUrl) {\n url += `&debug=1&relay=${encodeURIComponent(opts.relayWssUrl)}`;\n }\n if (opts.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts.webViewType === 'game') {\n url += '&navBarType=game';\n }\n if (opts.navBarTransparent === true) {\n url += '&navBarTransparent=1';\n }\n if (opts.navBarTheme === 'light' || opts.navBarTheme === 'dark') {\n url += `&navBarTheme=${opts.navBarTheme}`;\n }\n return url;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, {\n relayWssUrl: opts.relayWssUrl,\n name: opts.name,\n webViewType: opts.webViewType,\n navBarTransparent: opts.navBarTransparent,\n navBarTheme: opts.navBarTheme,\n });\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\n/**\n * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in\n * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path\n * does not statically pull the heavy MCP `tools.ts` module graph into the lazy\n * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.\n *\n * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).\n * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.\n * - CI (`CI=true`/`CI=1`) → no.\n */\nfunction canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/** Handle returned by {@link startTunnelDashboard}. */\nexport interface TunnelDashboard {\n /** `http://127.0.0.1:<port>` — the local dashboard URL opened in the browser. */\n url: string;\n /** Tear down the local HTTP server. Idempotent via the underlying server. */\n close: () => Promise<void>;\n}\n\nexport interface StartTunnelDashboardOptions {\n /** The public `https://*.trycloudflare.com` app tunnel URL the launcher frames. */\n tunnelUrl: string;\n /** The `wss://` relay URL of the env-2 CDP tunnel. REQUIRED — the dashboard is a CDP-only UX. */\n relayWssUrl: string;\n /** Mirror of `tunnel.qr` — when `false` the dashboard is skipped (no browser open). */\n qr?: boolean;\n /**\n * Override the GUI/opt-out gate (testing only). When omitted the real\n * `canOpenBrowser()` + `AIT_AUTO_DEVTOOLS` checks decide.\n */\n shouldOpen?: () => boolean;\n /** Sink for the one-line \"opened in browser\" note (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n}\n\n/**\n * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is\n * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps\n * + FAQ) that the MCP `start_attach` path serves, and auto-open it in the\n * browser. headless / opt-out falls back to the terminal ASCII QR (printed\n * separately by {@link printTunnelBanner}).\n *\n * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP\n * server, the opener) is reached only through dynamic `import()` here, inside\n * the already-lazy `tunnel.js` chunk — nothing is added to the common build\n * graph or the MCP-only install graph.\n *\n * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH\n * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds\n * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on\n * each SSE push / page reload from this closure, the code a phone scans is always\n * within its 30 s window — no stale code is baked into static HTML.\n *\n * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`\n * value/path are NEVER written to stdout/stderr/logs here. They live only inside\n * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).\n * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).\n *\n * @returns the dashboard handle when it started (caller wires `close()` into the\n * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,\n * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.\n */\nexport async function startTunnelDashboard(\n opts: StartTunnelDashboardOptions,\n): Promise<TunnelDashboard | undefined> {\n const log = opts.log ?? ((m: string) => console.log(m));\n\n // Gate: dashboard is a CDP-only UX (needs a relay to attach to).\n if (!opts.relayWssUrl) return undefined;\n // Opt-out via `tunnel.qr:false` (same toggle that suppresses the ASCII QR).\n if (opts.qr === false) return undefined;\n\n // GUI + AIT_AUTO_DEVTOOLS gate. Reuse the MCP opener's opt-out predicate so\n // the env-2 path honours the same `AIT_AUTO_DEVTOOLS=0` switch as env 3/4.\n const { isAutoDevtoolsDisabled } = await import('../mcp/devtools-opener.js');\n const gateOpen = opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser());\n if (!gateOpen()) return undefined;\n\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n const { buildLauncherAttachUrl } = await import('../mcp/deeplink.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // getDashboardState — mints a fresh TOTP + attach URL on every call so the QR\n // the dashboard renders (on load and on each SSE push) is never expired.\n // SECRET-HANDLING: the secret is read from env AT CALL TIME (it was injected\n // by ensureRelaySecret in the same CDP block) and is used only to compute the\n // at= code folded into attachUrl. tunnel.up is always true here — the relay\n // tunnel is already up by the time this runs.\n const getDashboardState = () => {\n const secret = process.env.AIT_DEBUG_TOTP_SECRET;\n const totpCode = secret ? generateTotp(secret, Date.now()) : undefined;\n const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode, {\n name: opts.name,\n });\n // pages: null — env 2(unplugin)는 데몬이 아니라 vite 플러그인 안이라\n // startChiiRelay 핸들이 connected target을 노출하지 않는다. 라이브 page 목록을\n // 알 수 없으므로 거짓 빈 목록 대신 \"연결된 Pages\" 섹션 자체를 숨긴다(#411).\n // env 3/4(debug-server.ts)는 router.active.listTargets()로 실제 목록을 채운다.\n // mode: 'relay-mobile' — 이 대시보드는 항상 환경 2(AITC Sandbox PWA) 전용이므로\n // /attach 카피가 launcher PWA 절차(sandbox family)로 분기된다(#468).\n // inspectorUrl: null — env 2에서는 unplugin relay가 connected target ID를 노출하지\n // 않아 buildChiiInspectorUrl에 필요한 targetId를 알 수 없다. target attach 후\n // target ID가 필요하므로 env 3/4에서만 non-null이 된다(#503).\n return {\n tunnel: { up: true, wssUrl: opts.relayWssUrl },\n pages: null,\n attachUrl,\n inspectorUrl: null,\n mode: 'relay-mobile' as const,\n // phase (#730): this dev-tunnel dashboard has no CLI run lifecycle or\n // daemon shutdown signal to drive — it stays 'active' for its lifetime.\n phase: 'active' as const,\n };\n };\n\n let server: Awaited<ReturnType<typeof startQrHttpServer>>;\n try {\n server = await startQrHttpServer(getDashboardState);\n } catch {\n // SECRET-HANDLING: do not surface the error (could embed paths/hosts). The\n // ASCII QR printed by printTunnelBanner stays as the fallback.\n return undefined;\n }\n\n // TOTP periodic refresh timer — pushes a fresh at= code to SSE clients every\n // 20 s so a page left open never stales past the 90 s acceptance window (#448).\n // tunnel.ts always has relayWssUrl available here (gated above), so no\n // lastAttachParts guard is needed — getDashboardState mints a fresh TOTP on\n // every call unconditionally.\n // SECRET-HANDLING: callback is a plain trigger only — TOTP value and at= code\n // must never be logged or written to stdout.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = setInterval(() => {\n server.notifyStateChange();\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\n\n const dashboardUrl = `http://127.0.0.1:${server.port}`;\n\n const { openUrlInBrowser } = await import('../mcp/devtools-opener.js');\n const opened = openUrlInBrowser(dashboardUrl);\n // SECRET-HANDLING: only the local 127.0.0.1 URL is logged — never the tunnel\n // host, relay wssUrl, or TOTP code.\n log(\n opened\n ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}`\n : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`,\n );\n\n return {\n url: dashboardUrl,\n close: () => {\n if (totpRefreshHandle) {\n clearInterval(totpRefreshHandle);\n totpRefreshHandle = null;\n }\n return server.close();\n },\n };\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\n/**\n * Sanitize cloudflared stderr output for error diagnostics (#421).\n *\n * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs\n * that carry those hostnames so tunnel host values never appear in error\n * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.\n *\n * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only\n * placeholder text is emitted.\n */\nexport function sanitizeCloudflaredOutput(line: string): string {\n // Full URL forms: https://xxx.trycloudflare.com/… and wss://xxx.trycloudflare.com/…\n let s = line.replace(/(?:https?|wss?):\\/\\/[a-z0-9-]+\\.trycloudflare\\.com(?:\\/[^\\s]*)*/gi, (m) =>\n m.replace(/[a-z0-9-]+\\.trycloudflare\\.com/i, '<HOST>.trycloudflare.com'),\n );\n // Bare hostname without scheme (e.g. printed in cloudflared JSON logs)\n s = s.replace(/[a-z0-9-]+\\.trycloudflare\\.com/gi, '<HOST>.trycloudflare.com');\n return s;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n // #421: accumulate stderr to attach as diagnostics on failure.\n // SECRET-HANDLING: lines are sanitized before inclusion in error messages.\n const stderrLines: string[] = [];\n\n /**\n * Format the last `n` sanitized stderr lines as a diagnostic appendix.\n * Returns an empty string when no lines have been collected.\n */\n const stderrTail = (n = 15): string => {\n if (stderrLines.length === 0) return '';\n const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join('');\n return `\\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\\n${tail}`;\n };\n\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.${stderrTail()}`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n // Accumulate stderr lines for diagnostics (#421). Named so it can be\n // removed from the listener list when cleanup() runs.\n const pushStderr = (line: string) => {\n stderrLines.push(line);\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n tunnel.off('stderr', pushStderr);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n // Second stderr listener: accumulate all lines for error diagnostics.\n tunnel.on('stderr', pushStderr);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.${stderrTail()}`,\n ),\n );\n });\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tunnel-DP453L4e.cjs","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, the deep-link carries `&navBarType=game`\n * so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the default) is the launcher's implicit default — not added to\n * keep the URL clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, the deep-link carries `&navBarTransparent=1` so the launcher\n * partner bar renders with a transparent background (content shows through).\n * `false` / omitted → not added (URL clean, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (`granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, the deep-link carries\n * `&navBarTheme=<v>` so the launcher partner bar uses the matching foreground\n * colour. Omitted / other values → not added (URL clean, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Options for {@link buildLauncherDeepLink}.\n */\nexport interface BuildLauncherDeepLinkOptions {\n /**\n * `wss://` relay URL for env-2 CDP wiring. When present the deep-link carries\n * `&debug=1&relay=<wss>`.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param, #498).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, adds `&navBarType=game` to the\n * deep-link so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the launcher's implicit default) is not added to keep the URL\n * clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, adds `&navBarTransparent=1` to the deep-link so the launcher\n * partner bar renders with a transparent background. Omitted when `false` /\n * undefined to keep the URL clean (back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, adds `&navBarTheme=<v>` to\n * the deep-link so the launcher partner bar uses the matching foreground colour.\n * Omitted when undefined / other values to keep the URL clean (back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `opts.relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the launcher\n * partner bar shows the app name instead of the generic default (#498).\n *\n * When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the\n * launcher enters game nav chrome (floating capsule, no full bar) automatically\n * on scan. `'partner'` is the launcher's implicit default and is not added to\n * keep the URL clean (#584).\n *\n * When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended\n * so the launcher partner bar renders with a transparent background (#587).\n *\n * When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is\n * appended so the launcher partner bar uses the matching foreground colour (#587).\n *\n * Back-compat: the second argument may also be a plain string (`relayWssUrl`)\n * for callers that haven't migrated to the options object yet.\n */\nexport function buildLauncherDeepLink(\n tunnelUrl: string,\n optsOrRelay?: string | BuildLauncherDeepLinkOptions,\n): string {\n // Normalise the overloaded second argument.\n const opts: BuildLauncherDeepLinkOptions =\n typeof optsOrRelay === 'string' ? { relayWssUrl: optsOrRelay } : (optsOrRelay ?? {});\n\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n let url = base;\n if (opts.relayWssUrl) {\n url += `&debug=1&relay=${encodeURIComponent(opts.relayWssUrl)}`;\n }\n if (opts.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts.webViewType === 'game') {\n url += '&navBarType=game';\n }\n if (opts.navBarTransparent === true) {\n url += '&navBarTransparent=1';\n }\n if (opts.navBarTheme === 'light' || opts.navBarTheme === 'dark') {\n url += `&navBarTheme=${opts.navBarTheme}`;\n }\n return url;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, {\n relayWssUrl: opts.relayWssUrl,\n name: opts.name,\n webViewType: opts.webViewType,\n navBarTransparent: opts.navBarTransparent,\n navBarTheme: opts.navBarTheme,\n });\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\n/**\n * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in\n * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path\n * does not statically pull the heavy MCP `tools.ts` module graph into the lazy\n * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.\n *\n * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).\n * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.\n * - CI (`CI=true`/`CI=1`) → no.\n */\nfunction canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/** Handle returned by {@link startTunnelDashboard}. */\nexport interface TunnelDashboard {\n /** `http://127.0.0.1:<port>` — the local dashboard URL opened in the browser. */\n url: string;\n /** Tear down the local HTTP server. Idempotent via the underlying server. */\n close: () => Promise<void>;\n}\n\nexport interface StartTunnelDashboardOptions {\n /** The public `https://*.trycloudflare.com` app tunnel URL the launcher frames. */\n tunnelUrl: string;\n /** The `wss://` relay URL of the env-2 CDP tunnel. REQUIRED — the dashboard is a CDP-only UX. */\n relayWssUrl: string;\n /** Mirror of `tunnel.qr` — when `false` the dashboard is skipped (no browser open). */\n qr?: boolean;\n /**\n * Override the GUI/opt-out gate (testing only). When omitted the real\n * `canOpenBrowser()` + `AIT_AUTO_DEVTOOLS` checks decide.\n */\n shouldOpen?: () => boolean;\n /** Sink for the one-line \"opened in browser\" note (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n}\n\n/**\n * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is\n * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps\n * + FAQ) that the MCP `start_attach` path serves, and auto-open it in the\n * browser. headless / opt-out falls back to the terminal ASCII QR (printed\n * separately by {@link printTunnelBanner}).\n *\n * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP\n * server, the opener) is reached only through dynamic `import()` here, inside\n * the already-lazy `tunnel.js` chunk — nothing is added to the common build\n * graph or the MCP-only install graph.\n *\n * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH\n * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds\n * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on\n * each SSE push / page reload from this closure, the code a phone scans is always\n * within its 30 s window — no stale code is baked into static HTML.\n *\n * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`\n * value/path are NEVER written to stdout/stderr/logs here. They live only inside\n * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).\n * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).\n *\n * @returns the dashboard handle when it started (caller wires `close()` into the\n * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,\n * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.\n */\nexport async function startTunnelDashboard(\n opts: StartTunnelDashboardOptions,\n): Promise<TunnelDashboard | undefined> {\n const log = opts.log ?? ((m: string) => console.log(m));\n\n // Gate: dashboard is a CDP-only UX (needs a relay to attach to).\n if (!opts.relayWssUrl) return undefined;\n // Opt-out via `tunnel.qr:false` (same toggle that suppresses the ASCII QR).\n if (opts.qr === false) return undefined;\n\n // GUI + AIT_AUTO_DEVTOOLS gate. Reuse the MCP opener's opt-out predicate so\n // the env-2 path honours the same `AIT_AUTO_DEVTOOLS=0` switch as env 3/4.\n const { isAutoDevtoolsDisabled } = await import('../mcp/devtools-opener.js');\n const gateOpen = opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser());\n if (!gateOpen()) return undefined;\n\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n const { buildLauncherAttachUrl } = await import('../mcp/deeplink.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // getDashboardState — mints a fresh TOTP + attach URL on every call so the QR\n // the dashboard renders (on load and on each SSE push) is never expired.\n // SECRET-HANDLING: the secret is read from env AT CALL TIME (it was injected\n // by ensureRelaySecret in the same CDP block) and is used only to compute the\n // at= code folded into attachUrl. tunnel.up is always true here — the relay\n // tunnel is already up by the time this runs.\n const getDashboardState = () => {\n const secret = process.env.AIT_DEBUG_TOTP_SECRET;\n const totpCode = secret ? generateTotp(secret, Date.now()) : undefined;\n const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode, {\n name: opts.name,\n });\n // pages: null — env 2(unplugin)는 데몬이 아니라 vite 플러그인 안이라\n // startChiiRelay 핸들이 connected target을 노출하지 않는다. 라이브 page 목록을\n // 알 수 없으므로 거짓 빈 목록 대신 \"연결된 Pages\" 섹션 자체를 숨긴다(#411).\n // env 3/4(debug-server.ts)는 router.active.listTargets()로 실제 목록을 채운다.\n // mode: 'relay-mobile' — 이 대시보드는 항상 환경 2(AITC Sandbox PWA) 전용이므로\n // /attach 카피가 launcher PWA 절차(sandbox family)로 분기된다(#468).\n // inspectorUrl: null — env 2에서는 unplugin relay가 connected target ID를 노출하지\n // 않아 buildChiiInspectorUrl에 필요한 targetId를 알 수 없다. target attach 후\n // target ID가 필요하므로 env 3/4에서만 non-null이 된다(#503).\n return {\n tunnel: { up: true, wssUrl: opts.relayWssUrl },\n pages: null,\n attachUrl,\n inspectorUrl: null,\n mode: 'relay-mobile' as const,\n // phase (#730): this dev-tunnel dashboard has no CLI run lifecycle or\n // daemon shutdown signal to drive — it stays 'active' for its lifetime.\n phase: 'active' as const,\n };\n };\n\n let server: Awaited<ReturnType<typeof startQrHttpServer>>;\n try {\n server = await startQrHttpServer(getDashboardState);\n } catch {\n // SECRET-HANDLING: do not surface the error (could embed paths/hosts). The\n // ASCII QR printed by printTunnelBanner stays as the fallback.\n return undefined;\n }\n\n // TOTP periodic refresh timer — pushes a fresh at= code to SSE clients every\n // 20 s so a page left open never stales past the 90 s acceptance window (#448).\n // tunnel.ts always has relayWssUrl available here (gated above), so no\n // lastAttachParts guard is needed — getDashboardState mints a fresh TOTP on\n // every call unconditionally.\n // SECRET-HANDLING: callback is a plain trigger only — TOTP value and at= code\n // must never be logged or written to stdout.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = setInterval(() => {\n server.notifyStateChange();\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\n\n const dashboardUrl = `http://127.0.0.1:${server.port}`;\n\n const { openUrlInBrowser } = await import('../mcp/devtools-opener.js');\n const opened = openUrlInBrowser(dashboardUrl);\n // SECRET-HANDLING: only the local 127.0.0.1 URL is logged — never the tunnel\n // host, relay wssUrl, or TOTP code.\n log(\n opened\n ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}`\n : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`,\n );\n\n return {\n url: dashboardUrl,\n close: () => {\n if (totpRefreshHandle) {\n clearInterval(totpRefreshHandle);\n totpRefreshHandle = null;\n }\n return server.close();\n },\n };\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\n/**\n * Sanitize cloudflared stderr output for error diagnostics (#421).\n *\n * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs\n * that carry those hostnames so tunnel host values never appear in error\n * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.\n *\n * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only\n * placeholder text is emitted.\n */\nexport function sanitizeCloudflaredOutput(line: string): string {\n // Full URL forms: https://xxx.trycloudflare.com/… and wss://xxx.trycloudflare.com/…\n let s = line.replace(/(?:https?|wss?):\\/\\/[a-z0-9-]+\\.trycloudflare\\.com(?:\\/[^\\s]*)*/gi, (m) =>\n m.replace(/[a-z0-9-]+\\.trycloudflare\\.com/i, '<HOST>.trycloudflare.com'),\n );\n // Bare hostname without scheme (e.g. printed in cloudflared JSON logs)\n s = s.replace(/[a-z0-9-]+\\.trycloudflare\\.com/gi, '<HOST>.trycloudflare.com');\n return s;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n // #421: accumulate stderr to attach as diagnostics on failure.\n // SECRET-HANDLING: lines are sanitized before inclusion in error messages.\n const stderrLines: string[] = [];\n\n /**\n * Format the last `n` sanitized stderr lines as a diagnostic appendix.\n * Returns an empty string when no lines have been collected.\n */\n const stderrTail = (n = 15): string => {\n if (stderrLines.length === 0) return '';\n const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join('');\n return `\\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\\n${tail}`;\n };\n\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.${stderrTail()}`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n // Accumulate stderr lines for diagnostics (#421). Named so it can be\n // removed from the listener list when cleanup() runs.\n const pushStderr = (line: string) => {\n stderrLines.push(line);\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n tunnel.off('stderr', pushStderr);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n // Second stderr listener: accumulate all lines for error diagnostics.\n tunnel.on('stderr', pushStderr);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.${stderrTail()}`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AA6CpB,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsErB,SAAgB,sBACd,WACA,aACQ;CAER,MAAM,OACJ,OAAO,gBAAgB,WAAW,EAAE,aAAa,aAAa,GAAI,eAAe,EAAE;CAGrF,IAAI,MADS,GAAG,aAAa,OAAO,mBAAmB,UAAU;AAEjE,KAAI,KAAK,YACP,QAAO,kBAAkB,mBAAmB,KAAK,YAAY;AAE/D,KAAI,KAAK,SAAS,KAAA,KAAa,KAAK,KAAK,MAAM,KAAK,GAClD,QAAO,SAAS,mBAAmB,KAAK,KAAK,MAAM,CAAC;AAEtD,KAAI,KAAK,gBAAgB,OACvB,QAAO;AAET,KAAI,KAAK,sBAAsB,KAC7B,QAAO;AAET,KAAI,KAAK,gBAAgB,WAAW,KAAK,gBAAgB,OACvD,QAAO,gBAAgB,KAAK;AAE9B,QAAO;;;;;;;;AAST,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;CACtD,MAAM,WAAW,sBAAsB,KAAK;EAC1C,aAAa,KAAK;EAClB,MAAM,KAAK;EACX,aAAa,KAAK;EAClB,mBAAmB,KAAK;EACxB,aAAa,KAAK;EACnB,CAAC;AAoBF,KAnBwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,wCAAwC;EACxC;EACA;EACA,GAAI,KAAK,cACL,CACE,+DACA,uDACD,GACD,EAAE;EACN;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AAClD,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;;;;;;;;;;;AAcN,SAAS,iBAA0B;AACjC,KAAI,QAAQ,IAAI,OAAO,UAAU,QAAQ,IAAI,OAAO,IAAK,QAAO;CAChE,MAAM,WAAW,QAAQ;AACzB,KAAI,aAAa,YAAY,aAAa,QAAS,QAAO;AAC1D,KAAI,aAAa,QACf,QAAO,QAAQ,QAAQ,IAAI,WAAW,QAAQ,IAAI,gBAAgB;AAEpE,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4DT,eAAsB,qBACpB,MACsC;CACtC,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;AAGtD,KAAI,CAAC,KAAK,YAAa,QAAO,KAAA;AAE9B,KAAI,KAAK,OAAO,MAAO,QAAO,KAAA;CAI9B,MAAM,EAAE,2BAA2B,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,iCAAA,CAAA;AAEzC,KAAI,EADa,KAAK,qBAAqB,CAAC,wBAAwB,IAAI,gBAAgB,IACzE,CAAE,QAAO,KAAA;CAExB,MAAM,EAAE,sBAAsB,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,gCAAA,CAAA;CACpC,MAAM,EAAE,2BAA2B,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,0BAAA,CAAA;CACzC,MAAM,EAAE,iBAAiB,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,sBAAA,CAAA;CAQ/B,MAAM,0BAA0B;EAC9B,MAAM,SAAS,QAAQ,IAAI;EAC3B,MAAM,WAAW,SAAS,aAAa,QAAQ,KAAK,KAAK,CAAC,GAAG,KAAA;EAC7D,MAAM,YAAY,uBAAuB,KAAK,WAAW,KAAK,aAAa,UAAU,EACnF,MAAM,KAAK,MACZ,CAAC;AAUF,SAAO;GACL,QAAQ;IAAE,IAAI;IAAM,QAAQ,KAAK;IAAa;GAC9C,OAAO;GACP;GACA,cAAc;GACd,MAAM;GAGN,OAAO;GACR;;CAGH,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,kBAAkB,kBAAkB;SAC7C;AAGN;;CAWF,IAAI,oBAA2D,kBAAkB;AAC/E,SAAO,mBAAmB;IAFK,IAGL;AAC5B,mBAAkB,OAAO;CAEzB,MAAM,eAAe,oBAAoB,OAAO;CAEhD,MAAM,EAAE,qBAAqB,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,iCAAA,CAAA;AAInC,KAHe,iBAAiB,aAAa,GAKvC,+CAA+C,iBAC/C,gDAAgD,eACrD;AAED,QAAO;EACL,KAAK;EACL,aAAa;AACX,OAAI,mBAAmB;AACrB,kBAAc,kBAAkB;AAChC,wBAAoB;;AAEtB,UAAO,OAAO,OAAO;;EAExB;;;;;;;;;;;;AAoBH,SAAgB,0BAA0B,MAAsB;CAE9D,IAAI,IAAI,KAAK,QAAQ,sEAAsE,MACzF,EAAE,QAAQ,mCAAmC,2BAA2B,CACzE;AAED,KAAI,EAAE,QAAQ,oCAAoC,2BAA2B;AAC7E,QAAO;;AAGT,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,EAAA,GAAA,QAAA,YAAY,IAAI,EAAE;AACpB,SAAA,GAAA,iBAAA,QAAA,GAAA,UAAA,SAAoB,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EAGnD,MAAM,cAAwB,EAAE;;;;;EAMhC,MAAM,cAAc,IAAI,OAAe;AACrC,OAAI,YAAY,WAAW,EAAG,QAAO;GACrC,MAAM,OAAO,YAAY,MAAM,CAAC,EAAE,CAAC,IAAI,0BAA0B,CAAC,KAAK,GAAG;AAC1E,UAAO,yBAAyB,KAAK,IAAI,GAAG,YAAY,OAAO,CAAC,OAAO;;EAGzE,MAAM,QAAQ,iBAAiB;AAC7B,YAAS;AACT,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAAc,YAAY,GACxH,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,YAAS;AACT,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;EAK/B,MAAM,cAAc,SAAiB;AACnC,eAAY,KAAK,KAAK;;EAGxB,MAAM,gBAAgB;AACpB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,WAAW;;AAKlC,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAE1B,SAAO,GAAG,UAAU,WAAW;AAC/B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,YAAS;AACT,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,YAAS;AACT,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAAkC,YAAY,GAC7G,CACF;IACD;GACF"}
|
|
@@ -154,7 +154,7 @@ async function startTunnelDashboard(opts) {
|
|
|
154
154
|
if (opts.qr === false) return void 0;
|
|
155
155
|
const { isAutoDevtoolsDisabled } = await import("./devtools-opener-B8nxrxqu.js");
|
|
156
156
|
if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
|
|
157
|
-
const { startQrHttpServer } = await import("./qr-http-server-
|
|
157
|
+
const { startQrHttpServer } = await import("./qr-http-server-DeRrKp_R.js");
|
|
158
158
|
const { buildLauncherAttachUrl } = await import("./deeplink-B5-Hxu0Q.js");
|
|
159
159
|
const { generateTotp } = await import("./totp-DIbrZtI7.js");
|
|
160
160
|
const getDashboardState = () => {
|
|
@@ -289,4 +289,4 @@ async function startQuickTunnel(port) {
|
|
|
289
289
|
//#endregion
|
|
290
290
|
export { printTunnelBanner, startQuickTunnel, startTunnelDashboard };
|
|
291
291
|
|
|
292
|
-
//# sourceMappingURL=tunnel-
|
|
292
|
+
//# sourceMappingURL=tunnel-Djf-zmjJ.js.map
|