Files
suaveplan/scripts/lint/check-test-runner-imports.test.ts
2026-05-27 23:47:53 +01:00

223 lines
7.5 KiB
TypeScript

/**
* Unit test for check-test-runner-imports.ts.
*
* Run with: `bun test scripts/lint/check-test-runner-imports.test.ts`
*
* Uses Bun's native test runner because this file is repo tooling, not a
* workspace package — the canonical-runner-wrapper rule (openspec/AGENTS.md
* §7) governs `packages/<plugin>/<pkg>/src/**\/*.test.ts`, not `scripts/`.
* Importing the rule-enforcer's own test from `@suaveplan/testing/runner`
* would create a circular dependency between the gate and the wrapper it
* polices.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
EXEMPT_PACKAGES,
findViolations,
formatViolations,
} from "./check-test-runner-imports.ts";
interface Fixture {
readonly root: string;
cleanup(): Promise<void>;
}
async function makeFixture(): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), "check-runner-imports-"));
return {
root,
async cleanup() {
await rm(root, { recursive: true, force: true });
},
};
}
async function writePackage(args: {
root: string;
plugin: string;
pkg: string;
name: string;
files: Record<string, string>;
}): Promise<void> {
const pkgDir = join(args.root, "packages", args.plugin, args.pkg);
await mkdir(pkgDir, { recursive: true });
await writeFile(
join(pkgDir, "package.json"),
JSON.stringify({ name: args.name })
);
for (const [relPath, content] of Object.entries(args.files)) {
const full = join(pkgDir, relPath);
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, content);
}
}
describe("check-test-runner-imports", () => {
let fixture: Fixture;
beforeEach(async () => {
fixture = await makeFixture();
});
afterEach(async () => {
await fixture.cleanup();
});
it("reports zero violations when every package uses @suaveplan/testing/runner", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "good-pkg",
name: "@suaveplan/good-pkg",
files: {
"src/feature/feature.test.ts":
'import { describe, it, expect } from "@suaveplan/testing/runner";\n' +
'describe("ok", () => { it("passes", () => { expect(1).toBe(1); }); });\n',
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toEqual([]);
expect(formatViolations(violations)).toContain(
"All non-exempt packages use the canonical"
);
});
it("flags a direct vitest import in a non-exempt package", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "bad-pkg",
name: "@suaveplan/bad-pkg",
files: {
"src/feature/feature.test.ts":
'import { describe, it, expect } from "vitest";\n',
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toHaveLength(1);
expect(violations[0]?.packageName).toBe("@suaveplan/bad-pkg");
expect(violations[0]?.file).toBe(
"packages/demo/bad-pkg/src/feature/feature.test.ts"
);
expect(violations[0]?.line).toBe(1);
expect(violations[0]?.source).toContain('from "vitest"');
const message = formatViolations(violations);
expect(message).toContain("❌ Direct vitest / bun:test imports");
expect(message).toContain("@suaveplan/bad-pkg");
expect(message).toContain("openspec/AGENTS.md §7");
});
it("flags a direct bun:test import in a non-exempt package", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "bad-bun",
name: "@suaveplan/bad-bun",
files: {
"src/x.test.ts": "import { describe } from 'bun:test';\n",
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toHaveLength(1);
expect(violations[0]?.source).toContain("'bun:test'");
});
it("skips every package in the exempt allowlist", {
timeout: 5000,
}, async () => {
for (const exemptName of EXEMPT_PACKAGES) {
const slug = exemptName.replace("@suaveplan/", "");
await writePackage({
root: fixture.root,
plugin: "dev",
pkg: slug,
name: exemptName,
files: {
"src/x.test.ts":
'import { describe, it, expect } from "vitest";\n' +
"import { describe as bunDescribe } from 'bun:test';\n",
},
});
}
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toEqual([]);
});
it("ignores packages without a src/ directory", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "no-src",
name: "@suaveplan/no-src",
files: {
"README.md": "# no src here\n",
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toEqual([]);
});
it("does not flag re-exports from a barrel (only `import` statements)", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "reexport",
name: "@suaveplan/reexport",
files: {
"src/x.ts": 'export { describe } from "vitest";\n',
},
});
const violations = await findViolations({ repoRoot: fixture.root });
// The rule scope is `import` statements; re-exports from vitest in
// non-test code are caught by other rules (and are nonsensical for
// a non-exempt package — they would mean shipping vitest at
// runtime). The check stays import-statement-scoped to keep the
// signal high.
expect(violations).toEqual([]);
});
it("ignores packages whose package.json is missing or unparsable", {
timeout: 5000,
}, async () => {
const pkgDir = join(fixture.root, "packages", "demo", "no-manifest");
await mkdir(join(pkgDir, "src"), { recursive: true });
await writeFile(
join(pkgDir, "src", "x.test.ts"),
'import { describe } from "vitest";\n'
);
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toEqual([]);
});
it("scans .tsx as well as .ts", { timeout: 5000 }, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "tsx-pkg",
name: "@suaveplan/tsx-pkg",
files: {
"src/x.test.tsx": 'import { describe } from "vitest";\n',
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toHaveLength(1);
expect(violations[0]?.file).toEndWith(".tsx");
});
});