mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
* fix(runtime): support Bun across tooling suite * fix(security): guard Anthropic probe upstream requests * fix(runtime): preserve native TypeScript script loading
604 lines
28 KiB
TypeScript
604 lines
28 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { resolveRepoToolBinPath } from "../../scripts/lib/local-check-runtime.mts";
|
|
import {
|
|
TSDOWN_NON_SDK_DTS_CONFIG_GROUPS,
|
|
TSDOWN_PLUGIN_SDK_DTS_CONFIG_GROUPS,
|
|
} from "../../scripts/lib/tsdown-config-groups.mts";
|
|
import { prepareTsgoCommand } from "../../scripts/run-tsgo.mts";
|
|
import { materializeNativeCompiler } from "./native-boundary-fixture.js";
|
|
import { createScriptTestHarness } from "./test-helpers.js";
|
|
import {
|
|
createFixture,
|
|
declarationCacheRecords,
|
|
expectStagingClean,
|
|
runFixtureModule,
|
|
runUnifiedWriter,
|
|
runWriter,
|
|
treeHashes,
|
|
} from "./tsdown-declaration-fixture.js";
|
|
|
|
const { createTempDir } = createScriptTestHarness();
|
|
const coreText = (origin: string) =>
|
|
`export interface Marker { origin: "${origin}" }\ndeclare global { const declarationOrigin: "${origin}"; }\n`;
|
|
|
|
function nestedFixture(groups: readonly string[] = TSDOWN_PLUGIN_SDK_DTS_CONFIG_GROUPS) {
|
|
const ancestor = fs.realpathSync.native(createTempDir("openclaw-declaration-resolution-"));
|
|
const root = path.join(ancestor, ".claude/worktrees/validation");
|
|
const fixture = createFixture(groups, root);
|
|
const ancestorPackage = path.join(ancestor, "node_modules/@types/synthetic-core");
|
|
fs.mkdirSync(ancestorPackage, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ancestorPackage, "package.json"),
|
|
JSON.stringify({ name: "@types/synthetic-core", version: "1.0.0", types: "index.d.ts" }),
|
|
);
|
|
const ancestorInput = path.join(ancestorPackage, "index.d.ts");
|
|
fs.writeFileSync(ancestorInput, coreText("ancestor"));
|
|
const wrapper = "node_modules/.pnpm/wrapper/node_modules/@types/synthetic-wrapper";
|
|
const local = "node_modules/.pnpm/core/node_modules/@types/synthetic-core";
|
|
for (const [directory, name, text] of [
|
|
[
|
|
wrapper,
|
|
"synthetic-wrapper",
|
|
'/// <reference types="synthetic-core" />\nexport type { Marker } from "synthetic-core";\n',
|
|
],
|
|
[local, "synthetic-core", coreText("local")],
|
|
[
|
|
"node_modules/@types/synthetic-automatic",
|
|
"synthetic-automatic",
|
|
'declare function declarationDirectoryName(): "declared-cwd";\n',
|
|
],
|
|
] as const) {
|
|
fixture.write(
|
|
`${directory}/package.json`,
|
|
JSON.stringify({ name: `@types/${name}`, version: "2.0.0", types: "index.d.ts" }),
|
|
);
|
|
fixture.write(`${directory}/index.d.ts`, text);
|
|
}
|
|
const link = (target: string, alias: string) => {
|
|
fs.mkdirSync(path.dirname(alias), { recursive: true });
|
|
fs.symlinkSync(path.relative(path.dirname(alias), target), alias, "junction");
|
|
};
|
|
link(path.join(root, wrapper), path.join(root, "node_modules/@types/synthetic-wrapper"));
|
|
link(path.join(root, local), path.join(root, path.dirname(wrapper), "synthetic-core"));
|
|
const tsconfig = JSON.parse(fs.readFileSync(path.join(root, "tsconfig.json"), "utf8")) as {
|
|
compilerOptions: { types: string[] };
|
|
};
|
|
tsconfig.compilerOptions.types = ["synthetic-automatic"];
|
|
fixture.write("tsconfig.json", JSON.stringify(tsconfig));
|
|
fixture.write(
|
|
"src/shared.ts",
|
|
'import type { Marker } from "synthetic-wrapper";\nexport type { Marker };\nexport const inferredOrigin = declarationOrigin;\nexport function directoryName() { return declarationDirectoryName(); }\nexport class Shared { private brand = "canonical"; }\n',
|
|
);
|
|
const entry = Object.values(fixture.declarations).flat()[0]!;
|
|
fs.appendFileSync(
|
|
path.join(root, entry),
|
|
'\nexport { inferredOrigin } from "@openclaw/llm-core";\n',
|
|
);
|
|
return { ...fixture, ancestorInput, localInput: `${local}/index.d.ts` };
|
|
}
|
|
|
|
describe("tsdown checkout declaration resolution", () => {
|
|
it.runIf(process.platform === "win32")(
|
|
"starts the checkout compiler when its Windows executable uses an extended-length path",
|
|
() => {
|
|
const root = path.join(
|
|
fs.realpathSync.native(createTempDir("openclaw-native-long-path-")),
|
|
"nested-checkout-".repeat(5),
|
|
"nested-install-".repeat(5),
|
|
);
|
|
fs.mkdirSync(root, { recursive: true });
|
|
const native = materializeNativeCompiler(root);
|
|
expect(native.length).toBeGreaterThanOrEqual(248);
|
|
const require = createRequire(path.join(root, "package.json"));
|
|
const getExePath: { default: () => string } = require(
|
|
path.join(root, "node_modules/typescript-native/lib/getExePath.js"),
|
|
);
|
|
expect(getExePath.default()).toBe(path.toNamespacedPath(native));
|
|
const command = prepareTsgoCommand(["--version"], process.env, root);
|
|
expect(command?.bin).toBe(getExePath.default());
|
|
const result = spawnSync(resolveRepoToolBinPath("tsgo", { cwd: root }), ["--version"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
timeout: 10_000,
|
|
});
|
|
const manifest: { version: string } = JSON.parse(
|
|
fs.readFileSync(require.resolve("typescript-native/package.json"), "utf8"),
|
|
);
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout.trim()).toBe(`Version ${manifest.version}`);
|
|
},
|
|
);
|
|
|
|
for (const kind of [
|
|
"directory alias",
|
|
"Windows 8.3 alias",
|
|
"directory alias targeting Windows 8.3",
|
|
"directory alias targeting a case alias",
|
|
]) {
|
|
it.skipIf(kind.includes("Windows") && process.platform !== "win32")(
|
|
`compiles and receipts every local input through a ${kind}`,
|
|
(context) => {
|
|
const { root, localInput, ancestorInput } = nestedFixture();
|
|
// Even an ancestor package pointing back inside must remain undiscoverable.
|
|
fs.rmSync(path.dirname(ancestorInput), { recursive: true });
|
|
fs.symlinkSync(
|
|
path.join(root, path.dirname(localInput)),
|
|
path.dirname(ancestorInput),
|
|
"junction",
|
|
);
|
|
let target = root;
|
|
if (kind.includes("Windows")) {
|
|
const short = spawnSync(
|
|
"cmd.exe",
|
|
["/d", "/c", 'for %I in ("%DECLARATION_ALIAS_ROOT%") do @echo %~sI'],
|
|
{
|
|
encoding: "utf8",
|
|
// cmd.exe owns this command's quotes; libuv must not backslash-escape them.
|
|
windowsVerbatimArguments: true,
|
|
env: { ...process.env, DECLARATION_ALIAS_ROOT: root },
|
|
},
|
|
);
|
|
expect(short.status, short.stderr).toBe(0);
|
|
target = short.stdout.trim();
|
|
expect(fs.realpathSync.native(target)).toBe(root);
|
|
if (fs.realpathSync(target).toLowerCase() === root.toLowerCase()) {
|
|
context.skip("Filesystem does not expose a distinct Windows 8.3 checkout alias");
|
|
}
|
|
} else if (kind.endsWith("case alias")) {
|
|
target = path.join(path.dirname(root), path.basename(root).toUpperCase());
|
|
if (!fs.existsSync(target)) {
|
|
context.skip("Filesystem does not expose a case-insensitive checkout alias");
|
|
}
|
|
expect(fs.realpathSync.native(target)).toBe(root);
|
|
}
|
|
let alias = target;
|
|
if (kind.startsWith("directory alias")) {
|
|
alias = `${root}-alias`;
|
|
fs.symlinkSync(target, alias, "junction");
|
|
}
|
|
if (kind.startsWith("directory alias targeting")) {
|
|
// Keep all three inputs distinct even when the runtime canonicalizes the
|
|
// case-only target before realpath returns it.
|
|
expect(alias).not.toBe(target);
|
|
expect(target).not.toBe(root);
|
|
}
|
|
const result = runFixtureModule(
|
|
root,
|
|
`
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { build } from "tsdown";
|
|
import ts from "typescript";
|
|
import { createDeclarationBoundaryHooks, resolveDeclarationInputCaptureModule } from "./scripts/lib/tsdown-declaration-boundary.mts";
|
|
import { createDeclarationStage, createDeclarationInputCapture, requestDeclarationInputs, readDeclarationInputs } from "./scripts/lib/tsdown-declaration-inputs.mts";
|
|
const cwd = ${JSON.stringify(alias)};
|
|
const canonical = fs.realpathSync.native(cwd);
|
|
const stage = createDeclarationStage(cwd);
|
|
const outDir = path.join(cwd, path.relative(canonical, fs.realpathSync.native(stage)), "dist");
|
|
requestDeclarationInputs(outDir, "alias", [path.join(cwd, "src/shared.ts")]);
|
|
const { bundles } = await build({
|
|
config: false, cwd, entry: path.join(cwd, "src/shared.ts"), outDir,
|
|
dts: true, clean: false, logLevel: "silent",
|
|
hooks: createDeclarationBoundaryHooks({ "build:done": createDeclarationInputCapture("alias") }),
|
|
plugins: [{ name: "fixture-checkout-alias", buildStart: { order: "post", handler() {
|
|
for (const spelling of [cwd, fs.realpathSync(cwd), canonical]) {
|
|
assert.match(ts.sys.readFile(path.join(spelling, "src/shared.ts")), /inferredOrigin/);
|
|
assert.equal(ts.sys.fileExists(path.join(spelling, "src/shared.ts")), true);
|
|
assert.equal(ts.sys.directoryExists(path.join(spelling, "src")), true);
|
|
}
|
|
assert.equal(ts.sys.fileExists(${JSON.stringify(ancestorInput)}), false);
|
|
assert.equal(ts.sys.directoryExists(${JSON.stringify(path.dirname(ancestorInput))}), false);
|
|
assert.deepEqual(ts.sys.getDirectories(${JSON.stringify(path.dirname(ancestorInput))}), []);
|
|
} } }],
|
|
});
|
|
try {
|
|
const declaration = fs.readFileSync(path.join(outDir, "shared.d.mts"), "utf8");
|
|
assert.match(declaration, /inferredOrigin: "local"/);
|
|
assert.match(declaration, /directoryName\\(\\): "declared-cwd"/);
|
|
const inputs = readDeclarationInputs(outDir, "alias");
|
|
assert.ok(inputs.includes(${JSON.stringify(localInput)}));
|
|
const { globalContext } = await import(pathToFileURL(resolveDeclarationInputCaptureModule()).href);
|
|
const consumed = [...new Set(globalContext.programs.flatMap(program => program.getSourceFiles()
|
|
.map(source => path.relative(canonical, fs.realpathSync.native(source.fileName)).split(path.sep).join("/"))))].sort();
|
|
assert.deepEqual(inputs, consumed, "receipt must retain every actual Program input");
|
|
} finally {
|
|
for (const bundle of bundles) await bundle[Symbol.asyncDispose]();
|
|
fs.rmSync(stage, { recursive: true, force: true });
|
|
}
|
|
`,
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
if (!kind.includes("Windows")) {
|
|
return;
|
|
}
|
|
const dist = path.join(root, "dist");
|
|
fs.mkdirSync(dist, { recursive: true });
|
|
// An installed alias exposes live output to topology scanning. Its physical
|
|
// owner must still be excluded when the writer starts through a short cwd.
|
|
fs.symlinkSync(dist, path.join(root, "node_modules/fixture-published"), "junction");
|
|
const cold = runWriter(alias);
|
|
expect(cold.status, cold.stdout + cold.stderr).toBe(0);
|
|
expect(declarationCacheRecords(root).flatMap((record) => record.inputs ?? [])).toContain(
|
|
localInput,
|
|
);
|
|
const published = treeHashes(dist);
|
|
const cache = path.join(root, ".artifacts/build-all-cache");
|
|
const cached = treeHashes(cache);
|
|
const warm = runWriter(alias);
|
|
expect(warm.status, warm.stdout + warm.stderr).toBe(0);
|
|
expect(warm.stdout + warm.stderr).not.toContain("[tsdown-build] invocation");
|
|
expect(treeHashes(dist)).toEqual(published);
|
|
expect(treeHashes(cache)).toEqual(cached);
|
|
expectStagingClean(root);
|
|
},
|
|
);
|
|
}
|
|
|
|
it("preserves object and registration hooks while enforcing the declaration boundary", () => {
|
|
const { root } = nestedFixture();
|
|
const result = runFixtureModule(
|
|
root,
|
|
`
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import { build } from "tsdown";
|
|
import { createDeclarationBoundaryHooks } from "./scripts/lib/tsdown-declaration-boundary.mts";
|
|
for (const registration of [false, true]) {
|
|
const calls = [];
|
|
const existing = {
|
|
"build:prepare": async () => { await Promise.resolve(); calls.push("prepare"); },
|
|
"build:done": () => { calls.push("done"); },
|
|
};
|
|
const hooks = createDeclarationBoundaryHooks(registration
|
|
? async hooks => { hooks.addHooks(existing); }
|
|
: existing);
|
|
const outDir = ".artifacts/hook-composition-" + registration;
|
|
const { bundles } = await build({ config: false, entry: "src/shared.ts", dts: { newContext: true }, outDir, clean: false, logLevel: "silent", hooks });
|
|
try {
|
|
assert.deepEqual(calls, ["prepare", "done"]);
|
|
assert.match(fs.readFileSync(outDir + "/shared.d.mts", "utf8"), /inferredOrigin: "local"/);
|
|
} finally {
|
|
for (const bundle of bundles) await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
`,
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
});
|
|
|
|
it.each(
|
|
["node", "workspace", "AI"].flatMap((owner) => [true, false].map((dts) => ({ owner, dts }))),
|
|
)("honors resolved dts=$dts over the opposite $owner default", ({ owner, dts }) => {
|
|
const { root, write } = nestedFixture();
|
|
write("tsdown.ai.config.ts", fs.readFileSync("tsdown.ai.config.ts", "utf8"));
|
|
const outside = path.join(path.dirname(root), "runtime.ts");
|
|
fs.writeFileSync(outside, 'export const runtimeValue = "outside-runtime";');
|
|
const result = runFixtureModule(
|
|
root,
|
|
`
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { build } from "tsdown";
|
|
import { resolveDeclarationInputCaptureModule } from "./scripts/lib/tsdown-declaration-boundary.mts";
|
|
const ts = createRequire(resolveDeclarationInputCaptureModule())("typescript");
|
|
const root = process.cwd();
|
|
const canonicalRoot = fs.realpathSync.native(root);
|
|
const original = { ...ts.sys };
|
|
process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD = ${JSON.stringify(dts ? "1" : "0")};
|
|
const { default: configs } = await import("./tsdown.config.ts");
|
|
const { default: ai } = await import("./tsdown.ai.config.ts");
|
|
const owner = ${JSON.stringify(owner)};
|
|
const config = owner === "AI" ? ai : configs.find(config => config.outDir ===
|
|
(owner === "node" ? "packages/agent-core/dist" : "packages/gateway-protocol/dist"));
|
|
assert.ok(config);
|
|
assert.equal(config.dts, ${!dts});
|
|
for (const key of Object.keys(original)) assert.equal(ts.sys[key], original[key], "config import mutated " + key);
|
|
// The build API's cwd, rather than the importing process's cwd, owns inputs.
|
|
process.chdir(path.dirname(root));
|
|
let finishedRuntime;
|
|
const runtimeDone = new Promise(resolve => { finishedRuntime = resolve; });
|
|
const { bundles } = await build({
|
|
...config, config: false, cwd: root, clean: false, logLevel: "silent",
|
|
dts: ${dts ? '{ enabled: true, entry: ["src/shared.ts"], newContext: true }' : owner === "AI" ? "{ enabled: false }" : "false"}, format: ["esm", "cjs"], concurrency: 1,
|
|
entry: ${JSON.stringify(dts ? "src/shared.ts" : outside)},
|
|
outDir: "override-output", outExtensions: undefined, fixedExtension: true,
|
|
inputOptions: async (input, format, context) => {
|
|
const resolved = await config.inputOptions?.(input, format, context) ?? input;
|
|
return { ...resolved, plugins: [resolved.plugins, {
|
|
name: "fixture-independent-cjs",
|
|
buildStart: { order: context.cjsDts ? "post" : "pre", async handler() {
|
|
if (${dts} && !context.cjsDts) {
|
|
assert.equal(ts.sys.getCurrentDirectory(), canonicalRoot, "callback plugin started before boundary acquisition");
|
|
}
|
|
// CJS declarations must remain bounded after the runtime sibling finishes.
|
|
if (context.cjsDts) await runtimeDone;
|
|
} },
|
|
buildEnd: { order: "post", handler() {
|
|
if (format === "cjs" && !context.cjsDts) finishedRuntime();
|
|
} },
|
|
}] };
|
|
},
|
|
});
|
|
try {
|
|
const files = fs.readdirSync(path.join(root, "override-output"));
|
|
if (${dts}) {
|
|
for (const extension of ["mts", "cts"]) {
|
|
const declaration = fs.readFileSync(path.join(root, "override-output/shared.d." + extension), "utf8");
|
|
assert.match(declaration, /inferredOrigin: "local"/, extension + " used ancestor declarations");
|
|
assert.match(declaration, /directoryName\\(\\): "declared-cwd"/, extension + " lost automatic types from declared cwd");
|
|
}
|
|
} else {
|
|
assert.equal(files.some(file => /\\.d\\.[cm]?ts$/.test(file)), false);
|
|
for (const extension of ["mjs", "cjs"]) {
|
|
assert.match(fs.readFileSync(path.join(root, "override-output/runtime." + extension), "utf8"), /outside-runtime/);
|
|
}
|
|
}
|
|
if (${dts && owner === "AI"}) {
|
|
let starts = 0;
|
|
const { bundles: objectOptions } = await build({
|
|
...config, config: false, cwd: root, clean: false, logLevel: "silent",
|
|
dts: { cwd: path.join(root, "src"), entry: ["shared.ts"] }, format: "cjs", entry: "src/shared.ts", outDir: "object-options-output",
|
|
inputOptions: { plugins: [{ name: "fixture-object-options", buildStart() { starts++; } }] },
|
|
});
|
|
try {
|
|
assert.equal(starts, 2, "object inputOptions plugin must run for runtime and declarations");
|
|
assert.match(fs.readFileSync(path.join(root, "object-options-output/shared.d.cts"), "utf8"), /inferredOrigin: "local"/);
|
|
assert.match(fs.readFileSync(path.join(root, "object-options-output/shared.d.cts"), "utf8"), /directoryName\\(\\): "declared-cwd"/);
|
|
} finally {
|
|
for (const bundle of objectOptions) await bundle[Symbol.asyncDispose]();
|
|
}
|
|
}
|
|
for (const key of Object.keys(original)) assert.equal(ts.sys[key], original[key], "build retained " + key);
|
|
} finally {
|
|
for (const bundle of bundles) await bundle[Symbol.asyncDispose]();
|
|
}
|
|
console.log("resolved declaration override honored");
|
|
`,
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("resolved declaration override honored");
|
|
});
|
|
|
|
it("restores the compiler system after overlapping workspace and AI builds, including failures", () => {
|
|
const { root, write, ancestorInput } = nestedFixture();
|
|
write("tsdown.ai.config.ts", fs.readFileSync("tsdown.ai.config.ts", "utf8"));
|
|
write(
|
|
"src/second.ts",
|
|
'import "synthetic-wrapper"; export const secondOrigin = declarationOrigin;',
|
|
);
|
|
const result = runFixtureModule(
|
|
root,
|
|
`
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { build } from "tsdown";
|
|
import ts from "typescript";
|
|
const root = process.cwd();
|
|
const canonicalRoot = fs.realpathSync.native(root);
|
|
const methods = ["readFile", "fileExists", "directoryExists", "getDirectories", "readDirectory", "realpath"];
|
|
const delegated = new Set();
|
|
for (const name of methods) {
|
|
const saved = ts.sys[name];
|
|
ts.sys[name] = function (...args) {
|
|
assert.equal(this, ts.sys, name + " lost the original System receiver");
|
|
delegated.add(name);
|
|
return saved.apply(this, args);
|
|
};
|
|
}
|
|
const original = { ...ts.sys };
|
|
const { default: configs } = await import("./tsdown.config.ts");
|
|
const { default: ai } = await import("./tsdown.ai.config.ts");
|
|
for (const key of Object.keys(original)) assert.equal(ts.sys[key], original[key], "config import mutated " + key);
|
|
const workspace = configs.find(config => config.outDir === "packages/gateway-protocol/dist");
|
|
assert.ok(workspace);
|
|
process.chdir(path.dirname(root));
|
|
for (const failure of [false, true]) {
|
|
delegated.clear();
|
|
const unrelatedWrite = (...args) => original.write.apply(ts.sys, args);
|
|
let started = 0;
|
|
let finishedFirst;
|
|
const firstDone = new Promise(resolve => { finishedFirst = resolve; });
|
|
let bothStarted;
|
|
const barrier = new Promise(resolve => { bothStarted = resolve; });
|
|
const launch = (config, index) => build({
|
|
...config, config: false, cwd: root, clean: false, logLevel: "silent",
|
|
entry: index ? "src/second.ts" : "src/shared.ts",
|
|
outDir: ".artifacts/lifecycle-" + index,
|
|
plugins: [config.plugins, {
|
|
name: "fixture-lifecycle",
|
|
buildStart: { order: "post", async handler() {
|
|
assert.notEqual(ts.sys.readFile, original.readFile);
|
|
ts.sys.write = unrelatedWrite;
|
|
assert.equal(ts.sys.getCurrentDirectory(), canonicalRoot);
|
|
assert.equal(process.cwd(), path.dirname(root), "boundary changed process cwd");
|
|
assert.match(ts.sys.readFile("src/shared.ts"), /inferredOrigin/);
|
|
assert.equal(ts.sys.fileExists("tsconfig.json"), true);
|
|
assert.equal(ts.sys.directoryExists("src"), true);
|
|
assert.ok(ts.sys.getDirectories(".").includes("src"));
|
|
assert.ok(ts.sys.readDirectory(".", [".ts"], ["node_modules"], ["src/*"]).map(file => fs.realpathSync.native(file)).includes(path.join(canonicalRoot, "src/shared.ts")));
|
|
assert.equal(ts.sys.realpath("."), canonicalRoot);
|
|
assert.equal(ts.sys.fileExists(${JSON.stringify(ancestorInput)}), false);
|
|
assert.equal(ts.sys.directoryExists(process.cwd()), false);
|
|
assert.deepEqual(ts.sys.getDirectories(process.cwd()), []);
|
|
assert.deepEqual(ts.sys.readDirectory(process.cwd()), []);
|
|
if (++started === 2) bothStarted();
|
|
await barrier;
|
|
if (index) {
|
|
await firstDone;
|
|
assert.notEqual(ts.sys.readFile, original.readFile, "first sibling restored a live compiler");
|
|
assert.equal(ts.sys.getCurrentDirectory(), canonicalRoot, "first sibling restored a live compiler cwd");
|
|
if (failure) throw new Error("fixture buildStart failure");
|
|
}
|
|
} },
|
|
}],
|
|
});
|
|
const results = await Promise.allSettled([
|
|
launch(workspace, 0).finally(() => finishedFirst()),
|
|
launch(ai, 1),
|
|
]);
|
|
assert.equal(results[0].status, "fulfilled", results[0].reason?.stack);
|
|
assert.equal(results[1].status, failure ? "rejected" : "fulfilled", results[1].reason?.stack);
|
|
for (const name of methods) assert.ok(delegated.has(name), name + " was not delegated");
|
|
assert.equal(ts.sys.write, unrelatedWrite, "restoration overwrote another hook's method");
|
|
ts.sys.write = original.write;
|
|
for (const key of Object.keys(original)) assert.equal(ts.sys[key], original[key], "build retained " + key);
|
|
assert.equal(ts.sys.getCurrentDirectory(), process.cwd());
|
|
if (!failure) {
|
|
assert.match(fs.readFileSync(path.join(root, ".artifacts/lifecycle-1/second.d.mts"), "utf8"), /secondOrigin: "local"/);
|
|
}
|
|
}
|
|
console.log("workspace/AI lifecycle restored after success and failure");
|
|
`,
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("workspace/AI lifecycle restored after success and failure");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "SDK",
|
|
groups: TSDOWN_PLUGIN_SDK_DTS_CONFIG_GROUPS,
|
|
run: (root: string, env = {}) => runWriter(root, false, env),
|
|
},
|
|
{ name: "unified", groups: TSDOWN_NON_SDK_DTS_CONFIG_GROUPS, run: runUnifiedWriter },
|
|
])("uses local explicit references and seals real inputs for $name", ({ groups, run }) => {
|
|
const { root, write, ancestorInput, localInput } = nestedFixture(groups);
|
|
write(
|
|
"tsdown.config.ts",
|
|
`${fs.readFileSync(path.join(root, "tsdown.config.ts"), "utf8")}
|
|
for (const config of configs) {
|
|
if (!config.dts?.emitDtsOnly) continue;
|
|
const register = config.hooks;
|
|
config.hooks = async hooks => {
|
|
await register(hooks);
|
|
hooks.hook("build:done", () => {
|
|
const marker = ".artifacts/replace-input";
|
|
if (!fs.existsSync(marker)) return;
|
|
const file = fs.readFileSync(marker, "utf8") === "ancestor" ? ${JSON.stringify(ancestorInput)} : ${JSON.stringify(localInput)};
|
|
fs.writeFileSync(file + ".replacement", fs.readFileSync(file));
|
|
fs.renameSync(file + ".replacement", file);
|
|
});
|
|
};
|
|
}
|
|
`,
|
|
);
|
|
const initial = run(root);
|
|
expect(initial.status, initial.stdout + initial.stderr).toBe(0);
|
|
const published = treeHashes(path.join(root, "dist"));
|
|
const declarations = Object.keys(published)
|
|
.filter((file) => file.endsWith(".d.ts"))
|
|
.map((file) => fs.readFileSync(path.join(root, "dist", file), "utf8"))
|
|
.join("\n");
|
|
expect(declarations.match(/declare const inferredOrigin: [^;]+;/u)?.[0]).toBe(
|
|
'declare const inferredOrigin: "local";',
|
|
);
|
|
const inputs = declarationCacheRecords(root).flatMap((record) => record.inputs ?? []);
|
|
expect(inputs).toContain(localInput);
|
|
for (const input of inputs) {
|
|
const relative = path.relative(root, fs.realpathSync(path.resolve(root, input)));
|
|
expect(
|
|
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative),
|
|
input,
|
|
).toBe(false);
|
|
}
|
|
expectStagingClean(root);
|
|
// Both writers seal through the same owner. Replay its mutation cycle once;
|
|
// the unified suite separately covers failed and mixed-cache publication.
|
|
if (groups === TSDOWN_NON_SDK_DTS_CONFIG_GROUPS) {
|
|
return;
|
|
}
|
|
const cached = treeHashes(path.join(root, ".artifacts/build-all-cache"));
|
|
write(".artifacts/replace-input", "ancestor");
|
|
const ancestorChanged = run(root, { OPENCLAW_BUILD_CACHE: "0" });
|
|
expect(ancestorChanged.status, ancestorChanged.stdout + ancestorChanged.stderr).toBe(0);
|
|
expect(treeHashes(path.join(root, "dist"))).toEqual(published);
|
|
const restored = run(root);
|
|
expect(restored.status, restored.stdout + restored.stderr).toBe(0);
|
|
expect(restored.stdout + restored.stderr).not.toContain("[tsdown-build] invocation");
|
|
write(".artifacts/replace-input", "local");
|
|
const localChanged = run(root, { OPENCLAW_BUILD_CACHE: "0" });
|
|
expect(localChanged.status, localChanged.stdout + localChanged.stderr).toBeGreaterThan(0);
|
|
expect(localChanged.stdout + localChanged.stderr).toContain("changed during compilation");
|
|
expect(treeHashes(path.join(root, "dist"))).toEqual(published);
|
|
expect(treeHashes(path.join(root, ".artifacts/build-all-cache"))).toEqual(cached);
|
|
expectStagingClean(root);
|
|
});
|
|
|
|
it.each([
|
|
"ancestor module",
|
|
"package symlink",
|
|
"source symlink",
|
|
"source reference",
|
|
"ancestor symlink reference",
|
|
])("refuses an escaped %s before publication", (kind) => {
|
|
const { root, write, ancestorInput } = nestedFixture();
|
|
const outside = `${root}-outside`;
|
|
fs.mkdirSync(outside);
|
|
fs.writeFileSync(path.join(outside, "index.d.ts"), "export interface Marker { escaped: true }");
|
|
if (kind === "ancestor module") {
|
|
write("src/shared.ts", 'export type { Marker as Shared } from "synthetic-core";');
|
|
} else if (kind === "package symlink") {
|
|
fs.writeFileSync(
|
|
path.join(outside, "package.json"),
|
|
'{"name":"escaped","types":"index.d.ts"}',
|
|
);
|
|
fs.symlinkSync(outside, path.join(root, "node_modules/escaped"), "junction");
|
|
write("src/shared.ts", 'export type { Marker as Shared } from "escaped";');
|
|
} else if (kind === "source symlink") {
|
|
fs.symlinkSync(path.join(outside, "index.d.ts"), path.join(root, "src/escaped.d.ts"));
|
|
write("src/shared.ts", 'export type { Marker as Shared } from "./escaped.js";');
|
|
} else {
|
|
if (kind === "ancestor symlink reference") {
|
|
fs.rmSync(path.join(outside, "index.d.ts"));
|
|
fs.symlinkSync(path.join(root, "src/contract.d.ts"), path.join(outside, "index.d.ts"));
|
|
}
|
|
write(
|
|
"src/shared.ts",
|
|
`/// <reference path="${path.join(outside, "index.d.ts").replaceAll(path.sep, "/")}" />\nexport class Shared {}\n`,
|
|
);
|
|
write(
|
|
"tsdown.config.ts",
|
|
`${fs.readFileSync(path.join(root, "tsdown.config.ts"), "utf8")}
|
|
for (const config of configs) {
|
|
if (!config.dts?.emitDtsOnly) continue;
|
|
config.plugins = [config.plugins, {
|
|
name: "fixture-read-error",
|
|
buildEnd(error) {
|
|
if (!error) console.log("fixture: compiler reached buildEnd without a bundler error");
|
|
},
|
|
}];
|
|
}
|
|
`,
|
|
);
|
|
}
|
|
fs.appendFileSync(
|
|
path.join(root, "src/shared.ts"),
|
|
'\nexport const inferredOrigin = "unused";\n',
|
|
);
|
|
write("dist/plugin-sdk/core.d.ts", "previous declaration");
|
|
const before = treeHashes(path.join(root, "dist"));
|
|
const failed = runWriter(root);
|
|
expect(failed.status, failed.stdout + failed.stderr).toBeGreaterThan(0);
|
|
expect(failed.stdout + failed.stderr).toContain("Declaration input escapes checkout");
|
|
if (kind.endsWith("reference")) {
|
|
expect(failed.stdout + failed.stderr).toContain(
|
|
"fixture: compiler reached buildEnd without a bundler error",
|
|
);
|
|
}
|
|
expect(failed.stdout + failed.stderr).toContain(
|
|
kind === "ancestor module" ? ancestorInput : outside,
|
|
);
|
|
expect(treeHashes(path.join(root, "dist"))).toEqual(before);
|
|
expectStagingClean(root);
|
|
});
|
|
});
|