refactor(test): dedupe command config and model test fixtures

This commit is contained in:
Peter Steinberger
2026-02-16 16:32:11 +00:00
parent 130e59a9c0
commit 261f5ee492
6 changed files with 273 additions and 363 deletions

View File

@@ -1,6 +1,18 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildGatewayAuthConfig } from "./configure.js"; import { buildGatewayAuthConfig } from "./configure.js";
function expectGeneratedTokenFromInput(token: string | undefined, literalToAvoid = "undefined") {
const result = buildGatewayAuthConfig({
mode: "token",
token,
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe(literalToAvoid);
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
}
describe("buildGatewayAuthConfig", () => { describe("buildGatewayAuthConfig", () => {
it("preserves allowTailscale when switching to token", () => { it("preserves allowTailscale when switching to token", () => {
const result = buildGatewayAuthConfig({ const result = buildGatewayAuthConfig({
@@ -54,68 +66,23 @@ describe("buildGatewayAuthConfig", () => {
}); });
it("generates random token when token param is undefined", () => { it("generates random token when token param is undefined", () => {
const result = buildGatewayAuthConfig({ expectGeneratedTokenFromInput(undefined);
mode: "token",
token: undefined,
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("undefined");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
}); });
it("generates random token when token param is empty string", () => { it("generates random token when token param is empty string", () => {
const result = buildGatewayAuthConfig({ expectGeneratedTokenFromInput("");
mode: "token",
token: "",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("undefined");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
}); });
it("generates random token when token param is whitespace only", () => { it("generates random token when token param is whitespace only", () => {
const result = buildGatewayAuthConfig({ expectGeneratedTokenFromInput(" ");
mode: "token",
token: " ",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("undefined");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
}); });
it('generates random token when token param is the literal string "undefined"', () => { it('generates random token when token param is the literal string "undefined"', () => {
const result = buildGatewayAuthConfig({ expectGeneratedTokenFromInput("undefined");
mode: "token",
token: "undefined",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("undefined");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
}); });
it('generates random token when token param is the literal string "null"', () => { it('generates random token when token param is the literal string "null"', () => {
const result = buildGatewayAuthConfig({ expectGeneratedTokenFromInput("null", "null");
mode: "token",
token: "null",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("null");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
}); });
it("builds trusted-proxy config with all options", () => { it("builds trusted-proxy config with all options", () => {

View File

@@ -55,81 +55,71 @@ function makeRuntime(): RuntimeEnv {
}; };
} }
async function runTrustedProxyPrompt(textQueue: Array<string | undefined>) { async function runGatewayPrompt(params: {
selectQueue: string[];
textQueue: Array<string | undefined>;
randomToken?: string;
confirmResult?: boolean;
authConfigFactory?: (input: Record<string, unknown>) => Record<string, unknown>;
}) {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789); mocks.resolveGatewayPort.mockReturnValue(18789);
const selectQueue = ["loopback", "trusted-proxy", "off"]; mocks.select.mockImplementation(async () => params.selectQueue.shift());
mocks.select.mockImplementation(async () => selectQueue.shift()); mocks.text.mockImplementation(async () => params.textQueue.shift());
mocks.text.mockImplementation(async () => textQueue.shift()); mocks.randomToken.mockReturnValue(params.randomToken ?? "generated-token");
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({ mocks.confirm.mockResolvedValue(params.confirmResult ?? true);
mode, mocks.buildGatewayAuthConfig.mockImplementation((input) =>
trustedProxy, params.authConfigFactory ? params.authConfigFactory(input as Record<string, unknown>) : input,
})); );
const result = await promptGatewayConfig({}, makeRuntime()); const result = await promptGatewayConfig({}, makeRuntime());
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0]; const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
return { result, call }; return { result, call };
} }
async function runTrustedProxyPrompt(params: {
textQueue: Array<string | undefined>;
tailscaleMode?: "off" | "serve";
}) {
return runGatewayPrompt({
selectQueue: ["loopback", "trusted-proxy", params.tailscaleMode ?? "off"],
textQueue: params.textQueue,
authConfigFactory: ({ mode, trustedProxy }) => ({ mode, trustedProxy }),
});
}
describe("promptGatewayConfig", () => { describe("promptGatewayConfig", () => {
it("generates a token when the prompt returns undefined", async () => { it("generates a token when the prompt returns undefined", async () => {
mocks.resolveGatewayPort.mockReturnValue(18789); const { result } = await runGatewayPrompt({
const selectQueue = ["loopback", "token", "off"]; selectQueue: ["loopback", "token", "off"],
mocks.select.mockImplementation(async () => selectQueue.shift()); textQueue: ["18789", undefined],
const textQueue = ["18789", undefined]; randomToken: "generated-token",
mocks.text.mockImplementation(async () => textQueue.shift()); authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }),
mocks.randomToken.mockReturnValue("generated-token"); });
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, token, password }) => ({
mode,
token,
password,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await promptGatewayConfig({}, runtime);
expect(result.token).toBe("generated-token"); expect(result.token).toBe("generated-token");
}); });
it("does not set password to literal 'undefined' when prompt returns undefined", async () => { it("does not set password to literal 'undefined' when prompt returns undefined", async () => {
vi.clearAllMocks(); const { call } = await runGatewayPrompt({
mocks.resolveGatewayPort.mockReturnValue(18789); selectQueue: ["loopback", "password", "off"],
// Flow: loopback bind → password auth → tailscale off textQueue: ["18789", undefined],
const selectQueue = ["loopback", "password", "off"]; randomToken: "unused",
mocks.select.mockImplementation(async () => selectQueue.shift()); authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }),
// Port prompt → OK, then password prompt → returns undefined (simulating prompter edge case) });
const textQueue = ["18789", undefined];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.randomToken.mockReturnValue("unused");
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, token, password }) => ({
mode,
token,
password,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await promptGatewayConfig({}, runtime);
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
expect(call?.password).not.toBe("undefined"); expect(call?.password).not.toBe("undefined");
expect(call?.password).toBe(""); expect(call?.password).toBe("");
}); });
it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => { it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => {
const { result, call } = await runTrustedProxyPrompt([ const { result, call } = await runTrustedProxyPrompt({
"18789", textQueue: [
"x-forwarded-user", "18789",
"x-forwarded-proto,x-forwarded-host", "x-forwarded-user",
"nick@example.com", "x-forwarded-proto,x-forwarded-host",
"10.0.1.10,192.168.1.5", "nick@example.com",
]); "10.0.1.10,192.168.1.5",
],
});
expect(call?.mode).toBe("trusted-proxy"); expect(call?.mode).toBe("trusted-proxy");
expect(call?.trustedProxy).toEqual({ expect(call?.trustedProxy).toEqual({
@@ -142,13 +132,9 @@ describe("promptGatewayConfig", () => {
}); });
it("handles trusted-proxy with no optional fields", async () => { it("handles trusted-proxy with no optional fields", async () => {
const { result, call } = await runTrustedProxyPrompt([ const { result, call } = await runTrustedProxyPrompt({
"18789", textQueue: ["18789", "x-remote-user", "", "", "10.0.0.1"],
"x-remote-user", });
"",
"",
"10.0.0.1",
]);
expect(call?.mode).toBe("trusted-proxy"); expect(call?.mode).toBe("trusted-proxy");
expect(call?.trustedProxy).toEqual({ expect(call?.trustedProxy).toEqual({
@@ -160,25 +146,10 @@ describe("promptGatewayConfig", () => {
}); });
it("forces tailscale off when trusted-proxy is selected", async () => { it("forces tailscale off when trusted-proxy is selected", async () => {
vi.clearAllMocks(); const { result } = await runTrustedProxyPrompt({
mocks.resolveGatewayPort.mockReturnValue(18789); tailscaleMode: "serve",
const selectQueue = ["loopback", "trusted-proxy", "serve"]; textQueue: ["18789", "x-forwarded-user", "", "", "10.0.0.1"],
mocks.select.mockImplementation(async () => selectQueue.shift()); });
const textQueue = ["18789", "x-forwarded-user", "", "", "10.0.0.1"];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.confirm.mockResolvedValue(true);
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
mode,
trustedProxy,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await promptGatewayConfig({}, runtime);
expect(result.config.gateway?.bind).toBe("lan"); expect(result.config.gateway?.bind).toBe("lan");
expect(result.config.gateway?.tailscale?.mode).toBe("off"); expect(result.config.gateway?.tailscale?.mode).toBe("off");
expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false); expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false);

View File

@@ -42,20 +42,39 @@ describe("resolveGatewayDevMode", () => {
}); });
}); });
function mockNodeGatewayPlanFixture(
params: {
workingDirectory?: string;
version?: string;
supported?: boolean;
warning?: string;
serviceEnvironment?: Record<string, string>;
} = {},
) {
const {
workingDirectory = "/Users/me",
version = "22.0.0",
supported = true,
warning,
serviceEnvironment = { OPENCLAW_PORT: "3000" },
} = params;
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory,
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version,
supported,
});
mocks.renderSystemNodeWarning.mockReturnValue(warning);
mocks.buildServiceEnvironment.mockReturnValue(serviceEnvironment);
}
describe("buildGatewayInstallPlan", () => { describe("buildGatewayInstallPlan", () => {
it("uses provided nodePath and returns plan", async () => { it("uses provided nodePath and returns plan", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); mockNodeGatewayPlanFixture();
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
mocks.buildServiceEnvironment.mockReturnValue({ OPENCLAW_PORT: "3000" });
const plan = await buildGatewayInstallPlan({ const plan = await buildGatewayInstallPlan({
env: {}, env: {},
@@ -72,18 +91,13 @@ describe("buildGatewayInstallPlan", () => {
it("emits warnings when renderSystemNodeWarning returns one", async () => { it("emits warnings when renderSystemNodeWarning returns one", async () => {
const warn = vi.fn(); const warn = vi.fn();
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); mockNodeGatewayPlanFixture({
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: undefined, workingDirectory: undefined,
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "18.0.0", version: "18.0.0",
supported: false, supported: false,
warning: "Node too old",
serviceEnvironment: {},
}); });
mocks.renderSystemNodeWarning.mockReturnValue("Node too old");
mocks.buildServiceEnvironment.mockReturnValue({});
await buildGatewayInstallPlan({ await buildGatewayInstallPlan({
env: {}, env: {},
@@ -97,19 +111,11 @@ describe("buildGatewayInstallPlan", () => {
}); });
it("merges config env vars into the environment", async () => { it("merges config env vars into the environment", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); mockNodeGatewayPlanFixture({
mocks.resolveGatewayProgramArguments.mockResolvedValue({ serviceEnvironment: {
programArguments: ["node", "gateway"], OPENCLAW_PORT: "3000",
workingDirectory: "/Users/me", HOME: "/Users/me",
}); },
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({
OPENCLAW_PORT: "3000",
HOME: "/Users/me",
}); });
const plan = await buildGatewayInstallPlan({ const plan = await buildGatewayInstallPlan({
@@ -135,17 +141,7 @@ describe("buildGatewayInstallPlan", () => {
}); });
it("does not include empty config env values", async () => { it("does not include empty config env values", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); mockNodeGatewayPlanFixture();
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({ OPENCLAW_PORT: "3000" });
const plan = await buildGatewayInstallPlan({ const plan = await buildGatewayInstallPlan({
env: {}, env: {},
@@ -166,17 +162,7 @@ describe("buildGatewayInstallPlan", () => {
}); });
it("drops whitespace-only config env values", async () => { it("drops whitespace-only config env values", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); mockNodeGatewayPlanFixture({ serviceEnvironment: {} });
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({});
const plan = await buildGatewayInstallPlan({ const plan = await buildGatewayInstallPlan({
env: {}, env: {},
@@ -197,19 +183,11 @@ describe("buildGatewayInstallPlan", () => {
}); });
it("keeps service env values over config env vars", async () => { it("keeps service env values over config env vars", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); mockNodeGatewayPlanFixture({
mocks.resolveGatewayProgramArguments.mockResolvedValue({ serviceEnvironment: {
programArguments: ["node", "gateway"], HOME: "/Users/service",
workingDirectory: "/Users/me", OPENCLAW_PORT: "3000",
}); },
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({
HOME: "/Users/service",
OPENCLAW_PORT: "3000",
}); });
const plan = await buildGatewayInstallPlan({ const plan = await buildGatewayInstallPlan({

View File

@@ -36,20 +36,29 @@ vi.mock("../agents/model-auth.js", () => ({
getCustomProviderApiKey, getCustomProviderApiKey,
})); }));
const OPENROUTER_CATALOG = [
{
provider: "openrouter",
id: "auto",
name: "OpenRouter Auto",
},
{
provider: "openrouter",
id: "meta-llama/llama-3.3-70b:free",
name: "Llama 3.3 70B",
},
] as const;
function expectRouterModelFiltering(options: Array<{ value: string }>) {
expect(options.some((opt) => opt.value === "openrouter/auto")).toBe(false);
expect(options.some((opt) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free")).toBe(
true,
);
}
describe("promptDefaultModel", () => { describe("promptDefaultModel", () => {
it("filters internal router models from the selection list", async () => { it("filters internal router models from the selection list", async () => {
loadModelCatalog.mockResolvedValue([ loadModelCatalog.mockResolvedValue(OPENROUTER_CATALOG);
{
provider: "openrouter",
id: "auto",
name: "OpenRouter Auto",
},
{
provider: "openrouter",
id: "meta-llama/llama-3.3-70b:free",
name: "Llama 3.3 70B",
},
]);
const select = vi.fn(async (params) => { const select = vi.fn(async (params) => {
const first = params.options[0]; const first = params.options[0];
@@ -67,10 +76,7 @@ describe("promptDefaultModel", () => {
}); });
const options = select.mock.calls[0]?.[0]?.options ?? []; const options = select.mock.calls[0]?.[0]?.options ?? [];
expect(options.some((opt) => opt.value === "openrouter/auto")).toBe(false); expectRouterModelFiltering(options);
expect(options.some((opt) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free")).toBe(
true,
);
}); });
it("supports configuring vLLM during onboarding", async () => { it("supports configuring vLLM during onboarding", async () => {
@@ -124,18 +130,7 @@ describe("promptDefaultModel", () => {
describe("promptModelAllowlist", () => { describe("promptModelAllowlist", () => {
it("filters internal router models from the selection list", async () => { it("filters internal router models from the selection list", async () => {
loadModelCatalog.mockResolvedValue([ loadModelCatalog.mockResolvedValue(OPENROUTER_CATALOG);
{
provider: "openrouter",
id: "auto",
name: "OpenRouter Auto",
},
{
provider: "openrouter",
id: "meta-llama/llama-3.3-70b:free",
name: "Llama 3.3 70B",
},
]);
const multiselect = vi.fn(async (params) => const multiselect = vi.fn(async (params) =>
params.options.map((option: { value: string }) => option.value), params.options.map((option: { value: string }) => option.value),
@@ -146,12 +141,7 @@ describe("promptModelAllowlist", () => {
await promptModelAllowlist({ config, prompter }); await promptModelAllowlist({ config, prompter });
const options = multiselect.mock.calls[0]?.[0]?.options ?? []; const options = multiselect.mock.calls[0]?.[0]?.options ?? [];
expect(options.some((opt: { value: string }) => opt.value === "openrouter/auto")).toBe(false); expectRouterModelFiltering(options as Array<{ value: string }>);
expect(
options.some(
(opt: { value: string }) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free",
),
).toBe(true);
}); });
it("filters to allowed keys when provided", async () => { it("filters to allowed keys when provided", async () => {

View File

@@ -203,11 +203,8 @@ describe("models list/status", () => {
} }
async function expectZaiProviderFilter(provider: string) { async function expectZaiProviderFilter(provider: string) {
setDefaultModel("z.ai/glm-4.7"); setDefaultZaiRegistry();
const runtime = makeRuntime(); const runtime = makeRuntime();
const models = [ZAI_MODEL, OPENAI_MODEL];
modelRegistryState.models = models;
modelRegistryState.available = models;
await modelsListCommand({ all: true, provider, json: true }, runtime); await modelsListCommand({ all: true, provider, json: true }, runtime);
@@ -216,22 +213,67 @@ describe("models list/status", () => {
expect(payload.models[0]?.key).toBe("zai/glm-4.7"); expect(payload.models[0]?.key).toBe("zai/glm-4.7");
} }
function setDefaultZaiRegistry(params: { available?: boolean } = {}) {
const available = params.available ?? true;
setDefaultModel("z.ai/glm-4.7");
modelRegistryState.models = [ZAI_MODEL, OPENAI_MODEL];
modelRegistryState.available = available ? [ZAI_MODEL, OPENAI_MODEL] : [];
}
function setupGoogleAntigravityTemplateCase(params: {
configuredModelId: string;
templateId: string;
templateName: string;
available?: boolean;
}) {
configureGoogleAntigravityModel(params.configuredModelId);
const template = makeGoogleAntigravityTemplate(params.templateId, params.templateName);
modelRegistryState.models = [template];
modelRegistryState.available = params.available ? [template] : [];
return template;
}
async function runGoogleAntigravityListCase(params: {
configuredModelId: string;
templateId: string;
templateName: string;
available?: boolean;
withAuthProfile?: boolean;
}) {
setupGoogleAntigravityTemplateCase(params);
if (params.withAuthProfile) {
enableGoogleAntigravityAuthProfile();
}
const runtime = makeRuntime();
await modelsListCommand({ json: true }, runtime);
return parseJsonLog(runtime);
}
function expectAntigravityModel(
payload: Record<string, unknown>,
params: { key: string; available: boolean; includesTags?: boolean },
) {
const model = (payload.models as Array<Record<string, unknown>>)[0] ?? {};
expect(model.key).toBe(params.key);
expect(model.missing).toBe(false);
expect(model.available).toBe(params.available);
if (params.includesTags) {
expect(model.tags).toContain("default");
expect(model.tags).toContain("configured");
}
}
beforeAll(async () => { beforeAll(async () => {
({ modelsListCommand } = await import("./models/list.list-command.js")); ({ modelsListCommand } = await import("./models/list.list-command.js"));
}); });
it("models list outputs canonical zai key for configured z.ai model", async () => { it("models list outputs canonical zai key for configured z.ai model", async () => {
loadConfig.mockReturnValue({ setDefaultZaiRegistry();
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime(); const runtime = makeRuntime();
modelRegistryState.models = [ZAI_MODEL];
modelRegistryState.available = [ZAI_MODEL];
await modelsListCommand({ json: true }, runtime); await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1); const payload = parseJsonLog(runtime);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("zai/glm-4.7"); expect(payload.models[0]?.key).toBe("zai/glm-4.7");
}); });
@@ -262,109 +304,78 @@ describe("models list/status", () => {
}); });
it("models list marks auth as unavailable when ZAI key is missing", async () => { it("models list marks auth as unavailable when ZAI key is missing", async () => {
loadConfig.mockReturnValue({ setDefaultZaiRegistry({ available: false });
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime(); const runtime = makeRuntime();
modelRegistryState.models = [ZAI_MODEL];
modelRegistryState.available = [];
await modelsListCommand({ all: true, json: true }, runtime); await modelsListCommand({ all: true, json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1); const payload = parseJsonLog(runtime);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.available).toBe(false); expect(payload.models[0]?.available).toBe(false);
}); });
it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => { it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking"); const payload = await runGoogleAntigravityListCase({
const runtime = makeRuntime(); configuredModelId: "claude-opus-4-6-thinking",
templateId: "claude-opus-4-5-thinking",
modelRegistryState.models = [ templateName: "Claude Opus 4.5 Thinking",
makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"), });
]; expectAntigravityModel(payload, {
modelRegistryState.available = []; key: "google-antigravity/claude-opus-4-6-thinking",
await modelsListCommand({ json: true }, runtime); available: false,
includesTags: true,
expect(runtime.log).toHaveBeenCalledTimes(1); });
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.tags).toContain("default");
expect(payload.models[0]?.tags).toContain("configured");
}); });
it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => { it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => {
configureGoogleAntigravityModel("claude-opus-4-6"); const payload = await runGoogleAntigravityListCase({
const runtime = makeRuntime(); configuredModelId: "claude-opus-4-6",
templateId: "claude-opus-4-5",
modelRegistryState.models = [ templateName: "Claude Opus 4.5",
makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5"), });
]; expectAntigravityModel(payload, {
modelRegistryState.available = []; key: "google-antigravity/claude-opus-4-6",
await modelsListCommand({ json: true }, runtime); available: false,
includesTags: true,
expect(runtime.log).toHaveBeenCalledTimes(1); });
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.tags).toContain("default");
expect(payload.models[0]?.tags).toContain("configured");
}); });
it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => { it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking"); const payload = await runGoogleAntigravityListCase({
const runtime = makeRuntime(); configuredModelId: "claude-opus-4-6-thinking",
templateId: "claude-opus-4-5-thinking",
const template = makeGoogleAntigravityTemplate( templateName: "Claude Opus 4.5 Thinking",
"claude-opus-4-5-thinking", available: true,
"Claude Opus 4.5 Thinking", });
); expectAntigravityModel(payload, {
modelRegistryState.models = [template]; key: "google-antigravity/claude-opus-4-6-thinking",
modelRegistryState.available = [template]; available: true,
await modelsListCommand({ json: true }, runtime); });
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
}); });
it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => { it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => {
configureGoogleAntigravityModel("claude-opus-4-6"); const payload = await runGoogleAntigravityListCase({
const runtime = makeRuntime(); configuredModelId: "claude-opus-4-6",
templateId: "claude-opus-4-5",
const template = makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5"); templateName: "Claude Opus 4.5",
modelRegistryState.models = [template]; available: true,
modelRegistryState.available = [template]; });
await modelsListCommand({ json: true }, runtime); expectAntigravityModel(payload, {
key: "google-antigravity/claude-opus-4-6",
expect(runtime.log).toHaveBeenCalledTimes(1); available: true,
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); });
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
}); });
it("models list prefers registry availability over provider auth heuristics", async () => { it("models list prefers registry availability over provider auth heuristics", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking"); const payload = await runGoogleAntigravityListCase({
enableGoogleAntigravityAuthProfile(); configuredModelId: "claude-opus-4-6-thinking",
const runtime = makeRuntime(); templateId: "claude-opus-4-5-thinking",
templateName: "Claude Opus 4.5 Thinking",
const template = makeGoogleAntigravityTemplate( withAuthProfile: true,
"claude-opus-4-5-thinking", });
"Claude Opus 4.5 Thinking", expectAntigravityModel(payload, {
); key: "google-antigravity/claude-opus-4-6-thinking",
modelRegistryState.models = [template]; available: false,
modelRegistryState.available = []; });
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(false);
listProfilesForProvider.mockReturnValue([]); listProfilesForProvider.mockReturnValue([]);
}); });

View File

@@ -33,6 +33,22 @@ function makePrompter(): WizardPrompter {
}; };
} }
function expectPrimaryModelChanged(
applied: { changed: boolean; next: OpenClawConfig },
primary: string,
) {
expect(applied.changed).toBe(true);
expect(applied.next.agents?.defaults?.model).toEqual({ primary });
}
function expectConfigUnchanged(
applied: { changed: boolean; next: OpenClawConfig },
cfg: OpenClawConfig,
) {
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
}
describe("applyDefaultModelChoice", () => { describe("applyDefaultModelChoice", () => {
it("ensures allowlist entry exists when returning an agent override", async () => { it("ensures allowlist entry exists when returning an agent override", async () => {
const defaultModel = "vercel-ai-gateway/anthropic/claude-opus-4.6"; const defaultModel = "vercel-ai-gateway/anthropic/claude-opus-4.6";
@@ -97,10 +113,7 @@ describe("applyGoogleGeminiModelDefault", () => {
it("sets gemini default when model is unset", () => { it("sets gemini default when model is unset", () => {
const cfg: OpenClawConfig = { agents: { defaults: {} } }; const cfg: OpenClawConfig = { agents: { defaults: {} } };
const applied = applyGoogleGeminiModelDefault(cfg); const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true); expectPrimaryModelChanged(applied, GOOGLE_GEMINI_DEFAULT_MODEL);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
}); });
it("overrides existing model", () => { it("overrides existing model", () => {
@@ -108,10 +121,7 @@ describe("applyGoogleGeminiModelDefault", () => {
agents: { defaults: { model: "anthropic/claude-opus-4-5" } }, agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
}; };
const applied = applyGoogleGeminiModelDefault(cfg); const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true); expectPrimaryModelChanged(applied, GOOGLE_GEMINI_DEFAULT_MODEL);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
}); });
it("no-ops when already gemini default", () => { it("no-ops when already gemini default", () => {
@@ -119,8 +129,7 @@ describe("applyGoogleGeminiModelDefault", () => {
agents: { defaults: { model: GOOGLE_GEMINI_DEFAULT_MODEL } }, agents: { defaults: { model: GOOGLE_GEMINI_DEFAULT_MODEL } },
}; };
const applied = applyGoogleGeminiModelDefault(cfg); const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(false); expectConfigUnchanged(applied, cfg);
expect(applied.next).toEqual(cfg);
}); });
}); });
@@ -162,10 +171,7 @@ describe("applyOpenAICodexModelDefault", () => {
it("sets openai-codex default when model is unset", () => { it("sets openai-codex default when model is unset", () => {
const cfg: OpenClawConfig = { agents: { defaults: {} } }; const cfg: OpenClawConfig = { agents: { defaults: {} } };
const applied = applyOpenAICodexModelDefault(cfg); const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(true); expectPrimaryModelChanged(applied, OPENAI_CODEX_DEFAULT_MODEL);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENAI_CODEX_DEFAULT_MODEL,
});
}); });
it("sets openai-codex default when model is openai/*", () => { it("sets openai-codex default when model is openai/*", () => {
@@ -173,10 +179,7 @@ describe("applyOpenAICodexModelDefault", () => {
agents: { defaults: { model: OPENAI_DEFAULT_MODEL } }, agents: { defaults: { model: OPENAI_DEFAULT_MODEL } },
}; };
const applied = applyOpenAICodexModelDefault(cfg); const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(true); expectPrimaryModelChanged(applied, OPENAI_CODEX_DEFAULT_MODEL);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENAI_CODEX_DEFAULT_MODEL,
});
}); });
it("does not override openai-codex/*", () => { it("does not override openai-codex/*", () => {
@@ -184,8 +187,7 @@ describe("applyOpenAICodexModelDefault", () => {
agents: { defaults: { model: OPENAI_CODEX_DEFAULT_MODEL } }, agents: { defaults: { model: OPENAI_CODEX_DEFAULT_MODEL } },
}; };
const applied = applyOpenAICodexModelDefault(cfg); const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(false); expectConfigUnchanged(applied, cfg);
expect(applied.next).toEqual(cfg);
}); });
it("does not override non-openai models", () => { it("does not override non-openai models", () => {
@@ -193,8 +195,7 @@ describe("applyOpenAICodexModelDefault", () => {
agents: { defaults: { model: "anthropic/claude-opus-4-5" } }, agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
}; };
const applied = applyOpenAICodexModelDefault(cfg); const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(false); expectConfigUnchanged(applied, cfg);
expect(applied.next).toEqual(cfg);
}); });
}); });
@@ -202,10 +203,7 @@ describe("applyOpencodeZenModelDefault", () => {
it("sets opencode default when model is unset", () => { it("sets opencode default when model is unset", () => {
const cfg: OpenClawConfig = { agents: { defaults: {} } }; const cfg: OpenClawConfig = { agents: { defaults: {} } };
const applied = applyOpencodeZenModelDefault(cfg); const applied = applyOpencodeZenModelDefault(cfg);
expect(applied.changed).toBe(true); expectPrimaryModelChanged(applied, OPENCODE_ZEN_DEFAULT_MODEL);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENCODE_ZEN_DEFAULT_MODEL,
});
}); });
it("overrides existing model", () => { it("overrides existing model", () => {
@@ -213,10 +211,7 @@ describe("applyOpencodeZenModelDefault", () => {
agents: { defaults: { model: "anthropic/claude-opus-4-5" } }, agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
} as OpenClawConfig; } as OpenClawConfig;
const applied = applyOpencodeZenModelDefault(cfg); const applied = applyOpencodeZenModelDefault(cfg);
expect(applied.changed).toBe(true); expectPrimaryModelChanged(applied, OPENCODE_ZEN_DEFAULT_MODEL);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENCODE_ZEN_DEFAULT_MODEL,
});
}); });
it("no-ops when already opencode-zen default", () => { it("no-ops when already opencode-zen default", () => {
@@ -224,8 +219,7 @@ describe("applyOpencodeZenModelDefault", () => {
agents: { defaults: { model: OPENCODE_ZEN_DEFAULT_MODEL } }, agents: { defaults: { model: OPENCODE_ZEN_DEFAULT_MODEL } },
} as OpenClawConfig; } as OpenClawConfig;
const applied = applyOpencodeZenModelDefault(cfg); const applied = applyOpencodeZenModelDefault(cfg);
expect(applied.changed).toBe(false); expectConfigUnchanged(applied, cfg);
expect(applied.next).toEqual(cfg);
}); });
it("no-ops when already legacy opencode-zen default", () => { it("no-ops when already legacy opencode-zen default", () => {
@@ -233,8 +227,7 @@ describe("applyOpencodeZenModelDefault", () => {
agents: { defaults: { model: "opencode-zen/claude-opus-4-5" } }, agents: { defaults: { model: "opencode-zen/claude-opus-4-5" } },
} as OpenClawConfig; } as OpenClawConfig;
const applied = applyOpencodeZenModelDefault(cfg); const applied = applyOpencodeZenModelDefault(cfg);
expect(applied.changed).toBe(false); expectConfigUnchanged(applied, cfg);
expect(applied.next).toEqual(cfg);
}); });
it("preserves fallbacks when setting primary", () => { it("preserves fallbacks when setting primary", () => {