mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
fix(qa): ship scenario pack and isolate completion cache
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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, {
|
||||
|
||||
109
src/cli/completion-cli.write-state.test.ts
Normal file
109
src/cli/completion-cli.write-state.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user