Initial Commit
This commit is contained in:
60
packages/testkit/testing/testing-dom/tests/dep-graph.test.ts
Normal file
60
packages/testkit/testing/testing-dom/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Dep-graph guard — runtime closure of @suaveplan/testing-dom must
|
||||
* contain zero `@suaveplan/*` packages. devDeps may include
|
||||
* @suaveplan/testing for our own tests.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(here, "..", "package.json");
|
||||
|
||||
describe("dep-graph guard", () => {
|
||||
it("no @suaveplan/* in dependencies or peerDependencies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const sections: Array<
|
||||
[
|
||||
"dependencies" | "peerDependencies",
|
||||
Record<string, string> | undefined,
|
||||
]
|
||||
> = [
|
||||
["dependencies", pkg.dependencies],
|
||||
["peerDependencies", pkg.peerDependencies],
|
||||
];
|
||||
for (const [name, deps] of sections) {
|
||||
if (!deps) continue;
|
||||
const offending = Object.keys(deps).filter((k) =>
|
||||
k.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(
|
||||
offending,
|
||||
`${name} contains @suaveplan/* deps: ${offending.join(", ")}`
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("devDependencies may contain @suaveplan/testing only (utilities for our own tests)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const ours = Object.keys(pkg.devDependencies ?? {}).filter((k) =>
|
||||
k.startsWith("@suaveplan/")
|
||||
);
|
||||
const allowed = new Set(["@suaveplan/testing"]);
|
||||
for (const dep of ours) {
|
||||
expect(
|
||||
allowed.has(dep),
|
||||
`unexpected @suaveplan/* devDep: ${dep}`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Spec-conformance: pins the dual-runner conditional-exports shape
|
||||
* declared in `package.json`. A regression that flips the `bun`
|
||||
* condition to a vitest-only barrel (or vice versa) would silently
|
||||
* break consumers running under `bun test`; this test catches it at
|
||||
* the JSON-shape level rather than at integration time.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(here, "..", "package.json");
|
||||
|
||||
interface ExportTarget {
|
||||
readonly types?: string;
|
||||
readonly default?: string;
|
||||
}
|
||||
|
||||
interface ExportsMap {
|
||||
readonly "."?: {
|
||||
readonly vitest?: ExportTarget;
|
||||
readonly bun?: ExportTarget;
|
||||
readonly default?: ExportTarget;
|
||||
};
|
||||
readonly "./polyfills"?: string | ExportTarget;
|
||||
readonly "./matchers"?: string | ExportTarget;
|
||||
readonly "./config"?: string | ExportTarget;
|
||||
readonly "./errors"?: string | ExportTarget;
|
||||
readonly "./render"?: string | ExportTarget;
|
||||
readonly "./queries"?: string | ExportTarget;
|
||||
readonly "./user"?: string | ExportTarget;
|
||||
readonly "./package.json"?: string;
|
||||
}
|
||||
|
||||
interface MinimalPkg {
|
||||
exports?: ExportsMap;
|
||||
}
|
||||
|
||||
async function loadPkg(): Promise<MinimalPkg> {
|
||||
return JSON.parse(await readFile(pkgPath, "utf8")) as MinimalPkg;
|
||||
}
|
||||
|
||||
describe("dual-runner conditional exports", () => {
|
||||
it("routes vitest + default to the index barrel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root, "exports['.'] missing").toBeDefined();
|
||||
expect(root?.vitest?.default).toBe("./dist/index.js");
|
||||
expect(root?.default?.default).toBe("./dist/index.js");
|
||||
});
|
||||
|
||||
it("routes the bun condition to the bun-test barrel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root?.bun?.default).toBe("./dist/runner/bun-test.js");
|
||||
});
|
||||
|
||||
it("vitest and default conditions share the same types entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root?.vitest?.types).toBe("./dist/index.d.ts");
|
||||
expect(root?.default?.types).toBe("./dist/index.d.ts");
|
||||
});
|
||||
|
||||
it("bun condition has its own types entry", { timeout: 5000 }, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root?.bun?.types).toBe("./dist/runner/bun-test.d.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subpath exports presence", () => {
|
||||
const REQUIRED_SUBPATHS = [
|
||||
"./polyfills",
|
||||
"./matchers",
|
||||
"./config",
|
||||
"./errors",
|
||||
"./render",
|
||||
"./queries",
|
||||
"./user",
|
||||
] as const;
|
||||
|
||||
it("declares every required subpath", { timeout: 5000 }, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const exportsMap = pkg.exports ?? {};
|
||||
for (const sub of REQUIRED_SUBPATHS) {
|
||||
expect(
|
||||
Object.hasOwn(exportsMap, sub),
|
||||
`package.json#exports is missing "${sub}"`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("polyfills + matchers resolve to side-effecting dist files", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const exportsMap = pkg.exports ?? {};
|
||||
const polyfills = exportsMap["./polyfills"];
|
||||
const matchers = exportsMap["./matchers"];
|
||||
expect(
|
||||
typeof polyfills === "string" ? polyfills : polyfills?.default
|
||||
).toBe("./dist/setup/polyfills.js");
|
||||
expect(
|
||||
typeof matchers === "string" ? matchers : matchers?.default
|
||||
).toBe("./dist/matchers/matchers.js");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Grep guard — fail if `src/` contains any disallowed construct:
|
||||
* `any`, `ts-ignore`, `ts-expect-error`, `TODO`, `FIXME`, `@deprecated`,
|
||||
* `.skip(`, `.only(`. Test fixtures and benchmarks are excluded.
|
||||
*/
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const srcRoot = resolve(here, "..", "src");
|
||||
|
||||
const PATTERNS: Array<{ name: string; regex: RegExp }> = [
|
||||
{ name: "any", regex: /(?<![a-zA-Z0-9_])any(?![a-zA-Z0-9_])/ },
|
||||
{ name: "ts-ignore", regex: /@ts-ignore/ },
|
||||
{ name: "ts-expect-error", regex: /@ts-expect-error/ },
|
||||
{ name: "TODO", regex: /\bTODO\b/ },
|
||||
{ name: "FIXME", regex: /\bFIXME\b/ },
|
||||
{ name: "@deprecated", regex: /@deprecated/ },
|
||||
{ name: ".skip(", regex: /\.skip\s*\(/ },
|
||||
{ name: ".only(", regex: /\.only\s*\(/ },
|
||||
];
|
||||
|
||||
const EXCLUDED_FILE_SUFFIXES = [".test.ts", ".test.tsx", ".bench.ts"];
|
||||
|
||||
const EXCLUDED_FROM_ANY_CHECK: readonly string[] = [
|
||||
// Mock helpers necessarily reach into untyped runner internals.
|
||||
"runner/mocks.ts",
|
||||
"runner/bun-test.ts",
|
||||
];
|
||||
|
||||
async function walk(dir: string, out: string[]): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full, out);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))
|
||||
) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed source guard", () => {
|
||||
it("src/ contains no disallowed constructs", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const all: string[] = [];
|
||||
await walk(srcRoot, all);
|
||||
const files = all.filter((file) => {
|
||||
const rel = relative(srcRoot, file).replace(/\\/g, "/");
|
||||
return !EXCLUDED_FILE_SUFFIXES.some((s) => rel.endsWith(s));
|
||||
});
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await readFile(file, "utf8");
|
||||
const lines = text.split("\n");
|
||||
const rel = relative(srcRoot, file).replace(/\\/g, "/");
|
||||
for (const { name, regex } of PATTERNS) {
|
||||
if (name === "any" && EXCLUDED_FROM_ANY_CHECK.includes(rel)) {
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("*") ||
|
||||
trimmed.startsWith("/*")
|
||||
)
|
||||
continue;
|
||||
if (regex.test(line)) {
|
||||
violations.push(
|
||||
`${rel}:${i + 1} contains "${name}": ${trimmed}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Side-effecting polyfills module for happy-dom / jsdom / bare-Node test
|
||||
* environments on WSL2 or older toolchains.
|
||||
*
|
||||
* Import via vitest `setupFiles`:
|
||||
*
|
||||
* ```ts
|
||||
* // vitest.config.ts
|
||||
* export default defineConfig({
|
||||
* test: {
|
||||
* setupFiles: ["@suaveplan/testing/polyfills"],
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Installs:
|
||||
* - `globalThis.crypto` (from `node:crypto`'s `webcrypto` export) if missing
|
||||
* - `globalThis.TextEncoder` / `TextDecoder` (usually present, covered defensively)
|
||||
*
|
||||
* Exports nothing. Safe to import multiple times; operations are idempotent.
|
||||
*/
|
||||
|
||||
import { webcrypto } from "node:crypto";
|
||||
import {
|
||||
TextDecoder as NodeTextDecoder,
|
||||
TextEncoder as NodeTextEncoder,
|
||||
} from "node:util";
|
||||
|
||||
type WritableGlobal = {
|
||||
crypto?: Crypto;
|
||||
TextEncoder?: typeof NodeTextEncoder;
|
||||
TextDecoder?: typeof NodeTextDecoder;
|
||||
};
|
||||
|
||||
const g = globalThis as unknown as WritableGlobal;
|
||||
|
||||
if (
|
||||
typeof g.crypto === "undefined" ||
|
||||
typeof g.crypto.getRandomValues !== "function"
|
||||
) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
|
||||
if (typeof g.TextEncoder === "undefined") {
|
||||
g.TextEncoder = NodeTextEncoder;
|
||||
}
|
||||
|
||||
if (typeof g.TextDecoder === "undefined") {
|
||||
g.TextDecoder = NodeTextDecoder;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Tree-shake guard — verify subpaths don't drag unrelated modules.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const distRoot = resolve(here, "..", "dist");
|
||||
|
||||
async function distExists(): Promise<boolean> {
|
||||
try {
|
||||
await readFile(resolve(distRoot, "index.js"), "utf8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readDist(rel: string): Promise<string> {
|
||||
return readFile(resolve(distRoot, rel), "utf8");
|
||||
}
|
||||
|
||||
describe("tree-shake guards", () => {
|
||||
it("matchers subpath does not pull renderComponent", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
if (!(await distExists())) return;
|
||||
const text = await readDist("matchers/matchers.js");
|
||||
expect(text).not.toMatch(/function\s+renderComponent\b/);
|
||||
expect(text).not.toMatch(/class\s+RenderError\b/);
|
||||
});
|
||||
|
||||
it("user/setup-user does not pull jest-dom matchers", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
if (!(await distExists())) return;
|
||||
const text = await readDist("user/setup-user.js");
|
||||
expect(text).not.toMatch(/toBeInTheDocument/);
|
||||
expect(text).not.toMatch(/jest-dom/);
|
||||
});
|
||||
|
||||
it("polyfills subpath has no functional surface", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
if (!(await distExists())) return;
|
||||
const text = await readDist("setup/polyfills.js");
|
||||
expect(text).not.toMatch(/export\s+(?:const|function|class)/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user