openclaw/test/vitest-ui-e2e-prebuilt.test.ts
Vincent Koc e82ff061ff
fix(test): prepare matching Control UI assets before E2E (#159624)
* fix(test): prepare matching Control UI assets before E2E

* fix(test): reconcile Control UI E2E preparation with main

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-09-27 19:46:53 +07:00

232 lines
8.6 KiB
TypeScript

// Parallel readers must be rejected before a cold, dirty, or stale runtime can rebuild.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
BUILD_STAMP_FILE,
writeBuildStamp,
writeRuntimePostBuildStamp,
} from "../scripts/lib/local-build-metadata.mts";
import { writeUpdateCompatibilityChunks } from "../scripts/lib/update-compat-chunks.mts";
import { listCoreRuntimePostBuildOutputs } from "../scripts/runtime-postbuild.mts";
import { writeBuildInfo } from "../scripts/write-build-info.ts";
import { inspectControlUiRootAssets } from "../src/infra/control-ui-assets.ts";
import { spawnNodeEvalSync } from "../src/test-utils/node-process.ts";
import {
previousReleaseInventory,
writeUpdateCompatibilityBuildFixture,
} from "./scripts/update-compat-chunks.test-support.js";
import { assertPrebuiltUiE2eRuntime } from "./vitest/vitest.ui-e2e-prebuilt.global-setup.ts";
let root: string;
const fixtureBuildEnv = { OPENCLAW_BUILD_TIMESTAMP: "2026-09-23T00:00:00.000Z" };
function write(relative: string, contents = "fixture\n") {
const file = path.join(root, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
}
function git(...args: string[]) {
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim();
}
function writeMatchingUi() {
const { buildId } = JSON.parse(fs.readFileSync(path.join(root, "dist/build-info.json"), "utf8"));
write(
"dist/control-ui/index.html",
`<html data-openclaw-control-ui-build-id="${buildId}-${"a".repeat(64)}"><script src="./assets/entry.js"></script></html>`,
);
}
beforeEach(() => {
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-prebuilt-ui-")));
vi.stubEnv("OPENCLAW_FORCE_BUILD", undefined);
vi.stubEnv("OPENCLAW_FORCE_RUNTIME_POSTBUILD", undefined);
vi.stubEnv("OPENCLAW_DEV_SOURCE_ROOT", root);
write(".gitignore", "dist/\n");
write("package.json", '{"name":"prebuilt-ui-fixture","private":true}\n');
git("init", "--quiet");
git("add", ".");
git(
"-c",
"user.name=Fixture",
"-c",
"user.email=fixture@example.invalid",
"-c",
"commit.gpgsign=false",
"-c",
"core.hooksPath=/dev/null",
"commit",
"--quiet",
"-m",
"fixture",
);
writeUpdateCompatibilityBuildFixture(root);
writeUpdateCompatibilityChunks({
distDir: path.join(root, "dist"),
sourceDir: root,
inventory: previousReleaseInventory,
});
for (const file of [
"dist/entry.js",
"dist/plugin-sdk/qa-lab.js",
"dist/plugin-sdk/qa-runtime.js",
...listCoreRuntimePostBuildOutputs({ rootDir: root }),
]) {
if (!fs.existsSync(path.join(root, file))) {
write(file);
}
}
write("dist/control-ui/assets/entry.js");
write("dist/control-ui/asset-manifest.json", '{"assets":["assets/entry.js"]}');
writeBuildInfo({ rootDir: root, env: fixtureBuildEnv });
writeMatchingUi();
writeBuildStamp({ cwd: root });
writeRuntimePostBuildStamp({ cwd: root });
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(root, { recursive: true, force: true });
});
it("admits a ready generation without writing outputs and detects later metadata drift", () => {
const stampPath = path.join(root, "dist", BUILD_STAMP_FILE);
const before = fs.readFileSync(stampPath);
const generation = assertPrebuiltUiE2eRuntime(root);
expect(assertPrebuiltUiE2eRuntime(root)).toBe(generation);
expect(fs.readFileSync(stampPath)).toEqual(before);
const metadata = JSON.parse(fs.readFileSync(path.join(root, "dist/build-info.json"), "utf8"));
write("dist/build-info.json", JSON.stringify({ ...metadata, generation: "later" }));
expect(assertPrebuiltUiE2eRuntime(root)).not.toBe(generation);
});
it("rejects retained UI assets after a same-source runtime rebuild without repairing them", () => {
const uiRoot = path.join(root, "dist/control-ui");
const indexPath = path.join(uiRoot, "index.html");
const index = fs.readFileSync(indexPath);
const before = JSON.parse(fs.readFileSync(path.join(root, "dist/build-info.json"), "utf8"));
expect(inspectControlUiRootAssets(uiRoot, before.buildId).kind).toBe("ready");
writeBuildInfo({
rootDir: root,
env: { OPENCLAW_BUILD_TIMESTAMP: "2026-09-23T00:01:00.000Z" },
});
const after = JSON.parse(fs.readFileSync(path.join(root, "dist/build-info.json"), "utf8"));
expect(after.commit).toBe(before.commit);
expect(after.buildId).not.toBe(before.buildId);
expect(inspectControlUiRootAssets(uiRoot, after.buildId)).toMatchObject({
kind: "stale",
buildId: before.buildId,
});
expect(() => assertPrebuiltUiE2eRuntime(root)).toThrow("Control UI assets are not ready (stale)");
expect(fs.readFileSync(indexPath)).toEqual(index);
});
it.each([
{
name: "missing private QA",
remove: "dist/plugin-sdk/qa-runtime.js",
reason: "missing_private_qa_dist",
},
{
name: "missing postbuild output",
remove: "dist/build-info.json",
reason: "missing_runtime_postbuild_output",
},
{ name: "incomplete UI", remove: "dist/control-ui/assets/entry.js", reason: "Control UI assets" },
])("rejects $name without repairing it", ({ remove, reason }) => {
const target = path.join(root, remove);
fs.rmSync(target, { recursive: true, force: true });
expect(() => assertPrebuiltUiE2eRuntime(root)).toThrow(reason);
expect(fs.existsSync(target)).toBe(false);
});
it.each(["untracked.ts", "package.json"])("rejects dirty source %s", (file) => {
write(file);
expect(() => assertPrebuiltUiE2eRuntime(root)).toThrow("source state is dirty");
});
it("rejects an artifact generation built at another HEAD", () => {
write(`dist/${BUILD_STAMP_FILE}`, '{"head":"different"}');
expect(() => assertPrebuiltUiE2eRuntime(root)).toThrow("git_head_changed");
});
it.each([
["OPENCLAW_FORCE_BUILD", "force_build"],
["OPENCLAW_FORCE_RUNTIME_POSTBUILD", "force_runtime_postbuild"],
])("honors the owner's %s rebuild decision", (key, reason) => {
vi.stubEnv(key, "1");
expect(() => assertPrebuiltUiE2eRuntime(root)).toThrow(reason);
});
it("rejects unavailable Git identity instead of falling back to mtimes", () => {
fs.rmSync(path.join(root, ".git"), { recursive: true, force: true });
expect(() => assertPrebuiltUiE2eRuntime(root)).toThrow("Git HEAD is unavailable");
});
it.each([false, true])("sets the native CLI outcome after teardown (drift: %s)", (drift) => {
const repoRoot = path.resolve(import.meta.dirname, "..");
write(".gitignore", "dist/\nnode_modules/\n");
fs.symlinkSync(path.join(repoRoot, "node_modules"), path.join(root, "node_modules"), "junction");
write(
"probe.test.ts",
`import fs from "node:fs";
import { it } from "vitest";
it("finishes before the generation check", () => {
if (${drift}) {
const file = ${JSON.stringify(path.join(root, "dist/build-info.json"))};
const metadata = JSON.parse(fs.readFileSync(file, "utf8"));
fs.writeFileSync(file, JSON.stringify({ ...metadata, generation: "later" }));
}
});`,
);
git("add", ".");
git(
"-c",
"user.name=Fixture",
"-c",
"user.email=fixture@example.invalid",
"-c",
"commit.gpgsign=false",
"-c",
"core.hooksPath=/dev/null",
"commit",
"--quiet",
"-m",
"native fixture",
);
writeBuildInfo({ rootDir: root, env: fixtureBuildEnv });
writeMatchingUi();
writeBuildStamp({ cwd: root });
writeRuntimePostBuildStamp({ cwd: root });
const reportFile = path.join(root, ".git/report.json");
const configFile = path.join(root, ".git/probe.config.mts");
write(
".git/probe.config.mts",
`import { sharedVitestConfig } from ${JSON.stringify(path.join(repoRoot, "test/vitest/vitest.shared.config.ts"))};
export default { resolve: sharedVitestConfig.resolve };`,
);
const result = spawnNodeEvalSync(
`import { startVitest } from "vitest/node";
await startVitest("test", [], {
root: ${JSON.stringify(root)}, config: ${JSON.stringify(configFile)},
configLoader: "runner", watch: false,
include: ["probe.test.ts"], pool: "forks", maxWorkers: 1,
globalSetup: [${JSON.stringify(path.join(repoRoot, "test/vitest/vitest.ui-e2e-prebuilt.global-setup.ts"))}],
reporters: ["json"], outputFile: ${JSON.stringify(reportFile)},
});`,
{ cwd: repoRoot, timeout: 15_000 },
);
expect(result.error, result.stderr).toBeUndefined();
expect(result.signal, result.stderr).toBeNull();
const report = JSON.parse(fs.readFileSync(reportFile, "utf8"));
expect(report.numPassedTests, JSON.stringify(report) + result.stderr).toBe(1);
expect(result.status, result.stderr).toBe(drift ? 1 : 0);
if (drift) {
expect(result.stderr).toContain("runtime generation changed during the run");
}
});