diff --git a/package.json b/package.json index fd027079ba4..3405c81f4ce 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,14 @@ "!dist/extensions/qa-channel/**", "dist/extensions/qa-channel/runtime-api.js", "!dist/extensions/qa-lab/**", - "dist/extensions/qa-lab/runtime-api.js", "!dist/extensions/qa-matrix/**", + "!dist/plugin-sdk/extensions/qa-lab/**", + "!dist/plugin-sdk/qa-lab.*", + "!dist/plugin-sdk/qa-runtime.*", + "!dist/plugin-sdk/src/plugin-sdk/qa-lab.d.ts", + "!dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts", + "!dist/qa-runtime-*.js", "docs/reference/templates/", - "qa/scenarios/", "skills/", "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", @@ -767,10 +771,6 @@ "types": "./dist/plugin-sdk/matrix-thread-bindings.d.ts", "default": "./dist/plugin-sdk/matrix-thread-bindings.js" }, - "./plugin-sdk/qa-runtime": { - "types": "./dist/plugin-sdk/qa-runtime.d.ts", - "default": "./dist/plugin-sdk/qa-runtime.js" - }, "./plugin-sdk/qa-runner-runtime": { "types": "./dist/plugin-sdk/qa-runner-runtime.d.ts", "default": "./dist/plugin-sdk/qa-runner-runtime.js" diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 04ecaac7ea3..0c141d5602f 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { NON_PACKAGED_BUNDLED_PLUGIN_DIRS } from "./lib/bundled-plugin-build-entries.mjs"; import { shouldBuildBundledCluster } from "./lib/optional-bundled-clusters.mjs"; import { removeFileIfExists, @@ -12,6 +13,13 @@ const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills"; const TRANSIENT_COPY_ERROR_CODES = new Set(["EEXIST", "ENOENT", "ENOTEMPTY", "EBUSY"]); const COPY_RETRY_DELAYS_MS = [10, 25, 50]; +function shouldCopyBundledPluginMetadata(id, env) { + if (!NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(id)) { + return true; + } + return env.OPENCLAW_BUILD_PRIVATE_QA === "1"; +} + export function rewritePackageExtensions(entries) { if (!Array.isArray(entries)) { return undefined; @@ -234,6 +242,10 @@ export function copyBundledPluginMetadata(params = {}) { ? JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) : undefined; const topLevelPublicSurfaceEntries = collectTopLevelPublicSurfaceEntries(pluginDir); + if (!shouldCopyBundledPluginMetadata(dirent.name, env)) { + removePathIfExists(distPluginDir); + continue; + } if (!shouldBuildBundledCluster(dirent.name, env, { packageJson })) { removePathIfExists(distPluginDir); continue; diff --git a/scripts/lib/bundled-plugin-build-entries.d.mts b/scripts/lib/bundled-plugin-build-entries.d.mts index b8d10c5e9c3..f5326f85925 100644 --- a/scripts/lib/bundled-plugin-build-entries.d.mts +++ b/scripts/lib/bundled-plugin-build-entries.d.mts @@ -10,6 +10,7 @@ export type BundledPluginBuildEntryParams = { env?: NodeJS.ProcessEnv; }; +export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS: Set; export function collectBundledPluginBuildEntries( params?: BundledPluginBuildEntryParams, ): BundledPluginBuildEntry[]; diff --git a/scripts/lib/bundled-plugin-build-entries.d.ts b/scripts/lib/bundled-plugin-build-entries.d.ts index b8d10c5e9c3..f5326f85925 100644 --- a/scripts/lib/bundled-plugin-build-entries.d.ts +++ b/scripts/lib/bundled-plugin-build-entries.d.ts @@ -10,6 +10,7 @@ export type BundledPluginBuildEntryParams = { env?: NodeJS.ProcessEnv; }; +export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS: Set; export function collectBundledPluginBuildEntries( params?: BundledPluginBuildEntryParams, ): BundledPluginBuildEntry[]; diff --git a/scripts/lib/bundled-plugin-build-entries.mjs b/scripts/lib/bundled-plugin-build-entries.mjs index 836170713d4..0712ef6350e 100644 --- a/scripts/lib/bundled-plugin-build-entries.mjs +++ b/scripts/lib/bundled-plugin-build-entries.mjs @@ -8,7 +8,7 @@ import { import { shouldBuildBundledCluster } from "./optional-bundled-clusters.mjs"; const TOP_LEVEL_PUBLIC_SURFACE_EXTENSIONS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); -const NON_PACKAGED_BUNDLED_PLUGIN_DIRS = new Set(["qa-channel", "qa-lab", "qa-matrix"]); +export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS = new Set(["qa-channel", "qa-lab", "qa-matrix"]); const toPosixPath = (value) => value.replaceAll("\\", "/"); function readBundledPluginPackageJson(packageJsonPath) { diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 100880b9510..017f4203025 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -179,7 +179,6 @@ "matrix-runtime-surface", "matrix-surface", "matrix-thread-bindings", - "qa-runtime", "qa-runner-runtime", "mattermost", "mattermost-policy", diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index 0eb9c6b4e1f..f46e8b54b94 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -46,7 +46,6 @@ const LEGACY_CONTEXT_ENGINE_UNRESOLVED_RUNTIME_MARKER = "Failed to load legacy context engine runtime."; const LEGACY_UPDATE_COMPAT_RUNTIME_SIDECAR_PATHS = [ "dist/extensions/qa-channel/runtime-api.js", - "dist/extensions/qa-lab/runtime-api.js", ] as const; const PUBLISHED_BUNDLED_RUNTIME_SIDECAR_PATHS = [ ...BUNDLED_RUNTIME_SIDECAR_PATHS.filter((relativePath) => diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index 0813db74d67..c1c83835a07 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -2,8 +2,12 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; -import { basename } from "node:path"; +import { basename, join } from "node:path"; import { pathToFileURL } from "node:url"; +import { + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, + writePackageDistInventory, +} from "../src/infra/package-dist-inventory.ts"; import { compareReleaseVersions as compareReleaseVersionsBase, resolveNpmDistTagMirrorAuth as resolveNpmDistTagMirrorAuthBase, @@ -55,11 +59,9 @@ export type NpmDistTagMirrorAuth = { }; const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; const MAX_CALVER_DISTANCE_DAYS = 2; -const LEGACY_UPDATE_COMPAT_PACKED_PATHS = [ - "dist/extensions/qa-channel/runtime-api.js", - "dist/extensions/qa-lab/runtime-api.js", -] as const; +const LEGACY_UPDATE_COMPAT_PACKED_PATHS = ["dist/extensions/qa-channel/runtime-api.js"] as const; const REQUIRED_PACKED_PATHS = [ + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, "dist/control-ui/index.html", ...LEGACY_UPDATE_COMPAT_PACKED_PATHS, ...WORKSPACE_TEMPLATE_PACK_PATHS, @@ -81,7 +83,28 @@ const FORBIDDEN_PACKED_PATH_RULES = [ describe: (packedPath: string) => `npm package must not include private QA lab artifact "${packedPath}".`, }, + { + prefix: "dist/plugin-sdk/extensions/qa-lab/", + describe: (packedPath: string) => + `npm package must not include private QA lab type artifact "${packedPath}".`, + }, + { + prefix: "dist/qa-runtime-", + describe: (packedPath: string) => + `npm package must not include private QA runtime chunk "${packedPath}".`, + }, + { + prefix: "qa/", + describe: (packedPath: string) => + `npm package must not include private QA suite artifact "${packedPath}".`, + }, ] as const; +const FORBIDDEN_PRIVATE_QA_CONTENT_MARKERS = [ + "//#region extensions/qa-lab/", + "qa-lab/cli.js", + "qa-lab/runtime-api.js", +] as const; +const FORBIDDEN_PRIVATE_QA_CONTENT_SCAN_PREFIXES = ["dist/"] as const; const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024; const skipPackValidationEnv = "OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK"; @@ -466,6 +489,7 @@ function collectPackedTarballErrors(): string[] { return [ ...collectControlUiPackErrors(packedPaths), ...collectForbiddenPackedPathErrors(packedPaths), + ...collectForbiddenPackedContentErrors(packedPaths), ]; } @@ -486,7 +510,41 @@ export function collectForbiddenPackedPathErrors(paths: Iterable): strin return errors.toSorted((left, right) => left.localeCompare(right)); } -function main(): number { +export function collectForbiddenPackedContentErrors( + paths: Iterable, + rootDir = process.cwd(), +): string[] { + const textPathPattern = /\.(?:[cm]?js|d\.ts|json|md|mjs|cjs)$/u; + const errors: string[] = []; + for (const packedPath of paths) { + if ( + !FORBIDDEN_PRIVATE_QA_CONTENT_SCAN_PREFIXES.some((prefix) => packedPath.startsWith(prefix)) + ) { + continue; + } + if (!textPathPattern.test(packedPath)) { + continue; + } + let content: string; + try { + content = readFileSync(pathToFileURL(join(rootDir, packedPath)), "utf8"); + } catch { + continue; + } + const matchedMarker = FORBIDDEN_PRIVATE_QA_CONTENT_MARKERS.find((marker) => + content.includes(marker), + ); + if (!matchedMarker) { + continue; + } + errors.push( + `npm package must not include private QA lab marker "${matchedMarker}" in "${packedPath}".`, + ); + } + return errors.toSorted((left, right) => left.localeCompare(right)); +} + +async function main(): Promise { const pkg = loadPackageJson(); const now = new Date(); const skipPackValidation = shouldSkipPackedTarballValidation(); @@ -498,6 +556,9 @@ function main(): number { releaseMainRef: process.env.RELEASE_MAIN_REF, now, }); + if (!skipPackValidation) { + await writePackageDistInventory(process.cwd()); + } const tarballErrors = skipPackValidation ? [] : collectPackedTarballErrors(); const errors = [...metadataErrors, ...tagErrors, ...tarballErrors]; @@ -519,5 +580,5 @@ function main(): number { } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - process.exit(main()); + process.exit(await main()); } diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 091b2154c4e..228c9288b39 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -12,6 +12,7 @@ import { closeSync, existsSync, lstatSync, + mkdirSync, openSync, readdirSync, readFileSync, @@ -33,6 +34,14 @@ const DEFAULT_EXTENSIONS_DIR = join(__dirname, "..", "dist", "extensions"); const DEFAULT_PACKAGE_ROOT = join(__dirname, ".."); const DISABLE_POSTINSTALL_ENV = "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL"; const DIST_INVENTORY_PATH = "dist/postinstall-inventory.json"; +const LEGACY_UPDATE_COMPAT_SIDECARS = [ + { + path: "dist/extensions/qa-lab/runtime-api.js", + removedPrefix: "dist/extensions/qa-lab/", + content: + "// Compatibility stub for older OpenClaw updaters. QA Lab is not packaged.\nexport {};\n", + }, +]; const BAILEYS_MEDIA_FILE = join( "node_modules", "@whiskeysockets", @@ -296,6 +305,30 @@ export function pruneInstalledPackageDist(params = {}) { return removed; } +export function restoreLegacyUpdaterCompatSidecars(params = {}) { + const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; + const writeFile = params.writeFileSync ?? writeFileSync; + const makeDirectory = params.mkdirSync ?? mkdirSync; + const log = params.log ?? console; + const restored = []; + + for (const sidecar of LEGACY_UPDATE_COMPAT_SIDECARS) { + // Older npm updater builds verify this exact sidecar after npm has already + // replaced the package. npm may remove stale QA Lab files before this + // postinstall hook runs, so this must be generated independently of prune + // results. The tarball and dist inventory still omit QA Lab. + const sidecarPath = join(packageRoot, sidecar.path); + makeDirectory(dirname(sidecarPath), { recursive: true }); + writeFile(sidecarPath, sidecar.content, "utf8"); + restored.push(sidecar.path); + } + + if (restored.length > 0) { + log.log(`[postinstall] restored legacy updater compat sidecars: ${restored.join(", ")}`); + } + return restored; +} + function dependencySentinelPath(depName) { return join("node_modules", ...depName.split("/"), "package.json"); } @@ -650,7 +683,7 @@ export function runBundledPluginPostinstall(params = {}) { }); return; } - pruneInstalledPackageDist({ + const prunedDistFiles = pruneInstalledPackageDist({ packageRoot, existsSync: pathExists, readFileSync: params.readFileSync, @@ -658,6 +691,13 @@ export function runBundledPluginPostinstall(params = {}) { rmSync: params.rmSync, log, }); + restoreLegacyUpdaterCompatSidecars({ + packageRoot, + removedFiles: prunedDistFiles, + mkdirSync: params.mkdirSync, + writeFileSync: params.writeFileSync, + log, + }); if ( !shouldRunBundledPluginPostinstall({ env, diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 35db18baa91..6af627ca8f5 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -5,6 +5,10 @@ import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node: import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, + writePackageDistInventory, +} from "../src/infra/package-dist-inventory.ts"; import { collectBundledExtensionManifestErrors, type BundledExtension, @@ -38,13 +42,13 @@ type PackFile = { path: string }; type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number }; const requiredPathGroups = [ + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, ["dist/index.js", "dist/index.mjs"], ["dist/entry.js", "dist/entry.mjs"], ...listPluginSdkDistArtifacts(), ...listBundledPluginPackArtifacts(), ...listStaticExtensionAssetOutputs(), ...WORKSPACE_TEMPLATE_PACK_PATHS, - ...listRequiredQaScenarioPackPaths(), "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", "scripts/postinstall-bundled-plugins.mjs", @@ -57,21 +61,27 @@ const requiredPathGroups = [ const forbiddenPrefixes = [ "dist-runtime/", "dist/OpenClaw.app/", + "dist/extensions/qa-lab/", + "dist/plugin-sdk/extensions/qa-lab/", + "dist/plugin-sdk/qa-lab.", + "dist/plugin-sdk/qa-runtime.", + "dist/plugin-sdk/src/plugin-sdk/qa-lab.d.ts", + "dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts", + "dist/qa-runtime-", "dist/plugin-sdk/.tsbuildinfo", "docs/.generated/", + "qa/", ]; +const forbiddenPrivateQaContentMarkers = [ + "//#region extensions/qa-lab/", + "qa-lab/cli.js", + "qa-lab/runtime-api.js", +] as const; +const forbiddenPrivateQaContentScanPrefixes = ["dist/"] as const; 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) => @@ -269,6 +279,30 @@ export function collectForbiddenPackPaths(paths: Iterable): string[] { .toSorted((left, right) => left.localeCompare(right)); } +export function collectForbiddenPackContentPaths( + paths: Iterable, + rootDir = process.cwd(), +): string[] { + const textPathPattern = /\.(?:[cm]?js|d\.ts|json|md|mjs|cjs)$/u; + return [...paths] + .filter((packedPath) => { + if (!forbiddenPrivateQaContentScanPrefixes.some((prefix) => packedPath.startsWith(prefix))) { + return false; + } + if (!textPathPattern.test(packedPath)) { + return false; + } + let content: string; + try { + content = readFileSync(resolve(rootDir, packedPath), "utf8"); + } catch { + return false; + } + return forbiddenPrivateQaContentMarkers.some((marker) => content.includes(marker)); + }) + .toSorted((left, right) => left.localeCompare(right)); +} + export { collectPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs"; function extractTag(item: string, tag: string): string | null { @@ -430,6 +464,7 @@ async function main() { checkAppcastSparkleVersions(); await checkPluginSdkExports(); checkBundledExtensionMetadata(); + await writePackageDistInventory(process.cwd()); const results = runPackDry(); const files = results.flatMap((entry) => entry.files ?? []); @@ -444,9 +479,15 @@ async function main() { }) .toSorted((left, right) => left.localeCompare(right)); const forbidden = collectForbiddenPackPaths(paths); + const forbiddenContent = collectForbiddenPackContentPaths(paths); const sizeErrors = collectNpmPackUnpackedSizeErrors(results); - if (missing.length > 0 || forbidden.length > 0 || sizeErrors.length > 0) { + if ( + missing.length > 0 || + forbidden.length > 0 || + forbiddenContent.length > 0 || + sizeErrors.length > 0 + ) { if (missing.length > 0) { console.error("release-check: missing files in npm pack:"); for (const path of missing) { @@ -471,6 +512,12 @@ async function main() { console.error(` - ${path}`); } } + if (forbiddenContent.length > 0) { + console.error("release-check: forbidden private QA markers in npm pack:"); + for (const path of forbiddenContent) { + console.error(` - ${path}`); + } + } if (sizeErrors.length > 0) { console.error("release-check: npm pack unpacked size budget exceeded:"); for (const error of sizeErrors) { diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index abc9e0550fc..ebf4c9bb5d9 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -208,6 +208,13 @@ export const resolveBuildRequirement = (deps) => { if (deps.env.OPENCLAW_FORCE_BUILD === "1") { return { shouldBuild: true, reason: "force_build" }; } + if ( + deps.env.OPENCLAW_BUILD_PRIVATE_QA === "1" && + deps.privateQaDistEntry && + statMtime(deps.privateQaDistEntry, deps.fs) == null + ) { + return { shouldBuild: true, reason: "missing_private_qa_dist" }; + } const stamp = readBuildStamp(deps); if (stamp.mtime == null) { return { shouldBuild: true, reason: "missing_build_stamp" }; @@ -255,6 +262,7 @@ const BUILD_REASON_LABELS = { git_head_changed: "git head changed", dirty_watched_tree: "dirty watched source tree", source_mtime_newer: "source mtime newer than build stamp", + missing_private_qa_dist: "private QA dist entry missing", clean: "clean", }; @@ -389,6 +397,11 @@ export async function runNodeMain(params = {}) { path: path.join(deps.cwd, sourceRoot), })); deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath)); + deps.privateQaDistEntry = path.join(deps.distRoot, "extensions", "qa-lab", "cli.js"); + if (deps.args[0] === "qa") { + deps.env.OPENCLAW_BUILD_PRIVATE_QA = "1"; + deps.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; + } const buildRequirement = resolveBuildRequirement(deps); if (!buildRequirement.shouldBuild) { diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index 5afc52c934d..df9d73e19e5 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -228,6 +228,7 @@ prepare_update_tarball() { UPDATE_EXPECT_VERSION="$( node -p 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).version' )" + node --import tsx scripts/write-package-dist-inventory.ts quiet_npm pack --ignore-scripts --json --pack-destination "$UPDATE_DIR" >"$pack_json_file" fi UPDATE_TGZ_FILE="$( diff --git a/scripts/write-package-dist-inventory.ts b/scripts/write-package-dist-inventory.ts new file mode 100644 index 00000000000..e6ceabf2ec8 --- /dev/null +++ b/scripts/write-package-dist-inventory.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env -S node --import tsx + +import { pathToFileURL } from "node:url"; +import { writePackageDistInventory } from "../src/infra/package-dist-inventory.ts"; + +export async function writeCurrentPackageDistInventory(): Promise { + await writePackageDistInventory(process.cwd()); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await writeCurrentPackageDistInventory(); +} diff --git a/src/cli/program/private-qa-cli.ts b/src/cli/program/private-qa-cli.ts new file mode 100644 index 00000000000..e5f425d9a39 --- /dev/null +++ b/src/cli/program/private-qa-cli.ts @@ -0,0 +1,8 @@ +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"].join(""); + return import(specifier) as Promise>; +} diff --git a/src/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts index fc24f85b5cb..27ef05daa95 100644 --- a/src/cli/program/register.subclis-core.ts +++ b/src/cli/program/register.subclis-core.ts @@ -9,6 +9,7 @@ import { defineImportedProgramCommandGroupSpecs, type CommandGroupDescriptorSpec, } from "./command-group-descriptors.js"; +import { loadPrivateQaCliModule } from "./private-qa-cli.js"; import { registerCommandGroupByName, registerCommandGroups, @@ -131,7 +132,7 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ }, { commandNames: ["qa"], - loadModule: () => import("../../plugin-sdk/qa-lab.js"), + loadModule: loadPrivateQaCliModule, exportName: "registerQaLabCli", }, { diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index 54572599e71..594714aff44 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -19,8 +19,7 @@ const { nodesAction, registerNodesCli } = vi.hoisted(() => { return { nodesAction: action, registerNodesCli: register }; }); -const { isQaLabCliAvailable, registerQaLabCli } = vi.hoisted(() => ({ - isQaLabCliAvailable: vi.fn(() => true), +const { registerQaLabCli } = vi.hoisted(() => ({ registerQaLabCli: vi.fn((program: Command) => { const qa = program.command("qa"); qa.command("run").action(() => undefined); @@ -38,11 +37,12 @@ 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", () => ({ isQaLabCliAvailable, registerQaLabCli })); +vi.mock("../../plugin-sdk/qa-lab.js", () => ({ registerQaLabCli })); describe("registerSubCliCommands", () => { const originalArgv = process.argv; const originalDisableLazySubcommands = process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS; + const originalEnablePrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; const createRegisteredProgram = (argv: string[], name?: string) => { process.argv = argv; @@ -60,11 +60,11 @@ describe("registerSubCliCommands", () => { } else { process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS = originalDisableLazySubcommands; } + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; registerAcpCli.mockClear(); acpAction.mockClear(); registerNodesCli.mockClear(); nodesAction.mockClear(); - isQaLabCliAvailable.mockReset().mockReturnValue(true); registerQaLabCli.mockClear(); registerCapabilityCli.mockClear(); inferAction.mockClear(); @@ -77,6 +77,11 @@ describe("registerSubCliCommands", () => { } else { process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS = originalDisableLazySubcommands; } + if (originalEnablePrivateQaCli === undefined) { + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + } else { + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalEnablePrivateQaCli; + } }); it("registers the primary placeholder plus completion and dispatches", async () => { @@ -101,8 +106,8 @@ describe("registerSubCliCommands", () => { expect(registerAcpCli).not.toHaveBeenCalled(); }); - it("omits the qa placeholder when the private qa bundle is unavailable", () => { - isQaLabCliAvailable.mockReturnValue(false); + it("omits the qa placeholder when the private qa cli is disabled", () => { + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; const program = createRegisteredProgram(["node", "openclaw"]); diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index 5a8b269aad9..2060cfe78a4 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -1,6 +1,6 @@ -import { isQaLabCliAvailable } from "../../plugin-sdk/qa-lab.js"; import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js"; import type { NamedCommandDescriptor } from "./command-group-descriptors.js"; +import { isPrivateQaCliEnabled } from "./private-qa-cli.js"; export type SubCliDescriptor = NamedCommandDescriptor; @@ -164,7 +164,7 @@ export const SUB_CLI_DESCRIPTORS = subCliCommandCatalog.descriptors; export function getSubCliEntries(): ReadonlyArray { const descriptors = subCliCommandCatalog.getDescriptors(); - if (isQaLabCliAvailable()) { + if (isPrivateQaCliEnabled()) { return descriptors; } return descriptors.filter((descriptor) => descriptor.name !== "qa"); @@ -172,7 +172,7 @@ export function getSubCliEntries(): ReadonlyArray { export function getSubCliCommandsWithSubcommands(): string[] { const commands = subCliCommandCatalog.getCommandsWithSubcommands(); - if (isQaLabCliAvailable()) { + if (isPrivateQaCliEnabled()) { return commands; } return commands.filter((command) => command !== "qa"); diff --git a/src/infra/npm-update-compat-sidecars.ts b/src/infra/npm-update-compat-sidecars.ts index 3db8bfa1ca8..eedb8a585d1 100644 --- a/src/infra/npm-update-compat-sidecars.ts +++ b/src/infra/npm-update-compat-sidecars.ts @@ -4,13 +4,13 @@ export const NPM_UPDATE_COMPAT_SIDECARS = [ content: "// Compatibility stub for older OpenClaw updaters. The QA channel implementation is not packaged.\nexport {};\n", }, - { - path: "dist/extensions/qa-lab/runtime-api.js", - content: - "// Compatibility stub for older OpenClaw updaters. The QA lab implementation is not packaged.\nexport {};\n", - }, ] as const; export const NPM_UPDATE_COMPAT_SIDECAR_PATHS = new Set( NPM_UPDATE_COMPAT_SIDECARS.map((entry) => entry.path), ); + +export const NPM_UPDATE_OMITTED_BUNDLED_PLUGIN_ROOTS = new Set([ + "dist/extensions/qa-lab", + "dist/extensions/qa-matrix", +]); diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index 081ba1f2656..6d67fd819fa 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -52,12 +52,41 @@ describe("package dist inventory", () => { "qa-matrix", "index.js", ); + const omittedQaLabPluginSdk = path.join(packageRoot, "dist", "plugin-sdk", "qa-lab.js"); + const omittedQaLabTypes = path.join( + packageRoot, + "dist", + "plugin-sdk", + "extensions", + "qa-lab", + "cli.d.ts", + ); + const omittedQaRuntimeChunk = path.join(packageRoot, "dist", "qa-runtime-B9LDtssJ.js"); + const omittedExtensionNodeModuleSymlink = path.join( + packageRoot, + "dist", + "extensions", + "discord", + "node_modules", + ".bin", + "color-support", + ); const omittedMap = path.join(packageRoot, "dist", "feature.runtime.js.map"); await fs.mkdir(path.dirname(packagedQaRuntime), { recursive: true }); await fs.mkdir(path.dirname(omittedQaMatrixChunk), { recursive: true }); + await fs.mkdir(path.dirname(omittedQaLabTypes), { recursive: true }); + await fs.mkdir(path.dirname(omittedExtensionNodeModuleSymlink), { recursive: true }); + await fs.writeFile(path.join(packageRoot, "color-support.js"), "export {};\n", "utf8"); await fs.writeFile(packagedQaRuntime, "export {};\n", "utf8"); await fs.writeFile(omittedQaChunk, "export {};\n", "utf8"); await fs.writeFile(omittedQaMatrixChunk, "export {};\n", "utf8"); + await fs.writeFile(omittedQaLabPluginSdk, "export {};\n", "utf8"); + await fs.writeFile(omittedQaLabTypes, "export {};\n", "utf8"); + await fs.writeFile(omittedQaRuntimeChunk, "export {};\n", "utf8"); + await fs.symlink( + path.join(packageRoot, "color-support.js"), + omittedExtensionNodeModuleSymlink, + ); await fs.writeFile(omittedMap, "{}", "utf8"); await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([ diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index 2ccfecc81f1..d89bd0fca06 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -2,15 +2,28 @@ import fs from "node:fs/promises"; import path from "node:path"; export const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json"; -const PACKAGED_QA_RUNTIME_PATHS = new Set([ - "dist/extensions/qa-channel/runtime-api.js", - "dist/extensions/qa-lab/runtime-api.js", -]); +const PACKAGED_QA_RUNTIME_PATHS = new Set(["dist/extensions/qa-channel/runtime-api.js"]); const OMITTED_QA_EXTENSION_PREFIXES = [ "dist/extensions/qa-channel/", "dist/extensions/qa-lab/", "dist/extensions/qa-matrix/", ]; +const OMITTED_PRIVATE_QA_PLUGIN_SDK_PREFIXES = ["dist/plugin-sdk/extensions/qa-lab/"]; +const OMITTED_PRIVATE_QA_PLUGIN_SDK_FILES = new Set([ + "dist/plugin-sdk/qa-lab.d.ts", + "dist/plugin-sdk/qa-lab.js", + "dist/plugin-sdk/qa-runtime.d.ts", + "dist/plugin-sdk/qa-runtime.js", + "dist/plugin-sdk/src/plugin-sdk/qa-lab.d.ts", + "dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts", +]); +const OMITTED_PRIVATE_QA_DIST_PREFIXES = ["dist/qa-runtime-"]; +const OMITTED_DIST_SUBTREE_PATTERNS = [ + /^dist\/extensions\/[^/]+\/node_modules(?:\/|$)/u, + /^dist\/extensions\/qa-lab(?:\/|$)/u, + /^dist\/extensions\/qa-matrix(?:\/|$)/u, + /^dist\/plugin-sdk\/extensions\/qa-lab(?:\/|$)/u, +] as const; function normalizeRelativePath(value: string): string { return value.replace(/\\/g, "/"); @@ -29,12 +42,28 @@ function isPackagedDistPath(relativePath: string): boolean { if (relativePath === "dist/plugin-sdk/.tsbuildinfo") { return false; } + if ( + OMITTED_PRIVATE_QA_PLUGIN_SDK_PREFIXES.some((prefix) => relativePath.startsWith(prefix)) || + OMITTED_PRIVATE_QA_PLUGIN_SDK_FILES.has(relativePath) || + OMITTED_PRIVATE_QA_DIST_PREFIXES.some((prefix) => relativePath.startsWith(prefix)) + ) { + return false; + } if (OMITTED_QA_EXTENSION_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) { return PACKAGED_QA_RUNTIME_PATHS.has(relativePath); } return true; } + +function isOmittedDistSubtree(relativePath: string): boolean { + return OMITTED_DIST_SUBTREE_PATTERNS.some((pattern) => pattern.test(relativePath)); +} + async function collectRelativeFiles(rootDir: string, baseDir: string): Promise { + const rootRelativePath = normalizeRelativePath(path.relative(baseDir, rootDir)); + if (rootRelativePath && isOmittedDistSubtree(rootRelativePath)) { + return []; + } try { const rootStats = await fs.lstat(rootDir); if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 4ab18b5d248..6d53ac1338e 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -533,4 +533,38 @@ describe("update global helpers", () => { }, ); }); + + it("ignores stale metadata for non-packaged private QA plugins during inventory verify", async () => { + await withTempDir( + { prefix: "openclaw-update-global-stale-private-qa-" }, + async (packageRoot) => { + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.15" }), + "utf-8", + ); + for (const relativePath of NPM_UPDATE_COMPAT_SIDECAR_PATHS) { + const absolutePath = path.join(packageRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf-8"); + } + const staleQaLabPackageJson = path.join( + packageRoot, + "dist", + "extensions", + "qa-lab", + "package.json", + ); + await fs.mkdir(path.dirname(staleQaLabPackageJson), { recursive: true }); + await fs.writeFile( + staleQaLabPackageJson, + JSON.stringify({ name: "@openclaw/qa-lab" }), + "utf-8", + ); + await writePackageDistInventory(packageRoot); + + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toEqual([]); + }, + ); + }); }); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index dedff0b9d53..9032a16ca97 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -5,7 +5,10 @@ import path from "node:path"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { pathExists } from "../utils.js"; -import { NPM_UPDATE_COMPAT_SIDECAR_PATHS } from "./npm-update-compat-sidecars.js"; +import { + NPM_UPDATE_COMPAT_SIDECAR_PATHS, + NPM_UPDATE_OMITTED_BUNDLED_PLUGIN_ROOTS, +} from "./npm-update-compat-sidecars.js"; import { collectPackageDistInventory, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, @@ -202,6 +205,9 @@ async function collectCriticalInstalledPackageDistPaths(packageRoot: string): Pr if (pluginRoot === null) { return; } + if (NPM_UPDATE_OMITTED_BUNDLED_PLUGIN_ROOTS.has(pluginRoot)) { + return; + } if ( (await pathExists(path.join(packageRoot, pluginRoot, "package.json"))) || (await pathExists(path.join(packageRoot, pluginRoot, "openclaw.plugin.json"))) diff --git a/src/plugin-sdk/qa-lab.ts b/src/plugin-sdk/qa-lab.ts index 6080c250106..59e81aa1aaa 100644 --- a/src/plugin-sdk/qa-lab.ts +++ b/src/plugin-sdk/qa-lab.ts @@ -1,7 +1,11 @@ // Manual facade. Keep loader boundary explicit. -type FacadeModule = typeof import("@openclaw/qa-lab/cli.js"); import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; +type FacadeModule = { + isQaLabCliAvailable: () => boolean; + registerQaLabCli: (program: unknown) => void; +}; + function loadFacadeModule(): FacadeModule { return loadBundledPluginPublicSurfaceModuleSync({ dirName: "qa-lab", diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 444abdb80fd..277741428ba 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -318,6 +318,30 @@ describe("copyBundledPluginMetadata", () => { expect(fs.existsSync(staleDistDir)).toBe(false); }); + it("removes non-packaged private QA plugin metadata unless private QA build is enabled", () => { + const repoRoot = makeRepoRoot("openclaw-private-qa-metadata-"); + createPlugin(repoRoot, { + id: "qa-lab", + packageName: "@openclaw/qa-lab", + packageOpenClaw: { extensions: ["./index.ts"] }, + }); + const staleDistDir = path.join(repoRoot, "dist", "extensions", "qa-lab"); + fs.mkdirSync(staleDistDir, { recursive: true }); + fs.writeFileSync(path.join(staleDistDir, "runtime-api.js"), "export {};\n", "utf8"); + + copyBundledPluginMetadataWithEnv({ repoRoot, env: {} }); + + expect(fs.existsSync(staleDistDir)).toBe(false); + + copyBundledPluginMetadataWithEnv({ + repoRoot, + env: { OPENCLAW_BUILD_PRIVATE_QA: "1" } as NodeJS.ProcessEnv, + }); + + expect(fs.existsSync(path.join(staleDistDir, "openclaw.plugin.json"))).toBe(true); + expect(fs.existsSync(path.join(staleDistDir, "package.json"))).toBe(true); + }); + it.each([ { name: "skips metadata for optional bundled clusters only when explicitly disabled", diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts index 403d3a63f51..d88ef4221a2 100644 --- a/test/openclaw-npm-postpublish-verify.test.ts +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -19,7 +19,6 @@ const PUBLISHED_BUNDLED_RUNTIME_SIDECAR_PATHS = BUNDLED_RUNTIME_SIDECAR_PATHS.fi ); const LEGACY_UPDATE_COMPAT_RUNTIME_SIDECAR_PATHS = [ "dist/extensions/qa-channel/runtime-api.js", - "dist/extensions/qa-lab/runtime-api.js", ] as const; const REQUIRED_INSTALLED_RUNTIME_SIDECAR_PATHS = [ ...PUBLISHED_BUNDLED_RUNTIME_SIDECAR_PATHS, @@ -316,7 +315,7 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => { } }); - it("rejects private qa sidecar directories that are missing package.json", () => { + it("rejects packaged qa channel sidecar directories that are missing package.json", () => { const packageRoot = makeInstalledPackageRoot(); try { @@ -325,21 +324,14 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => { dependencies: {}, }); mkdirSync(join(packageRoot, "dist/extensions/qa-channel"), { recursive: true }); - mkdirSync(join(packageRoot, "dist/extensions/qa-lab"), { recursive: true }); writeFileSync( join(packageRoot, "dist/extensions/qa-channel/runtime-api.js"), "export {};\n", "utf8", ); - writeFileSync( - join(packageRoot, "dist/extensions/qa-lab/runtime-api.js"), - "export {};\n", - "utf8", - ); expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ `installed bundled extension manifest missing: ${join(packageRoot, "dist/extensions/qa-channel/package.json")}.`, - `installed bundled extension manifest missing: ${join(packageRoot, "dist/extensions/qa-lab/package.json")}.`, ]); } finally { rmSync(packageRoot, { recursive: true, force: true }); diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index e354d46a08b..6eaa26d9ac1 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -1,8 +1,12 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { WORKSPACE_TEMPLATE_PACK_PATHS } from "../scripts/lib/workspace-bootstrap-smoke.mjs"; import { compareReleaseVersions, collectControlUiPackErrors, + collectForbiddenPackedContentErrors, collectForbiddenPackedPathErrors, collectReleasePackageMetadataErrors, collectReleaseTagErrors, @@ -15,10 +19,13 @@ import { shouldSkipPackedTarballValidation, utcCalendarDayDistance, } from "../scripts/openclaw-npm-release-check.ts"; +import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH } from "../src/infra/package-dist-inventory.ts"; -const LEGACY_UPDATE_COMPAT_PACKED_PATHS = [ - "dist/extensions/qa-channel/runtime-api.js", - "dist/extensions/qa-lab/runtime-api.js", +const LEGACY_UPDATE_COMPAT_PACKED_PATHS = ["dist/extensions/qa-channel/runtime-api.js"] as const; +const REQUIRED_PACKED_PATHS = [ + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, + ...LEGACY_UPDATE_COMPAT_PACKED_PATHS, + ...WORKSPACE_TEMPLATE_PACK_PATHS, ] as const; describe("parseReleaseVersion", () => { @@ -286,11 +293,7 @@ describe("parseNpmPackJsonOutput", () => { describe("collectControlUiPackErrors", () => { it("rejects packs that ship the dashboard HTML without the asset payload", () => { expect(collectControlUiPackErrors(["dist/control-ui/index.html"])).toEqual([ - ...LEGACY_UPDATE_COMPAT_PACKED_PATHS.map( - (requiredPath) => - `npm package is missing required path "${requiredPath}". Ensure UI assets are built and included before publish.`, - ), - ...WORKSPACE_TEMPLATE_PACK_PATHS.map( + ...REQUIRED_PACKED_PATHS.map( (requiredPath) => `npm package is missing required path "${requiredPath}". Ensure UI assets are built and included before publish.`, ), @@ -302,8 +305,7 @@ describe("collectControlUiPackErrors", () => { expect( collectControlUiPackErrors([ "dist/control-ui/index.html", - ...LEGACY_UPDATE_COMPAT_PACKED_PATHS, - ...WORKSPACE_TEMPLATE_PACK_PATHS, + ...REQUIRED_PACKED_PATHS, "dist/control-ui/assets/index-Bu8rSoJV.js", "dist/control-ui/assets/index-BK0yXA_h.css", ]), @@ -332,20 +334,49 @@ describe("collectForbiddenPackedPathErrors", () => { "dist/extensions/qa-channel/package.json", "dist/extensions/qa-lab/runtime-api.js", "dist/extensions/qa-lab/src/cli.js", + "dist/plugin-sdk/extensions/qa-lab/cli.d.ts", + "dist/qa-runtime-B9LDtssJ.js", + "qa/scenarios/index.md", ]), ).toEqual([ 'npm package must not include private QA channel artifact "dist/extensions/qa-channel/package.json".', + 'npm package must not include private QA lab artifact "dist/extensions/qa-lab/runtime-api.js".', 'npm package must not include private QA lab artifact "dist/extensions/qa-lab/src/cli.js".', + 'npm package must not include private QA lab type artifact "dist/plugin-sdk/extensions/qa-lab/cli.d.ts".', + 'npm package must not include private QA runtime chunk "dist/qa-runtime-B9LDtssJ.js".', + 'npm package must not include private QA suite artifact "qa/scenarios/index.md".', ]); }); - it("allows the legacy update verifier QA runtime sidecars", () => { + it("allows only the legacy update verifier QA channel runtime sidecar", () => { expect( collectForbiddenPackedPathErrors([ "dist/extensions/qa-channel/runtime-api.js", "dist/extensions/qa-lab/runtime-api.js", ]), - ).toEqual([]); + ).toEqual([ + 'npm package must not include private QA lab artifact "dist/extensions/qa-lab/runtime-api.js".', + ]); + }); + + it("rejects root dist chunks that still reference the private qa lab", () => { + const rootDir = mkdtempSync(join(tmpdir(), "openclaw-pack-private-qa-")); + + try { + mkdirSync(join(rootDir, "dist"), { recursive: true }); + writeFileSync( + join(rootDir, "dist", "entry.js"), + "//#region extensions/qa-lab/src/cli.ts\n", + "utf8", + ); + writeFileSync(join(rootDir, "README.md"), "developer docs mention extensions/qa-lab/\n"); + + expect(collectForbiddenPackedContentErrors(["dist/entry.js", "README.md"], rootDir)).toEqual([ + 'npm package must not include private QA lab marker "//#region extensions/qa-lab/" in "dist/entry.js".', + ]); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } }); }); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 99ff7bf2735..2004659a6ff 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -9,13 +9,14 @@ import { collectAppcastSparkleVersionErrors, collectBundledExtensionManifestErrors, collectBundledPluginRootRuntimeMirrorErrors, + collectForbiddenPackContentPaths, collectRootDistBundledRuntimeMirrors, collectForbiddenPackPaths, collectMissingPackPaths, collectPackUnpackedSizeErrors, - listRequiredQaScenarioPackPaths, packageNameFromSpecifier, } from "../scripts/release-check.ts"; +import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH } from "../src/infra/package-dist-inventory.ts"; import { bundledDistPluginFile, bundledPluginFile } from "./helpers/bundled-plugin-paths.js"; function makeItem(shortVersion: string, sparkleVersion: string): string { @@ -28,7 +29,6 @@ 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", () => { @@ -309,6 +309,47 @@ describe("collectForbiddenPackPaths", () => { "dist/plugin-sdk/.tsbuildinfo", ]); }); + + it("blocks private qa lab and suite paths from npm pack output", () => { + expect( + collectForbiddenPackPaths([ + "dist/index.js", + "dist/extensions/qa-lab/runtime-api.js", + "dist/plugin-sdk/extensions/qa-lab/cli.d.ts", + "dist/plugin-sdk/qa-lab.js", + "dist/plugin-sdk/qa-runtime.js", + "dist/qa-runtime-B9LDtssJ.js", + "qa/scenarios/index.md", + ]), + ).toEqual([ + "dist/extensions/qa-lab/runtime-api.js", + "dist/plugin-sdk/extensions/qa-lab/cli.d.ts", + "dist/plugin-sdk/qa-lab.js", + "dist/plugin-sdk/qa-runtime.js", + "dist/qa-runtime-B9LDtssJ.js", + "qa/scenarios/index.md", + ]); + }); + + it("blocks root dist chunks that still reference private qa lab sources", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-release-private-qa-")); + + try { + mkdirSync(join(tempRoot, "dist"), { recursive: true }); + writeFileSync( + join(tempRoot, "dist", "entry.js"), + "//#region extensions/qa-lab/src/runtime-api.ts\n", + "utf8", + ); + writeFileSync(join(tempRoot, "CHANGELOG.md"), "local QA notes mention extensions/qa-lab/\n"); + + expect(collectForbiddenPackContentPaths(["dist/entry.js", "CHANGELOG.md"], tempRoot)).toEqual( + ["dist/entry.js"], + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); describe("collectMissingPackPaths", () => { @@ -326,8 +367,8 @@ describe("collectMissingPackPaths", () => { expect(missing).toEqual( expect.arrayContaining([ "dist/channel-catalog.json", + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, "dist/control-ui/index.html", - "qa/scenarios/index.md", "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", "scripts/postinstall-bundled-plugins.mjs", @@ -343,9 +384,6 @@ 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", () => { @@ -357,7 +395,6 @@ describe("collectMissingPackPaths", () => { "dist/extensions/acpx/mcp-proxy.mjs", bundledDistPluginFile("diffs", "assets/viewer-runtime.js"), ...requiredBundledPluginPackPaths, - ...requiredQaScenarioPackPaths, ...requiredPluginSdkPackPaths, ...WORKSPACE_TEMPLATE_PACK_PATHS, "scripts/npm-runner.mjs", @@ -366,6 +403,7 @@ describe("collectMissingPackPaths", () => { "dist/plugin-sdk/root-alias.cjs", "dist/build-info.json", "dist/channel-catalog.json", + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, ]), ).toEqual([]); }); @@ -381,15 +419,6 @@ 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", () => { diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 3f02e24fd3f..e6b6f0b616f 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -6,6 +6,7 @@ import { pruneInstalledPackageDist, discoverBundledPluginRuntimeDeps, pruneBundledPluginSourceNodeModules, + restoreLegacyUpdaterCompatSidecars, runBundledPluginPostinstall, } from "../../scripts/postinstall-bundled-plugins.mjs"; import { writePackageDistInventory } from "../../src/infra/package-dist-inventory.ts"; @@ -214,6 +215,63 @@ describe("bundled plugin postinstall", () => { await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("restores only postinstall-generated QA lab compat sidecar after pruning old installs", async () => { + const packageRoot = await createTempDirAsync("openclaw-packaged-install-qa-compat-"); + const currentFile = path.join(packageRoot, "dist", "entry.js"); + const stalePackage = path.join(packageRoot, "dist", "extensions", "qa-lab", "package.json"); + const staleManifest = path.join( + packageRoot, + "dist", + "extensions", + "qa-lab", + "openclaw.plugin.json", + ); + await fs.mkdir(path.dirname(stalePackage), { recursive: true }); + await fs.writeFile(currentFile, "export {};\n"); + await writePackageDistInventory(packageRoot); + await fs.writeFile(stalePackage, "{}\n"); + await fs.writeFile(staleManifest, "{}\n"); + + runBundledPluginPostinstall({ + packageRoot, + spawnSync: vi.fn(), + log: { log: vi.fn(), warn: vi.fn() }, + }); + + await expect(fs.stat(stalePackage)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(staleManifest)).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + fs.readFile(path.join(packageRoot, "dist", "extensions", "qa-lab", "runtime-api.js"), "utf8"), + ).resolves.toContain("QA Lab is not packaged"); + }); + + it("creates only an empty QA lab compat sidecar for fresh installs", async () => { + const packageRoot = await createTempDirAsync("openclaw-packaged-install-no-qa-compat-"); + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await fs.writeFile(path.join(packageRoot, "dist", "entry.js"), "export {};\n"); + await writePackageDistInventory(packageRoot); + + expect( + restoreLegacyUpdaterCompatSidecars({ + packageRoot, + removedFiles: ["dist/entry-old.js"], + log: { log: vi.fn(), warn: vi.fn() }, + }), + ).toEqual(["dist/extensions/qa-lab/runtime-api.js"]); + + await expect( + fs.readFile(path.join(packageRoot, "dist", "extensions", "qa-lab", "runtime-api.js"), "utf8"), + ).resolves.toBe( + "// Compatibility stub for older OpenClaw updaters. QA Lab is not packaged.\nexport {};\n", + ); + await expect( + fs.stat(path.join(packageRoot, "dist", "extensions", "qa-lab", "package.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + fs.stat(path.join(packageRoot, "dist", "extensions", "qa-lab", "openclaw.plugin.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("keeps packaged postinstall non-fatal when the dist inventory is missing", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-missing-inventory-"); const staleFile = path.join(packageRoot, "dist", "channel-CJUAgRQR.js"); diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 0c04ca6b790..74f95227a5b 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -53,6 +53,13 @@ describe("test-install-sh-docker", () => { expect(script).toContain('from "./scripts/lib/npm-pack-budget.mjs"'); expect(script).toContain("install smoke cannot verify pack budget"); }); + + it("writes the package dist inventory before packing ignore-scripts tarballs", () => { + const script = readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain("node --import tsx scripts/write-package-dist-inventory.ts"); + expect(script).toContain("quiet_npm pack --ignore-scripts"); + }); }); describe("install-sh smoke runner", () => { diff --git a/tsdown.config.ts b/tsdown.config.ts index fe8336674b6..0d7973cca81 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -4,6 +4,7 @@ import { defineConfig, type UserConfig } from "tsdown"; import { collectBundledPluginBuildEntries, listBundledPluginRuntimeDependencies, + NON_PACKAGED_BUNDLED_PLUGIN_DIRS, } from "./scripts/lib/bundled-plugin-build-entries.mjs"; import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs"; @@ -92,6 +93,7 @@ function nodeBuildConfig(config: UserConfig): UserConfig { const bundledPluginBuildEntries = collectBundledPluginBuildEntries(); const bundledPluginRuntimeDependencies = listBundledPluginRuntimeDependencies(); +const shouldBuildPrivateQaEntries = process.env.OPENCLAW_BUILD_PRIVATE_QA === "1"; function buildBundledHookEntries(): Record { const hooksRoot = path.join(process.cwd(), "src", "hooks", "bundled"); @@ -235,7 +237,9 @@ const stagedBundledPluginBuildEntries = bundledPluginBuildEntries.filter(({ pack shouldStageBundledPluginRuntimeDependencies(packageJson), ); const rootBundledPluginBuildEntries = bundledPluginBuildEntries.filter( - ({ packageJson }) => !shouldStageBundledPluginRuntimeDependencies(packageJson), + ({ id, packageJson }) => + !shouldStageBundledPluginRuntimeDependencies(packageJson) && + (shouldBuildPrivateQaEntries || !NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(id)), ); function buildUnifiedDistEntries(): Record { @@ -249,6 +253,12 @@ function buildUnifiedDistEntries(): Record { source, ]), ), + ...(shouldBuildPrivateQaEntries + ? { + "plugin-sdk/qa-lab": "src/plugin-sdk/qa-lab.ts", + "plugin-sdk/qa-runtime": "src/plugin-sdk/qa-runtime.ts", + } + : {}), ...listBundledPluginEntrySources(rootBundledPluginBuildEntries), ...bundledHookEntries, };