diff --git a/tests/config/format-test-setup.js b/tests/config/format-test-setup.js index 98222ffc2..56fb206d2 100644 --- a/tests/config/format-test-setup.js +++ b/tests/config/format-test-setup.js @@ -1,3 +1,3 @@ -import runFormatTest from "./run-format-test.js"; +import { runFormatTest } from "./format-test/index.js"; globalThis.runFormatTest = runFormatTest; diff --git a/tests/config/utilities/consistent-end-of-line.js b/tests/config/format-test/consistent-end-of-line.js similarity index 100% rename from tests/config/utilities/consistent-end-of-line.js rename to tests/config/format-test/consistent-end-of-line.js diff --git a/tests/config/format-test/constants.js b/tests/config/format-test/constants.js new file mode 100644 index 000000000..c2d45137f --- /dev/null +++ b/tests/config/format-test/constants.js @@ -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 = "<<>>"; +export const RANGE_END_PLACEHOLDER = "<<>>"; diff --git a/tests/config/utilities/create-snapshot.js b/tests/config/format-test/create-snapshot.js similarity index 95% rename from tests/config/utilities/create-snapshot.js rename to tests/config/format-test/create-snapshot.js index b8a217399..a33d881d8 100644 --- a/tests/config/utilities/create-snapshot.js +++ b/tests/config/format-test/create-snapshot.js @@ -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, diff --git a/tests/config/failed-format-tests.js b/tests/config/format-test/failed-format-tests.js similarity index 87% rename from tests/config/failed-format-tests.js rename to tests/config/format-test/failed-format-tests.js index 89e625830..e34ce86b6 100644 --- a/tests/config/failed-format-tests.js +++ b/tests/config/format-test/failed-format-tests.js @@ -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 }; diff --git a/tests/config/format-test/get-fixtures.js b/tests/config/format-test/get-fixtures.js new file mode 100644 index 000000000..2f0745c0c --- /dev/null +++ b/tests/config/format-test/get-fixtures.js @@ -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["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 }; diff --git a/tests/config/format-test/get-parsers.js b/tests/config/format-test/get-parsers.js new file mode 100644 index 000000000..2329cabd7 --- /dev/null +++ b/tests/config/format-test/get-parsers.js @@ -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 }; diff --git a/tests/config/format-test/index.js b/tests/config/format-test/index.js new file mode 100644 index 000000000..ee790c00c --- /dev/null +++ b/tests/config/format-test/index.js @@ -0,0 +1 @@ +export { runFormatTest } from "./run-format-test.js"; diff --git a/tests/config/format-test/replace-placeholders.js b/tests/config/format-test/replace-placeholders.js new file mode 100644 index 000000000..2a38b5c0c --- /dev/null +++ b/tests/config/format-test/replace-placeholders.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 }; diff --git a/tests/config/format-test/run-format-test.js b/tests/config/format-test/run-format-test.js new file mode 100644 index 000000000..c6c63e726 --- /dev/null +++ b/tests/config/format-test/run-format-test.js @@ -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 }; diff --git a/tests/config/format-test/run-prettier.js b/tests/config/format-test/run-prettier.js new file mode 100644 index 000000000..a97f26ac9 --- /dev/null +++ b/tests/config/format-test/run-prettier.js @@ -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 }; diff --git a/tests/config/format-test/run-test.js b/tests/config/format-test/run-test.js new file mode 100644 index 000000000..6adf76410 --- /dev/null +++ b/tests/config/format-test/run-test.js @@ -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} 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 | 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 }; diff --git a/tests/config/utilities/stringify-options-for-title.js b/tests/config/format-test/stringify-options-for-title.js similarity index 77% rename from tests/config/utilities/stringify-options-for-title.js rename to tests/config/format-test/stringify-options-for-title.js index 531fc0650..cae349ef7 100644 --- a/tests/config/utilities/stringify-options-for-title.js +++ b/tests/config/format-test/stringify-options-for-title.js @@ -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 }; diff --git a/tests/config/format-test/utilities.js b/tests/config/format-test/utilities.js new file mode 100644 index 000000000..d34b66490 --- /dev/null +++ b/tests/config/format-test/utilities.js @@ -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, +}; diff --git a/tests/config/utilities/check-parsers.js b/tests/config/format-test/verify-fixtures.js similarity index 78% rename from tests/config/utilities/check-parsers.js rename to tests/config/format-test/verify-fixtures.js index 58815cc28..04126e84d 100644 --- a/tests/config/utilities/check-parsers.js +++ b/tests/config/format-test/verify-fixtures.js @@ -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 }; diff --git a/tests/config/utilities/visualize-end-of-line.js b/tests/config/format-test/visualize-end-of-line.js similarity index 100% rename from tests/config/utilities/visualize-end-of-line.js rename to tests/config/format-test/visualize-end-of-line.js diff --git a/tests/config/utilities/visualize-range.js b/tests/config/format-test/visualize-range.js similarity index 100% rename from tests/config/utilities/visualize-range.js rename to tests/config/format-test/visualize-range.js diff --git a/tests/config/run-format-test.js b/tests/config/run-format-test.js deleted file mode 100644 index ae6a2d684..000000000 --- a/tests/config/run-format-test.js +++ /dev/null @@ -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 = "<<>>"; -const RANGE_END_PLACEHOLDER = "<<>>"; - -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;