mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-19 05:29:46 +08:00
- Shard portable coverage across 16 sandboxes while retaining focused Darwin contracts. - Run invariant suites once and keep differential and sanitizer-sensitive coverage in both lanes. - Harden sandbox transport, exit-status parsing, and Linux runtime test portability.
178 lines
8.4 KiB
TypeScript
178 lines
8.4 KiB
TypeScript
import { globSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { expect, test } from "vitest";
|
|
import { analyze, renderCoverage } from "@scriptc/compiler";
|
|
import { shardSelect, shardSuffix } from "./shard.js";
|
|
|
|
const repoRoot = join(import.meta.dirname, "../..");
|
|
const fixture = (name: string) => join(repoRoot, "tests/coverage-fixtures", name);
|
|
|
|
function report(path: string, opts: { dynamic?: boolean } = {}): string {
|
|
const { coverage } = analyze(path, opts);
|
|
return renderCoverage({ ...coverage, file: coverage.file.replace(repoRoot + "/", "") });
|
|
}
|
|
|
|
test("mixed program: percentage and grouped blockers", async () => {
|
|
await expect(report(fixture("mixed.ts"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-mixed.txt",
|
|
);
|
|
});
|
|
|
|
test("dynamic-capable blockers split from static rejections", async () => {
|
|
// SC2010/SC2011/SC2012 sites group under "runs with --dynamic";
|
|
// constructs no flag fixes stay under "blockers".
|
|
await expect(report(fixture("dynamic-mix.ts"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-dynamic-mix.txt",
|
|
);
|
|
});
|
|
|
|
test("--dynamic analysis: island sites count as compiled-dynamically", async () => {
|
|
// The same fixture analyzed as a --dynamic build: the island-capable
|
|
// sites LOWER (splitting out of "compile statically" into "compile
|
|
// dynamically") and only the flag-independent rejections remain
|
|
// blockers — this report is the "distance to a working build" view.
|
|
await expect(report(fixture("dynamic-mix.ts"), { dynamic: true })).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-dynamic-mix-dynamic.txt",
|
|
);
|
|
});
|
|
|
|
test("unreached blockers report in their own group", async () => {
|
|
// Bodies nothing on the entry path reaches lower only for analysis:
|
|
// their blockers render dim, under "in unreached code" — and the same
|
|
// program BUILDS (the differential dead-strip tests pin that side).
|
|
await expect(report(fixture("unreached.ts"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-unreached.txt",
|
|
);
|
|
});
|
|
|
|
test("fully static program reports 100%", () => {
|
|
const out = report(join(repoRoot, "tests/corpus/400-fib.ts"));
|
|
expect(out).toContain("(100%)");
|
|
expect(out).toContain("fully static");
|
|
});
|
|
|
|
test("fully static JavaScript program reports 100%", () => {
|
|
const out = report(join(repoRoot, "tests/corpus/1590-js-unannotated.js"));
|
|
expect(out).toContain("(100%)");
|
|
expect(out).toContain("fully static");
|
|
});
|
|
|
|
test("JS inference gaps land where 'any' lands: SC2011 static, island dynamic", async () => {
|
|
// The js-gap fixture's tsconfig turns noImplicitAny off, so the untyped
|
|
// parameter types `any` — the static analysis reports the site as
|
|
// dynamic-capable (SC2011) while the JSDoc-typed neighbor stays static.
|
|
await expect(report(fixture("js-gap/gap.js"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-js-gap.txt",
|
|
);
|
|
await expect(report(fixture("js-gap/gap.js"), { dynamic: true })).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-js-gap-dynamic.txt",
|
|
);
|
|
});
|
|
|
|
test("npm package sites attribute per package", async () => {
|
|
// Every site of a package-declared value groups into one SC2013 line
|
|
// naming the package ("values from the 'mathkit' package ..."), inside
|
|
// the "runs with --dynamic" group — reusing the diagnostics fixture so
|
|
// the two views of the same program stay in sync.
|
|
await expect(report(join(repoRoot, "tests/diagnostics/npm-imports/main.ts"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-npm-packages.txt",
|
|
);
|
|
});
|
|
|
|
test("lazy edges inventory: unresolvable require()/import() targets mark as lazy traps", async () => {
|
|
// The lazy-traps npm fixture builds (Node's per-kind edge semantics:
|
|
// require fails at the call, import() at evaluation) — the coverage
|
|
// table lists every blocked-but-lazy specifier with the lazy-trap
|
|
// marker instead of reporting SC2030 blockers.
|
|
await expect(
|
|
report(join(repoRoot, "tests/fixtures/npm/cases/lazy-traps/main.ts"), { dynamic: true }),
|
|
).toMatchFileSnapshot("__snapshots__/coverage-npm-lazy-traps.txt");
|
|
});
|
|
|
|
test("lazy builtin edges mark in the builtins table, __require sites included", async () => {
|
|
// The esbuild-require fixture routes external requires through the
|
|
// bundle's __require helper — its literal call sites collect as require
|
|
// edges, so the builtins table lists node:os/node:tty (shimmed) and
|
|
// node:stream as a lazy trap (unshimmed, reached only by the
|
|
// never-called require) without failing the build.
|
|
await expect(
|
|
report(join(repoRoot, "tests/fixtures/npm/cases/esbuild-require/main.ts"), { dynamic: true }),
|
|
).toMatchFileSnapshot("__snapshots__/coverage-npm-lazy-builtin.txt");
|
|
});
|
|
|
|
test("import fences no longer stop analysis: percentage plus module blockers", async () => {
|
|
// The fenced module reports ONE grouped blocker (the import line plus
|
|
// every use of its bindings carry the same message); the rest of the
|
|
// program still counts. Lives in the node-types fixture: builtin
|
|
// specifiers only RESOLVE (typecheck) with @types/node adopted — the
|
|
// fallback world turns them into tsc errors, which stay fatal. The same
|
|
// file must FAIL a build (compile() keeps preflight fatal).
|
|
await expect(
|
|
report(join(repoRoot, "tests/fixtures/node-types/import-fences.ts")),
|
|
).toMatchFileSnapshot("__snapshots__/coverage-import-fences.txt");
|
|
});
|
|
|
|
test("type errors block analysis", () => {
|
|
const out = report(fixture("type-errors.ts"));
|
|
expect(out).toContain("not analyzable");
|
|
expect(out).toContain("1 TypeScript error");
|
|
});
|
|
|
|
test("type errors render the same code frame a build prints", () => {
|
|
const { coverage, sourceTexts } = analyze(fixture("type-errors.ts"));
|
|
const out = renderCoverage(coverage, { sourceTexts });
|
|
expect(out).toContain("not analyzable");
|
|
expect(out).toContain("error SC0001");
|
|
// The frame gutter with the offending line, exactly the build renderer.
|
|
expect(out).toMatch(/\d+ \| /);
|
|
expect(out).toContain("^");
|
|
});
|
|
|
|
test("bare '.' and '..' imports are relative edges (the vercel-CLI dialect)", () => {
|
|
// `from '..'` / `from '.'` resolve as directory imports to the target's
|
|
// index module — exactly what the project's bundler-resolution checker
|
|
// answered — so the graph loads and everything compiles statically.
|
|
const out = report(fixture("dot-parent/main.ts"));
|
|
expect(out).toContain("(100%)");
|
|
expect(out).toContain("fully static");
|
|
});
|
|
|
|
test("a cycle whose top level calls a builtin with a builtin function value is admitted", () => {
|
|
// vercel's link.ts cluster: `promisify(fs.readFile)` at a cycle member's
|
|
// top level. Callee and callable argument are both dts-rooted — no user
|
|
// code can run in the init window — so no SC1016 and the analysis
|
|
// renders statement counts (the statement itself may stay a
|
|
// statement-LEVEL blocker; that is a different fence).
|
|
const out = report(fixture("cycle-inert-builtin/main.ts"));
|
|
expect(out).not.toContain("circular imports");
|
|
expect(out).toContain("statements analyzed");
|
|
});
|
|
|
|
test(`every corpus program is 100% static (corpus and coverage agree${shardSuffix()})`, async () => {
|
|
// The differential corpus compiles by definition; coverage must agree.
|
|
// `// @dynamic` programs compile under --dynamic, so analyze them that way.
|
|
// The sweep runs minutes on slow machines and each analyze() blocks the
|
|
// worker thread — without the periodic yield, vitest's worker RPC starves
|
|
// ("Timeout calling onTaskUpdate") even while every check passes, and the
|
|
// default per-test timeout is far too small for a whole-corpus analysis.
|
|
let n = 0;
|
|
const flatEntries = shardSelect(
|
|
["ts", "js", "mjs", "cjs"].flatMap((ext) =>
|
|
globSync(join(repoRoot, `tests/corpus/*.${ext}`)),
|
|
).sort(),
|
|
(file) => file.slice(repoRoot.length + 1),
|
|
);
|
|
for (const file of flatEntries) {
|
|
const firstLine = readFileSync(file, "utf8").split("\n", 1)[0] ?? "";
|
|
// `// @deferred-fences: N` on the first line: a JS program that
|
|
// DELIBERATELY carries N runtime-fence statements on untaken paths
|
|
// (the deferred-fence corpus) — it still passes the differential
|
|
// oracle because the fences never execute.
|
|
const deferred = /^\/\/ @deferred-fences:\s*(\d+)\s*$/.exec(firstLine);
|
|
const { coverage } = analyze(file, { dynamic: /^\/\/ @dynamic\s*$/.test(firstLine) });
|
|
expect.soft(coverage.diagnostics, file).toEqual([]);
|
|
expect.soft(coverage.stats.statementsFailed, file).toBe(deferred ? Number(deferred[1]) : 0);
|
|
if (++n % 10 === 0) await new Promise((r) => setImmediate(r));
|
|
}
|
|
}, 600_000);
|