mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
* test(codex): deslop s003 tests * test(gateway): deslop s010 tests * test(agents): deslop s013 tests * test(commands): deslop s011 tests * test(gateway): deslop s014 tests * test(gateway): deslop s019 tests * test(infra): deslop s016 tests * test(agents): deslop s020 tests * test(scripts): deslop s017 tests * test(auto-reply): deslop s021 tests * test: align d009 replay with current fixture contracts * test(agents): remove orphaned BTW fixture export * test: preserve independent regression coverage in d009 Restore eight independent authentication, retry, transcript, goal, reply ownership, and Xcode coverage contracts identified during review. Final correction review passed; final Testbox validation remains pending after lease and transport failures.
1978 lines
75 KiB
TypeScript
1978 lines
75 KiB
TypeScript
// Runtime Postbuild tests cover runtime postbuild script behavior.
|
|
import childProcess from "node:child_process";
|
|
import fsSync from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
import { createNativeTypeScriptParser } from "../../scripts/lib/native-typescript.mts";
|
|
import {
|
|
copyStaticExtensionAssets,
|
|
copyStaticExtensionAssetsToRuntimeOverlay,
|
|
discoverStaticExtensionAssets,
|
|
} from "../../scripts/lib/static-extension-assets.mts";
|
|
import {
|
|
parseUpdateCompatibilityInventory,
|
|
readUpdateCompatibilityInventory,
|
|
recordUpdateCompatibilityRelease,
|
|
writeUpdateCompatibilityChunks,
|
|
type UpdateCompatibilityInventory,
|
|
type UpdateCompatibilityRelease,
|
|
} from "../../scripts/lib/update-compat-chunks.mts";
|
|
import { buildUpdateConfigRuntimeAlias } from "../../scripts/lib/update-config-runtime-compat.mts";
|
|
import {
|
|
rewriteRootRuntimeImportsToStableAliases,
|
|
runRuntimePostBuild,
|
|
writeLegacyCliExitCompatChunks,
|
|
writeLegacyRootRuntimeCompatAliases,
|
|
writeStableRootRuntimeAliases,
|
|
} from "../../scripts/runtime-postbuild.mts";
|
|
import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js";
|
|
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
|
|
import { readBuildIdFromBuildInfoForModuleUrl } from "../../src/version.js";
|
|
import { createScriptTestHarness } from "./test-helpers.js";
|
|
|
|
const parser = createNativeTypeScriptParser();
|
|
afterAll(() => parser.close());
|
|
const testNodeExecPath = resolveTestNodeExecPath();
|
|
import {
|
|
previousReleaseInventory,
|
|
writeUpdateCompatibilityBuildFixture,
|
|
} from "./update-compat-chunks.test-support.js";
|
|
|
|
const { createTempDir } = createScriptTestHarness();
|
|
const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
|
|
async function expectPathMissing(targetPath: string): Promise<void> {
|
|
let statError: unknown;
|
|
try {
|
|
await fs.stat(targetPath);
|
|
} catch (error) {
|
|
statError = error;
|
|
}
|
|
expect(statError).toBeInstanceOf(Error);
|
|
if (!(statError instanceof Error)) {
|
|
throw new Error("expected missing path error");
|
|
}
|
|
expect(Reflect.get(statError, "code")).toBe("ENOENT");
|
|
}
|
|
|
|
async function writeExportHtmlBuildFixture(rootDir: string): Promise<void> {
|
|
const sourceDir = path.join(rootDir, "src", "auto-reply", "reply", "export-html");
|
|
await fs.mkdir(sourceDir, { recursive: true });
|
|
await fs.writeFile(path.join(sourceDir, "template.html"), "<html></html>\n", "utf8");
|
|
await fs.writeFile(path.join(rootDir, "package.json"), "{}\n", "utf8");
|
|
for (const fixture of [
|
|
{
|
|
name: "marked",
|
|
exports: { ".": "./index.js", "./package.json": "./package.json" },
|
|
source: 'export const parse = () => "alternate-root-marked"; export const use = () => {};\n',
|
|
license: "ALTERNATE ROOT MARKED LICENSE\n",
|
|
},
|
|
{
|
|
name: "highlight.js",
|
|
exports: { "./lib/common": "./common.js", "./package.json": "./package.json" },
|
|
source: 'export default { marker: "alternate-root-highlight" };\n',
|
|
license: "ALTERNATE ROOT HIGHLIGHT LICENSE\n",
|
|
},
|
|
]) {
|
|
const packageDir = path.join(rootDir, "node_modules", fixture.name);
|
|
await fs.mkdir(packageDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(packageDir, "package.json"),
|
|
`${JSON.stringify({ name: fixture.name, type: "module", exports: fixture.exports })}\n`,
|
|
"utf8",
|
|
);
|
|
const entryName = fixture.name === "marked" ? "index.js" : "common.js";
|
|
await fs.writeFile(path.join(packageDir, entryName), fixture.source, "utf8");
|
|
await fs.writeFile(path.join(packageDir, "LICENSE"), fixture.license, "utf8");
|
|
}
|
|
}
|
|
|
|
describe("runtime postbuild static assets", () => {
|
|
it("copies bundled hook metadata without replacing compiled handlers", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-hooks-");
|
|
writeUpdateCompatibilityBuildFixture(rootDir);
|
|
const sourceHookDir = path.join(rootDir, "src", "hooks", "bundled", "session-memory");
|
|
const distHookDir = path.join(rootDir, "dist", "bundled", "session-memory");
|
|
await fs.mkdir(sourceHookDir, { recursive: true });
|
|
await fs.mkdir(distHookDir, { recursive: true });
|
|
await fs.writeFile(path.join(sourceHookDir, "HOOK.md"), "---\nname: session-memory\n---\n");
|
|
await fs.writeFile(path.join(distHookDir, "handler.js"), "export default () => {};\n");
|
|
|
|
runRuntimePostBuild({
|
|
rootDir,
|
|
env: { OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0" },
|
|
timings: false,
|
|
});
|
|
|
|
await expect(fs.readFile(path.join(distHookDir, "HOOK.md"), "utf8")).resolves.toContain(
|
|
"name: session-memory",
|
|
);
|
|
await expect(fs.readFile(path.join(distHookDir, "handler.js"), "utf8")).resolves.toBe(
|
|
"export default () => {};\n",
|
|
);
|
|
});
|
|
|
|
it("discovers repo static asset metadata without scanning extension directories", () => {
|
|
const payload = expectNoNodeFsScans<{
|
|
outputs: string[];
|
|
sources: string[];
|
|
packageOutputs: string[];
|
|
}>(`
|
|
const assets = await import("./scripts/lib/static-extension-assets.mts");
|
|
const discovered = assets.discoverStaticExtensionAssets();
|
|
const packageAssets = assets.discoverStaticExtensionAssets({ includeExternalPlugins: true });
|
|
return {
|
|
outputs: discovered.map(({ dest }) => dest),
|
|
sources: assets.listStaticExtensionAssetSources(),
|
|
packageOutputs: packageAssets.map(({ dest }) => dest),
|
|
};
|
|
`);
|
|
|
|
expect(payload.outputs).toEqual([
|
|
"dist/extensions/acpx/mcp-command-line.mjs",
|
|
"dist/extensions/acpx/mcp-proxy.mjs",
|
|
"dist/extensions/apple-fm/assets/AppleFoundationModels.swift",
|
|
"dist/extensions/code-mode-quickjs/assets/encoding.so",
|
|
"dist/extensions/code-mode-quickjs/assets/quickjs.wasm",
|
|
"dist/extensions/crabbox/assets/openclaw-worker-wallpaper.png",
|
|
"dist/extensions/onepassword/onepassword-op-path.js",
|
|
"dist/extensions/onepassword/onepassword-secret-id.js",
|
|
"dist/extensions/onepassword/onepassword-secret-ref-resolver.js",
|
|
"dist/extensions/vault/vault-secret-id.js",
|
|
"dist/extensions/vault/vault-secret-ref-resolver.js",
|
|
]);
|
|
expect(payload.sources).not.toContain(
|
|
"extensions/diffs-language-pack/assets/viewer-runtime.js",
|
|
);
|
|
expect(payload.sources).not.toContain("extensions/diffs/assets/viewer-runtime.js");
|
|
expect(payload.sources).not.toContain("extensions/discord/assets/embedded-app-sdk.mjs");
|
|
expect(payload.packageOutputs).toContain("dist/extensions/discord/assets/embedded-app-sdk.mjs");
|
|
expect(payload.sources).toContain("extensions/crabbox/assets/openclaw-worker-wallpaper.png");
|
|
});
|
|
|
|
it("copies each package asset once with multiple Git index stages", async () => {
|
|
const rootDir = createTempDir("openclaw-static-assets-index-");
|
|
const git = (args: string[], input?: string) =>
|
|
childProcess.execFileSync("git", args, { cwd: rootDir, encoding: "utf8", input });
|
|
const pluginIds = ["conflicted", "package-only", "with-manifest"];
|
|
for (const id of pluginIds) {
|
|
const pluginDir = path.join(rootDir, "extensions", id);
|
|
await fs.mkdir(pluginDir, { recursive: true });
|
|
await fs.writeFile(path.join(pluginDir, "asset.txt"), `${id} bytes\n`);
|
|
await fs.writeFile(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify({
|
|
openclaw: { build: { staticAssets: [{ source: "asset.txt", output: "asset.txt" }] } },
|
|
}),
|
|
);
|
|
}
|
|
for (const id of ["with-manifest", "manifest-only"]) {
|
|
const pluginDir = path.join(rootDir, "extensions", id);
|
|
await fs.mkdir(pluginDir, { recursive: true });
|
|
await fs.writeFile(path.join(pluginDir, "openclaw.plugin.json"), "not valid JSON");
|
|
}
|
|
git(["init", "-q"]);
|
|
git(["add", "extensions"]);
|
|
const packagePath = "extensions/conflicted/package.json";
|
|
const blob = git(["hash-object", "-w", "--stdin"], "{}").trim();
|
|
// Keep resolved working-tree metadata while the index still holds all merge stages.
|
|
git(
|
|
["update-index", "--index-info"],
|
|
[
|
|
`0 ${"0".repeat(blob.length)}\t${packagePath}`,
|
|
...[1, 2, 3].map((stage) => `100644 ${blob} ${stage}\t${packagePath}`),
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
expect(git(["ls-files", "--", packagePath]).trim().split("\n")).toHaveLength(3);
|
|
|
|
const copy = vi.spyOn(fsSync, "copyFileSync");
|
|
try {
|
|
copyStaticExtensionAssets({ rootDir });
|
|
expect(copy).toHaveBeenCalledTimes(3);
|
|
expect(discoverStaticExtensionAssets({ rootDir }).map(({ pluginDir }) => pluginDir)).toEqual(
|
|
pluginIds,
|
|
);
|
|
for (const id of pluginIds) {
|
|
await expect(
|
|
fs.readFile(path.join(rootDir, "dist", "extensions", id, "asset.txt"), "utf8"),
|
|
).resolves.toBe(`${id} bytes\n`);
|
|
}
|
|
} finally {
|
|
copy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ name: "top-level array", packageJson: [] },
|
|
{ name: "array openclaw section", packageJson: { openclaw: [] } },
|
|
{ name: "array build section", packageJson: { openclaw: { build: [] } } },
|
|
{
|
|
name: "non-record asset entries",
|
|
packageJson: {
|
|
openclaw: {
|
|
build: {
|
|
staticAssets: [[], "asset", null, { source: 42, output: [] }],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])("ignores malformed $name metadata", async ({ packageJson }) => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-malformed-");
|
|
const packageDir = path.join(rootDir, "extensions", "demo");
|
|
await fs.mkdir(packageDir, { recursive: true });
|
|
await fs.writeFile(path.join(packageDir, "package.json"), JSON.stringify(packageJson), "utf8");
|
|
|
|
expect(discoverStaticExtensionAssets({ rootDir })).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
{ name: "normal root build", params: {}, included: false },
|
|
{ name: "isolated external build", params: { includeExternalPlugins: true }, included: true },
|
|
{
|
|
name: "Docker-selected build",
|
|
params: { env: { OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS: "external-demo" } },
|
|
included: true,
|
|
},
|
|
])("$name handles external plugin assets", async ({ params, included }) => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const packageDir = path.join(rootDir, "extensions", "external-demo");
|
|
await fs.mkdir(packageDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(packageDir, "package.json"),
|
|
JSON.stringify({
|
|
name: "@openclaw/external-demo",
|
|
openclaw: {
|
|
build: {
|
|
bundledDist: false,
|
|
staticAssets: [
|
|
{
|
|
source: "./assets/runtime.js",
|
|
output: "assets/runtime.js",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
|
|
expect(discoverStaticExtensionAssets({ rootDir, ...params })).toEqual(
|
|
included
|
|
? [
|
|
{
|
|
pluginDir: "external-demo",
|
|
src: "extensions/external-demo/assets/runtime.js",
|
|
dest: "dist/extensions/external-demo/assets/runtime.js",
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{ name: "package-relative source", dependency: "", local: false, missing: false },
|
|
{ name: "hoisted dependency", dependency: "engine", local: false, missing: false },
|
|
{
|
|
name: "hoisted scoped dependency",
|
|
dependency: "@fixture/engine",
|
|
local: false,
|
|
missing: false,
|
|
},
|
|
{
|
|
name: "plugin-local dependency precedence",
|
|
dependency: "@fixture/engine",
|
|
local: true,
|
|
missing: false,
|
|
},
|
|
{
|
|
name: "missing asset in selected dependency",
|
|
dependency: "@fixture/engine",
|
|
local: true,
|
|
missing: true,
|
|
},
|
|
])("stages $name during the same postbuild run", async ({ dependency, local, missing }) => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const output = "assets/viewer-runtime.js";
|
|
const source = dependency ? `node_modules/${dependency}/private/runtime.js` : output;
|
|
const packageDir = path.join(rootDir, "extensions", "diffs");
|
|
const distAsset = "dist/extensions/diffs/assets/viewer-runtime.js";
|
|
const runtimeAsset = "dist-runtime/extensions/diffs/assets/viewer-runtime.js";
|
|
const contents = "export const viewer = true;\n";
|
|
const warn = vi.fn();
|
|
|
|
await fs.mkdir(path.join(rootDir, "extensions", "diffs", "assets"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(rootDir, "extensions", "diffs", "package.json"),
|
|
JSON.stringify({
|
|
name: "@openclaw/diffs",
|
|
...(dependency ? { dependencies: { [dependency]: "1.0.0" } } : {}),
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
build: {
|
|
staticAssets: [{ source: `./${source}`, output }],
|
|
},
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(rootDir, "extensions", "diffs", "openclaw.plugin.json"),
|
|
'{"id":"diffs"}\n',
|
|
"utf8",
|
|
);
|
|
if (dependency) {
|
|
for (const base of local ? [rootDir, packageDir] : [rootDir]) {
|
|
const dependencyDir = path.join(base, "node_modules", dependency);
|
|
await fs.mkdir(path.join(dependencyDir, "private"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(dependencyDir, "package.json"),
|
|
JSON.stringify({ name: dependency, exports: { "./runtime": "./private/runtime.js" } }),
|
|
);
|
|
if (!(missing && base === packageDir)) {
|
|
await fs.writeFile(
|
|
path.join(dependencyDir, "private/runtime.js"),
|
|
local && base === rootDir ? "wrong ancestor version\n" : contents,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
await fs.writeFile(path.join(packageDir, source), contents);
|
|
}
|
|
|
|
writeUpdateCompatibilityBuildFixture(rootDir);
|
|
runRuntimePostBuild({
|
|
cwd: rootDir,
|
|
repoRoot: rootDir,
|
|
rootDir,
|
|
timings: false,
|
|
warn,
|
|
});
|
|
|
|
for (const asset of [distAsset, runtimeAsset]) {
|
|
if (missing) {
|
|
await expectPathMissing(path.join(rootDir, asset));
|
|
} else {
|
|
await expect(fs.readFile(path.join(rootDir, asset), "utf8")).resolves.toBe(contents);
|
|
}
|
|
}
|
|
if (missing) {
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("static asset not found"));
|
|
}
|
|
});
|
|
|
|
it("writes every phase beneath the cwd-only caller root", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-cwd-");
|
|
await writeExportHtmlBuildFixture(rootDir);
|
|
writeUpdateCompatibilityBuildFixture(rootDir);
|
|
runRuntimePostBuild({
|
|
cwd: rootDir,
|
|
env: {
|
|
OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0",
|
|
OPENCLAW_CONTROL_UI_BUILD_ID: "source-runtime-build",
|
|
},
|
|
timings: false,
|
|
});
|
|
|
|
expect(
|
|
readBuildIdFromBuildInfoForModuleUrl(pathToFileURL(path.join(rootDir, "dist/entry.js")).href),
|
|
).toBe("source-runtime-build");
|
|
await expect(
|
|
fs.readFile(path.join(rootDir, "dist", "export-html", "template.html"), "utf8"),
|
|
).resolves.toBe("<html></html>\n");
|
|
const vendorDir = path.join(rootDir, "dist", "export-html", "vendor");
|
|
const markedAsset = await fs.readFile(path.join(vendorDir, "marked.min.js"), "utf8");
|
|
const highlightAsset = await fs.readFile(path.join(vendorDir, "highlight.min.js"), "utf8");
|
|
expect(markedAsset).toContain("ALTERNATE ROOT MARKED LICENSE");
|
|
expect(markedAsset).toContain("alternate-root-marked");
|
|
expect(highlightAsset).toContain("ALTERNATE ROOT HIGHLIGHT LICENSE");
|
|
expect(highlightAsset).toContain("alternate-root-highlight");
|
|
await expect(
|
|
fs.readFile(path.join(rootDir, "dist", "channel-catalog.json"), "utf8"),
|
|
).resolves.toContain('"entries"');
|
|
const bridge = await import(
|
|
pathToFileURL(path.join(rootDir, "dist", "shared-Y6bNiw2w.js")).href
|
|
);
|
|
expect(bridge.resolveNodeRunner()).toBe(process.versions.bun ? "node" : process.execPath);
|
|
});
|
|
|
|
it("uses rootDir ahead of conflicting cwd and repoRoot for every phase", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-root-");
|
|
const cwd = createTempDir("openclaw-runtime-postbuild-rejected-cwd-");
|
|
const repoRoot = createTempDir("openclaw-runtime-postbuild-rejected-repo-");
|
|
await writeExportHtmlBuildFixture(rootDir);
|
|
writeUpdateCompatibilityBuildFixture(rootDir);
|
|
|
|
runRuntimePostBuild({
|
|
cwd,
|
|
env: { OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0" },
|
|
repoRoot,
|
|
rootDir,
|
|
timings: false,
|
|
});
|
|
|
|
await expect(
|
|
fs.readFile(path.join(rootDir, "dist", "export-html", "template.html"), "utf8"),
|
|
).resolves.toBe("<html></html>\n");
|
|
await expect(
|
|
fs.readFile(path.join(rootDir, "dist", "channel-catalog.json"), "utf8"),
|
|
).resolves.toContain('"entries"');
|
|
await expect(
|
|
fs.readFile(path.join(rootDir, "dist", "shared-Y6bNiw2w.js"), "utf8"),
|
|
).resolves.toContain("resolveNodeRunner");
|
|
await expectPathMissing(path.join(cwd, "dist"));
|
|
await expectPathMissing(path.join(repoRoot, "dist"));
|
|
});
|
|
|
|
it("validates every postbuild root before running any phase", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-roots-");
|
|
const distFile = path.join(rootDir, "dist", "keep.js");
|
|
const targetDir = path.join(rootDir, "gateway-runtime");
|
|
await fs.mkdir(path.dirname(distFile), { recursive: true });
|
|
await fs.mkdir(targetDir);
|
|
await fs.writeFile(distFile, "keep\n");
|
|
await fs.symlink(targetDir, path.join(rootDir, "dist-runtime"), "dir");
|
|
|
|
expect(() =>
|
|
runRuntimePostBuild({
|
|
cwd: rootDir,
|
|
repoRoot: rootDir,
|
|
rootDir,
|
|
timings: false,
|
|
}),
|
|
).toThrow(/symbolic link/u);
|
|
|
|
await expect(fs.readdir(path.join(rootDir, "dist"))).resolves.toEqual(["keep.js"]);
|
|
await expect(fs.readFile(distFile, "utf8")).resolves.toBe("keep\n");
|
|
});
|
|
|
|
it("preserves restored dist static assets when plugin sources are absent", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const output = "assets/viewer-runtime.js";
|
|
const distPluginDir = path.join(rootDir, "dist", "extensions", "diffs");
|
|
const runtimeAsset = path.join(rootDir, "dist-runtime", "extensions", "diffs", output);
|
|
|
|
await fs.mkdir(path.join(distPluginDir, "assets"), { recursive: true });
|
|
await fs.writeFile(path.join(distPluginDir, "index.js"), "export default {};\n", "utf8");
|
|
await fs.writeFile(
|
|
path.join(distPluginDir, "openclaw.plugin.json"),
|
|
'{"id":"diffs"}\n',
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distPluginDir, "package.json"),
|
|
JSON.stringify({
|
|
name: "@openclaw/diffs",
|
|
openclaw: {
|
|
extensions: ["./index.js"],
|
|
build: {
|
|
staticAssets: [{ source: `./${output}`, output }],
|
|
},
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(path.join(distPluginDir, output), "console.log('viewer');\n", "utf8");
|
|
|
|
writeUpdateCompatibilityBuildFixture(rootDir);
|
|
runRuntimePostBuild({
|
|
cwd: rootDir,
|
|
repoRoot: rootDir,
|
|
rootDir,
|
|
timings: false,
|
|
});
|
|
|
|
await expect(fs.readFile(runtimeAsset, "utf8")).resolves.toBe("console.log('viewer');\n");
|
|
});
|
|
|
|
it("can skip static asset copies for minimal runtime builds", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
writeUpdateCompatibilityBuildFixture(rootDir);
|
|
const warn = vi.fn();
|
|
const output = "assets/viewer-runtime.js";
|
|
|
|
await fs.mkdir(path.join(rootDir, "extensions", "diffs"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(rootDir, "extensions", "diffs", "package.json"),
|
|
JSON.stringify({
|
|
name: "@openclaw/diffs",
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
build: {
|
|
staticAssets: [{ source: `./${output}`, output }],
|
|
},
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
|
|
runRuntimePostBuild({
|
|
cwd: rootDir,
|
|
repoRoot: rootDir,
|
|
rootDir,
|
|
env: { OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0" },
|
|
timings: false,
|
|
warn,
|
|
});
|
|
|
|
expect(warn).not.toHaveBeenCalled();
|
|
await expectPathMissing(path.join(rootDir, "dist", "extensions", "diffs", output));
|
|
});
|
|
|
|
it("skips runtime overlay asset copies when the runtime extension root is absent", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
await fs.mkdir(path.join(rootDir, "extensions", "demo", "assets"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(rootDir, "extensions", "demo", "assets", "viewer.js"),
|
|
"viewer\n",
|
|
"utf8",
|
|
);
|
|
|
|
copyStaticExtensionAssetsToRuntimeOverlay({
|
|
rootDir,
|
|
assets: [
|
|
{
|
|
src: "extensions/demo/assets/viewer.js",
|
|
dest: "dist/extensions/demo/assets/viewer.js",
|
|
},
|
|
],
|
|
});
|
|
|
|
await expectPathMissing(path.join(rootDir, "dist-runtime", "extensions", "demo", "assets"));
|
|
});
|
|
|
|
it("ignores runtime overlay static assets outside dist extensions", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
await fs.mkdir(path.join(rootDir, "dist-runtime", "extensions"), { recursive: true });
|
|
await fs.mkdir(path.join(rootDir, "extensions", "demo", "assets"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(rootDir, "extensions", "demo", "assets", "viewer.js"),
|
|
"viewer\n",
|
|
"utf8",
|
|
);
|
|
|
|
copyStaticExtensionAssetsToRuntimeOverlay({
|
|
rootDir,
|
|
assets: [
|
|
{
|
|
src: "extensions/demo/assets/viewer.js",
|
|
dest: "dist/other/demo/assets/viewer.js",
|
|
},
|
|
],
|
|
});
|
|
|
|
await expectPathMissing(path.join(rootDir, "dist-runtime", "other", "demo", "assets"));
|
|
});
|
|
|
|
it("warns when a runtime overlay static asset source is missing", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const warn = vi.fn();
|
|
await fs.mkdir(path.join(rootDir, "dist-runtime", "extensions"), { recursive: true });
|
|
|
|
copyStaticExtensionAssetsToRuntimeOverlay({
|
|
rootDir,
|
|
assets: [
|
|
{
|
|
src: "extensions/demo/assets/missing.js",
|
|
dest: "dist/extensions/demo/assets/missing.js",
|
|
},
|
|
],
|
|
warn,
|
|
});
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"[runtime-postbuild] static asset not found, skipping: extensions/demo/assets/missing.js",
|
|
);
|
|
await expectPathMissing(
|
|
path.join(rootDir, "dist-runtime", "extensions", "demo", "assets", "missing.js"),
|
|
);
|
|
});
|
|
|
|
it("warns when a declared static asset is missing", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const warn = vi.fn();
|
|
|
|
copyStaticExtensionAssets({
|
|
rootDir,
|
|
assets: [{ src: "missing/file.mjs", dest: "dist/file.mjs" }],
|
|
warn,
|
|
});
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"[runtime-postbuild] static asset not found, skipping: missing/file.mjs",
|
|
);
|
|
});
|
|
|
|
it("writes stable aliases for hashed root runtime modules", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "runtime-model-auth.runtime-XyZ987.js"),
|
|
"export const auth = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "runtime-tts.runtime-AbCd1234.mjs"),
|
|
"export const tts = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "dispatch.contract-AbCd1234.mjs"),
|
|
"export const dispatch = true;\n",
|
|
"utf8",
|
|
);
|
|
for (const extension of ["js", "mjs"]) {
|
|
await fs.writeFile(
|
|
path.join(distDir, `library-Other123.${extension}`),
|
|
"export const x = true;\n",
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
writeStableRootRuntimeAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "runtime-model-auth.runtime.js"), "utf8")).toBe(
|
|
'export * from "./runtime-model-auth.runtime-XyZ987.js";\n',
|
|
);
|
|
expect(await fs.readFile(path.join(distDir, "runtime-tts.runtime.js"), "utf8")).toBe(
|
|
'export * from "./runtime-tts.runtime-AbCd1234.mjs";\n',
|
|
);
|
|
expect(await fs.readFile(path.join(distDir, "dispatch.contract.js"), "utf8")).toBe(
|
|
'export * from "./dispatch.contract-AbCd1234.mjs";\n',
|
|
);
|
|
for (const fileName of ["library.js", "runtime-tts.runtime.mjs", "dispatch.contract.mjs"]) {
|
|
await expectPathMissing(path.join(distDir, fileName));
|
|
}
|
|
});
|
|
|
|
it("refuses to rewrite stable aliases through a symlinked dist root", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-symlink-");
|
|
const targetDir = path.join(rootDir, "gateway-dist");
|
|
await fs.mkdir(targetDir, { recursive: true });
|
|
const hashedFile = path.join(targetDir, "runtime-model-auth.runtime-XyZ987.js");
|
|
await fs.writeFile(hashedFile, "export const auth = true;\n", "utf8");
|
|
const distLink = path.join(rootDir, "dist");
|
|
await fs.symlink(targetDir, distLink, "dir");
|
|
|
|
expect(() => writeStableRootRuntimeAliases({ rootDir })).toThrow(/symbolic link/u);
|
|
|
|
expect(await fs.readlink(distLink)).toBe(targetDir);
|
|
expect(await fs.readFile(hashedFile, "utf8")).toBe("export const auth = true;\n");
|
|
await expectPathMissing(path.join(targetDir, "runtime-model-auth.runtime.js"));
|
|
});
|
|
|
|
it("forwards default exports through stable and legacy aliases", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(path.join(rootDir, "package.json"), '{"type":"module"}\n', "utf8");
|
|
await fs.writeFile(
|
|
path.join(distDir, "text-transforms.runtime-Hash111.mjs"),
|
|
"function reconcile(value) { return value; }\nexport { reconcile as default };\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "mixed.contract-Hash222.mjs"),
|
|
"export const named = true;\nexport default function run() {}\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "named-only.runtime-Hash333.js"),
|
|
'const marker = "export { marker as default }";\nexport { marker };\n',
|
|
"utf8",
|
|
);
|
|
|
|
writeStableRootRuntimeAliases({ rootDir });
|
|
writeLegacyRootRuntimeCompatAliases({ rootDir });
|
|
|
|
const stable = await import(
|
|
pathToFileURL(path.join(distDir, "text-transforms.runtime.js")).href
|
|
);
|
|
const legacy = await import(
|
|
pathToFileURL(path.join(distDir, "text-transforms.runtime-sEqsN4pN.js")).href
|
|
);
|
|
const mixed = await import(pathToFileURL(path.join(distDir, "mixed.contract.js")).href);
|
|
const namedOnly = await import(pathToFileURL(path.join(distDir, "named-only.runtime.js")).href);
|
|
|
|
expect(stable.default("stable")).toBe("stable");
|
|
expect(legacy.default("legacy")).toBe("legacy");
|
|
expect(mixed.default).toBeTypeOf("function");
|
|
expect(mixed.named).toBe(true);
|
|
expect(namedOnly.marker).toBe("export { marker as default }");
|
|
expect(namedOnly).not.toHaveProperty("default");
|
|
|
|
rewriteRootRuntimeImportsToStableAliases({ rootDir });
|
|
writeStableRootRuntimeAliases({ rootDir });
|
|
writeLegacyRootRuntimeCompatAliases({ rootDir });
|
|
expect(await fs.readFile(path.join(distDir, "text-transforms.runtime.js"), "utf8")).toBe(
|
|
'export * from "./text-transforms.runtime-Hash111.mjs";\n' +
|
|
'export { default } from "./text-transforms.runtime-Hash111.mjs";\n',
|
|
);
|
|
|
|
const stableAfterRerun = await import(
|
|
`${pathToFileURL(path.join(distDir, "text-transforms.runtime.js")).href}?rerun=1`
|
|
);
|
|
expect(stableAfterRerun.default("rerun")).toBe("rerun");
|
|
});
|
|
|
|
it("does not write ambiguous stable aliases for colliding root runtime chunks", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "install.runtime-Aaa111.mjs"),
|
|
"export const pluginInstall = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "install.runtime-Bbb222.js"),
|
|
"export const daemonInstall = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "install.runtime.js"),
|
|
'export * from "./install.runtime-Stale.js";\n',
|
|
"utf8",
|
|
);
|
|
|
|
writeStableRootRuntimeAliases({ rootDir });
|
|
|
|
await expectPathMissing(path.join(distDir, "install.runtime.js"));
|
|
});
|
|
|
|
it("writes a stable plugin install runtime alias when install runtimes collide", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "install.runtime-Aaa111.mjs"),
|
|
[
|
|
"export const scanPackageInstallSource = true;",
|
|
"export const scanFileInstallSource = true;",
|
|
"export const scanInstalledPackageDependencyTree = true;",
|
|
"export const scanBundleInstallSource = true;",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "install.runtime-Bbb222.js"),
|
|
"export const daemonInstall = true;\n",
|
|
"utf8",
|
|
);
|
|
|
|
writeStableRootRuntimeAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "install.runtime.js"), "utf8")).toBe(
|
|
'export * from "./install.runtime-Aaa111.mjs";\n',
|
|
);
|
|
});
|
|
|
|
it("keeps stable aliases when one colliding root runtime chunk re-exports the implementation", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "runtime-model-auth.runtime-Impl123.js"),
|
|
"export const auth = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "runtime-model-auth.runtime-Wrap456.mjs"),
|
|
'import { auth } from "./runtime-model-auth.runtime-Impl123.js";\nexport { auth };\n',
|
|
"utf8",
|
|
);
|
|
|
|
writeStableRootRuntimeAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "runtime-model-auth.runtime.js"), "utf8")).toBe(
|
|
'export * from "./runtime-model-auth.runtime-Wrap456.mjs";\n',
|
|
);
|
|
});
|
|
|
|
it("ignores legacy wrappers to the stable runtime alias when choosing the implementation", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "runtime-plugins.runtime-NewHash.mjs"),
|
|
"export const ready = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "runtime-plugins.runtime-OldHash.js"),
|
|
'export * from "./runtime-plugins.runtime.js";\n',
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "dispatch-OldHash.js"),
|
|
['const lazy = () => import("./runtime-plugins.runtime-NewHash.mjs");', ""].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
rewriteRootRuntimeImportsToStableAliases({ rootDir });
|
|
writeStableRootRuntimeAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "dispatch-OldHash.js"), "utf8")).toBe(
|
|
['const lazy = () => import("./runtime-plugins.runtime.js");', ""].join("\n"),
|
|
);
|
|
expect(await fs.readFile(path.join(distDir, "runtime-plugins.runtime.js"), "utf8")).toBe(
|
|
'export * from "./runtime-plugins.runtime-NewHash.mjs";\n',
|
|
);
|
|
});
|
|
|
|
it.each(["js", "mjs"])("rewrites mixed runtime imports in a %s importer", async (extension) => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "runtime-plugins.runtime-AbCd1234.mjs"),
|
|
"export const ready = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "dispatch.contract-AbCd1234.js"),
|
|
"export const dispatch = true;\n",
|
|
"utf8",
|
|
);
|
|
const importerPath = path.join(distDir, `dispatch-OldHash.${extension}`);
|
|
await fs.writeFile(
|
|
importerPath,
|
|
[
|
|
'const lazy = () => import("./runtime-plugins.runtime-AbCd1234.mjs");',
|
|
'export { dispatch } from "./dispatch.contract-AbCd1234.js";',
|
|
'import "./missing.runtime-Nope.js";',
|
|
'import "./missing.contract-Nope.mjs";',
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
rewriteRootRuntimeImportsToStableAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(importerPath, "utf8")).toBe(
|
|
[
|
|
'const lazy = () => import("./runtime-plugins.runtime.js");',
|
|
'export { dispatch } from "./dispatch.contract.js";',
|
|
'import "./missing.runtime-Nope.js";',
|
|
'import "./missing.contract-Nope.mjs";',
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("keeps text-transform runtime imports hashed after the stable alias export surface grew", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "text-transforms.runtime-NewHash.mjs"),
|
|
"export const n = true;\nexport const t = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "provider-runtime-NewHash.mjs"),
|
|
[
|
|
'import { n as applyPluginTextReplacements } from "./text-transforms.runtime-NewHash.mjs";',
|
|
"export { applyPluginTextReplacements };",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
rewriteRootRuntimeImportsToStableAliases({ rootDir });
|
|
writeStableRootRuntimeAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "provider-runtime-NewHash.mjs"), "utf8")).toBe(
|
|
[
|
|
'import { n as applyPluginTextReplacements } from "./text-transforms.runtime-NewHash.mjs";',
|
|
"export { applyPluginTextReplacements };",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
expect(await fs.readFile(path.join(distDir, "text-transforms.runtime.js"), "utf8")).toBe(
|
|
'export * from "./text-transforms.runtime-NewHash.mjs";\n',
|
|
);
|
|
});
|
|
|
|
it("rewrites gateway shutdown imports to stable runtime aliases", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "server-close.runtime-AbCd1234.js"),
|
|
"export const close = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "server.impl-OldHash.js"),
|
|
[
|
|
'const closeModule = () => import("./server-close.runtime-AbCd1234.js");',
|
|
'const ordinaryChunk = () => import("./server-close-OldHash.js");',
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
rewriteRootRuntimeImportsToStableAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "server.impl-OldHash.js"), "utf8")).toBe(
|
|
[
|
|
'const closeModule = () => import("./server-close.runtime.js");',
|
|
'const ordinaryChunk = () => import("./server-close-OldHash.js");',
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("rewrites reply-dispatch imports to the stable provider dispatcher runtime alias", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "provider-dispatcher.runtime-NewHash.js"),
|
|
'export * from "./provider-dispatcher-ImplHash.js";\n',
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "reply-dispatch-runtime-OldHash.js"),
|
|
['const dispatcher = () => import("./provider-dispatcher.runtime-NewHash.js");', ""].join(
|
|
"\n",
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
rewriteRootRuntimeImportsToStableAliases({ rootDir });
|
|
writeStableRootRuntimeAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "reply-dispatch-runtime-OldHash.js"), "utf8")).toBe(
|
|
['const dispatcher = () => import("./provider-dispatcher.runtime.js");', ""].join("\n"),
|
|
);
|
|
expect(await fs.readFile(path.join(distDir, "provider-dispatcher.runtime.js"), "utf8")).toBe(
|
|
'export * from "./provider-dispatcher.runtime-NewHash.js";\n',
|
|
);
|
|
});
|
|
|
|
it("keeps hashed imports when a stable runtime alias would collide", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "install.runtime-Aaa111.mjs"),
|
|
"export const pluginInstall = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "install.runtime-Bbb222.js"),
|
|
"export const daemonInstall = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "install-OldHash.js"),
|
|
[
|
|
'const pluginRuntime = () => import("./install.runtime-Aaa111.mjs");',
|
|
'const daemonRuntime = () => import("./install.runtime-Bbb222.js");',
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
rewriteRootRuntimeImportsToStableAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "install-OldHash.js"), "utf8")).toBe(
|
|
[
|
|
'const pluginRuntime = () => import("./install.runtime-Aaa111.mjs");',
|
|
'const daemonRuntime = () => import("./install.runtime-Bbb222.js");',
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("rewrites plugin install runtime imports to stable aliases when install runtimes collide", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "install.runtime-Aaa111.mjs"),
|
|
[
|
|
"export const scanPackageInstallSource = true;",
|
|
"export const scanFileInstallSource = true;",
|
|
"export const scanInstalledPackageDependencyTree = true;",
|
|
"export const scanBundleInstallSource = true;",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "install.runtime-Bbb222.js"),
|
|
"export const daemonInstall = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "install-OldHash.js"),
|
|
[
|
|
'const pluginRuntime = () => import("./install.runtime-Aaa111.mjs");',
|
|
'const daemonRuntime = () => import("./install.runtime-Bbb222.js");',
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
rewriteRootRuntimeImportsToStableAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "install-OldHash.js"), "utf8")).toBe(
|
|
[
|
|
'const pluginRuntime = () => import("./install.runtime.js");',
|
|
'const daemonRuntime = () => import("./install.runtime-Bbb222.js");',
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("leaves stable alias files pointing at their hashed runtime chunks", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(distDir, "runtime-plugins.runtime-AbCd1234.mjs"),
|
|
"export const ready = true;\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(distDir, "runtime-plugins.runtime.js"),
|
|
'export * from "./runtime-plugins.runtime-AbCd1234.mjs";\n',
|
|
"utf8",
|
|
);
|
|
|
|
rewriteRootRuntimeImportsToStableAliases({ rootDir });
|
|
|
|
expect(await fs.readFile(path.join(distDir, "runtime-plugins.runtime.js"), "utf8")).toBe(
|
|
'export * from "./runtime-plugins.runtime-AbCd1234.mjs";\n',
|
|
);
|
|
});
|
|
|
|
it("keeps every recorded previous-release lazy import loadable after replacement", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-update-compat-");
|
|
writeUpdateCompatibilityBuildFixture(rootDir);
|
|
runRuntimePostBuild({
|
|
rootDir,
|
|
env: { OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0" },
|
|
timings: false,
|
|
});
|
|
|
|
for (const release of previousReleaseInventory.releases) {
|
|
for (const chunk of release.chunks) {
|
|
const loaded = await import(pathToFileURL(path.join(rootDir, "dist", chunk.path)).href);
|
|
for (const { exported, origin } of chunk.exports) {
|
|
expect(loaded[exported](), `${release.version}: ${chunk.path}:${exported}`).toBe(
|
|
origin.symbol,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps the 2026.9.1 Git updater restart import loadable after dist replacement", async () => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-old-updater-");
|
|
const distDir = path.join(rootDir, "dist");
|
|
const ownerPath = path.join(distDir, "update-command-service-command.mjs");
|
|
await fs.mkdir(distDir, { recursive: true });
|
|
await fs.writeFile(
|
|
ownerPath,
|
|
'export async function restart() { return (await import("./shared-1Uyqkfns.js")).resolveNodeRunner(); }\n',
|
|
);
|
|
const output = childProcess.execFileSync(
|
|
testNodeExecPath,
|
|
[
|
|
"--import",
|
|
path.join(MODULE_ROOT, "scripts/tsx.mjs"),
|
|
"--input-type=module",
|
|
"-e",
|
|
[
|
|
'import fs from "node:fs/promises";',
|
|
'import path from "node:path";',
|
|
'import { pathToFileURL } from "node:url";',
|
|
`import { writeLegacyCliExitCompatChunks } from ${JSON.stringify(pathToFileURL(path.join(MODULE_ROOT, "scripts/runtime-postbuild.mts")).href)};`,
|
|
"const rootDir = process.argv[1];",
|
|
'const owner = await import(pathToFileURL(path.join(rootDir, "dist/update-command-service-command.mjs")).href);',
|
|
'await fs.rm(path.join(rootDir, "dist"), { recursive: true, force: true });',
|
|
"writeLegacyCliExitCompatChunks({ rootDir });",
|
|
"process.stdout.write(await owner.restart());",
|
|
].join("\n"),
|
|
rootDir,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
|
|
expect(output).toBe(testNodeExecPath);
|
|
});
|
|
|
|
it.each(["shared-Y6bNiw2w.js", "shared-DTaQo6Hi.js"])(
|
|
"preserves the old updater node-runner ABI through %s",
|
|
async (chunk) => {
|
|
const rootDir = createTempDir("openclaw-runtime-postbuild-");
|
|
|
|
writeLegacyCliExitCompatChunks({ rootDir });
|
|
|
|
const bridge = await import(pathToFileURL(path.join(rootDir, "dist", chunk)).href);
|
|
expect(bridge.resolveNodeRunner()).toBe(process.versions.bun ? "node" : process.execPath);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("previous release update compatibility", () => {
|
|
const integrity = `sha512-${Buffer.alloc(64).toString("base64")}`;
|
|
|
|
function write(root: string, relative: string, contents: string): void {
|
|
const file = path.join(root, relative);
|
|
fsSync.mkdirSync(path.dirname(file), { recursive: true });
|
|
fsSync.writeFileSync(file, contents);
|
|
}
|
|
|
|
function recordFixture(): UpdateCompatibilityInventory & {
|
|
releases: [UpdateCompatibilityRelease];
|
|
} {
|
|
const root = createTempDir("update-compat-release-");
|
|
write(root, "package.json", JSON.stringify({ name: "openclaw", version: "2026.9.1" }));
|
|
write(
|
|
root,
|
|
"dist/build-info.json",
|
|
JSON.stringify({ version: "2026.9.1", buildId: "fixture", commit: "0".repeat(40) }),
|
|
);
|
|
write(
|
|
root,
|
|
"dist/command.js",
|
|
[
|
|
"//#region src/cli/update-cli/update-command-service-command.ts",
|
|
'export async function restart() { return (await import("./service-abcdefgh.js")).runner(); }',
|
|
'export async function recover() { const { mode: selected } = await import("./service-abcdefgh.js"); return selected(); }',
|
|
].join("\n"),
|
|
);
|
|
write(
|
|
root,
|
|
"dist/service-abcdefgh.js",
|
|
'export { r as runner, m as mode } from "./implementation-12345678.js";',
|
|
);
|
|
write(
|
|
root,
|
|
"dist/implementation-12345678.js",
|
|
[
|
|
"//#region src/cli/update-cli/runner.ts",
|
|
'function resolveRunner() { return "old"; }',
|
|
"//#region src/cli/update-cli/recovery.ts",
|
|
'function resolveMode() { return "old"; }',
|
|
"export { resolveRunner as r, resolveMode as m };",
|
|
].join("\n"),
|
|
);
|
|
return {
|
|
schemaVersion: 1,
|
|
releases: [recordUpdateCompatibilityRelease({ packageDir: root, integrity })],
|
|
};
|
|
}
|
|
|
|
function candidate(root: string): void {
|
|
write(root, "src/cli/update-cli/recovery.ts", 'import { resolveMode } from "./mode.js";');
|
|
write(root, "src/cli/update-cli/mode.ts", 'export function resolveMode() { return "npm"; }');
|
|
write(
|
|
root,
|
|
"dist/current.mjs",
|
|
[
|
|
"//#region src/cli/update-cli/runner.ts",
|
|
'function resolveRunner() { return "node"; }',
|
|
"//#region src/cli/update-cli/mode.ts",
|
|
'function resolveMode() { return "npm"; }',
|
|
"export { resolveRunner as x, resolveMode as y };",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
function recordImportedFixture(
|
|
expression: string,
|
|
modules: Record<string, string>,
|
|
identity: Pick<UpdateCompatibilityRelease, "version" | "buildId" | "commit" | "integrity"> = {
|
|
version: "2026.9.1",
|
|
buildId: "fixture",
|
|
commit: "0".repeat(40),
|
|
integrity,
|
|
},
|
|
owner = "src/cli/update-cli/update-command-service-command.ts",
|
|
) {
|
|
const root = createTempDir("update-compat-import-graph-");
|
|
write(
|
|
root,
|
|
"package.json",
|
|
JSON.stringify({ name: "openclaw", version: identity.version, type: "module" }),
|
|
);
|
|
write(root, "dist/build-info.json", JSON.stringify(identity));
|
|
write(
|
|
root,
|
|
"dist/command.js",
|
|
[`//#region ${owner}`, `export async function restart() { return ${expression}; }`].join(
|
|
"\n",
|
|
),
|
|
);
|
|
for (const [file, source] of Object.entries(modules)) {
|
|
write(root, `dist/${file}`, source);
|
|
}
|
|
const inventory: UpdateCompatibilityInventory = {
|
|
schemaVersion: 1,
|
|
releases: [
|
|
recordUpdateCompatibilityRelease({ packageDir: root, integrity: identity.integrity }),
|
|
],
|
|
};
|
|
return { root, inventory };
|
|
}
|
|
|
|
it.each(["generated", "changed delegation", "missing source region"])(
|
|
"traces only verified delegating config aliases (%s)",
|
|
(variant) => {
|
|
const facade =
|
|
'export { createConfigIO, readConfigFileSnapshot } from "./config-abcdefgh.mjs";\nexport * from "./extra.mjs";\n';
|
|
const alias = buildUpdateConfigRuntimeAlias(
|
|
"io.runtime-abcdefgh.mjs",
|
|
parser.parseSourceFile("facade.mjs", facade),
|
|
);
|
|
const record = () =>
|
|
recordImportedFixture('(await import("./io.runtime.js"))', {
|
|
"io.runtime.js":
|
|
variant === "changed delegation"
|
|
? alias.replace("return runtime[name]", "return undefined")
|
|
: alias,
|
|
"io.runtime-abcdefgh.mjs": facade,
|
|
"extra.mjs": "//#region src/config/extra.ts\nexport const targetOnly = true;\n",
|
|
"config-abcdefgh.mjs": [
|
|
'throw new Error("The recorder must not execute release code");',
|
|
...(variant === "missing source region" ? [] : ["//#region src/config/io.ts"]),
|
|
"export function createConfigIO() {}",
|
|
"export function readConfigFileSnapshot() {}",
|
|
].join("\n"),
|
|
});
|
|
if (variant !== "generated") {
|
|
expect(record).toThrow("Cannot trace io.runtime.js export createConfigIO");
|
|
return;
|
|
}
|
|
expect(record().inventory.releases[0]?.chunks).toMatchObject([
|
|
{
|
|
path: "io.runtime.js",
|
|
exports: ["createConfigIO", "readConfigFileSnapshot"].map((exported) => ({
|
|
exported,
|
|
origin: { module: "src/config/io.ts", symbol: exported },
|
|
})),
|
|
},
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each(["source scripts", "different owner", "mutable binding", "dist path", "unknown script"])(
|
|
"distinguishes source completion contracts from unknown dynamic imports (%s)",
|
|
(variant) => {
|
|
const declaration = variant === "mutable binding" ? "let" : "const";
|
|
const directory = variant === "dist path" ? "dist" : "scripts";
|
|
const script =
|
|
variant === "unknown script" ? "unknown.mts" : "stage-bundled-plugin-runtime.mts";
|
|
const expression = `await (async () => {
|
|
${declaration} stagingFile = path.join(root, "${directory}", "${script}");
|
|
await import(pathToFileURL(stagingFile).href);
|
|
await import(pathToFileURL(path.join(root, "scripts", "lib", "dist-artifact-ownership.mts")).href);
|
|
return (await import("./surface-abcdefgh.js")).x;
|
|
})()`;
|
|
const record = () =>
|
|
recordImportedFixture(
|
|
expression,
|
|
{
|
|
"surface-abcdefgh.js": "//#region src/infra/value.ts\nexport const x = 1;\n",
|
|
},
|
|
undefined,
|
|
variant === "different owner"
|
|
? undefined
|
|
: "src/cli/update-cli/update-command-runtime.ts",
|
|
);
|
|
if (variant !== "source scripts") {
|
|
expect(record).toThrow("Nonliteral post-swap import");
|
|
return;
|
|
}
|
|
expect(record().inventory.releases[0]?.chunks.map((chunk) => chunk.path)).toEqual([
|
|
"surface-abcdefgh.js",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ name: "published bootstrap" },
|
|
{ name: "different owner", owner: "src/cli/update-cli/update-command-runtime.ts" },
|
|
{
|
|
name: "mutable root",
|
|
binding: "let driverRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });",
|
|
},
|
|
{ name: "unknown root", binding: "" },
|
|
{ name: "target root", binding: "const driverRoot = root;" },
|
|
{
|
|
name: "cwd root",
|
|
binding: "const driverRoot = resolveOpenClawPackageRootSync({ cwd: process.cwd() });",
|
|
},
|
|
{
|
|
name: "different module",
|
|
binding: "const driverRoot = resolveOpenClawPackageRootSync({ moduleUrl: targetUrl });",
|
|
},
|
|
{
|
|
name: "extra root options",
|
|
binding:
|
|
"const driverRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url, cwd: root });",
|
|
},
|
|
{ name: "extra path", target: 'path.join(driverRoot, "node-runtime-recovery.mjs", "extra")' },
|
|
{ name: "dist path", target: 'path.join(driverRoot, "dist", "node-runtime-recovery.mjs")' },
|
|
{ name: "traversal", target: 'path.join(driverRoot, "../node-runtime-recovery.mjs")' },
|
|
{ name: "dynamic path", target: "path.join(driverRoot, entry)" },
|
|
{
|
|
name: "different URL form",
|
|
url: 'new URL("node-runtime-recovery.mjs", import.meta.url).href',
|
|
},
|
|
{ name: "import options", suffix: ", { with: options }" },
|
|
{ name: "for-of shadow", prefix: "for (const driverRoot of roots) ", declaration: "" },
|
|
{
|
|
name: "for initializer shadow",
|
|
prefix: "for (const driverRoot = root; driverRoot;) ",
|
|
declaration: "",
|
|
},
|
|
{ name: "arrow shadow", prefix: "const load = async (driverRoot) => ", declaration: "" },
|
|
])(
|
|
"records dist edges while qualifying the package bootstrap ($name)",
|
|
({
|
|
name,
|
|
owner = "src/cli/update-cli/update-command-node-runtime-resolution.ts",
|
|
binding = "const driverRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });",
|
|
target = 'path.join(driverRoot, "node-runtime-recovery.mjs")',
|
|
url = `pathToFileURL(${target}).href`,
|
|
suffix = "",
|
|
prefix = "",
|
|
declaration = "const { findUsableNodeRuntime } = ",
|
|
}) => {
|
|
const expression = `await (async () => {
|
|
${binding}
|
|
if (!driverRoot) return;
|
|
${prefix}${declaration}await import(${url}${suffix});
|
|
return (await import("./surface-abcdefgh.js")).x;
|
|
})()`;
|
|
const record = () =>
|
|
recordImportedFixture(
|
|
expression,
|
|
{ "surface-abcdefgh.js": "//#region src/infra/value.ts\nexport const x = 1;\n" },
|
|
undefined,
|
|
owner,
|
|
);
|
|
if (name !== "published bootstrap") {
|
|
expect(record).toThrow("Nonliteral post-swap import");
|
|
return;
|
|
}
|
|
expect(record().inventory.releases[0]?.chunks.map((chunk) => chunk.path)).toEqual([
|
|
"surface-abcdefgh.js",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
previousReleaseInventory.releases
|
|
.filter(({ version }) => ["2026.9.1", "2026.9.2", "2026.9.3", "2026.9.4"].includes(version))
|
|
.flatMap((release) =>
|
|
["exact", "version", "buildId", "commit", "integrity", "chunk", "owner", "symbol"].map(
|
|
(changed) => ({ release, changed }),
|
|
),
|
|
),
|
|
)(
|
|
"corrects only verified coalesced release provenance ($release.version, $changed)",
|
|
async ({ release, changed }) => {
|
|
const chunk = release.chunks.find((entry) =>
|
|
entry.exports.some((item) => item.exported === "markPluginRegistryRetired"),
|
|
);
|
|
if (!chunk) {
|
|
throw new Error(`Missing historical retirement import for ${release.version}`);
|
|
}
|
|
const identity = { ...release };
|
|
if (changed === "version") {
|
|
identity.version = "2026.9.99";
|
|
}
|
|
if (changed === "buildId") {
|
|
identity.buildId = "different-build";
|
|
}
|
|
if (changed === "commit") {
|
|
identity.commit = "0".repeat(40);
|
|
}
|
|
if (changed === "integrity") {
|
|
identity.integrity = integrity;
|
|
}
|
|
const target = changed === "chunk" ? "registry-lifecycle-unknown1.js" : chunk.path;
|
|
const module =
|
|
changed === "owner" ? "src/plugins/another-owner.ts" : "src/plugins/loader-cache-state.ts";
|
|
const symbol = changed === "symbol" ? "anotherRetirement" : "markPluginRegistryRetired";
|
|
const { inventory } = recordImportedFixture(
|
|
`(await import("./${target}")).${symbol}`,
|
|
{ [target]: `//#region ${module}\nfunction ${symbol}() {}\nexport { ${symbol} };\n` },
|
|
identity,
|
|
);
|
|
expect(inventory.releases[0]?.chunks[0]?.exports).toEqual([
|
|
{
|
|
exported: symbol,
|
|
origin: {
|
|
module: changed === "exact" ? "src/plugins/registry-lifecycle.ts" : module,
|
|
symbol,
|
|
},
|
|
},
|
|
]);
|
|
if (changed !== "exact") {
|
|
return;
|
|
}
|
|
const current = createTempDir("update-compat-corrected-origin-");
|
|
write(current, "package.json", '{"type":"module"}');
|
|
write(
|
|
current,
|
|
"src/plugins/registry-lifecycle.ts",
|
|
'export function markPluginRegistryRetired() { return "current"; }',
|
|
);
|
|
write(
|
|
current,
|
|
"dist/current.mjs",
|
|
'//#region src/plugins/registry-lifecycle.ts\nfunction markPluginRegistryRetired() { return "current"; }\nexport { markPluginRegistryRetired };\n',
|
|
);
|
|
writeUpdateCompatibilityChunks({
|
|
distDir: path.join(current, "dist"),
|
|
sourceDir: current,
|
|
inventory,
|
|
});
|
|
const bridge = await import(pathToFileURL(path.join(current, "dist", target)).href);
|
|
expect(bridge.markPluginRegistryRetired()).toBe("current");
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
access: "then",
|
|
expression: 'import("./surface-abcdefgh.js").then((module) => module.x)',
|
|
names: ["x", "y"],
|
|
},
|
|
{
|
|
access: "catch",
|
|
expression: 'import("./surface-abcdefgh.js").catch(() => undefined)',
|
|
names: ["x", "y"],
|
|
},
|
|
{
|
|
access: "finally",
|
|
expression: 'import("./surface-abcdefgh.js").finally(() => undefined)',
|
|
names: ["x", "y"],
|
|
},
|
|
{
|
|
access: "awaited property",
|
|
expression: '(await import("./surface-abcdefgh.js")).x',
|
|
names: ["x"],
|
|
},
|
|
{
|
|
access: "awaited bracket",
|
|
expression: '(await import("./surface-abcdefgh.js"))["x"]',
|
|
names: ["x"],
|
|
},
|
|
])(
|
|
"records namespace exports instead of Promise methods for $access access",
|
|
({ expression, names }) => {
|
|
const { inventory } = recordImportedFixture(expression, {
|
|
"surface-abcdefgh.js":
|
|
"//#region src/infra/values.ts\nconst x = 1; const y = 2; export { x, y };\n",
|
|
});
|
|
expect(inventory.releases[0]?.chunks).toMatchObject([
|
|
{
|
|
path: "surface-abcdefgh.js",
|
|
imports: [{ exports: names }],
|
|
exports: names.map((exported) => ({
|
|
exported,
|
|
origin: { module: "src/infra/values.ts", symbol: exported },
|
|
})),
|
|
},
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each(["distinct owners", "the same source annotation"])(
|
|
"rejects conflicting emitted star bindings with %s and names both sources",
|
|
(annotation) => {
|
|
const leftOwner = annotation === "the same source annotation" ? "shared" : "left";
|
|
const rightOwner = annotation === "the same source annotation" ? "shared" : "right";
|
|
expect(() =>
|
|
recordImportedFixture('(await import("./surface-abcdefgh.js")).x', {
|
|
"surface-abcdefgh.js": 'export * from "./left.mjs"; export * from "./right.mjs";\n',
|
|
"left.mjs": `//#region src/infra/${leftOwner}.ts\nexport const x = "left";\n`,
|
|
"right.mjs": `//#region src/infra/${rightOwner}.ts\nexport const x = "right";\n`,
|
|
}),
|
|
).toThrow(/(?=[\s\S]*left\.mjs)(?=[\s\S]*right\.mjs)/);
|
|
},
|
|
);
|
|
|
|
it("resolves a star diamond to the single declaration shared by both paths", async () => {
|
|
const { root, inventory } = recordImportedFixture('(await import("./surface-abcdefgh.js")).x', {
|
|
"surface-abcdefgh.js": 'export * from "./left.mjs"; export * from "./right.mjs";\n',
|
|
"left.mjs": 'export { x } from "./origin.mjs";\n',
|
|
"right.mjs": 'export * from "./origin.mjs";\n',
|
|
"origin.mjs": '//#region src/infra/origin.ts\nexport const x = { value: "shared" };\n',
|
|
});
|
|
const namespace = await import(pathToFileURL(path.join(root, "dist/surface-abcdefgh.js")).href);
|
|
const declaration = await import(pathToFileURL(path.join(root, "dist/origin.mjs")).href);
|
|
expect(namespace.x).toBe(declaration.x);
|
|
expect(inventory.releases[0]?.chunks[0]?.exports).toEqual([
|
|
{ exported: "x", origin: { module: "src/infra/origin.ts", symbol: "x" } },
|
|
]);
|
|
});
|
|
|
|
it("lets an explicit export resolve an otherwise ambiguous star name", async () => {
|
|
const { root, inventory } = recordImportedFixture('(await import("./surface-abcdefgh.js")).x', {
|
|
"surface-abcdefgh.js":
|
|
'export * from "./left.mjs"; export * from "./right.mjs"; export { x } from "./left.mjs";\n',
|
|
"left.mjs": '//#region src/infra/left.ts\nexport const x = "left";\n',
|
|
"right.mjs": '//#region src/infra/right.ts\nexport const x = "right";\n',
|
|
});
|
|
const namespace = await import(pathToFileURL(path.join(root, "dist/surface-abcdefgh.js")).href);
|
|
expect(namespace.x).toBe("left");
|
|
expect(inventory.releases[0]?.chunks[0]?.exports).toEqual([
|
|
{ exported: "x", origin: { module: "src/infra/left.ts", symbol: "x" } },
|
|
]);
|
|
});
|
|
|
|
it.each(["surface.js", "surface-abcdefgh.js"])(
|
|
"rejects an existing %s whose star ambiguity omits the required export",
|
|
(target) => {
|
|
const { inventory } = recordImportedFixture(`(await import("./${target}")).x`, {
|
|
[target]: '//#region src/infra/original.ts\nexport const x = "original";\n',
|
|
});
|
|
const root = createTempDir("update-compat-existing-stars-");
|
|
write(root, "package.json", '{"type":"module"}\n');
|
|
write(root, `dist/${target}`, 'export * from "./left.mjs"; export * from "./right.mjs";\n');
|
|
write(root, "dist/left.mjs", '//#region src/infra/original.ts\nexport const x = "left";\n');
|
|
write(root, "dist/right.mjs", '//#region src/infra/other.ts\nexport const x = "right";\n');
|
|
const namespaceHasX = childProcess.execFileSync(
|
|
testNodeExecPath,
|
|
[
|
|
"--input-type=module",
|
|
"-e",
|
|
'const namespace = await import(process.argv[1]); console.log(Object.hasOwn(namespace, "x"));',
|
|
pathToFileURL(path.join(root, "dist", target)).href,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(namespaceHasX.trim()).toBe("false");
|
|
expect(() =>
|
|
writeUpdateCompatibilityChunks({
|
|
distDir: path.join(root, "dist"),
|
|
sourceDir: root,
|
|
inventory,
|
|
}),
|
|
).toThrow(`${target} lacks x`);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
route: "source star forwarding",
|
|
owner: 'export * from "./moved.js";\n',
|
|
module: "src/infra/moved.ts",
|
|
symbol: "oldName",
|
|
},
|
|
{
|
|
route: "a local export alias",
|
|
owner: 'function newName() { return "current"; } export { newName as oldName };\n',
|
|
module: "src/infra/old.ts",
|
|
symbol: "newName",
|
|
},
|
|
])("bridges a moved declaration through $route", async ({ owner, module, symbol }) => {
|
|
const { inventory } = recordImportedFixture('(await import("./surface-abcdefgh.js")).x', {
|
|
"surface-abcdefgh.js":
|
|
'//#region src/infra/old.ts\nfunction oldName() { return "old"; } export { oldName as x };\n',
|
|
});
|
|
const root = createTempDir("update-compat-source-forwarding-");
|
|
write(root, "package.json", '{"type":"module"}\n');
|
|
write(root, "src/infra/old.ts", owner);
|
|
if (module !== "src/infra/old.ts") {
|
|
write(root, module, 'export function oldName() { return "current"; }\n');
|
|
}
|
|
write(
|
|
root,
|
|
"dist/current.mjs",
|
|
`//#region ${module}\nfunction ${symbol}() { return "current"; } export { ${symbol} as n };\n`,
|
|
);
|
|
writeUpdateCompatibilityChunks({
|
|
distDir: path.join(root, "dist"),
|
|
sourceDir: root,
|
|
inventory,
|
|
});
|
|
const bridge = await import(pathToFileURL(path.join(root, "dist/surface-abcdefgh.js")).href);
|
|
expect(bridge.x()).toBe("current");
|
|
});
|
|
|
|
it("refuses ambiguous source stars and names both possible declaration owners", () => {
|
|
const { inventory } = recordImportedFixture('(await import("./surface-abcdefgh.js")).x', {
|
|
"surface-abcdefgh.js":
|
|
'//#region src/infra/old.ts\nfunction oldName() { return "old"; } export { oldName as x };\n',
|
|
});
|
|
const root = createTempDir("update-compat-source-conflict-");
|
|
write(root, "src/infra/old.ts", 'export * from "./left.js"; export * from "./right.js";\n');
|
|
for (const side of ["left", "right"]) {
|
|
write(root, `src/infra/${side}.ts`, `export function oldName() { return "${side}"; }\n`);
|
|
write(
|
|
root,
|
|
`dist/${side}.mjs`,
|
|
`//#region src/infra/${side}.ts\nfunction oldName() { return "${side}"; } export { oldName as n };\n`,
|
|
);
|
|
}
|
|
expect(() =>
|
|
writeUpdateCompatibilityChunks({
|
|
distDir: path.join(root, "dist"),
|
|
sourceDir: root,
|
|
inventory,
|
|
}),
|
|
).toThrow(/(?=[\s\S]*left\.ts)(?=[\s\S]*right\.ts)/);
|
|
expect(fsSync.existsSync(path.join(root, "dist/surface-abcdefgh.js"))).toBe(false);
|
|
});
|
|
|
|
function writeWindowInventory(root: string): string {
|
|
const release = recordFixture().releases[0];
|
|
const output = path.join(root, "inventory.json");
|
|
write(
|
|
root,
|
|
"inventory.json",
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
releases: [
|
|
release,
|
|
{ ...release, version: "2026.9.2", chunks: [] },
|
|
{ ...release, version: "2026.9.3", chunks: [] },
|
|
],
|
|
}),
|
|
);
|
|
return output;
|
|
}
|
|
|
|
function runInventoryCli(
|
|
args: string[],
|
|
replies: Array<{ args: string[]; value: unknown }> = [],
|
|
) {
|
|
const root = createTempDir("update-compat-npm-");
|
|
const bin = path.join(root, "bin");
|
|
const callsFile = path.join(root, "calls.json");
|
|
const stub = path.join(bin, "npm.cjs");
|
|
write(
|
|
root,
|
|
"bin/npm.cjs",
|
|
[
|
|
`#!${testNodeExecPath}`,
|
|
'const fs = require("node:fs");',
|
|
`const callsFile = ${JSON.stringify(callsFile)};`,
|
|
`const replies = ${JSON.stringify(replies)};`,
|
|
'const calls = fs.existsSync(callsFile) ? JSON.parse(fs.readFileSync(callsFile, "utf8")) : [];',
|
|
"const args = process.argv.slice(2);",
|
|
"const reply = replies[calls.length];",
|
|
"calls.push(args);",
|
|
"fs.writeFileSync(callsFile, JSON.stringify(calls));",
|
|
'if (!reply || JSON.stringify(reply.args) !== JSON.stringify(args)) throw new Error("unexpected npm call: " + JSON.stringify(args));',
|
|
"console.log(JSON.stringify(reply.value));",
|
|
].join("\n"),
|
|
);
|
|
if (process.platform === "win32") {
|
|
write(root, "bin/npm.cmd", `@"${testNodeExecPath}" "${stub}" %*\r\n`);
|
|
} else {
|
|
fsSync.copyFileSync(stub, path.join(bin, "npm"));
|
|
fsSync.chmodSync(path.join(bin, "npm"), 0o755);
|
|
}
|
|
const result = childProcess.spawnSync(
|
|
testNodeExecPath,
|
|
[path.join(MODULE_ROOT, "scripts/update-compat-inventory.mts"), ...args],
|
|
{
|
|
encoding: "utf8",
|
|
env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` },
|
|
timeout: 30_000,
|
|
},
|
|
);
|
|
const calls: unknown = fsSync.existsSync(callsFile)
|
|
? JSON.parse(fsSync.readFileSync(callsFile, "utf8"))
|
|
: [];
|
|
return { result, calls };
|
|
}
|
|
|
|
it("records an ordered supported window, including releases without post-swap imports, offline", () => {
|
|
const root = createTempDir("update-compat-window-");
|
|
const output = path.join(root, "inventory.json");
|
|
const args = ["--output", output];
|
|
for (const version of ["2026.9.3", "2026.9.1", "2026.9.2"]) {
|
|
const packageDir = path.join(root, version);
|
|
write(packageDir, "package.json", JSON.stringify({ name: "openclaw", version }));
|
|
write(
|
|
packageDir,
|
|
"dist/build-info.json",
|
|
JSON.stringify({ version, buildId: version, commit: "0".repeat(40) }),
|
|
);
|
|
write(packageDir, "dist/entry.js", "export {};\n");
|
|
args.push("--release", `${packageDir}=${integrity}`);
|
|
}
|
|
const generated = runInventoryCli(args);
|
|
expect(generated.result.status, generated.result.stderr).toBe(0);
|
|
expect(generated.calls).toEqual([]);
|
|
expect(
|
|
readUpdateCompatibilityInventory(output).releases.map(({ version, chunks }) => ({
|
|
version,
|
|
chunks,
|
|
})),
|
|
).toEqual([
|
|
{ version: "2026.9.1", chunks: [] },
|
|
{ version: "2026.9.2", chunks: [] },
|
|
{ version: "2026.9.3", chunks: [] },
|
|
]);
|
|
const checked = runInventoryCli([...args, "--check"]);
|
|
expect(checked.result.status, checked.result.stderr).toBe(0);
|
|
expect(checked.calls).toEqual([]);
|
|
});
|
|
|
|
it.each(["npm 11", "npm 12"])(
|
|
"checks %s latest and beta coverage without refetching recorded integrity",
|
|
(npmVersion) => {
|
|
const output = writeWindowInventory(createTempDir("update-compat-tag-coverage-"));
|
|
const tags = { latest: "2026.9.3", beta: "2026.9.2" };
|
|
const npmArgs = ["view", "openclaw", "dist-tags", "--json"];
|
|
const { result, calls } = runInventoryCli(
|
|
["--check", "--output", output],
|
|
[{ args: npmArgs, value: npmVersion === "npm 12" ? [tags] : tags }],
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(calls).toEqual([npmArgs]);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ latest: "2026.9.3", beta: "2026.9.4-beta.1", missing: "2026.9.4-beta.1" },
|
|
{ latest: "2026.9.4", beta: "2026.9.4", missing: "2026.9.4" },
|
|
])(
|
|
"prints a complete refresh command when $missing leaves the window",
|
|
({ latest, beta, missing }) => {
|
|
const output = writeWindowInventory(createTempDir("update-compat-tag-refresh-"));
|
|
const newIntegrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
|
|
const tags = { latest, beta };
|
|
const expectedCalls = [
|
|
["view", "openclaw", "dist-tags", "--json"],
|
|
["view", `openclaw@${missing}`, "dist.integrity", "--json"],
|
|
];
|
|
const { result, calls } = runInventoryCli(
|
|
["--check", "--output", output],
|
|
[
|
|
{ args: expectedCalls[0]!, value: latest === beta ? [tags] : tags },
|
|
{ args: expectedCalls[1]!, value: latest === beta ? [newIntegrity] : newIntegrity },
|
|
],
|
|
);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("pnpm update:compat:gen --output");
|
|
expect(result.stderr).toContain(output);
|
|
expect(result.stderr).toContain(
|
|
[
|
|
...["2026.9.1", "2026.9.2", "2026.9.3"].map(
|
|
(version) => `--release .artifacts/update-compat/${version}/package=${integrity}`,
|
|
),
|
|
`--release .artifacts/update-compat/${missing}/package=${newIntegrity}`,
|
|
].join(" "),
|
|
);
|
|
expect(calls).toEqual(expectedCalls);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ tags: { beta: "2026.9.3" }, invalid: "latest" },
|
|
{ tags: { latest: "2026.9.3", beta: "not-a-release" }, invalid: "beta" },
|
|
])("refuses an absent or invalid $invalid dist-tag", ({ tags, invalid }) => {
|
|
const output = writeWindowInventory(createTempDir("update-compat-invalid-tags-"));
|
|
const npmArgs = ["view", "openclaw", "dist-tags", "--json"];
|
|
const { result, calls } = runInventoryCli(
|
|
["--check", "--output", output],
|
|
[{ args: npmArgs, value: tags }],
|
|
);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(`dist-tag ${invalid} is missing or invalid`);
|
|
expect(calls).toEqual([npmArgs]);
|
|
});
|
|
|
|
it("refuses conflicting release origins before writing any bridge", () => {
|
|
const inventory = recordFixture();
|
|
const newer = structuredClone(inventory.releases[0]);
|
|
newer.version = "2026.9.2";
|
|
const firstExport = newer.chunks[0]?.exports[0];
|
|
if (!firstExport) {
|
|
throw new Error("Fixture has no recorded export");
|
|
}
|
|
firstExport.origin.module = "src/cli/update-cli/different.ts";
|
|
inventory.releases.push(newer);
|
|
const conflict =
|
|
/Release inventories disagree about service-abcdefgh.js:mode: 2026.9.1 uses .*; 2026.9.2 uses/;
|
|
expect(() => parseUpdateCompatibilityInventory(inventory)).toThrow(conflict);
|
|
const root = createTempDir("update-compat-conflict-");
|
|
candidate(root);
|
|
expect(() =>
|
|
writeUpdateCompatibilityChunks({
|
|
distDir: path.join(root, "dist"),
|
|
sourceDir: root,
|
|
inventory,
|
|
}),
|
|
).toThrow(conflict);
|
|
expect(fsSync.existsSync(path.join(root, "dist/service-abcdefgh.js"))).toBe(false);
|
|
});
|
|
|
|
it("keeps compatibility facades out of current runtime alias selection on rebuild", async () => {
|
|
const inventory = recordFixture();
|
|
inventory.releases[0].chunks = inventory.releases[0].chunks.map((chunk) => ({
|
|
...chunk,
|
|
path: "worker.runtime-12345678.js",
|
|
}));
|
|
const root = createTempDir("update-compat-rebuild-");
|
|
candidate(root);
|
|
fsSync.renameSync(
|
|
path.join(root, "dist/current.mjs"),
|
|
path.join(root, "dist/worker.runtime-abcdefgh.mjs"),
|
|
);
|
|
writeStableRootRuntimeAliases({ rootDir: root });
|
|
writeUpdateCompatibilityChunks({
|
|
distDir: path.join(root, "dist"),
|
|
sourceDir: root,
|
|
inventory,
|
|
});
|
|
writeStableRootRuntimeAliases({ rootDir: root });
|
|
const current = await import(pathToFileURL(path.join(root, "dist/worker.runtime.js")).href);
|
|
expect(current.x()).toBe("node");
|
|
expect(current.y()).toBe("npm");
|
|
});
|
|
|
|
it("records emitted aliases and forwards old consumers to the current implementations", async () => {
|
|
const inventory = recordFixture();
|
|
expect(inventory.releases[0].chunks).toMatchObject([
|
|
{
|
|
path: "service-abcdefgh.js",
|
|
exports: [
|
|
{
|
|
exported: "mode",
|
|
origin: { module: "src/cli/update-cli/recovery.ts", symbol: "resolveMode" },
|
|
},
|
|
{
|
|
exported: "runner",
|
|
origin: { module: "src/cli/update-cli/runner.ts", symbol: "resolveRunner" },
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
const root = createTempDir("update-compat-candidate-");
|
|
candidate(root);
|
|
const options = { distDir: path.join(root, "dist"), sourceDir: root, inventory };
|
|
writeUpdateCompatibilityChunks(options);
|
|
const bridge = path.join(root, "dist/service-abcdefgh.js");
|
|
const first = fsSync.readFileSync(bridge, "utf8");
|
|
writeUpdateCompatibilityChunks(options);
|
|
expect(fsSync.readFileSync(bridge, "utf8")).toBe(first);
|
|
const loaded = await import(pathToFileURL(bridge).href);
|
|
expect(loaded.runner()).toBe("node");
|
|
expect(loaded.mode()).toBe("npm");
|
|
});
|
|
|
|
it.each([
|
|
'import { y as mode } from "../current.mjs"; export { mode as forwarded };',
|
|
'export { y as forwarded } from "../current.mjs";',
|
|
'export * from "../current.mjs";',
|
|
])("bridges shared declaration bindings through %s", async (forwarding) => {
|
|
const inventory = recordFixture();
|
|
const root = createTempDir("update-compat-shared-binding-");
|
|
candidate(root);
|
|
fsSync.appendFileSync(path.join(root, "dist/current.mjs"), "\nexport { resolveMode as z };\n");
|
|
write(
|
|
root,
|
|
"dist/a-facade/forward.mjs",
|
|
`//#region src/cli/update-cli/mode.ts\n${forwarding}\n`,
|
|
);
|
|
const options = { distDir: path.join(root, "dist"), sourceDir: root, inventory };
|
|
writeUpdateCompatibilityChunks(options);
|
|
const bridge = path.join(root, "dist/service-abcdefgh.js");
|
|
const contents = fsSync.readFileSync(bridge, "utf8");
|
|
expect(contents).toContain('export { y as mode } from "./current.mjs";');
|
|
writeUpdateCompatibilityChunks(options);
|
|
expect(fsSync.readFileSync(bridge, "utf8")).toBe(contents);
|
|
const loaded = await import(pathToFileURL(bridge).href);
|
|
const current = await import(pathToFileURL(path.join(root, "dist/current.mjs")).href);
|
|
expect(loaded.mode).toBe(current.y);
|
|
expect(loaded.mode()).toBe("npm");
|
|
expect(loaded.runner).toBe(current.x);
|
|
});
|
|
|
|
it.each([
|
|
["config-doctor", "present"],
|
|
["config-doctor", "missing"],
|
|
["native-hook-relay", "present"],
|
|
["native-hook-relay", "missing"],
|
|
])(
|
|
"excludes the isolated %s graph when the runtime binding is %s",
|
|
async (directory, runtime) => {
|
|
const inventory = recordFixture();
|
|
const root = createTempDir("update-compat-isolated-graph-");
|
|
candidate(root);
|
|
const current = path.join(root, "dist/current.mjs");
|
|
write(root, `dist/${directory}/inspect.mjs`, fsSync.readFileSync(current, "utf8"));
|
|
const options = { distDir: path.join(root, "dist"), sourceDir: root, inventory };
|
|
if (runtime === "missing") {
|
|
fsSync.unlinkSync(current);
|
|
expect(() => writeUpdateCompatibilityChunks(options)).toThrow(
|
|
/no equivalent current export/,
|
|
);
|
|
expect(fsSync.existsSync(path.join(root, "dist/service-abcdefgh.js"))).toBe(false);
|
|
return;
|
|
}
|
|
writeUpdateCompatibilityChunks(options);
|
|
const bridge = await import(pathToFileURL(path.join(root, "dist/service-abcdefgh.js")).href);
|
|
const declaration = await import(pathToFileURL(current).href);
|
|
expect(bridge.mode).toBe(declaration.y);
|
|
expect(bridge.runner).toBe(declaration.x);
|
|
},
|
|
);
|
|
|
|
it.each(["missing", "ambiguous"])(
|
|
"refuses %s required implementations before writing bridges",
|
|
(failure) => {
|
|
const inventory = recordFixture();
|
|
const root = createTempDir("update-compat-refusal-");
|
|
candidate(root);
|
|
if (failure === "missing") {
|
|
write(root, "dist/current.mjs", "export {};\n");
|
|
} else {
|
|
fsSync.copyFileSync(
|
|
path.join(root, "dist/current.mjs"),
|
|
path.join(root, "dist/duplicate.mjs"),
|
|
);
|
|
}
|
|
expect(() =>
|
|
writeUpdateCompatibilityChunks({
|
|
distDir: path.join(root, "dist"),
|
|
sourceDir: root,
|
|
inventory,
|
|
}),
|
|
).toThrow(/Cannot bridge service-abcdefgh\.js export mode/);
|
|
expect(fsSync.existsSync(path.join(root, "dist/service-abcdefgh.js"))).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("does not replace a public entrypoint to conceal a missing export", () => {
|
|
const inventory = recordFixture();
|
|
inventory.releases[0].chunks = inventory.releases[0].chunks.map((chunk) => ({
|
|
...chunk,
|
|
path: "service.js",
|
|
}));
|
|
const root = createTempDir("update-compat-public-");
|
|
candidate(root);
|
|
const original = "export const other = true;\n";
|
|
write(root, "dist/service.js", original);
|
|
expect(() =>
|
|
writeUpdateCompatibilityChunks({
|
|
distDir: path.join(root, "dist"),
|
|
sourceDir: root,
|
|
inventory,
|
|
}),
|
|
).toThrow(/service\.js lacks mode/);
|
|
expect(fsSync.readFileSync(path.join(root, "dist/service.js"), "utf8")).toBe(original);
|
|
});
|
|
});
|