179 lines
5.6 KiB
TypeScript
179 lines
5.6 KiB
TypeScript
/** Level-aware `wrnexus test` runner with Bun and optional Playwright backends. */
|
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { join, relative, resolve } from "node:path";
|
|
|
|
export const TEST_LEVELS = [
|
|
"unit",
|
|
"component",
|
|
"api",
|
|
"browser",
|
|
"visual",
|
|
"accessibility",
|
|
"performance",
|
|
] as const;
|
|
export type TestLevel = (typeof TEST_LEVELS)[number];
|
|
export interface TestCommandPlan {
|
|
command: string;
|
|
args: string[];
|
|
cwd: string;
|
|
level?: TestLevel;
|
|
files: string[];
|
|
setup?: { command: string; args: string[] };
|
|
env?: Record<string, string>;
|
|
}
|
|
|
|
function shardFiles(files: string[], value?: string): string[] {
|
|
if (!value) return files;
|
|
const match = /^(\d+)\/(\d+)$/.exec(value);
|
|
if (!match) throw new Error("WRN-TEST-SHARD: expected --shard=<index>/<total>");
|
|
const index = Number(match[1]);
|
|
const total = Number(match[2]);
|
|
if (index < 1 || total < 1 || index > total)
|
|
throw new Error("WRN-TEST-SHARD: index must be between 1 and total");
|
|
return files.filter((_file, position) => position % total === index - 1);
|
|
}
|
|
|
|
function testFiles(root: string): string[] {
|
|
const files: string[] = [];
|
|
const walk = (directory: string): void => {
|
|
if (!existsSync(directory)) return;
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
if (["node_modules", "dist", ".wrnexus", "coverage"].includes(entry.name)) continue;
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) walk(path);
|
|
else if (/\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(entry.name)) files.push(path);
|
|
}
|
|
};
|
|
for (const directory of ["app", "test", "tests"]) walk(join(root, directory));
|
|
return files.sort();
|
|
}
|
|
|
|
function hasPlaywright(root: string): boolean {
|
|
if (
|
|
["playwright.config.ts", "playwright.config.js", "playwright.config.mjs"].some((file) =>
|
|
existsSync(join(root, file)),
|
|
)
|
|
)
|
|
return true;
|
|
try {
|
|
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
return Boolean(
|
|
manifest.dependencies?.["@playwright/test"] || manifest.devDependencies?.["@playwright/test"],
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function createTestPlan(appRoot: string, args: string[]): TestCommandPlan {
|
|
const root = resolve(appRoot);
|
|
const level = args.find((value): value is TestLevel => TEST_LEVELS.includes(value as TestLevel));
|
|
const watch = args.includes("--watch");
|
|
const profile = args.find((value) => value.startsWith("--profile="))?.slice(10) || "test";
|
|
const setupSource = join(import.meta.dir, "test-setup.ts");
|
|
const setupModule = existsSync(setupSource)
|
|
? setupSource
|
|
: join(import.meta.dir, "test-setup.js");
|
|
const shard = args.find((value) => value.startsWith("--shard="))?.slice(8);
|
|
const browsers = (args.find((value) => value.startsWith("--browsers="))?.slice(11) ?? "chromium")
|
|
.split(",")
|
|
.filter(Boolean);
|
|
const passthrough = args.filter(
|
|
(value) =>
|
|
!value.startsWith("--profile=") &&
|
|
value !== "--watch" &&
|
|
value !== appRoot &&
|
|
value !== level &&
|
|
!value.startsWith("--browsers=") &&
|
|
!value.startsWith("--shard=") &&
|
|
value !== "--install-browsers",
|
|
);
|
|
if ((level === "browser" || level === "visual") && hasPlaywright(root)) {
|
|
return {
|
|
command: process.execPath,
|
|
args: [
|
|
"x",
|
|
"playwright",
|
|
"test",
|
|
...(level === "visual" ? ["--grep", "@visual"] : []),
|
|
...browsers.flatMap((browser) => ["--project", browser]),
|
|
...(shard ? [`--shard=${shard}`] : []),
|
|
"--reporter=line,html",
|
|
...passthrough,
|
|
],
|
|
cwd: root,
|
|
level,
|
|
files: [],
|
|
...(args.includes("--install-browsers")
|
|
? {
|
|
setup: { command: process.execPath, args: ["x", "playwright", "install", ...browsers] },
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
const pattern =
|
|
level === "accessibility"
|
|
? /(?:accessibility|a11y)/i
|
|
: level === "performance"
|
|
? /(?:performance|benchmark)/i
|
|
: level
|
|
? new RegExp(level, "i")
|
|
: null;
|
|
const files = shardFiles(
|
|
pattern ? testFiles(root).filter((file) => pattern.test(relative(root, file))) : [],
|
|
shard,
|
|
);
|
|
return {
|
|
command: process.execPath,
|
|
args: [
|
|
"test",
|
|
"--preload",
|
|
setupModule,
|
|
...(watch ? ["--watch"] : []),
|
|
...(pattern ? files : []),
|
|
...passthrough,
|
|
],
|
|
cwd: root,
|
|
level,
|
|
files,
|
|
env: { WRNEXUS_TEST_ROOT: root, WRNEXUS_PROFILE: profile },
|
|
};
|
|
}
|
|
|
|
export function runTests(appRoot: string, args: string[]): ChildProcess | null {
|
|
const plan = createTestPlan(appRoot, args);
|
|
if (plan.level && !plan.files.length && !plan.args.includes("playwright")) {
|
|
console.error(
|
|
`WRN-TEST-NO-FILES: no ${plan.level} tests found. Name a file or directory with '${plan.level}' under app/, test/, or tests/.`,
|
|
);
|
|
process.exitCode = 1;
|
|
return null;
|
|
}
|
|
const launch = () =>
|
|
spawn(plan.command, plan.args, {
|
|
stdio: "inherit",
|
|
cwd: plan.cwd,
|
|
env: { ...process.env, ...plan.env },
|
|
});
|
|
if (plan.setup) {
|
|
const setup = spawn(plan.setup.command, plan.setup.args, { stdio: "inherit", cwd: plan.cwd });
|
|
setup.on("exit", (code, signal) => {
|
|
if (signal || code !== 0) {
|
|
process.exitCode = code ?? 1;
|
|
return;
|
|
}
|
|
const test = launch();
|
|
test.on("exit", (testCode, testSignal) => {
|
|
if (!testSignal) process.exitCode = testCode ?? 0;
|
|
});
|
|
});
|
|
return setup;
|
|
}
|
|
const child = launch();
|
|
child.on("exit", (code, signal) => {
|
|
if (!signal) process.exitCode = code ?? 0;
|
|
});
|
|
return child;
|
|
}
|