diff --git a/apps/oxlint/test/e2e.test.ts b/apps/oxlint/test/e2e.test.ts index 7fbeac806ec97..a74a20cd5d01f 100644 --- a/apps/oxlint/test/e2e.test.ts +++ b/apps/oxlint/test/e2e.test.ts @@ -1,265 +1,59 @@ -// oxlint-disable jest/expect-expect - -import fs from 'node:fs/promises'; import { join as pathJoin } from 'node:path'; import { describe, it } from 'vitest'; -import { FIXTURES_DIR_PATH, PACKAGE_ROOT_PATH, testFixtureWithCommand } from './utils.js'; +import { PACKAGE_ROOT_PATH, getFixtures, testFixtureWithCommand } from './utils.js'; + +import type { Fixture } from './utils.ts'; const CLI_PATH = pathJoin(PACKAGE_ROOT_PATH, 'dist/cli.js'); -// Options to pass to `testFixture`. -interface TestOptions { - // Arguments to pass to the CLI. - args?: string[]; - // Name of the snapshot file. - // Defaults to `output`. - // Supply a different name when there are multiple tests for a single fixture. - snapshotName?: string; - // Function to get extra data to include in the snapshot - getExtraSnapshotData?: (dirPath: string) => Promise>; -} +// Use current NodeJS executable, rather than `node`, to avoid problems with a Node version manager +// installed on system resulting in using wrong NodeJS version +const NODE_BIN_PATH = process.execPath; /** - * Run a test fixture. - * @param fixtureName - Name of the fixture directory within `test/fixtures` - * @param options - Options to customize the test (optional) + * Run Oxlint tests for all fixtures in `test/fixtures`. + * + * Oxlint is run with: + * - CWD set to the fixture directory. + * - `files` as the only argument (so only lints the files in the fixture's `files` directory). + * + * Fixtures with an `options.json` file containing `"fix": true` are also run with `--fix` CLI option. + * The files' contents after fixes are recorded in the snapshot. + * + * Fixtures with an `options.json` file containing `"oxlint": false` are skipped. */ -async function testFixture(fixtureName: string, options?: TestOptions): Promise { - const args = options?.args ?? []; - - await testFixtureWithCommand({ - // Use current NodeJS executable, rather than `node`, to avoid problems with a Node version manager - // installed on system resulting in using wrong NodeJS version - command: process.execPath, - args: [CLI_PATH, ...args, 'files'], - fixtureName, - snapshotName: options?.snapshotName ?? 'output', - getExtraSnapshotData: options?.getExtraSnapshotData, - isESLint: false, - }); -} - describe('oxlint CLI', () => { - it('should lint a directory without errors', async () => { - await testFixture('built_in_no_errors'); - }); - - it('should lint a directory with errors', async () => { - await testFixture('built_in_errors'); - }); - - it('should load a custom plugin', async () => { - await testFixture('basic_custom_plugin'); - }); - - it('should support message placeholder interpolation', async () => { - await testFixture('message_interpolation'); - }); - - it('should support messageId', async () => { - await testFixture('message_id_plugin'); - }); - - it('should support messageId placeholder interpolation', async () => { - await testFixture('message_id_interpolation'); - }); - - it('should report an error for unknown messageId', async () => { - await testFixture('message_id_error'); - }); - - it('should load a custom plugin with various import styles', async () => { - await testFixture('load_paths'); - }); - - it('should load a custom plugin with multiple files', async () => { - await testFixture('basic_custom_plugin_many_files'); - }); - - it('should load a custom plugin correctly when extending in a nested config', async () => { - await testFixture('custom_plugin_nested_config'); - }); - it('should do something', async () => { - await testFixture('custom_plugin_nested_config_duplicate'); - }); - - it('should load a custom plugin when configured in overrides', async () => { - await testFixture('custom_plugin_via_overrides'); - }); - - it('should report an error if a custom plugin is missing', async () => { - await testFixture('missing_custom_plugin'); - }); - - it('should report an error if a custom plugin has a reserved name', async () => { - await testFixture('reserved_name'); - }); - - it('should report an error if a custom plugin throws an error during import', async () => { - await testFixture('custom_plugin_import_error'); - }); - - it('should report an error if a rule is not found within a custom plugin', async () => { - await testFixture('custom_plugin_missing_rule'); - }); - - it('should report an error if a a rule is not found within a custom plugin (via overrides)', async () => { - await testFixture('custom_plugin_via_overrides_missing_rule'); - }); - - describe('should report an error if a custom plugin throws an error during linting', () => { - it('in `create` method', async () => { - await testFixture('custom_plugin_lint_create_error'); - }); - - it('in `createOnce` method', async () => { - await testFixture('custom_plugin_lint_createOnce_error'); - }); - - it('in visit function', async () => { - await testFixture('custom_plugin_lint_visit_error'); - }); - - it('in `before` hook', async () => { - await testFixture('custom_plugin_lint_before_hook_error'); - }); - - it('in `after` hook', async () => { - await testFixture('custom_plugin_lint_after_hook_error'); - }); - - it('in `fix` function', async () => { - await testFixture('custom_plugin_lint_fix_error'); - }); - }); - - it('should report the correct severity when using a custom plugin', async () => { - await testFixture('basic_custom_plugin_warn_severity'); - }); + const fixtures = getFixtures(); + for (const fixture of fixtures) { + if (!fixture.options.oxlint) continue; - it('should work with multiple rules', async () => { - await testFixture('basic_custom_plugin_multiple_rules'); - }); - - it('should support reporting diagnostic with `loc`', async () => { - await testFixture('diagnostic_loc'); - }); - - it('should receive ESTree-compatible AST', async () => { - await testFixture('estree'); - }); - - it('should receive AST with all nodes having `parent` property', async () => { - await testFixture('parent'); - }); - - it('should receive data via `context`', async () => { - await testFixture('context_properties'); - }); - - it('should give access to source code via `context.sourceCode`', async () => { - await testFixture('sourceCode'); - }); - - it('should give access to settings via `context.settings`', async () => { - await testFixture('settings'); - }); - - it('should get source text and AST from `context.sourceCode` when accessed late', async () => { - await testFixture('sourceCode_late_access'); - }); - - it('should get source text and AST from `context.sourceCode` when accessed in `after` hook only', async () => { - await testFixture('sourceCode_late_access_after_only'); - }); - - it('should support scopeManager', async () => { - await testFixture('scope_manager'); - }); - - it('should support scope helper methods in `context.sourceCode`', async () => { - await testFixture('sourceCode_scope_methods'); - }); - - it('should support languageOptions', async () => { - await testFixture('languageOptions'); - }); - - it('should support selectors', async () => { - await testFixture('selector'); - }); - - it('should support `createOnce`', async () => { - await testFixture('createOnce'); - }); - - it('should support `definePlugin`', async () => { - await testFixture('definePlugin'); - }); - - it('should support `defineRule`', async () => { - await testFixture('defineRule'); - }); - - it('should support `definePlugin` and `defineRule` together', async () => { - await testFixture('definePlugin_and_defineRule'); - }); - - it('should have UTF-16 spans in AST', async () => { - await testFixture('utf16_offsets'); - }); + // oxlint-disable-next-line jest/expect-expect + it(`fixture: ${fixture.name}`, () => runFixture(fixture)); + } +}); - it('should respect disable directives for custom plugin rules', async () => { - await testFixture('custom_plugin_disable_directives'); +/** + * Run Oxlint on a test fixture. + * @param fixture - Fixture object + */ +async function runFixture(fixture: Fixture): Promise { + // Run Oxlint without `--fix` option + await testFixtureWithCommand({ + command: NODE_BIN_PATH, + args: [CLI_PATH, 'files'], + fixture, + snapshotName: 'output', + isESLint: false, }); - it('should not apply fixes when `--fix` is disabled', async () => { - await testFixture('fixes', { - snapshotName: 'fixes_disabled', - async getExtraSnapshotData(fixtureDirPath) { - const fixtureFilePath = pathJoin(fixtureDirPath, 'files/index.js'); - const codeAfter = await fs.readFile(fixtureFilePath, 'utf8'); - return { 'Code after': codeAfter }; - }, + // Run Oxlint with `--fix` option + if (fixture.options.fix) { + await testFixtureWithCommand({ + command: NODE_BIN_PATH, + args: [CLI_PATH, '--fix', 'files'], + fixture, + snapshotName: 'fix', + isESLint: false, }); - }); - - it('should apply fixes when `--fix` is enabled', async () => { - const fixtureFilePath = pathJoin(FIXTURES_DIR_PATH, 'fixes/files/index.js'); - const codeBefore = await fs.readFile(fixtureFilePath, 'utf8'); - - try { - await testFixture('fixes', { - args: ['--fix'], - snapshotName: 'fixes_enabled', - async getExtraSnapshotData() { - const codeAfter = await fs.readFile(fixtureFilePath, 'utf8'); - return { 'Code after': codeAfter }; - }, - }); - } finally { - // Revert fixture file code changes - await fs.writeFile(fixtureFilePath, codeBefore); - } - }); - - it('should support comments-related APIs in `context.sourceCode`', async () => { - await testFixture('comments'); - }); - - it('should support UTF16 characters in source code and comments with correct spans', async () => { - await testFixture('unicode_comments'); - }); - - it('should return empty object for `parserServices` without throwing', async () => { - await testFixture('parser_services'); - }); - - it('wrapping context should work', async () => { - await testFixture('context_wrapping'); - }); - - it('should support `isSpaceBetween` in `context.sourceCode`', async () => { - await testFixture('isSpaceBetween'); - }); -}); + } +} diff --git a/apps/oxlint/test/eslint-compat.test.ts b/apps/oxlint/test/eslint-compat.test.ts index 722319e51db7b..1ac780b0be60e 100644 --- a/apps/oxlint/test/eslint-compat.test.ts +++ b/apps/oxlint/test/eslint-compat.test.ts @@ -1,37 +1,39 @@ -// oxlint-disable jest/expect-expect - import { join as pathJoin } from 'node:path'; import { describe, it } from 'vitest'; -import { testFixtureWithCommand } from './utils.js'; +import { PACKAGE_ROOT_PATH, getFixtures, testFixtureWithCommand } from './utils.js'; + +import type { Fixture } from './utils.ts'; + +const ESLINT_PATH = pathJoin(PACKAGE_ROOT_PATH, 'node_modules/.bin/eslint'); + +/** + * Run ESLint tests for all fixtures in `test/fixtures` which contain an `options.json` file + * containing `"eslint": true`. + * + * ESLint is run with CWD set to the fixture directory. + */ +// These tests take longer than 5 seconds on CI, so increase timeout to 10 seconds +// oxlint-disable-next-line jest/valid-describe-callback +describe('ESLint compatibility', { timeout: 10_000 }, () => { + const fixtures = getFixtures(); + for (const fixture of fixtures) { + if (!fixture.options.eslint) continue; -const ESLINT_PATH = pathJoin(import.meta.dirname, '../node_modules/.bin/eslint'); + // oxlint-disable-next-line jest/expect-expect + it(`fixture: ${fixture.name}`, () => runFixture(fixture)); + } +}); /** * Run ESLint on a test fixture. - * @param fixtureName - Name of the fixture directory within `test/fixtures` + * @param fixture - Fixture object */ -async function testFixture(fixtureName: string): Promise { +async function runFixture(fixture: Fixture): Promise { await testFixtureWithCommand({ command: ESLINT_PATH, args: [], - fixtureName, + fixture, snapshotName: 'eslint', isESLint: true, }); } - -// These tests take longer than 5 seconds on CI, so increase timeout to 10 seconds -// oxlint-disable-next-line jest/valid-describe-callback -describe('ESLint compatibility', { timeout: 10_000 }, () => { - it('`definePlugin` should work', async () => { - await testFixture('definePlugin'); - }); - - it('`defineRule` should work', async () => { - await testFixture('defineRule'); - }); - - it('`definePlugin` and `defineRule` together should work', async () => { - await testFixture('definePlugin_and_defineRule'); - }); -}); diff --git a/apps/oxlint/test/fixtures/definePlugin/options.json b/apps/oxlint/test/fixtures/definePlugin/options.json new file mode 100644 index 0000000000000..f380a596b9d7a --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin/options.json @@ -0,0 +1,3 @@ +{ + "eslint": true +} diff --git a/apps/oxlint/test/fixtures/definePlugin_and_defineRule/options.json b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/options.json new file mode 100644 index 0000000000000..f380a596b9d7a --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/options.json @@ -0,0 +1,3 @@ +{ + "eslint": true +} diff --git a/apps/oxlint/test/fixtures/defineRule/options.json b/apps/oxlint/test/fixtures/defineRule/options.json new file mode 100644 index 0000000000000..f380a596b9d7a --- /dev/null +++ b/apps/oxlint/test/fixtures/defineRule/options.json @@ -0,0 +1,3 @@ +{ + "eslint": true +} diff --git a/apps/oxlint/test/fixtures/fixes/fixes_enabled.snap.md b/apps/oxlint/test/fixtures/fixes/fix.snap.md similarity index 93% rename from apps/oxlint/test/fixtures/fixes/fixes_enabled.snap.md rename to apps/oxlint/test/fixtures/fixes/fix.snap.md index 879ab429e1622..8da76ee3bc05d 100644 --- a/apps/oxlint/test/fixtures/fixes/fixes_enabled.snap.md +++ b/apps/oxlint/test/fixtures/fixes/fix.snap.md @@ -13,7 +13,7 @@ WARNING: JS plugins are experimental and not subject to semver. Breaking changes are possible while JS plugins support is under development. ``` -# Code after +# File altered: files/index.js ``` diff --git a/apps/oxlint/test/fixtures/fixes/options.json b/apps/oxlint/test/fixtures/fixes/options.json new file mode 100644 index 0000000000000..602385b3bf26d --- /dev/null +++ b/apps/oxlint/test/fixtures/fixes/options.json @@ -0,0 +1,3 @@ +{ + "fix": true +} diff --git a/apps/oxlint/test/fixtures/fixes/fixes_disabled.snap.md b/apps/oxlint/test/fixtures/fixes/output.snap.md similarity index 92% rename from apps/oxlint/test/fixtures/fixes/fixes_disabled.snap.md rename to apps/oxlint/test/fixtures/fixes/output.snap.md index b19e68a750baf..9576474b22f79 100644 --- a/apps/oxlint/test/fixtures/fixes/fixes_disabled.snap.md +++ b/apps/oxlint/test/fixtures/fixes/output.snap.md @@ -106,22 +106,3 @@ Finished in Xms on 1 file using X threads. WARNING: JS plugins are experimental and not subject to semver. Breaking changes are possible while JS plugins support is under development. ``` - -# Code after -``` -debugger; - -let a = 1; -let b = 2; -let c = 3; -let d = 4; -let e = 5; -let f = 6; -let g = 7; -let h = 8; -let i = 9; -let j = 10; - -debugger; - -``` diff --git a/apps/oxlint/test/utils.ts b/apps/oxlint/test/utils.ts index b284084f48ff7..c2a10ff8b234e 100644 --- a/apps/oxlint/test/utils.ts +++ b/apps/oxlint/test/utils.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs/promises'; +import { readdirSync, readFileSync } from 'node:fs'; import { join as pathJoin, sep as pathSep } from 'node:path'; import { execa } from 'execa'; @@ -7,25 +9,79 @@ import { expect } from 'vitest'; const normalizeSlashes = pathSep === '\\' ? (path: string) => path.replaceAll('\\', '/') : (path: string) => path; export const PACKAGE_ROOT_PATH = pathJoin(import.meta.dirname, '..'); // `/path/to/oxc/apps/oxlint` -export const FIXTURES_DIR_PATH = pathJoin(import.meta.dirname, 'fixtures'); // `/path/to/oxc/apps/oxlint/test/fixtures` +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` const FIXTURES_SUBPATH = normalizeSlashes(FIXTURES_DIR_PATH.slice(REPO_ROOT_PATH.length)); // `/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; + }; +} + +const DEFAULT_OPTIONS: Fixture['options'] = { oxlint: true, eslint: false, fix: false }; + +/** + * 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?.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' + ) { + throw new TypeError('`oxlint`, `eslint`, and `fix` properties in `options.json` must be booleans'); + } + + 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 name - fixtureName: string; + // Fixture details + fixture: Fixture; // Name of the snapshot file snapshotName: string; - // Function to get extra data to include in the snapshot - getExtraSnapshotData?: (dirPath: string) => Promise>; // `true` if the command is ESLint isESLint: boolean; } @@ -35,28 +91,60 @@ interface TestFixtureOptions { * @param options - Options for running the test */ export async function testFixtureWithCommand(options: TestFixtureOptions): Promise { - const { fixtureName } = options; - const fixtureDirPath = pathJoin(FIXTURES_DIR_PATH, fixtureName); + const { name: fixtureName, dirPath } = options.fixture; + + // 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: path.slice(dirPath.length + 1), + code: await fs.readFile(path, 'utf8'), + }); + } + }), + ); + + // Run command let { stdout, stderr, exitCode } = await execa(options.command, options.args, { - cwd: fixtureDirPath, + cwd: dirPath, reject: false, }); - const snapshotPath = pathJoin(fixtureDirPath, `${options.snapshotName}.snap.md`); + // 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`; - if (options.getExtraSnapshotData) { - const extraSnapshots = await options.getExtraSnapshotData(fixtureDirPath); - for (const [name, data] of Object.entries(extraSnapshots)) { - snapshot += `\n# ${name}\n\`\`\`\n${data}\n\`\`\`\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`; } } + // Assert snapshot is as expected await expect(snapshot).toMatchFileSnapshot(snapshotPath); }