Files
2026-03-18 18:57:47 +01:00

244 lines
7.6 KiB
JavaScript

import { multiple as getFixtures } from "@babel/helper-fixtures";
import checkDuplicateNodes from "@babel/helper-check-duplicate-nodes";
import { readFileSync, unlinkSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import Difference from "./difference.js";
import FixtureError from "./fixture-error.js";
import toFuzzedOptions from "./to-fuzzed-options.js";
import { serialize, deserialize } from "./serialization.js";
import toContextualSyntaxError from "./to-contextual-syntax-error.js";
const { CI, OVERWRITE } = process.env;
const { stringify, parse: JSONParse } = JSON;
const writeFileWithNewline = (path, string) =>
writeFileSync(path, `${string}\n`, "utf-8");
/**
* run parser on given tests
*
* @param {string} fixturesPath A base search path for finding fixtures.
* @param {*} parseFunction A parser with the same interface of
* `@babel/parser#parse`
* @param {function} [filter] A filter function that takes a test and returns
* whether it should be run or not. Defaults to always returning true.
* @param {boolean} [onlyCompareErrors=false] Whether we should only compare the
* "errors" of generated AST against the expected AST. Used for tests where an
* ESTree AST is generated but we want to make sure `@babel/parser` still throws
* expected recoverable recoverable errors on given code locations.
* @param {boolean} [ignoreLocations=false] Whether we should ignore the
* "locations" of the AST nodes when comparing the generated AST against the
* expected AST.
* @returns {void}
*/
export default function runFixtureTests(
fixturesPath,
parseFunction,
filter = () => true,
onlyCompareErrors = false,
ignoreLocations = false,
) {
const fixtures = getFixtures(fixturesPath);
for (const [name, testSuites] of Object.entries(fixtures)) {
for (const { title, tests } of testSuites) {
for (const test of tests) {
if (!filter(test)) continue;
runAutogeneratedParseTests(
parseFunction,
`${name}/${title}`,
test,
onlyCompareErrors,
ignoreLocations,
);
}
}
}
}
function runAutogeneratedParseTests(
parse,
prefix,
task,
onlyCompareErrors = false,
) {
const { expect, options } = task;
const testFn = task.disabled ? it.skip : it;
const expected = deserialize(expect.loc, options, expect.code);
const title = `${prefix}/${task.title}`;
const toStartPosition = ({
startIndex = 0,
startLine = 1,
startColumn = 0,
}) => `(${startIndex}, ${startLine}, ${startColumn})`;
toFuzzedOptions(options)
.map(([adjust, options], index) => ({
...task,
title: `${title} start = ${toStartPosition(options)}`,
adjust,
options,
expected,
filename: task.actual.loc,
source: task.actual.code,
original: index === 0,
}))
.forEach(test =>
testFn(test.title, () => runParseTest(parse, test, onlyCompareErrors)),
);
}
const toJustErrors = result => ({
threw: result.threw,
ast: result.ast && { errors: result.ast.errors },
});
function runParseTest(parse, test, onlyCompareErrors) {
const { adjust, expected, source, filename, options } = test;
if (!OVERWRITE && expected.threw && expected.ast) {
throw Error(
"File expected.json exists although options specify throws. Remove expected.json.",
);
}
const actual = parseWithRecovery(parse, source, filename, options);
if (
Array.isArray(onlyCompareErrors) &&
actual.ast.errors &&
actual.ast.errors.some(error =>
onlyCompareErrors.includes(error.reasonCode),
)
) {
return;
}
const difference = new Difference(
adjust,
onlyCompareErrors ? toJustErrors(expected) : expected,
onlyCompareErrors ? toJustErrors(actual) : actual,
);
// No differences means we passed and there's nothing left to do.
if (difference === Difference.None) return;
const testLocation = test.taskDir;
// We only write the output of the original test, not all it's auto-generated
// variations.
if (!test.original) {
const err = new Error();
err.message = `Test Failed: ${testLocation}\nFixtureError.fromDifference: ${
FixtureError.fromDifference(difference, actual).message
}`;
throw err;
}
// FIXME: We're just maintaining the legacy behavior of storing *just* the
// error `message` here, which differs from the error's `toString()` that we
// store for each error in the `errors` array. In both cases, we should
// serialize the full error to be able to property test locations,
// reasonCodes, etc.
const throws = actual.threw ? actual.threw.message : undefined;
const optionsLocation = join(testLocation, "options.json");
// We want to throw away the contents of `throws` here.
const { throws: expectedThrows, ...oldOptions } = readJSON(optionsLocation);
const newOptions = { ...oldOptions, ...(throws && { throws }) };
const normalLocation = join(testLocation, "output.json");
const extendedLocation = join(testLocation, "output.extended.json");
// If we're not overwriting the current values with whatever we get this time
// around, then we have a legitimate error that we need to report.
const shouldThrow =
expectedThrows !== undefined ||
existsSync(normalLocation) ||
existsSync(extendedLocation);
if (CI || (!OVERWRITE && shouldThrow)) {
const err = new Error();
err.message = `Test Failed: ${testLocation}\nFixtureError.fromDifference: ${
FixtureError.fromDifference(difference, actual).message
}`;
throw err;
}
// Store (or overwrite) the options file if there's anything to record,
// otherwise remove it.
if (Object.keys(newOptions).length <= 0) {
rmf(optionsLocation);
} else if (throws !== expectedThrows) {
// The idea here is that we shouldn't need to change anything if this doesn't
// throw, and stringify will produce different output than what prettier
// wants.
writeFileWithNewline(optionsLocation, stringify(newOptions, null, 2));
}
// When only comparing errors, we don't want to overwrite the AST JSON because
// it belongs to a different test.
if (onlyCompareErrors) return;
const [extended, serialized] = actual.ast ? serialize(actual.ast) : [];
const outputLocation =
serialized && (extended ? extendedLocation : normalLocation);
if (outputLocation) {
writeFileWithNewline(outputLocation, serialized);
}
// Remove any previous output files that are no longer valid, either because
// extension changed, or because we aren't writing it out at all anymore.
for (const location of [normalLocation, extendedLocation]) {
if (location !== outputLocation) {
rmf(location);
}
}
}
function readJSON(filename) {
try {
return JSONParse(readFileSync(filename, "utf-8"));
} catch {
return {};
}
}
function rmf(path) {
try {
unlinkSync(path);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
function parseWithRecovery(parse, source, filename, options) {
try {
const ast = parse(source, { errorRecovery: true, ...options });
checkDuplicateNodes(ast);
// Normalize the AST
//
// TODO: We should consider doing something more involved here as
// we may miss bugs where we put unexpected falsy objects in these
// properties.
if (ast.comments && !ast.comments.length) delete ast.comments;
if (ast.errors && !ast.errors.length) delete ast.errors;
else {
ast.errors = ast.errors.map(error =>
toContextualSyntaxError(error, source, filename, options),
);
}
return { threw: false, ast };
} catch (error) {
return {
threw: toContextualSyntaxError(error, source, filename, options),
ast: false,
};
}
}