From 5dce1a8237f2cd594890c5654cd31a23994d3c8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 14:06:50 -0700 Subject: [PATCH] test(infra): canonicalize run-node project fixtures (#117630) --- src/infra/run-node.test.ts | 4024 ++++++++++++++---------------------- 1 file changed, 1601 insertions(+), 2423 deletions(-) diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 213b07fb2642..4b8a07b4612e 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -9,7 +9,7 @@ import { bundledPluginFile, bundledPluginRoot, } from "openclaw/plugin-sdk/test-fixtures"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it as baseIt, vi } from "vitest"; import { copyBundledPluginMetadata } from "../../scripts/copy-bundled-plugin-metadata.mjs"; import { BUILD_STAMP_FILE, @@ -23,6 +23,12 @@ import { } from "../../scripts/run-node.mjs"; import { withTempDir } from "../test-helpers/temp-dir.js"; +const it = baseIt.extend<{ tmp: string }>({ + tmp: async ({ task: _task }, use) => { + await withTempDir({ prefix: "openclaw-run-node-" }, use); + }, +}); + const ROOT_SRC = "src/index.ts"; const ROOT_TSCONFIG = "tsconfig.json"; const ROOT_PACKAGE = "package.json"; @@ -230,6 +236,35 @@ async function setupTrackedProject( await touchProjectFiles(tmp, options.newPaths ?? [], NEW_TIME); } +async function setupStampedProject( + tmp: string, + options: { + files?: Record; + oldPaths?: string[]; + newPaths?: string[]; + rootSource?: boolean; + trackConfig?: boolean; + }, +): Promise { + const files = { + ...(options.rootSource === false ? {} : { [ROOT_SRC]: "export const value = 1;\n" }), + ...options.files, + }; + const excludedPaths = new Set([...(options.oldPaths ?? []), ...(options.newPaths ?? [])]); + const buildPaths = [ + ...Object.keys(files).filter((filePath) => !excludedPaths.has(filePath)), + ...(options.trackConfig ? [ROOT_TSCONFIG, ROOT_PACKAGE] : []), + DIST_ENTRY, + BUILD_STAMP, + ]; + await setupTrackedProject(tmp, { + files, + ...(options.oldPaths ? { oldPaths: options.oldPaths } : {}), + buildPaths, + ...(options.newPaths ? { newPaths: options.newPaths } : {}), + }); +} + function createSpawnRecorder( options: { gitHead?: string; @@ -253,18 +288,18 @@ function createSpawnRecorder( return { spawnCalls, spawn, spawnSync }; } +function createCurrentGitSpawnRecorder(options: { gitHead?: string; gitStatus?: string } = {}) { + return createSpawnRecorder({ gitHead: "abc123\n", gitStatus: "", ...options }); +} + function createBuildRequirementDeps( tmp: string, options: { - gitHead?: string; gitStatus?: string; env?: Record; } = {}, ) { - const { spawnSync } = createSpawnRecorder({ - gitHead: options.gitHead, - gitStatus: options.gitStatus, - }); + const { spawnSync } = createCurrentGitSpawnRecorder({ gitStatus: options.gitStatus ?? "" }); return { cwd: tmp, env: { @@ -289,35 +324,25 @@ function createBuildRequirementDeps( }; } -async function runStatusCommand(params: { - tmp: string; - spawn: (cmd: string, args: string[]) => ReturnType; - spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string }; - env?: Record; - runRuntimePostBuild?: (params?: { - cwd?: string; - env?: Record; - }) => void | Promise; -}) { +type RunNodeTestOptions = NonNullable[0]> & { + stdout?: NodeJS.WriteStream; +}; + +async function runNodeCommand(tmp: string, options: RunNodeTestOptions): Promise { + const { env, ...overrides } = options; return await runNodeMain({ - cwd: params.tmp, + cwd: tmp, args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - ...params.env, - }, - spawn: params.spawn, - ...(params.spawnSync ? { spawnSync: params.spawnSync } : {}), - ...(params.runRuntimePostBuild ? { runRuntimePostBuild: params.runRuntimePostBuild } : {}), + ...overrides, + env: { ...process.env, OPENCLAW_RUNNER_LOG: "0", ...env }, execPath: process.execPath, - platform: process.platform, - }); + platform: options.platform ?? process.platform, + } as RunNodeTestOptions); } -async function runGatewayClientCommand(params: { +type RunCommandParams = { tmp: string; - args: string[]; + args?: string[]; spawn: (cmd: string, args: string[]) => ReturnType; spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string }; env?: Record; @@ -325,46 +350,16 @@ async function runGatewayClientCommand(params: { cwd?: string; env?: Record; }) => void | Promise; -}) { - return await runNodeMain({ - cwd: params.tmp, - args: params.args, - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - ...params.env, - }, - spawn: params.spawn, - ...(params.spawnSync ? { spawnSync: params.spawnSync } : {}), - ...(params.runRuntimePostBuild ? { runRuntimePostBuild: params.runRuntimePostBuild } : {}), - execPath: process.execPath, - platform: process.platform, - }); +}; + +async function runStatusCommand({ tmp, ...options }: RunCommandParams): Promise { + return await runNodeCommand(tmp, options); } -async function runQaCommand(params: { - tmp: string; - spawn: (cmd: string, args: string[]) => ReturnType; - spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string }; - env?: Record; - runRuntimePostBuild?: (params?: { - cwd?: string; - env?: Record; - }) => void | Promise; -}) { - return await runNodeMain({ - cwd: params.tmp, +async function runQaCommand(params: RunCommandParams): Promise { + return await runStatusCommand({ + ...params, args: ["qa", "suite", "--transport", "qa-channel", "--provider-mode", "mock-openai"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - ...params.env, - }, - spawn: params.spawn, - ...(params.spawnSync ? { spawnSync: params.spawnSync } : {}), - ...(params.runRuntimePostBuild ? { runRuntimePostBuild: params.runRuntimePostBuild } : {}), - execPath: process.execPath, - platform: process.platform, }); } @@ -378,1172 +373,914 @@ async function expectManifestId(tmp: string, relativePath: string, id: string) { describe("run-node script", () => { it.runIf(process.platform !== "win32")( "preserves control-ui assets by building with tsdown --no-clean", - async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - const argsPath = resolvePath(tmp, ".build-args.txt"); - const indexPath = resolvePath(tmp, "dist/control-ui/index.html"); + async ({ tmp }) => { + const argsPath = resolvePath(tmp, ".build-args.txt"); + const indexPath = resolvePath(tmp, "dist/control-ui/index.html"); - await writeRuntimePostBuildScaffold(tmp); - await fs.mkdir(path.dirname(indexPath), { recursive: true }); - await fs.writeFile(indexPath, "sentinel\n", "utf-8"); + await writeRuntimePostBuildScaffold(tmp); + await fs.mkdir(path.dirname(indexPath), { recursive: true }); + await fs.writeFile(indexPath, "sentinel\n", "utf-8"); - const nodeCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") { - fsSync.writeFileSync(argsPath, args.join(" "), "utf-8"); - if (!args.includes("--no-clean")) { - fsSync.rmSync(resolvePath(tmp, "dist/control-ui"), { recursive: true, force: true }); - } + const nodeCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") { + fsSync.writeFileSync(argsPath, args.join(" "), "utf-8"); + if (!args.includes("--no-clean")) { + fsSync.rmSync(resolvePath(tmp, "dist/control-ui"), { recursive: true, force: true }); } - if (cmd === process.execPath) { - nodeCalls.push([cmd, ...args]); - } - return createExitedProcess(0); - }; + } + if (cmd === process.execPath) { + nodeCalls.push([cmd, ...args]); + } + return createExitedProcess(0); + }; - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["--version"], - env: { - ...process.env, - OPENCLAW_FORCE_BUILD: "1", - OPENCLAW_RUNNER_LOG: "0", - }, - spawn, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - }); - - expect(exitCode).toBe(0); - await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain( - "scripts/tsdown-build.mjs --no-clean", - ); - await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel"); - expect(nodeCalls).toEqual([ - [process.execPath, "scripts/bundled-plugin-assets.mjs", "--phase", "build"], - [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"], - [process.execPath, "openclaw.mjs", "--version"], - ]); + const exitCode = await runNodeCommand(tmp, { + args: ["--version"], + env: { OPENCLAW_FORCE_BUILD: "1" }, + spawn, + runRuntimePostBuild: skipRuntimePostBuild, }); + + expect(exitCode).toBe(0); + await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain( + "scripts/tsdown-build.mjs --no-clean", + ); + await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel"); + expect(nodeCalls).toEqual([ + [process.execPath, "scripts/bundled-plugin-assets.mjs", "--phase", "build"], + [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"], + [process.execPath, "openclaw.mjs", "--version"], + ]); }, ); - it("copies bundled plugin metadata after rebuilding from a clean dist", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await writeRuntimePostBuildScaffold(tmp); - await writeProjectFiles(tmp, { - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [EXTENSION_PACKAGE]: - JSON.stringify( - { - name: "demo", - openclaw: { - extensions: ["./src/index.ts", "./nested/entry.mts"], - }, + it("copies bundled plugin metadata after rebuilding from a clean dist", async ({ tmp }) => { + await writeRuntimePostBuildScaffold(tmp); + await writeProjectFiles(tmp, { + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: + JSON.stringify( + { + name: "demo", + openclaw: { + extensions: ["./src/index.ts", "./nested/entry.mts"], }, - null, - 2, - ) + "\n", - }); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - - const exitCode = await runStatusCommand({ - tmp, - spawn, - env: { OPENCLAW_FORCE_BUILD: "1" }, - runRuntimePostBuild: syncBundledPluginMetadata, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBundledPluginAssetBuildSpawn(), - expectedBuildSpawn(), - statusCommandSpawn(), - ]); - - await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo"); - await expect( - fs.readFile(resolvePath(tmp, DIST_EXTENSION_PACKAGE), "utf-8"), - ).resolves.toContain( - '"extensions": [\n "./src/index.js",\n "./nested/entry.js"\n ]', - ); + }, + null, + 2, + ) + "\n", }); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + + const exitCode = await runStatusCommand({ + tmp, + spawn, + env: { OPENCLAW_FORCE_BUILD: "1" }, + runRuntimePostBuild: syncBundledPluginMetadata, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBundledPluginAssetBuildSpawn(), + expectedBuildSpawn(), + statusCommandSpawn(), + ]); + + await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo"); + await expect(fs.readFile(resolvePath(tmp, DIST_EXTENSION_PACKAGE), "utf-8")).resolves.toContain( + '"extensions": [\n "./src/index.js",\n "./nested/entry.js"\n ]', + ); }); - it("skips DTS generation only for launcher-triggered local builds", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await writeRuntimePostBuildScaffold(tmp); - const spawnCalls: Array<{ - args: string[]; - env: Record; - }> = []; - const spawn = (_cmd: string, args: string[], options?: unknown) => { - const opts = options as { env?: NodeJS.ProcessEnv } | undefined; - spawnCalls.push({ - args, - env: { ...opts?.env }, - }); - return createExitedProcess(0); - }; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_FORCE_BUILD: "1", - OPENCLAW_RUNNER_LOG: "0", - }, - spawn, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, + it("skips DTS generation only for launcher-triggered local builds", async ({ tmp }) => { + await writeRuntimePostBuildScaffold(tmp); + const spawnCalls: Array<{ + args: string[]; + env: Record; + }> = []; + const spawn = (_cmd: string, args: string[], options?: unknown) => { + const opts = options as { env?: NodeJS.ProcessEnv } | undefined; + spawnCalls.push({ + args, + env: { ...opts?.env }, }); + return createExitedProcess(0); + }; - expect(exitCode).toBe(0); - expect(spawnCalls).toHaveLength(3); - expect(spawnCalls[0]?.args).toEqual([ - "scripts/bundled-plugin-assets.mjs", - "--phase", - "build", - ]); - expect(spawnCalls[1]?.args).toEqual(["scripts/tsdown-build.mjs", "--no-clean"]); - expect(spawnCalls[2]?.args).toEqual(["openclaw.mjs", "status"]); - expect(spawnCalls[0]?.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD).toBeUndefined(); - expect(spawnCalls[1]?.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD).toBe("1"); - expect(spawnCalls[2]?.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD).toBeUndefined(); + const exitCode = await runNodeCommand(tmp, { + env: { OPENCLAW_FORCE_BUILD: "1" }, + spawn, + runRuntimePostBuild: skipRuntimePostBuild, }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toHaveLength(3); + expect(spawnCalls[0]?.args).toEqual(["scripts/bundled-plugin-assets.mjs", "--phase", "build"]); + expect(spawnCalls[1]?.args).toEqual(["scripts/tsdown-build.mjs", "--no-clean"]); + expect(spawnCalls[2]?.args).toEqual(["openclaw.mjs", "status"]); + expect(spawnCalls[0]?.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD).toBeUndefined(); + expect(spawnCalls[1]?.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD).toBe("1"); + expect(spawnCalls[2]?.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD).toBeUndefined(); }); - it("tees launcher output into the requested generic output log", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp); - const outputPath = path.join(tmp, ".artifacts", "qa-e2e", "matrix", "output.log"); - const spawnCalls: Array<{ - args: string[]; - env: Record; - stdio: unknown; - }> = []; - const spawn = (_cmd: string, args: string[], options?: unknown) => { - const opts = options as { env?: NodeJS.ProcessEnv; stdio?: unknown } | undefined; - spawnCalls.push({ - args, - env: { ...opts?.env }, - stdio: opts?.stdio, - }); + it("tees launcher output into the requested generic output log", async ({ tmp }) => { + await setupTrackedProject(tmp); + const outputPath = path.join(tmp, ".artifacts", "qa-e2e", "matrix", "output.log"); + const spawnCalls: Array<{ + args: string[]; + env: Record; + stdio: unknown; + }> = []; + const spawn = (_cmd: string, args: string[], options?: unknown) => { + const opts = options as { env?: NodeJS.ProcessEnv; stdio?: unknown } | undefined; + spawnCalls.push({ + args, + env: { ...opts?.env }, + stdio: opts?.stdio, + }); + return createPipedExitedProcess({ + stdout: args[0] === "openclaw.mjs" ? "child stdout\n" : "", + stderr: args[0] === "openclaw.mjs" ? "child stderr\n" : "", + }); + }; + const mutedStream = { + write: () => true, + } as unknown as NodeJS.WriteStream; + + const exitCode = await runNodeCommand(tmp, { + env: { + OPENCLAW_FORCE_BUILD: "1", + OPENCLAW_RUNNER_LOG: "1", + OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath, + }, + spawn, + stderr: mutedStream, + stdout: mutedStream, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain("child stdout\n"); + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain("child stderr\n"); + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain("[openclaw]"); + expect(spawnCalls.at(-1)?.args).toEqual(["openclaw.mjs", "status"]); + expect(spawnCalls.at(-1)?.env.OPENCLAW_RUN_NODE_OUTPUT_LOG).toBe(outputPath); + expect(spawnCalls.at(-1)?.stdio).toEqual(["inherit", "pipe", "pipe"]); + }); + + it("routes local build stdout to stderr before JSON command output", async ({ tmp }) => { + await writeRuntimePostBuildScaffold(tmp); + const outputPath = path.join(tmp, ".artifacts", "run-node", "output.log"); + const spawn = (_cmd: string, args: string[]) => { + if (args[0] === "scripts/bundled-plugin-assets.mjs") { return createPipedExitedProcess({ - stdout: args[0] === "openclaw.mjs" ? "child stdout\n" : "", - stderr: args[0] === "openclaw.mjs" ? "child stderr\n" : "", + stdout: "asset stdout\n", + stderr: "asset stderr\n", }); - }; - const mutedStream = { - write: () => true, - } as unknown as NodeJS.WriteStream; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_FORCE_BUILD: "1", - OPENCLAW_RUNNER_LOG: "1", - OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath, - }, - spawn, - stderr: mutedStream, - stdout: mutedStream, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - } as Parameters[0] & { stdout: NodeJS.WriteStream }); - - expect(exitCode).toBe(0); - await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain("child stdout\n"); - await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain("child stderr\n"); - await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain("[openclaw]"); - expect(spawnCalls.at(-1)?.args).toEqual(["openclaw.mjs", "status"]); - expect(spawnCalls.at(-1)?.env.OPENCLAW_RUN_NODE_OUTPUT_LOG).toBe(outputPath); - expect(spawnCalls.at(-1)?.stdio).toEqual(["inherit", "pipe", "pipe"]); - }); - }); - - it("routes local build stdout to stderr before JSON command output", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await writeRuntimePostBuildScaffold(tmp); - const outputPath = path.join(tmp, ".artifacts", "run-node", "output.log"); - const spawn = (_cmd: string, args: string[]) => { - if (args[0] === "scripts/bundled-plugin-assets.mjs") { - return createPipedExitedProcess({ - stdout: "asset stdout\n", - stderr: "asset stderr\n", - }); - } - if (args[0] === "scripts/tsdown-build.mjs") { - return createPipedExitedProcess({ - stdout: "build stdout\n", - stderr: "build stderr\n", - }); - } - return createPipedExitedProcess({ stdout: '{"plugins":[]}\n' }); - }; - const stdoutChunks: string[] = []; - const stderrChunks: string[] = []; - const stdout = { - write: (chunk: string | Buffer) => { - stdoutChunks.push(String(chunk)); - return true; - }, - } as unknown as NodeJS.WriteStream; - const stderr = { - write: (chunk: string | Buffer) => { - stderrChunks.push(String(chunk)); - return true; - }, - } as unknown as NodeJS.WriteStream; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["plugins", "list", "--json"], - env: { - ...process.env, - OPENCLAW_FORCE_BUILD: "1", - OPENCLAW_RUNNER_LOG: "0", - OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath, - }, - spawn, - stdout, - stderr, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - } as Parameters[0] & { - stdout: NodeJS.WriteStream; - stderr: NodeJS.WriteStream; - }); - - expect(exitCode).toBe(0); - expect(stdoutChunks.join("")).toBe('{"plugins":[]}\n'); - expect(stderrChunks.join("")).toContain("asset stdout\n"); - expect(stderrChunks.join("")).toContain("asset stderr\n"); - expect(stderrChunks.join("")).toContain("build stdout\n"); - expect(stderrChunks.join("")).toContain("build stderr\n"); - }); - }); - - it("routes sync I/O trace stderr blocks to the output log without flooding stderr", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp); - const outputPath = path.join(tmp, ".artifacts", "gateway-watch-profiles", "output.log"); - const childStderr = [ - "normal before\n", - "(node:12345) WARNING: Detected use of sync API\n", - " at statSync (node:fs:1739:25)\n", - " at loadConfig (/repo/src/config.ts:1:1)\n", - "\n", - "normal after\n", - ].join(""); - const spawn = (_cmd: string, args: string[]) => - createPipedExitedProcess({ - stderr: args[0] === "openclaw.mjs" ? childStderr : "", - }); - const stderrChunks: string[] = []; - const stderr = { - write: (chunk: string | Buffer) => { - stderrChunks.push(String(chunk)); - return true; - }, - } as unknown as NodeJS.WriteStream; - const stdout = { - write: () => true, - } as unknown as NodeJS.WriteStream; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR: "1", - OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath, - }, - spawn, - stderr, - stdout, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - } as Parameters[0] & { stdout: NodeJS.WriteStream }); - - expect(exitCode).toBe(0); - const terminalStderr = stderrChunks.join(""); - expect(terminalStderr).toContain("normal before\n"); - expect(terminalStderr).toContain("normal after\n"); - expect(terminalStderr).not.toContain("Detected use of sync API"); - expect(terminalStderr).not.toContain("statSync"); - await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain(childStderr); - }); - }); - - it("adds Node CPU profiling flags to the launched OpenClaw child when requested", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [DIST_CHANNEL_CATALOG]: '{"entries":[]}\n', - [DIST_LEGACY_CLI_EXIT_COMPAT]: "export function hasMemoryRuntime() { return false; }\n", - [DIST_LEGACY_CLI_EXIT_COMPAT_ALT]: - "export function hasMemoryRuntime() { return false; }\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [ - DIST_ENTRY, - DIST_CHANNEL_CATALOG, - DIST_LEGACY_CLI_EXIT_COMPAT, - DIST_LEGACY_CLI_EXIT_COMPAT_ALT, - BUILD_STAMP, - ], - }); - const profileDir = path.join(tmp, ".artifacts", "profiles"); - const spawnCalls: Array<{ args: string[]; env: Record }> = []; - const spawn = (_cmd: string, args: string[], options?: unknown) => { - const opts = options as { env?: NodeJS.ProcessEnv } | undefined; - spawnCalls.push({ args, env: { ...opts?.env } }); - return createExitedProcess(0); - }; - const { spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - OPENCLAW_RUN_NODE_CPU_PROF_DIR: ".artifacts/profiles", - }, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - process: createFakeProcess(), - }); - - expect(exitCode).toBe(0); - const childArgs = spawnCalls.at(-1)?.args ?? []; - expect(childArgs[0]).toBe("--cpu-prof"); - expect(childArgs[1]).toBe(`--cpu-prof-dir=${profileDir}`); - expect(childArgs[2]).toMatch( - /^--cpu-prof-name=openclaw-status-4242-\d{4}-\d{2}-\d{2}T.*\.cpuprofile$/, - ); - expect(childArgs.slice(3)).toEqual(["openclaw.mjs", "status"]); - expect(spawnCalls.at(-1)?.env.OPENCLAW_RUN_NODE_CPU_PROF_DIR).toBe(profileDir); - expect(fsSync.existsSync(profileDir)).toBe(true); - }); - }); - - it("rotates old Node CPU profiles when a retention cap is set", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - const profileDir = path.join(tmp, ".artifacts", "profiles"); - fsSync.mkdirSync(profileDir, { recursive: true }); - const oldProfiles = [ - "openclaw-status-oldest.cpuprofile", - "openclaw-status-middle.cpuprofile", - "openclaw-status-newest.cpuprofile", - ]; - for (const [index, name] of oldProfiles.entries()) { - const filePath = path.join(profileDir, name); - fsSync.writeFileSync(filePath, "{}"); - const mtime = new Date(1_700_000_000_000 + index * 1000); - fsSync.utimesSync(filePath, mtime, mtime); } - fsSync.writeFileSync(path.join(profileDir, "openclaw-models-old.cpuprofile"), "{}"); + if (args[0] === "scripts/tsdown-build.mjs") { + return createPipedExitedProcess({ + stdout: "build stdout\n", + stderr: "build stderr\n", + }); + } + return createPipedExitedProcess({ stdout: '{"plugins":[]}\n' }); + }; + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const stdout = { + write: (chunk: string | Buffer) => { + stdoutChunks.push(String(chunk)); + return true; + }, + } as unknown as NodeJS.WriteStream; + const stderr = { + write: (chunk: string | Buffer) => { + stderrChunks.push(String(chunk)); + return true; + }, + } as unknown as NodeJS.WriteStream; - const spawn = () => createExitedProcess(0); - const { spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", + const exitCode = await runNodeCommand(tmp, { + args: ["plugins", "list", "--json"], + env: { OPENCLAW_FORCE_BUILD: "1", OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath }, + spawn, + stdout, + stderr, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(stdoutChunks.join("")).toBe('{"plugins":[]}\n'); + expect(stderrChunks.join("")).toContain("asset stdout\n"); + expect(stderrChunks.join("")).toContain("asset stderr\n"); + expect(stderrChunks.join("")).toContain("build stdout\n"); + expect(stderrChunks.join("")).toContain("build stderr\n"); + }); + + it("routes sync I/O trace stderr blocks to the output log without flooding stderr", async ({ + tmp, + }) => { + await setupTrackedProject(tmp); + const outputPath = path.join(tmp, ".artifacts", "gateway-watch-profiles", "output.log"); + const childStderr = [ + "normal before\n", + "(node:12345) WARNING: Detected use of sync API\n", + " at statSync (node:fs:1739:25)\n", + " at loadConfig (/repo/src/config.ts:1:1)\n", + "\n", + "normal after\n", + ].join(""); + const spawn = (_cmd: string, args: string[]) => + createPipedExitedProcess({ + stderr: args[0] === "openclaw.mjs" ? childStderr : "", }); + const stderrChunks: string[] = []; + const stderr = { + write: (chunk: string | Buffer) => { + stderrChunks.push(String(chunk)); + return true; + }, + } as unknown as NodeJS.WriteStream; + const stdout = { + write: () => true, + } as unknown as NodeJS.WriteStream; - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - OPENCLAW_RUN_NODE_CPU_PROF_DIR: ".artifacts/profiles", - OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES: "2", - }, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - process: createFakeProcess(), - }); + const exitCode = await runNodeCommand(tmp, { + env: { + OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR: "1", + OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath, + }, + spawn, + stderr, + stdout, + runRuntimePostBuild: skipRuntimePostBuild, + }); - expect(exitCode).toBe(0); - expect( - fsSync.existsSync( - path.join(profileDir, expectDefined(oldProfiles[0], "oldProfiles[0] test invariant")), - ), - ).toBe(false); - expect( - fsSync.existsSync( - path.join(profileDir, expectDefined(oldProfiles[1], "oldProfiles[1] test invariant")), - ), - ).toBe(false); - expect( - fsSync.existsSync( - path.join(profileDir, expectDefined(oldProfiles[2], "oldProfiles[2] test invariant")), - ), - ).toBe(true); - expect(fsSync.existsSync(path.join(profileDir, "openclaw-models-old.cpuprofile"))).toBe(true); + expect(exitCode).toBe(0); + const terminalStderr = stderrChunks.join(""); + expect(terminalStderr).toContain("normal before\n"); + expect(terminalStderr).toContain("normal after\n"); + expect(terminalStderr).not.toContain("Detected use of sync API"); + expect(terminalStderr).not.toContain("statSync"); + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain(childStderr); + }); + + it("adds Node CPU profiling flags to the launched OpenClaw child when requested", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [DIST_CHANNEL_CATALOG]: '{"entries":[]}\n', + [DIST_LEGACY_CLI_EXIT_COMPAT]: "export function hasMemoryRuntime() { return false; }\n", + [DIST_LEGACY_CLI_EXIT_COMPAT_ALT]: "export function hasMemoryRuntime() { return false; }\n", + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + }); + const profileDir = path.join(tmp, ".artifacts", "profiles"); + const spawnCalls: Array<{ args: string[]; env: Record }> = []; + const spawn = (_cmd: string, args: string[], options?: unknown) => { + const opts = options as { env?: NodeJS.ProcessEnv } | undefined; + spawnCalls.push({ args, env: { ...opts?.env } }); + return createExitedProcess(0); + }; + const { spawnSync } = createCurrentGitSpawnRecorder(); + + const exitCode = await runNodeCommand(tmp, { + env: { OPENCLAW_RUN_NODE_CPU_PROF_DIR: ".artifacts/profiles" }, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, + process: createFakeProcess(), + }); + + expect(exitCode).toBe(0); + const childArgs = spawnCalls.at(-1)?.args ?? []; + expect(childArgs[0]).toBe("--cpu-prof"); + expect(childArgs[1]).toBe(`--cpu-prof-dir=${profileDir}`); + expect(childArgs[2]).toMatch( + /^--cpu-prof-name=openclaw-status-4242-\d{4}-\d{2}-\d{2}T.*\.cpuprofile$/, + ); + expect(childArgs.slice(3)).toEqual(["openclaw.mjs", "status"]); + expect(spawnCalls.at(-1)?.env.OPENCLAW_RUN_NODE_CPU_PROF_DIR).toBe(profileDir); + expect(fsSync.existsSync(profileDir)).toBe(true); + }); + + it("rotates old Node CPU profiles when a retention cap is set", async ({ tmp }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); + const profileDir = path.join(tmp, ".artifacts", "profiles"); + fsSync.mkdirSync(profileDir, { recursive: true }); + const oldProfiles = [ + "openclaw-status-oldest.cpuprofile", + "openclaw-status-middle.cpuprofile", + "openclaw-status-newest.cpuprofile", + ]; + for (const [index, name] of oldProfiles.entries()) { + const filePath = path.join(profileDir, name); + fsSync.writeFileSync(filePath, "{}"); + const mtime = new Date(1_700_000_000_000 + index * 1000); + fsSync.utimesSync(filePath, mtime, mtime); + } + fsSync.writeFileSync(path.join(profileDir, "openclaw-models-old.cpuprofile"), "{}"); + + const spawn = () => createExitedProcess(0); + const { spawnSync } = createCurrentGitSpawnRecorder(); + + const exitCode = await runNodeCommand(tmp, { + env: { + OPENCLAW_RUN_NODE_CPU_PROF_DIR: ".artifacts/profiles", + OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES: "2", + }, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, + process: createFakeProcess(), + }); + + expect(exitCode).toBe(0); + expect( + fsSync.existsSync( + path.join(profileDir, expectDefined(oldProfiles[0], "oldProfiles[0] test invariant")), + ), + ).toBe(false); + expect( + fsSync.existsSync( + path.join(profileDir, expectDefined(oldProfiles[1], "oldProfiles[1] test invariant")), + ), + ).toBe(false); + expect( + fsSync.existsSync( + path.join(profileDir, expectDefined(oldProfiles[2], "oldProfiles[2] test invariant")), + ), + ).toBe(true); + expect(fsSync.existsSync(path.join(profileDir, "openclaw-models-old.cpuprofile"))).toBe(true); + }); + + it("adds Node sync I/O tracing flag to the launched OpenClaw child when requested", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); + const spawnCalls: string[][] = []; + const spawn = (_cmd: string, args: string[]) => { + spawnCalls.push(args); + return createExitedProcess(0); + }; + const { spawnSync } = createCurrentGitSpawnRecorder(); + + const exitCode = await runNodeCommand(tmp, { + args: ["gateway", "--force"], + env: { OPENCLAW_TRACE_SYNC_IO: "1" }, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls.at(-1)).toEqual(["--trace-sync-io", "openclaw.mjs", "gateway", "--force"]); + }); + + it("surfaces generic output log stream errors", async ({ tmp }) => { + await setupTrackedProject(tmp); + const outputPath = path.join(tmp, ".artifacts", "qa-e2e", "matrix", "output.log"); + await fs.mkdir(outputPath, { recursive: true }); + const spawn = () => createPipedExitedProcess({ stdout: "child stdout\n" }); + const stderrChunks: string[] = []; + const mutedStream = { + write: (chunk: string | Buffer) => { + stderrChunks.push(String(chunk)); + return true; + }, + } as unknown as NodeJS.WriteStream; + + const exitCode = await runNodeCommand(tmp, { + env: { OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath }, + spawn, + stderr: mutedStream, + stdout: mutedStream, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(1); + expect(stderrChunks.join("")).toContain("Failed to write output log"); + }); + + it("does not mutate Matrix QA args when no generic output log is requested", async ({ tmp }) => { + await setupTrackedProject(tmp); + const spawnCalls: Array<{ args: string[]; env: Record }> = []; + const spawn = (_cmd: string, args: string[], options?: unknown) => { + const opts = options as { env?: NodeJS.ProcessEnv } | undefined; + spawnCalls.push({ args, env: { ...opts?.env } }); + return createPipedExitedProcess({}); + }; + const mutedStream = { + write: () => true, + } as unknown as NodeJS.WriteStream; + + const exitCode = await runNodeCommand(tmp, { + args: ["qa", "matrix"], + spawn, + stderr: mutedStream, + stdout: mutedStream, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + const childArgs = spawnCalls.at(-1)?.args ?? []; + expect(childArgs).toEqual(["openclaw.mjs", "qa", "matrix"]); + expect(spawnCalls.at(-1)?.env.OPENCLAW_RUN_NODE_OUTPUT_LOG).toBeUndefined(); + }); + + it("skips rebuilding when dist is current and the source tree is clean", async ({ tmp }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); + + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + }); + + it("skips rebuilding for private QA commands when the private QA facades are present", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n", + [QA_RUNTIME_PLUGIN_SDK_ENTRY]: "export const qaRuntime = true;\n", + }, + oldPaths: [ + ROOT_SRC, + ROOT_TSCONFIG, + ROOT_PACKAGE, + QA_LAB_PLUGIN_SDK_ENTRY, + QA_RUNTIME_PLUGIN_SDK_ENTRY, + ], + }); + + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runQaCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + [ + process.execPath, + "openclaw.mjs", + "qa", + "suite", + "--transport", + "qa-channel", + "--provider-mode", + "mock-openai", + ], + ]); + }); + + it("rebuilds private QA commands when the private QA runtime facade is missing", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n" }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY], + }); + + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runQaCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBundledPluginAssetBuildSpawn(), + expectedBuildSpawn(), + [ + process.execPath, + "openclaw.mjs", + "qa", + "suite", + "--transport", + "qa-channel", + "--provider-mode", + "mock-openai", + ], + ]); + }); + + it("passes the synthesized private QA env into runtime postbuild staging", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n" }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY], + }); + + const runRuntimePostBuild = vi.fn(); + const { spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runQaCommand({ tmp, spawn, spawnSync, runRuntimePostBuild }); + + expect(exitCode).toBe(0); + expect(runRuntimePostBuild).toHaveBeenCalledTimes(1); + const postBuildParams = firstMockCall(runRuntimePostBuild)?.[0] as + | { cwd?: string; env?: Record } + | undefined; + expect(postBuildParams?.cwd).toBe(tmp); + expect(postBuildParams?.env?.OPENCLAW_BUILD_PRIVATE_QA).toBe("1"); + expect(postBuildParams?.env?.OPENCLAW_ENABLE_PRIVATE_QA_CLI).toBe("1"); + expect(postBuildParams?.env?.OPENCLAW_DISABLE_BUNDLED_PLUGINS).toBe("0"); + }); + + it("preserves an explicit bundled plugin disable flag for QA runs", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n" }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY], + }); + + const runRuntimePostBuild = vi.fn(); + const { spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runQaCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild, + env: { OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }, + }); + + expect(exitCode).toBe(0); + const postBuildParams = firstMockCall(runRuntimePostBuild)?.[0] as + | { cwd?: string; env?: Record } + | undefined; + expect(postBuildParams?.env?.OPENCLAW_DISABLE_BUNDLED_PLUGINS).toBe("1"); + }); + + it("derives private QA facade checks from distRoot for direct freshness checks", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n" }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY], + }); + + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { env: { OPENCLAW_BUILD_PRIVATE_QA: "1" } }), + ); + + expect(requirement).toEqual({ + shouldBuild: true, + reason: "missing_private_qa_dist", }); }); - it("adds Node sync I/O tracing flag to the launched OpenClaw child when requested", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + it("skips runtime postbuild restaging in watch mode when dist is already current", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); + + const runRuntimePostBuild = vi.fn(); + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + env: { OPENCLAW_WATCH_MODE: "1" }, + runRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + expect(runRuntimePostBuild).not.toHaveBeenCalled(); + }); + + it("reruns runtime postbuild in watch mode when required outputs are missing with no runtime stamp", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [DIST_LEGACY_CLI_EXIT_COMPAT]: "export function hasMemoryRuntime() { return false; }\n", + [DIST_LEGACY_CLI_EXIT_COMPAT_ALT]: "export function hasMemoryRuntime() { return false; }\n", + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + }); + await fs.rm(resolvePath(tmp, DIST_OPENCLAW_ALIAS_PACKAGE)); + + const runRuntimePostBuild = vi.fn(); + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + env: { OPENCLAW_WATCH_MODE: "1" }, + runRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + expect(runRuntimePostBuild).toHaveBeenCalledOnce(); + }); + + it("reruns runtime postbuild for dirty extension package metadata in watch mode", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./index.ts"]}}\n', + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + trackConfig: true, + }); + + const runRuntimePostBuild = vi.fn(); + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder({ + gitStatus: ` M ${EXTENSION_PACKAGE}\n`, + }); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + env: { OPENCLAW_WATCH_MODE: "1" }, + runRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + expect(runRuntimePostBuild).toHaveBeenCalledOnce(); + }); + + for (const { title, command, reportScript, reportArgs } of [ + { + title: "runs QA parity report from source without rebuilding private QA dist", + command: "parity-report", + reportScript: "qa-parity-report.ts", + reportArgs: [ + "--candidate-summary", + ".artifacts/qa-e2e/openai-candidate/qa-suite-summary.json", + "--baseline-summary", + ".artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json", + ], + }, + { + title: "runs QA coverage report from source without rebuilding private QA dist", + command: "coverage", + reportScript: "qa-coverage-report.ts", + reportArgs: [ + "--json", + "--tools", + "--summary", + ".artifacts/qa-e2e/runtime-pair-core/qa-suite-summary.json", + ], + }, + ]) { + it(title, async ({ tmp }) => { await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + files: { "extensions/qa-lab/src/cli.runtime.ts": "export {};\n" }, buildPaths: [DIST_ENTRY, BUILD_STAMP], }); - const spawnCalls: string[][] = []; - const spawn = (_cmd: string, args: string[]) => { - spawnCalls.push(args); - return createExitedProcess(0); - }; - const { spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["gateway", "--force"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - OPENCLAW_TRACE_SYNC_IO: "1", - }, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls.at(-1)).toEqual(["--trace-sync-io", "openclaw.mjs", "gateway", "--force"]); - }); - }); - - it("surfaces generic output log stream errors", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp); - const outputPath = path.join(tmp, ".artifacts", "qa-e2e", "matrix", "output.log"); - await fs.mkdir(outputPath, { recursive: true }); - const spawn = () => createPipedExitedProcess({ stdout: "child stdout\n" }); - const stderrChunks: string[] = []; - const mutedStream = { - write: (chunk: string | Buffer) => { - stderrChunks.push(String(chunk)); - return true; - }, - } as unknown as NodeJS.WriteStream; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath, - }, - spawn, - stderr: mutedStream, - stdout: mutedStream, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - } as Parameters[0] & { stdout: NodeJS.WriteStream }); - - expect(exitCode).toBe(1); - expect(stderrChunks.join("")).toContain("Failed to write output log"); - }); - }); - - it("does not mutate Matrix QA args when no generic output log is requested", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp); - const spawnCalls: Array<{ args: string[]; env: Record }> = []; - const spawn = (_cmd: string, args: string[], options?: unknown) => { - const opts = options as { env?: NodeJS.ProcessEnv } | undefined; - spawnCalls.push({ args, env: { ...opts?.env } }); - return createPipedExitedProcess({}); - }; - const mutedStream = { - write: () => true, - } as unknown as NodeJS.WriteStream; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["qa", "matrix"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - }, - spawn, - stderr: mutedStream, - stdout: mutedStream, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - } as Parameters[0] & { stdout: NodeJS.WriteStream }); - - expect(exitCode).toBe(0); - const childArgs = spawnCalls.at(-1)?.args ?? []; - expect(childArgs).toEqual(["openclaw.mjs", "qa", "matrix"]); - expect(spawnCalls.at(-1)?.env.OPENCLAW_RUN_NODE_OUTPUT_LOG).toBeUndefined(); - }); - }); - - it("skips rebuilding when dist is current and the source tree is clean", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - }); - }); - - it("skips rebuilding for private QA commands when the private QA facades are present", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n", - [QA_RUNTIME_PLUGIN_SDK_ENTRY]: "export const qaRuntime = true;\n", - }, - oldPaths: [ - ROOT_SRC, - ROOT_TSCONFIG, - ROOT_PACKAGE, - QA_LAB_PLUGIN_SDK_ENTRY, - QA_RUNTIME_PLUGIN_SDK_ENTRY, - ], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runQaCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - [ - process.execPath, - "openclaw.mjs", - "qa", - "suite", - "--transport", - "qa-channel", - "--provider-mode", - "mock-openai", - ], - ]); - }); - }); - - it("rebuilds private QA commands when the private QA runtime facade is missing", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runQaCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBundledPluginAssetBuildSpawn(), - expectedBuildSpawn(), - [ - process.execPath, - "openclaw.mjs", - "qa", - "suite", - "--transport", - "qa-channel", - "--provider-mode", - "mock-openai", - ], - ]); - }); - }); - - it("passes the synthesized private QA env into runtime postbuild staging", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const runRuntimePostBuild = vi.fn(); - const { spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runQaCommand({ tmp, spawn, spawnSync, runRuntimePostBuild }); - - expect(exitCode).toBe(0); - expect(runRuntimePostBuild).toHaveBeenCalledTimes(1); - const postBuildParams = firstMockCall(runRuntimePostBuild)?.[0] as - | { cwd?: string; env?: Record } - | undefined; - expect(postBuildParams?.cwd).toBe(tmp); - expect(postBuildParams?.env?.OPENCLAW_BUILD_PRIVATE_QA).toBe("1"); - expect(postBuildParams?.env?.OPENCLAW_ENABLE_PRIVATE_QA_CLI).toBe("1"); - expect(postBuildParams?.env?.OPENCLAW_DISABLE_BUNDLED_PLUGINS).toBe("0"); - }); - }); - - it("preserves an explicit bundled plugin disable flag for QA runs", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const runRuntimePostBuild = vi.fn(); - const { spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runQaCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild, - env: { OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }, - }); - - expect(exitCode).toBe(0); - const postBuildParams = firstMockCall(runRuntimePostBuild)?.[0] as - | { cwd?: string; env?: Record } - | undefined; - expect(postBuildParams?.env?.OPENCLAW_DISABLE_BUNDLED_PLUGINS).toBe("1"); - }); - }); - - it("derives private QA facade checks from distRoot for direct freshness checks", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const requirement = resolveBuildRequirement( - createBuildRequirementDeps(tmp, { - env: { OPENCLAW_BUILD_PRIVATE_QA: "1" }, - gitHead: "abc123\n", - gitStatus: "", - }), - ); - - expect(requirement).toEqual({ - shouldBuild: true, - reason: "missing_private_qa_dist", - }); - }); - }); - - it("skips runtime postbuild restaging in watch mode when dist is already current", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const runRuntimePostBuild = vi.fn(); - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - env: { OPENCLAW_WATCH_MODE: "1" }, - runRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - expect(runRuntimePostBuild).not.toHaveBeenCalled(); - }); - }); - - it("reruns runtime postbuild in watch mode when required outputs are missing with no runtime stamp", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [DIST_LEGACY_CLI_EXIT_COMPAT]: "export function hasMemoryRuntime() { return false; }\n", - [DIST_LEGACY_CLI_EXIT_COMPAT_ALT]: - "export function hasMemoryRuntime() { return false; }\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [ - DIST_ENTRY, - DIST_LEGACY_CLI_EXIT_COMPAT, - DIST_LEGACY_CLI_EXIT_COMPAT_ALT, - BUILD_STAMP, - ], - }); - await fs.rm(resolvePath(tmp, DIST_OPENCLAW_ALIAS_PACKAGE)); - - const runRuntimePostBuild = vi.fn(); - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - env: { OPENCLAW_WATCH_MODE: "1" }, - runRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - expect(runRuntimePostBuild).toHaveBeenCalledOnce(); - }); - }); - - it("reruns runtime postbuild for dirty extension package metadata in watch mode", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./index.ts"]}}\n', - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_PACKAGE, - ROOT_TSCONFIG, - ROOT_PACKAGE, - DIST_ENTRY, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - - const runRuntimePostBuild = vi.fn(); - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: ` M ${EXTENSION_PACKAGE}\n`, - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - env: { OPENCLAW_WATCH_MODE: "1" }, - runRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - expect(runRuntimePostBuild).toHaveBeenCalledOnce(); - }); - }); - - it("runs QA parity report from source without rebuilding private QA dist", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - "extensions/qa-lab/src/cli.runtime.ts": "export {};\n", - }, - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - const spawnCalls: string[][] = []; const spawn = (cmd: string, args: string[]) => { spawnCalls.push([cmd, ...args]); return createExitedProcess(0); }; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: [ - "qa", - "parity-report", - "--candidate-summary", - ".artifacts/qa-e2e/openai-candidate/qa-suite-summary.json", - "--baseline-summary", - ".artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json", - ], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - }, + const exitCode = await runNodeCommand(tmp, { + args: ["qa", command, ...reportArgs], spawn, - execPath: process.execPath, - platform: process.platform, }); - expect(exitCode).toBe(0); expect(spawnCalls).toEqual([ [ process.execPath, "--import", "tsx", - path.join(tmp, "scripts", "qa-parity-report.ts"), - "--candidate-summary", - ".artifacts/qa-e2e/openai-candidate/qa-suite-summary.json", - "--baseline-summary", - ".artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json", + path.join(tmp, "scripts", reportScript), + ...reportArgs, ], ]); }); - }); + } - it("runs QA coverage report from source without rebuilding private QA dist", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - "extensions/qa-lab/src/cli.runtime.ts": "export {};\n", - }, - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const spawnCalls: string[][] = []; - const spawn = (cmd: string, args: string[]) => { - spawnCalls.push([cmd, ...args]); - return createExitedProcess(0); - }; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: [ - "qa", - "coverage", - "--json", - "--tools", - "--summary", - ".artifacts/qa-e2e/runtime-pair-core/qa-suite-summary.json", - ], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - }, - spawn, - execPath: process.execPath, - platform: process.platform, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - [ - process.execPath, - "--import", - "tsx", - path.join(tmp, "scripts", "qa-coverage-report.ts"), - "--json", - "--tools", - "--summary", - ".artifacts/qa-e2e/runtime-pair-core/qa-suite-summary.json", - ], - ]); + it("skips runtime postbuild restaging when the runtime stamp is current", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n' }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], }); + + const runRuntimePostBuild = vi.fn(); + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + expect(runRuntimePostBuild).not.toHaveBeenCalled(); }); - it("skips runtime postbuild restaging when the runtime stamp is current", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP, RUNTIME_POSTBUILD_STAMP], - }); + it("restages runtime artifacts when runtime metadata is dirty", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_INDEX]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [DIST_EXTENSION_INDEX]: "export default {};\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + trackConfig: true, + }); - const runRuntimePostBuild = vi.fn(); - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runStatusCommand({ + const runRuntimePostBuild = vi.fn(); + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder({ + gitStatus: ` M ${EXTENSION_MANIFEST}\n`, + }); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + expect(runRuntimePostBuild).toHaveBeenCalledOnce(); + }); + + it("serializes runtime postbuild restaging across concurrent clean launchers", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); + + let activePostbuilds = 0; + let maxActivePostbuilds = 0; + let markPostbuildStarted!: () => void; + let releasePostbuild!: () => void; + const postbuildStarted = new Promise((resolve) => { + markPostbuildStarted = resolve; + }); + const postbuildRelease = new Promise((resolve) => { + releasePostbuild = resolve; + }); + const runRuntimePostBuild = vi.fn(async () => { + activePostbuilds += 1; + maxActivePostbuilds = Math.max(maxActivePostbuilds, activePostbuilds); + markPostbuildStarted(); + await postbuildRelease; + activePostbuilds -= 1; + }); + const { spawn, spawnSync } = createCurrentGitSpawnRecorder(); + + const runs = Promise.all([ + runStatusCommand({ tmp, spawn, spawnSync, - runRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - expect(runRuntimePostBuild).not.toHaveBeenCalled(); - }); - }); - - it("restages runtime artifacts when runtime metadata is dirty", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_INDEX]: "export default {};\n", - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [DIST_EXTENSION_INDEX]: "export default {};\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + env: { + OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS: "1", }, - buildPaths: [ - ROOT_SRC, - EXTENSION_INDEX, - EXTENSION_MANIFEST, - DIST_EXTENSION_INDEX, - ROOT_TSCONFIG, - ROOT_PACKAGE, - DIST_ENTRY, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - - const runRuntimePostBuild = vi.fn(); - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: ` M ${EXTENSION_MANIFEST}\n`, - }); - const exitCode = await runStatusCommand({ + runRuntimePostBuild, + }), + runStatusCommand({ tmp, spawn, spawnSync, + env: { + OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS: "1", + }, runRuntimePostBuild, - }); + }), + ]); - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - expect(runRuntimePostBuild).toHaveBeenCalledOnce(); - }); + await postbuildStarted; + releasePostbuild(); + await expect(runs).resolves.toEqual([0, 0]); + + expect(runRuntimePostBuild).toHaveBeenCalledTimes(1); + expect(maxActivePostbuilds).toBe(1); + expect(fsSync.existsSync(path.join(tmp, ".artifacts", "run-node-build.lock"))).toBe(false); }); - it("serializes runtime postbuild restaging across concurrent clean launchers", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); + it("returns the build exit code when the compiler step fails", async ({ tmp }) => { + const spawn = (cmd: string, args: string[] = []) => { + if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") { + return createExitedProcess(23); + } + return createExitedProcess(0); + }; - let activePostbuilds = 0; - let maxActivePostbuilds = 0; - let markPostbuildStarted!: () => void; - let releasePostbuild!: () => void; - const postbuildStarted = new Promise((resolve) => { - markPostbuildStarted = resolve; - }); - const postbuildRelease = new Promise((resolve) => { - releasePostbuild = resolve; - }); - const runRuntimePostBuild = vi.fn(async () => { - activePostbuilds += 1; - maxActivePostbuilds = Math.max(maxActivePostbuilds, activePostbuilds); - markPostbuildStarted(); - await postbuildRelease; - activePostbuilds -= 1; - }); - const { spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); + const exitCode = await runNodeCommand(tmp, { env: { OPENCLAW_FORCE_BUILD: "1" }, spawn }); - const runs = Promise.all([ - runStatusCommand({ - tmp, - spawn, - spawnSync, - env: { - OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS: "1", + expect(exitCode).toBe(23); + }); + + it("returns failure and releases the build lock when the compiler spawn errors", async ({ + tmp, + }) => { + const spawn = (cmd: string, args: string[] = []) => { + if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") { + const events = new EventEmitter(); + queueMicrotask(() => events.emit("error", new Error("spawn failed"))); + return { + on: (event: string, cb: (code: number | null, signal: string | null) => void) => { + events.on(event, cb); + return undefined; }, - runRuntimePostBuild, - }), - runStatusCommand({ - tmp, - spawn, - spawnSync, - env: { - OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS: "1", - }, - runRuntimePostBuild, - }), - ]); + }; + } + return createExitedProcess(0); + }; - await postbuildStarted; - releasePostbuild(); - await expect(runs).resolves.toEqual([0, 0]); + const exitCode = await runNodeCommand(tmp, { env: { OPENCLAW_FORCE_BUILD: "1" }, spawn }); - expect(runRuntimePostBuild).toHaveBeenCalledTimes(1); - expect(maxActivePostbuilds).toBe(1); - expect(fsSync.existsSync(path.join(tmp, ".artifacts", "run-node-build.lock"))).toBe(false); - }); + expect(exitCode).toBe(1); + expect(fsSync.existsSync(path.join(tmp, ".artifacts", "run-node-build.lock"))).toBe(false); }); - it("returns the build exit code when the compiler step fails", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - const spawn = (cmd: string, args: string[] = []) => { - if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") { - return createExitedProcess(23); - } - return createExitedProcess(0); - }; + it("forwards wrapper SIGTERM to the active openclaw child and returns 143", async ({ tmp }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_FORCE_BUILD: "1", - OPENCLAW_RUNNER_LOG: "0", - }, - spawn, - execPath: process.execPath, - platform: process.platform, - }); - - expect(exitCode).toBe(23); + const fakeProcess = Object.assign(createFakeProcess(), { + stdin: { + isTTY: true, + }, }); + const child = Object.assign(new EventEmitter(), { + kill: vi.fn((signal: string) => { + queueMicrotask(() => child.emit("exit", 0, null)); + return signal; + }), + }); + const spawn = vi.fn< + ( + cmd: string, + args: string[], + options: unknown, + ) => { + kill: (signal?: string) => boolean; + on: (event: "exit", cb: (code: number | null, signal: string | null) => void) => void; + } + >(() => ({ + kill: (signal) => { + child.kill(signal ?? "SIGTERM"); + return true; + }, + on: (event, cb) => { + child.on(event, cb); + }, + })); + + const exitCodePromise = runNodeCommand(tmp, { + process: fakeProcess, + spawn, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + await vi.waitFor(() => { + expect(spawn).toHaveBeenCalled(); + }); + fakeProcess.emit("SIGTERM"); + const exitCode = await exitCodePromise; + + expect(exitCode).toBe(143); + expect(spawn).toHaveBeenCalledTimes(1); + const spawnCall = firstMockCall(spawn) as [string, string[], { stdio?: unknown }] | undefined; + expect(spawnCall?.[0]).toBe(process.execPath); + expect(spawnCall?.[1]).toEqual(["openclaw.mjs", "status"]); + expect(spawnCall?.[2].stdio).toBe("inherit"); + expect(spawnCall?.[2]).toMatchObject({ detached: false }); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(fakeProcess.listenerCount("SIGINT")).toBe(0); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); }); - it("returns failure and releases the build lock when the compiler spawn errors", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - const spawn = (cmd: string, args: string[] = []) => { - if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") { - const events = new EventEmitter(); - queueMicrotask(() => events.emit("error", new Error("spawn failed"))); - return { - on: (event: string, cb: (code: number | null, signal: string | null) => void) => { - events.on(event, cb); - return undefined; - }, - }; - } - return createExitedProcess(0); - }; - - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_FORCE_BUILD: "1", - OPENCLAW_RUNNER_LOG: "0", - }, - spawn, - execPath: process.execPath, - platform: process.platform, - }); - - expect(exitCode).toBe(1); - expect(fsSync.existsSync(path.join(tmp, ".artifacts", "run-node-build.lock"))).toBe(false); - }); - }); - - it("forwards wrapper SIGTERM to the active openclaw child and returns 143", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); + it.runIf(process.platform !== "win32")( + "force-cleans the active openclaw child process group after forwarded SIGTERM", + async ({ tmp }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); const fakeProcess = Object.assign(createFakeProcess(), { stdin: { - isTTY: true, + isTTY: false, }, }); const child = Object.assign(new EventEmitter(), { - kill: vi.fn((signal: string) => { - queueMicrotask(() => child.emit("exit", 0, null)); - return signal; - }), + pid: 42_420, + kill: vi.fn(), }); + const groupSignals: Array<[number, string | number]> = []; const spawn = vi.fn< ( cmd: string, @@ -1552,6 +1289,7 @@ describe("run-node script", () => { ) => { kill: (signal?: string) => boolean; on: (event: "exit", cb: (code: number | null, signal: string | null) => void) => void; + pid: number; } >(() => ({ kill: (signal) => { @@ -1561,19 +1299,21 @@ describe("run-node script", () => { on: (event, cb) => { child.on(event, cb); }, + pid: child.pid, })); - const exitCodePromise = runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - }, + const exitCodePromise = runNodeCommand(tmp, { + platform: "darwin", process: fakeProcess, + signalProcess: (pid: number, signal?: string | number) => { + groupSignals.push([pid, signal ?? "SIGTERM"]); + if (signal === "SIGTERM") { + queueMicrotask(() => child.emit("exit", 0, null)); + } + return true; + }, spawn, runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, }); await vi.waitFor(() => { @@ -1583,333 +1323,185 @@ describe("run-node script", () => { const exitCode = await exitCodePromise; expect(exitCode).toBe(143); - expect(spawn).toHaveBeenCalledTimes(1); - const spawnCall = firstMockCall(spawn) as [string, string[], { stdio?: unknown }] | undefined; - expect(spawnCall?.[0]).toBe(process.execPath); + const spawnCall = firstMockCall(spawn) as + | [string, string[], { detached?: boolean; stdio?: unknown }] + | undefined; expect(spawnCall?.[1]).toEqual(["openclaw.mjs", "status"]); - expect(spawnCall?.[2].stdio).toBe("inherit"); - expect(spawnCall?.[2]).toMatchObject({ detached: false }); - expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(spawnCall?.[2]).toMatchObject({ detached: true, stdio: "inherit" }); + expect(groupSignals).toEqual([ + [-42_420, "SIGTERM"], + [-42_420, "SIGKILL"], + ]); + expect(child.kill).not.toHaveBeenCalled(); expect(fakeProcess.listenerCount("SIGINT")).toBe(0); expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); - }); - }); - - it.runIf(process.platform !== "win32")( - "force-cleans the active openclaw child process group after forwarded SIGTERM", - async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const fakeProcess = Object.assign(createFakeProcess(), { - stdin: { - isTTY: false, - }, - }); - const child = Object.assign(new EventEmitter(), { - pid: 42_420, - kill: vi.fn(), - }); - const groupSignals: Array<[number, string | number]> = []; - const spawn = vi.fn< - ( - cmd: string, - args: string[], - options: unknown, - ) => { - kill: (signal?: string) => boolean; - on: (event: "exit", cb: (code: number | null, signal: string | null) => void) => void; - pid: number; - } - >(() => ({ - kill: (signal) => { - child.kill(signal ?? "SIGTERM"); - return true; - }, - on: (event, cb) => { - child.on(event, cb); - }, - pid: child.pid, - })); - - const exitCodePromise = runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "0", - }, - platform: "darwin", - process: fakeProcess, - signalProcess: (pid: number, signal?: string | number) => { - groupSignals.push([pid, signal ?? "SIGTERM"]); - if (signal === "SIGTERM") { - queueMicrotask(() => child.emit("exit", 0, null)); - } - return true; - }, - spawn, - runRuntimePostBuild: skipRuntimePostBuild, - execPath: process.execPath, - }); - - await vi.waitFor(() => { - expect(spawn).toHaveBeenCalled(); - }); - fakeProcess.emit("SIGTERM"); - const exitCode = await exitCodePromise; - - expect(exitCode).toBe(143); - const spawnCall = firstMockCall(spawn) as - | [string, string[], { detached?: boolean; stdio?: unknown }] - | undefined; - expect(spawnCall?.[1]).toEqual(["openclaw.mjs", "status"]); - expect(spawnCall?.[2]).toMatchObject({ detached: true, stdio: "inherit" }); - expect(groupSignals).toEqual([ - [-42_420, "SIGTERM"], - [-42_420, "SIGKILL"], - ]); - expect(child.kill).not.toHaveBeenCalled(); - expect(fakeProcess.listenerCount("SIGINT")).toBe(0); - expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); - }); }, ); - it("rebuilds when extension sources are newer than the build stamp", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [EXTENSION_SRC]: "export const extensionValue = 1;\n", - }, - buildPaths: [ROOT_TSCONFIG, ROOT_PACKAGE, DIST_ENTRY, BUILD_STAMP], - newPaths: [EXTENSION_SRC], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder(); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBundledPluginAssetBuildSpawn(), - expectedBuildSpawn(), - statusCommandSpawn(), - ]); + it("rebuilds when extension sources are newer than the build stamp", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_SRC]: "export const extensionValue = 1;\n", + }, + newPaths: [EXTENSION_SRC], + rootSource: false, + trackConfig: true, }); + + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBundledPluginAssetBuildSpawn(), + expectedBuildSpawn(), + statusCommandSpawn(), + ]); }); - it("shows tty progress while rebuilding source-checkout artifacts", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - const { spawn, spawnSync } = createSpawnRecorder(); - const stderrChunks: string[] = []; - const stderr = { - isTTY: true, - write: vi.fn((chunk: string | Buffer) => { - stderrChunks.push(String(chunk)); - return true; - }), - } as unknown as NodeJS.WriteStream; + it("shows tty progress while rebuilding source-checkout artifacts", async ({ tmp }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); + const { spawn, spawnSync } = createSpawnRecorder(); + const stderrChunks: string[] = []; + const stderr = { + isTTY: true, + write: vi.fn((chunk: string | Buffer) => { + stderrChunks.push(String(chunk)); + return true; + }), + } as unknown as NodeJS.WriteStream; - const exitCode = await runNodeMain({ - cwd: tmp, - args: ["status"], - env: { - ...process.env, - CI: "false", - OPENCLAW_FORCE_BUILD: "1", - }, - spawn, - spawnSync, - stderr, - runRuntimePostBuild: async () => {}, - execPath: process.execPath, - platform: process.platform, - } as Parameters[0]); - - expect(exitCode).toBe(0); - const stderrText = stderrChunks.join(""); - expect(stderrText).toContain("Building local CLI artifacts"); - expect(stderrText).toContain("\x1b[2K"); + const exitCode = await runNodeCommand(tmp, { + env: { CI: "false", OPENCLAW_FORCE_BUILD: "1" }, + spawn, + spawnSync, + stderr, + runRuntimePostBuild: async () => {}, }); + + expect(exitCode).toBe(0); + const stderrText = stderrChunks.join(""); + expect(stderrText).toContain("Building local CLI artifacts"); + expect(stderrText).toContain("\x1b[2K"); }); - it("rebuilds when git HEAD changes even if source mtimes do not exceed the old build stamp", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); + it("rebuilds when git HEAD changes even if source mtimes do not exceed the old build stamp", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "def456\n", - gitStatus: "", - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBundledPluginAssetBuildSpawn(), - expectedBuildSpawn(), - statusCommandSpawn(), - ]); + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder({ gitHead: "def456\n" }); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBundledPluginAssetBuildSpawn(), + expectedBuildSpawn(), + statusCommandSpawn(), + ]); }); - it("skips rebuilding when extension package metadata is newer than the build stamp", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [EXTENSION_INDEX]: "export default {};\n", - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', - [ROOT_TSDOWN]: "export default {};\n", - [DIST_EXTENSION_INDEX]: "export default {};\n", - [DIST_EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\n', - }, - oldPaths: [EXTENSION_INDEX, EXTENSION_MANIFEST, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN], - buildPaths: [DIST_ENTRY, BUILD_STAMP, DIST_EXTENSION_INDEX, DIST_EXTENSION_PACKAGE], - newPaths: [EXTENSION_PACKAGE], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder(); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: syncBundledPluginMetadata, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - await expect( - fs.readFile(resolvePath(tmp, DIST_EXTENSION_PACKAGE), "utf-8"), - ).resolves.toContain('"./index.js"'); + it("skips rebuilding when extension package metadata is newer than the build stamp", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_INDEX]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', + [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_INDEX]: "export default {};\n", + [DIST_EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\n', + }, + oldPaths: [EXTENSION_INDEX, EXTENSION_MANIFEST, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN], + newPaths: [EXTENSION_PACKAGE], + rootSource: false, }); + + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: syncBundledPluginMetadata, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + await expect(fs.readFile(resolvePath(tmp, DIST_EXTENSION_PACKAGE), "utf-8")).resolves.toContain( + '"./index.js"', + ); }); - it("skips rebuilding for dirty non-source files under extensions", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_README]: "# demo\n", - [ROOT_TSDOWN]: "export default {};\n", - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_README, - ROOT_TSCONFIG, - ROOT_PACKAGE, - ROOT_TSDOWN, - DIST_ENTRY, - BUILD_STAMP, - ], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: ` M ${EXTENSION_README}\n`, - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); + it("skips rebuilding for dirty non-source files under extensions", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { [EXTENSION_README]: "# demo\n", [ROOT_TSDOWN]: "export default {};\n" }, + trackConfig: true, }); + + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder({ + gitStatus: ` M ${EXTENSION_README}\n`, + }); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); }); - it("skips rebuilding for dirty extension manifests that only affect runtime reload", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_INDEX]: "export default {};\n", - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [ROOT_TSDOWN]: "export default {};\n", - [DIST_EXTENSION_INDEX]: "export default {};\n", - [DIST_EXTENSION_MANIFEST]: '{"id":"stale","configSchema":{"type":"object"}}\n', - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_INDEX, - EXTENSION_MANIFEST, - ROOT_TSCONFIG, - ROOT_PACKAGE, - ROOT_TSDOWN, - DIST_ENTRY, - BUILD_STAMP, - DIST_EXTENSION_INDEX, - DIST_EXTENSION_MANIFEST, - ], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: ` M ${EXTENSION_MANIFEST}\n`, - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: syncBundledPluginMetadata, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo"); + it("skips rebuilding for dirty extension manifests that only affect runtime reload", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_INDEX]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_INDEX]: "export default {};\n", + [DIST_EXTENSION_MANIFEST]: '{"id":"stale","configSchema":{"type":"object"}}\n', + }, + trackConfig: true, }); + + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder({ + gitStatus: ` M ${EXTENSION_MANIFEST}\n`, + }); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: syncBundledPluginMetadata, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo"); }); - it("reports dirty watched source trees as an explicit build reason", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - buildPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, DIST_ENTRY, BUILD_STAMP], - }); + it("reports dirty watched source trees as an explicit build reason", async ({ tmp }) => { + await setupStampedProject(tmp, { trackConfig: true }); - const requirement = resolveBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: ` M ${ROOT_SRC}\n`, - }), - ); + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { gitStatus: ` M ${ROOT_SRC}\n` }), + ); - expect(requirement).toEqual({ - shouldBuild: true, - reason: "dirty_watched_tree", - }); + expect(requirement).toEqual({ + shouldBuild: true, + reason: "dirty_watched_tree", }); }); @@ -1920,27 +1512,16 @@ describe("run-node script", () => { { label: "dashboard", args: ["dashboard", "--no-open", "--yes"] }, ])("does not rebuild for $label calls against an existing dirty dist", async ({ args }) => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - ROOT_TSCONFIG, - ROOT_PACKAGE, - DIST_ENTRY, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], + await setupStampedProject(tmp, { + files: { [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n' }, + trackConfig: true, }); const runRuntimePostBuild = vi.fn(); - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder({ gitStatus: ` M ${ROOT_SRC}\n`, }); - const exitCode = await runGatewayClientCommand({ + const exitCode = await runStatusCommand({ tmp, args, spawn, @@ -1954,495 +1535,316 @@ describe("run-node script", () => { }); }); - it("rechecks a dirty dashboard client after waiting for an active build", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - ROOT_TSCONFIG, - ROOT_PACKAGE, - DIST_ENTRY, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - await fs.rm(resolvePath(tmp, BUILD_STAMP)); - await fs.rm(resolvePath(tmp, RUNTIME_POSTBUILD_STAMP)); + it("rechecks a dirty dashboard client after waiting for an active build", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n' }, + trackConfig: true, + }); + await fs.rm(resolvePath(tmp, BUILD_STAMP)); + await fs.rm(resolvePath(tmp, RUNTIME_POSTBUILD_STAMP)); - const lockProcess = Object.assign(createFakeProcess(), { - kill: vi.fn(() => true), - }) as unknown as NodeJS.Process; - const releaseLock = await acquireRunNodeBuildLock({ - cwd: tmp, - args: ["gateway"], - env: { OPENCLAW_RUNNER_LOG: "0" }, - fs: fsSync, - process: lockProcess, - stderr: { write: () => true } as unknown as NodeJS.WriteStream, - }); - let markWaiting!: () => void; - const waitingForLock = new Promise((resolve) => { - markWaiting = resolve; - }); - const stderr = { - write: (chunk: string | Buffer) => { - if (String(chunk).includes("Waiting for TypeScript/runtime artifact lock")) { - markWaiting(); - } - return true; - }, - } as unknown as NodeJS.WriteStream; - const runRuntimePostBuild = vi.fn(); - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: ` M ${ROOT_SRC}\n`, - }); - const clientRun = runNodeMain({ - cwd: tmp, - args: ["dashboard", "--no-open", "--yes"], - env: { - ...process.env, - OPENCLAW_RUNNER_LOG: "1", - OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS: "1", - }, - spawn, - spawnSync, - process: lockProcess, - stderr, - runRuntimePostBuild, - execPath: process.execPath, - platform: process.platform, - }); + const lockProcess = Object.assign(createFakeProcess(), { + kill: vi.fn(() => true), + }) as unknown as NodeJS.Process; + const releaseLock = await acquireRunNodeBuildLock({ + cwd: tmp, + args: ["gateway"], + env: { OPENCLAW_RUNNER_LOG: "0" }, + fs: fsSync, + process: lockProcess, + stderr: { write: () => true } as unknown as NodeJS.WriteStream, + }); + let markWaiting!: () => void; + const waitingForLock = new Promise((resolve) => { + markWaiting = resolve; + }); + const stderr = { + write: (chunk: string | Buffer) => { + if (String(chunk).includes("Waiting for TypeScript/runtime artifact lock")) { + markWaiting(); + } + return true; + }, + } as unknown as NodeJS.WriteStream; + const runRuntimePostBuild = vi.fn(); + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder({ + gitStatus: ` M ${ROOT_SRC}\n`, + }); + const clientRun = runNodeCommand(tmp, { + args: ["dashboard", "--no-open", "--yes"], + env: { OPENCLAW_RUNNER_LOG: "1", OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS: "1" }, + spawn, + spawnSync, + process: lockProcess, + stderr, + runRuntimePostBuild, + }); - await waitingForLock; - await fs.writeFile(resolvePath(tmp, BUILD_STAMP), '{"head":"abc123"}\n', "utf-8"); - await fs.writeFile(resolvePath(tmp, RUNTIME_POSTBUILD_STAMP), '{"head":"abc123"}\n', "utf-8"); - releaseLock(); + await waitingForLock; + await fs.writeFile(resolvePath(tmp, BUILD_STAMP), '{"head":"abc123"}\n', "utf-8"); + await fs.writeFile(resolvePath(tmp, RUNTIME_POSTBUILD_STAMP), '{"head":"abc123"}\n', "utf-8"); + releaseLock(); - await expect(clientRun).resolves.toBe(0); - expect(spawnCalls).toEqual([ - [process.execPath, "openclaw.mjs", "dashboard", "--no-open", "--yes"], - ]); - expect(runRuntimePostBuild).not.toHaveBeenCalled(); + await expect(clientRun).resolves.toBe(0); + expect(spawnCalls).toEqual([ + [process.execPath, "openclaw.mjs", "dashboard", "--no-open", "--yes"], + ]); + expect(runRuntimePostBuild).not.toHaveBeenCalled(); + }); + + it("reports a clean tree explicitly when dist is current", async ({ tmp }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); + + const requirement = resolveBuildRequirement(createBuildRequirementDeps(tmp)); + + expect(requirement).toEqual({ + shouldBuild: false, + reason: "clean", }); }); - it("reports a clean tree explicitly when dist is current", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); + it("reports clean in sparse worktrees without bundled plugin sources", async ({ tmp }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); + await fs.rm(resolvePath(tmp, "extensions"), { recursive: true, force: true }); - const requirement = resolveBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldBuild: false, - reason: "clean", - }); + expect(requirement).toEqual({ + shouldBuild: false, + reason: "clean", }); }); - it("reports clean in sparse worktrees without bundled plugin sources", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - await fs.rm(resolvePath(tmp, "extensions"), { recursive: true, force: true }); + it("rebuilds when dirty bundled package entries point at missing dist outputs", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_SRC]: "export default {};\n", + [EXTENSION_EXTRA_SRC]: "export const extra = true;\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts","./src/extra.ts"]}}\n', + [DIST_EXTENSION_SRC]: "export default {};\n", + }, + trackConfig: true, + }); - const requirement = resolveBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { gitStatus: ` M ${EXTENSION_PACKAGE}\n` }), + ); - expect(requirement).toEqual({ - shouldBuild: false, - reason: "clean", - }); + expect(requirement).toEqual({ + shouldBuild: true, + reason: "dirty_watched_tree", }); }); - it("rebuilds when dirty bundled package entries point at missing dist outputs", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_SRC]: "export default {};\n", - [EXTENSION_EXTRA_SRC]: "export const extra = true;\n", - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts","./src/extra.ts"]}}\n', - [DIST_EXTENSION_SRC]: "export default {};\n", - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_SRC, - EXTENSION_EXTRA_SRC, - EXTENSION_MANIFEST, - EXTENSION_PACKAGE, - ROOT_TSCONFIG, - ROOT_PACKAGE, - DIST_ENTRY, - DIST_EXTENSION_SRC, - BUILD_STAMP, - ], - }); + it("rebuilds when clean bundled plugin dist outputs are partially missing", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_SRC]: "export default {};\n", + [EXTENSION_EXTRA_SRC]: "export const extra = true;\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts","./src/extra.ts"]}}\n', + [DIST_EXTENSION_SRC]: "export default {};\n", + }, + trackConfig: true, + }); - const requirement = resolveBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: ` M ${EXTENSION_PACKAGE}\n`, - }), - ); + const requirement = resolveBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldBuild: true, - reason: "dirty_watched_tree", - }); + expect(requirement).toEqual({ + shouldBuild: true, + reason: "missing_bundled_plugin_dist_entry", }); }); - it("rebuilds when clean bundled plugin dist outputs are partially missing", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_SRC]: "export default {};\n", - [EXTENSION_EXTRA_SRC]: "export const extra = true;\n", - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts","./src/extra.ts"]}}\n', - [DIST_EXTENSION_SRC]: "export default {};\n", - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_SRC, - EXTENSION_EXTRA_SRC, - EXTENSION_MANIFEST, - EXTENSION_PACKAGE, - ROOT_TSCONFIG, - ROOT_PACKAGE, - DIST_ENTRY, - DIST_EXTENSION_SRC, - BUILD_STAMP, - ], - }); + it("rebuilds when a clean stamped bundled plugin dist directory is missing", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_SRC]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts"]}}\n', + }, + trackConfig: true, + }); - const requirement = resolveBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldBuild: true, - reason: "missing_bundled_plugin_dist_entry", - }); + expect(requirement).toEqual({ + shouldBuild: true, + reason: "missing_bundled_plugin_dist_entry", }); }); - it("rebuilds when a clean stamped bundled plugin dist directory is missing", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_SRC]: "export default {};\n", - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts"]}}\n', - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_SRC, - EXTENSION_MANIFEST, - EXTENSION_PACKAGE, - ROOT_TSCONFIG, - ROOT_PACKAGE, - DIST_ENTRY, - BUILD_STAMP, - ], - }); + it("reports clean runtime postbuild artifacts when the runtime stamp matches HEAD", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n' }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + }); - const requirement = resolveBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldBuild: true, - reason: "missing_bundled_plugin_dist_entry", - }); + expect(requirement).toEqual({ + shouldSync: false, + reason: "clean", }); }); - it("reports clean runtime postbuild artifacts when the runtime stamp matches HEAD", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP, RUNTIME_POSTBUILD_STAMP], - }); + it("reports missing runtime postbuild outputs even when stamps match HEAD", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_SRC]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts"]}}\n', + [DIST_EXTENSION_SRC]: "export default {};\n", + [DIST_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [DIST_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.js"]}}\n', + [DIST_EXTENSION_RUNTIME_SRC]: "export default {};\n", + [DIST_RUNTIME_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [DIST_RUNTIME_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.js"]}}\n', + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + }); + await fs.rm(resolvePath(tmp, DIST_EXTENSION_PACKAGE)); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldSync: false, - reason: "clean", - }); + expect(requirement).toEqual({ + shouldSync: true, + reason: "missing_runtime_postbuild_output", }); }); - it("reports missing runtime postbuild outputs even when stamps match HEAD", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_SRC]: "export default {};\n", - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts"]}}\n', - [DIST_EXTENSION_SRC]: "export default {};\n", - [DIST_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [DIST_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.js"]}}\n', - [DIST_EXTENSION_RUNTIME_SRC]: "export default {};\n", - [DIST_RUNTIME_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [DIST_RUNTIME_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.js"]}}\n', - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_SRC, - EXTENSION_MANIFEST, - EXTENSION_PACKAGE, - DIST_ENTRY, - DIST_EXTENSION_SRC, - DIST_EXTENSION_MANIFEST, - DIST_EXTENSION_PACKAGE, - DIST_EXTENSION_RUNTIME_SRC, - DIST_RUNTIME_EXTENSION_MANIFEST, - DIST_RUNTIME_EXTENSION_PACKAGE, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - await fs.rm(resolvePath(tmp, DIST_EXTENSION_PACKAGE)); + it("reports missing runtime overlay outputs from restored dist without plugin sources", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [DIST_EXTENSION_INDEX]: "export default {};\n", + [DIST_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [DIST_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./index.js"]}}\n', + [DIST_RUNTIME_EXTENSION_INDEX]: "export default {};\n", + [DIST_RUNTIME_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [DIST_RUNTIME_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./index.js"]}}\n', + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + }); + await fs.rm(resolvePath(tmp, "extensions"), { recursive: true, force: true }); + await fs.rm(resolvePath(tmp, DIST_RUNTIME_EXTENSION_INDEX)); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldSync: true, - reason: "missing_runtime_postbuild_output", - }); + expect(requirement).toEqual({ + shouldSync: true, + reason: "missing_runtime_postbuild_output", }); }); - it("reports missing runtime overlay outputs from restored dist without plugin sources", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [DIST_EXTENSION_INDEX]: "export default {};\n", - [DIST_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [DIST_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./index.js"]}}\n', - [DIST_RUNTIME_EXTENSION_INDEX]: "export default {};\n", - [DIST_RUNTIME_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [DIST_RUNTIME_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./index.js"]}}\n', - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - DIST_ENTRY, - DIST_EXTENSION_INDEX, - DIST_EXTENSION_MANIFEST, - DIST_EXTENSION_PACKAGE, - DIST_RUNTIME_EXTENSION_INDEX, - DIST_RUNTIME_EXTENSION_MANIFEST, - DIST_RUNTIME_EXTENSION_PACKAGE, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - await fs.rm(resolvePath(tmp, "extensions"), { recursive: true, force: true }); - await fs.rm(resolvePath(tmp, DIST_RUNTIME_EXTENSION_INDEX)); + it("does not require OpenClaw SDK alias outputs when dist extensions are absent", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [DIST_PLUGIN_SDK_CORE]: "export const core = true;\n", + [DIST_CHANNEL_CATALOG]: '{"entries":[]}\n', + [DIST_LEGACY_CLI_EXIT_COMPAT]: "export function hasMemoryRuntime() { return false; }\n", + [DIST_LEGACY_CLI_EXIT_COMPAT_ALT]: "export function hasMemoryRuntime() { return false; }\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + }); + await fs.rm(path.join(tmp, "dist", "extensions"), { recursive: true, force: true }); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldSync: true, - reason: "missing_runtime_postbuild_output", - }); + expect(requirement).toEqual({ + shouldSync: false, + reason: "clean", }); }); - it("does not require OpenClaw SDK alias outputs when dist extensions are absent", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [DIST_PLUGIN_SDK_CORE]: "export const core = true;\n", - [DIST_CHANNEL_CATALOG]: '{"entries":[]}\n', - [DIST_LEGACY_CLI_EXIT_COMPAT]: "export function hasMemoryRuntime() { return false; }\n", - [DIST_LEGACY_CLI_EXIT_COMPAT_ALT]: - "export function hasMemoryRuntime() { return false; }\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - DIST_ENTRY, - DIST_PLUGIN_SDK_CORE, - DIST_CHANNEL_CATALOG, - DIST_LEGACY_CLI_EXIT_COMPAT, - DIST_LEGACY_CLI_EXIT_COMPAT_ALT, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - await fs.rm(path.join(tmp, "dist", "extensions"), { recursive: true, force: true }); + it("reports missing OpenClaw SDK alias outputs when runtime stamps match HEAD", async ({ + tmp, + }) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [ROOT_PACKAGE]: + '{"name":"openclaw-test","exports":{"./plugin-sdk/core":"./dist/plugin-sdk/core.js"}}\n', + [DIST_PLUGIN_SDK_CORE]: "export const core = true;\n", + [DIST_OPENCLAW_ALIAS_PACKAGE]: + '{"name":"openclaw","type":"module","exports":{"./plugin-sdk/core":"./plugin-sdk/core.js"}}\n', + [DIST_OPENCLAW_ALIAS_PLUGIN_SDK_CORE]: "export * from '../../../../plugin-sdk/core.js';\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + buildPaths: [ + ROOT_SRC, + DIST_ENTRY, + DIST_PLUGIN_SDK_CORE, + DIST_OPENCLAW_ALIAS_PACKAGE, + DIST_OPENCLAW_ALIAS_PLUGIN_SDK_CORE, + BUILD_STAMP, + RUNTIME_POSTBUILD_STAMP, + ], + }); + await fs.rm(resolvePath(tmp, DIST_OPENCLAW_ALIAS_PLUGIN_SDK_CORE)); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldSync: false, - reason: "clean", - }); + expect(requirement).toEqual({ + shouldSync: true, + reason: "missing_runtime_postbuild_output", }); }); - it("reports missing OpenClaw SDK alias outputs when runtime stamps match HEAD", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [ROOT_PACKAGE]: - '{"name":"openclaw-test","exports":{"./plugin-sdk/core":"./dist/plugin-sdk/core.js"}}\n', - [DIST_PLUGIN_SDK_CORE]: "export const core = true;\n", - [DIST_OPENCLAW_ALIAS_PACKAGE]: - '{"name":"openclaw","type":"module","exports":{"./plugin-sdk/core":"./plugin-sdk/core.js"}}\n', - [DIST_OPENCLAW_ALIAS_PLUGIN_SDK_CORE]: - "export * from '../../../../plugin-sdk/core.js';\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - DIST_ENTRY, - DIST_PLUGIN_SDK_CORE, - DIST_OPENCLAW_ALIAS_PACKAGE, - DIST_OPENCLAW_ALIAS_PLUGIN_SDK_CORE, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - await fs.rm(resolvePath(tmp, DIST_OPENCLAW_ALIAS_PLUGIN_SDK_CORE)); - - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); - - expect(requirement).toEqual({ - shouldSync: true, - reason: "missing_runtime_postbuild_output", - }); - }); - }); - - it("does not require private OpenClaw SDK dist files that package exports omit", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [ROOT_PACKAGE]: JSON.stringify( - { - name: "openclaw-test", - exports: { - "./plugin-sdk/string-coerce-runtime": "./dist/plugin-sdk/string-coerce-runtime.js", - }, + it("does not require private OpenClaw SDK dist files that package exports omit", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [ROOT_PACKAGE]: JSON.stringify( + { + name: "openclaw-test", + exports: { + "./plugin-sdk/string-coerce-runtime": "./dist/plugin-sdk/string-coerce-runtime.js", }, - null, - 2, - ), - "dist/plugin-sdk/string-coerce-runtime.js": "export const publicRuntime = true;\n", - "dist/plugin-sdk/ssrf-runtime-internal.js": "export const internal = true;\n", - [DIST_OPENCLAW_ALIAS_PACKAGE]: - '{"name":"openclaw","type":"module","exports":{"./plugin-sdk/string-coerce-runtime":"./plugin-sdk/string-coerce-runtime.js"}}\n', - [DIST_OPENCLAW_ALIAS_PLUGIN_SDK_STRING_COERCE]: - "export * from '../../../../plugin-sdk/string-coerce-runtime.js';\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - ROOT_PACKAGE, - DIST_ENTRY, - "dist/plugin-sdk/string-coerce-runtime.js", - "dist/plugin-sdk/ssrf-runtime-internal.js", - DIST_OPENCLAW_ALIAS_PACKAGE, - DIST_OPENCLAW_ALIAS_PLUGIN_SDK_STRING_COERCE, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); + }, + null, + 2, + ), + "dist/plugin-sdk/string-coerce-runtime.js": "export const publicRuntime = true;\n", + "dist/plugin-sdk/ssrf-runtime-internal.js": "export const internal = true;\n", + [DIST_OPENCLAW_ALIAS_PACKAGE]: + '{"name":"openclaw","type":"module","exports":{"./plugin-sdk/string-coerce-runtime":"./plugin-sdk/string-coerce-runtime.js"}}\n', + [DIST_OPENCLAW_ALIAS_PLUGIN_SDK_STRING_COERCE]: + "export * from '../../../../plugin-sdk/string-coerce-runtime.js';\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + }); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldSync: false, - reason: "clean", - }); + expect(requirement).toEqual({ + shouldSync: false, + reason: "clean", }); }); - it("reports missing static runtime postbuild asset outputs when runtime stamps match HEAD", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { + for (const [title, missingPath] of [ + [ + "reports missing static runtime postbuild asset outputs when runtime stamps match HEAD", + DIST_DIFFS_VIEWER_RUNTIME, + ], + [ + "reports missing static runtime overlay asset outputs when runtime stamps match HEAD", + DIST_RUNTIME_DIFFS_VIEWER_RUNTIME, + ], + ] as const) { + it(title, async ({ tmp }) => { + await setupStampedProject(tmp, { files: { - [ROOT_SRC]: "export const value = 1;\n", [DIFFS_PACKAGE]: '{"openclaw":{"build":{"staticAssets":[{"source":"./assets/viewer-runtime.js","output":"assets/viewer-runtime.js"}]}}}\n', [DIFFS_VIEWER_RUNTIME_SOURCE]: "export {};\n", @@ -2450,498 +1852,282 @@ describe("run-node script", () => { [DIST_RUNTIME_DIFFS_VIEWER_RUNTIME]: "export {};\n", [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', }, - buildPaths: [ - ROOT_SRC, - DIFFS_PACKAGE, - DIFFS_VIEWER_RUNTIME_SOURCE, - DIST_DIFFS_VIEWER_RUNTIME, - DIST_RUNTIME_DIFFS_VIEWER_RUNTIME, - DIST_ENTRY, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], }); - await fs.rm(resolvePath(tmp, DIST_DIFFS_VIEWER_RUNTIME)); - - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); - + await fs.rm(resolvePath(tmp, missingPath)); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); expect(requirement).toEqual({ shouldSync: true, reason: "missing_runtime_postbuild_output", }); }); + } + + it("does not require static asset outputs when runtime static assets are disabled", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [DIFFS_PACKAGE]: + '{"openclaw":{"build":{"staticAssets":[{"source":"./assets/viewer-runtime.js","output":"assets/viewer-runtime.js"}]}}}\n', + [DIFFS_VIEWER_RUNTIME_SOURCE]: "export {};\n", + [DIST_RUNTIME_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./index.js"]}}\n', + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + }); + + const requirement = resolveRuntimePostBuildRequirement( + createBuildRequirementDeps(tmp, { env: { OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0" } }), + ); + + expect(requirement).toEqual({ + shouldSync: false, + reason: "clean", + }); }); - it("reports missing static runtime overlay asset outputs when runtime stamps match HEAD", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [DIFFS_PACKAGE]: - '{"openclaw":{"build":{"staticAssets":[{"source":"./assets/viewer-runtime.js","output":"assets/viewer-runtime.js"}]}}}\n', - [DIFFS_VIEWER_RUNTIME_SOURCE]: "export {};\n", - [DIST_DIFFS_VIEWER_RUNTIME]: "export {};\n", - [DIST_RUNTIME_DIFFS_VIEWER_RUNTIME]: "export {};\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - DIFFS_PACKAGE, - DIFFS_VIEWER_RUNTIME_SOURCE, - DIST_DIFFS_VIEWER_RUNTIME, - DIST_RUNTIME_DIFFS_VIEWER_RUNTIME, - DIST_ENTRY, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - await fs.rm(resolvePath(tmp, DIST_RUNTIME_DIFFS_VIEWER_RUNTIME)); + it("does not require static asset outputs when the declared source is absent", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [DIFFS_PACKAGE]: + '{"openclaw":{"build":{"staticAssets":[{"source":"./assets/viewer-runtime.js","output":"assets/viewer-runtime.js"}]}}}\n', + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + }); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); + + expect(requirement).toEqual({ + shouldSync: false, + reason: "clean", + }); + }); + + it("reports missing core runtime postbuild outputs when runtime stamps match HEAD", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [DIST_STABLE_ROOT_RUNTIME_SOURCE]: "export const value = 1;\n", + [DIST_STABLE_ROOT_RUNTIME_ALIAS]: "export * from './model-catalog.runtime-AbCd1234.js';\n", + [DIST_LEGACY_ROOT_RUNTIME_TARGET]: "export const aborted = true;\n", + [DIST_LEGACY_ROOT_RUNTIME_COMPAT]: "export * from './abort.runtime.js';\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + }); + + for (const missingPath of [ + DIST_CHANNEL_CATALOG, + DIST_LEGACY_CLI_EXIT_COMPAT, + DIST_STABLE_ROOT_RUNTIME_ALIAS, + DIST_LEGACY_ROOT_RUNTIME_COMPAT, + ]) { + const resolvedMissingPath = resolvePath(tmp, missingPath); + const originalContent = await fs.readFile(resolvedMissingPath, "utf8"); + await fs.rm(resolvedMissingPath); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); expect(requirement).toEqual({ shouldSync: true, reason: "missing_runtime_postbuild_output", }); + await fs.writeFile(resolvedMissingPath, originalContent); + } + }); + + it("does not require ambiguous stable runtime aliases that postbuild cannot create", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { + [DIST_STABLE_ROOT_RUNTIME_SOURCE]: "export const value = 1;\n", + [DIST_STABLE_ROOT_RUNTIME_SOURCE_ALT]: "export const value = 2;\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + }); + + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); + + expect(requirement).toEqual({ + shouldSync: false, + reason: "clean", }); }); - it("does not require static asset outputs when runtime static assets are disabled", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [DIFFS_PACKAGE]: - '{"openclaw":{"build":{"staticAssets":[{"source":"./assets/viewer-runtime.js","output":"assets/viewer-runtime.js"}]}}}\n', - [DIFFS_VIEWER_RUNTIME_SOURCE]: "export {};\n", - [DIST_RUNTIME_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./index.js"]}}\n', - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - DIFFS_PACKAGE, - DIFFS_VIEWER_RUNTIME_SOURCE, - DIST_RUNTIME_EXTENSION_PACKAGE, - DIST_ENTRY, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); + it("reports missing runtime skill outputs even when stamps match HEAD", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_INDEX]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', + [EXTENSION_SKILL]: "# Demo\n", + [DIST_EXTENSION_INDEX]: "export default {};\n", + [DIST_EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', + [DIST_EXTENSION_SKILL]: "# Demo\n", + [DIST_RUNTIME_EXTENSION_INDEX]: "export default {};\n", + [DIST_RUNTIME_EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', + [DIST_RUNTIME_EXTENSION_SKILL]: "# Demo\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + }); + await fs.rm(resolvePath(tmp, DIST_RUNTIME_EXTENSION_SKILL)); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - env: { OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0" }, - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveRuntimePostBuildRequirement(createBuildRequirementDeps(tmp)); - expect(requirement).toEqual({ - shouldSync: false, - reason: "clean", - }); + expect(requirement).toEqual({ + shouldSync: true, + reason: "missing_runtime_postbuild_output", }); }); - it("does not require static asset outputs when the declared source is absent", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [DIFFS_PACKAGE]: - '{"openclaw":{"build":{"staticAssets":[{"source":"./assets/viewer-runtime.js","output":"assets/viewer-runtime.js"}]}}}\n', - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ROOT_SRC, DIFFS_PACKAGE, DIST_ENTRY, BUILD_STAMP, RUNTIME_POSTBUILD_STAMP], - }); + it("reports dirty runtime postbuild inputs separately from rebuild inputs", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_INDEX]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + [DIST_EXTENSION_INDEX]: "export default {};\n", + }, + trackConfig: true, + }); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const deps = createBuildRequirementDeps(tmp, { gitStatus: ` M ${EXTENSION_MANIFEST}\n` }); - expect(requirement).toEqual({ - shouldSync: false, - reason: "clean", - }); + expect(resolveBuildRequirement(deps)).toEqual({ + shouldBuild: false, + reason: "clean", + }); + expect(resolveRuntimePostBuildRequirement(deps)).toEqual({ + shouldSync: true, + reason: "dirty_runtime_postbuild_inputs", }); }); - it("reports missing core runtime postbuild outputs when runtime stamps match HEAD", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [DIST_STABLE_ROOT_RUNTIME_SOURCE]: "export const value = 1;\n", - [DIST_STABLE_ROOT_RUNTIME_ALIAS]: - "export * from './model-catalog.runtime-AbCd1234.js';\n", - [DIST_LEGACY_ROOT_RUNTIME_TARGET]: "export const aborted = true;\n", - [DIST_LEGACY_ROOT_RUNTIME_COMPAT]: "export * from './abort.runtime.js';\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - DIST_ENTRY, - DIST_STABLE_ROOT_RUNTIME_SOURCE, - DIST_STABLE_ROOT_RUNTIME_ALIAS, - DIST_LEGACY_ROOT_RUNTIME_TARGET, - DIST_LEGACY_ROOT_RUNTIME_COMPAT, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); + it("ignores dirty generated plugin bundle artifacts when dist is current", async ({ tmp }) => { + await setupStampedProject(tmp, { oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE] }); - for (const missingPath of [ - DIST_CHANNEL_CATALOG, - DIST_LEGACY_CLI_EXIT_COMPAT, - DIST_STABLE_ROOT_RUNTIME_ALIAS, - DIST_LEGACY_ROOT_RUNTIME_COMPAT, - ]) { - const resolvedMissingPath = resolvePath(tmp, missingPath); - const originalContent = await fs.readFile(resolvedMissingPath, "utf8"); - await fs.rm(resolvedMissingPath); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { + gitStatus: ` M ${GENERATED_PLUGIN_ASSET_BUNDLE_HASH}\n M ${GENERATED_PLUGIN_ASSET_BUNDLE}\n`, + }), + ); - expect(requirement).toEqual({ - shouldSync: true, - reason: "missing_runtime_postbuild_output", - }); - await fs.writeFile(resolvedMissingPath, originalContent); - } + expect(requirement).toEqual({ + shouldBuild: false, + reason: "clean", }); }); - it("does not require ambiguous stable runtime aliases that postbuild cannot create", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [DIST_STABLE_ROOT_RUNTIME_SOURCE]: "export const value = 1;\n", - [DIST_STABLE_ROOT_RUNTIME_SOURCE_ALT]: "export const value = 2;\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - DIST_ENTRY, - DIST_STABLE_ROOT_RUNTIME_SOURCE, - DIST_STABLE_ROOT_RUNTIME_SOURCE_ALT, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); + it("reports bundled skill edits as runtime postbuild inputs", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', + [EXTENSION_SKILL]: "# Demo\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + trackConfig: true, + }); - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); + const deps = createBuildRequirementDeps(tmp, { gitStatus: ` M ${EXTENSION_SKILL}\n` }); - expect(requirement).toEqual({ - shouldSync: false, - reason: "clean", - }); + expect(resolveRuntimePostBuildRequirement(deps)).toEqual({ + shouldSync: true, + reason: "dirty_runtime_postbuild_inputs", }); }); - it("reports missing runtime skill outputs even when stamps match HEAD", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_INDEX]: "export default {};\n", - [EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', - [EXTENSION_SKILL]: "# Demo\n", - [DIST_EXTENSION_INDEX]: "export default {};\n", - [DIST_EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', - [DIST_EXTENSION_SKILL]: "# Demo\n", - [DIST_RUNTIME_EXTENSION_INDEX]: "export default {};\n", - [DIST_RUNTIME_EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', - [DIST_RUNTIME_EXTENSION_SKILL]: "# Demo\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_INDEX, - EXTENSION_MANIFEST, - EXTENSION_SKILL, - DIST_ENTRY, - DIST_EXTENSION_INDEX, - DIST_EXTENSION_MANIFEST, - DIST_EXTENSION_SKILL, - DIST_RUNTIME_EXTENSION_INDEX, - DIST_RUNTIME_EXTENSION_MANIFEST, - DIST_RUNTIME_EXTENSION_SKILL, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - await fs.rm(resolvePath(tmp, DIST_RUNTIME_EXTENSION_SKILL)); - - const requirement = resolveRuntimePostBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: "", - }), - ); - - expect(requirement).toEqual({ - shouldSync: true, - reason: "missing_runtime_postbuild_output", - }); + it("repairs missing bundled plugin metadata without rerunning tsdown", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [EXTENSION_INDEX]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_INDEX]: "export default {};\n", + }, + trackConfig: true, }); + + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: syncBundledPluginMetadata, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo"); }); - it("reports dirty runtime postbuild inputs separately from rebuild inputs", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_INDEX]: "export default {};\n", - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - [DIST_EXTENSION_INDEX]: "export default {};\n", - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_INDEX, - EXTENSION_MANIFEST, - DIST_EXTENSION_INDEX, - ROOT_TSCONFIG, - ROOT_PACKAGE, - DIST_ENTRY, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - - const deps = createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: ` M ${EXTENSION_MANIFEST}\n`, - }); - - expect(resolveBuildRequirement(deps)).toEqual({ - shouldBuild: false, - reason: "clean", - }); - expect(resolveRuntimePostBuildRequirement(deps)).toEqual({ - shouldSync: true, - reason: "dirty_runtime_postbuild_inputs", - }); + it("removes stale bundled plugin metadata when the source manifest is gone", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { + [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_MANIFEST]: '{"id":"stale","configSchema":{"type":"object"}}\n', + [DIST_EXTENSION_PACKAGE]: '{"name":"stale"}\n', + }, + trackConfig: true, }); + + await fs.mkdir(resolvePath(tmp, bundledPluginRoot("demo")), { recursive: true }); + + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: syncBundledPluginMetadata, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + await expectPathMissing(resolvePath(tmp, DIST_EXTENSION_MANIFEST)); + await expectPathMissing(resolvePath(tmp, DIST_EXTENSION_PACKAGE)); }); - it("ignores dirty generated plugin bundle artifacts when dist is current", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - }); - - const requirement = resolveBuildRequirement( - createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: ` M ${GENERATED_PLUGIN_ASSET_BUNDLE_HASH}\n M ${GENERATED_PLUGIN_ASSET_BUNDLE}\n`, - }), - ); - - expect(requirement).toEqual({ - shouldBuild: false, - reason: "clean", - }); + it("skips rebuilding when only non-source extension files are newer than the build stamp", async ({ + tmp, + }) => { + await setupStampedProject(tmp, { + files: { [EXTENSION_README]: "# demo\n", [ROOT_TSDOWN]: "export default {};\n" }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN], + newPaths: [EXTENSION_README], }); + + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); }); - it("reports bundled skill edits as runtime postbuild inputs", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', - [EXTENSION_SKILL]: "# Demo\n", - [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_MANIFEST, - EXTENSION_SKILL, - ROOT_TSCONFIG, - ROOT_PACKAGE, - DIST_ENTRY, - BUILD_STAMP, - RUNTIME_POSTBUILD_STAMP, - ], - }); - - const deps = createBuildRequirementDeps(tmp, { - gitHead: "abc123\n", - gitStatus: ` M ${EXTENSION_SKILL}\n`, - }); - - expect(resolveRuntimePostBuildRequirement(deps)).toEqual({ - shouldSync: true, - reason: "dirty_runtime_postbuild_inputs", - }); + it("rebuilds when tsdown config is newer than the build stamp", async ({ tmp }) => { + await setupStampedProject(tmp, { + files: { [ROOT_TSDOWN]: "export default {};\n" }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + newPaths: [ROOT_TSDOWN], }); - }); - it("repairs missing bundled plugin metadata without rerunning tsdown", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_INDEX]: "export default {};\n", - [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', - [ROOT_TSDOWN]: "export default {};\n", - [DIST_EXTENSION_INDEX]: "export default {};\n", - }, - buildPaths: [ - ROOT_SRC, - EXTENSION_INDEX, - EXTENSION_MANIFEST, - ROOT_TSCONFIG, - ROOT_PACKAGE, - ROOT_TSDOWN, - DIST_ENTRY, - DIST_EXTENSION_INDEX, - BUILD_STAMP, - ], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: syncBundledPluginMetadata, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo"); + const { spawnCalls, spawn, spawnSync } = createCurrentGitSpawnRecorder(); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild: skipRuntimePostBuild, }); - }); - it("removes stale bundled plugin metadata when the source manifest is gone", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [ROOT_TSDOWN]: "export default {};\n", - [DIST_EXTENSION_MANIFEST]: '{"id":"stale","configSchema":{"type":"object"}}\n', - [DIST_EXTENSION_PACKAGE]: '{"name":"stale"}\n', - }, - buildPaths: [ - ROOT_SRC, - ROOT_TSCONFIG, - ROOT_PACKAGE, - ROOT_TSDOWN, - DIST_ENTRY, - BUILD_STAMP, - DIST_EXTENSION_MANIFEST, - DIST_EXTENSION_PACKAGE, - ], - }); - - await fs.mkdir(resolvePath(tmp, bundledPluginRoot("demo")), { recursive: true }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: syncBundledPluginMetadata, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - await expectPathMissing(resolvePath(tmp, DIST_EXTENSION_MANIFEST)); - await expectPathMissing(resolvePath(tmp, DIST_EXTENSION_PACKAGE)); - }); - }); - - it("skips rebuilding when only non-source extension files are newer than the build stamp", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_README]: "# demo\n", - [ROOT_TSDOWN]: "export default {};\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - newPaths: [EXTENSION_README], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder(); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([statusCommandSpawn()]); - }); - }); - - it("rebuilds when tsdown config is newer than the build stamp", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - await setupTrackedProject(tmp, { - files: { - [ROOT_SRC]: "export const value = 1;\n", - [ROOT_TSDOWN]: "export default {};\n", - }, - oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], - buildPaths: [DIST_ENTRY, BUILD_STAMP], - newPaths: [ROOT_TSDOWN], - }); - - const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ - gitHead: "abc123\n", - gitStatus: "", - }); - const exitCode = await runStatusCommand({ - tmp, - spawn, - spawnSync, - runRuntimePostBuild: skipRuntimePostBuild, - }); - - expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBundledPluginAssetBuildSpawn(), - expectedBuildSpawn(), - statusCommandSpawn(), - ]); - }); + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBundledPluginAssetBuildSpawn(), + expectedBuildSpawn(), + statusCommandSpawn(), + ]); }); describe("acquireRunNodeBuildLock", () => { @@ -2954,101 +2140,93 @@ describe("run-node script", () => { stderr: { write: () => true } as unknown as NodeJS.WriteStream, }); - it("releases the lock directory when the wrapper receives SIGINT", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - const fakeProcess = createFakeProcess(); - const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); + it("releases the lock directory when the wrapper receives SIGINT", async ({ tmp }) => { + const fakeProcess = createFakeProcess(); + const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); - const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); - expect(fsSync.existsSync(lockDir)).toBe(true); + const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); + expect(fsSync.existsSync(lockDir)).toBe(true); - fakeProcess.emit("SIGINT"); - expect(fsSync.existsSync(lockDir)).toBe(false); + fakeProcess.emit("SIGINT"); + expect(fsSync.existsSync(lockDir)).toBe(false); - // Normal release after signal must be a no-op. - expect(release()).toBeUndefined(); - expect(fakeProcess.listenerCount("SIGINT")).toBe(0); - expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); - expect(fakeProcess.listenerCount("exit")).toBe(0); - }); + // Normal release after signal must be a no-op. + expect(release()).toBeUndefined(); + expect(fakeProcess.listenerCount("SIGINT")).toBe(0); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); + expect(fakeProcess.listenerCount("exit")).toBe(0); }); - it("releases the lock directory when the wrapper receives SIGTERM", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - const fakeProcess = createFakeProcess(); - const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); + it("releases the lock directory when the wrapper receives SIGTERM", async ({ tmp }) => { + const fakeProcess = createFakeProcess(); + const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); - const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); - expect(fsSync.existsSync(lockDir)).toBe(true); + const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); + expect(fsSync.existsSync(lockDir)).toBe(true); - fakeProcess.emit("SIGTERM"); - expect(fsSync.existsSync(lockDir)).toBe(false); - expect(release()).toBeUndefined(); - }); + fakeProcess.emit("SIGTERM"); + expect(fsSync.existsSync(lockDir)).toBe(false); + expect(release()).toBeUndefined(); }); - it("releases the lock directory on process exit", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - const fakeProcess = createFakeProcess(); - const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); + it("releases the lock directory on process exit", async ({ tmp }) => { + const fakeProcess = createFakeProcess(); + const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); - const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); - expect(fsSync.existsSync(lockDir)).toBe(true); + const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); + expect(fsSync.existsSync(lockDir)).toBe(true); - fakeProcess.emit("exit"); - expect(fsSync.existsSync(lockDir)).toBe(false); - expect(release()).toBeUndefined(); - }); + fakeProcess.emit("exit"); + expect(fsSync.existsSync(lockDir)).toBe(false); + expect(release()).toBeUndefined(); }); - it("detaches signal listeners after a normal release", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - const fakeProcess = createFakeProcess(); - const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); + it("detaches signal listeners after a normal release", async ({ tmp }) => { + const fakeProcess = createFakeProcess(); + const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); - const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); - expect(fakeProcess.listenerCount("SIGINT")).toBe(1); - expect(fakeProcess.listenerCount("SIGTERM")).toBe(1); - expect(fakeProcess.listenerCount("exit")).toBe(1); + const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); + expect(fakeProcess.listenerCount("SIGINT")).toBe(1); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(1); + expect(fakeProcess.listenerCount("exit")).toBe(1); - release(); - expect(fsSync.existsSync(lockDir)).toBe(false); - expect(fakeProcess.listenerCount("SIGINT")).toBe(0); - expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); - expect(fakeProcess.listenerCount("exit")).toBe(0); - }); + release(); + expect(fsSync.existsSync(lockDir)).toBe(false); + expect(fakeProcess.listenerCount("SIGINT")).toBe(0); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); + expect(fakeProcess.listenerCount("exit")).toBe(0); }); - it("removes a lock left by a dead wrapper process without waiting for age-out", async () => { - await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { - const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); - await fs.mkdir(lockDir, { recursive: true }); - await fs.writeFile( - path.join(lockDir, "owner.json"), - JSON.stringify({ pid: 987654, args: ["gateway"] }), - "utf-8", - ); + it("removes a lock left by a dead wrapper process without waiting for age-out", async ({ + tmp, + }) => { + const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile( + path.join(lockDir, "owner.json"), + JSON.stringify({ pid: 987654, args: ["gateway"] }), + "utf-8", + ); - const fakeProcess = Object.assign(createFakeProcess(), { - kill: vi.fn((pid: number, signal?: NodeJS.Signals | number) => { - if (pid === 987654 && signal === 0) { - const err = new Error("missing process") as Error & { code: string }; - err.code = "ESRCH"; - throw err; - } - return true; - }), - }) as unknown as NodeJS.Process; + const fakeProcess = Object.assign(createFakeProcess(), { + kill: vi.fn((pid: number, signal?: NodeJS.Signals | number) => { + if (pid === 987654 && signal === 0) { + const err = new Error("missing process") as Error & { code: string }; + err.code = "ESRCH"; + throw err; + } + return true; + }), + }) as unknown as NodeJS.Process; - const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); - expect(fakeProcess["kill"]).toHaveBeenCalledWith(987654, 0); - expect(JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf-8")).pid).toBe( - 4242, - ); + const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); + expect(fakeProcess["kill"]).toHaveBeenCalledWith(987654, 0); + expect(JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf-8")).pid).toBe( + 4242, + ); - release(); - expect(fsSync.existsSync(lockDir)).toBe(false); - }); + release(); + expect(fsSync.existsSync(lockDir)).toBe(false); }); }); });