openclaw/test/scripts/compiler-input-snapshot.test.ts
Peter Steinberger ec5058c973
fix(test): plugin install, staging, release, and Docker tooling tests fail on loaded hosts when fixture children outlast polling deadlines (#162848)
Script-tooling tests for the bundled-plugin install/uninstall probe and runtime command, the ClawHub fixture server, compiler input snapshots, Crabbox staging, E2E mock config limits, full-release publication admission, kitchen-sink plugin assertions, the Parallels npm update smoke, the release CI summary, test-install-sh Docker, and the upgrade-survivor stop policy raced healthy fixture startup, file commitment, or teardown against 500 ms-20 s observation deadlines, so a slow but healthy fixture on a loaded host failed the test.

Waits now follow the producer: owned stdout/IPC (the ClawHub fixture server also reports its port over process.send when a Node caller owns IPC, after its existing atomic port-file write), fixture socket receipts reconciled against durable records, inherited pipe/FIFO receipts, existing deferreds, and process close joins. Crabbox staging uses distinct ready/release paths per recovery generation. Owners whose timed-out body Vitest would abandon register the same memoized cleanup with the test lifecycle and keep scoped directories until that join. The eight Darwin-native sites in pr-darwin-process-identity.test.py stay unchanged. No product source, test timeout, product timeout, or assertion meaning changed.

Part of the polling audit from #162274. Proof on Blacksmith Testbox: deterministic delay probes fail the original bytes and pass these bytes, forced-abort probes verify first-finally markers and actual child extinction, 220 standalone runs, 3/3 replays of each of the eight owning shards, root types, type-aware lint, base-aware timeout-race ratchet; Codex autoreview clean.
2026-10-01 10:28:51 -07:00

592 lines
23 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { ARTIFACT_CACHE_VERSION } from "../../scripts/lib/build-artifact-cache.mts";
import { CompilerInputSnapshot } from "../../scripts/lib/compiler-input-snapshot.mts";
import { awaitGateBeforeSettlement, createDeferred, withinTest } from "../helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const roots = useAutoCleanupTempDirTracker(afterEach);
function fixture(root = roots.make("compiler-input-snapshot-")) {
const write = (file: string, bytes: string) => {
const filename = path.join(root, file);
fs.mkdirSync(path.dirname(filename), { recursive: true });
fs.writeFileSync(filename, bytes);
};
write("package.json", '{"type":"module"}');
write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
write("base.json", '{"compilerOptions":{"target":"ES2023","types":[]}}');
write("tsconfig.json", '{"extends":"./base.json","include":["src/**/*.ts"]}');
write("src/index.ts", "export const value = 1;\n");
write("tools/compiler.js", "export const compiler = 1;\n");
write("scripts/generator.mts", "export const generator = 1;\n");
write("packages/local/package.json", '{"name":"fixture-package","type":"module"}');
write("packages/local/src/index.ts", "export const dependency = 1;\n");
write("packages/local/dist/index.js", "export const built = 1;\n");
write(".cache/vitest/ignored.json", "{}");
fs.mkdirSync(path.join(root, "node_modules"));
const link = (target: string, name: string) =>
fs.symlinkSync(
path.join(root, target),
path.join(root, name),
process.platform === "win32" ? "junction" : "dir",
);
// The source alias is visited before the installed alias upgrades this tree.
link("packages/local", "alias");
link("packages/local", "node_modules/fixture-package");
link("packages/local", "packages/local/self");
const snapshot = () =>
new CompilerInputSnapshot(root, {
toolchainFiles: ["tools/compiler.js"],
generatorInputs: ["package.json", "pnpm-lock.yaml", "scripts/generator.mts"],
isGeneratorInput: (file) => file.endsWith("/package.json"),
});
const signature = (input: CompilerInputSnapshot, outputRoot?: string) =>
input.signature("tsconfig.json", ["fixture-compiler"], ["src/index.ts"], outputRoot);
return { root, write, snapshot, signature };
}
function sealDiagnostic(before: CompilerInputSnapshot, after: CompilerInputSnapshot) {
let message = "";
try {
after.seal("tsconfig.json", ["fixture-compiler"], ["src/index.ts"], before, Date.now());
} catch (error) {
// Worker errors cross the process boundary as String(error), without custom fields.
message = String(error);
}
const prefix =
"Error: Boundary configuration or resolution topology changed during compilation: ";
expect(message.startsWith(prefix)).toBe(true);
const suffix = message.slice(prefix.length);
expect(Buffer.byteLength(suffix)).toBeLessThanOrEqual(4096);
return { suffix, detail: JSON.parse(suffix) };
}
it.each([
['{"compilerOptions":{"target":"invalid"},"include":["src/**/*.ts"]}', "TS6046"],
['{"include":"src/**/*.ts"}', "TS5024"],
['{"files":"src/index.ts"}', "TS5024"],
['{"extends":"./missing.json","include":["src/**/*.ts"]}', "TS5083"],
['{"compilerOptions": {', "TS1005"],
])("rejects invalid native compiler configuration %s", (config, diagnostic) => {
const f = fixture();
f.write("tsconfig.json", config);
expect(() => f.signature(f.snapshot())).toThrow(diagnostic);
});
it("seals an unchanged captured config at the compilation clock boundary", () => {
const f = fixture();
const stage = path.join(f.root, ".artifacts/native-declarations-fixture");
const config = path.join(stage, "tsconfig.json");
f.write(path.relative(f.root, config), '{"extends":"../../tsconfig.json"}');
const before = f.snapshot();
const signature = before.signature(config, [], [], stage);
const startedAt = fs.statSync(config).ctimeMs;
const after = f.snapshot();
for (let attempt = 0; attempt < 2; attempt++) {
expect(after.seal(config, [], [], before, startedAt, stage).signature).toBe(signature);
}
});
it("does not promote config reads from an earlier seal into precompilation evidence", () => {
const f = fixture();
const stage = path.join(f.root, ".artifacts/native-declarations-fixture");
const config = path.join(stage, "tsconfig.json");
f.write(path.relative(f.root, config), '{"extends":"../../tsconfig.json"}');
const before = f.snapshot();
f.signature(before, stage);
const startedAt = fs.statSync(config).ctimeMs;
for (let attempt = 0; attempt < 2; attempt++) {
expect(() => f.snapshot().seal(config, [], [], before, startedAt, stage)).toThrow(
`Boundary input changed during compilation: ${config}`,
);
}
});
it("keeps the clock fence for source bytes first discovered in compiler membership", () => {
const f = fixture();
f.write("src/discovered.ts", "export const discovered = 1;\n");
const before = f.snapshot();
f.signature(before);
const startedAt = fs.statSync(path.join(f.root, "src/discovered.ts")).ctimeMs;
expect(() =>
f.snapshot().seal("tsconfig.json", [], ["src/discovered.ts"], before, startedAt),
).toThrow("Boundary input changed during compilation: src/discovered.ts");
});
it.each(["ctimeMs", "dev", "ino"] as const)(
"rejects a captured input whose %s changes while its bytes stay identical",
(field) => {
const f = fixture();
const input = path.join(f.root, "src/index.ts");
const captured = fs.statSync(input);
const before = f.snapshot();
f.signature(before);
const stat = fs.statSync.bind(fs);
const reader = vi.spyOn(fs, "statSync").mockImplementation((...args) => {
const result = stat(...args);
if (args[0] === input && result) {
Object.defineProperty(result, field, { value: captured[field] + 1 });
}
return result;
});
try {
// Isolate identity comparison from the timestamp fence for late reads.
expect(() =>
f.snapshot().seal("tsconfig.json", [], ["src/index.ts"], before, captured.ctimeMs + 2),
).toThrow("Boundary input changed during compilation: src/index.ts");
} finally {
reader.mockRestore();
}
},
);
it.each(["added", "removed"] as const)("identifies a %s namespace entry when sealing", (change) => {
const f = fixture();
const filename = ".workflow-shell-fixture.mjs";
if (change === "removed") {
f.write(filename, "export {};\n");
}
const before = f.snapshot();
f.signature(before);
if (change === "added") {
f.write(filename, "export {};\n");
} else {
fs.unlinkSync(path.join(f.root, filename));
}
// Namespace remains the first rejection even when a later guard also differs.
f.write("tools/compiler.js", "export const compiler = 2;\n");
const after = f.snapshot();
f.signature(after);
const reader = vi.spyOn(fs, "readFileSync");
try {
expect(sealDiagnostic(before, after).detail).toEqual({
category: "namespace",
changes: [{ change, path: filename }],
omitted: 0,
});
expect(reader).not.toHaveBeenCalled();
} finally {
reader.mockRestore();
}
});
it("admits exact producer additions while retaining their completed namespace signature", () => {
const f = fixture();
const before = f.snapshot();
const original = f.signature(before);
const startedAt = Date.now();
const output = path.join(fs.realpathSync.native(f.root), "runtime-output/chunk.mjs");
f.write("runtime-output/chunk.mjs", "export const bundled = 1;\n");
const after = f.snapshot();
const sealed = after.seal(
"tsconfig.json",
["fixture-compiler"],
["src/index.ts"],
before,
startedAt,
undefined,
new Set([output]),
);
expect(sealed.signature).toBe(f.signature(after));
expect(sealed.signature).not.toBe(original);
});
it.each(["unowned root addition", "consumed output input", "output directory symlink"])(
"retains the compiler boundary for a %s alongside producer facts",
(change) => {
const f = fixture();
const canonicalRoot = fs.realpathSync.native(f.root);
if (change === "output directory symlink") {
fs.symlinkSync(
path.join(f.root, "src"),
path.join(f.root, "runtime-output"),
process.platform === "win32" ? "junction" : "dir",
);
}
const before = f.snapshot();
f.signature(before);
const startedAt = Date.now();
let output = path.join(canonicalRoot, "chunk.mjs");
if (change === "unowned root addition") {
f.write("chunk.mjs", "export const bundled = 1;\n");
f.write("unowned.ts", "export const candidate = 1;\n");
} else if (change === "consumed output input") {
output = path.join(canonicalRoot, "src/index.ts");
f.write("src/index.ts", "export const value = 2;\n");
} else {
output = path.join(canonicalRoot, "runtime-output/chunk.mjs");
f.write("src/chunk.mjs", "export const bundled = 1;\n");
}
expect(() =>
f
.snapshot()
.seal(
"tsconfig.json",
["fixture-compiler"],
["src/index.ts"],
before,
startedAt,
undefined,
new Set([output]),
),
).toThrow(
change === "consumed output input"
? "Boundary input changed during compilation"
: "Boundary configuration or resolution topology changed during compilation",
);
},
);
it.each([
["toolchain", "tools/compiler.js", "export const compiler = 2;\n"],
["config", "base.json", '{"compilerOptions":{"target":"ES2022","types":[]}}'],
["config-bytes", "base.json", '{ "compilerOptions": {"target":"ES2023","types":[]} }\n'],
])(
"identifies a %s rejection without exposing configuration or tool bytes",
(category, file, bytes) => {
const f = fixture();
const before = f.snapshot();
f.signature(before);
f.write(file!, bytes!);
expect(sealDiagnostic(before, f.snapshot()).detail).toEqual({ category });
},
);
it.each(["ascii", "unicode", "controls"])(
"bounds and orders %s namespace diagnostics deterministically",
(kind) => {
const f = fixture();
const before = f.snapshot();
f.signature(before);
const segment =
kind === "ascii"
? "x".repeat(80)
: kind === "unicode"
? "😀".repeat(30)
: "\u0085\u202e\u2066\u2028".repeat(15);
const filenames = Array.from(
{ length: 20 },
(_, index) => `added/${String(index).padStart(2, "0")}/${segment}/${segment}.mjs`,
);
for (const filename of filenames.toReversed()) {
f.write(filename, "export {};\n");
}
const first = sealDiagnostic(before, f.snapshot());
const second = sealDiagnostic(before, f.snapshot());
expect(second.suffix).toBe(first.suffix);
if (kind === "controls") {
expect(first.suffix).not.toMatch(/[\u0085\u202e\u2066\u2028]/u);
expect(first.suffix).toContain("\\u0085\\u202e\\u2066\\u2028");
}
const changes = first.detail.changes as { change: string; path: string }[];
expect(changes.length).toBeGreaterThan(0);
expect(changes.length).toBeLessThanOrEqual(16);
expect(first.detail.omitted).toBe(20 - changes.length);
expect(changes).toEqual(
filenames.slice(0, changes.length).map((file) => ({
change: "added",
path: file.length > 160 ? `${file.slice(0, 157)}...` : file,
})),
);
expect(changes.every(({ path: filename }) => filename.length <= 160)).toBe(true);
},
);
it("prepares the same ordered source and installed-alias namespace as synchronous readers", async () => {
const f = fixture();
const synchronous = f.snapshot();
const prepared = f.snapshot();
await prepared.prepare();
for (const outputRoot of [undefined, path.join(f.root, "packages/local/dist")]) {
expect(f.signature(prepared, outputRoot)).toBe(f.signature(synchronous, outputRoot));
}
});
it("ignores checkout scratch packages that disappear during preparation", async () => {
const f = fixture();
const original = f.signature(f.snapshot());
const scratch = path.join(f.root, ".tmp", "fixture-package");
f.write(".tmp/fixture-package/package.json", '{"name":"transient-fixture"}');
const read = fs.promises.readdir.bind(fs.promises);
const reader = vi.spyOn(fs.promises, "readdir").mockImplementation(async (...args) => {
const entries = await read(...args);
if (args[0] === scratch) {
fs.rmSync(scratch, { recursive: true });
}
return entries;
});
try {
const prepared = f.snapshot();
await prepared.prepare();
expect(f.signature(prepared)).toBe(original);
expect(f.signature(f.snapshot())).toBe(original);
} finally {
reader.mockRestore();
}
});
it("preserves signatures across checkout depths with one shared external dependency", async () => {
const directory = roots.make("compiler-input-snapshot-portability-");
const dependency = path.join(directory, "shared-dependency");
fs.mkdirSync(dependency);
fs.writeFileSync(
path.join(dependency, "package.json"),
'{"name":"shared-dependency","type":"module"}',
);
fs.writeFileSync(path.join(dependency, "index.js"), "export const value = 1;\n");
const signatures: string[] = [];
for (const root of [path.join(directory, "shallow"), path.join(directory, "deeper/checkout")]) {
fs.mkdirSync(path.join(root, "src"), { recursive: true });
fs.mkdirSync(path.join(root, "node_modules"));
fs.writeFileSync(path.join(root, "package.json"), '{"type":"module"}');
fs.writeFileSync(
path.join(root, "tsconfig.json"),
'{"compilerOptions":{"target":"ES2023","types":[]},"include":["src/**/*.ts"]}',
);
fs.writeFileSync(
path.join(root, "src/index.ts"),
'export { value } from "shared-dependency";\n',
);
fs.symlinkSync(
dependency,
path.join(root, "node_modules/shared-dependency"),
process.platform === "win32" ? "junction" : "dir",
);
const snapshot = new CompilerInputSnapshot(root, {
toolchainFiles: [],
generatorInputs: ["package.json"],
isGeneratorInput: (file) => file.endsWith("/package.json"),
});
await snapshot.prepare();
signatures.push(
snapshot.signature("tsconfig.json", [], ["src/index.ts", path.join(dependency, "index.js")]),
);
}
expect(signatures[1]).toBe(signatures[0]);
});
it("invalidates warm records when ancestor installs appear or disappear without reading their contents", async () => {
const directory = roots.make("compiler-input-snapshot-ancestor-");
const f = fixture(path.join(directory, "checkout"));
f.write("dist/index.d.ts", "export declare const value = 1;\n");
const record = (snapshot: CompilerInputSnapshot) => ({
version: ARTIFACT_CACHE_VERSION,
signature: f.signature(snapshot),
inputs: ["src/index.ts"],
outputs: { "dist/index.d.ts": snapshot.hash("dist/index.d.ts") },
});
const matches = (saved: ReturnType<typeof record>) =>
f.snapshot().matches(saved, "tsconfig.json", ["fixture-compiler"], ["dist/index.d.ts"]);
const before = f.snapshot();
const absent = record(before);
expect(matches(absent)).toBe(true);
const install = path.join(directory, "node_modules");
fs.mkdirSync(install);
const after = f.snapshot();
await after.prepare();
expect(after.matches(absent, "tsconfig.json", ["fixture-compiler"], ["dist/index.d.ts"])).toBe(
false,
);
expect(sealDiagnostic(before, after).detail.category).toBe("namespace");
const present = record(after);
expect(matches(present)).toBe(true);
fs.mkdirSync(path.join(install, "external"));
fs.writeFileSync(path.join(install, "external/package.json"), '{"version":"1.0.0"}');
expect(matches(present)).toBe(true);
fs.writeFileSync(path.join(install, "external/package.json"), '{"version":"2.0.0"}');
expect(matches(present)).toBe(true);
fs.rmSync(install, { recursive: true });
expect(matches(present)).toBe(false);
expect(matches(absent)).toBe(true);
});
it.each(["dependency", "@scope/dependency"])(
"guards empty local package directory %s before acceptance and warm reuse",
(name) => {
const f = fixture();
const directory = path.join(f.root, "node_modules", name);
fs.mkdirSync(path.dirname(directory), { recursive: true });
const record = (snapshot: CompilerInputSnapshot) => ({
version: ARTIFACT_CACHE_VERSION,
signature: f.signature(snapshot),
inputs: ["src/index.ts"],
outputs: {},
});
const before = f.snapshot();
const absent = record(before);
fs.mkdirSync(directory);
const after = f.snapshot();
const present = record(after);
expect(after.matches(absent, "tsconfig.json", ["fixture-compiler"], [])).toBe(false);
expect(sealDiagnostic(before, after).detail.category).toBe("namespace");
fs.rmdirSync(directory);
const removed = f.snapshot();
expect(removed.matches(present, "tsconfig.json", ["fixture-compiler"], [])).toBe(false);
expect(sealDiagnostic(after, removed).detail.category).toBe("namespace");
expect(removed.matches(absent, "tsconfig.json", ["fixture-compiler"], [])).toBe(true);
},
);
it("invalidates an indirect dependency symlink when its final target changes", async () => {
const f = fixture();
const outside = roots.make("compiler-input-snapshot-targets-");
const first = path.join(outside, "first");
const second = path.join(outside, "second");
for (const [directory, value] of [
[first, "first"],
[second, "second"],
] as const) {
fs.mkdirSync(directory);
fs.writeFileSync(
path.join(directory, "package.json"),
'{"name":"external-fixture","type":"module"}',
);
fs.writeFileSync(path.join(directory, "source.js"), `export const value = "${value}";\n`);
}
const indirect = path.join(outside, "indirect");
const installed = path.join(f.root, "node_modules/external-fixture");
const linkType = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync(first, indirect, linkType);
fs.symlinkSync(indirect, installed, linkType);
const originalLink = fs.readlinkSync(installed);
const inputs = [path.join(first, "source.js")];
const before = f.snapshot();
await before.prepare();
const original = before.signature("tsconfig.json", [], inputs);
fs.unlinkSync(indirect);
fs.symlinkSync(second, indirect, linkType);
const after = f.snapshot();
await after.prepare();
expect(fs.readlinkSync(installed)).toBe(originalLink);
expect(fs.readFileSync(path.join(installed, "source.js"), "utf8")).toContain('"second"');
expect(fs.readFileSync(inputs[0]!, "utf8")).toContain('"first"');
expect(after.signature("tsconfig.json", [], inputs)).not.toBe(original);
const diagnostic = sealDiagnostic(before, after);
expect(diagnostic.detail).toEqual({
category: "namespace",
changes: [{ change: "changed", path: "node_modules/external-fixture" }],
omitted: 0,
});
for (const privatePath of [f.root, outside, first, second, indirect, originalLink]) {
expect(diagnostic.suffix).not.toContain(privatePath);
}
});
it("preloads sibling subtrees while the ordered visitor waits on a deeper directory", async ({
signal,
onTestFinished,
}) => {
const testRoots = useAutoCleanupTempDirTracker(onTestFinished);
const f = fixture(testRoots.make("compiler-input-snapshot-preparation-"));
f.write("fanout/a/deeper/input.ts", "export {};\n");
f.write("fanout/b/deeper/input.ts", "export {};\n");
f.write(".artifacts/ignored/input.ts", "export {};\n");
const held = path.join(f.root, "fanout/a/deeper");
const sibling = path.join(f.root, "fanout/b/deeper");
const heldStarted = createDeferred();
const siblingStarted = createDeferred();
const release = createDeferred();
const read = fs.promises.readdir.bind(fs.promises);
const observed = new Set<string>();
let active = 0;
let peak = 0;
const reader = vi.spyOn(fs.promises, "readdir").mockImplementation(async (...args) => {
observed.add(String(args[0]));
active += 1;
peak = Math.max(peak, active);
try {
const entries = await read(...args);
if (args[0] === held) {
heldStarted.resolve();
await release.promise;
} else if (args[0] === sibling) {
siblingStarted.resolve();
}
return entries;
} finally {
active -= 1;
}
});
const snapshot = f.snapshot();
const preparation = snapshot.prepare();
let cleanup: Promise<void> | undefined;
const finishPreparation = () =>
(cleanup ??= (async () => {
release.resolve();
try {
await preparation;
} finally {
reader.mockRestore();
}
})());
// Vitest runs these hooks in reverse order, after afterEach: join before removing inputs.
onTestFinished(finishPreparation);
try {
await withinTest(
awaitGateBeforeSettlement(
Promise.all([heldStarted.promise, siblingStarted.promise]),
preparation,
"preparation serialized the independent directory subtrees",
),
signal,
);
expect(peak).toBeLessThanOrEqual(16);
} finally {
await finishPreparation();
}
expect(active).toBe(0);
expect(observed.has(path.join(f.root, ".artifacts"))).toBe(false);
expect(observed.has(path.join(f.root, ".cache/vitest"))).toBe(false);
expect(f.signature(snapshot)).toBe(f.signature(f.snapshot()));
});
it.each([
["source addition", "src/shadow.ts", "export const shadow = 1;\n"],
["package addition", "src/package.json", '{"type":"commonjs"}'],
["nested workspace metadata", "packages/local/.tmp/package.json", '{"type":"commonjs"}'],
[
"installed package metadata",
"packages/local/package.json",
'{"name":"fixture-package","type":"commonjs"}',
],
["inherited config", "base.json", '{"compilerOptions":{"target":"ES2022","types":[]}}'],
["generator input", "scripts/generator.mts", "export const generator = 2;\n"],
["compiler input", "tools/compiler.js", "export const compiler = 2;\n"],
])("retains invalidation after a %s change", async (_label, filename, bytes) => {
const f = fixture();
const before = f.snapshot();
await before.prepare();
const original = f.signature(before);
f.write(filename!, bytes!);
const after = f.snapshot();
await after.prepare();
expect(f.signature(after)).not.toBe(original);
expect(f.signature(after)).toBe(f.signature(f.snapshot()));
});
it("rejects metadata changed during asynchronous preparation", async () => {
const f = fixture();
const metadata = path.join(f.root, "package.json");
const read = fs.promises.readFile.bind(fs.promises);
const reader = vi.spyOn(fs.promises, "readFile").mockImplementation(async (...args) => {
const bytes = await read(...args);
if (args[0] === metadata) {
fs.appendFileSync(metadata, "\n");
}
return bytes;
});
try {
await expect(f.snapshot().prepare()).rejects.toThrow("Boundary input changed while reading");
} finally {
reader.mockRestore();
}
});