refactor: share sdk lazy config and cli test helpers

This commit is contained in:
Peter Steinberger
2026-04-04 16:52:45 +09:00
parent 6a55556b83
commit a81cf1da1f
8 changed files with 138 additions and 137 deletions

View File

@@ -1,40 +1,7 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { encodePairingSetupCode } from "../pairing/setup-code.js";
import type { CliMockOutputRuntime } from "./test-runtime-capture.js";
const runtimeState = vi.hoisted(() => {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" ");
const defaultRuntime: CliMockOutputRuntime = {
log: vi.fn((...args: unknown[]) => {
runtimeLogs.push(stringifyArgs(args));
}),
error: vi.fn((...args: unknown[]) => {
runtimeErrors.push(stringifyArgs(args));
}),
writeStdout: vi.fn((value: string) => {
const normalized = value.endsWith("\n") ? value.slice(0, -1) : value;
defaultRuntime.log(normalized);
}),
writeJson: vi.fn((value: unknown, space = 2) => {
defaultRuntime.log(JSON.stringify(value, null, space > 0 ? space : undefined));
}),
exit: vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
}),
};
return {
runtimeLogs,
runtimeErrors,
defaultRuntime,
resetRuntimeCapture: () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
},
};
});
import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(),
@@ -47,14 +14,16 @@ const mocks = vi.hoisted(() => ({
cb("ASCII-QR");
}),
}));
const runtime = runtimeState.defaultRuntime;
const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture();
const runtimeLog = runtime.log;
const runtimeError = runtime.error;
const runtimeExit = runtime.exit;
vi.mock("../runtime.js", async () => {
const actual = await vi.importActual<typeof import("../runtime.js")>("../runtime.js");
return {
...actual,
defaultRuntime: runtimeState.defaultRuntime,
};
return mockRuntimeModule(
() => vi.importActual<typeof import("../runtime.js")>("../runtime.js"),
runtime,
);
});
vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig }));
vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout }));
@@ -168,7 +137,7 @@ describe("registerQrCli", () => {
}
function parseLastLoggedQrJson() {
const raw = vi.mocked(runtime.log).mock.calls.at(-1)?.[0];
const raw = runtimeLog.mock.calls.at(-1)?.[0];
return JSON.parse(typeof raw === "string" ? raw : "{}") as {
setupCode?: string;
gatewayUrl?: string;
@@ -199,10 +168,10 @@ describe("registerQrCli", () => {
beforeEach(() => {
vi.clearAllMocks();
runtimeState.resetRuntimeCapture();
resetRuntimeCapture();
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "");
vi.mocked(runtime.exit).mockImplementation(() => {
runtimeExit.mockImplementation(() => {
throw new Error("exit");
});
});
@@ -243,10 +212,7 @@ describe("registerQrCli", () => {
await runQr([]);
expect(qrGenerate).toHaveBeenCalledTimes(1);
const output = vi
.mocked(runtime.log)
.mock.calls.map((call) => readRuntimeCallText(call))
.join("\n");
const output = runtimeLog.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
expect(output).toContain("Pairing QR");
expect(output).toContain("ASCII-QR");
expect(output).toContain("Gateway:");
@@ -264,7 +230,7 @@ describe("registerQrCli", () => {
await expectQrExit(["--setup-code-only"]);
const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
expect(output).toContain("Tailscale and public mobile pairing require a secure gateway URL");
expect(output).toContain("gateway.tailscale.mode=serve");
});
@@ -397,7 +363,7 @@ describe("registerQrCli", () => {
});
await expectQrExit(["--setup-code-only"]);
const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
expect(output).toContain("gateway.auth.mode is unset");
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
@@ -443,11 +409,9 @@ describe("registerQrCli", () => {
await runQr(["--remote"]);
expect(
vi
.mocked(runtime.log)
.mock.calls.some((call) =>
readRuntimeCallText(call).includes("gateway.remote.token inactive"),
),
runtimeLog.mock.calls.some((call) =>
readRuntimeCallText(call).includes("gateway.remote.token inactive"),
),
).toBe(true);
});
@@ -461,7 +425,7 @@ describe("registerQrCli", () => {
await runQr(["--setup-code-only", "--remote"]);
expect(
runtime.error.mock.calls.some((call) =>
runtimeError.mock.calls.some((call) =>
readRuntimeCallText(call).includes("gateway.remote.token inactive"),
),
).toBe(true);
@@ -501,11 +465,9 @@ describe("registerQrCli", () => {
const payload = parseLastLoggedQrJson();
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
expect(
vi
.mocked(runtime.error)
.mock.calls.some((call) =>
readRuntimeCallText(call).includes("gateway.remote.password inactive"),
),
runtimeError.mock.calls.some((call) =>
readRuntimeCallText(call).includes("gateway.remote.password inactive"),
),
).toBe(true);
});
@@ -519,7 +481,7 @@ describe("registerQrCli", () => {
});
await expectQrExit(["--remote"]);
const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
expect(output).toContain("qr --remote requires");
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
@@ -546,12 +508,7 @@ describe("registerQrCli", () => {
await runQr(["--json", "--remote"]);
const raw = vi.mocked(runtime.log).mock.calls.at(-1)?.[0];
const payload = JSON.parse(typeof raw === "string" ? raw : "{}") as {
gatewayUrl?: string;
auth?: string;
urlSource?: string;
};
const payload = parseLastLoggedQrJson();
expect(payload.gatewayUrl).toBe("wss://ts-host.tailnet.ts.net");
expect(payload.auth).toBe("token");
expect(payload.urlSource).toBe("gateway.tailscale.mode=serve");

View File

@@ -1,56 +1,43 @@
import { Command } from "commander";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { captureEnv } from "../test-utils/env.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
const loadConfigMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
const copyToClipboardMock = vi.hoisted(() => vi.fn(async () => false));
const {
runtimeLogs,
runtimeErrors,
defaultRuntime: runtime,
resetRuntimeCapture,
} = createCliRuntimeCapture();
const runtimeExit = runtime.exit;
type CliRuntimeEnv = RuntimeEnv & {
log: MockFn<RuntimeEnv["log"]>;
error: MockFn<RuntimeEnv["error"]>;
exit: MockFn<RuntimeEnv["exit"]>;
};
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = vi.hoisted<CliRuntimeEnv>(() => ({
log: vi.fn((...args: unknown[]) => {
runtimeLogs.push(args.map(String).join(" "));
}),
error: vi.fn((...args: unknown[]) => {
runtimeErrors.push(args.map(String).join(" "));
}),
exit: vi.fn<(code: number) => void>(),
vi.mock("../config/config.js", () => ({
loadConfig: loadConfigMock,
readConfigFileSnapshot: readConfigFileSnapshotMock,
resolveGatewayPort: resolveGatewayPortMock,
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: loadConfigMock,
readConfigFileSnapshot: readConfigFileSnapshotMock,
resolveGatewayPort: resolveGatewayPortMock,
};
});
vi.mock("../infra/clipboard.js", () => ({
copyToClipboard: copyToClipboardMock,
}));
vi.mock("../runtime.js", async () => {
const actual = await vi.importActual<typeof import("../runtime.js")>("../runtime.js");
return {
...actual,
defaultRuntime: runtime,
};
});
vi.mock("../infra/device-bootstrap.js", () => ({
issueDeviceBootstrapToken: vi.fn(async () => ({
token: "bootstrap-123",
expiresAtMs: 123,
})),
}));
let dashboardCommand: typeof import("../commands/dashboard.js").dashboardCommand;
let registerQrCli: typeof import("./qr-cli.js").registerQrCli;
vi.mock("../runtime.js", () => ({
defaultRuntime: runtime,
}));
const { dashboardCommand } = await import("../commands/dashboard.js");
const { registerQrCli } = await import("./qr-cli.js");
function createGatewayTokenRefFixture() {
return {
@@ -124,16 +111,11 @@ describe("cli integration: qr + dashboard token SecretRef", () => {
"OPENCLAW_GATEWAY_PASSWORD",
]);
});
beforeAll(async () => {
({ dashboardCommand } = await import("../commands/dashboard.js"));
({ registerQrCli } = await import("./qr-cli.js"));
});
beforeEach(() => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
resetRuntimeCapture();
vi.clearAllMocks();
vi.mocked(runtime.exit).mockImplementation(() => {});
runtimeExit.mockImplementation(() => {});
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.SHARED_GATEWAY_TOKEN;

View File

@@ -0,0 +1,21 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { isOwningNpmCommand } from "./update-cli.test-helpers.js";
describe("isOwningNpmCommand", () => {
it("accepts absolute npm binaries under the owning prefix", () => {
const prefix = path.join(path.sep, "opt", "homebrew");
expect(isOwningNpmCommand(path.join(prefix, "bin", "npm"), prefix)).toBe(true);
expect(isOwningNpmCommand(path.join(prefix, "npm.cmd"), prefix)).toBe(true);
});
it("rejects plain npm and paths outside the owning prefix", () => {
const prefix = path.join(path.sep, "opt", "homebrew");
expect(isOwningNpmCommand("npm", prefix)).toBe(false);
expect(isOwningNpmCommand(path.join(path.sep, "usr", "local", "bin", "npm"), prefix)).toBe(
false,
);
});
});

View File

@@ -0,0 +1,18 @@
import path from "node:path";
function isPathInsideRoot(candidate: string, root: string): boolean {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
export function isOwningNpmCommand(value: unknown, owningPrefix: string): boolean {
if (typeof value !== "string" || !path.isAbsolute(value)) {
return false;
}
const normalized = path.normalize(value);
return (
normalized !== path.normalize("npm") &&
isPathInsideRoot(normalized, owningPrefix) &&
/npm(?:\.cmd)?$/i.test(normalized)
);
}

View File

@@ -8,6 +8,7 @@ import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.opencla
import type { UpdateRunResult } from "../infra/update-runner.js";
import { withEnvAsync } from "../test-utils/env.js";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
import { isOwningNpmCommand } from "./update-cli.test-helpers.js";
const confirm = vi.fn();
const select = vi.fn();
@@ -747,18 +748,6 @@ describe("update-cli", () => {
const pkgRoot = path.join(brewRoot, "openclaw");
const brewNpm = path.join(brewPrefix, "bin", "npm");
const win32PrefixNpm = path.join(brewPrefix, "npm.cmd");
const isOwningNpmCommand = (value: unknown): boolean => {
if (typeof value !== "string") {
return false;
}
const normalized = path.normalize(value);
return (
normalized !== path.normalize("npm") &&
path.isAbsolute(value) &&
normalized.includes(path.normalize(brewPrefix)) &&
/npm(?:\.cmd)?$/i.test(normalized)
);
};
const pathNpmRoot = createCaseDir("nvm-root");
mockPackageInstallStatus(pkgRoot);
pathExists.mockResolvedValue(false);
@@ -784,7 +773,7 @@ describe("update-cli", () => {
termination: "exit",
};
}
if (isOwningNpmCommand(argv[0]) && argv[1] === "root" && argv[2] === "-g") {
if (isOwningNpmCommand(argv[0], brewPrefix) && argv[1] === "root" && argv[2] === "-g") {
return {
stdout: `${brewRoot}\n`,
stderr: "",
@@ -817,7 +806,7 @@ describe("update-cli", () => {
.mock.calls.find(
([argv]) =>
Array.isArray(argv) &&
isOwningNpmCommand(argv[0]) &&
isOwningNpmCommand(argv[0], brewPrefix) &&
argv[1] === "i" &&
argv[2] === "-g" &&
argv[3] === "openclaw@latest",

View File

@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { createCachedLazyValueGetter } from "./lazy-value.js";
describe("createCachedLazyValueGetter", () => {
it("memoizes lazy factories", () => {
const resolveSchema = vi.fn(() => ({ type: "object" as const }));
const getSchema = createCachedLazyValueGetter(resolveSchema);
expect(getSchema()).toEqual({ type: "object" });
expect(getSchema()).toEqual({ type: "object" });
expect(resolveSchema).toHaveBeenCalledTimes(1);
});
it("uses the fallback when the lazy value resolves nullish", () => {
const fallback = { type: "object" as const, properties: {} };
const getSchema = createCachedLazyValueGetter<typeof fallback>(() => undefined, fallback);
expect(getSchema()).toBe(fallback);
});
});

View File

@@ -0,0 +1,24 @@
type LazyValue<T> = T | (() => T);
export function createCachedLazyValueGetter<T>(value: LazyValue<T>): () => T;
export function createCachedLazyValueGetter<T>(
value: LazyValue<T | null | undefined>,
fallback: T,
): () => T;
export function createCachedLazyValueGetter<T>(
value: LazyValue<T | null | undefined>,
fallback?: T,
): () => T | undefined {
let resolved = false;
let cached: T | undefined;
return () => {
if (!resolved) {
const nextValue =
typeof value === "function" ? (value as () => T | null | undefined)() : value;
cached = nextValue ?? fallback;
resolved = true;
}
return cached;
};
}

View File

@@ -65,6 +65,7 @@ import type {
SpeechProviderPlugin,
PluginCommandContext,
} from "../plugins/types.js";
import { createCachedLazyValueGetter } from "./lazy-value.js";
export type {
AnyAgentTool,
@@ -154,13 +155,6 @@ type DefinedPluginEntry = {
register: NonNullable<OpenClawPluginDefinition["register"]>;
} & Pick<OpenClawPluginDefinition, "kind">;
/** Resolve either a concrete config schema or a lazy schema factory. */
function resolvePluginConfigSchema(
configSchema: DefinePluginEntryOptions["configSchema"] = emptyPluginConfigSchema,
): OpenClawPluginConfigSchema {
return typeof configSchema === "function" ? configSchema() : configSchema;
}
/**
* Canonical entry helper for non-channel plugins.
*
@@ -176,11 +170,7 @@ export function definePluginEntry({
configSchema = emptyPluginConfigSchema,
register,
}: DefinePluginEntryOptions): DefinedPluginEntry {
let resolvedConfigSchema: OpenClawPluginConfigSchema | undefined;
const getConfigSchema = (): OpenClawPluginConfigSchema => {
resolvedConfigSchema ??= resolvePluginConfigSchema(configSchema);
return resolvedConfigSchema;
};
const getConfigSchema = createCachedLazyValueGetter(configSchema);
return {
id,
name,