From 9adcaccd0bca5d811047196dfc6b6b2a61526776 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 15:57:40 +0000 Subject: [PATCH] refactor(test): share non-interactive onboarding test helpers --- ...nboard-non-interactive.gateway.e2e.test.ts | 107 +++++---- ...-non-interactive.provider-auth.e2e.test.ts | 226 +++++------------- .../onboard-non-interactive.test-helpers.ts | 54 +++++ 3 files changed, 173 insertions(+), 214 deletions(-) create mode 100644 src/commands/onboard-non-interactive.test-helpers.ts diff --git a/src/commands/onboard-non-interactive.gateway.e2e.test.ts b/src/commands/onboard-non-interactive.gateway.e2e.test.ts index 2172a69c9d2..1c06e285e32 100644 --- a/src/commands/onboard-non-interactive.gateway.e2e.test.ts +++ b/src/commands/onboard-non-interactive.gateway.e2e.test.ts @@ -1,8 +1,13 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; +import { + createThrowingRuntime, + readJsonFile, + runNonInteractiveOnboarding, +} from "./onboard-non-interactive.test-helpers.js"; const gatewayClientCalls: Array<{ url?: string; @@ -53,15 +58,7 @@ async function getFreeGatewayPort(): Promise { }); } -const runtime = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, -}; +const runtime = createThrowingRuntime(); async function expectGatewayTokenAuth(params: { authConfig: unknown; @@ -111,11 +108,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { await fs.rm(stateDir, { recursive: true, force: true }); } }; - const runOnboarding = async (options: Record) => { - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding(options, runtime); - }; - beforeAll(async () => { process.env.OPENCLAW_SKIP_CHANNELS = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; @@ -125,7 +117,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_PASSWORD; - tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-")); + tempHome = await makeTempWorkspace("openclaw-onboard-"); process.env.HOME = tempHome; }); @@ -150,25 +142,28 @@ describe("onboard (non-interactive): gateway and remote auth", () => { const token = "tok_test_123"; const workspace = path.join(stateDir, "openclaw"); - await runOnboarding({ - nonInteractive: true, - mode: "local", - workspace, - authChoice: "skip", - skipSkills: true, - skipHealth: true, - installDaemon: false, - gatewayBind: "loopback", - gatewayAuth: "token", - gatewayToken: token, - }); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayBind: "loopback", + gatewayAuth: "token", + gatewayToken: token, + }, + runtime, + ); const { resolveConfigPath } = await import("../config/paths.js"); const configPath = resolveConfigPath(process.env, stateDir); - const cfg = JSON.parse(await fs.readFile(configPath, "utf8")) as { + const cfg = await readJsonFile<{ gateway?: { auth?: { mode?: string; token?: string } }; agents?: { defaults?: { workspace?: string } }; - }; + }>(configPath); expect(cfg?.agents?.defaults?.workspace).toBe(workspace); expect(cfg?.gateway?.auth?.mode).toBe("token"); @@ -186,19 +181,22 @@ describe("onboard (non-interactive): gateway and remote auth", () => { await withStateDir("state-remote-", async () => { const port = await getFreePort(); const token = "tok_remote_123"; - await runOnboarding({ - nonInteractive: true, - mode: "remote", - remoteUrl: `ws://127.0.0.1:${port}`, - remoteToken: token, - authChoice: "skip", - json: true, - }); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "remote", + remoteUrl: `ws://127.0.0.1:${port}`, + remoteToken: token, + authChoice: "skip", + json: true, + }, + runtime, + ); const { resolveConfigPath } = await import("../config/config.js"); - const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as { + const cfg = await readJsonFile<{ gateway?: { mode?: string; remote?: { url?: string; token?: string } }; - }; + }>(resolveConfigPath()); expect(cfg.gateway?.mode).toBe("remote"); expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`); @@ -226,27 +224,30 @@ describe("onboard (non-interactive): gateway and remote auth", () => { const port = await getFreeGatewayPort(); const workspace = path.join(stateDir, "openclaw"); - await runOnboarding({ - nonInteractive: true, - mode: "local", - workspace, - authChoice: "skip", - skipSkills: true, - skipHealth: true, - installDaemon: false, - gatewayPort: port, - gatewayBind: "lan", - }); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayPort: port, + gatewayBind: "lan", + }, + runtime, + ); const { resolveConfigPath } = await import("../config/paths.js"); const configPath = resolveConfigPath(process.env, stateDir); - const cfg = JSON.parse(await fs.readFile(configPath, "utf8")) as { + const cfg = await readJsonFile<{ gateway?: { bind?: string; port?: number; auth?: { mode?: string; token?: string }; }; - }; + }>(configPath); expect(cfg.gateway?.bind).toBe("lan"); expect(cfg.gateway?.port).toBe(port); diff --git a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts index 318d2cc6184..e59c5b193b3 100644 --- a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts @@ -1,21 +1,21 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { describe, expect, it } from "vitest"; +import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { captureEnv } from "../test-utils/env.js"; import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL } from "./onboard-auth.js"; +import { + createThrowingRuntime, + readJsonFile, + runNonInteractiveOnboardingWithDefaults, + type NonInteractiveRuntime, +} from "./onboard-non-interactive.test-helpers.js"; import { OPENAI_DEFAULT_MODEL } from "./openai-model-default.js"; -type RuntimeMock = { - log: () => void; - error: (msg: string) => never; - exit: (code: number) => never; -}; - type OnboardEnv = { configPath: string; - runtime: RuntimeMock; + runtime: NonInteractiveRuntime; }; type ProviderAuthConfigSnapshot = { @@ -77,21 +77,13 @@ async function withOnboardEnv( delete process.env.OPENCLAW_GATEWAY_PASSWORD; delete process.env.CUSTOM_API_KEY; - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const tempHome = await makeTempWorkspace(prefix); const configPath = path.join(tempHome, "openclaw.json"); process.env.HOME = tempHome; process.env.OPENCLAW_STATE_DIR = tempHome; process.env.OPENCLAW_CONFIG_PATH = configPath; - const runtime: RuntimeMock = { - log: () => {}, - error: (msg: string) => { - throw new Error(msg); - }, - exit: (code: number) => { - throw new Error(`exit:${code}`); - }, - }; + const runtime = createThrowingRuntime(); try { await run({ configPath, runtime }); @@ -101,72 +93,26 @@ async function withOnboardEnv( } } -async function runNonInteractive( - options: Record, - runtime: RuntimeMock, -): Promise { - const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); - await runNonInteractiveOnboarding(options, runtime); -} - -async function runNonInteractiveWithDefaults( - runtime: RuntimeMock, - options: Record, -): Promise { - await runNonInteractive( - { - nonInteractive: true, - skipHealth: true, - skipChannels: true, - json: true, - ...options, - }, - runtime, - ); -} - -async function readJsonFile(filePath: string): Promise { - return JSON.parse(await fs.readFile(filePath, "utf8")) as T; -} - -async function runApiKeyOnboardingAndReadConfig( +async function runOnboardingAndReadConfig( env: OnboardEnv, options: Record, ): Promise { - await runNonInteractiveWithDefaults(env.runtime, { + await runNonInteractiveOnboardingWithDefaults(env.runtime, { skipSkills: true, ...options, }); return readJsonFile(env.configPath); } -async function runInferredApiKeyOnboardingAndReadConfig( - env: OnboardEnv, - options: Record, -): Promise { - await runNonInteractive( - { - nonInteractive: true, - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - ...options, - }, - env.runtime, - ); - return readJsonFile(env.configPath); -} - const CUSTOM_LOCAL_BASE_URL = "https://models.custom.local/v1"; const CUSTOM_LOCAL_MODEL_ID = "local-large"; const CUSTOM_LOCAL_PROVIDER_ID = "custom-models-custom-local"; async function runCustomLocalNonInteractive( - runtime: RuntimeMock, + runtime: NonInteractiveRuntime, overrides: Record = {}, ): Promise { - await runNonInteractiveWithDefaults(runtime, { + await runNonInteractiveOnboardingWithDefaults(runtime, { authChoice: "custom-api-key", customBaseUrl: CUSTOM_LOCAL_BASE_URL, customModelId: CUSTOM_LOCAL_MODEL_ID, @@ -202,7 +148,7 @@ async function expectApiKeyProfile(params: { describe("onboard (non-interactive): provider auth", () => { it("stores MiniMax API key and uses global baseUrl by default", async () => { await withOnboardEnv("openclaw-onboard-minimax-", async (env) => { - const cfg = await runApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { authChoice: "minimax-api", minimaxApiKey: "sk-minimax-test", }); @@ -221,7 +167,7 @@ describe("onboard (non-interactive): provider auth", () => { it("supports MiniMax CN API endpoint auth choice", async () => { await withOnboardEnv("openclaw-onboard-minimax-cn-", async (env) => { - const cfg = await runApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { authChoice: "minimax-api-key-cn", minimaxApiKey: "sk-minimax-test", }); @@ -240,7 +186,7 @@ describe("onboard (non-interactive): provider auth", () => { it("stores Z.AI API key and uses global baseUrl by default", async () => { await withOnboardEnv("openclaw-onboard-zai-", async (env) => { - const cfg = await runApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { authChoice: "zai-api-key", zaiApiKey: "zai-test-key", }); @@ -255,7 +201,7 @@ describe("onboard (non-interactive): provider auth", () => { it("supports Z.AI CN coding endpoint auth choice", async () => { await withOnboardEnv("openclaw-onboard-zai-cn-", async (env) => { - const cfg = await runApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { authChoice: "zai-coding-cn", zaiApiKey: "zai-test-key", }); @@ -269,7 +215,7 @@ describe("onboard (non-interactive): provider auth", () => { it("stores xAI API key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-xai-", async (env) => { const rawKey = "xai-test-\r\nkey"; - const cfg = await runApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { authChoice: "xai-api-key", xaiApiKey: rawKey, }); @@ -283,7 +229,7 @@ describe("onboard (non-interactive): provider auth", () => { it("stores Vercel AI Gateway API key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-ai-gateway-", async (env) => { - const cfg = await runApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { authChoice: "ai-gateway-api-key", aiGatewayApiKey: "gateway-test-key", }); @@ -306,7 +252,7 @@ describe("onboard (non-interactive): provider auth", () => { const cleanToken = `sk-ant-oat01-${"a".repeat(80)}`; const token = `${cleanToken.slice(0, 30)}\r${cleanToken.slice(30)}`; - await runNonInteractiveWithDefaults(runtime, { + await runNonInteractiveOnboardingWithDefaults(runtime, { authChoice: "token", tokenProvider: "anthropic", token, @@ -331,7 +277,7 @@ describe("onboard (non-interactive): provider auth", () => { it("stores OpenAI API key and sets OpenAI default model", async () => { await withOnboardEnv("openclaw-onboard-openai-", async (env) => { - const cfg = await runApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { authChoice: "openai-api-key", openaiApiKey: "sk-openai-test", }); @@ -343,7 +289,7 @@ describe("onboard (non-interactive): provider auth", () => { it("rejects vLLM auth choice in non-interactive mode", async () => { await withOnboardEnv("openclaw-onboard-vllm-non-interactive-", async ({ runtime }) => { await expect( - runNonInteractiveWithDefaults(runtime, { + runNonInteractiveOnboardingWithDefaults(runtime, { authChoice: "vllm", skipSkills: true, }), @@ -353,7 +299,7 @@ describe("onboard (non-interactive): provider auth", () => { it("stores LiteLLM API key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-litellm-", async (env) => { - const cfg = await runApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { authChoice: "litellm-api-key", litellmApiKey: "litellm-test-key", }); @@ -386,20 +332,13 @@ describe("onboard (non-interactive): provider auth", () => { "$name", async ({ prefix, options }) => { await withOnboardEnv(prefix, async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - cloudflareAiGatewayAccountId: "cf-account-id", - cloudflareAiGatewayGatewayId: "cf-gateway-id", - cloudflareAiGatewayApiKey: "cf-gateway-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - ...options, - }, - runtime, - ); + await runNonInteractiveOnboardingWithDefaults(runtime, { + cloudflareAiGatewayAccountId: "cf-account-id", + cloudflareAiGatewayGatewayId: "cf-gateway-id", + cloudflareAiGatewayApiKey: "cf-gateway-test-key", + skipSkills: true, + ...options, + }); const cfg = await readJsonFile(configPath); @@ -423,7 +362,7 @@ describe("onboard (non-interactive): provider auth", () => { it("infers Together auth choice from --together-api-key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-together-infer-", async (env) => { - const cfg = await runInferredApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { togetherApiKey: "together-test-key", }); @@ -440,7 +379,7 @@ describe("onboard (non-interactive): provider auth", () => { it("infers QIANFAN auth choice from --qianfan-api-key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-qianfan-infer-", async (env) => { - const cfg = await runInferredApiKeyOnboardingAndReadConfig(env, { + const cfg = await runOnboardingAndReadConfig(env, { qianfanApiKey: "qianfan-test-key", }); @@ -457,21 +396,14 @@ describe("onboard (non-interactive): provider auth", () => { it("configures a custom provider from non-interactive flags", async () => { await withOnboardEnv("openclaw-onboard-custom-provider-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - authChoice: "custom-api-key", - customBaseUrl: "https://llm.example.com/v1", - customApiKey: "custom-test-key", - customModelId: "foo-large", - customCompatibility: "anthropic", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); + await runNonInteractiveOnboardingWithDefaults(runtime, { + authChoice: "custom-api-key", + customBaseUrl: "https://llm.example.com/v1", + customApiKey: "custom-test-key", + customModelId: "foo-large", + customCompatibility: "anthropic", + skipSkills: true, + }); const cfg = await readJsonFile(configPath); @@ -488,19 +420,12 @@ describe("onboard (non-interactive): provider auth", () => { await withOnboardEnv( "openclaw-onboard-custom-provider-infer-", async ({ configPath, runtime }) => { - await runNonInteractive( - { - nonInteractive: true, - customBaseUrl: "https://models.custom.local/v1", - customModelId: "local-large", - customApiKey: "custom-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ); + await runNonInteractiveOnboardingWithDefaults(runtime, { + customBaseUrl: "https://models.custom.local/v1", + customModelId: "local-large", + customApiKey: "custom-test-key", + skipSkills: true, + }); const cfg = await readJsonFile(configPath); @@ -550,20 +475,13 @@ describe("onboard (non-interactive): provider auth", () => { "openclaw-onboard-custom-provider-invalid-compat-", async ({ runtime }) => { await expect( - runNonInteractive( - { - nonInteractive: true, - authChoice: "custom-api-key", - customBaseUrl: "https://models.custom.local/v1", - customModelId: "local-large", - customCompatibility: "xmlrpc", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ), + runNonInteractiveOnboardingWithDefaults(runtime, { + authChoice: "custom-api-key", + customBaseUrl: "https://models.custom.local/v1", + customModelId: "local-large", + customCompatibility: "xmlrpc", + skipSkills: true, + }), ).rejects.toThrow('Invalid --custom-compatibility (use "openai" or "anthropic").'); }, ); @@ -572,20 +490,13 @@ describe("onboard (non-interactive): provider auth", () => { it("fails custom provider auth when explicit provider id is invalid", async () => { await withOnboardEnv("openclaw-onboard-custom-provider-invalid-id-", async ({ runtime }) => { await expect( - runNonInteractive( - { - nonInteractive: true, - authChoice: "custom-api-key", - customBaseUrl: "https://models.custom.local/v1", - customModelId: "local-large", - customProviderId: "!!!", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ), + runNonInteractiveOnboardingWithDefaults(runtime, { + authChoice: "custom-api-key", + customBaseUrl: "https://models.custom.local/v1", + customModelId: "local-large", + customProviderId: "!!!", + skipSkills: true, + }), ).rejects.toThrow( "Invalid custom provider config: Custom provider ID must include letters, numbers, or hyphens.", ); @@ -597,17 +508,10 @@ describe("onboard (non-interactive): provider auth", () => { "openclaw-onboard-custom-provider-missing-required-", async ({ runtime }) => { await expect( - runNonInteractive( - { - nonInteractive: true, - customApiKey: "custom-test-key", - skipHealth: true, - skipChannels: true, - skipSkills: true, - json: true, - }, - runtime, - ), + runNonInteractiveOnboardingWithDefaults(runtime, { + customApiKey: "custom-test-key", + skipSkills: true, + }), ).rejects.toThrow('Auth choice "custom-api-key" requires a base URL and model ID.'); }, ); diff --git a/src/commands/onboard-non-interactive.test-helpers.ts b/src/commands/onboard-non-interactive.test-helpers.ts new file mode 100644 index 00000000000..67ecaa682e0 --- /dev/null +++ b/src/commands/onboard-non-interactive.test-helpers.ts @@ -0,0 +1,54 @@ +import fs from "node:fs/promises"; +import type { RuntimeEnv } from "../runtime.js"; + +type RuntimeLike = Pick; + +export type NonInteractiveRuntime = { + log: RuntimeLike["log"]; + error: RuntimeLike["error"]; + exit: RuntimeLike["exit"]; +}; + +const NON_INTERACTIVE_DEFAULT_OPTIONS = { + nonInteractive: true, + skipHealth: true, + skipChannels: true, + json: true, +} as const; + +export function createThrowingRuntime(): NonInteractiveRuntime { + return { + log: () => {}, + error: (msg: string) => { + throw new Error(msg); + }, + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, + }; +} + +export async function runNonInteractiveOnboarding( + options: Record, + runtime: NonInteractiveRuntime, +): Promise { + const { runNonInteractiveOnboarding: run } = await import("./onboard-non-interactive.js"); + await run(options, runtime); +} + +export async function runNonInteractiveOnboardingWithDefaults( + runtime: NonInteractiveRuntime, + options: Record, +): Promise { + await runNonInteractiveOnboarding( + { + ...NON_INTERACTIVE_DEFAULT_OPTIONS, + ...options, + }, + runtime, + ); +} + +export async function readJsonFile(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf8")) as T; +}