Refactor format test (#18747)

This commit is contained in:
fisker Cheung authored and GitHub committed 2026-01-25 20:48:49 +08:00
1 parent 7ca5d83e45
commit 8fc02d3671
18 files changed
+693 -547

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
import runFormatTest from "./run-format-test.js";
import { runFormatTest } from "./format-test/index.js";
globalThis.runFormatTest = runFormatTest;
+20
View File
@@ -0,0 +1,20 @@
import path from "node:path";
import createEsmUtils from "esm-utils";
import { normalizeDirectory } from "./utilities.js";
const { __dirname } = createEsmUtils(import.meta);
export const FORMAT_SCRIPT_FILENAME = "format.test.js";
export const FORMAT_TEST_DIRECTORY = normalizeDirectory(
path.join(__dirname, "../../format/"),
);
export const { FULL_TEST, TEST_STANDALONE, NODE_ENV, TEST_RUNTIME } =
process.env;
export const isProduction = NODE_ENV === "production";
export const BOM = "\uFEFF";
export const CURSOR_PLACEHOLDER = "<|>";
export const RANGE_START_PLACEHOLDER = "<<<PRETTIER_RANGE_START>>>";
export const RANGE_END_PLACEHOLDER = "<<<PRETTIER_RANGE_END>>>";
@@ -1,4 +1,5 @@
import { wrap as raw } from "jest-snapshot-serializer-raw";
import { CURSOR_PLACEHOLDER } from "./constants.js";
import visualizeEndOfLine from "./visualize-end-of-line.js";
import visualizeRange from "./visualize-range.js";
@@ -50,10 +51,7 @@ function printWidthIndicator(printWidth, offset) {
return `${before}${" ".repeat(printWidth)}| printWidth`;
}
function createSnapshot(
formatResult,
{ parsers, formatOptions, CURSOR_PLACEHOLDER },
) {
function createSnapshot(formatResult, { parsers, formatOptions }) {
let {
inputWithCursor: input,
outputWithCursor: output,
@@ -1,8 +1,5 @@
import path from "node:path";
import createEsmUtils from "esm-utils";
const { __dirname } = createEsmUtils(import.meta);
const fixturesDirectory = path.join(__dirname, "../format/");
import { FORMAT_TEST_DIRECTORY } from "./constants.js";
// TODO: these test files need fix
const unstableTests = new Map(
@@ -39,7 +36,7 @@ const unstableTests = new Map(
const [file, isUnstable = () => true] = Array.isArray(fixture)
? fixture
: [fixture];
return [path.join(fixturesDirectory, file), isUnstable];
return [path.join(FORMAT_TEST_DIRECTORY, file), isUnstable];
}),
);
@@ -106,17 +103,17 @@ const disabledTests = new Map(
],
}).map(([parser, tests]) => [
parser,
new Set(tests.map((file) => path.join(__dirname, "../format", file))),
new Set(tests.map((file) => path.join(FORMAT_TEST_DIRECTORY, file))),
]),
);
const isUnstable = (filename, options) =>
unstableTests.get(filename)?.(options);
const isUnstable = (filepath, options) =>
unstableTests.get(filepath)?.(options);
const isAstUnstable = (filename, options) =>
unstableAstTests.get(filename)?.(options);
const isAstUnstable = (filepath, options) =>
unstableAstTests.get(filepath)?.(options);
const shouldDisable = (dirnameOrFilename, parser) =>
disabledTests.get(parser)?.has(dirnameOrFilename);
const shouldDisable = (directoryOrFile, parser) =>
disabledTests.get(parser)?.has(directoryOrFile);
export { isAstUnstable, isUnstable, shouldDisable };
+81
View File
@@ -0,0 +1,81 @@
import fs from "node:fs";
import path from "node:path";
import { FORMAT_SCRIPT_FILENAME } from "./constants.js";
import { verifyFilename } from "./verify-fixtures.js";
import visualizeEndOfLine from "./visualize-end-of-line.js";
/**
@import {Context} from "./run-format-test.js"
@typedef {Exclude<ReturnType<ReturnType<getFixtures>["next"]>["value"], void>} Fixture
*/
/**
@param {Context} context
*/
function* getFixtures(context) {
yield* getFiles(context);
yield* getSnippets(context);
}
/**
@param {Context} context
*/
function* getFiles(context) {
const { dirname } = context;
for (const file of fs.readdirSync(dirname, { withFileTypes: true })) {
const filename = file.name;
const filepath = path.join(dirname, filename);
if (
!file.isFile() ||
filename[0] === "." ||
filename === FORMAT_SCRIPT_FILENAME ||
// VSCode creates this file sometime https://github.com/microsoft/vscode/issues/105191
filename === "debug.log" ||
path.extname(filename) === ".snap"
) {
continue;
}
const text = fs.readFileSync(filepath, "utf8");
verifyFilename(context, filename);
yield {
context,
name: filename,
filename,
filepath,
code: text,
};
}
}
/**
@param {Context} context
*/
function* getSnippets(context) {
for (const [index, snippet] of context.snippets.entries()) {
const testCase =
typeof snippet === "string"
? { code: snippet, context }
: { ...snippet, context };
if (typeof testCase.code !== "string") {
throw Object.assign(new Error("Invalid test"), { snippet });
}
if (typeof testCase.output === "string") {
testCase.output = visualizeEndOfLine(testCase.output);
}
testCase.name = `snippet: ${testCase.name || `#${index}`}`;
if (typeof testCase.filename === "string") {
testCase.filepath = path.join(context.dirname, testCase.filename);
}
yield testCase;
}
}
export { getFixtures };
+38
View File
@@ -0,0 +1,38 @@
import { shouldDisable } from "./failed-format-tests.js";
import { isErrorTest, isLanguage } from "./utilities.js";
function getParsers(dirname, parsers) {
const allParsers = [...parsers];
const addParsers = isErrorTest(dirname)
? () => {}
: (...parsers) => {
for (const parser of parsers) {
if (!allParsers.includes(parser) && !shouldDisable(dirname, parser)) {
allParsers.push(parser);
}
}
};
if (
parsers.includes("babel") &&
(isLanguage(dirname, "js") || isLanguage(dirname, "jsx"))
) {
addParsers("acorn", "espree", "meriyah", "oxc");
}
if (parsers.includes("typescript")) {
addParsers("babel-ts", "oxc-ts");
}
if (parsers.includes("flow")) {
addParsers("hermes");
}
if (parsers.includes("babel")) {
addParsers("__babel_estree");
}
return allParsers;
}
export { getParsers };
+1
View File
@@ -0,0 +1 @@
export { runFormatTest } from "./run-format-test.js";
@@ -0,0 +1,41 @@
import {
CURSOR_PLACEHOLDER,
RANGE_END_PLACEHOLDER,
RANGE_START_PLACEHOLDER,
} from "./constants.js";
const indexProperties = [
{
property: "cursorOffset",
placeholder: CURSOR_PLACEHOLDER,
},
{
property: "rangeStart",
placeholder: RANGE_START_PLACEHOLDER,
},
{
property: "rangeEnd",
placeholder: RANGE_END_PLACEHOLDER,
},
];
function replacePlaceholders(originalText, originalOptions) {
const indexes = indexProperties
.map(({ property, placeholder }) => {
const value = originalText.indexOf(placeholder);
return value === -1 ? undefined : { property, value, placeholder };
})
.filter(Boolean)
.sort((a, b) => a.value - b.value);
const options = { ...originalOptions };
let text = originalText;
let offset = 0;
for (const { property, value, placeholder } of indexes) {
text = text.replace(placeholder, "");
options[property] = value + offset;
offset -= placeholder.length;
}
return { text, options };
}
export { replacePlaceholders };
@@ -0,0 +1,84 @@
import path from "node:path";
import url from "node:url";
import { FORMAT_SCRIPT_FILENAME } from "./constants.js";
import { getFixtures } from "./get-fixtures.js";
import { getParsers } from "./get-parsers.js";
import { testFixture } from "./run-test.js";
import { stringifyOptionsForTitle } from "./stringify-options-for-title.js";
import { isErrorTest, normalizeDirectory } from "./utilities.js";
import { verifyParsers } from "./verify-fixtures.js";
/**
@typedef {
| string
| { code: string; name?: string; filename?: string; output?: string }
} Snippet
@typedef {{
dirname: string,
stringifiedOptions: string,
parsers: string[],
options: any,
explicitParsers: string[],
rawOptions: any,
snippets: Snippet[],
}} Context
*/
/**
@param {
| ImportMeta
| { importMeta: ImportMeta, snippets?: Snippet[] }
} rawFixtures
@param {string[]} explicitParsers
@param {any} rawOptions
*/
function runFormatTest(rawFixtures, explicitParsers, rawOptions) {
const { importMeta, snippets = [] } = rawFixtures.importMeta
? rawFixtures
: { importMeta: rawFixtures };
const filename = path.basename(new URL(importMeta.url).pathname);
if (filename !== FORMAT_SCRIPT_FILENAME) {
throw new Error(
`Format test should run in file named '${FORMAT_SCRIPT_FILENAME}'.`,
);
}
const dirname = normalizeDirectory(
path.dirname(url.fileURLToPath(importMeta.url)),
);
let options = {
printWidth: 80,
...rawOptions,
};
// `IS_ERROR_TEST` mean to watch errors like:
// - syntax parser hasn't supported yet
// - syntax errors that should throws
const IS_ERROR_TEST = isErrorTest(dirname);
if (IS_ERROR_TEST) {
options = { errors: true, ...options };
}
// Make sure tests are in correct location
const context = {
dirname,
stringifiedOptions: stringifyOptionsForTitle(rawOptions),
parsers: getParsers(dirname, explicitParsers),
options,
explicitParsers,
rawOptions,
snippets,
};
verifyParsers(context);
for (const fixture of getFixtures(context)) {
testFixture(fixture);
}
}
export { runFormatTest };
+108
View File
@@ -0,0 +1,108 @@
import fs from "node:fs";
import getPrettier from "../get-prettier.js";
import {
CURSOR_PLACEHOLDER,
isProduction,
TEST_RUNTIME,
TEST_STANDALONE,
} from "./constants.js";
import { replacePlaceholders } from "./replace-placeholders.js";
import { ensurePromise } from "./utilities.js";
import visualizeEndOfLine from "./visualize-end-of-line.js";
async function parse(source, options) {
const prettier = await getPrettier();
const { ast } = await ensurePromise(
prettier.__debug.parse(source, await loadPlugins(options), {
massage: true,
}),
);
return ast;
}
async function format(originalText, originalOptions) {
const { text: input, options } = replacePlaceholders(
originalText,
originalOptions,
);
const inputWithCursor = insertCursor(input, options.cursorOffset);
const prettier = await getPrettier();
const { formatted: output, cursorOffset } = await ensurePromise(
prettier.formatWithCursor(input, await loadPlugins(options)),
);
const outputWithCursor = insertCursor(output, cursorOffset);
const eolVisualizedOutput = visualizeEndOfLine(outputWithCursor);
const changed = outputWithCursor !== inputWithCursor;
return {
changed,
options,
input,
inputWithCursor,
output,
outputWithCursor,
eolVisualizedOutput,
};
}
const insertCursor = (text, cursorOffset) =>
cursorOffset >= 0
? text.slice(0, cursorOffset) +
CURSOR_PLACEHOLDER +
text.slice(cursorOffset)
: text;
// Move this to prettier installation
let externalParsers;
async function loadPlugins(options) {
if (!isProduction || !options.parser || TEST_RUNTIME === "browser") {
return options;
}
const { parser } = options;
if (!externalParsers) {
externalParsers = getExternalPlugins();
}
externalParsers = await externalParsers;
if (!externalParsers.has(parser)) {
return options;
}
const plugin = externalParsers.get(parser);
const plugins = options.plugins ?? [];
plugins.push(TEST_STANDALONE ? plugin.implementation : plugin.url);
return { ...options, plugins };
}
async function getExternalPlugins() {
const distDirectory = new URL("../../../dist/", import.meta.url);
const directories = fs.readdirSync(distDirectory);
const plugins = await Promise.all(
directories
.filter((directory) => directory.startsWith("plugin-"))
.map(async (directory) => {
const url = new URL(`./${directory}/index.mjs`, distDirectory);
const implementation = await import(url);
return {
url,
implementation,
};
}),
);
const externalParsers = new Map();
for (const plugin of plugins) {
const { parsers } = plugin.implementation;
if (parsers) {
for (const parser of Object.keys(parsers)) {
externalParsers.set(parser, plugin);
}
}
}
return externalParsers;
}
export { format, parse };
+210
View File
@@ -0,0 +1,210 @@
import consistentEndOfLine from "./consistent-end-of-line.js";
import { BOM, FULL_TEST } from "./constants.js";
import createSnapshot from "./create-snapshot.js";
import * as failedTests from "./failed-format-tests.js";
import { format, parse } from "./run-prettier.js";
import { shouldThrowOnFormat } from "./utilities.js";
import visualizeEndOfLine from "./visualize-end-of-line.js";
/**
@import {Fixture} from "./get-fixtures.js"
@typedef {ReturnType<getTestCase>} TestCase
*/
/**
@param {Fixture} fixture
*/
function testFixture(fixture) {
const { name, context, filepath } = fixture;
const { stringifiedOptions, parsers } = context;
const title = `${name}${
stringifiedOptions ? ` - ${stringifiedOptions}` : ""
}`;
const testCases = parsers
.filter((parser) => !failedTests.shouldDisable(filepath, parser))
.map((parser) => getTestCase(fixture, parser));
const testCaseForSnapshot = testCases.find(
(testCase) =>
!testCase.expectFail && typeof testCase.expectedOutput !== "string",
);
const hasMultipleParsers = testCases.length > 1;
describe(title, () => {
for (const testCase of testCases) {
let testTitle = "format";
if (testCaseForSnapshot !== testCase && hasMultipleParsers) {
testTitle += `[${testCase.parser}]`;
}
test(testTitle, async () => {
await runTest(testCase, testCaseForSnapshot);
});
}
});
}
/**
@param {Fixture} fixture
@param {string} parser
*/
function getTestCase(fixture, parser) {
const { code, context, filepath } = fixture;
const { options } = context;
const formatOptions = { filepath, ...options, parser };
const expectFail = shouldThrowOnFormat(fixture, options, parser);
/** @type {ReturnType<format> | undefined} */
let promise;
return {
context,
parser,
filepath,
code,
formatOptions,
expectFail,
expectedOutput: fixture.output,
runFormat: () =>
promise === undefined ? (promise = format(code, formatOptions)) : promise,
};
}
/**
@param {TestCase} testCase
@param {TestCase} testCaseForSnapshot
*/
async function runTest(testCase, testCaseForSnapshot) {
if (testCase.expectFail) {
await expect(testCase.runFormat).rejects.toThrowErrorMatchingSnapshot();
return;
}
const formatResult = await testCase.runFormat();
expect(formatResult).toBeDefined();
// Make sure output has consistent EOL
expect(formatResult.eolVisualizedOutput).toBe(
visualizeEndOfLine(consistentEndOfLine(formatResult.outputWithCursor)),
);
if (typeof testCase.expectedOutput === "string") {
expect(formatResult.eolVisualizedOutput).toBe(testCase.expectedOutput);
} else if (testCase === testCaseForSnapshot) {
expect(
createSnapshot(formatResult, {
parsers: testCase.context.explicitParsers,
formatOptions: testCase.formatOptions,
}),
).toMatchSnapshot();
} else {
const { eolVisualizedOutput } = await testCaseForSnapshot.runFormat();
expect(formatResult.eolVisualizedOutput).toStrictEqual(eolVisualizedOutput);
}
if (!FULL_TEST) {
return;
}
const { filepath, code, formatOptions } = testCase;
// Some parsers skip parsing empty files
if (formatResult.changed && code.trim()) {
const { input, output } = formatResult;
const [originalAst, formattedAst] = await Promise.all(
[input, output].map((code) => parse(code, formatOptions)),
);
const isAstUnstableTest = failedTests.isAstUnstable(
filepath,
formatOptions,
);
if (isAstUnstableTest) {
expect(formattedAst).not.toStrictEqual(originalAst);
} else {
expect(formattedAst).toStrictEqual(originalAst);
}
}
// The following cases should only run on main parser
if (testCase === testCaseForSnapshot) {
return;
}
const isUnstableTest = failedTests.isUnstable(filepath, formatOptions);
if (
(formatResult.changed || isUnstableTest) &&
// No range and cursor
formatResult.input === code
) {
const { eolVisualizedOutput: firstOutput, output } = formatResult;
const { eolVisualizedOutput: secondOutput } = await format(
output,
formatOptions,
);
if (isUnstableTest) {
if (secondOutput === firstOutput) {
throw new Error(
`Unstable file '${filepath}' is stable now, please remove from the 'unstableTests' list.`,
);
}
// To keep eye on failed tests, this assert never supposed to pass,
// if it fails, just remove the file from `unstableTests`
expect(secondOutput).not.toBe(firstOutput);
} else {
expect(secondOutput).toBe(firstOutput);
}
}
if (!shouldSkipEolTest(code, formatResult.options)) {
await Promise.all(
["\r\n", "\r"].map(async (eol) => {
const { eolVisualizedOutput: output } = await format(
code.replace(/\n/g, eol),
formatOptions,
);
// Only if `endOfLine: "auto"` the result will be different
const expected =
formatOptions.endOfLine === "auto"
? visualizeEndOfLine(
// All `code` use `LF`, so the `eol` of result is always `LF`
formatResult.outputWithCursor.replace(/\n/g, eol),
)
: formatResult.eolVisualizedOutput;
expect(output).toBe(expected);
}),
);
}
if (code.charAt(0) !== BOM) {
const { eolVisualizedOutput: output } = await format(
BOM + code,
formatOptions,
);
const expected = BOM + formatResult.eolVisualizedOutput;
expect(output).toBe(expected);
}
}
function shouldSkipEolTest(code, options) {
if (code.includes("\r")) {
return true;
}
const { requirePragma, checkIgnorePragma, rangeStart, rangeEnd } = options;
if (requirePragma || checkIgnorePragma) {
return true;
}
if (
typeof rangeStart === "number" &&
typeof rangeEnd === "number" &&
rangeStart >= rangeEnd
) {
return true;
}
return false;
}
export { testFixture };
@@ -1,4 +1,4 @@
function stringifyOptions(options) {
function stringifyOptionsForTitle(options) {
const string = JSON.stringify(options || {}, (key, value) => {
if (key === "plugins" || key === "errors") {
return;
@@ -14,4 +14,4 @@ function stringifyOptions(options) {
return string === "{}" ? "" : string;
}
export default stringifyOptions;
export { stringifyOptionsForTitle };
+48
View File
@@ -0,0 +1,48 @@
import path from "node:path";
import { FORMAT_TEST_DIRECTORY, TEST_STANDALONE } from "./constants.js";
const normalizeDirectory = (directory) => path.normalize(directory + path.sep);
const isLanguage = (dirname, language) =>
normalizeDirectory(dirname).startsWith(
normalizeDirectory(path.join(FORMAT_TEST_DIRECTORY, language)),
);
const isErrorTest = (dirname) =>
normalizeDirectory(dirname).includes(`${path.sep}_errors_${path.sep}`);
const ensurePromise = (value) => {
const isPromise = TEST_STANDALONE
? // In standalone test, promise is from another context
Object.prototype.toString.call(value) === "[object Promise]"
: value instanceof Promise;
if (!isPromise) {
throw new TypeError("Expected value to be a 'Promise'.");
}
return value;
};
const shouldThrowOnFormat = ({ filename }, options, parser) => {
const { errors = {} } = options;
if (errors === true) {
return true;
}
const files = errors[parser];
if (files === true || (Array.isArray(files) && files.includes(filename))) {
return true;
}
return false;
};
export {
ensurePromise,
isErrorTest,
isLanguage,
normalizeDirectory,
shouldThrowOnFormat,
};
@@ -1,13 +1,12 @@
import path from "node:path";
import createEsmUtils from "esm-utils";
import { outdent } from "outdent";
import { FORMAT_TEST_DIRECTORY, FULL_TEST } from "./constants.js";
const { __dirname, __filename } = createEsmUtils(import.meta);
const TESTS_ROOT = path.join(__dirname, "../../format");
const { __filename } = createEsmUtils(import.meta);
const getCategory = (dirname) =>
path.relative(TESTS_ROOT, dirname).split(path.sep).shift();
path.relative(FORMAT_TEST_DIRECTORY, dirname).split(path.sep).shift();
const categoryParsers = new Map([
[
@@ -166,19 +165,28 @@ const getParserCategories = (parser) => {
return categories;
};
const checkParser = ({ dirname, files }, parsers = []) => {
const category = getCategory(dirname);
const categoryAllowedParsers = categoryParsers.get(category);
const verifyParsers = (context) => {
if (!FULL_TEST) {
return;
}
if (!categoryAllowedParsers) {
const { explicitParsers: parsers, dirname } = context;
if (!Array.isArray(parsers) || parsers.length === 0) {
throw new Error(`No parsers were specified for ${dirname}`);
}
const category = getCategory(dirname);
const settings = categoryParsers.get(category);
if (!settings) {
return;
}
const {
parsers: allowedParsers = [],
verifyParsers: allowedVerifyParsers = [],
extensions = [],
} = categoryAllowedParsers;
} = settings;
const [parser, ...verifyParsers] = parsers;
@@ -199,7 +207,9 @@ const checkParser = ({ dirname, files }, parsers = []) => {
: outdent`
Suggest move your tests to:
${suggestCategories
.map((category) => `- ${path.join(TESTS_ROOT, category)}`)
.map(
(category) => `- ${path.join(FORMAT_TEST_DIRECTORY, category)}`,
)
.join("\n")}
Or config to allow use this parser in "${__filename}".
@@ -224,19 +234,34 @@ const checkParser = ({ dirname, files }, parsers = []) => {
}
}
}
};
for (const { name, filename } of files) {
const ext = path.extname(filename);
if (!extensions.includes(ext)) {
throw new Error(
outdent`
File "${name}" should not tested in "${dirname}".
Allowed extensions: ${extensions.join(",")}.
Please rename it or config to allow test "${ext}" file in "${__filename}".
`,
);
}
const verifyFilename = (context, filename) => {
if (!FULL_TEST) {
return;
}
const { dirname } = context;
const category = getCategory(dirname);
const settings = categoryParsers.get(category);
if (!settings) {
return;
}
const { extensions = [] } = settings;
const ext = path.extname(filename);
if (!extensions.includes(ext)) {
throw new Error(
outdent`
File "${filename}" should not tested in "${dirname}".
Allowed extensions: ${extensions.join(",")}.
Please rename it or config to allow test "${ext}" file in "${__filename}".
`,
);
}
};
export default checkParser;
export { verifyFilename, verifyParsers };
-505
View File
@@ -1,505 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
import createEsmUtils from "esm-utils";
import * as failedTests from "./failed-format-tests.js";
import getPrettier from "./get-prettier.js";
import checkParsers from "./utilities/check-parsers.js";
import consistentEndOfLine from "./utilities/consistent-end-of-line.js";
import createSnapshot from "./utilities/create-snapshot.js";
import stringifyOptionsForTitle from "./utilities/stringify-options-for-title.js";
import visualizeEndOfLine from "./utilities/visualize-end-of-line.js";
const { __dirname } = createEsmUtils(import.meta);
const { FULL_TEST, TEST_STANDALONE, NODE_ENV, TEST_RUNTIME } = process.env;
const isProduction = NODE_ENV === "production";
const BOM = "\uFEFF";
const CURSOR_PLACEHOLDER = "<|>";
const RANGE_START_PLACEHOLDER = "<<<PRETTIER_RANGE_START>>>";
const RANGE_END_PLACEHOLDER = "<<<PRETTIER_RANGE_END>>>";
const shouldThrowOnFormat = (filename, options, parser) => {
const { errors = {} } = options;
if (errors === true) {
return true;
}
const files = errors[parser];
if (files === true || (Array.isArray(files) && files.includes(filename))) {
return true;
}
return false;
};
const isTestDirectory = (dirname, name) =>
(dirname + path.sep).startsWith(
path.join(__dirname, "../format", name) + path.sep,
);
const ensurePromise = (value) => {
const isPromise = TEST_STANDALONE
? // In standalone test, promise is from another context
Object.prototype.toString.call(value) === "[object Promise]"
: value instanceof Promise;
if (!isPromise) {
throw new TypeError("Expected value to be a 'Promise'.");
}
return value;
};
function runFormatTest(fixtures, parsers, options = {}) {
let { importMeta, snippets = [] } = fixtures.importMeta
? fixtures
: { importMeta: fixtures };
const filename = path.basename(new URL(importMeta.url).pathname);
if (filename !== "format.test.js") {
throw new Error(`Format test should run in file named 'format.test.js'.`);
}
const dirname = path.normalize(
path.dirname(url.fileURLToPath(importMeta.url)) + path.sep,
);
// `IS_ERROR_TEST` mean to watch errors like:
// - syntax parser hasn't supported yet
// - syntax errors that should throws
const IS_ERROR_TEST = dirname.includes(`${path.sep}_errors_${path.sep}`);
if (IS_ERROR_TEST) {
options = { errors: true, ...options };
}
snippets = snippets.map((test, index) => {
test = typeof test === "string" ? { code: test } : test;
if (typeof test.code !== "string") {
throw Object.assign(new Error("Invalid test"), { test });
}
return {
...test,
name: `snippet: ${test.name || `#${index}`}`,
};
});
const files = fs
.readdirSync(dirname, { withFileTypes: true })
.map((file) => {
const basename = file.name;
const filename = path.join(dirname, basename);
if (
path.extname(basename) === ".snap" ||
!file.isFile() ||
basename[0] === "." ||
basename === "format.test.js" ||
// VSCode creates this file sometime https://github.com/microsoft/vscode/issues/105191
basename === "debug.log"
) {
return;
}
const text = fs.readFileSync(filename, "utf8");
return {
name: basename,
filename,
code: text,
};
})
.filter(Boolean);
// Make sure tests are in correct location
if (process.env.CHECK_TEST_PARSERS) {
if (!Array.isArray(parsers) || parsers.length === 0) {
throw new Error(`No parsers were specified for ${dirname}`);
}
checkParsers({ dirname, files }, parsers);
}
const allParsers = [...parsers];
const addParsers = (...parsers) => {
if (IS_ERROR_TEST) {
return;
}
for (const parser of parsers) {
if (
!allParsers.includes(parser) &&
!failedTests.shouldDisable(dirname, parser)
) {
allParsers.push(parser);
}
}
};
if (
parsers.includes("babel") &&
(isTestDirectory(dirname, "js") || isTestDirectory(dirname, "jsx"))
) {
addParsers("acorn", "espree", "meriyah", "oxc");
}
if (parsers.includes("typescript")) {
addParsers("babel-ts", "oxc-ts");
}
if (parsers.includes("flow")) {
addParsers("hermes");
}
if (parsers.includes("babel")) {
addParsers("__babel_estree");
}
const stringifiedOptions = stringifyOptionsForTitle(options);
for (const { name, filename, code, output } of [...files, ...snippets]) {
const title = `${name}${
stringifiedOptions ? ` - ${stringifiedOptions}` : ""
}`;
describe(title, () => {
const basicOptions = {
printWidth: 80,
filepath: filename,
...options,
};
const testCases = allParsers
.filter((parser) => !failedTests.shouldDisable(filename, parser))
.map((parser) => {
const formatOptions = { ...basicOptions, parser };
const expectFail = shouldThrowOnFormat(name, options, parser);
return {
parser,
explicitParsers: parsers,
code,
formatOptions,
expectFail,
expectedOutput: output,
runFormat: () => format(code, formatOptions),
};
});
const testCaseForSnapshot = testCases.find(
(testCase) =>
!testCase.expectFail && typeof testCase.expectedOutput !== "string",
);
beforeAll(async () => {
await Promise.all(
testCases.map(async (test) => {
if (test.expectFail) {
return;
}
try {
test.formatResult = await test.runFormat();
} catch (error) {
test.error = error;
}
}),
);
});
const hasMultipleParsers = testCases.length > 1;
for (const testCase of testCases) {
const testTitle = `format${testCaseForSnapshot !== testCase && hasMultipleParsers ? `[${testCase.parser}]` : ""}`;
test(testTitle, async () => {
await runTest({
filename,
code,
output,
result: testCase,
testCaseForSnapshot,
});
});
}
});
}
}
async function runTest({
filename,
code,
output,
result,
testCaseForSnapshot,
}) {
if (result.expectFail) {
await expect(result.runFormat).rejects.toThrowErrorMatchingSnapshot();
return;
}
const { error, formatResult } = result;
if (!formatResult) {
throw error;
}
expect(formatResult).toBeDefined();
// Make sure output has consistent EOL
expect(formatResult.eolVisualizedOutput).toBe(
visualizeEndOfLine(consistentEndOfLine(formatResult.outputWithCursor)),
);
if (typeof result.expectedOutput === "string") {
expect(formatResult.eolVisualizedOutput).toBe(visualizeEndOfLine(output));
} else {
expect(testCaseForSnapshot).toBeDefined();
expect(testCaseForSnapshot.formatResult).toBeDefined();
if (testCaseForSnapshot === result) {
expect(
createSnapshot(testCaseForSnapshot.formatResult, {
parsers: testCaseForSnapshot.explicitParsers,
formatOptions: testCaseForSnapshot.formatOptions,
CURSOR_PLACEHOLDER,
}),
).toMatchSnapshot();
} else {
expect(formatResult.eolVisualizedOutput).toStrictEqual(
testCaseForSnapshot.formatResult.eolVisualizedOutput,
);
}
}
if (!FULL_TEST) {
return;
}
const { formatOptions } = result;
// Some parsers skip parsing empty files
if (formatResult.changed && code.trim()) {
const { input, output } = formatResult;
const [originalAst, formattedAst] = await Promise.all(
[input, output].map((code) => parse(code, formatOptions)),
);
const isAstUnstableTest = failedTests.isAstUnstable(
filename,
formatOptions,
);
if (isAstUnstableTest) {
expect(formattedAst).not.toStrictEqual(originalAst);
} else {
expect(formattedAst).toStrictEqual(originalAst);
}
}
if (testCaseForSnapshot === result) {
return;
}
const isUnstableTest = failedTests.isUnstable(filename, formatOptions);
if (
(formatResult.changed || isUnstableTest) &&
// No range and cursor
formatResult.input === code
) {
const { eolVisualizedOutput: firstOutput, output } = formatResult;
const { eolVisualizedOutput: secondOutput } = await format(
output,
formatOptions,
);
if (isUnstableTest) {
if (secondOutput === firstOutput) {
throw new Error(
`Unstable file '${filename}' is stable now, please remove from the 'unstableTests' list.`,
);
}
// To keep eye on failed tests, this assert never supposed to pass,
// if it fails, just remove the file from `unstableTests`
expect(secondOutput).not.toBe(firstOutput);
} else {
expect(secondOutput).toBe(firstOutput);
}
}
if (!shouldSkipEolTest(code, formatResult.options)) {
await Promise.all(
["\r\n", "\r"].map(async (eol) => {
const { eolVisualizedOutput: output } = await format(
code.replace(/\n/g, eol),
formatOptions,
);
// Only if `endOfLine: "auto"` the result will be different
const expected =
formatOptions.endOfLine === "auto"
? visualizeEndOfLine(
// All `code` use `LF`, so the `eol` of result is always `LF`
formatResult.outputWithCursor.replace(/\n/g, eol),
)
: formatResult.eolVisualizedOutput;
expect(output).toBe(expected);
}),
);
}
if (code.charAt(0) !== BOM) {
const { eolVisualizedOutput: output } = await format(
BOM + code,
formatOptions,
);
const expected = BOM + formatResult.eolVisualizedOutput;
expect(output).toBe(expected);
}
}
function shouldSkipEolTest(code, options) {
if (code.includes("\r")) {
return true;
}
const { requirePragma, checkIgnorePragma, rangeStart, rangeEnd } = options;
if (requirePragma || checkIgnorePragma) {
return true;
}
if (
typeof rangeStart === "number" &&
typeof rangeEnd === "number" &&
rangeStart >= rangeEnd
) {
return true;
}
return false;
}
async function parse(source, options) {
const prettier = await getPrettier();
const { ast } = await ensurePromise(
prettier.__debug.parse(source, await loadPlugins(options), {
massage: true,
}),
);
return ast;
}
const indexProperties = [
{
property: "cursorOffset",
placeholder: CURSOR_PLACEHOLDER,
},
{
property: "rangeStart",
placeholder: RANGE_START_PLACEHOLDER,
},
{
property: "rangeEnd",
placeholder: RANGE_END_PLACEHOLDER,
},
];
function replacePlaceholders(originalText, originalOptions) {
const indexes = indexProperties
.map(({ property, placeholder }) => {
const value = originalText.indexOf(placeholder);
return value === -1 ? undefined : { property, value, placeholder };
})
.filter(Boolean)
.sort((a, b) => a.value - b.value);
const options = { ...originalOptions };
let text = originalText;
let offset = 0;
for (const { property, value, placeholder } of indexes) {
text = text.replace(placeholder, "");
options[property] = value + offset;
offset -= placeholder.length;
}
return { text, options };
}
const insertCursor = (text, cursorOffset) =>
cursorOffset >= 0
? text.slice(0, cursorOffset) +
CURSOR_PLACEHOLDER +
text.slice(cursorOffset)
: text;
async function format(originalText, originalOptions) {
const { text: input, options } = replacePlaceholders(
originalText,
originalOptions,
);
const inputWithCursor = insertCursor(input, options.cursorOffset);
const prettier = await getPrettier();
const { formatted: output, cursorOffset } = await ensurePromise(
prettier.formatWithCursor(input, await loadPlugins(options)),
);
const outputWithCursor = insertCursor(output, cursorOffset);
const eolVisualizedOutput = visualizeEndOfLine(outputWithCursor);
const changed = outputWithCursor !== inputWithCursor;
return {
changed,
options,
input,
inputWithCursor,
output,
outputWithCursor,
eolVisualizedOutput,
};
}
async function getExternalPlugins() {
const distDirectory = new URL("../../dist/", import.meta.url);
const directories = fs.readdirSync(distDirectory);
const plugins = await Promise.all(
directories
.filter((directory) => directory.startsWith("plugin-"))
.map(async (directory) => {
const url = new URL(`./${directory}/index.mjs`, distDirectory);
const implementation = await import(url);
return {
url,
implementation,
};
}),
);
const externalParsers = new Map();
for (const plugin of plugins) {
const { parsers } = plugin.implementation;
if (parsers) {
for (const parser of Object.keys(parsers)) {
externalParsers.set(parser, plugin);
}
}
}
return externalParsers;
}
let externalParsers;
async function loadPlugins(options) {
if (!isProduction || !options.parser || TEST_RUNTIME === "browser") {
return options;
}
const { parser } = options;
if (!externalParsers) {
externalParsers = getExternalPlugins();
}
externalParsers = await externalParsers;
if (!externalParsers.has(parser)) {
return options;
}
const plugin = externalParsers.get(parser);
const plugins = options.plugins ?? [];
plugins.push(TEST_STANDALONE ? plugin.implementation : plugin.url);
return { ...options, plugins };
}
export default runFormatTest;