mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-10 08:41:13 +00:00
refactor: share sdk lazy config and cli test helpers
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
src/cli/update-cli.test-helpers.test.ts
Normal file
21
src/cli/update-cli.test-helpers.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
18
src/cli/update-cli.test-helpers.ts
Normal file
18
src/cli/update-cli.test-helpers.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
20
src/plugin-sdk/lazy-value.test.ts
Normal file
20
src/plugin-sdk/lazy-value.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
24
src/plugin-sdk/lazy-value.ts
Normal file
24
src/plugin-sdk/lazy-value.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user