From 4fac94f2595fd4e57e0fc411771dd68bff668dff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 03:44:17 +0000 Subject: [PATCH] test(gateway): add wizard e2e + isolate live suite --- .../gateway-models.profiles.live.test.ts | 49 ++- src/gateway/gateway.wizard.e2e.test.ts | 285 ++++++++++++++++++ 2 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 src/gateway/gateway.wizard.e2e.test.ts diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 90a603944..b0736be00 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; +import { createServer } from "node:net"; import os from "node:os"; import path from "node:path"; @@ -16,7 +17,6 @@ import { loadConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; import { GatewayClient } from "./client.js"; import { startGatewayServer } from "./server.js"; -import { getFreePort } from "./test-helpers.js"; const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1"; const GATEWAY_LIVE = process.env.CLAWDBOT_LIVE_GATEWAY === "1"; @@ -59,6 +59,51 @@ function isMeaningful(text: string): boolean { return true; } +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const srv = createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (!addr || typeof addr === "string") { + srv.close(); + reject(new Error("failed to acquire free port")); + return; + } + const port = addr.port; + srv.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +async function isPortFree(port: number): Promise { + if (!Number.isFinite(port) || port <= 0 || port > 65535) return false; + return await new Promise((resolve) => { + const srv = createServer(); + srv.once("error", () => resolve(false)); + srv.listen(port, "127.0.0.1", () => { + srv.close(() => resolve(true)); + }); + }); +} + +async function getFreeGatewayPort(): Promise { + // Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by + // ensuring the common derived offsets are free too. + for (let attempt = 0; attempt < 25; attempt += 1) { + const port = await getFreePort(); + const candidates = [port, port + 1, port + 2, port + 4]; + const ok = ( + await Promise.all(candidates.map((candidate) => isPortFree(candidate))) + ).every(Boolean); + if (ok) return port; + } + throw new Error("failed to acquire a free gateway port block"); +} + type AgentFinalPayload = { status?: unknown; result?: unknown; @@ -182,7 +227,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { ); process.env.CLAWDBOT_CONFIG_PATH = tempConfigPath; - const port = await getFreePort(); + const port = await getFreeGatewayPort(); const server = await startGatewayServer(port, { bind: "loopback", auth: { mode: "token", token }, diff --git a/src/gateway/gateway.wizard.e2e.test.ts b/src/gateway/gateway.wizard.e2e.test.ts new file mode 100644 index 000000000..8ff79a004 --- /dev/null +++ b/src/gateway/gateway.wizard.e2e.test.ts @@ -0,0 +1,285 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import { createServer } from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; + +import { rawDataToString } from "../infra/ws.js"; +import { PROTOCOL_VERSION } from "./protocol/index.js"; + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const srv = createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (!addr || typeof addr === "string") { + srv.close(); + reject(new Error("failed to acquire free port")); + return; + } + const port = addr.port; + srv.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +async function isPortFree(port: number): Promise { + if (!Number.isFinite(port) || port <= 0 || port > 65535) return false; + return await new Promise((resolve) => { + const srv = createServer(); + srv.once("error", () => resolve(false)); + srv.listen(port, "127.0.0.1", () => { + srv.close(() => resolve(true)); + }); + }); +} + +async function getFreeGatewayPort(): Promise { + // Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by + // ensuring the common derived offsets are free too. + for (let attempt = 0; attempt < 25; attempt += 1) { + const port = await getFreePort(); + const candidates = [port, port + 1, port + 2, port + 4]; + const ok = ( + await Promise.all(candidates.map((candidate) => isPortFree(candidate))) + ).every(Boolean); + if (ok) return port; + } + throw new Error("failed to acquire a free gateway port block"); +} + +async function onceMessage( + ws: WebSocket, + filter: (obj: unknown) => boolean, + timeoutMs = 5000, +): Promise { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); + const closeHandler = (code: number, reason: Buffer) => { + clearTimeout(timer); + ws.off("message", handler); + reject(new Error(`closed ${code}: ${rawDataToString(reason)}`)); + }; + const handler = (data: WebSocket.RawData) => { + const obj = JSON.parse(rawDataToString(data)); + if (!filter(obj)) return; + clearTimeout(timer); + ws.off("message", handler); + ws.off("close", closeHandler); + resolve(obj as T); + }; + ws.on("message", handler); + ws.once("close", closeHandler); + }); +} + +async function connectReq(params: { url: string; token?: string }) { + const ws = new WebSocket(params.url); + await new Promise((resolve) => ws.once("open", resolve)); + ws.send( + JSON.stringify({ + type: "req", + id: "c1", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + name: "vitest", + version: "dev", + platform: process.platform, + mode: "test", + }, + caps: [], + auth: params.token ? { token: params.token } : undefined, + }, + }), + ); + const res = await onceMessage<{ + type: "res"; + id: string; + ok: boolean; + error?: { message?: string }; + }>(ws, (o) => { + const obj = o as { type?: unknown; id?: unknown } | undefined; + return obj?.type === "res" && obj?.id === "c1"; + }); + ws.close(); + return res; +} + +async function connectClient(params: { url: string; token?: string }) { + const { GatewayClient } = await import("./client.js"); + return await new Promise>( + (resolve, reject) => { + let settled = false; + const stop = ( + err?: Error, + client?: InstanceType, + ) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) reject(err); + else resolve(client as InstanceType); + }; + const client = new GatewayClient({ + url: params.url, + token: params.token, + clientName: "vitest-wizard", + clientVersion: "dev", + mode: "test", + onHelloOk: () => stop(undefined, client), + onConnectError: (err) => stop(err), + onClose: (code, reason) => + stop(new Error(`gateway closed during connect (${code}): ${reason}`)), + }); + const timer = setTimeout( + () => stop(new Error("gateway connect timeout")), + 10_000, + ); + timer.unref(); + client.start(); + }, + ); +} + +type WizardStep = { + id: string; + type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress"; +}; + +type WizardNextPayload = { + sessionId?: string; + done: boolean; + status: "running" | "done" | "cancelled" | "error"; + step?: WizardStep; + error?: string; +}; + +describe("gateway wizard (e2e)", () => { + it("runs wizard over ws and writes auth token config", async () => { + const prev = { + home: process.env.HOME, + stateDir: process.env.CLAWDBOT_STATE_DIR, + configPath: process.env.CLAWDBOT_CONFIG_PATH, + token: process.env.CLAWDBOT_GATEWAY_TOKEN, + skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, + skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, + skipCron: process.env.CLAWDBOT_SKIP_CRON, + skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, + }; + + process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; + process.env.CLAWDBOT_SKIP_CRON = "1"; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + + const tempHome = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-wizard-home-"), + ); + process.env.HOME = tempHome; + delete process.env.CLAWDBOT_STATE_DIR; + delete process.env.CLAWDBOT_CONFIG_PATH; + + const wizardToken = `wiz-${randomUUID()}`; + const port = await getFreeGatewayPort(); + const { startGatewayServer } = await import("./server.js"); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "none" }, + controlUiEnabled: false, + wizardRunner: async (_opts, _runtime, prompter) => { + await prompter.intro("Wizard E2E"); + await prompter.note("write token"); + const token = await prompter.text({ message: "token" }); + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { auth: { mode: "token", token: String(token) } }, + }); + await prompter.outro("ok"); + }, + }); + + const client = await connectClient({ url: `ws://127.0.0.1:${port}` }); + + try { + const start = await client.request("wizard.start", { + mode: "local", + }); + const sessionId = start.sessionId; + expect(typeof sessionId).toBe("string"); + + let next: WizardNextPayload = start; + let didSendToken = false; + while (!next.done) { + const step = next.step; + if (!step) throw new Error("wizard missing step"); + const value = step.type === "text" ? wizardToken : null; + if (step.type === "text") didSendToken = true; + next = await client.request("wizard.next", { + sessionId, + answer: { stepId: step.id, value }, + }); + } + + expect(didSendToken).toBe(true); + expect(next.status).toBe("done"); + + const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js"); + const parsed = JSON.parse( + await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8"), + ); + const token = (parsed as Record)?.gateway as + | Record + | undefined; + expect((token?.auth as { token?: string } | undefined)?.token).toBe( + wizardToken, + ); + } finally { + client.stop(); + await server.close({ reason: "wizard e2e complete" }); + } + + const port2 = await getFreeGatewayPort(); + const { startGatewayServer: startGatewayServer2 } = await import( + "./server.js" + ); + const server2 = await startGatewayServer2(port2, { + bind: "loopback", + controlUiEnabled: false, + }); + try { + const resNoToken = await connectReq({ + url: `ws://127.0.0.1:${port2}`, + }); + expect(resNoToken.ok).toBe(false); + expect(resNoToken.error?.message ?? "").toContain("unauthorized"); + + const resToken = await connectReq({ + url: `ws://127.0.0.1:${port2}`, + token: wizardToken, + }); + expect(resToken.ok).toBe(true); + } finally { + await server2.close({ reason: "wizard auth verify" }); + await fs.rm(tempHome, { recursive: true, force: true }); + process.env.HOME = prev.home; + process.env.CLAWDBOT_STATE_DIR = prev.stateDir; + process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; + process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; + process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; + } + }, 60_000); +});