From cded1b960a0b9216a50eb9414c5566a2647d6af2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 06:40:52 +0000 Subject: [PATCH] test(commands): dedupe command and onboarding test cases --- src/channels/typing.test.ts | 208 +++----- src/cli/cron-cli.test.ts | 96 ++-- src/cli/program/config-guard.test.ts | 40 +- src/commands/agent.acp.test.ts | 80 ++- src/commands/agent.test.ts | 492 ++++++++---------- .../auth-choice.apply.minimax.test.ts | 152 +++--- ...h-choice.apply.volcengine-byteplus.test.ts | 242 ++++----- ....adds-non-default-telegram-account.test.ts | 240 +++++---- ...re.gateway-auth.prompt-auth-config.test.ts | 106 ++-- src/commands/onboard-auth.credentials.test.ts | 180 ++++--- src/commands/onboard-channels.e2e.test.ts | 334 +++++------- src/commands/onboard-custom.test.ts | 136 ++--- src/commands/onboard-remote.test.ts | 41 +- src/commands/status.test.ts | 260 ++++----- src/discord/monitor.gateway.test.ts | 99 ++-- src/wizard/onboarding.gateway-config.test.ts | 147 ++---- 16 files changed, 1262 insertions(+), 1591 deletions(-) diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts index 69149e302..c1fdbaa43 100644 --- a/src/channels/typing.test.ts +++ b/src/channels/typing.test.ts @@ -6,11 +6,36 @@ const flushMicrotasks = async () => { await Promise.resolve(); }; +async function withFakeTimers(run: () => Promise) { + vi.useFakeTimers(); + try { + await run(); + } finally { + vi.useRealTimers(); + } +} + +function createTypingHarness(overrides: Partial[0]> = {}) { + const start = overrides.start ?? vi.fn().mockResolvedValue(undefined); + const stop = overrides.stop ?? vi.fn().mockResolvedValue(undefined); + const onStartError = overrides.onStartError ?? vi.fn(); + const onStopError = overrides.onStopError ?? vi.fn(); + const callbacks = createTypingCallbacks({ + start, + stop, + onStartError, + ...(onStopError ? { onStopError } : {}), + ...(overrides.maxConsecutiveFailures !== undefined + ? { maxConsecutiveFailures: overrides.maxConsecutiveFailures } + : {}), + ...(overrides.maxDurationMs !== undefined ? { maxDurationMs: overrides.maxDurationMs } : {}), + }); + return { start, stop, onStartError, onStopError, callbacks }; +} + describe("createTypingCallbacks", () => { it("invokes start on reply start", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, onStartError }); + const { start, onStartError, callbacks } = createTypingHarness(); await callbacks.onReplyStart(); @@ -19,9 +44,9 @@ describe("createTypingCallbacks", () => { }); it("reports start errors", async () => { - const start = vi.fn().mockRejectedValue(new Error("fail")); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, onStartError }); + const { onStartError, callbacks } = createTypingHarness({ + start: vi.fn().mockRejectedValue(new Error("fail")), + }); await callbacks.onReplyStart(); @@ -29,11 +54,9 @@ describe("createTypingCallbacks", () => { }); it("invokes stop on idle and reports stop errors", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockRejectedValue(new Error("stop")); - const onStartError = vi.fn(); - const onStopError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError }); + const { stop, onStopError, callbacks } = createTypingHarness({ + stop: vi.fn().mockRejectedValue(new Error("stop")), + }); callbacks.onIdle?.(); await flushMicrotasks(); @@ -43,13 +66,8 @@ describe("createTypingCallbacks", () => { }); it("sends typing keepalive pings until idle cleanup", async () => { - vi.useFakeTimers(); - try { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError }); - + await withFakeTimers(async () => { + const { start, stop, callbacks } = createTypingHarness(); await callbacks.onReplyStart(); expect(start).toHaveBeenCalledTimes(1); @@ -68,18 +86,14 @@ describe("createTypingCallbacks", () => { await vi.advanceTimersByTimeAsync(9_000); expect(start).toHaveBeenCalledTimes(3); - } finally { - vi.useRealTimers(); - } + }); }); it("stops keepalive after consecutive start failures", async () => { - vi.useFakeTimers(); - try { - const start = vi.fn().mockRejectedValue(new Error("gone")); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, onStartError }); - + await withFakeTimers(async () => { + const { start, onStartError, callbacks } = createTypingHarness({ + start: vi.fn().mockRejectedValue(new Error("gone")), + }); await callbacks.onReplyStart(); expect(start).toHaveBeenCalledTimes(1); expect(onStartError).toHaveBeenCalledTimes(1); @@ -90,19 +104,13 @@ describe("createTypingCallbacks", () => { await vi.advanceTimersByTimeAsync(9_000); expect(start).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } + }); }); it("does not restart keepalive when breaker trips on initial start", async () => { - vi.useFakeTimers(); - try { - const start = vi.fn().mockRejectedValue(new Error("gone")); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ - start, - onStartError, + await withFakeTimers(async () => { + const { start, onStartError, callbacks } = createTypingHarness({ + start: vi.fn().mockRejectedValue(new Error("gone")), maxConsecutiveFailures: 1, }); @@ -112,28 +120,21 @@ describe("createTypingCallbacks", () => { await vi.advanceTimersByTimeAsync(9_000); expect(start).toHaveBeenCalledTimes(1); expect(onStartError).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } + }); }); it("resets failure counter after a successful keepalive tick", async () => { - vi.useFakeTimers(); - try { + await withFakeTimers(async () => { let callCount = 0; - const start = vi.fn().mockImplementation(async () => { - callCount += 1; - if (callCount % 2 === 1) { - throw new Error("flaky"); - } - }); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ - start, - onStartError, + const { start, onStartError, callbacks } = createTypingHarness({ + start: vi.fn().mockImplementation(async () => { + callCount += 1; + if (callCount % 2 === 1) { + throw new Error("flaky"); + } + }), maxConsecutiveFailures: 2, }); - await callbacks.onReplyStart(); // fail await vi.advanceTimersByTimeAsync(3_000); // success await vi.advanceTimersByTimeAsync(3_000); // fail @@ -142,16 +143,11 @@ describe("createTypingCallbacks", () => { expect(start).toHaveBeenCalledTimes(5); expect(onStartError).toHaveBeenCalledTimes(3); - } finally { - vi.useRealTimers(); - } + }); }); it("deduplicates stop across idle and cleanup", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError }); + const { stop, callbacks } = createTypingHarness(); callbacks.onIdle?.(); callbacks.onCleanup?.(); @@ -161,12 +157,8 @@ describe("createTypingCallbacks", () => { }); it("does not restart keepalive after idle cleanup", async () => { - vi.useFakeTimers(); - try { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError }); + await withFakeTimers(async () => { + const { start, stop, callbacks } = createTypingHarness(); await callbacks.onReplyStart(); expect(start).toHaveBeenCalledTimes(1); @@ -179,26 +171,15 @@ describe("createTypingCallbacks", () => { expect(start).toHaveBeenCalledTimes(1); expect(stop).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } + }); }); // ========== TTL Safety Tests ========== describe("TTL safety", () => { it("auto-stops typing after maxDurationMs", async () => { - vi.useFakeTimers(); - try { + await withFakeTimers(async () => { const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ - start, - stop, - onStartError, - maxDurationMs: 10_000, - }); + const { start, stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 }); await callbacks.onReplyStart(); expect(start).toHaveBeenCalledTimes(1); @@ -212,24 +193,13 @@ describe("createTypingCallbacks", () => { expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("TTL exceeded")); consoleWarn.mockRestore(); - } finally { - vi.useRealTimers(); - } + }); }); it("does not auto-stop if idle is called before TTL", async () => { - vi.useFakeTimers(); - try { + await withFakeTimers(async () => { const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ - start, - stop, - onStartError, - maxDurationMs: 10_000, - }); + const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 }); await callbacks.onReplyStart(); @@ -249,18 +219,12 @@ describe("createTypingCallbacks", () => { expect(stop).toHaveBeenCalledTimes(1); consoleWarn.mockRestore(); - } finally { - vi.useRealTimers(); - } + }); }); it("uses default 60s TTL when not specified", async () => { - vi.useFakeTimers(); - try { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError }); + await withFakeTimers(async () => { + const { stop, callbacks } = createTypingHarness(); await callbacks.onReplyStart(); @@ -271,46 +235,24 @@ describe("createTypingCallbacks", () => { // Should stop at 60s await vi.advanceTimersByTimeAsync(1_000); expect(stop).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } + }); }); it("disables TTL when maxDurationMs is 0", async () => { - vi.useFakeTimers(); - try { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ - start, - stop, - onStartError, - maxDurationMs: 0, - }); + await withFakeTimers(async () => { + const { stop, callbacks } = createTypingHarness({ maxDurationMs: 0 }); await callbacks.onReplyStart(); // Should not auto-stop even after long time await vi.advanceTimersByTimeAsync(300_000); expect(stop).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + }); }); it("resets TTL timer on restart after idle", async () => { - vi.useFakeTimers(); - try { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ - start, - stop, - onStartError, - maxDurationMs: 10_000, - }); + await withFakeTimers(async () => { + const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 }); // First start await callbacks.onReplyStart(); @@ -330,9 +272,7 @@ describe("createTypingCallbacks", () => { // Should not trigger stop again since it's closed expect(stop).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + }); }); }); }); diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 998a6322c..6ed74ba83 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -156,61 +156,49 @@ async function expectCronEditWithScheduleLookupExit( ).rejects.toThrow("__exit__:1"); } +async function runCronRunAndCaptureExit(params: { ran: boolean }) { + resetGatewayMock(); + callGatewayFromCli.mockImplementation( + async (method: string, _opts: unknown, callParams?: unknown) => { + if (method === "cron.status") { + return { enabled: true }; + } + if (method === "cron.run") { + return { ok: true, params: callParams, ran: params.ran }; + } + return { ok: true, params: callParams }; + }, + ); + + const runtimeModule = await import("../runtime.js"); + const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void }; + const originalExit = runtime.exit; + const exitSpy = vi.fn(); + runtime.exit = exitSpy; + try { + const program = buildProgram(); + await program.parseAsync(["cron", "run", "job-1"], { from: "user" }); + } finally { + runtime.exit = originalExit; + } + return exitSpy; +} + describe("cron cli", () => { - it("exits 0 for cron run when job executes successfully", async () => { - resetGatewayMock(); - callGatewayFromCli.mockImplementation( - async (method: string, _opts: unknown, params?: unknown) => { - if (method === "cron.status") { - return { enabled: true }; - } - if (method === "cron.run") { - return { ok: true, params, ran: true }; - } - return { ok: true, params }; - }, - ); - - const runtimeModule = await import("../runtime.js"); - const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void }; - const originalExit = runtime.exit; - const exitSpy = vi.fn(); - runtime.exit = exitSpy; - try { - const program = buildProgram(); - await program.parseAsync(["cron", "run", "job-1"], { from: "user" }); - expect(exitSpy).toHaveBeenCalledWith(0); - } finally { - runtime.exit = originalExit; - } - }); - - it("exits 1 for cron run when job does not execute", async () => { - resetGatewayMock(); - callGatewayFromCli.mockImplementation( - async (method: string, _opts: unknown, params?: unknown) => { - if (method === "cron.status") { - return { enabled: true }; - } - if (method === "cron.run") { - return { ok: true, params, ran: false }; - } - return { ok: true, params }; - }, - ); - - const runtimeModule = await import("../runtime.js"); - const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void }; - const originalExit = runtime.exit; - const exitSpy = vi.fn(); - runtime.exit = exitSpy; - try { - const program = buildProgram(); - await program.parseAsync(["cron", "run", "job-1"], { from: "user" }); - expect(exitSpy).toHaveBeenCalledWith(1); - } finally { - runtime.exit = originalExit; - } + it.each([ + { + name: "exits 0 for cron run when job executes successfully", + ran: true, + expectedExitCode: 0, + }, + { + name: "exits 1 for cron run when job does not execute", + ran: false, + expectedExitCode: 1, + }, + ])("$name", async ({ ran, expectedExitCode }) => { + const exitSpy = await runCronRunAndCaptureExit({ ran }); + expect(exitSpy).toHaveBeenCalledWith(expectedExitCode); }); it("trims model and thinking on cron add", { timeout: CRON_CLI_TEST_TIMEOUT_MS }, async () => { diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index d0d2dbf03..8886ddaaf 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -28,6 +28,20 @@ function makeRuntime() { }; } +async function withCapturedStdout(run: () => Promise): Promise { + const writes: string[] = []; + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + try { + await run(); + return writes.join(""); + } finally { + writeSpy.mockRestore(); + } +} + describe("ensureConfigReady", () => { async function loadEnsureConfigReady() { vi.resetModules(); @@ -107,36 +121,22 @@ describe("ensureConfigReady", () => { }); it("prevents preflight stdout noise when suppression is enabled", async () => { - const stdoutWrites: string[] = []; - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { - stdoutWrites.push(String(chunk)); - return true; - }) as typeof process.stdout.write); loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => { process.stdout.write("Doctor warnings\n"); }); - try { + const output = await withCapturedStdout(async () => { await runEnsureConfigReady(["message"], true); - expect(stdoutWrites.join("")).not.toContain("Doctor warnings"); - } finally { - writeSpy.mockRestore(); - } + }); + expect(output).not.toContain("Doctor warnings"); }); it("allows preflight stdout noise when suppression is not enabled", async () => { - const stdoutWrites: string[] = []; - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { - stdoutWrites.push(String(chunk)); - return true; - }) as typeof process.stdout.write); loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => { process.stdout.write("Doctor warnings\n"); }); - try { + const output = await withCapturedStdout(async () => { await runEnsureConfigReady(["message"], false); - expect(stdoutWrites.join("")).toContain("Doctor warnings"); - } finally { - writeSpy.mockRestore(); - } + }); + expect(output).toContain("Doctor warnings"); }); }); diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index cd8934799..c2edd0574 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -129,6 +129,31 @@ function mockAcpManager(params: { } as unknown as ReturnType); } +async function runAcpSessionWithPolicyOverrides(params: { + acpOverrides: Partial>; + resolveSession?: Parameters[0]["resolveSession"]; +}) { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + writeAcpSessionStore(storePath); + mockConfigWithAcpOverrides(home, storePath, params.acpOverrides); + + const runTurn = vi.fn(async (_params: unknown) => {}); + mockAcpManager({ + runTurn: (input: unknown) => runTurn(input), + ...(params.resolveSession ? { resolveSession: params.resolveSession } : {}), + }); + + await expect( + agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime), + ).rejects.toMatchObject({ + code: "ACP_DISPATCH_DISABLED", + }); + expect(runTurn).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled(); + }); +} + describe("agentCommand ACP runtime routing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -221,50 +246,19 @@ describe("agentCommand ACP runtime routing", () => { }); }); - it("blocks ACP turns when ACP is disabled by policy", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfigWithAcpOverrides(home, storePath, { - enabled: false, - }); - - const runTurn = vi.fn(async (_params: unknown) => {}); - mockAcpManager({ - runTurn: (params: unknown) => runTurn(params), - }); - - await expect( - agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime), - ).rejects.toMatchObject({ - code: "ACP_DISPATCH_DISABLED", - }); - expect(runTurn).not.toHaveBeenCalled(); - expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled(); - }); - }); - - it("blocks ACP turns when ACP dispatch is disabled by policy", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfigWithAcpOverrides(home, storePath, { + it.each([ + { + name: "blocks ACP turns when ACP is disabled by policy", + acpOverrides: { enabled: false } satisfies Partial>, + }, + { + name: "blocks ACP turns when ACP dispatch is disabled by policy", + acpOverrides: { dispatch: { enabled: false }, - }); - - const runTurn = vi.fn(async (_params: unknown) => {}); - mockAcpManager({ - runTurn: (params: unknown) => runTurn(params), - }); - - await expect( - agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime), - ).rejects.toMatchObject({ - code: "ACP_DISPATCH_DISABLED", - }); - expect(runTurn).not.toHaveBeenCalled(); - expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled(); - }); + } satisfies Partial>, + }, + ])("$name", async ({ acpOverrides }) => { + await runAcpSessionWithPolicyOverrides({ acpOverrides }); }); it("blocks ACP turns when ACP agent is disallowed by policy", async () => { diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index c08cf1f3c..ec79a433c 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -93,6 +93,20 @@ async function runWithDefaultAgentConfig(params: { return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; } +async function runEmbeddedWithTempConfig(params: { + args: Parameters[0]; + agentOverrides?: Partial["defaults"]>>; + telegramOverrides?: Partial["telegram"]>>; + agentsList?: Array<{ id: string; default?: boolean }>; +}) { + return withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, params.agentOverrides, params.telegramOverrides, params.agentsList); + await agentCommand(params.args, runtime); + return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + }); +} + function writeSessionStoreSeed( storePath: string, sessions: Record>, @@ -101,54 +115,149 @@ function writeSessionStoreSeed( fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2)); } +function createDefaultAgentResult(params?: { + payloads?: Array>; + durationMs?: number; +}) { + return { + payloads: params?.payloads ?? [{ text: "ok" }], + meta: { + durationMs: params?.durationMs ?? 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }; +} + +function getLastEmbeddedCall() { + return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; +} + +function expectLastRunProviderModel(provider: string, model: string): void { + const callArgs = getLastEmbeddedCall(); + expect(callArgs?.provider).toBe(provider); + expect(callArgs?.model).toBe(model); +} + +function readSessionStore(storePath: string): Record { + return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record; +} + +async function withCrossAgentResumeFixture( + run: (params: { + home: string; + storePattern: string; + sessionId: string; + sessionKey: string; + }) => Promise, +): Promise { + await withTempHome(async (home) => { + const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json"); + const execStore = path.join(home, "sessions", "exec", "sessions.json"); + const sessionId = "session-exec-hook"; + const sessionKey = "agent:exec:hook:gmail:thread-1"; + writeSessionStoreSeed(execStore, { + [sessionKey]: { + sessionId, + updatedAt: Date.now(), + systemSent: true, + }, + }); + mockConfig(home, storePattern, undefined, undefined, [ + { id: "dev" }, + { id: "exec", default: true }, + ]); + await agentCommand({ message: "resume me", sessionId }, runtime); + await run({ home, storePattern, sessionId, sessionKey }); + }); +} + +async function expectPersistedSessionFile(params: { + seedKey: string; + sessionId: string; + expectedPathFragment: string; +}) { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + writeSessionStoreSeed(store, { + [params.seedKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + }, + }); + mockConfig(home, store); + await agentCommand({ message: "hi", sessionKey: params.seedKey }, runtime); + const saved = readSessionStore<{ sessionId?: string; sessionFile?: string }>(store); + const entry = saved[params.seedKey]; + expect(entry?.sessionId).toBe(params.sessionId); + expect(entry?.sessionFile).toContain(params.expectedPathFragment); + expect(getLastEmbeddedCall()?.sessionFile).toBe(entry?.sessionFile); + }); +} + +async function runAgentWithSessionKey(sessionKey: string): Promise { + await agentCommand({ message: "hi", sessionKey }, runtime); +} + +async function expectDefaultThinkLevel(params: { + agentOverrides?: Partial["defaults"]>>; + catalogEntry: Record; + expected: string; +}) { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, params.agentOverrides); + vi.mocked(loadModelCatalog).mockResolvedValueOnce([params.catalogEntry as never]); + await agentCommand({ message: "hi", to: "+1555" }, runtime); + expect(getLastEmbeddedCall()?.thinkLevel).toBe(params.expected); + }); +} + function createTelegramOutboundPlugin() { + const sendWithTelegram = async ( + ctx: { + deps?: { + sendTelegram?: ( + to: string, + text: string, + opts: Record, + ) => Promise<{ + messageId: string; + chatId: string; + }>; + }; + to: string; + text: string; + accountId?: string; + mediaUrl?: string; + }, + mediaUrl?: string, + ) => { + const sendTelegram = ctx.deps?.sendTelegram; + if (!sendTelegram) { + throw new Error("sendTelegram dependency missing"); + } + const result = await sendTelegram(ctx.to, ctx.text, { + accountId: ctx.accountId ?? undefined, + ...(mediaUrl ? { mediaUrl } : {}), + verbose: false, + }); + return { channel: "telegram", messageId: result.messageId, chatId: result.chatId }; + }; + return createOutboundTestPlugin({ id: "telegram", outbound: { deliveryMode: "direct", - sendText: async (ctx) => { - const sendTelegram = ctx.deps?.sendTelegram; - if (!sendTelegram) { - throw new Error("sendTelegram dependency missing"); - } - const result = await sendTelegram(ctx.to, ctx.text, { - accountId: ctx.accountId ?? undefined, - verbose: false, - }); - return { channel: "telegram", messageId: result.messageId, chatId: result.chatId }; - }, - sendMedia: async (ctx) => { - const sendTelegram = ctx.deps?.sendTelegram; - if (!sendTelegram) { - throw new Error("sendTelegram dependency missing"); - } - const result = await sendTelegram(ctx.to, ctx.text, { - accountId: ctx.accountId ?? undefined, - mediaUrl: ctx.mediaUrl, - verbose: false, - }); - return { channel: "telegram", messageId: result.messageId, chatId: result.chatId }; - }, + sendText: async (ctx) => sendWithTelegram(ctx), + sendMedia: async (ctx) => sendWithTelegram(ctx, ctx.mediaUrl), }, }); } beforeEach(() => { vi.clearAllMocks(); - runCliAgentSpy.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - } as never); - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never); + vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult()); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); }); @@ -191,28 +300,20 @@ describe("agentCommand", () => { }); }); - it("defaults senderIsOwner to true for local agent runs", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - - await agentCommand({ message: "hi", to: "+1555" }, runtime); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.senderIsOwner).toBe(true); - }); - }); - - it("honors explicit senderIsOwner override", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - - await agentCommand({ message: "hi", to: "+1555", senderIsOwner: false }, runtime); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.senderIsOwner).toBe(false); - }); + it.each([ + { + name: "defaults senderIsOwner to true for local agent runs", + args: { message: "hi", to: "+1555" }, + expected: true, + }, + { + name: "honors explicit senderIsOwner override", + args: { message: "hi", to: "+1555", senderIsOwner: false }, + expected: false, + }, + ])("$name", async ({ args, expected }) => { + const callArgs = await runEmbeddedWithTempConfig({ args }); + expect(callArgs?.senderIsOwner).toBe(expected); }); it("resumes when session-id is provided", async () => { @@ -235,53 +336,21 @@ describe("agentCommand", () => { }); it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => { - await withTempHome(async (home) => { - const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json"); - const execStore = path.join(home, "sessions", "exec", "sessions.json"); - writeSessionStoreSeed(execStore, { - "agent:exec:hook:gmail:thread-1": { - sessionId: "session-exec-hook", - updatedAt: Date.now(), - systemSent: true, - }, - }); - mockConfig(home, storePattern, undefined, undefined, [ - { id: "dev" }, - { id: "exec", default: true }, - ]); - - await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.sessionKey).toBe("agent:exec:hook:gmail:thread-1"); + await withCrossAgentResumeFixture(async ({ sessionKey }) => { + const callArgs = getLastEmbeddedCall(); + expect(callArgs?.sessionKey).toBe(sessionKey); expect(callArgs?.agentId).toBe("exec"); expect(callArgs?.agentDir).toContain(`${path.sep}agents${path.sep}exec${path.sep}agent`); }); }); it("forwards resolved outbound session context when resuming by sessionId", async () => { - await withTempHome(async (home) => { - const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json"); - const execStore = path.join(home, "sessions", "exec", "sessions.json"); - writeSessionStoreSeed(execStore, { - "agent:exec:hook:gmail:thread-1": { - sessionId: "session-exec-hook", - updatedAt: Date.now(), - systemSent: true, - }, - }); - mockConfig(home, storePattern, undefined, undefined, [ - { id: "dev" }, - { id: "exec", default: true }, - ]); - - await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime); - + await withCrossAgentResumeFixture(async ({ sessionKey }) => { const deliverCall = deliverAgentCommandResultSpy.mock.calls.at(-1)?.[0]; expect(deliverCall?.opts.sessionKey).toBeUndefined(); expect(deliverCall?.outboundSession).toEqual( expect.objectContaining({ - key: "agent:exec:hook:gmail:thread-1", + key: sessionKey, agentId: "exec", }), ); @@ -362,9 +431,7 @@ describe("agentCommand", () => { await agentCommand({ message: "hi", to: "+1555" }, runtime); - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.provider).toBe("openai"); - expect(callArgs?.model).toBe("gpt-4.1-mini"); + expectLastRunProviderModel("openai", "gpt-4.1-mini"); }); }); @@ -446,13 +513,7 @@ describe("agentCommand", () => { { id: "claude-opus-4-5", name: "Opus", provider: "anthropic" }, ]); - await agentCommand( - { - message: "hi", - sessionKey: "agent:main:subagent:allow-any", - }, - runtime, - ); + await runAgentWithSessionKey("agent:main:subagent:allow-any"); const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.provider).toBe("openai"); @@ -497,17 +558,9 @@ describe("agentCommand", () => { { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, ]); - await agentCommand( - { - message: "hi", - sessionKey: "agent:main:subagent:clear-overrides", - }, - runtime, - ); + await runAgentWithSessionKey("agent:main:subagent:clear-overrides"); - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.provider).toBe("openai"); - expect(callArgs?.model).toBe("gpt-4.1-mini"); + expectLastRunProviderModel("openai", "gpt-4.1-mini"); const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< string, @@ -566,68 +619,18 @@ describe("agentCommand", () => { }); it("persists resolved sessionFile for existing session keys", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - writeSessionStoreSeed(store, { - "agent:main:subagent:abc": { - sessionId: "sess-main", - updatedAt: Date.now(), - }, - }); - mockConfig(home, store); - - await agentCommand( - { - message: "hi", - sessionKey: "agent:main:subagent:abc", - }, - runtime, - ); - - const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< - string, - { sessionId?: string; sessionFile?: string } - >; - const entry = saved["agent:main:subagent:abc"]; - expect(entry?.sessionId).toBe("sess-main"); - expect(entry?.sessionFile).toContain( - `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`, - ); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.sessionFile).toBe(entry?.sessionFile); + await expectPersistedSessionFile({ + seedKey: "agent:main:subagent:abc", + sessionId: "sess-main", + expectedPathFragment: `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`, }); }); it("preserves topic transcript suffix when persisting missing sessionFile", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - writeSessionStoreSeed(store, { - "agent:main:telegram:group:123:topic:456": { - sessionId: "sess-topic", - updatedAt: Date.now(), - }, - }); - mockConfig(home, store); - - await agentCommand( - { - message: "hi", - sessionKey: "agent:main:telegram:group:123:topic:456", - }, - runtime, - ); - - const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< - string, - { sessionId?: string; sessionFile?: string } - >; - const entry = saved["agent:main:telegram:group:123:topic:456"]; - expect(entry?.sessionId).toBe("sess-topic"); - expect(entry?.sessionFile).toContain("sess-topic-topic-456.jsonl"); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.sessionFile).toBe(entry?.sessionFile); + await expectPersistedSessionFile({ + seedKey: "agent:main:telegram:group:123:topic:456", + sessionId: "sess-topic", + expectedPathFragment: "sess-topic-topic-456.jsonl", }); }); @@ -715,76 +718,61 @@ describe("agentCommand", () => { }); it("defaults thinking to low for reasoning-capable models", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { - id: "claude-opus-4-5", - name: "Opus 4.5", - provider: "anthropic", - reasoning: true, - }, - ]); - - await agentCommand({ message: "hi", to: "+1555" }, runtime); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.thinkLevel).toBe("low"); + await expectDefaultThinkLevel({ + catalogEntry: { + id: "claude-opus-4-5", + name: "Opus 4.5", + provider: "anthropic", + reasoning: true, + }, + expected: "low", }); }); it("defaults thinking to adaptive for Anthropic Claude 4.6 models", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, { + await expectDefaultThinkLevel({ + agentOverrides: { model: { primary: "anthropic/claude-opus-4-6" }, models: { "anthropic/claude-opus-4-6": {} }, - }); - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { - id: "claude-opus-4-6", - name: "Opus 4.6", - provider: "anthropic", - reasoning: true, - }, - ]); - - await agentCommand({ message: "hi", to: "+1555" }, runtime); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.thinkLevel).toBe("adaptive"); + }, + catalogEntry: { + id: "claude-opus-4-6", + name: "Opus 4.6", + provider: "anthropic", + reasoning: true, + }, + expected: "adaptive", }); }); it("prefers per-model thinking over global thinkingDefault", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, { + await expectDefaultThinkLevel({ + agentOverrides: { thinkingDefault: "low", models: { "anthropic/claude-opus-4-5": { params: { thinking: "high" }, }, }, - }); - - await agentCommand({ message: "hi", to: "+1555" }, runtime); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.thinkLevel).toBe("high"); + }, + catalogEntry: { + id: "claude-opus-4-5", + name: "Opus 4.5", + provider: "anthropic", + reasoning: true, + }, + expected: "high", }); }); it("prints JSON payload when requested", async () => { await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], - meta: { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue( + createDefaultAgentResult({ + payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], durationMs: 42, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + }), + ); const store = path.join(home, "sessions.json"); mockConfig(home, store); @@ -802,15 +790,10 @@ describe("agentCommand", () => { }); it("passes the message through as the agent prompt", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - - await agentCommand({ message: "ping", to: "+1333" }, runtime); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.prompt).toBe("ping"); + const callArgs = await runEmbeddedWithTempConfig({ + args: { message: "ping", to: "+1333" }, }); + expect(callArgs?.prompt).toBe("ping"); }); it("passes through telegram accountId when delivering", async () => { @@ -861,48 +844,31 @@ describe("agentCommand", () => { }); it("uses reply channel as the message channel context", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, undefined, undefined, [{ id: "ops" }]); - - await agentCommand({ message: "hi", agentId: "ops", replyChannel: "slack" }, runtime); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.messageChannel).toBe("slack"); + const callArgs = await runEmbeddedWithTempConfig({ + args: { message: "hi", agentId: "ops", replyChannel: "slack" }, + agentsList: [{ id: "ops" }], }); + expect(callArgs?.messageChannel).toBe("slack"); }); it("prefers runContext for embedded routing", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - - await agentCommand( - { - message: "hi", - to: "+1555", - channel: "whatsapp", - runContext: { messageChannel: "slack", accountId: "acct-2" }, - }, - runtime, - ); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.messageChannel).toBe("slack"); - expect(callArgs?.agentAccountId).toBe("acct-2"); + const callArgs = await runEmbeddedWithTempConfig({ + args: { + message: "hi", + to: "+1555", + channel: "whatsapp", + runContext: { messageChannel: "slack", accountId: "acct-2" }, + }, }); + expect(callArgs?.messageChannel).toBe("slack"); + expect(callArgs?.agentAccountId).toBe("acct-2"); }); it("forwards accountId to embedded runs", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - - await agentCommand({ message: "hi", to: "+1555", accountId: "kev" }, runtime); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.agentAccountId).toBe("kev"); + const callArgs = await runEmbeddedWithTempConfig({ + args: { message: "hi", to: "+1555", accountId: "kev" }, }); + expect(callArgs?.agentAccountId).toBe("kev"); }); it("logs output when delivery is disabled", async () => { diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts index c3de54b1e..b561e22b3 100644 --- a/src/commands/auth-choice.apply.minimax.test.ts +++ b/src/commands/auth-choice.apply.minimax.test.ts @@ -53,6 +53,39 @@ describe("applyAuthChoiceMiniMax", () => { delete process.env.MINIMAX_OAUTH_TOKEN; } + async function runMiniMaxChoice(params: { + authChoice: Parameters[0]["authChoice"]; + opts?: Parameters[0]["opts"]; + env?: { apiKey?: string; oauthToken?: string }; + prompter?: Parameters[0]; + }) { + const agentDir = await setupTempState(); + resetMiniMaxEnv(); + if (params.env?.apiKey !== undefined) { + process.env.MINIMAX_API_KEY = params.env.apiKey; + } + if (params.env?.oauthToken !== undefined) { + process.env.MINIMAX_OAUTH_TOKEN = params.env.oauthToken; + } + + const text = vi.fn(async () => "should-not-be-used"); + const confirm = vi.fn(async () => true); + const result = await applyAuthChoiceMiniMax({ + authChoice: params.authChoice, + config: {}, + prompter: createMinimaxPrompter({ + text, + confirm, + ...params.prompter, + }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + ...(params.opts ? { opts: params.opts } : {}), + }); + + return { agentDir, result, text, confirm }; + } + afterEach(async () => { await lifecycle.cleanup(); }); @@ -92,18 +125,8 @@ describe("applyAuthChoiceMiniMax", () => { ])( "$caseName", async ({ authChoice, tokenProvider, token, profileId, provider, expectedModel }) => { - const agentDir = await setupTempState(); - resetMiniMaxEnv(); - - const text = vi.fn(async () => "should-not-be-used"); - const confirm = vi.fn(async () => true); - - const result = await applyAuthChoiceMiniMax({ + const { agentDir, result, text, confirm } = await runMiniMaxChoice({ authChoice, - config: {}, - prompter: createMinimaxPrompter({ text, confirm }), - runtime: createExitThrowingRuntime(), - setDefaultModel: true, opts: { tokenProvider, token, @@ -126,80 +149,57 @@ describe("applyAuthChoiceMiniMax", () => { }, ); - it("uses env token for minimax-api-key-cn as plaintext by default", async () => { - const agentDir = await setupTempState(); - process.env.MINIMAX_API_KEY = "mm-env-token"; - delete process.env.MINIMAX_OAUTH_TOKEN; - - const text = vi.fn(async () => "should-not-be-used"); - const confirm = vi.fn(async () => true); - - const result = await applyAuthChoiceMiniMax({ - authChoice: "minimax-api-key-cn", - config: {}, - prompter: createMinimaxPrompter({ text, confirm }), - runtime: createExitThrowingRuntime(), - setDefaultModel: true, - }); - - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({ - provider: "minimax-cn", - mode: "api_key", - }); - expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( - "minimax-cn/MiniMax-M2.5", - ); - expect(text).not.toHaveBeenCalled(); - expect(confirm).toHaveBeenCalled(); - - const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-env-token"); - expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined(); - }); - - it("uses env token for minimax-api-key-cn as keyRef in ref mode", async () => { - const agentDir = await setupTempState(); - process.env.MINIMAX_API_KEY = "mm-env-token"; - delete process.env.MINIMAX_OAUTH_TOKEN; - - const text = vi.fn(async () => "should-not-be-used"); - const confirm = vi.fn(async () => true); - - const result = await applyAuthChoiceMiniMax({ - authChoice: "minimax-api-key-cn", - config: {}, - prompter: createMinimaxPrompter({ text, confirm }), - runtime: createExitThrowingRuntime(), - setDefaultModel: true, - opts: { - secretInputMode: "ref", + it.each([ + { + name: "uses env token for minimax-api-key-cn as plaintext by default", + opts: undefined, + expectKey: "mm-env-token", + expectKeyRef: undefined, + expectConfirmCalls: 1, + }, + { + name: "uses env token for minimax-api-key-cn as keyRef in ref mode", + opts: { secretInputMode: "ref" as const }, + expectKey: undefined, + expectKeyRef: { + source: "env", + provider: "default", + id: "MINIMAX_API_KEY", }, + expectConfirmCalls: 0, + }, + ])("$name", async ({ opts, expectKey, expectKeyRef, expectConfirmCalls }) => { + const { agentDir, result, text, confirm } = await runMiniMaxChoice({ + authChoice: "minimax-api-key-cn", + opts, + env: { apiKey: "mm-env-token" }, }); expect(result).not.toBeNull(); + if (!opts) { + expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({ + provider: "minimax-cn", + mode: "api_key", + }); + expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( + "minimax-cn/MiniMax-M2.5", + ); + } + expect(text).not.toHaveBeenCalled(); + expect(confirm).toHaveBeenCalledTimes(expectConfirmCalls); + const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual({ - source: "env", - provider: "default", - id: "MINIMAX_API_KEY", - }); - expect(parsed.profiles?.["minimax-cn:default"]?.key).toBeUndefined(); + expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe(expectKey); + if (expectKeyRef) { + expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual(expectKeyRef); + } else { + expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined(); + } }); it("uses minimax-api-lightning default model", async () => { - const agentDir = await setupTempState(); - resetMiniMaxEnv(); - - const text = vi.fn(async () => "should-not-be-used"); - const confirm = vi.fn(async () => true); - - const result = await applyAuthChoiceMiniMax({ + const { agentDir, result, text, confirm } = await runMiniMaxChoice({ authChoice: "minimax-api-lightning", - config: {}, - prompter: createMinimaxPrompter({ text, confirm }), - runtime: createExitThrowingRuntime(), - setDefaultModel: true, opts: { tokenProvider: "minimax", token: "mm-lightning-token", diff --git a/src/commands/auth-choice.apply.volcengine-byteplus.test.ts b/src/commands/auth-choice.apply.volcengine-byteplus.test.ts index c1d83bf71..85f07e68b 100644 --- a/src/commands/auth-choice.apply.volcengine-byteplus.test.ts +++ b/src/commands/auth-choice.apply.volcengine-byteplus.test.ts @@ -24,163 +24,117 @@ describe("volcengine/byteplus auth choice", () => { return env.agentDir; } + function createTestContext(defaultSelect: string, confirmResult = true, textValue = "unused") { + return { + prompter: createWizardPrompter( + { + confirm: vi.fn(async () => confirmResult), + text: vi.fn(async () => textValue), + }, + { defaultSelect }, + ), + runtime: createExitThrowingRuntime(), + }; + } + + type ProviderAuthCase = { + provider: "volcengine" | "byteplus"; + authChoice: "volcengine-api-key" | "byteplus-api-key"; + envVar: "VOLCANO_ENGINE_API_KEY" | "BYTEPLUS_API_KEY"; + envValue: string; + profileId: "volcengine:default" | "byteplus:default"; + applyAuthChoice: typeof applyAuthChoiceVolcengine | typeof applyAuthChoiceBytePlus; + }; + + async function runProviderAuthChoice( + testCase: ProviderAuthCase, + options?: { + defaultSelect?: string; + confirmResult?: boolean; + textValue?: string; + secretInputMode?: "ref"; + }, + ) { + const agentDir = await setupTempState(); + process.env[testCase.envVar] = testCase.envValue; + + const { prompter, runtime } = createTestContext( + options?.defaultSelect ?? "plaintext", + options?.confirmResult ?? true, + options?.textValue ?? "unused", + ); + + const result = await testCase.applyAuthChoice({ + authChoice: testCase.authChoice, + config: {}, + prompter, + runtime, + setDefaultModel: true, + ...(options?.secretInputMode ? { opts: { secretInputMode: options.secretInputMode } } : {}), + }); + + const parsed = await readAuthProfilesForAgent<{ + profiles?: Record; + }>(agentDir); + + return { result, parsed }; + } + + const providerAuthCases: ProviderAuthCase[] = [ + { + provider: "volcengine", + authChoice: "volcengine-api-key", + envVar: "VOLCANO_ENGINE_API_KEY", + envValue: "volc-env-key", + profileId: "volcengine:default", + applyAuthChoice: applyAuthChoiceVolcengine, + }, + { + provider: "byteplus", + authChoice: "byteplus-api-key", + envVar: "BYTEPLUS_API_KEY", + envValue: "byte-env-key", + profileId: "byteplus:default", + applyAuthChoice: applyAuthChoiceBytePlus, + }, + ]; + afterEach(async () => { await lifecycle.cleanup(); }); - it("stores volcengine env key as plaintext by default", async () => { - const agentDir = await setupTempState(); - process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key"; + it.each(providerAuthCases)( + "stores $provider env key as plaintext by default", + async (testCase) => { + const { result, parsed } = await runProviderAuthChoice(testCase); + expect(result).not.toBeNull(); + expect(result?.config.auth?.profiles?.[testCase.profileId]).toMatchObject({ + provider: testCase.provider, + mode: "api_key", + }); + expect(parsed.profiles?.[testCase.profileId]?.key).toBe(testCase.envValue); + expect(parsed.profiles?.[testCase.profileId]?.keyRef).toBeUndefined(); + }, + ); - const prompter = createWizardPrompter( - { - confirm: vi.fn(async () => true), - text: vi.fn(async () => "unused"), - }, - { defaultSelect: "plaintext" }, - ); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoiceVolcengine({ - authChoice: "volcengine-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, + it.each(providerAuthCases)("stores $provider env key as keyRef in ref mode", async (testCase) => { + const { result, parsed } = await runProviderAuthChoice(testCase, { + defaultSelect: "ref", }); - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["volcengine:default"]).toMatchObject({ - provider: "volcengine", - mode: "api_key", + expect(parsed.profiles?.[testCase.profileId]).toMatchObject({ + keyRef: { source: "env", provider: "default", id: testCase.envVar }, }); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-env-key"); - expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined(); - }); - - it("stores volcengine env key as keyRef in ref mode", async () => { - const agentDir = await setupTempState(); - process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key"; - - const prompter = createWizardPrompter( - { - confirm: vi.fn(async () => true), - text: vi.fn(async () => "unused"), - }, - { defaultSelect: "ref" }, - ); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoiceVolcengine({ - authChoice: "volcengine-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(result).not.toBeNull(); - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - expect(parsed.profiles?.["volcengine:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" }, - }); - expect(parsed.profiles?.["volcengine:default"]?.key).toBeUndefined(); - }); - - it("stores byteplus env key as plaintext by default", async () => { - const agentDir = await setupTempState(); - process.env.BYTEPLUS_API_KEY = "byte-env-key"; - - const prompter = createWizardPrompter( - { - confirm: vi.fn(async () => true), - text: vi.fn(async () => "unused"), - }, - { defaultSelect: "plaintext" }, - ); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoiceBytePlus({ - authChoice: "byteplus-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["byteplus:default"]).toMatchObject({ - provider: "byteplus", - mode: "api_key", - }); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - expect(parsed.profiles?.["byteplus:default"]?.key).toBe("byte-env-key"); - expect(parsed.profiles?.["byteplus:default"]?.keyRef).toBeUndefined(); - }); - - it("stores byteplus env key as keyRef in ref mode", async () => { - const agentDir = await setupTempState(); - process.env.BYTEPLUS_API_KEY = "byte-env-key"; - - const prompter = createWizardPrompter( - { - confirm: vi.fn(async () => true), - text: vi.fn(async () => "unused"), - }, - { defaultSelect: "ref" }, - ); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoiceBytePlus({ - authChoice: "byteplus-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(result).not.toBeNull(); - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - expect(parsed.profiles?.["byteplus:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "BYTEPLUS_API_KEY" }, - }); - expect(parsed.profiles?.["byteplus:default"]?.key).toBeUndefined(); + expect(parsed.profiles?.[testCase.profileId]?.key).toBeUndefined(); }); it("stores explicit volcengine key when env is not used", async () => { - const agentDir = await setupTempState(); - const prompter = createWizardPrompter( - { - confirm: vi.fn(async () => false), - text: vi.fn(async () => "volc-manual-key"), - }, - { defaultSelect: "" }, - ); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoiceVolcengine({ - authChoice: "volcengine-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, + const { result, parsed } = await runProviderAuthChoice(providerAuthCases[0], { + defaultSelect: "", + confirmResult: false, + textValue: "volc-manual-key", }); - expect(result).not.toBeNull(); - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-manual-key"); expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined(); }); diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index 3df9fc110..6fbd2f754 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -25,6 +25,10 @@ import { const runtime = createTestRuntime(); let clackPrompterModule: typeof import("../wizard/clack-prompter.js"); +function formatChannelStatusJoined(channelAccounts: Record) { + return formatGatewayChannelsStatusLines({ channelAccounts }).join("\n"); +} + describe("channels command", () => { beforeAll(async () => { clackPrompterModule = await import("../wizard/clack-prompter.js"); @@ -45,23 +49,53 @@ describe("channels command", () => { setDefaultChannelPluginRegistryForTests(); }); - it("adds a non-default telegram account", async () => { - configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); - await channelsAddCommand( - { channel: "telegram", account: "alerts", token: "123:abc" }, - runtime, - { hasFlags: true }, - ); - + function getWrittenConfig(): T { expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + return configMocks.writeConfigFile.mock.calls[0]?.[0] as T; + } + + async function runRemoveWithConfirm( + args: Parameters[0], + ): Promise { + const prompt = { confirm: vi.fn().mockResolvedValue(true) }; + const promptSpy = vi + .spyOn(clackPrompterModule, "createClackPrompter") + .mockReturnValue(prompt as never); + try { + await channelsRemoveCommand(args, runtime, { hasFlags: true }); + } finally { + promptSpy.mockRestore(); + } + } + + async function addTelegramAccount(account: string, token: string): Promise { + await channelsAddCommand({ channel: "telegram", account, token }, runtime, { + hasFlags: true, + }); + } + + async function addAlertsTelegramAccount(token: string): Promise<{ + channels?: { + telegram?: { + enabled?: boolean; + accounts?: Record; + }; + }; + }> { + await addTelegramAccount("alerts", token); + return getWrittenConfig<{ channels?: { telegram?: { enabled?: boolean; accounts?: Record; }; }; - }; + }>(); + } + + it("adds a non-default telegram account", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + const next = await addAlertsTelegramAccount("123:abc"); expect(next.channels?.telegram?.enabled).toBe(true); expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("123:abc"); }); @@ -83,13 +117,9 @@ describe("channels command", () => { }, }); - await channelsAddCommand( - { channel: "telegram", account: "alerts", token: "alerts-token" }, - runtime, - { hasFlags: true }, - ); + await addTelegramAccount("alerts", "alerts-token"); - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + const next = getWrittenConfig<{ channels?: { telegram?: { botToken?: string; @@ -109,7 +139,7 @@ describe("channels command", () => { >; }; }; - }; + }>(); expect(next.channels?.telegram?.accounts?.default).toEqual({ botToken: "legacy-token", dmPolicy: "allowlist", @@ -137,20 +167,7 @@ describe("channels command", () => { }, }); - await channelsAddCommand( - { channel: "telegram", account: "alerts", token: "alerts-token" }, - runtime, - { hasFlags: true }, - ); - - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { - channels?: { - telegram?: { - enabled?: boolean; - accounts?: Record; - }; - }; - }; + const next = await addAlertsTelegramAccount("alerts-token"); expect(next.channels?.telegram?.enabled).toBe(true); expect(next.channels?.telegram?.accounts?.default).toEqual({}); expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token"); @@ -169,12 +186,11 @@ describe("channels command", () => { { hasFlags: true }, ); - expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + const next = getWrittenConfig<{ channels?: { slack?: { enabled?: boolean; botToken?: string; appToken?: string }; }; - }; + }>(); expect(next.channels?.slack?.enabled).toBe(true); expect(next.channels?.slack?.botToken).toBe("xoxb-1"); expect(next.channels?.slack?.appToken).toBe("xapp-1"); @@ -199,12 +215,11 @@ describe("channels command", () => { hasFlags: true, }); - expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + const next = getWrittenConfig<{ channels?: { discord?: { accounts?: Record }; }; - }; + }>(); expect(next.channels?.discord?.accounts?.work).toBeUndefined(); expect(next.channels?.discord?.accounts?.default?.token).toBe("d0"); }); @@ -217,11 +232,11 @@ describe("channels command", () => { { hasFlags: true }, ); - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + const next = getWrittenConfig<{ channels?: { whatsapp?: { accounts?: Record }; }; - }; + }>(); expect(next.channels?.whatsapp?.accounts?.family?.name).toBe("Family Phone"); }); @@ -250,13 +265,13 @@ describe("channels command", () => { { hasFlags: true }, ); - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + const next = getWrittenConfig<{ channels?: { signal?: { accounts?: Record; }; }; - }; + }>(); expect(next.channels?.signal?.accounts?.lab?.account).toBe("+15555550123"); expect(next.channels?.signal?.accounts?.lab?.name).toBe("Lab"); expect(next.channels?.signal?.accounts?.default?.name).toBe("Primary"); @@ -270,20 +285,12 @@ describe("channels command", () => { }, }); - const prompt = { confirm: vi.fn().mockResolvedValue(true) }; - const promptSpy = vi - .spyOn(clackPrompterModule, "createClackPrompter") - .mockReturnValue(prompt as never); + await runRemoveWithConfirm({ channel: "discord", account: "default" }); - await channelsRemoveCommand({ channel: "discord", account: "default" }, runtime, { - hasFlags: true, - }); - - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + const next = getWrittenConfig<{ channels?: { discord?: { enabled?: boolean } }; - }; + }>(); expect(next.channels?.discord?.enabled).toBe(false); - promptSpy.mockRestore(); }); it("includes external auth profiles in JSON output", async () => { @@ -348,14 +355,14 @@ describe("channels command", () => { { hasFlags: true }, ); - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + const next = getWrittenConfig<{ channels?: { telegram?: { name?: string; accounts?: Record; }; }; - }; + }>(); expect(next.channels?.telegram?.name).toBeUndefined(); expect(next.channels?.telegram?.accounts?.default?.name).toBe("Primary Bot"); }); @@ -377,14 +384,14 @@ describe("channels command", () => { hasFlags: true, }); - const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + const next = getWrittenConfig<{ channels?: { discord?: { name?: string; accounts?: Record; }; }; - }; + }>(); expect(next.channels?.discord?.name).toBeUndefined(); expect(next.channels?.discord?.accounts?.default?.name).toBe("Primary Bot"); expect(next.channels?.discord?.accounts?.work?.token).toBe("d1"); @@ -405,8 +412,9 @@ describe("channels command", () => { expect(telegramIndex).toBeLessThan(whatsappIndex); }); - it("surfaces Discord privileged intent issues in channels status output", () => { - const lines = formatGatewayChannelsStatusLines({ + it.each([ + { + name: "surfaces Discord privileged intent issues in channels status output", channelAccounts: { discord: [ { @@ -417,14 +425,14 @@ describe("channels command", () => { }, ], }, - }); - expect(lines.join("\n")).toMatch(/Warnings:/); - expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i); - expect(lines.join("\n")).toMatch(/Run: (?:openclaw|openclaw)( --profile isolated)? doctor/); - }); - - it("surfaces Discord permission audit issues in channels status output", () => { - const lines = formatGatewayChannelsStatusLines({ + patterns: [ + /Warnings:/, + /Message Content Intent is disabled/i, + /Run: (?:openclaw|openclaw)( --profile isolated)? doctor/, + ], + }, + { + name: "surfaces Discord permission audit issues in channels status output", channelAccounts: { discord: [ { @@ -444,14 +452,10 @@ describe("channels command", () => { }, ], }, - }); - expect(lines.join("\n")).toMatch(/Warnings:/); - expect(lines.join("\n")).toMatch(/permission audit/i); - expect(lines.join("\n")).toMatch(/Channel 111/i); - }); - - it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => { - const lines = formatGatewayChannelsStatusLines({ + patterns: [/Warnings:/, /permission audit/i, /Channel 111/i], + }, + { + name: "surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", channelAccounts: { telegram: [ { @@ -462,54 +466,54 @@ describe("channels command", () => { }, ], }, - }); - expect(lines.join("\n")).toMatch(/Warnings:/); - expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i); + patterns: [/Warnings:/, /Telegram Bot API privacy mode/i], + }, + ])("$name", ({ channelAccounts, patterns }) => { + const joined = formatChannelStatusJoined(channelAccounts); + for (const pattern of patterns) { + expect(joined).toMatch(pattern); + } }); it("includes Telegram bot username from probe data", () => { - const lines = formatGatewayChannelsStatusLines({ - channelAccounts: { - telegram: [ - { - accountId: "default", - enabled: true, - configured: true, - probe: { ok: true, bot: { username: "openclaw_bot" } }, - }, - ], - }, + const joined = formatChannelStatusJoined({ + telegram: [ + { + accountId: "default", + enabled: true, + configured: true, + probe: { ok: true, bot: { username: "openclaw_bot" } }, + }, + ], }); - expect(lines.join("\n")).toMatch(/bot:@openclaw_bot/); + expect(joined).toMatch(/bot:@openclaw_bot/); }); it("surfaces Telegram group membership audit issues in channels status output", () => { - const lines = formatGatewayChannelsStatusLines({ - channelAccounts: { - telegram: [ - { - accountId: "default", - enabled: true, - configured: true, - audit: { - hasWildcardUnmentionedGroups: true, - unresolvedGroups: 1, - groups: [ - { - chatId: "-1001", - ok: false, - status: "left", - error: "not in group", - }, - ], - }, + const joined = formatChannelStatusJoined({ + telegram: [ + { + accountId: "default", + enabled: true, + configured: true, + audit: { + hasWildcardUnmentionedGroups: true, + unresolvedGroups: 1, + groups: [ + { + chatId: "-1001", + ok: false, + status: "left", + error: "not in group", + }, + ], }, - ], - }, + }, + ], }); - expect(lines.join("\n")).toMatch(/Warnings:/); - expect(lines.join("\n")).toMatch(/membership probing is not possible/i); - expect(lines.join("\n")).toMatch(/Group -1001/i); + expect(joined).toMatch(/Warnings:/); + expect(joined).toMatch(/membership probing is not possible/i); + expect(joined).toMatch(/Group -1001/i); }); it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => { @@ -591,16 +595,8 @@ describe("channels command", () => { }, }); - const prompt = { confirm: vi.fn().mockResolvedValue(true) }; - const promptSpy = vi - .spyOn(clackPrompterModule, "createClackPrompter") - .mockReturnValue(prompt as never); - - await channelsRemoveCommand({ channel: "telegram", account: "default" }, runtime, { - hasFlags: true, - }); + await runRemoveWithConfirm({ channel: "telegram", account: "default" }); expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled(); - promptSpy.mockRestore(); }); }); diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index e866f92e5..889519e9c 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -51,35 +51,56 @@ function makeRuntime(): RuntimeEnv { const noopPrompter = {} as WizardPrompter; -describe("promptAuthConfig", () => { - it("keeps Kilo provider models while applying allowlist defaults", async () => { - mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key"); - mocks.applyAuthChoice.mockResolvedValue({ - config: { - agents: { - defaults: { - model: { primary: "kilocode/anthropic/claude-opus-4.6" }, - }, - }, - models: { - providers: { - kilocode: { - baseUrl: "https://api.kilo.ai/api/gateway/", - api: "openai-completions", - models: [ - { id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" }, - { id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" }, - ], - }, - }, +function createKilocodeProvider() { + return { + baseUrl: "https://api.kilo.ai/api/gateway/", + api: "openai-completions", + models: [ + { id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" }, + { id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" }, + ], + }; +} + +function createApplyAuthChoiceConfig(includeMinimaxProvider = false) { + return { + config: { + agents: { + defaults: { + model: { primary: "kilocode/anthropic/claude-opus-4.6" }, }, }, - }); - mocks.promptModelAllowlist.mockResolvedValue({ - models: ["kilocode/anthropic/claude-opus-4.6"], - }); + models: { + providers: { + kilocode: createKilocodeProvider(), + ...(includeMinimaxProvider + ? { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }], + }, + } + : {}), + }, + }, + }, + }; +} - const result = await promptAuthConfig({}, makeRuntime(), noopPrompter); +async function runPromptAuthConfigWithAllowlist(includeMinimaxProvider = false) { + mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key"); + mocks.applyAuthChoice.mockResolvedValue(createApplyAuthChoiceConfig(includeMinimaxProvider)); + mocks.promptModelAllowlist.mockResolvedValue({ + models: ["kilocode/anthropic/claude-opus-4.6"], + }); + + return promptAuthConfig({}, makeRuntime(), noopPrompter); +} + +describe("promptAuthConfig", () => { + it("keeps Kilo provider models while applying allowlist defaults", async () => { + const result = await runPromptAuthConfigWithAllowlist(); expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([ "anthropic/claude-opus-4.6", "minimax/minimax-m2.5:free", @@ -90,38 +111,7 @@ describe("promptAuthConfig", () => { }); it("does not mutate provider model catalogs when allowlist is set", async () => { - mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key"); - mocks.applyAuthChoice.mockResolvedValue({ - config: { - agents: { - defaults: { - model: { primary: "kilocode/anthropic/claude-opus-4.6" }, - }, - }, - models: { - providers: { - kilocode: { - baseUrl: "https://api.kilo.ai/api/gateway/", - api: "openai-completions", - models: [ - { id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" }, - { id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" }, - ], - }, - minimax: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }], - }, - }, - }, - }, - }); - mocks.promptModelAllowlist.mockResolvedValue({ - models: ["kilocode/anthropic/claude-opus-4.6"], - }); - - const result = await promptAuthConfig({}, makeRuntime(), noopPrompter); + const result = await runPromptAuthConfigWithAllowlist(true); expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([ "anthropic/claude-opus-4.6", "minimax/minimax-m2.5:free", diff --git a/src/commands/onboard-auth.credentials.test.ts b/src/commands/onboard-auth.credentials.test.ts index 48ccc9954..946619331 100644 --- a/src/commands/onboard-auth.credentials.test.ts +++ b/src/commands/onboard-auth.credentials.test.ts @@ -28,67 +28,109 @@ describe("onboard auth credentials secret refs", () => { await lifecycle.cleanup(); }); - it("keeps env-backed moonshot key as plaintext by default", async () => { - const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-"); + type AuthProfileEntry = { key?: string; keyRef?: unknown; metadata?: unknown }; + + async function withAuthEnv( + prefix: string, + run: (env: Awaited>) => Promise, + ) { + const env = await setupAuthTestEnv(prefix); lifecycle.setStateDir(env.stateDir); - process.env.MOONSHOT_API_KEY = "sk-moonshot-env"; - - await setMoonshotApiKey("sk-moonshot-env"); + await run(env); + } + async function readProfile( + agentDir: string, + profileId: string, + ): Promise { const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.["moonshot:default"]).toMatchObject({ - key: "sk-moonshot-env", + profiles?: Record; + }>(agentDir); + return parsed.profiles?.[profileId]; + } + + async function expectStoredAuthKey(params: { + prefix: string; + envVar?: string; + envValue?: string; + profileId: string; + apply: (agentDir: string) => Promise; + expected: AuthProfileEntry; + absent?: Array; + }) { + await withAuthEnv(params.prefix, async (env) => { + if (params.envVar && params.envValue !== undefined) { + process.env[params.envVar] = params.envValue; + } + await params.apply(env.agentDir); + const profile = await readProfile(env.agentDir, params.profileId); + expect(profile).toMatchObject(params.expected); + for (const key of params.absent ?? []) { + expect(profile?.[key]).toBeUndefined(); + } + }); + } + + it("keeps env-backed moonshot key as plaintext by default", async () => { + await expectStoredAuthKey({ + prefix: "openclaw-onboard-auth-credentials-", + envVar: "MOONSHOT_API_KEY", + envValue: "sk-moonshot-env", + profileId: "moonshot:default", + apply: async () => { + await setMoonshotApiKey("sk-moonshot-env"); + }, + expected: { + key: "sk-moonshot-env", + }, + absent: ["keyRef"], }); - expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined(); }); it("stores env-backed moonshot key as keyRef when secret-input-mode=ref", async () => { - const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-ref-"); - lifecycle.setStateDir(env.stateDir); - process.env.MOONSHOT_API_KEY = "sk-moonshot-env"; - - await setMoonshotApiKey("sk-moonshot-env", env.agentDir, { secretInputMode: "ref" }); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.["moonshot:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, + await expectStoredAuthKey({ + prefix: "openclaw-onboard-auth-credentials-ref-", + envVar: "MOONSHOT_API_KEY", + envValue: "sk-moonshot-env", + profileId: "moonshot:default", + apply: async (agentDir) => { + await setMoonshotApiKey("sk-moonshot-env", agentDir, { secretInputMode: "ref" }); + }, + expected: { + keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, + }, + absent: ["key"], }); - expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined(); }); it("stores ${ENV} moonshot input as keyRef even when env value is unset", async () => { - const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-inline-ref-"); - lifecycle.setStateDir(env.stateDir); - - await setMoonshotApiKey("${MOONSHOT_API_KEY}"); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.["moonshot:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, + await expectStoredAuthKey({ + prefix: "openclaw-onboard-auth-credentials-inline-ref-", + profileId: "moonshot:default", + apply: async () => { + await setMoonshotApiKey("${MOONSHOT_API_KEY}"); + }, + expected: { + keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, + }, + absent: ["key"], }); - expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined(); }); it("keeps plaintext moonshot key when no env ref applies", async () => { - const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-plaintext-"); - lifecycle.setStateDir(env.stateDir); - process.env.MOONSHOT_API_KEY = "sk-moonshot-other"; - - await setMoonshotApiKey("sk-moonshot-plaintext"); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.["moonshot:default"]).toMatchObject({ - key: "sk-moonshot-plaintext", + await expectStoredAuthKey({ + prefix: "openclaw-onboard-auth-credentials-plaintext-", + envVar: "MOONSHOT_API_KEY", + envValue: "sk-moonshot-other", + profileId: "moonshot:default", + apply: async () => { + await setMoonshotApiKey("sk-moonshot-plaintext"); + }, + expected: { + key: "sk-moonshot-plaintext", + }, + absent: ["keyRef"], }); - expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined(); }); it("preserves cloudflare metadata when storing keyRef", async () => { @@ -111,35 +153,35 @@ describe("onboard auth credentials secret refs", () => { }); it("keeps env-backed openai key as plaintext by default", async () => { - const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-"); - lifecycle.setStateDir(env.stateDir); - process.env.OPENAI_API_KEY = "sk-openai-env"; - - await setOpenaiApiKey("sk-openai-env"); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.["openai:default"]).toMatchObject({ - key: "sk-openai-env", + await expectStoredAuthKey({ + prefix: "openclaw-onboard-auth-credentials-openai-", + envVar: "OPENAI_API_KEY", + envValue: "sk-openai-env", + profileId: "openai:default", + apply: async () => { + await setOpenaiApiKey("sk-openai-env"); + }, + expected: { + key: "sk-openai-env", + }, + absent: ["keyRef"], }); - expect(parsed.profiles?.["openai:default"]?.keyRef).toBeUndefined(); }); it("stores env-backed openai key as keyRef in ref mode", async () => { - const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-ref-"); - lifecycle.setStateDir(env.stateDir); - process.env.OPENAI_API_KEY = "sk-openai-env"; - - await setOpenaiApiKey("sk-openai-env", env.agentDir, { secretInputMode: "ref" }); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.["openai:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + await expectStoredAuthKey({ + prefix: "openclaw-onboard-auth-credentials-openai-ref-", + envVar: "OPENAI_API_KEY", + envValue: "sk-openai-env", + profileId: "openai:default", + apply: async (agentDir) => { + await setOpenaiApiKey("sk-openai-env", agentDir, { secretInputMode: "ref" }); + }, + expected: { + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + absent: ["key"], }); - expect(parsed.profiles?.["openai:default"]?.key).toBeUndefined(); }); it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => { diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index cd146b82c..ec2bb0419 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -31,6 +31,68 @@ function createUnexpectedPromptGuards() { }; } +type SetupChannelsOptions = Parameters[3]; + +function runSetupChannels( + cfg: OpenClawConfig, + prompter: WizardPrompter, + options?: SetupChannelsOptions, +) { + return setupChannels(cfg, createExitThrowingRuntime(), prompter, { + skipConfirm: true, + ...options, + }); +} + +function createQuickstartTelegramSelect(options?: { + configuredAction?: "skip"; + strictUnexpected?: boolean; +}) { + return vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + if (options?.configuredAction && message.includes("already configured")) { + return options.configuredAction; + } + if (options?.strictUnexpected) { + throw new Error(`unexpected select prompt: ${message}`); + } + return "__done__"; + }); +} + +function createUnexpectedQuickstartPrompter(select: WizardPrompter["select"]) { + const { multiselect, text } = createUnexpectedPromptGuards(); + return { + prompter: createPrompter({ select, multiselect, text }), + multiselect, + text, + }; +} + +function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig { + return { + channels: { + telegram: { + botToken, + ...(typeof enabled === "boolean" ? { enabled } : {}), + }, + }, + } as OpenClawConfig; +} + +function patchTelegramAdapter(overrides: Parameters[1]) { + return patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + ...overrides, + }); +} + vi.mock("node:fs/promises", () => ({ default: { access: vi.fn(async () => { @@ -81,10 +143,7 @@ describe("setupChannels", () => { text: text as unknown as WizardPrompter["text"], }); - const runtime = createExitThrowingRuntime(); - - await setupChannels({} as OpenClawConfig, runtime, prompter, { - skipConfirm: true, + await runSetupChannels({} as OpenClawConfig, prompter, { quickstartDefaults: true, forceAllowFromChannels: ["whatsapp"], }); @@ -116,10 +175,7 @@ describe("setupChannels", () => { text: text as unknown as WizardPrompter["text"], }); - const runtime = createExitThrowingRuntime(); - - await setupChannels({} as OpenClawConfig, runtime, prompter, { - skipConfirm: true, + await runSetupChannels({} as OpenClawConfig, prompter, { quickstartDefaults: true, }); @@ -146,11 +202,7 @@ describe("setupChannels", () => { text, }); - const runtime = createExitThrowingRuntime(); - - await setupChannels({} as OpenClawConfig, runtime, prompter, { - skipConfirm: true, - }); + await runSetupChannels({} as OpenClawConfig, prompter); const sawPrimer = note.mock.calls.some( ([message, title]) => @@ -162,41 +214,18 @@ describe("setupChannels", () => { }); it("prompts for configured channel action and skips configuration when told to skip", async () => { - const select = vi.fn(async ({ message }: { message: string }) => { - if (message === "Select channel (QuickStart)") { - return "telegram"; - } - if (message.includes("already configured")) { - return "skip"; - } - throw new Error(`unexpected select prompt: ${message}`); + const select = createQuickstartTelegramSelect({ + configuredAction: "skip", + strictUnexpected: true, }); - const { multiselect, text } = createUnexpectedPromptGuards(); - - const prompter = createPrompter({ - select: select as unknown as WizardPrompter["select"], - multiselect, - text, - }); - - const runtime = createExitThrowingRuntime(); - - await setupChannels( - { - channels: { - telegram: { - botToken: "token", - }, - }, - } as OpenClawConfig, - runtime, - prompter, - { - skipConfirm: true, - quickstartDefaults: true, - }, + const { prompter, multiselect, text } = createUnexpectedQuickstartPrompter( + select as unknown as WizardPrompter["select"], ); + await runSetupChannels(createTelegramCfg("token"), prompter, { + quickstartDefaults: true, + }); + expect(select).toHaveBeenCalledWith( expect.objectContaining({ message: "Select channel (QuickStart)" }), ); @@ -231,58 +260,26 @@ describe("setupChannels", () => { text: vi.fn(async () => "") as unknown as WizardPrompter["text"], }); - const runtime = createExitThrowingRuntime(); - - await setupChannels( - { - channels: { - telegram: { - botToken: "token", - enabled: false, - }, - }, - } as OpenClawConfig, - runtime, - prompter, - { - skipConfirm: true, - }, - ); + await runSetupChannels(createTelegramCfg("token", false), prompter); expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" })); expect(multiselect).not.toHaveBeenCalled(); }); it("uses configureInteractive skip without mutating selection/account state", async () => { - const select = vi.fn(async ({ message }: { message: string }) => { - if (message === "Select channel (QuickStart)") { - return "telegram"; - } - return "__done__"; - }); + const select = createQuickstartTelegramSelect(); const selection = vi.fn(); const onAccountId = vi.fn(); const configureInteractive = vi.fn(async () => "skip" as const); - const restore = patchChannelOnboardingAdapter("telegram", { - getStatus: vi.fn(async ({ cfg }) => ({ - channel: "telegram", - configured: Boolean(cfg.channels?.telegram?.botToken), - statusLines: [], - })), + const restore = patchTelegramAdapter({ configureInteractive, }); - const { multiselect, text } = createUnexpectedPromptGuards(); + const { prompter } = createUnexpectedQuickstartPrompter( + select as unknown as WizardPrompter["select"], + ); - const prompter = createPrompter({ - select: select as unknown as WizardPrompter["select"], - multiselect, - text, - }); - - const runtime = createExitThrowingRuntime(); try { - const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, { - skipConfirm: true, + const cfg = await runSetupChannels({} as OpenClawConfig, prompter, { quickstartDefaults: true, onSelection: selection, onAccountId, @@ -300,12 +297,7 @@ describe("setupChannels", () => { }); it("applies configureInteractive result cfg/account updates", async () => { - const select = vi.fn(async ({ message }: { message: string }) => { - if (message === "Select channel (QuickStart)") { - return "telegram"; - } - return "__done__"; - }); + const select = createQuickstartTelegramSelect(); const selection = vi.fn(); const onAccountId = vi.fn(); const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ @@ -321,27 +313,16 @@ describe("setupChannels", () => { const configure = vi.fn(async () => { throw new Error("configure should not be called when configureInteractive is present"); }); - const restore = patchChannelOnboardingAdapter("telegram", { - getStatus: vi.fn(async ({ cfg }) => ({ - channel: "telegram", - configured: Boolean(cfg.channels?.telegram?.botToken), - statusLines: [], - })), + const restore = patchTelegramAdapter({ configureInteractive, configure, }); - const { multiselect, text } = createUnexpectedPromptGuards(); + const { prompter } = createUnexpectedQuickstartPrompter( + select as unknown as WizardPrompter["select"], + ); - const prompter = createPrompter({ - select: select as unknown as WizardPrompter["select"], - multiselect, - text, - }); - - const runtime = createExitThrowingRuntime(); try { - const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, { - skipConfirm: true, + const cfg = await runSetupChannels({} as OpenClawConfig, prompter, { quickstartDefaults: true, onSelection: selection, onAccountId, @@ -358,12 +339,7 @@ describe("setupChannels", () => { }); it("uses configureWhenConfigured when channel is already configured", async () => { - const select = vi.fn(async ({ message }: { message: string }) => { - if (message === "Select channel (QuickStart)") { - return "telegram"; - } - return "__done__"; - }); + const select = createQuickstartTelegramSelect(); const selection = vi.fn(); const onAccountId = vi.fn(); const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ @@ -381,43 +357,21 @@ describe("setupChannels", () => { "configure should not be called when configureWhenConfigured handles updates", ); }); - const restore = patchChannelOnboardingAdapter("telegram", { - getStatus: vi.fn(async ({ cfg }) => ({ - channel: "telegram", - configured: Boolean(cfg.channels?.telegram?.botToken), - statusLines: [], - })), + const restore = patchTelegramAdapter({ configureInteractive: undefined, configureWhenConfigured, configure, }); - const { multiselect, text } = createUnexpectedPromptGuards(); + const { prompter } = createUnexpectedQuickstartPrompter( + select as unknown as WizardPrompter["select"], + ); - const prompter = createPrompter({ - select: select as unknown as WizardPrompter["select"], - multiselect, - text, - }); - - const runtime = createExitThrowingRuntime(); try { - const cfg = await setupChannels( - { - channels: { - telegram: { - botToken: "old-token", - }, - }, - } as OpenClawConfig, - runtime, - prompter, - { - skipConfirm: true, - quickstartDefaults: true, - onSelection: selection, - onAccountId, - }, - ); + const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, { + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }); expect(configureWhenConfigured).toHaveBeenCalledTimes(1); expect(configureWhenConfigured).toHaveBeenCalledWith( @@ -433,55 +387,28 @@ describe("setupChannels", () => { }); it("respects configureWhenConfigured skip without mutating selection or account state", async () => { - const select = vi.fn(async ({ message }: { message: string }) => { - if (message === "Select channel (QuickStart)") { - return "telegram"; - } - throw new Error(`unexpected select prompt: ${message}`); - }); + const select = createQuickstartTelegramSelect({ strictUnexpected: true }); const selection = vi.fn(); const onAccountId = vi.fn(); const configureWhenConfigured = vi.fn(async () => "skip" as const); const configure = vi.fn(async () => { throw new Error("configure should not run when configureWhenConfigured handles skip"); }); - const restore = patchChannelOnboardingAdapter("telegram", { - getStatus: vi.fn(async ({ cfg }) => ({ - channel: "telegram", - configured: Boolean(cfg.channels?.telegram?.botToken), - statusLines: [], - })), + const restore = patchTelegramAdapter({ configureInteractive: undefined, configureWhenConfigured, configure, }); - const { multiselect, text } = createUnexpectedPromptGuards(); + const { prompter } = createUnexpectedQuickstartPrompter( + select as unknown as WizardPrompter["select"], + ); - const prompter = createPrompter({ - select: select as unknown as WizardPrompter["select"], - multiselect, - text, - }); - - const runtime = createExitThrowingRuntime(); try { - const cfg = await setupChannels( - { - channels: { - telegram: { - botToken: "old-token", - }, - }, - } as OpenClawConfig, - runtime, - prompter, - { - skipConfirm: true, - quickstartDefaults: true, - onSelection: selection, - onAccountId, - }, - ); + const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, { + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }); expect(configureWhenConfigured).toHaveBeenCalledWith( expect.objectContaining({ configured: true, label: expect.any(String) }), @@ -496,54 +423,27 @@ describe("setupChannels", () => { }); it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => { - const select = vi.fn(async ({ message }: { message: string }) => { - if (message === "Select channel (QuickStart)") { - return "telegram"; - } - throw new Error(`unexpected select prompt: ${message}`); - }); + const select = createQuickstartTelegramSelect({ strictUnexpected: true }); const selection = vi.fn(); const onAccountId = vi.fn(); const configureInteractive = vi.fn(async () => "skip" as const); const configureWhenConfigured = vi.fn(async () => { throw new Error("configureWhenConfigured should not run when configureInteractive exists"); }); - const restore = patchChannelOnboardingAdapter("telegram", { - getStatus: vi.fn(async ({ cfg }) => ({ - channel: "telegram", - configured: Boolean(cfg.channels?.telegram?.botToken), - statusLines: [], - })), + const restore = patchTelegramAdapter({ configureInteractive, configureWhenConfigured, }); - const { multiselect, text } = createUnexpectedPromptGuards(); + const { prompter } = createUnexpectedQuickstartPrompter( + select as unknown as WizardPrompter["select"], + ); - const prompter = createPrompter({ - select: select as unknown as WizardPrompter["select"], - multiselect, - text, - }); - - const runtime = createExitThrowingRuntime(); try { - await setupChannels( - { - channels: { - telegram: { - botToken: "old-token", - }, - }, - } as OpenClawConfig, - runtime, - prompter, - { - skipConfirm: true, - quickstartDefaults: true, - onSelection: selection, - onAccountId, - }, - ); + await runSetupChannels(createTelegramCfg("old-token"), prompter, { + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }); expect(configureInteractive).toHaveBeenCalledWith( expect.objectContaining({ configured: true, label: expect.any(String) }), diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index 34e420d20..4396bcc11 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -76,6 +76,43 @@ function expectOpenAiCompatResult(params: { expect(params.result.config.models?.providers?.custom?.api).toBe("openai-completions"); } +function buildCustomProviderConfig(contextWindow?: number) { + if (contextWindow === undefined) { + return {}; + } + return { + models: { + providers: { + custom: { + api: "openai-completions", + baseUrl: "https://llm.example.com/v1", + models: [ + { + id: "foo-large", + name: "foo-large", + contextWindow, + maxTokens: contextWindow > CONTEXT_WINDOW_HARD_MIN_TOKENS ? 4096 : 1024, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }, + ], + }, + }, + }, + }; +} + +function applyCustomModelConfigWithContextWindow(contextWindow?: number) { + return applyCustomApiConfig({ + config: buildCustomProviderConfig(contextWindow), + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + providerId: "custom", + }); +} + describe("promptCustomApiConfig", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -327,89 +364,28 @@ describe("promptCustomApiConfig", () => { }); describe("applyCustomApiConfig", () => { - it("uses hard-min context window for newly added custom models", () => { - const result = applyCustomApiConfig({ - config: {}, - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "openai", - providerId: "custom", - }); - + it.each([ + { + name: "uses hard-min context window for newly added custom models", + existingContextWindow: undefined, + expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS, + }, + { + name: "upgrades existing custom model context window when below hard minimum", + existingContextWindow: 4096, + expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS, + }, + { + name: "preserves existing custom model context window when already above minimum", + existingContextWindow: 131072, + expectedContextWindow: 131072, + }, + ])("$name", ({ existingContextWindow, expectedContextWindow }) => { + const result = applyCustomModelConfigWithContextWindow(existingContextWindow); const model = result.config.models?.providers?.custom?.models?.find( (entry) => entry.id === "foo-large", ); - expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS); - }); - - it("upgrades existing custom model context window when below hard minimum", () => { - const result = applyCustomApiConfig({ - config: { - models: { - providers: { - custom: { - api: "openai-completions", - baseUrl: "https://llm.example.com/v1", - models: [ - { - id: "foo-large", - name: "foo-large", - contextWindow: 4096, - maxTokens: 1024, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: false, - }, - ], - }, - }, - }, - }, - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "openai", - providerId: "custom", - }); - - const model = result.config.models?.providers?.custom?.models?.find( - (entry) => entry.id === "foo-large", - ); - expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS); - }); - - it("preserves existing custom model context window when already above minimum", () => { - const result = applyCustomApiConfig({ - config: { - models: { - providers: { - custom: { - api: "openai-completions", - baseUrl: "https://llm.example.com/v1", - models: [ - { - id: "foo-large", - name: "foo-large", - contextWindow: 131072, - maxTokens: 4096, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: false, - }, - ], - }, - }, - }, - }, - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "openai", - providerId: "custom", - }); - - const model = result.config.models?.providers?.custom?.models?.find( - (entry) => entry.id === "foo-large", - ); - expect(model?.contextWindow).toBe(131072); + expect(model?.contextWindow).toBe(expectedContextWindow); }); it.each([ diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts index 2fbc61c3d..509af82c2 100644 --- a/src/commands/onboard-remote.test.ts +++ b/src/commands/onboard-remote.test.ts @@ -27,6 +27,18 @@ function createPrompter(overrides: Partial): WizardPrompter { return createWizardPrompter(overrides, { defaultSelect: "" }); } +function createSelectPrompter( + responses: Partial>, +): WizardPrompter["select"] { + return vi.fn(async (params) => { + const value = responses[params.message]; + if (value !== undefined) { + return value as never; + } + return (params.options[0]?.value ?? "") as never; + }); +} + describe("promptRemoteGatewayConfig", () => { const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]); @@ -49,17 +61,10 @@ describe("promptRemoteGatewayConfig", () => { }, ]); - const select: WizardPrompter["select"] = vi.fn(async (params) => { - if (params.message === "Select gateway") { - return "0" as never; - } - if (params.message === "Connection method") { - return "direct" as never; - } - if (params.message === "Gateway auth") { - return "token" as never; - } - return (params.options[0]?.value ?? "") as never; + const select = createSelectPrompter({ + "Select gateway": "0", + "Connection method": "direct", + "Gateway auth": "token", }); const text: WizardPrompter["text"] = vi.fn(async (params) => { @@ -106,12 +111,7 @@ describe("promptRemoteGatewayConfig", () => { return ""; }) as WizardPrompter["text"]; - const select: WizardPrompter["select"] = vi.fn(async (params) => { - if (params.message === "Gateway auth") { - return "off" as never; - } - return (params.options[0]?.value ?? "") as never; - }); + const select = createSelectPrompter({ "Gateway auth": "off" }); const cfg = {} as OpenClawConfig; const prompter = createPrompter({ @@ -138,12 +138,7 @@ describe("promptRemoteGatewayConfig", () => { return ""; }) as WizardPrompter["text"]; - const select: WizardPrompter["select"] = vi.fn(async (params) => { - if (params.message === "Gateway auth") { - return "off" as never; - } - return (params.options[0]?.value ?? "") as never; - }); + const select = createSelectPrompter({ "Gateway auth": "off" }); const cfg = {} as OpenClawConfig; const prompter = createPrompter({ diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index f4243b08a..5ecb6d1ef 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -85,6 +85,66 @@ async function withUnknownUsageStore(run: () => Promise) { } } +function getRuntimeLogs() { + return runtimeLogMock.mock.calls.map((call: unknown[]) => String(call[0])); +} + +function getJoinedRuntimeLogs() { + return getRuntimeLogs().join("\n"); +} + +async function runStatusAndGetLogs(args: Parameters[0] = {}) { + runtimeLogMock.mockClear(); + await statusCommand(args, runtime as never); + return getRuntimeLogs(); +} + +async function runStatusAndGetJoinedLogs(args: Parameters[0] = {}) { + await runStatusAndGetLogs(args); + return getJoinedRuntimeLogs(); +} + +type ProbeGatewayResult = { + ok: boolean; + url: string; + connectLatencyMs: number | null; + error: string | null; + close: { code: number; reason: string } | null; + health: unknown; + status: unknown; + presence: unknown; + configSnapshot: unknown; +}; + +function mockProbeGatewayResult(overrides: Partial) { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + ...overrides, + }); +} + +async function withEnvVar(key: string, value: string, run: () => Promise): Promise { + const prevValue = process.env[key]; + process.env[key] = value; + try { + return await run(); + } finally { + if (prevValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = prevValue; + } + } +} + const mocks = vi.hoisted(() => ({ loadSessionStore: vi.fn().mockReturnValue({ "+1000": createDefaultSessionStoreEntry(), @@ -367,86 +427,68 @@ describe("statusCommand", () => { it("prints unknown usage in formatted output when totalTokens is missing", async () => { await withUnknownUsageStore(async () => { - runtimeLogMock.mockClear(); - await statusCommand({}, runtime as never); - const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); + const logs = await runStatusAndGetLogs(); expect(logs.some((line) => line.includes("unknown/") && line.includes("(?%)"))).toBe(true); }); }); it("prints formatted lines otherwise", async () => { - runtimeLogMock.mockClear(); - await statusCommand({}, runtime as never); - const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); - expect(logs.some((l: string) => l.includes("OpenClaw status"))).toBe(true); - expect(logs.some((l: string) => l.includes("Overview"))).toBe(true); - expect(logs.some((l: string) => l.includes("Security audit"))).toBe(true); - expect(logs.some((l: string) => l.includes("Summary:"))).toBe(true); - expect(logs.some((l: string) => l.includes("CRITICAL"))).toBe(true); - expect(logs.some((l: string) => l.includes("Dashboard"))).toBe(true); - expect(logs.some((l: string) => l.includes("macos 14.0 (arm64)"))).toBe(true); - expect(logs.some((l: string) => l.includes("Memory"))).toBe(true); - expect(logs.some((l: string) => l.includes("Channels"))).toBe(true); - expect(logs.some((l: string) => l.includes("WhatsApp"))).toBe(true); - expect(logs.some((l: string) => l.includes("bootstrap files"))).toBe(true); - expect(logs.some((l: string) => l.includes("Sessions"))).toBe(true); - expect(logs.some((l: string) => l.includes("+1000"))).toBe(true); - expect(logs.some((l: string) => l.includes("50%"))).toBe(true); - expect(logs.some((l: string) => l.includes("40% cached"))).toBe(true); - expect(logs.some((l: string) => l.includes("LaunchAgent"))).toBe(true); - expect(logs.some((l: string) => l.includes("FAQ:"))).toBe(true); - expect(logs.some((l: string) => l.includes("Troubleshooting:"))).toBe(true); - expect(logs.some((l: string) => l.includes("Next steps:"))).toBe(true); + const logs = await runStatusAndGetLogs(); + for (const token of [ + "OpenClaw status", + "Overview", + "Security audit", + "Summary:", + "CRITICAL", + "Dashboard", + "macos 14.0 (arm64)", + "Memory", + "Channels", + "WhatsApp", + "bootstrap files", + "Sessions", + "+1000", + "50%", + "40% cached", + "LaunchAgent", + "FAQ:", + "Troubleshooting:", + "Next steps:", + ]) { + expect(logs.some((line) => line.includes(token))).toBe(true); + } expect( logs.some( - (l: string) => - l.includes("openclaw status --all") || - l.includes("openclaw --profile isolated status --all") || - l.includes("openclaw status --all") || - l.includes("openclaw --profile isolated status --all"), + (line) => + line.includes("openclaw status --all") || + line.includes("openclaw --profile isolated status --all"), ), ).toBe(true); }); it("shows gateway auth when reachable", async () => { - const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "abcd1234"; - try { - mocks.probeGateway.mockResolvedValueOnce({ + await withEnvVar("OPENCLAW_GATEWAY_TOKEN", "abcd1234", async () => { + mockProbeGatewayResult({ ok: true, - url: "ws://127.0.0.1:18789", connectLatencyMs: 123, error: null, - close: null, health: {}, status: {}, presence: [], - configSnapshot: null, }); - runtimeLogMock.mockClear(); - await statusCommand({}, runtime as never); - const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); + const logs = await runStatusAndGetLogs(); expect(logs.some((l: string) => l.includes("auth token"))).toBe(true); - } finally { - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } - } + }); }); it("surfaces channel runtime errors from the gateway", async () => { - mocks.probeGateway.mockResolvedValueOnce({ + mockProbeGatewayResult({ ok: true, - url: "ws://127.0.0.1:18789", connectLatencyMs: 10, error: null, - close: null, health: {}, status: {}, presence: [], - configSnapshot: null, }); mocks.callGateway.mockResolvedValueOnce({ channelAccounts: { @@ -471,98 +513,58 @@ describe("statusCommand", () => { }, }); - runtimeLogMock.mockClear(); - await statusCommand({}, runtime as never); - const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); - expect(logs.join("\n")).toMatch(/Signal/i); - expect(logs.join("\n")).toMatch(/iMessage/i); - expect(logs.join("\n")).toMatch(/gateway:/i); - expect(logs.join("\n")).toMatch(/WARN/); + const joined = await runStatusAndGetJoinedLogs(); + expect(joined).toMatch(/Signal/i); + expect(joined).toMatch(/iMessage/i); + expect(joined).toMatch(/gateway:/i); + expect(joined).toMatch(/WARN/); }); - it("prints requestId-aware recovery guidance when gateway pairing is required", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - url: "ws://127.0.0.1:18789", - connectLatencyMs: null, + it.each([ + { + name: "prints requestId-aware recovery guidance when gateway pairing is required", error: "connect failed: pairing required (requestId: req-123)", - close: { code: 1008, reason: "pairing required (requestId: req-123)" }, - health: null, - status: null, - presence: null, - configSnapshot: null, - }); - - runtimeLogMock.mockClear(); - await statusCommand({}, runtime as never); - const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); - const joined = logs.join("\n"); - expect(joined).toContain("Gateway pairing approval required."); - expect(joined).toContain("devices approve req-123"); - expect(joined).toContain("devices approve --latest"); - expect(joined).toContain("devices list"); - }); - - it("prints fallback recovery guidance when pairing requestId is unavailable", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - url: "ws://127.0.0.1:18789", - connectLatencyMs: null, + closeReason: "pairing required (requestId: req-123)", + includes: ["devices approve req-123"], + excludes: [], + }, + { + name: "prints fallback recovery guidance when pairing requestId is unavailable", error: "connect failed: pairing required", - close: { code: 1008, reason: "connect failed" }, - health: null, - status: null, - presence: null, - configSnapshot: null, + closeReason: "connect failed", + includes: [], + excludes: ["devices approve req-"], + }, + { + name: "does not render unsafe requestId content into approval command hints", + error: "connect failed: pairing required (requestId: req-123;rm -rf /)", + closeReason: "pairing required (requestId: req-123;rm -rf /)", + includes: [], + excludes: ["devices approve req-123;rm -rf /"], + }, + ])("$name", async ({ error, closeReason, includes, excludes }) => { + mockProbeGatewayResult({ + error, + close: { code: 1008, reason: closeReason }, }); - - runtimeLogMock.mockClear(); - await statusCommand({}, runtime as never); - const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); - const joined = logs.join("\n"); + const joined = await runStatusAndGetJoinedLogs(); expect(joined).toContain("Gateway pairing approval required."); - expect(joined).not.toContain("devices approve req-"); expect(joined).toContain("devices approve --latest"); expect(joined).toContain("devices list"); - }); - - it("does not render unsafe requestId content into approval command hints", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - url: "ws://127.0.0.1:18789", - connectLatencyMs: null, - error: "connect failed: pairing required (requestId: req-123;rm -rf /)", - close: { code: 1008, reason: "pairing required (requestId: req-123;rm -rf /)" }, - health: null, - status: null, - presence: null, - configSnapshot: null, - }); - - runtimeLogMock.mockClear(); - await statusCommand({}, runtime as never); - const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); - expect(joined).toContain("Gateway pairing approval required."); - expect(joined).not.toContain("devices approve req-123;rm -rf /"); - expect(joined).toContain("devices approve --latest"); + for (const expected of includes) { + expect(joined).toContain(expected); + } + for (const blocked of excludes) { + expect(joined).not.toContain(blocked); + } }); it("extracts requestId from close reason when error text omits it", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - url: "ws://127.0.0.1:18789", - connectLatencyMs: null, + mockProbeGatewayResult({ error: "connect failed: pairing required", close: { code: 1008, reason: "pairing required (requestId: req-close-456)" }, - health: null, - status: null, - presence: null, - configSnapshot: null, }); - - runtimeLogMock.mockClear(); - await statusCommand({}, runtime as never); - const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + const joined = await runStatusAndGetJoinedLogs(); expect(joined).toContain("devices approve req-close-456"); }); diff --git a/src/discord/monitor.gateway.test.ts b/src/discord/monitor.gateway.test.ts index d349edd4c..3e835d23c 100644 --- a/src/discord/monitor.gateway.test.ts +++ b/src/discord/monitor.gateway.test.ts @@ -2,35 +2,57 @@ import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; import { waitForDiscordGatewayStop } from "./monitor.gateway.js"; +function createGatewayWaitHarness() { + const emitter = new EventEmitter(); + const disconnect = vi.fn(); + const abort = new AbortController(); + return { emitter, disconnect, abort }; +} + +function startGatewayWait(params?: { + onGatewayError?: (error: unknown) => void; + shouldStopOnError?: (error: unknown) => boolean; + registerForceStop?: (fn: (error: unknown) => void) => void; +}) { + const harness = createGatewayWaitHarness(); + const promise = waitForDiscordGatewayStop({ + gateway: { emitter: harness.emitter, disconnect: harness.disconnect }, + abortSignal: harness.abort.signal, + ...(params?.onGatewayError ? { onGatewayError: params.onGatewayError } : {}), + ...(params?.shouldStopOnError ? { shouldStopOnError: params.shouldStopOnError } : {}), + ...(params?.registerForceStop ? { registerForceStop: params.registerForceStop } : {}), + }); + return { ...harness, promise }; +} + +async function expectAbortToResolve(params: { + emitter: EventEmitter; + disconnect: ReturnType; + abort: AbortController; + promise: Promise; + expectedDisconnectBeforeAbort?: number; +}) { + if (params.expectedDisconnectBeforeAbort !== undefined) { + expect(params.disconnect).toHaveBeenCalledTimes(params.expectedDisconnectBeforeAbort); + } + expect(params.emitter.listenerCount("error")).toBe(1); + params.abort.abort(); + await expect(params.promise).resolves.toBeUndefined(); + expect(params.disconnect).toHaveBeenCalledTimes(1); + expect(params.emitter.listenerCount("error")).toBe(0); +} + describe("waitForDiscordGatewayStop", () => { it("resolves on abort and disconnects gateway", async () => { - const emitter = new EventEmitter(); - const disconnect = vi.fn(); - const abort = new AbortController(); - - const promise = waitForDiscordGatewayStop({ - gateway: { emitter, disconnect }, - abortSignal: abort.signal, - }); - - expect(emitter.listenerCount("error")).toBe(1); - abort.abort(); - - await expect(promise).resolves.toBeUndefined(); - expect(disconnect).toHaveBeenCalledTimes(1); - expect(emitter.listenerCount("error")).toBe(0); + const { emitter, disconnect, abort, promise } = startGatewayWait(); + await expectAbortToResolve({ emitter, disconnect, abort, promise }); }); it("rejects on gateway error and disconnects", async () => { - const emitter = new EventEmitter(); - const disconnect = vi.fn(); const onGatewayError = vi.fn(); - const abort = new AbortController(); const err = new Error("boom"); - const promise = waitForDiscordGatewayStop({ - gateway: { emitter, disconnect }, - abortSignal: abort.signal, + const { emitter, disconnect, abort, promise } = startGatewayWait({ onGatewayError, }); @@ -46,28 +68,23 @@ describe("waitForDiscordGatewayStop", () => { }); it("ignores gateway errors when instructed", async () => { - const emitter = new EventEmitter(); - const disconnect = vi.fn(); const onGatewayError = vi.fn(); - const abort = new AbortController(); const err = new Error("transient"); - const promise = waitForDiscordGatewayStop({ - gateway: { emitter, disconnect }, - abortSignal: abort.signal, + const { emitter, disconnect, abort, promise } = startGatewayWait({ onGatewayError, shouldStopOnError: () => false, }); emitter.emit("error", err); expect(onGatewayError).toHaveBeenCalledWith(err); - expect(disconnect).toHaveBeenCalledTimes(0); - expect(emitter.listenerCount("error")).toBe(1); - - abort.abort(); - await expect(promise).resolves.toBeUndefined(); - expect(disconnect).toHaveBeenCalledTimes(1); - expect(emitter.listenerCount("error")).toBe(0); + await expectAbortToResolve({ + emitter, + disconnect, + abort, + promise, + expectedDisconnectBeforeAbort: 0, + }); }); it("resolves on abort without a gateway", async () => { @@ -83,14 +100,9 @@ describe("waitForDiscordGatewayStop", () => { }); it("rejects via registerForceStop and disconnects gateway", async () => { - const emitter = new EventEmitter(); - const disconnect = vi.fn(); - const abort = new AbortController(); let forceStop: ((err: unknown) => void) | undefined; - const promise = waitForDiscordGatewayStop({ - gateway: { emitter, disconnect }, - abortSignal: abort.signal, + const { emitter, disconnect, promise } = startGatewayWait({ registerForceStop: (fn) => { forceStop = fn; }, @@ -106,14 +118,9 @@ describe("waitForDiscordGatewayStop", () => { }); it("ignores forceStop after promise already settled", async () => { - const emitter = new EventEmitter(); - const disconnect = vi.fn(); - const abort = new AbortController(); let forceStop: ((err: unknown) => void) | undefined; - const promise = waitForDiscordGatewayStop({ - gateway: { emitter, disconnect }, - abortSignal: abort.signal, + const { abort, disconnect, promise } = startGatewayWait({ registerForceStop: (fn) => { forceStop = fn; }, diff --git a/src/wizard/onboarding.gateway-config.test.ts b/src/wizard/onboarding.gateway-config.test.ts index 3d7963a9f..ea67c2391 100644 --- a/src/wizard/onboarding.gateway-config.test.ts +++ b/src/wizard/onboarding.gateway-config.test.ts @@ -59,24 +59,34 @@ describe("configureGatewayForOnboarding", () => { }; } - it("generates a token when the prompt returns undefined", async () => { - mocks.randomToken.mockReturnValue("generated-token"); - + async function runGatewayConfig(params?: { + flow?: "advanced" | "quickstart"; + bindChoice?: string; + authChoice?: "token" | "password"; + tailscaleChoice?: "off" | "serve"; + textQueue?: Array; + nextConfig?: Record; + }) { + const authChoice = params?.authChoice ?? "token"; const prompter = createPrompter({ - selectQueue: ["loopback", "token", "off"], - textQueue: ["18789", undefined], + selectQueue: [params?.bindChoice ?? "loopback", authChoice, params?.tailscaleChoice ?? "off"], + textQueue: params?.textQueue ?? ["18789", undefined], }); const runtime = createRuntime(); - - const result = await configureGatewayForOnboarding({ - flow: "advanced", + return configureGatewayForOnboarding({ + flow: params?.flow ?? "advanced", baseConfig: {}, - nextConfig: {}, + nextConfig: params?.nextConfig ?? {}, localPort: 18789, - quickstartGateway: createQuickstartGateway("token"), + quickstartGateway: createQuickstartGateway(authChoice), prompter, runtime, }); + } + + it("generates a token when the prompt returns undefined", async () => { + mocks.randomToken.mockReturnValue("generated-token"); + const result = await runGatewayConfig(); expect(result.settings.gatewayToken).toBe("generated-token"); expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual([ @@ -95,21 +105,10 @@ describe("configureGatewayForOnboarding", () => { mocks.randomToken.mockReturnValue("generated-token"); mocks.randomToken.mockClear(); - const prompter = createPrompter({ - selectQueue: ["loopback", "token", "off"], - textQueue: [], - }); - const runtime = createRuntime(); - try { - const result = await configureGatewayForOnboarding({ + const result = await runGatewayConfig({ flow: "quickstart", - baseConfig: {}, - nextConfig: {}, - localPort: 18789, - quickstartGateway: createQuickstartGateway("token"), - prompter, - runtime, + textQueue: [], }); expect(result.settings.gatewayToken).toBe("token-from-env"); @@ -124,22 +123,8 @@ describe("configureGatewayForOnboarding", () => { it("does not set password to literal 'undefined' when prompt returns undefined", async () => { mocks.randomToken.mockReturnValue("unused"); - - // Flow: loopback bind → password auth → tailscale off - const prompter = createPrompter({ - selectQueue: ["loopback", "password", "off"], - textQueue: ["18789", undefined], - }); - const runtime = createRuntime(); - - const result = await configureGatewayForOnboarding({ - flow: "advanced", - baseConfig: {}, - nextConfig: {}, - localPort: 18789, - quickstartGateway: createQuickstartGateway("password"), - prompter, - runtime, + const result = await runGatewayConfig({ + authChoice: "password", }); const authConfig = result.nextConfig.gateway?.auth as { mode?: string; password?: string }; @@ -150,21 +135,8 @@ describe("configureGatewayForOnboarding", () => { it("seeds control UI allowed origins for non-loopback binds", async () => { mocks.randomToken.mockReturnValue("generated-token"); - - const prompter = createPrompter({ - selectQueue: ["lan", "token", "off"], - textQueue: ["18789", undefined], - }); - const runtime = createRuntime(); - - const result = await configureGatewayForOnboarding({ - flow: "advanced", - baseConfig: {}, - nextConfig: {}, - localPort: 18789, - quickstartGateway: createQuickstartGateway("token"), - prompter, - runtime, + const result = await runGatewayConfig({ + bindChoice: "lan", }); expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toEqual([ @@ -176,21 +148,8 @@ describe("configureGatewayForOnboarding", () => { it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => { mocks.randomToken.mockReturnValue("generated-token"); mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net"); - - const prompter = createPrompter({ - selectQueue: ["loopback", "token", "serve"], - textQueue: ["18789", undefined], - }); - const runtime = createRuntime(); - - const result = await configureGatewayForOnboarding({ - flow: "advanced", - baseConfig: {}, - nextConfig: {}, - localPort: 18789, - quickstartGateway: createQuickstartGateway("token"), - prompter, - runtime, + const result = await runGatewayConfig({ + tailscaleChoice: "serve", }); expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain( @@ -201,21 +160,8 @@ describe("configureGatewayForOnboarding", () => { it("does not add Tailscale origin when getTailnetHostname fails", async () => { mocks.randomToken.mockReturnValue("generated-token"); mocks.getTailnetHostname.mockRejectedValue(new Error("not found")); - - const prompter = createPrompter({ - selectQueue: ["loopback", "token", "serve"], - textQueue: ["18789", undefined], - }); - const runtime = createRuntime(); - - const result = await configureGatewayForOnboarding({ - flow: "advanced", - baseConfig: {}, - nextConfig: {}, - localPort: 18789, - quickstartGateway: createQuickstartGateway("token"), - prompter, - runtime, + const result = await runGatewayConfig({ + tailscaleChoice: "serve", }); expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toBeUndefined(); @@ -224,21 +170,8 @@ describe("configureGatewayForOnboarding", () => { it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => { mocks.randomToken.mockReturnValue("generated-token"); mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::99"); - - const prompter = createPrompter({ - selectQueue: ["loopback", "token", "serve"], - textQueue: ["18789", undefined], - }); - const runtime = createRuntime(); - - const result = await configureGatewayForOnboarding({ - flow: "advanced", - baseConfig: {}, - nextConfig: {}, - localPort: 18789, - quickstartGateway: createQuickstartGateway("token"), - prompter, - runtime, + const result = await runGatewayConfig({ + tailscaleChoice: "serve", }); expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain( @@ -249,16 +182,8 @@ describe("configureGatewayForOnboarding", () => { it("does not duplicate Tailscale origin when allowlist already contains case variants", async () => { mocks.randomToken.mockReturnValue("generated-token"); mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net"); - - const prompter = createPrompter({ - selectQueue: ["loopback", "token", "serve"], - textQueue: ["18789", undefined], - }); - const runtime = createRuntime(); - - const result = await configureGatewayForOnboarding({ - flow: "advanced", - baseConfig: {}, + const result = await runGatewayConfig({ + tailscaleChoice: "serve", nextConfig: { gateway: { controlUi: { @@ -266,10 +191,6 @@ describe("configureGatewayForOnboarding", () => { }, }, }, - localPort: 18789, - quickstartGateway: createQuickstartGateway("token"), - prompter, - runtime, }); const origins = result.nextConfig.gateway?.controlUi?.allowedOrigins ?? [];