From 636fe1c2dbbc6f409ba083e999400e970ae3a071 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 11 Apr 2026 12:42:23 +0100 Subject: [PATCH] fix(qa): ship scenario pack and isolate completion cache --- package.json | 1 + scripts/release-check.ts | 35 +++++++ src/cli/completion-cli.ts | 28 ++++-- src/cli/completion-cli.write-state.test.ts | 109 +++++++++++++++++++++ test/release-check.test.ts | 16 +++ 5 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 src/cli/completion-cli.write-state.test.ts diff --git a/package.json b/package.json index 420863b0484..8b615a2e7c5 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "docs/", "!docs/.generated/**", "!docs/.i18n/zh-CN.tm.jsonl", + "qa/scenarios/", "skills/", "scripts/npm-runner.mjs", "scripts/postinstall-bundled-plugins.mjs", diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 68e476dd0f4..86b2158df09 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -36,6 +36,7 @@ const requiredPathGroups = [ ...listPluginSdkDistArtifacts(), ...listBundledPluginPackArtifacts(), ...listStaticExtensionAssetOutputs(), + ...listRequiredQaScenarioPackPaths(), "scripts/npm-runner.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/plugin-sdk/compat.js", @@ -59,6 +60,14 @@ const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; +export function listRequiredQaScenarioPackPaths(): string[] { + const scenariosDir = resolve("qa/scenarios"); + return readdirSync(scenariosDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map((entry) => `qa/scenarios/${entry.name}`) + .toSorted((left, right) => left.localeCompare(right)); +} + function collectBundledExtensions(): BundledExtension[] { const extensionsDir = resolve("extensions"); const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => @@ -199,6 +208,32 @@ function runPackedBundledChannelEntrySmoke(): void { }, }, ); + + const homeDir = join(tmpRoot, "home"); + const stateDir = join(tmpRoot, "state"); + mkdirSync(homeDir, { recursive: true }); + execFileSync( + process.execPath, + [join(packageRoot, "openclaw.mjs"), "completion", "--write-state"], + { + cwd: packageRoot, + stdio: "inherit", + env: { + ...process.env, + HOME: homeDir, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_SUPPRESS_NOTES: "1", + OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", + }, + }, + ); + + const completionFiles = readdirSync(join(stateDir, "completions")).filter( + (entry) => !entry.startsWith("."), + ); + if (completionFiles.length === 0) { + throw new Error("release-check: packed completion smoke produced no completion files."); + } } finally { rmSync(tmpRoot, { recursive: true, force: true }); } diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index 180a0e53599..2b21d53fdcc 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -48,6 +48,26 @@ async function writeCompletionCache(params: { } } +function writeCompletionRegistrationWarning(message: string): void { + process.stderr.write(`[completion] ${message}\n`); +} + +async function registerSubcommandsForCompletion(program: Command): Promise { + const entries = getSubCliEntries(); + for (const entry of entries) { + if (entry.name === "completion") { + continue; + } + try { + await registerSubCliByName(program, entry.name); + } catch (error) { + writeCompletionRegistrationWarning( + `skipping subcommand \`${entry.name}\` while building completion cache: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + export function registerCompletionCli(program: Command) { program .command("completion") @@ -84,13 +104,7 @@ export function registerCompletionCli(program: Command) { } // Eagerly register all subcommands except completion itself to build the full tree. - const entries = getSubCliEntries(); - for (const entry of entries) { - if (entry.name === "completion") { - continue; - } - await registerSubCliByName(program, entry.name); - } + await registerSubcommandsForCompletion(program); const { registerPluginCliCommandsFromValidatedConfig } = await import("../plugins/cli.js"); await registerPluginCliCommandsFromValidatedConfig(program, undefined, undefined, { diff --git a/src/cli/completion-cli.write-state.test.ts b/src/cli/completion-cli.write-state.test.ts new file mode 100644 index 00000000000..ed9951b7b17 --- /dev/null +++ b/src/cli/completion-cli.write-state.test.ts @@ -0,0 +1,109 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const stderrWrites = vi.hoisted(() => vi.fn()); +const getCoreCliCommandNamesMock = vi.hoisted(() => vi.fn(() => [])); +const registerCoreCliByNameMock = vi.hoisted(() => vi.fn()); +const getProgramContextMock = vi.hoisted(() => vi.fn(() => null)); +const getSubCliEntriesMock = vi.hoisted(() => + vi.fn(() => [ + { name: "qa", description: "QA commands", hasSubcommands: true }, + { name: "completion", description: "Completion", hasSubcommands: false }, + ]), +); +const registerSubCliByNameMock = vi.hoisted(() => + vi.fn(async (program: Command, name: string) => { + if (name === "qa") { + throw new Error("qa scenario pack not found: qa/scenarios/index.md"); + } + program.command(name); + return true; + }), +); +const registerPluginCliCommandsFromValidatedConfigMock = vi.hoisted(() => vi.fn(async () => null)); + +vi.mock("./program/command-registry-core.js", () => ({ + getCoreCliCommandNames: getCoreCliCommandNamesMock, + registerCoreCliByName: registerCoreCliByNameMock, +})); + +vi.mock("./program/program-context.js", () => ({ + getProgramContext: getProgramContextMock, +})); + +vi.mock("./program/register.subclis-core.js", () => ({ + getSubCliEntries: getSubCliEntriesMock, + registerSubCliByName: registerSubCliByNameMock, +})); + +vi.mock("../plugins/cli.js", () => ({ + registerPluginCliCommandsFromValidatedConfig: registerPluginCliCommandsFromValidatedConfigMock, +})); + +describe("completion-cli write-state", () => { + const originalHome = process.env.HOME; + const originalStateDir = process.env.OPENCLAW_STATE_DIR; + let restoreStderrWriteSpy: (() => void) | null = null; + + beforeEach(() => { + stderrWrites.mockReset(); + getCoreCliCommandNamesMock.mockClear(); + registerCoreCliByNameMock.mockClear(); + getProgramContextMock.mockClear(); + getSubCliEntriesMock.mockClear(); + registerSubCliByNameMock.mockClear(); + registerPluginCliCommandsFromValidatedConfigMock.mockClear(); + const stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stderrWrites(chunk.toString()); + return true; + }) as typeof process.stderr.write); + restoreStderrWriteSpy = () => stderrWriteSpy.mockRestore(); + }); + + afterEach(async () => { + restoreStderrWriteSpy?.(); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalStateDir; + } + }); + + it("keeps completion cache generation alive when a subcli fails to register", async () => { + const { registerCompletionCli } = await import("./completion-cli.js"); + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-state-")); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-home-")); + + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.HOME = homeDir; + + const program = new Command(); + program.name("openclaw"); + registerCompletionCli(program); + + await program.parseAsync(["completion", "--write-state"], { from: "user" }); + + const cacheDir = path.join(stateDir, "completions"); + expect(await fs.readdir(cacheDir)).toEqual( + expect.arrayContaining(["openclaw.bash", "openclaw.fish", "openclaw.ps1", "openclaw.zsh"]), + ); + expect(registerSubCliByNameMock).toHaveBeenCalledWith(program, "qa"); + expect(registerPluginCliCommandsFromValidatedConfigMock).toHaveBeenCalledTimes(1); + expect(stderrWrites).toHaveBeenCalledWith( + expect.stringContaining("skipping subcommand `qa` while building completion cache"), + ); + + await fs.rm(stateDir, { recursive: true, force: true }); + await fs.rm(homeDir, { recursive: true, force: true }); + }); +}); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index cf1358f438d..2f7d647ee01 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -12,6 +12,7 @@ import { collectForbiddenPackPaths, collectMissingPackPaths, collectPackUnpackedSizeErrors, + listRequiredQaScenarioPackPaths, packageNameFromSpecifier, } from "../scripts/release-check.ts"; import { bundledDistPluginFile, bundledPluginFile } from "./helpers/bundled-plugin-paths.js"; @@ -26,6 +27,7 @@ function makePackResult(filename: string, unpackedSize: number) { const requiredPluginSdkPackPaths = [...listPluginSdkDistArtifacts(), "dist/plugin-sdk/compat.js"]; const requiredBundledPluginPackPaths = listBundledPluginPackArtifacts(); +const requiredQaScenarioPackPaths = listRequiredQaScenarioPackPaths(); describe("collectAppcastSparkleVersionErrors", () => { it("accepts legacy 9-digit calver builds before lane-floor cutover", () => { @@ -291,6 +293,7 @@ describe("collectMissingPackPaths", () => { expect.arrayContaining([ "dist/channel-catalog.json", "dist/control-ui/index.html", + "qa/scenarios/index.md", "scripts/npm-runner.mjs", "scripts/postinstall-bundled-plugins.mjs", bundledDistPluginFile("diffs", "assets/viewer-runtime.js"), @@ -305,6 +308,9 @@ describe("collectMissingPackPaths", () => { bundledDistPluginFile("whatsapp", "package.json"), ]), ); + expect( + missing.some((path) => path.startsWith("qa/scenarios/") && path !== "qa/scenarios/index.md"), + ).toBe(true); }); it("accepts the shipped upgrade surface when optional bundled metadata is present", () => { @@ -316,6 +322,7 @@ describe("collectMissingPackPaths", () => { "dist/extensions/acpx/mcp-proxy.mjs", bundledDistPluginFile("diffs", "assets/viewer-runtime.js"), ...requiredBundledPluginPackPaths, + ...requiredQaScenarioPackPaths, ...requiredPluginSdkPackPaths, "scripts/npm-runner.mjs", "scripts/postinstall-bundled-plugins.mjs", @@ -337,6 +344,15 @@ describe("collectMissingPackPaths", () => { ]), ); }); + + it("requires the authored qa scenario pack files in npm pack output", () => { + expect(requiredQaScenarioPackPaths).toContain("qa/scenarios/index.md"); + expect( + requiredQaScenarioPackPaths.some( + (path) => path.startsWith("qa/scenarios/") && path !== "qa/scenarios/index.md", + ), + ).toBe(true); + }); }); describe("collectPackUnpackedSizeErrors", () => {