mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-28 06:53:42 +08:00
245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
/* npm packages compiled natively: every program under tests/fixtures/npm/cases
|
|
* (plus the commander fixtures in npm-cases.ts) builds with the default
|
|
* static npm compile and runs against Node. A case matches Node (stdout and
|
|
* exit status for every argv list, stderr when it opts in with
|
|
* `// @stderr`) unless KNOWN records how it differs:
|
|
*
|
|
* - build: the compile refuses it with this diagnostic code;
|
|
* - run: it compiles, and the program's output names this diagnostic code
|
|
* (a refusal deferred to the operation that reached it), for every argv
|
|
* list or for the listed `argvs`, while the other lists match Node;
|
|
* - divergence: it runs but does not match Node yet. These run as expected
|
|
* failures, so a fix that makes one match fails here until KNOWN drops it.
|
|
*
|
|
* The fixture node_modules are committed test data. Binaries compile the
|
|
* package sources at build time and read nothing from node_modules at run
|
|
* time.
|
|
*/
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { globSync, mkdirSync, readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { describe, expect, test } from "vitest";
|
|
import { compile } from "@scriptc/compiler";
|
|
import { npmCases, type NpmCase } from "./npm-cases.js";
|
|
import { shardSelect, shardSuffix } from "./shard.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const repoRoot = join(import.meta.dirname, "../..");
|
|
const fixturesRoot = join(repoRoot, "tests/fixtures");
|
|
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
|
|
const sanitize = process.env["SCRIPTC_SAN"] === "1";
|
|
|
|
type Known = { build: string } | { run: string; argvs?: string[][] } | { divergence: string };
|
|
|
|
const KNOWN: Record<string, Known> = {
|
|
"any-chaining": { build: "SC2003" },
|
|
"assert-shims": { run: "SC1043" },
|
|
"buffer-shims": { run: "SC2020" },
|
|
"bytes-exit": { build: "SC2004" },
|
|
"cjs-esm-named": { build: "SC2013" },
|
|
"cjs-lexer-aligned": { build: "SC2013" },
|
|
"commander-calc": { run: "SC1090", argvs: [["--help"]] },
|
|
"commander-calc-async": { run: "SC1090", argvs: [["--help"]] },
|
|
"create-require": { run: "SC1090" },
|
|
"dotfolder": { run: "SC1090" },
|
|
"dual-package-kinds": { build: "SC2013" },
|
|
"dynamic-import": { build: "SC2012" },
|
|
"dynamic-import-local": { build: "SC2012" },
|
|
"esbuild-require": { build: "SC2013" },
|
|
"esm-missing-default": { build: "SC1090" },
|
|
"esm-missing-export": { build: "SC1090" },
|
|
"fetch-destructure": { run: "SC1031" },
|
|
"fs-shims": { run: "SC1101" },
|
|
"import-refusal": { build: "SC2020" },
|
|
"island-registry": { build: "SC2011" },
|
|
"island-timers": {
|
|
divergence:
|
|
"Timeout handles are numbers (typeof) and lose their ref flag once fired or cleared",
|
|
},
|
|
"island-web-plumbing": { run: "SC2020" },
|
|
"lazy-traps": { build: "SC2013" },
|
|
"misc-shims": { run: "SC2020" },
|
|
"namespace": { build: "SC1013" },
|
|
"node24-async-events": { run: "SC2020" },
|
|
"node24-commonjs-metadata": { run: "SC2020" },
|
|
"node24-crypto-kdf": { run: "SC2020" },
|
|
"node24-deep-equality": { run: "SC2020" },
|
|
"node24-diagnostics-async": { run: "SC1090" },
|
|
"node24-diagnostics-tracing": { run: "SC1090" },
|
|
"node24-env": { run: "SC1090" },
|
|
"node24-esm-metadata": { run: "SC1090" },
|
|
"node24-event-emitter": { run: "SC2020" },
|
|
"node24-file-url-conversion": { build: "SC1090" },
|
|
"node24-fs-descriptors": { run: "SC1090" },
|
|
"node24-mime": { run: "SC2020" },
|
|
"node24-mime-errors": { run: "SC1090" },
|
|
"node24-no-error-assertions": { run: "SC2020" },
|
|
"node24-parseargs": { run: "SC1090" },
|
|
"node24-promise-timers": { run: "SC2020" },
|
|
"node24-string-decoder": { run: "SC2020" },
|
|
"node24-style-text": { run: "SC2020" },
|
|
"node24-system-errors": { run: "SC1090" },
|
|
"node24-text-encoding": { run: "SC2020" },
|
|
"node24-text-utilities": { run: "SC2020" },
|
|
"node24-timers-unref": { run: "SC2020" },
|
|
"node24-url-factories": { run: "SC2020" },
|
|
"node24-url-mutation": { run: "SC1090" },
|
|
"node24-util-adapters": { run: "Object.create with this value is not supported yet" },
|
|
"node24-util-types": { run: "SC1090" },
|
|
"node24-web-stream-encoding": { run: "SC1090" },
|
|
"node24-web-stream-errors": { run: "SC1090" },
|
|
"node24-web-stream-lifecycle": { run: "SC1090" },
|
|
"node24-web-stream-pipe-abort": { run: "SC2020" },
|
|
"node24-web-stream-pipe-errors": { run: "SC1090" },
|
|
"node24-web-stream-pipes": { run: "SC1090" },
|
|
"node24-web-stream-queues": { run: "SC1090" },
|
|
"node24-web-stream-readable-lifecycle": { run: "SC2020" },
|
|
"node24-web-stream-readers": { run: "SC1090" },
|
|
"node24-web-stream-tee": { run: "SC2020" },
|
|
"node24-web-stream-transforms": { run: "SC1090" },
|
|
"package-imports": { build: "SC2013" },
|
|
"promise-bridge": { build: "SC1090" },
|
|
"self-name": { build: "SC2013" },
|
|
"stream-shims": { run: "SC1090" },
|
|
"util-shims": { run: "SC2020" },
|
|
"web-streams": { run: "SC1090" },
|
|
"workspace-copied": { build: "SC2013" },
|
|
"workspace-unclean": { build: "SC2013" },
|
|
};
|
|
|
|
const cases = shardSelect(npmCases(fixturesRoot), (c) => c.name);
|
|
|
|
/** Per-case timeouts past the default 120 s: the undici cases compile the
|
|
* built-in undici implementation's module graph, which a sanitized build
|
|
* on a CI runner takes minutes to compile. */
|
|
const TIMEOUTS: Record<string, number> = { undici5: 600_000, undici7: 600_000 };
|
|
|
|
interface RunResult {
|
|
stdout: Buffer;
|
|
stderr: Buffer;
|
|
exitCode: number;
|
|
}
|
|
|
|
function programStderr(stderr: Buffer): Buffer {
|
|
if (!sanitize) return stderr;
|
|
// Linux ASan emits this instrumentation warning at the first fiber swap.
|
|
// Latin-1 preserves every other byte, including non-UTF-8 program output.
|
|
return Buffer.from(
|
|
stderr
|
|
.toString("latin1")
|
|
.replace(
|
|
/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext functions and may produce false positives in some cases!\n/gm,
|
|
"",
|
|
),
|
|
"latin1",
|
|
);
|
|
}
|
|
|
|
async function runBinary(cmd: string, args: string[], cwd: string): Promise<RunResult> {
|
|
try {
|
|
// stdin closes immediately, as in differential.test.ts: a program that
|
|
// reads fd 0 to EOF must not hang on an open pipe.
|
|
const pending = execFileAsync(cmd, args, { cwd, encoding: "buffer" });
|
|
pending.child.stdin?.end();
|
|
const { stdout, stderr } = await pending;
|
|
return { stdout, stderr, exitCode: 0 };
|
|
} catch (err) {
|
|
const e = err as { code?: unknown; signal?: unknown; stdout?: Buffer; stderr?: Buffer };
|
|
if (!Buffer.isBuffer(e.stdout) || !Buffer.isBuffer(e.stderr)) throw err;
|
|
const exitCode = typeof e.code === "number" ? e.code : -1;
|
|
if (exitCode === -1 && typeof e.signal !== "string") throw err;
|
|
return { stdout: e.stdout, stderr: e.stderr, exitCode };
|
|
}
|
|
}
|
|
|
|
type Built = { ok: true; binary: string } | { ok: false; codes: string[]; text: string };
|
|
|
|
/** Compile once per (entry, lane); the cache key hashes the program and the
|
|
* fixture packages so edits to either rebuild. */
|
|
async function build(entry: string): Promise<Built> {
|
|
const hash = createHash("sha256");
|
|
const inputs = [
|
|
entry,
|
|
...globSync(join(dirname(entry), "*.{ts,mts,cts,js,mjs,cjs,json}")),
|
|
...globSync(join(fixturesRoot, "npm/node_modules/**/*.{js,mjs,cjs,json,d.ts,node}")),
|
|
].sort();
|
|
for (const f of inputs) hash.update(f).update(readFileSync(f));
|
|
const key = hash
|
|
.update(sanitize ? "san" : "plain")
|
|
.digest("hex")
|
|
.slice(0, 16);
|
|
const outDir = join(cacheDir, `npm-${key}`);
|
|
mkdirSync(outDir, { recursive: true });
|
|
const result = await compile(entry, { outPath: join(outDir, "program"), outDir, sanitize });
|
|
if (result.ok) return { ok: true, binary: result.binaryPath };
|
|
return {
|
|
ok: false,
|
|
codes: result.diagnostics.map((d) => d.code),
|
|
text: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
|
|
};
|
|
}
|
|
|
|
async function expectMatchesNode(c: NpmCase, binary: string): Promise<void> {
|
|
const cwd = dirname(c.entry);
|
|
for (const argv of c.argvs ?? [[]]) {
|
|
const [nodeRes, nativeRes] = await Promise.all([
|
|
runBinary(process.execPath, [c.entry, ...argv], cwd),
|
|
runBinary(binary, argv, cwd),
|
|
]);
|
|
const label = argv.join(" ");
|
|
if (!nodeRes.stdout.equals(nativeRes.stdout)) {
|
|
expect(nativeRes.stdout.toString("utf8"), label).toBe(nodeRes.stdout.toString("utf8"));
|
|
expect.unreachable("stdout differed at byte level but not after utf8 decode");
|
|
}
|
|
expect(nativeRes.exitCode, label).toBe(nodeRes.exitCode);
|
|
if (c.compareStderr) expect(programStderr(nativeRes.stderr), label).toEqual(nodeRes.stderr);
|
|
}
|
|
}
|
|
|
|
test("every KNOWN entry names a case", () => {
|
|
const names = new Set(npmCases(fixturesRoot).map((c) => c.name));
|
|
expect(Object.keys(KNOWN).filter((name) => !names.has(name))).toEqual([]);
|
|
});
|
|
|
|
describe(`npm native (${cases.length} programs${sanitize ? ", sanitized" : ""}${shardSuffix()})`, () => {
|
|
for (const c of cases) {
|
|
const known = KNOWN[c.name];
|
|
if (known !== undefined && "divergence" in known) {
|
|
test.fails(`${c.name} (diverges: ${known.divergence})`, async () => {
|
|
const built = await build(c.entry);
|
|
if (!built.ok) throw new Error(built.text);
|
|
await expectMatchesNode(c, built.binary);
|
|
}, 120_000);
|
|
continue;
|
|
}
|
|
test(
|
|
c.name,
|
|
async () => {
|
|
const built = await build(c.entry);
|
|
if (known !== undefined && "build" in known) {
|
|
expect(built.ok, "the compile refuses the program").toBe(false);
|
|
if (!built.ok) expect(built.codes, built.text).toContain(known.build);
|
|
return;
|
|
}
|
|
if (!built.ok) throw new Error(`npm fixture failed to compile:\n${built.text}`);
|
|
if (known !== undefined && "run" in known) {
|
|
for (const argv of known.argvs ?? c.argvs ?? [[]]) {
|
|
const res = await runBinary(built.binary, argv, dirname(c.entry));
|
|
const output = res.stdout.toString("utf8") + res.stderr.toString("utf8");
|
|
expect(output, `${argv.join(" ")}: the deferred refusal`).toContain(known.run);
|
|
}
|
|
if (known.argvs === undefined) return;
|
|
const refused = new Set(known.argvs.map((argv) => JSON.stringify(argv)));
|
|
const argvs = (c.argvs ?? [[]]).filter((argv) => !refused.has(JSON.stringify(argv)));
|
|
await expectMatchesNode({ ...c, argvs }, built.binary);
|
|
return;
|
|
}
|
|
await expectMatchesNode(c, built.binary);
|
|
},
|
|
TIMEOUTS[c.name] ?? 120_000,
|
|
);
|
|
}
|
|
});
|