refactor(test): share non-interactive onboarding test helpers

This commit is contained in:
Peter Steinberger
2026-02-16 15:57:40 +00:00
parent 2e7fac2231
commit 9adcaccd0b
3 changed files with 173 additions and 214 deletions

View File

@@ -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<number> {
});
}
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<string, unknown>) => {
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);

View File

@@ -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<string, unknown>,
runtime: RuntimeMock,
): Promise<void> {
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
await runNonInteractiveOnboarding(options, runtime);
}
async function runNonInteractiveWithDefaults(
runtime: RuntimeMock,
options: Record<string, unknown>,
): Promise<void> {
await runNonInteractive(
{
nonInteractive: true,
skipHealth: true,
skipChannels: true,
json: true,
...options,
},
runtime,
);
}
async function readJsonFile<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
}
async function runApiKeyOnboardingAndReadConfig(
async function runOnboardingAndReadConfig(
env: OnboardEnv,
options: Record<string, unknown>,
): Promise<ProviderAuthConfigSnapshot> {
await runNonInteractiveWithDefaults(env.runtime, {
await runNonInteractiveOnboardingWithDefaults(env.runtime, {
skipSkills: true,
...options,
});
return readJsonFile<ProviderAuthConfigSnapshot>(env.configPath);
}
async function runInferredApiKeyOnboardingAndReadConfig(
env: OnboardEnv,
options: Record<string, unknown>,
): Promise<ProviderAuthConfigSnapshot> {
await runNonInteractive(
{
nonInteractive: true,
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
...options,
},
env.runtime,
);
return readJsonFile<ProviderAuthConfigSnapshot>(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<string, unknown> = {},
): Promise<void> {
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<ProviderAuthConfigSnapshot>(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<ProviderAuthConfigSnapshot>(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<ProviderAuthConfigSnapshot>(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.');
},
);

View File

@@ -0,0 +1,54 @@
import fs from "node:fs/promises";
import type { RuntimeEnv } from "../runtime.js";
type RuntimeLike = Pick<RuntimeEnv, "log" | "error" | "exit">;
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<string, unknown>,
runtime: NonInteractiveRuntime,
): Promise<void> {
const { runNonInteractiveOnboarding: run } = await import("./onboard-non-interactive.js");
await run(options, runtime);
}
export async function runNonInteractiveOnboardingWithDefaults(
runtime: NonInteractiveRuntime,
options: Record<string, unknown>,
): Promise<void> {
await runNonInteractiveOnboarding(
{
...NON_INTERACTIVE_DEFAULT_OPTIONS,
...options,
},
runtime,
);
}
export async function readJsonFile<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
}