diff --git a/src/cli/program/private-qa-cli.test.ts b/src/cli/program/private-qa-cli.test.ts index 7462bb188ea..be33437e51a 100644 --- a/src/cli/program/private-qa-cli.test.ts +++ b/src/cli/program/private-qa-cli.test.ts @@ -1,13 +1,75 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { loadPrivateQaCliModule } from "./private-qa-cli.js"; describe("private-qa-cli", () => { - it("loads the private QA CLI facade through the local plugin-sdk entrypoint", async () => { - const module = await loadPrivateQaCliModule(); + const tempDirs: string[] = []; + const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + if (originalPrivateQaCli === undefined) { + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + } else { + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalPrivateQaCli; + } + }); + + it("loads the private QA CLI from a source checkout path", async () => { + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-private-qa-source-")); + tempDirs.push(repoRoot); + const importModule = vi.fn(async () => ({ + isQaLabCliAvailable: expect.any(Function), + registerQaLabCli: expect.any(Function), + })); + + const module = await loadPrivateQaCliModule({ + importModule, + resolvePackageRootSync: () => repoRoot, + existsSync: (filePath) => + [ + path.join(repoRoot, ".git"), + path.join(repoRoot, "src"), + path.join(repoRoot, "dist", "plugin-sdk", "qa-lab.js"), + ].includes(filePath), + }); + + expect(importModule).toHaveBeenCalledTimes(1); + expect(importModule.mock.calls[0]?.[0]).toContain("/dist/plugin-sdk/qa-lab.js"); expect(module).toMatchObject({ isQaLabCliAvailable: expect.any(Function), registerQaLabCli: expect.any(Function), }); }); + + it("rejects non-source package roots even when private QA is enabled", async () => { + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-private-qa-")); + tempDirs.push(root); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }), "utf8"); + const importModule = vi.fn(async () => ({})); + + expect(() => + loadPrivateQaCliModule({ + resolvePackageRootSync: () => root, + importModule, + }), + ).toThrow("Private QA CLI is only available from an OpenClaw source checkout."); + expect(importModule).not.toHaveBeenCalled(); + }); + + it("rejects when the private QA env flag is disabled", async () => { + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + const importModule = vi.fn(async () => ({})); + + expect(() => loadPrivateQaCliModule({ importModule })).toThrow( + "Private QA CLI is only available from an OpenClaw source checkout.", + ); + expect(importModule).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/program/private-qa-cli.ts b/src/cli/program/private-qa-cli.ts index ae093d2450e..9073711d0f9 100644 --- a/src/cli/program/private-qa-cli.ts +++ b/src/cli/program/private-qa-cli.ts @@ -1,8 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; + +const PRIVATE_QA_DIST_RELATIVE_PATH = path.join("dist", "plugin-sdk", "qa-lab.js"); + export function isPrivateQaCliEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1"; } -export function loadPrivateQaCliModule(): Promise> { - const specifier = "../../plugin-sdk/qa-lab.js"; - return import(specifier) as Promise>; +function resolvePrivateQaSourceModuleSpecifier(params?: { + env?: NodeJS.ProcessEnv; + cwd?: string; + argv1?: string; + moduleUrl?: string; + resolvePackageRootSync?: typeof resolveOpenClawPackageRootSync; + existsSync?: typeof fs.existsSync; +}): string | null { + const env = params?.env ?? process.env; + if (!isPrivateQaCliEnabled(env)) { + return null; + } + const resolvePackageRootSync = params?.resolvePackageRootSync ?? resolveOpenClawPackageRootSync; + const packageRoot = resolvePackageRootSync({ + argv1: params?.argv1 ?? process.argv[1], + cwd: params?.cwd ?? process.cwd(), + moduleUrl: params?.moduleUrl ?? import.meta.url, + }); + if (!packageRoot) { + return null; + } + const existsSync = params?.existsSync ?? fs.existsSync; + const sourceModulePath = path.join(packageRoot, PRIVATE_QA_DIST_RELATIVE_PATH); + if ( + !existsSync(path.join(packageRoot, ".git")) || + !existsSync(path.join(packageRoot, "src")) || + !existsSync(sourceModulePath) + ) { + return null; + } + return pathToFileURL(sourceModulePath).href; +} + +async function dynamicImportPrivateQaCliModule( + specifier: string, +): Promise> { + return (await import(specifier)) as Record; +} + +export function loadPrivateQaCliModule(params?: { + env?: NodeJS.ProcessEnv; + cwd?: string; + argv1?: string; + moduleUrl?: string; + resolvePackageRootSync?: typeof resolveOpenClawPackageRootSync; + existsSync?: typeof fs.existsSync; + importModule?: (specifier: string) => Promise>; +}): Promise> { + const specifier = resolvePrivateQaSourceModuleSpecifier(params); + if (!specifier) { + throw new Error("Private QA CLI is only available from an OpenClaw source checkout."); + } + return (params?.importModule ?? dynamicImportPrivateQaCliModule)(specifier); } diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index 594714aff44..6720eb3424f 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -25,6 +25,9 @@ const { registerQaLabCli } = vi.hoisted(() => ({ qa.command("run").action(() => undefined); }), })); +const { loadPrivateQaCliModule } = vi.hoisted(() => ({ + loadPrivateQaCliModule: vi.fn(async () => ({ registerQaLabCli })), +})); const { inferAction, registerCapabilityCli } = vi.hoisted(() => { const action = vi.fn(); @@ -37,7 +40,13 @@ const { inferAction, registerCapabilityCli } = vi.hoisted(() => { vi.mock("../acp-cli.js", () => ({ registerAcpCli })); vi.mock("../nodes-cli.js", () => ({ registerNodesCli })); vi.mock("../capability-cli.js", () => ({ registerCapabilityCli })); -vi.mock("../../plugin-sdk/qa-lab.js", () => ({ registerQaLabCli })); +vi.mock("./private-qa-cli.js", async () => { + const actual = await vi.importActual("./private-qa-cli.js"); + return { + ...actual, + loadPrivateQaCliModule, + }; +}); describe("registerSubCliCommands", () => { const originalArgv = process.argv; @@ -66,6 +75,7 @@ describe("registerSubCliCommands", () => { registerNodesCli.mockClear(); nodesAction.mockClear(); registerQaLabCli.mockClear(); + loadPrivateQaCliModule.mockClear(); registerCapabilityCli.mockClear(); inferAction.mockClear(); });