mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
1193c9dbaf
dogfood
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { readdirSync, readFileSync } from "node:fs";
|
|
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "node:path";
|
|
|
|
import { execa } from "execa";
|
|
import { expect as defaultExpect, type ExpectStatic } from "vitest";
|
|
|
|
const isWindows = pathSep === "\\";
|
|
const normalizeSlashes = (path: string) => path.replaceAll("\\", "/");
|
|
|
|
export const PACKAGE_ROOT_PATH = pathJoin(import.meta.dirname, ".."); // `/path/to/oxc/apps/oxlint`
|
|
const FIXTURES_DIR_PATH = pathJoin(import.meta.dirname, "fixtures"); // `/path/to/oxc/apps/oxlint/test/fixtures`
|
|
|
|
const REPO_ROOT_PATH = pathJoin(PACKAGE_ROOT_PATH, "../.."); // `/path/to/oxc`
|
|
export const FIXTURES_SUBPATH = `/${normalizeSlashes(pathRelative(REPO_ROOT_PATH, FIXTURES_DIR_PATH))}`; // `/apps/oxlint/test/fixtures`
|
|
|
|
const FIXTURES_URL = new URL("./fixtures/", import.meta.url).href; // `file:///path/to/oxc/apps/oxlint/test/fixtures/`
|
|
|
|
// Details of a test fixture.
|
|
export interface Fixture {
|
|
name: string;
|
|
dirPath: string;
|
|
options: {
|
|
// Run Oxlint. Default: `true`.
|
|
oxlint: boolean;
|
|
// Run ESLint. Default: `false`.
|
|
eslint: boolean;
|
|
// Run Oxlint with fixes. Default: `false`.
|
|
fix: boolean;
|
|
// Run Oxlint with fix-suggestions. Default: `false`.
|
|
fixSuggestions: boolean;
|
|
// Run Oxlint single-threaded. Default: `false`.
|
|
singleThread: boolean;
|
|
// Run Oxlint/ESLint in a specific working directory.
|
|
// If provided, `cwd` is relative to the fixture's directory.
|
|
// Default: `null`.
|
|
cwd: string | null;
|
|
|
|
// Additional arguments to run Oxlint with. Default: `[]`.
|
|
args: string[];
|
|
// Additional environment variables. Default: `{}`.
|
|
env: Record<string, string>;
|
|
};
|
|
}
|
|
|
|
const DEFAULT_OPTIONS: Fixture["options"] = {
|
|
oxlint: true,
|
|
eslint: false,
|
|
fix: false,
|
|
fixSuggestions: false,
|
|
singleThread: false,
|
|
cwd: null,
|
|
args: [],
|
|
env: {},
|
|
};
|
|
|
|
/**
|
|
* Get all fixtures in `test/fixtures`, and their options.
|
|
* @returns Array of fixtures
|
|
*/
|
|
export function getFixtures(): Fixture[] {
|
|
const fixtures: Fixture[] = [];
|
|
|
|
const fileObjs = readdirSync(FIXTURES_DIR_PATH, { withFileTypes: true });
|
|
for (const fileObj of fileObjs) {
|
|
if (!fileObj.isDirectory()) continue;
|
|
|
|
const { name } = fileObj;
|
|
|
|
// Read `options.json` file
|
|
const dirPath = pathJoin(FIXTURES_DIR_PATH, name);
|
|
|
|
let options: Fixture["options"];
|
|
try {
|
|
options = JSON.parse(readFileSync(pathJoin(dirPath, "options.json"), "utf8"));
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
|
|
options = DEFAULT_OPTIONS;
|
|
}
|
|
|
|
if (typeof options !== "object" || options === null) {
|
|
throw new TypeError("`options.json` must be an object");
|
|
}
|
|
options = { ...DEFAULT_OPTIONS, ...options };
|
|
if (
|
|
typeof options.oxlint !== "boolean"
|
|
|| typeof options.eslint !== "boolean"
|
|
|| typeof options.fix !== "boolean"
|
|
|| typeof options.fixSuggestions !== "boolean"
|
|
|| typeof options.singleThread !== "boolean"
|
|
) {
|
|
throw new TypeError(
|
|
"`oxlint`, `eslint`, `fix`, `fixSuggestions`, and `singleThread` properties in `options.json` must be booleans",
|
|
);
|
|
}
|
|
if (options.cwd !== null && typeof options.cwd !== "string") {
|
|
throw new TypeError("`cwd` property in `options.json` must be a string or null");
|
|
}
|
|
if (!Array.isArray(options.args) || !options.args.every((arg) => typeof arg === "string")) {
|
|
throw new TypeError("`args` property in `options.json` must be an array of strings");
|
|
}
|
|
if (
|
|
typeof options.env !== "object"
|
|
|| options.env === null
|
|
|| Array.isArray(options.env)
|
|
|| !Object.values(options.env).every((v) => typeof v === "string")
|
|
) {
|
|
throw new TypeError("`env` property in `options.json` must be an object of strings");
|
|
}
|
|
|
|
fixtures.push({ name, dirPath, options });
|
|
}
|
|
|
|
return fixtures;
|
|
}
|
|
|
|
// Options to pass to `testFixtureWithCommand`.
|
|
interface TestFixtureOptions {
|
|
// Command
|
|
command: string;
|
|
// Arguments to execute command with
|
|
args: string[];
|
|
// Fixture details
|
|
fixture: Fixture;
|
|
// Name of the snapshot file
|
|
snapshotName: string;
|
|
// `true` if the command is ESLint
|
|
isESLint: boolean;
|
|
// Vitest expect function (required for concurrent tests)
|
|
expect?: ExpectStatic;
|
|
}
|
|
|
|
/**
|
|
* Run a test fixture.
|
|
* @param options - Options for running the test
|
|
*/
|
|
export async function testFixtureWithCommand(options: TestFixtureOptions): Promise<void> {
|
|
const { expect = defaultExpect } = options;
|
|
const { name: fixtureName, dirPath, options: fixtureOptions } = options.fixture,
|
|
pathPrefixLen = dirPath.length + 1;
|
|
|
|
// Read all the files in fixture's directory
|
|
const fileObjs = await fs.readdir(dirPath, { withFileTypes: true, recursive: true });
|
|
|
|
const files: { filename: string; code: string }[] = [];
|
|
await Promise.all(
|
|
fileObjs.map(async (fileObj) => {
|
|
if (fileObj.isFile()) {
|
|
const path = pathJoin(fileObj.parentPath, fileObj.name);
|
|
files.push({
|
|
filename: normalizeSlashes(path.slice(pathPrefixLen)),
|
|
code: await fs.readFile(path, "utf8"),
|
|
});
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Run command
|
|
const cwd = fixtureOptions.cwd === null ? dirPath : pathJoin(dirPath, fixtureOptions.cwd);
|
|
|
|
let { stdout, stderr, exitCode } = await execa(options.command, options.args, {
|
|
cwd,
|
|
reject: false,
|
|
env: { ...process.env, ...fixtureOptions.env },
|
|
});
|
|
|
|
// Build snapshot `.snap.md` file
|
|
const snapshotPath = pathJoin(dirPath, `${options.snapshotName}.snap.md`);
|
|
|
|
stdout = normalizeStdout(stdout, fixtureName, options.isESLint);
|
|
stderr = normalizeStdout(stderr, fixtureName, false);
|
|
let snapshot =
|
|
`# Exit code\n${exitCode}\n\n`
|
|
+ `# stdout\n\`\`\`\n${stdout}\`\`\`\n\n`
|
|
+ `# stderr\n\`\`\`\n${stderr}\`\`\`\n`;
|
|
|
|
// Check for any changes to files in `files` and add them to the snapshot.
|
|
// Revert any changes to the files (useful for `--fix` tests).
|
|
const changes: { filename: string; code: string }[] = [];
|
|
await Promise.all(
|
|
files.map(async ({ filename, code: codeBefore }) => {
|
|
const path = pathJoin(dirPath, filename);
|
|
const codeAfter = await fs.readFile(path, "utf8");
|
|
if (codeAfter !== codeBefore) {
|
|
await fs.writeFile(path, codeBefore);
|
|
changes.push({ filename, code: codeAfter });
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (changes.length > 0) {
|
|
changes.sort((a, b) => (a.filename > b.filename ? 1 : -1));
|
|
for (const { filename, code } of changes) {
|
|
snapshot += `\n# File altered: ${filename}\n\`\`\`\n${code}\n\`\`\`\n`;
|
|
}
|
|
}
|
|
|
|
await expect(snapshot).toMatchFileSnapshot(snapshotPath);
|
|
}
|
|
|
|
export const NORMALIZED_REPO_ROOT = normalizeSlashes(REPO_ROOT_PATH);
|
|
// Regexp to match paths in output.
|
|
// Matches `/path/to/oxc`, `/path/to/oxc/`, `/path/to/oxc/whatever`,
|
|
// when preceded by whitespace, `(`, or a quote, and followed by whitespace, `)`, or a quote.
|
|
export const PATH_REGEXP = new RegExp(
|
|
`(?<=^|[\\s\\('"\`])${RegExp.escape(NORMALIZED_REPO_ROOT).replace(/\\\//g, "[\\\\/]")}([\\\\\\\\/][^\\s\\)'"\`]*)?(?=$|[\\s\\)'"\`])`,
|
|
isWindows ? "gi" : "g",
|
|
);
|
|
|
|
// Regexp to match lines of form `whatever plugin/rule` in ESLint output.
|
|
const ESLINT_REGEXP = /^(.*?)\s+([A-Za-z0-9_-]+\/[A-Za-z0-9_-]+)$/;
|
|
// Column to align rule names to in ESLint output.
|
|
const ESLINT_RULE_NAME_COLUMN = 60;
|
|
// Minimum number of spaces between line content and rule name.
|
|
const ESLINT_SPACES_MIN = 4;
|
|
|
|
/**
|
|
* Normalize output, so it's the same on every machine.
|
|
*
|
|
* - Remove timing + thread count info.
|
|
* - Replace start of file paths with `<root>`.
|
|
* - Remove irrelevant lines from stack traces.
|
|
* - Normalize line breaks.
|
|
*
|
|
* @param stdout - Output from process
|
|
* @param fixtureName - Name of the fixture
|
|
* @param isESLint - `true` if the output is from ESLint
|
|
* @returns Normalized output
|
|
*/
|
|
export function normalizeStdout(stdout: string, fixtureName: string, isESLint: boolean): string {
|
|
// Normalize line breaks, and trim line breaks from start and end
|
|
stdout = stdout.replace(/\r\n?/g, "\n").replace(/^\n+/, "").replace(/\n+$/, "");
|
|
if (stdout === "") return "";
|
|
|
|
let lines = stdout.split("\n");
|
|
|
|
// Remove timing and thread count info which can vary between runs.
|
|
//
|
|
// Examples, all need to be handled:
|
|
// `Finished in 123ms on 4 files with 10 rules using 2 threads.`
|
|
// `Finished in 1.23s on 1 file using 8 threads.`
|
|
// `Finished in 456us on 2 files with 5 rules using 4 threads.`
|
|
lines[lines.length - 1] = lines[lines.length - 1].replace(
|
|
/^Finished in \d+(?:\.\d+)?(?:s|ms|us|ns) on (\d+) file(s?) (?:with (\d+) rule(?:s?) )?using \d+ threads.$/,
|
|
(_match, filesCount, filePlural, rulesCount) =>
|
|
`Finished in Xms on ${filesCount} file${filePlural}${rulesCount ? ` with ${rulesCount} rules` : ""} using X threads.`,
|
|
);
|
|
|
|
// Remove lines from stack traces which are outside `fixtures` directory.
|
|
// Shorten paths in output with `<root>`, `<fixtures>`, or `<fixture>`.
|
|
lines = lines.flatMap((line) => {
|
|
// Normalize Windows backslashes in codeframe headers.
|
|
// e.g. `,-[files\\foo\\bar.js:1:1]` -> `,-[files/foo/bar.js:1:1]`
|
|
line = line.replace(/(,-\[)([^\]]+)(\])/g, (_match, prefix, content, suffix) => {
|
|
return `${prefix}${content.replaceAll("\\", "/")}${suffix}`;
|
|
});
|
|
|
|
// Handle uris with `?cache=...` query param, which are used to bypass Node.js module cache when loading config files in LSP tests.
|
|
line = line.replaceAll(/\?cache=\d+/g, "");
|
|
|
|
// Handle stack trace lines.
|
|
// e.g. ` at file:///path/to/oxc/apps/oxlint/test/fixtures/foo/bar.js:1:1`
|
|
// e.g. ` at whatever (file:///path/to/oxc/apps/oxlint/test/fixtures/foo/bar.js:1:1)`
|
|
// e.g. ` | at file:///path/to/oxc/apps/oxlint/test/fixtures/foo/bar.js:1:1`
|
|
// e.g. ` | at whatever (file:///path/to/oxc/apps/oxlint/test/fixtures/foo/bar.js:1:1)`
|
|
const match = line.match(/^(\s*\|?\s+at (?:.+?\()?)(.+)$/);
|
|
if (match) {
|
|
let [, preamble, at] = match;
|
|
|
|
const fixturesUrl = isWindows ? FIXTURES_URL.toLowerCase() : FIXTURES_URL;
|
|
const atCmp = isWindows ? at.toLowerCase() : at;
|
|
if (atCmp.startsWith(fixturesUrl)) {
|
|
at = convertFixturesSubPath(at.slice(FIXTURES_URL.length - 1), fixtureName);
|
|
return [`${preamble}${at}`];
|
|
}
|
|
|
|
// Some stack traces can use file paths instead of file URLs on Windows.
|
|
// Keep frames in fixtures, drop everything else.
|
|
const normalizedAt = at.replaceAll(PATH_REGEXP, (_, subPath) => {
|
|
if (subPath === undefined) return "<root>";
|
|
return convertSubPath(normalizeSlashes(subPath), fixtureName);
|
|
});
|
|
|
|
if (normalizedAt.includes("<fixture>") || normalizedAt.includes("<fixtures>")) {
|
|
return [`${preamble}${normalizedAt}`];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
// Handle fixture file URLs anywhere else in the line.
|
|
// e.g. `... file:///path/to/oxc/apps/oxlint/test/fixtures/foo/bar.js:1:1 ...`
|
|
line = line.replaceAll(
|
|
new RegExp(`${RegExp.escape(FIXTURES_URL)}([^\\s\\)'"\`]+)`, isWindows ? "gi" : "g"),
|
|
(_match, subPath) => convertFixturesSubPath(`/${subPath}`, fixtureName),
|
|
);
|
|
|
|
// Handle paths anywhere else in the line
|
|
line = line.replaceAll(PATH_REGEXP, (_, subPath) => {
|
|
if (subPath === undefined) return "<root>";
|
|
return convertSubPath(normalizeSlashes(subPath), fixtureName);
|
|
});
|
|
|
|
// Align rule names in ESLint output.
|
|
// This avoids:
|
|
// 1. Churn in snapshots when 1 line of output changes, because that changes how ESLint aligns rule names.
|
|
// 2. Mismatch between local development and CI, because alignment differs for some reason (terminal width?).
|
|
if (isESLint) {
|
|
const match = line.match(ESLINT_REGEXP);
|
|
if (match) {
|
|
const [, content, ruleName] = match;
|
|
const spaces = Math.max(ESLINT_RULE_NAME_COLUMN - content.length, ESLINT_SPACES_MIN);
|
|
line = `${content}${" ".repeat(spaces)}${ruleName}`;
|
|
}
|
|
}
|
|
|
|
return [line];
|
|
});
|
|
|
|
if (lines.length === 0) return "";
|
|
return lines.join("\n") + "\n";
|
|
}
|
|
|
|
/**
|
|
* Convert a sub path to a shorter form.
|
|
*
|
|
* `subPath` must be a path relative to the root of the repository.
|
|
* It should start with `/`, and have Windows backslashes replaced with forward slashes,
|
|
* before being passed to this function.
|
|
*
|
|
* Examples:
|
|
* - `/apps/oxlint/test/fixtures/foo/bar.js` => `<fixture>/bar.js`
|
|
* - `/apps/oxlint/test/fixtures/foo` => `<fixtures>/foo`
|
|
* - `/apps/oxlint/something/else` => `<root>/apps/oxlint/something/else`
|
|
*/
|
|
export function convertSubPath(subPath: string, fixtureName: string): string {
|
|
if (subPath.startsWith(FIXTURES_SUBPATH)) {
|
|
const relPath = subPath.slice(FIXTURES_SUBPATH.length);
|
|
if (relPath === "") return "<fixtures>";
|
|
if (relPath.startsWith("/")) return convertFixturesSubPath(relPath, fixtureName);
|
|
}
|
|
|
|
return `<root>${subPath}`;
|
|
}
|
|
|
|
/**
|
|
* Convert a fixtures sub path to a shorter form.
|
|
*
|
|
* `subPath` must be a path relative to the fixtures dir.
|
|
* It should start with `/`, and have Windows backslashes replaced with forward slashes,
|
|
* before being passed to this function.
|
|
*
|
|
* Examples:
|
|
* - `/foo/bar.js` => `<fixture>/bar.js`
|
|
* - `/foo` => `<fixtures>/foo`
|
|
*/
|
|
export function convertFixturesSubPath(subPath: string, fixtureName: string): string {
|
|
subPath = subPath.slice(1);
|
|
if (subPath.startsWith(fixtureName)) {
|
|
const relPath = subPath.slice(fixtureName.length);
|
|
if (relPath === "") return "<fixture>";
|
|
if (relPath.startsWith("/")) return `<fixture>${relPath}`;
|
|
}
|
|
return `<fixtures>/${subPath}`;
|
|
}
|