Files
openclaw/test/scripts/run-node.test-support.ts
Peter Steinberger bae12326a6 fix(update): rebuild stale Git runtimes when source is already current (#156996)
* fix(update): rebuild stale git runtimes through managed handoff

* fix(update): decide Git no-ops from build provenance

* fix(update): rebuild Git runtimes without provenance
2026-09-24 15:38:14 +00:00

482 lines
17 KiB
TypeScript

// Shared filesystem and process fixtures for the node launcher boundary.
// Tests node process runner lifecycle and captured output.
import { execFileSync, spawnSync as realSpawnSync } from "node:child_process";
import { EventEmitter } from "node:events";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
bundledDistPluginFile,
bundledPluginFile,
bundledPluginRoot,
} from "openclaw/plugin-sdk/test-fixtures";
import { expect, it as baseIt, vi } from "vitest";
import { copyBundledPluginMetadata } from "../../scripts/copy-bundled-plugin-metadata.mts";
import {
BUILD_STAMP_FILE,
RUNTIME_POSTBUILD_STAMP_FILE,
} from "../../scripts/lib/local-build-metadata-paths.mts";
import {
writeBuildStamp,
writeRuntimePostBuildStamp,
} from "../../scripts/lib/local-build-metadata.mts";
import {
UPDATE_COMPATIBILITY_INVENTORY_FILE,
writeUpdateCompatibilityChunks,
} from "../../scripts/lib/update-compat-chunks.mts";
import { runNodeMain } from "../../scripts/run-node.mts";
import { withTestDir } from "../../src/test-helpers/temp-dir.js";
// These launcher fixtures have no service. Publication custody is covered at its owner.
vi.mock("../../src/cli/update-cli/update-command-service-publication.js", () => ({
withGatewayRuntimeArtifactPublication: async (
_params: unknown,
publish: () => Promise<unknown>,
) => publish(),
}));
import {
previousReleaseInventory,
writeUpdateCompatibilityBuildFixture,
} from "./update-compat-chunks.test-support.js";
export const it = baseIt.extend<{ tmp: string }>({
tmp: async ({ task: _task }, use) => {
await withTestDir({ prefix: "openclaw-run-node-" }, use);
},
});
export const ROOT_SRC = "src/index.ts";
export const ROOT_TSCONFIG = "tsconfig.json";
export const ROOT_PACKAGE = "package.json";
export const ROOT_TSDOWN = "tsdown.config.ts";
export const RUNTIME_POSTBUILD_IMPLEMENTATION_PATHS = [
"scripts/check-built-plugin-control-plane-modules.mts",
"scripts/copy-bundled-plugin-metadata.mts",
"scripts/copy-hook-metadata.ts",
"scripts/runtime-postbuild.mts",
"scripts/stage-bundled-plugin-runtime.mts",
"scripts/write-build-info.ts",
"scripts/write-official-channel-catalog.mts",
] as const;
const DEPLOYMENT_MANIFEST = "deployment.json";
export const GENERATED_PLUGIN_ASSET_BUNDLE = "extensions/demo/src/host/assets/view.bundle.js";
export const GENERATED_PLUGIN_ASSET_BUNDLE_HASH = "extensions/demo/src/host/assets/.bundle.hash";
export const DIST_ENTRY = "dist/entry.js";
export const BUILD_STAMP = `dist/${BUILD_STAMP_FILE}`;
export const RUNTIME_POSTBUILD_STAMP = `dist/${RUNTIME_POSTBUILD_STAMP_FILE}`;
export const DIST_PLUGIN_SDK_CORE = "dist/plugin-sdk/core.js";
export const DIST_CHANNEL_CATALOG = "dist/channel-catalog.json";
export const DIST_BUILD_INFO = "dist/build-info.json";
export const DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT = "dist/shared-Y6bNiw2w.js";
export const DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT_ALT = "dist/shared-DTaQo6Hi.js";
export const DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT_0229A108 = "dist/shared-1Uyqkfns.js";
export const DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT_2026_9_1 = "dist/shared-DFJEouXv.js";
export const DIST_STABLE_ROOT_RUNTIME_SOURCE = "dist/model-catalog.runtime-AbCd1234.js";
export const DIST_STABLE_ROOT_RUNTIME_SOURCE_ALT = "dist/model-catalog.runtime-EfGh5678.js";
export const DIST_STABLE_ROOT_RUNTIME_ALIAS = "dist/model-catalog.runtime.js";
export const DIST_LEGACY_ROOT_RUNTIME_TARGET = "dist/text-transforms.runtime.js";
export const DIST_LEGACY_ROOT_RUNTIME_COMPAT = "dist/text-transforms.runtime-sEqsN4pN.js";
export const QA_LAB_PLUGIN_SDK_ENTRY = "dist/plugin-sdk/qa-lab.js";
export const QA_RUNTIME_PLUGIN_SDK_ENTRY = "dist/plugin-sdk/qa-runtime.js";
export const EXTENSION_INDEX = bundledPluginFile("demo", "index.ts");
export const EXTENSION_SRC = bundledPluginFile("demo", "src/index.ts");
export const EXTENSION_EXTRA_SRC = bundledPluginFile("demo", "src/extra.ts");
export const EXTENSION_SKILL = bundledPluginFile("demo", "skills/SKILL.md");
export const EXTENSION_MANIFEST = bundledPluginFile("demo", "openclaw.plugin.json");
export const EXTENSION_PACKAGE = bundledPluginFile("demo", "package.json");
export const EXTENSION_README = bundledPluginFile("demo", "README.md");
export const DIST_EXTENSION_INDEX = bundledDistPluginFile("demo", "index.js");
export const DIST_EXTENSION_SRC = bundledDistPluginFile("demo", "src/index.js");
export const DIST_EXTENSION_SKILL = bundledDistPluginFile("demo", "skills/SKILL.md");
export const DIST_EXTENSION_RUNTIME_SRC = "dist-runtime/extensions/demo/src/index.js";
export const DIST_RUNTIME_EXTENSION_INDEX = "dist-runtime/extensions/demo/index.js";
export const DIST_RUNTIME_EXTENSION_MANIFEST = "dist-runtime/extensions/demo/openclaw.plugin.json";
export const DIST_RUNTIME_EXTENSION_PACKAGE = "dist-runtime/extensions/demo/package.json";
export const DIST_RUNTIME_EXTENSION_SKILL = "dist-runtime/extensions/demo/skills/SKILL.md";
export const DIST_OPENCLAW_ALIAS_PACKAGE = "dist/extensions/node_modules/openclaw/package.json";
export const DIST_OPENCLAW_ALIAS_PLUGIN_SDK_CORE =
"dist/extensions/node_modules/openclaw/plugin-sdk/core.js";
export const DIST_OPENCLAW_ALIAS_PLUGIN_SDK_STRING_COERCE =
"dist/extensions/node_modules/openclaw/plugin-sdk/string-coerce-runtime.js";
export const DIFFS_PACKAGE = "extensions/diffs/package.json";
export const DIFFS_VIEWER_RUNTIME_SOURCE = "extensions/diffs/assets/viewer-runtime.js";
export const DIST_DIFFS_VIEWER_RUNTIME = "dist/extensions/diffs/assets/viewer-runtime.js";
export const DIST_RUNTIME_DIFFS_VIEWER_RUNTIME =
"dist-runtime/extensions/diffs/assets/viewer-runtime.js";
export const BUNDLED_HOOK_METADATA = "src/hooks/bundled/demo/HOOK.md";
export const DIST_BUNDLED_HOOK_METADATA = "dist/bundled/demo/HOOK.md";
export const DIST_EXTENSION_MANIFEST = bundledDistPluginFile("demo", "openclaw.plugin.json");
export const DIST_EXTENSION_PACKAGE = bundledDistPluginFile("demo", "package.json");
const OLD_TIME = new Date("2026-03-13T10:00:00.000Z");
const BUILD_TIME = new Date("2026-03-13T12:00:00.000Z");
export const NEW_TIME = new Date("2026-03-13T12:00:01.000Z");
const BASE_PROJECT_FILES = {
[ROOT_TSCONFIG]: "{}\n",
[ROOT_PACKAGE]: '{"name":"openclaw-test"}\n',
[DIST_ENTRY]: "console.log('built');\n",
[BUILD_STAMP]: '{"head":"abc123","inputsClean":true}\n',
} as const;
export function createExitedProcess(code: number | null, signal: string | null = null) {
return {
on: (event: string, cb: (code: number | null, signal: string | null) => void) => {
if (event === "exit") {
queueMicrotask(() => cb(code, signal));
}
return undefined;
},
};
}
export function createPipedExitedProcess(params: {
code?: number | null;
signal?: string | null;
stderr?: string;
stdout?: string;
}) {
const stdout = new EventEmitter();
const stderr = new EventEmitter();
return {
stdout,
stderr,
on: (event: string, cb: (code: number | null, signal: string | null) => void) => {
if (event === "exit") {
queueMicrotask(() => {
if (params.stdout) {
stdout.emit("data", Buffer.from(params.stdout));
}
if (params.stderr) {
stderr.emit("data", Buffer.from(params.stderr));
}
cb(params.code ?? 0, params.signal ?? null);
});
}
return undefined;
},
};
}
export function createFakeProcess() {
return Object.assign(new EventEmitter(), {
pid: 4242,
execPath: process.execPath,
}) as unknown as NodeJS.Process;
}
// Launcher plumbing tests do not need the real runtime artifact copier.
export async function skipRuntimePostBuild(): Promise<void> {}
export async function syncBundledPluginMetadata(params?: {
cwd?: string;
env?: Record<string, string | undefined>;
}): Promise<void> {
copyBundledPluginMetadata({ cwd: params?.cwd, env: params?.env });
}
export function firstMockCall<T extends unknown[]>(mock: { mock: { calls: T[] } }): T | undefined {
return mock.mock.calls[0];
}
export async function writeRuntimePostBuildScaffold(tmp: string): Promise<void> {
await fs.mkdir(path.join(tmp, "extensions"), { recursive: true });
await writeProjectFiles(tmp, {
[DIST_PLUGIN_SDK_CORE]: "export const core = true;\n",
[DIST_CHANNEL_CATALOG]: '{"entries":[]}\n',
[DIST_BUILD_INFO]: '{"buildId":"test-build"}\n',
[DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT]: "export function resolveNodeRunner() {}\n",
[DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT_ALT]: "export function resolveNodeRunner() {}\n",
[DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT_0229A108]: "export function resolveNodeRunner() {}\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",
});
writeUpdateCompatibilityBuildFixture(tmp);
writeUpdateCompatibilityChunks({
distDir: path.join(tmp, "dist"),
sourceDir: tmp,
inventory: previousReleaseInventory,
});
await touchProjectFiles(
tmp,
[
DIST_CHANNEL_CATALOG,
DIST_BUILD_INFO,
DIST_PLUGIN_SDK_CORE,
DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT,
DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT_ALT,
DIST_LEGACY_UPDATE_NODE_RUNNER_COMPAT_0229A108,
`dist/${UPDATE_COMPATIBILITY_INVENTORY_FILE}`,
...previousReleaseInventory.releases.flatMap((release) =>
release.chunks.map((chunk) => `dist/${chunk.path}`),
),
DIST_OPENCLAW_ALIAS_PACKAGE,
DIST_OPENCLAW_ALIAS_PLUGIN_SDK_CORE,
],
BUILD_TIME,
);
}
export function expectedBuildSpawn() {
return [
process.execPath,
"--import",
expect.stringMatching(/\/scripts\/tsx\.mjs$/),
expect.stringMatching(/[\\/]scripts[\\/]lib[\\/]dist-artifact-ownership\.mts$/),
expect.stringMatching(/\/scripts\/build-all\.mts$/),
"qaRuntime",
];
}
export function statusCommandSpawn() {
return [process.execPath, "openclaw.mjs", "status"];
}
export function gatewayStatusCommandSpawn() {
return [
process.execPath,
"openclaw.mjs",
"gateway",
"status",
"--deep",
"--require-rpc",
"--json",
];
}
export function resolvePath(tmp: string, relativePath: string) {
return path.join(tmp, relativePath);
}
export function isTsxScriptArgs(args: string[], scriptPath: string): boolean {
return args[0] === "--import" && args.some((arg) => arg.endsWith(scriptPath));
}
export async function expectPathMissing(targetPath: string): Promise<void> {
let accessError: unknown;
try {
await fs.access(targetPath);
} catch (error) {
accessError = error;
}
expect((accessError as NodeJS.ErrnoException | undefined)?.code).toBe("ENOENT");
}
async function writeProjectFiles(tmp: string, files: Record<string, string>) {
await Promise.all(
Object.entries(files).map(async ([relativePath, contents]) => {
const absolutePath = resolvePath(tmp, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, contents, "utf-8");
}),
);
}
export async function touchProjectFiles(tmp: string, relativePaths: string[], time: Date) {
await Promise.all(
relativePaths.map(async (relativePath) => {
const absolutePath = resolvePath(tmp, relativePath);
await fs.utimes(absolutePath, time, time);
}),
);
}
export async function setupTrackedProject(
tmp: string,
options: {
files?: Record<string, string>;
oldPaths?: string[];
buildPaths?: string[];
newPaths?: string[];
} = {},
) {
await writeRuntimePostBuildScaffold(tmp);
await writeProjectFiles(tmp, {
...BASE_PROJECT_FILES,
...options.files,
});
await touchProjectFiles(tmp, options.oldPaths ?? [], OLD_TIME);
await touchProjectFiles(tmp, options.buildPaths ?? [], BUILD_TIME);
await touchProjectFiles(tmp, options.newPaths ?? [], NEW_TIME);
}
export async function setupStampedProject(
tmp: string,
options: {
files?: Record<string, string>;
oldPaths?: string[];
newPaths?: string[];
rootSource?: boolean;
trackConfig?: boolean;
},
): Promise<void> {
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 } : {}),
});
}
export async function writeImmutableDeploymentManifest(tmp: string): Promise<void> {
await writeProjectFiles(tmp, {
[DEPLOYMENT_MANIFEST]: `${JSON.stringify({ kind: "git", sourceHead: "a".repeat(40) })}\n`,
});
}
export function createSpawnRecorder(
options: {
gitHead?: string;
gitStatus?: string;
} = {},
) {
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse" && options.gitHead !== undefined) {
return { status: 0, stdout: options.gitHead };
}
if (cmd === "git" && args[0] === "status" && options.gitStatus !== undefined) {
return { status: 0, stdout: options.gitStatus };
}
return { status: 1, stdout: "" };
};
return { spawnCalls, spawn, spawnSync };
}
export function createCurrentGitSpawnRecorder(
options: { gitHead?: string; gitStatus?: string } = {},
) {
return createSpawnRecorder({ gitHead: "abc123\n", gitStatus: "", ...options });
}
export function createBuildRequirementDeps(
tmp: string,
options: {
gitStatus?: string;
env?: Record<string, string>;
} = {},
) {
const { spawnSync } = createCurrentGitSpawnRecorder({ gitStatus: options.gitStatus ?? "" });
return {
cwd: tmp,
env: {
...process.env,
...options.env,
},
fs: fsSync,
spawnSync,
distRoot: path.join(tmp, "dist"),
distEntry: path.join(tmp, DIST_ENTRY),
buildStampPath: path.join(tmp, BUILD_STAMP),
runtimePostBuildStampPath: path.join(tmp, RUNTIME_POSTBUILD_STAMP),
sourceRoots: [path.join(tmp, "src"), path.join(tmp, bundledPluginRoot("demo"))].map(
(sourceRoot) => ({
name: path.relative(tmp, sourceRoot).replaceAll("\\", "/"),
path: sourceRoot,
}),
),
configFiles: [ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN].map((filePath) =>
path.join(tmp, filePath),
),
};
}
export async function trackProjectWithGit(tmp: string) {
const git = (...args: string[]) =>
execFileSync(
"git",
["-c", `core.hooksPath=${path.join(tmp, ".git", "disabled-hooks")}`, ...args],
{ cwd: tmp, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
git("init", "--quiet", "--template=");
git("config", "core.quotePath", "true");
git("add", "--all");
git(
"-c",
"user.name=OpenClaw Test",
"-c",
"user.email=test@openclaw.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"--quiet",
"-m",
"test: track runner fixture",
);
writeBuildStamp({ cwd: tmp, spawnSync: realSpawnSync });
writeRuntimePostBuildStamp({ cwd: tmp, spawnSync: realSpawnSync });
await touchProjectFiles(tmp, [BUILD_STAMP, RUNTIME_POSTBUILD_STAMP], BUILD_TIME);
return {
git,
deps: { ...createBuildRequirementDeps(tmp), env: {}, spawnSync: realSpawnSync },
};
}
type RunNodeTestOptions = NonNullable<Parameters<typeof runNodeMain>[0]> & {
stdout?: NodeJS.WriteStream;
};
type RunNodeResult = Awaited<ReturnType<typeof runNodeMain>>;
export async function runNodeCommand(
tmp: string,
options: RunNodeTestOptions,
): Promise<RunNodeResult> {
const { env, ...overrides } = options;
return await runNodeMain({
cwd: tmp,
args: ["status"],
...overrides,
env: { ...process.env, OPENCLAW_RUNNER_LOG: "0", ...env },
execPath: process.execPath,
platform: options.platform ?? process.platform,
} as RunNodeTestOptions);
}
type RunCommandParams = {
tmp: string;
args?: string[];
spawn: (cmd: string, args: string[]) => ReturnType<typeof createExitedProcess>;
spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string };
stderr?: NodeJS.WriteStream;
env?: Record<string, string>;
runRuntimePostBuild?: (params?: {
cwd?: string;
env?: Record<string, string | undefined>;
}) => void | Promise<void>;
};
export async function runStatusCommand({
tmp,
...options
}: RunCommandParams): Promise<RunNodeResult> {
return await runNodeCommand(tmp, options);
}
export async function runQaCommand(params: RunCommandParams): Promise<RunNodeResult> {
return await runStatusCommand({
...params,
args: ["qa", "suite", "--transport", "qa-channel", "--provider-mode", "mock-openai"],
});
}
export async function expectManifestId(tmp: string, relativePath: string, id: string) {
const manifest = JSON.parse(await fs.readFile(resolvePath(tmp, relativePath), "utf-8")) as {
id?: unknown;
};
expect(manifest.id).toBe(id);
}