refactor(test): dedupe command config and model test fixtures
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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", () => {
|
||||
it("preserves allowTailscale when switching to token", () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
@@ -54,68 +66,23 @@ describe("buildGatewayAuthConfig", () => {
|
||||
});
|
||||
|
||||
it("generates random token when token param is undefined", () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
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);
|
||||
expectGeneratedTokenFromInput(undefined);
|
||||
});
|
||||
|
||||
it("generates random token when token param is empty string", () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
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);
|
||||
expectGeneratedTokenFromInput("");
|
||||
});
|
||||
|
||||
it("generates random token when token param is whitespace only", () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
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);
|
||||
expectGeneratedTokenFromInput(" ");
|
||||
});
|
||||
|
||||
it('generates random token when token param is the literal string "undefined"', () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
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);
|
||||
expectGeneratedTokenFromInput("undefined");
|
||||
});
|
||||
|
||||
it('generates random token when token param is the literal string "null"', () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
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);
|
||||
expectGeneratedTokenFromInput("null", "null");
|
||||
});
|
||||
|
||||
it("builds trusted-proxy config with all options", () => {
|
||||
|
||||
@@ -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();
|
||||
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||
const selectQueue = ["loopback", "trusted-proxy", "off"];
|
||||
mocks.select.mockImplementation(async () => selectQueue.shift());
|
||||
mocks.text.mockImplementation(async () => textQueue.shift());
|
||||
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
|
||||
mode,
|
||||
trustedProxy,
|
||||
}));
|
||||
mocks.select.mockImplementation(async () => params.selectQueue.shift());
|
||||
mocks.text.mockImplementation(async () => params.textQueue.shift());
|
||||
mocks.randomToken.mockReturnValue(params.randomToken ?? "generated-token");
|
||||
mocks.confirm.mockResolvedValue(params.confirmResult ?? true);
|
||||
mocks.buildGatewayAuthConfig.mockImplementation((input) =>
|
||||
params.authConfigFactory ? params.authConfigFactory(input as Record<string, unknown>) : input,
|
||||
);
|
||||
|
||||
const result = await promptGatewayConfig({}, makeRuntime());
|
||||
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
|
||||
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", () => {
|
||||
it("generates a token when the prompt returns undefined", async () => {
|
||||
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||
const selectQueue = ["loopback", "token", "off"];
|
||||
mocks.select.mockImplementation(async () => selectQueue.shift());
|
||||
const textQueue = ["18789", undefined];
|
||||
mocks.text.mockImplementation(async () => textQueue.shift());
|
||||
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);
|
||||
const { result } = await runGatewayPrompt({
|
||||
selectQueue: ["loopback", "token", "off"],
|
||||
textQueue: ["18789", undefined],
|
||||
randomToken: "generated-token",
|
||||
authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }),
|
||||
});
|
||||
expect(result.token).toBe("generated-token");
|
||||
});
|
||||
|
||||
it("does not set password to literal 'undefined' when prompt returns undefined", async () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||
// Flow: loopback bind → password auth → tailscale off
|
||||
const selectQueue = ["loopback", "password", "off"];
|
||||
mocks.select.mockImplementation(async () => selectQueue.shift());
|
||||
// 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];
|
||||
const { call } = await runGatewayPrompt({
|
||||
selectQueue: ["loopback", "password", "off"],
|
||||
textQueue: ["18789", undefined],
|
||||
randomToken: "unused",
|
||||
authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }),
|
||||
});
|
||||
expect(call?.password).not.toBe("undefined");
|
||||
expect(call?.password).toBe("");
|
||||
});
|
||||
|
||||
it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => {
|
||||
const { result, call } = await runTrustedProxyPrompt([
|
||||
"18789",
|
||||
"x-forwarded-user",
|
||||
"x-forwarded-proto,x-forwarded-host",
|
||||
"nick@example.com",
|
||||
"10.0.1.10,192.168.1.5",
|
||||
]);
|
||||
const { result, call } = await runTrustedProxyPrompt({
|
||||
textQueue: [
|
||||
"18789",
|
||||
"x-forwarded-user",
|
||||
"x-forwarded-proto,x-forwarded-host",
|
||||
"nick@example.com",
|
||||
"10.0.1.10,192.168.1.5",
|
||||
],
|
||||
});
|
||||
|
||||
expect(call?.mode).toBe("trusted-proxy");
|
||||
expect(call?.trustedProxy).toEqual({
|
||||
@@ -142,13 +132,9 @@ describe("promptGatewayConfig", () => {
|
||||
});
|
||||
|
||||
it("handles trusted-proxy with no optional fields", async () => {
|
||||
const { result, call } = await runTrustedProxyPrompt([
|
||||
"18789",
|
||||
"x-remote-user",
|
||||
"",
|
||||
"",
|
||||
"10.0.0.1",
|
||||
]);
|
||||
const { result, call } = await runTrustedProxyPrompt({
|
||||
textQueue: ["18789", "x-remote-user", "", "", "10.0.0.1"],
|
||||
});
|
||||
|
||||
expect(call?.mode).toBe("trusted-proxy");
|
||||
expect(call?.trustedProxy).toEqual({
|
||||
@@ -160,25 +146,10 @@ describe("promptGatewayConfig", () => {
|
||||
});
|
||||
|
||||
it("forces tailscale off when trusted-proxy is selected", async () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||
const selectQueue = ["loopback", "trusted-proxy", "serve"];
|
||||
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);
|
||||
const { result } = await runTrustedProxyPrompt({
|
||||
tailscaleMode: "serve",
|
||||
textQueue: ["18789", "x-forwarded-user", "", "", "10.0.0.1"],
|
||||
});
|
||||
expect(result.config.gateway?.bind).toBe("lan");
|
||||
expect(result.config.gateway?.tailscale?.mode).toBe("off");
|
||||
expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false);
|
||||
|
||||
@@ -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", () => {
|
||||
it("uses provided nodePath and returns plan", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
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" });
|
||||
mockNodeGatewayPlanFixture();
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
@@ -72,18 +91,13 @@ describe("buildGatewayInstallPlan", () => {
|
||||
|
||||
it("emits warnings when renderSystemNodeWarning returns one", async () => {
|
||||
const warn = vi.fn();
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "gateway"],
|
||||
mockNodeGatewayPlanFixture({
|
||||
workingDirectory: undefined,
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node",
|
||||
version: "18.0.0",
|
||||
supported: false,
|
||||
warning: "Node too old",
|
||||
serviceEnvironment: {},
|
||||
});
|
||||
mocks.renderSystemNodeWarning.mockReturnValue("Node too old");
|
||||
mocks.buildServiceEnvironment.mockReturnValue({});
|
||||
|
||||
await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
@@ -97,19 +111,11 @@ describe("buildGatewayInstallPlan", () => {
|
||||
});
|
||||
|
||||
it("merges config env vars into the environment", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
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",
|
||||
HOME: "/Users/me",
|
||||
mockNodeGatewayPlanFixture({
|
||||
serviceEnvironment: {
|
||||
OPENCLAW_PORT: "3000",
|
||||
HOME: "/Users/me",
|
||||
},
|
||||
});
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
@@ -135,17 +141,7 @@ describe("buildGatewayInstallPlan", () => {
|
||||
});
|
||||
|
||||
it("does not include empty config env values", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
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" });
|
||||
mockNodeGatewayPlanFixture();
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
@@ -166,17 +162,7 @@ describe("buildGatewayInstallPlan", () => {
|
||||
});
|
||||
|
||||
it("drops whitespace-only config env values", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "gateway"],
|
||||
workingDirectory: "/Users/me",
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node",
|
||||
version: "22.0.0",
|
||||
supported: true,
|
||||
});
|
||||
mocks.buildServiceEnvironment.mockReturnValue({});
|
||||
mockNodeGatewayPlanFixture({ serviceEnvironment: {} });
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
@@ -197,19 +183,11 @@ describe("buildGatewayInstallPlan", () => {
|
||||
});
|
||||
|
||||
it("keeps service env values over config env vars", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "gateway"],
|
||||
workingDirectory: "/Users/me",
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node",
|
||||
version: "22.0.0",
|
||||
supported: true,
|
||||
});
|
||||
mocks.buildServiceEnvironment.mockReturnValue({
|
||||
HOME: "/Users/service",
|
||||
OPENCLAW_PORT: "3000",
|
||||
mockNodeGatewayPlanFixture({
|
||||
serviceEnvironment: {
|
||||
HOME: "/Users/service",
|
||||
OPENCLAW_PORT: "3000",
|
||||
},
|
||||
});
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
|
||||
@@ -36,20 +36,29 @@ vi.mock("../agents/model-auth.js", () => ({
|
||||
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", () => {
|
||||
it("filters internal router models from the selection list", async () => {
|
||||
loadModelCatalog.mockResolvedValue([
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "auto",
|
||||
name: "OpenRouter Auto",
|
||||
},
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "meta-llama/llama-3.3-70b:free",
|
||||
name: "Llama 3.3 70B",
|
||||
},
|
||||
]);
|
||||
loadModelCatalog.mockResolvedValue(OPENROUTER_CATALOG);
|
||||
|
||||
const select = vi.fn(async (params) => {
|
||||
const first = params.options[0];
|
||||
@@ -67,10 +76,7 @@ describe("promptDefaultModel", () => {
|
||||
});
|
||||
|
||||
const options = select.mock.calls[0]?.[0]?.options ?? [];
|
||||
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,
|
||||
);
|
||||
expectRouterModelFiltering(options);
|
||||
});
|
||||
|
||||
it("supports configuring vLLM during onboarding", async () => {
|
||||
@@ -124,18 +130,7 @@ describe("promptDefaultModel", () => {
|
||||
|
||||
describe("promptModelAllowlist", () => {
|
||||
it("filters internal router models from the selection list", async () => {
|
||||
loadModelCatalog.mockResolvedValue([
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "auto",
|
||||
name: "OpenRouter Auto",
|
||||
},
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "meta-llama/llama-3.3-70b:free",
|
||||
name: "Llama 3.3 70B",
|
||||
},
|
||||
]);
|
||||
loadModelCatalog.mockResolvedValue(OPENROUTER_CATALOG);
|
||||
|
||||
const multiselect = vi.fn(async (params) =>
|
||||
params.options.map((option: { value: string }) => option.value),
|
||||
@@ -146,12 +141,7 @@ describe("promptModelAllowlist", () => {
|
||||
await promptModelAllowlist({ config, prompter });
|
||||
|
||||
const options = multiselect.mock.calls[0]?.[0]?.options ?? [];
|
||||
expect(options.some((opt: { value: string }) => opt.value === "openrouter/auto")).toBe(false);
|
||||
expect(
|
||||
options.some(
|
||||
(opt: { value: string }) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free",
|
||||
),
|
||||
).toBe(true);
|
||||
expectRouterModelFiltering(options as Array<{ value: string }>);
|
||||
});
|
||||
|
||||
it("filters to allowed keys when provided", async () => {
|
||||
|
||||
@@ -203,11 +203,8 @@ describe("models list/status", () => {
|
||||
}
|
||||
|
||||
async function expectZaiProviderFilter(provider: string) {
|
||||
setDefaultModel("z.ai/glm-4.7");
|
||||
setDefaultZaiRegistry();
|
||||
const runtime = makeRuntime();
|
||||
const models = [ZAI_MODEL, OPENAI_MODEL];
|
||||
modelRegistryState.models = models;
|
||||
modelRegistryState.available = models;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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 () => {
|
||||
({ modelsListCommand } = await import("./models/list.list-command.js"));
|
||||
});
|
||||
|
||||
it("models list outputs canonical zai key for configured z.ai model", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
agents: { defaults: { model: "z.ai/glm-4.7" } },
|
||||
});
|
||||
setDefaultZaiRegistry();
|
||||
const runtime = makeRuntime();
|
||||
|
||||
modelRegistryState.models = [ZAI_MODEL];
|
||||
modelRegistryState.available = [ZAI_MODEL];
|
||||
await modelsListCommand({ json: true }, runtime);
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
|
||||
const payload = parseJsonLog(runtime);
|
||||
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 () => {
|
||||
loadConfig.mockReturnValue({
|
||||
agents: { defaults: { model: "z.ai/glm-4.7" } },
|
||||
});
|
||||
setDefaultZaiRegistry({ available: false });
|
||||
const runtime = makeRuntime();
|
||||
|
||||
modelRegistryState.models = [ZAI_MODEL];
|
||||
modelRegistryState.available = [];
|
||||
await modelsListCommand({ all: true, json: true }, runtime);
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
|
||||
const payload = parseJsonLog(runtime);
|
||||
expect(payload.models[0]?.available).toBe(false);
|
||||
});
|
||||
|
||||
it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => {
|
||||
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
|
||||
const runtime = makeRuntime();
|
||||
|
||||
modelRegistryState.models = [
|
||||
makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"),
|
||||
];
|
||||
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]?.tags).toContain("default");
|
||||
expect(payload.models[0]?.tags).toContain("configured");
|
||||
const payload = await runGoogleAntigravityListCase({
|
||||
configuredModelId: "claude-opus-4-6-thinking",
|
||||
templateId: "claude-opus-4-5-thinking",
|
||||
templateName: "Claude Opus 4.5 Thinking",
|
||||
});
|
||||
expectAntigravityModel(payload, {
|
||||
key: "google-antigravity/claude-opus-4-6-thinking",
|
||||
available: false,
|
||||
includesTags: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => {
|
||||
configureGoogleAntigravityModel("claude-opus-4-6");
|
||||
const runtime = makeRuntime();
|
||||
|
||||
modelRegistryState.models = [
|
||||
makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5"),
|
||||
];
|
||||
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");
|
||||
expect(payload.models[0]?.missing).toBe(false);
|
||||
expect(payload.models[0]?.tags).toContain("default");
|
||||
expect(payload.models[0]?.tags).toContain("configured");
|
||||
const payload = await runGoogleAntigravityListCase({
|
||||
configuredModelId: "claude-opus-4-6",
|
||||
templateId: "claude-opus-4-5",
|
||||
templateName: "Claude Opus 4.5",
|
||||
});
|
||||
expectAntigravityModel(payload, {
|
||||
key: "google-antigravity/claude-opus-4-6",
|
||||
available: false,
|
||||
includesTags: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => {
|
||||
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
|
||||
const runtime = makeRuntime();
|
||||
|
||||
const template = makeGoogleAntigravityTemplate(
|
||||
"claude-opus-4-5-thinking",
|
||||
"Claude Opus 4.5 Thinking",
|
||||
);
|
||||
modelRegistryState.models = [template];
|
||||
modelRegistryState.available = [template];
|
||||
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);
|
||||
const payload = await runGoogleAntigravityListCase({
|
||||
configuredModelId: "claude-opus-4-6-thinking",
|
||||
templateId: "claude-opus-4-5-thinking",
|
||||
templateName: "Claude Opus 4.5 Thinking",
|
||||
available: true,
|
||||
});
|
||||
expectAntigravityModel(payload, {
|
||||
key: "google-antigravity/claude-opus-4-6-thinking",
|
||||
available: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => {
|
||||
configureGoogleAntigravityModel("claude-opus-4-6");
|
||||
const runtime = makeRuntime();
|
||||
|
||||
const template = makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5");
|
||||
modelRegistryState.models = [template];
|
||||
modelRegistryState.available = [template];
|
||||
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");
|
||||
expect(payload.models[0]?.missing).toBe(false);
|
||||
expect(payload.models[0]?.available).toBe(true);
|
||||
const payload = await runGoogleAntigravityListCase({
|
||||
configuredModelId: "claude-opus-4-6",
|
||||
templateId: "claude-opus-4-5",
|
||||
templateName: "Claude Opus 4.5",
|
||||
available: true,
|
||||
});
|
||||
expectAntigravityModel(payload, {
|
||||
key: "google-antigravity/claude-opus-4-6",
|
||||
available: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("models list prefers registry availability over provider auth heuristics", async () => {
|
||||
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
|
||||
enableGoogleAntigravityAuthProfile();
|
||||
const runtime = makeRuntime();
|
||||
|
||||
const template = makeGoogleAntigravityTemplate(
|
||||
"claude-opus-4-5-thinking",
|
||||
"Claude Opus 4.5 Thinking",
|
||||
);
|
||||
modelRegistryState.models = [template];
|
||||
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);
|
||||
const payload = await runGoogleAntigravityListCase({
|
||||
configuredModelId: "claude-opus-4-6-thinking",
|
||||
templateId: "claude-opus-4-5-thinking",
|
||||
templateName: "Claude Opus 4.5 Thinking",
|
||||
withAuthProfile: true,
|
||||
});
|
||||
expectAntigravityModel(payload, {
|
||||
key: "google-antigravity/claude-opus-4-6-thinking",
|
||||
available: false,
|
||||
});
|
||||
listProfilesForProvider.mockReturnValue([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
it("ensures allowlist entry exists when returning an agent override", async () => {
|
||||
const defaultModel = "vercel-ai-gateway/anthropic/claude-opus-4.6";
|
||||
@@ -97,10 +113,7 @@ describe("applyGoogleGeminiModelDefault", () => {
|
||||
it("sets gemini default when model is unset", () => {
|
||||
const cfg: OpenClawConfig = { agents: { defaults: {} } };
|
||||
const applied = applyGoogleGeminiModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agents?.defaults?.model).toEqual({
|
||||
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
});
|
||||
expectPrimaryModelChanged(applied, GOOGLE_GEMINI_DEFAULT_MODEL);
|
||||
});
|
||||
|
||||
it("overrides existing model", () => {
|
||||
@@ -108,10 +121,7 @@ describe("applyGoogleGeminiModelDefault", () => {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
|
||||
};
|
||||
const applied = applyGoogleGeminiModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agents?.defaults?.model).toEqual({
|
||||
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
});
|
||||
expectPrimaryModelChanged(applied, GOOGLE_GEMINI_DEFAULT_MODEL);
|
||||
});
|
||||
|
||||
it("no-ops when already gemini default", () => {
|
||||
@@ -119,8 +129,7 @@ describe("applyGoogleGeminiModelDefault", () => {
|
||||
agents: { defaults: { model: GOOGLE_GEMINI_DEFAULT_MODEL } },
|
||||
};
|
||||
const applied = applyGoogleGeminiModelDefault(cfg);
|
||||
expect(applied.changed).toBe(false);
|
||||
expect(applied.next).toEqual(cfg);
|
||||
expectConfigUnchanged(applied, cfg);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,10 +171,7 @@ describe("applyOpenAICodexModelDefault", () => {
|
||||
it("sets openai-codex default when model is unset", () => {
|
||||
const cfg: OpenClawConfig = { agents: { defaults: {} } };
|
||||
const applied = applyOpenAICodexModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agents?.defaults?.model).toEqual({
|
||||
primary: OPENAI_CODEX_DEFAULT_MODEL,
|
||||
});
|
||||
expectPrimaryModelChanged(applied, OPENAI_CODEX_DEFAULT_MODEL);
|
||||
});
|
||||
|
||||
it("sets openai-codex default when model is openai/*", () => {
|
||||
@@ -173,10 +179,7 @@ describe("applyOpenAICodexModelDefault", () => {
|
||||
agents: { defaults: { model: OPENAI_DEFAULT_MODEL } },
|
||||
};
|
||||
const applied = applyOpenAICodexModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agents?.defaults?.model).toEqual({
|
||||
primary: OPENAI_CODEX_DEFAULT_MODEL,
|
||||
});
|
||||
expectPrimaryModelChanged(applied, OPENAI_CODEX_DEFAULT_MODEL);
|
||||
});
|
||||
|
||||
it("does not override openai-codex/*", () => {
|
||||
@@ -184,8 +187,7 @@ describe("applyOpenAICodexModelDefault", () => {
|
||||
agents: { defaults: { model: OPENAI_CODEX_DEFAULT_MODEL } },
|
||||
};
|
||||
const applied = applyOpenAICodexModelDefault(cfg);
|
||||
expect(applied.changed).toBe(false);
|
||||
expect(applied.next).toEqual(cfg);
|
||||
expectConfigUnchanged(applied, cfg);
|
||||
});
|
||||
|
||||
it("does not override non-openai models", () => {
|
||||
@@ -193,8 +195,7 @@ describe("applyOpenAICodexModelDefault", () => {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
|
||||
};
|
||||
const applied = applyOpenAICodexModelDefault(cfg);
|
||||
expect(applied.changed).toBe(false);
|
||||
expect(applied.next).toEqual(cfg);
|
||||
expectConfigUnchanged(applied, cfg);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,10 +203,7 @@ describe("applyOpencodeZenModelDefault", () => {
|
||||
it("sets opencode default when model is unset", () => {
|
||||
const cfg: OpenClawConfig = { agents: { defaults: {} } };
|
||||
const applied = applyOpencodeZenModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agents?.defaults?.model).toEqual({
|
||||
primary: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
});
|
||||
expectPrimaryModelChanged(applied, OPENCODE_ZEN_DEFAULT_MODEL);
|
||||
});
|
||||
|
||||
it("overrides existing model", () => {
|
||||
@@ -213,10 +211,7 @@ describe("applyOpencodeZenModelDefault", () => {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
|
||||
} as OpenClawConfig;
|
||||
const applied = applyOpencodeZenModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agents?.defaults?.model).toEqual({
|
||||
primary: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
});
|
||||
expectPrimaryModelChanged(applied, OPENCODE_ZEN_DEFAULT_MODEL);
|
||||
});
|
||||
|
||||
it("no-ops when already opencode-zen default", () => {
|
||||
@@ -224,8 +219,7 @@ describe("applyOpencodeZenModelDefault", () => {
|
||||
agents: { defaults: { model: OPENCODE_ZEN_DEFAULT_MODEL } },
|
||||
} as OpenClawConfig;
|
||||
const applied = applyOpencodeZenModelDefault(cfg);
|
||||
expect(applied.changed).toBe(false);
|
||||
expect(applied.next).toEqual(cfg);
|
||||
expectConfigUnchanged(applied, cfg);
|
||||
});
|
||||
|
||||
it("no-ops when already legacy opencode-zen default", () => {
|
||||
@@ -233,8 +227,7 @@ describe("applyOpencodeZenModelDefault", () => {
|
||||
agents: { defaults: { model: "opencode-zen/claude-opus-4-5" } },
|
||||
} as OpenClawConfig;
|
||||
const applied = applyOpencodeZenModelDefault(cfg);
|
||||
expect(applied.changed).toBe(false);
|
||||
expect(applied.next).toEqual(cfg);
|
||||
expectConfigUnchanged(applied, cfg);
|
||||
});
|
||||
|
||||
it("preserves fallbacks when setting primary", () => {
|
||||
|
||||
Reference in New Issue
Block a user