From 90d1035ef709329d297272e9164b0452c1ed37bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iiro=20J=C3=A4ppinen?= Date: Fri, 7 Jan 2022 21:42:14 +0200 Subject: [PATCH] feat: support multiple configuration files --- README.md | 47 ++- lib/dynamicImport.js | 3 + lib/generateTasks.js | 5 +- lib/getConfigGroups.js | 75 +++++ lib/getStagedFiles.js | 26 +- lib/index.js | 32 +- lib/loadConfig.js | 20 +- lib/runAll.js | 136 +++++---- lib/validateConfig.js | 16 +- .../__snapshots__/validateConfig.spec.js.snap | 2 + test/dynamicImport.spec.js | 7 + test/generateTasks.spec.js | 126 ++++---- test/getConfigGroups.spec.js | 56 ++++ test/getStagedFiles.spec.js | 14 +- test/index.spec.js | 279 +----------------- test/integration.test.js | 139 +++++++-- test/loadConfig.spec.js | 174 ++++++++++- test/runAll.spec.js | 87 +++++- test/validateConfig.spec.js | 20 +- 19 files changed, 748 insertions(+), 516 deletions(-) create mode 100644 lib/dynamicImport.js create mode 100644 lib/getConfigGroups.js create mode 100644 test/dynamicImport.spec.js create mode 100644 test/getConfigGroups.spec.js diff --git a/README.md b/README.md index 813b3f09b..e3c5ce9ee 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,23 @@ Run linters against staged git files and don't let :poop: slip into your code base! +``` +$ git commit + +✔ Preparing... +❯ Running tasks... + ❯ packages/frontend/.lintstagedrc.json — 1 file + ↓ *.js — no files [SKIPPED] + ❯ *.{json,md} — 1 file + ⠹ prettier --write + ↓ packages/backend/.lintstagedrc.json — 2 files + ❯ *.js — 2 files + ⠼ eslint --fix + ↓ *.{json,md} — no files [SKIPPED] +◼ Applying modifications... +◼ Cleaning up... +``` + [![asciicast](https://asciinema.org/a/199934.svg)](https://asciinema.org/a/199934) ## Why @@ -116,6 +133,8 @@ Starting with v3.1 you can now use different ways of configuring lint-staged: Configuration should be an object where each value is a command to run and its key is a glob pattern to use for this command. This package uses [micromatch](https://github.com/micromatch/micromatch) for glob patterns. JavaScript files can also export advanced configuration as a function. See [Using JS configuration files](#using-js-configuration-files) for more info. +You can also place multiple configuration files in different directories inside a project. For a given staged file, the closest configuration file will always be used. See ["How to use `lint-staged` in a multi-package monorepo?"](#how-to-use-lint-staged-in-a-multi-package-monorepo) for more info and an example. + #### `package.json` example: ```json @@ -644,12 +663,32 @@ _Thanks to [this comment](https://youtrack.jetbrains.com/issue/IDEA-135454#comme
Click to expand -Starting with v5.0, `lint-staged` automatically resolves the git root **without any** additional configuration. You configure `lint-staged` as you normally would if your project root and git root were the same directory. +Install _lint-staged_ on the monorepo root level, and add separate configuration files in each package. When running, _lint-staged_ will always use the configuration closest to a staged file, so having separate configuration files makes sure linters do not "leak" into other packages. + +For example, in a monorepo with `packages/frontend/.lintstagedrc.json` and `packages/backend/.lintstagedrc.json`, a staged file inside `packages/frontend/` will only match that configuration, and not the one in `packages/backend/`. + +**Note**: _lint-staged_ discovers the closest configuration to each staged file, even if that configuration doesn't include any matching globs. Given these example configurations: + +```js +// ./.lintstagedrc.json +{ "*.md": "prettier --write" } +``` + +```js +// ./packages/frontend/.lintstagedrc.json +{ "*.js": "eslint --fix" } +``` -If you wish to use `lint-staged` in a multi package monorepo, it is recommended to install [`husky`](https://github.com/typicode/husky) in the root package.json. -[`lerna`](https://github.com/lerna/lerna) can be used to execute the `precommit` script in all sub-packages. +When committing `./packages/frontend/README.md`, it **will not run** _prettier_, because the configuration in the `frontend/` directory is closer to the file and doesn't include it. You should treat all _lint-staged_ configuration files as isolated and separated from each other. You can always use JS files to "extend" configurations, for example: -Example repo: [sudo-suhas/lint-staged-multi-pkg](https://github.com/sudo-suhas/lint-staged-multi-pkg). +```js +import baseConfig from '../.lintstagedrc.js' + +export default { + ...baseConfig, + '*.js': 'eslint --fix', +} +```
diff --git a/lib/dynamicImport.js b/lib/dynamicImport.js new file mode 100644 index 000000000..75e228fc2 --- /dev/null +++ b/lib/dynamicImport.js @@ -0,0 +1,3 @@ +import { pathToFileURL } from 'url' + +export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default) diff --git a/lib/generateTasks.js b/lib/generateTasks.js index d31413703..164d69b49 100644 --- a/lib/generateTasks.js +++ b/lib/generateTasks.js @@ -16,11 +16,10 @@ const debugLog = debug('lint-staged:generateTasks') * @param {boolean} [options.files] - Staged filepaths * @param {boolean} [options.relative] - Whether filepaths to should be relative to gitDir */ -export const generateTasks = ({ config, cwd = process.cwd(), gitDir, files, relative = false }) => { +export const generateTasks = ({ config, cwd = process.cwd(), files, relative = false }) => { debugLog('Generating linter tasks') - const absoluteFiles = files.map((file) => normalize(path.resolve(gitDir, file))) - const relativeFiles = absoluteFiles.map((file) => normalize(path.relative(cwd, file))) + const relativeFiles = files.map((file) => normalize(path.relative(cwd, file))) return Object.entries(config).map(([rawPattern, commands]) => { let pattern = rawPattern diff --git a/lib/getConfigGroups.js b/lib/getConfigGroups.js new file mode 100644 index 000000000..6939c2aae --- /dev/null +++ b/lib/getConfigGroups.js @@ -0,0 +1,75 @@ +/** @typedef {import('./index').Logger} Logger */ + +import path from 'path' + +import { loadConfig } from './loadConfig.js' +import { ConfigNotFoundError } from './symbols.js' +import { validateConfig } from './validateConfig.js' + +/** + * Return matched files grouped by their configuration. + * + * @param {object} options + * @param {Object} [options.configObject] - Explicit config object from the js API + * @param {string} [options.configPath] - Explicit path to a config file + * @param {string} [options.cwd] - Current working directory + * @param {Logger} logger + */ +export const getConfigGroups = async ({ configObject, configPath, files }, logger = console) => { + // Return explicit config object from js API + if (configObject) { + const config = validateConfig(configObject, 'config object', logger) + return { '': { config, files } } + } + + // Use only explicit config path instead of discovering multiple + if (configPath) { + const { config, filepath } = await loadConfig({ configPath }, logger) + + if (!config) { + logger.error(`${ConfigNotFoundError.message}.`) + throw ConfigNotFoundError + } + + const validatedConfig = validateConfig(config, filepath, logger) + return { [configPath]: { config: validatedConfig, files } } + } + + // Group files by their base directory + const filesByDir = files.reduce((acc, file) => { + const dir = path.normalize(path.dirname(file)) + + if (dir in acc) { + acc[dir].push(file) + } else { + acc[dir] = [file] + } + + return acc + }, {}) + + // Group files by their discovered config + // { '.lintstagedrc.json': { config: {...}, files: [...] } } + const configGroups = {} + + for (const [dir, files] of Object.entries(filesByDir)) { + // Discover config from the base directory of the file + const { config, filepath } = await loadConfig({ cwd: dir }, logger) + + if (!config) { + logger.error(`${ConfigNotFoundError.message}.`) + throw ConfigNotFoundError + } + + if (filepath in configGroups) { + // Re-use cached config and skip validation + configGroups[filepath].files.push(...files) + continue + } + + const validatedConfig = validateConfig(config, filepath, logger) + configGroups[filepath] = { config: validatedConfig, files } + } + + return configGroups +} diff --git a/lib/getStagedFiles.js b/lib/getStagedFiles.js index 69d79aac5..a6a557383 100644 --- a/lib/getStagedFiles.js +++ b/lib/getStagedFiles.js @@ -1,16 +1,28 @@ +import path from 'path' + +import normalize from 'normalize-path' + import { execGit } from './execGit.js' -export const getStagedFiles = async (options) => { +export const getStagedFiles = async ({ cwd = process.cwd() } = {}) => { try { // Docs for --diff-filter option: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203 // Docs for -z option: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt--z - const lines = await execGit( - ['diff', '--staged', '--diff-filter=ACMR', '--name-only', '-z'], - options + const lines = await execGit(['diff', '--staged', '--diff-filter=ACMR', '--name-only', '-z'], { + cwd, + }) + + if (!lines) return [] + + // With `-z`, git prints `fileA\u0000fileB\u0000fileC\u0000` so we need to + // remove the last occurrence of `\u0000` before splitting + return ( + lines + // eslint-disable-next-line no-control-regex + .replace(/\u0000$/, '') + .split('\u0000') + .map((file) => normalize(path.resolve(cwd, file))) ) - // With `-z`, git prints `fileA\u0000fileB\u0000fileC\u0000` so we need to remove the last occurrence of `\u0000` before splitting - // eslint-disable-next-line no-control-regex - return lines ? lines.replace(/\u0000$/, '').split('\u0000') : [] } catch { return null } diff --git a/lib/index.js b/lib/index.js index 4048b444d..e01d6d948 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,17 +1,9 @@ import debug from 'debug' -import inspect from 'object-inspect' -import { loadConfig } from './loadConfig.js' import { PREVENTED_EMPTY_COMMIT, GIT_ERROR, RESTORE_STASH_EXAMPLE } from './messages.js' import { printTaskOutput } from './printTaskOutput.js' import { runAll } from './runAll.js' -import { - ApplyEmptyCommitError, - ConfigNotFoundError, - GetBackupStashError, - GitError, -} from './symbols.js' -import { validateConfig } from './validateConfig.js' +import { ApplyEmptyCommitError, GetBackupStashError, GitError } from './symbols.js' import { validateOptions } from './validateOptions.js' const debugLog = debug('lint-staged') @@ -58,25 +50,6 @@ const lintStaged = async ( ) => { await validateOptions({ shell }, logger) - const inputConfig = configObject || (await loadConfig({ configPath, cwd }, logger)) - - if (!inputConfig) { - logger.error(`${ConfigNotFoundError.message}.`) - throw ConfigNotFoundError - } - - const config = validateConfig(inputConfig, logger) - - if (debug) { - // Log using logger to be able to test through `consolemock`. - logger.log('Running lint-staged with the following config:') - logger.log(inspect(config, { indent: 2 })) - } else { - // We might not be in debug mode but `DEBUG=lint-staged*` could have - // been set. - debugLog('lint-staged config:\n%O', config) - } - // Unset GIT_LITERAL_PATHSPECS to not mess with path interpretation debugLog('Unset GIT_LITERAL_PATHSPECS (was `%s`)', process.env.GIT_LITERAL_PATHSPECS) delete process.env.GIT_LITERAL_PATHSPECS @@ -86,7 +59,8 @@ const lintStaged = async ( { allowEmpty, concurrent, - config, + configObject, + configPath, cwd, debug, maxArgLength, diff --git a/lib/loadConfig.js b/lib/loadConfig.js index 8c9b8c55e..78fa3c767 100644 --- a/lib/loadConfig.js +++ b/lib/loadConfig.js @@ -1,11 +1,10 @@ /** @typedef {import('./index').Logger} Logger */ -import { pathToFileURL } from 'url' - import debug from 'debug' import { lilconfig } from 'lilconfig' import YAML from 'yaml' +import { dynamicImport } from './dynamicImport.js' import { resolveConfig } from './resolveConfig.js' const debugLog = debug('lint-staged:loadConfig') @@ -28,9 +27,6 @@ const searchPlaces = [ 'lint-staged.config.cjs', ] -/** exported for tests */ -export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default) - const jsonParse = (path, content) => JSON.parse(content) const yamlParse = (path, content) => YAML.parse(content) @@ -51,6 +47,8 @@ const loaders = { noExt: yamlParse, } +const explorer = lilconfig('lint-staged', { searchPlaces, loaders }) + /** * @param {object} options * @param {string} [options.configPath] - Explicit path to a config file @@ -64,22 +62,22 @@ export const loadConfig = async ({ configPath, cwd }, logger) => { debugLog('Searching for configuration from `%s`...', cwd) } - const explorer = lilconfig('lint-staged', { searchPlaces, loaders }) - const result = await (configPath ? explorer.load(resolveConfig(configPath)) : explorer.search(cwd)) - if (!result) return null + + if (!result) return {} // config is a promise when using the `dynamicImport` loader const config = await result.config + const filepath = result.filepath - debugLog('Successfully loaded config from `%s`:\n%O', result.filepath, config) + debugLog('Successfully loaded config from `%s`:\n%O', filepath, config) - return config + return { config, filepath } } catch (error) { debugLog('Failed to load configuration!') logger.error(error) - return null + return {} } } diff --git a/lib/runAll.js b/lib/runAll.js index 1ba726004..04a80cf19 100644 --- a/lib/runAll.js +++ b/lib/runAll.js @@ -1,11 +1,16 @@ /** @typedef {import('./index').Logger} Logger */ +import path from 'path' + +import { dim } from 'colorette' import debug from 'debug' import { Listr } from 'listr2' +import normalize from 'normalize-path' import { chunkFiles } from './chunkFiles.js' import { execGit } from './execGit.js' import { generateTasks } from './generateTasks.js' +import { getConfigGroups } from './getConfigGroups.js' import { getRenderer } from './getRenderer.js' import { getStagedFiles } from './getStagedFiles.js' import { GitWorkflow } from './gitWorkflow.js' @@ -40,10 +45,11 @@ const createError = (ctx) => Object.assign(new Error('lint-staged failed'), { ct * Executes all tasks and either resolves or rejects the promise * * @param {object} options - * @param {Object} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes + * @param {boolean} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially - * @param {Object} [options.config] - Task configuration - * @param {Object} [options.cwd] - Current working directory + * @param {Object} [options.configObject] - Explicit config object from the js API + * @param {string} [options.configPath] - Explicit path to a config file + * @param {string} [options.cwd] - Current working directory * @param {boolean} [options.debug] - Enable debug mode * @param {number} [options.maxArgLength] - Maximum argument string length * @param {boolean} [options.quiet] - Disable lint-staged’s own console output @@ -58,7 +64,8 @@ export const runAll = async ( { allowEmpty = false, concurrent = true, - config, + configObject, + configPath, cwd = process.cwd(), debug = false, maxArgLength, @@ -107,9 +114,7 @@ export const runAll = async ( return ctx } - const stagedFileChunks = chunkFiles({ baseDir: gitDir, files, maxArgLength, relative }) - const chunkCount = stagedFileChunks.length - if (chunkCount > 1) debugLog(`Chunked staged files into ${chunkCount} part`, chunkCount) + const configGroups = await getConfigGroups({ configObject, configPath, files }, logger) // lint-staged 10 will automatically add modifications to index // Warn user when their command includes `git add` @@ -128,62 +133,77 @@ export const runAll = async ( // Set of all staged files that matched a task glob. Values in a set are unique. const matchedFiles = new Set() - for (const [index, files] of stagedFileChunks.entries()) { - const chunkTasks = generateTasks({ config, cwd, gitDir, files, relative }) - const chunkListrTasks = [] - - for (const task of chunkTasks) { - const subTasks = await makeCmdTasks({ - commands: task.commands, - cwd, - files: task.fileList, - gitDir, - renderer: listrOptions.renderer, - shell, - verbose, - }) + for (const [configPath, { config, files }] of Object.entries(configGroups)) { + const stagedFileChunks = chunkFiles({ baseDir: gitDir, files, maxArgLength, relative }) - // Add files from task to match set - task.fileList.forEach((file) => { - matchedFiles.add(file) - }) + const chunkCount = stagedFileChunks.length + if (chunkCount > 1) { + debugLog('Chunked staged files from `%s` into %d part', configPath, chunkCount) + } + + for (const [index, files] of stagedFileChunks.entries()) { + const chunkTasks = generateTasks({ config, cwd, files, relative }) + const chunkListrTasks = [] + + for (const task of chunkTasks) { + const subTasks = await makeCmdTasks({ + commands: task.commands, + cwd, + files: task.fileList, + gitDir, + renderer: listrOptions.renderer, + shell, + verbose, + }) + + // Add files from task to match set + task.fileList.forEach((file) => { + matchedFiles.add(file) + }) + + hasDeprecatedGitAdd = + hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.command === 'git add') - hasDeprecatedGitAdd = - hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.command === 'git add') - - chunkListrTasks.push({ - title: `Running tasks for ${task.pattern}`, - task: async () => - new Listr(subTasks, { - // In sub-tasks we don't want to run concurrently - // and we want to abort on errors - ...listrOptions, - concurrent: false, - exitOnError: true, - }), + const fileCount = task.fileList.length + + chunkListrTasks.push({ + title: `${task.pattern}${dim(` — ${fileCount} ${fileCount > 1 ? 'files' : 'file'}`)}`, + task: async () => + new Listr(subTasks, { + // In sub-tasks we don't want to run concurrently + // and we want to abort on errors + ...listrOptions, + concurrent: false, + exitOnError: true, + }), + skip: () => { + // Skip task when no files matched + if (fileCount === 0) { + return `${task.pattern}${dim(' — no files')}` + } + return false + }, + }) + } + + const relativeConfig = normalize(path.relative(cwd, configPath)) + + listrTasks.push({ + title: + `${relativeConfig}${dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}` + + (chunkCount > 1 ? dim(` (chunk ${index + 1}/${chunkCount})...`) : ''), + task: () => new Listr(chunkListrTasks, { ...listrOptions, concurrent, exitOnError: true }), skip: () => { - // Skip task when no files matched - if (task.fileList.length === 0) { - return `No staged files match ${task.pattern}` + // Skip if the first step (backup) failed + if (ctx.errors.has(GitError)) return SKIPPED_GIT_ERROR + // Skip chunk when no every task is skipped (due to no matches) + if (chunkListrTasks.every((task) => task.skip())) { + return `${relativeConfig}${dim(' — no tasks to run')}` } return false }, }) } - - listrTasks.push({ - // No need to show number of task chunks when there's only one - title: - chunkCount > 1 ? `Running tasks (chunk ${index + 1}/${chunkCount})...` : 'Running tasks...', - task: () => new Listr(chunkListrTasks, { ...listrOptions, concurrent }), - skip: () => { - // Skip if the first step (backup) failed - if (ctx.errors.has(GitError)) return SKIPPED_GIT_ERROR - // Skip chunk when no every task is skipped (due to no matches) - if (chunkListrTasks.every((task) => task.skip())) return 'No tasks to run.' - return false - }, - }) } if (hasDeprecatedGitAdd) { @@ -219,7 +239,11 @@ export const runAll = async ( task: (ctx) => git.hideUnstagedChanges(ctx), enabled: hasPartiallyStagedFiles, }, - ...listrTasks, + { + title: `Running tasks...`, + task: () => new Listr(listrTasks, { ...listrOptions, concurrent }), + skip: () => listrTasks.every((task) => task.skip()), + }, { title: 'Applying modifications...', task: (ctx) => git.applyModifications(ctx), diff --git a/lib/validateConfig.js b/lib/validateConfig.js index 11e1ceea0..d09382462 100644 --- a/lib/validateConfig.js +++ b/lib/validateConfig.js @@ -1,4 +1,7 @@ +/** @typedef {import('./index').Logger} Logger */ + import debug from 'debug' +import inspect from 'object-inspect' import { configurationError } from './messages.js' import { ConfigEmptyError, ConfigFormatError } from './symbols.js' @@ -21,11 +24,13 @@ const TEST_DEPRECATED_KEYS = new Map([ /** * Runs config validation. Throws error if the config is not valid. - * @param config {Object} - * @returns config {Object} + * @param {Object} config + * @param {string} configPath + * @param {Logger} logger + * @returns {Object} config */ -export const validateConfig = (config, logger) => { - debugLog('Validating config') +export const validateConfig = (config, configPath, logger) => { + debugLog('Validating config from `%s`...', configPath) if (!config || (typeof config !== 'object' && typeof config !== 'function')) { throw ConfigFormatError @@ -103,5 +108,8 @@ See https://github.com/okonet/lint-staged#configuration.`) throw new Error(message) } + debugLog('Validated config from `%s`:', configPath) + debugLog(inspect(config, { indent: 2 })) + return validatedConfig } diff --git a/test/__snapshots__/validateConfig.spec.js.snap b/test/__snapshots__/validateConfig.spec.js.snap index 38bf1af6a..734a30346 100644 --- a/test/__snapshots__/validateConfig.spec.js.snap +++ b/test/__snapshots__/validateConfig.spec.js.snap @@ -10,6 +10,8 @@ exports[`validateConfig should throw and should print validation errors for inva exports[`validateConfig should throw and should print validation errors for invalid config 1 1`] = `"Configuration should be an object or a function"`; +exports[`validateConfig should throw for empty config 1`] = `"Configuration should not be empty"`; + exports[`validateConfig should throw when detecting deprecated advanced configuration 1`] = ` "✖ Validation Error: diff --git a/test/dynamicImport.spec.js b/test/dynamicImport.spec.js new file mode 100644 index 000000000..9abc014bf --- /dev/null +++ b/test/dynamicImport.spec.js @@ -0,0 +1,7 @@ +import { dynamicImport } from '../lib/dynamicImport' + +describe('dynamicImport', () => { + it('should log errors into console', () => { + expect(() => dynamicImport('not-found.js')).rejects.toThrowError(`Cannot find module`) + }) +}) diff --git a/test/generateTasks.spec.js b/test/generateTasks.spec.js index c8208e077..227c478e0 100644 --- a/test/generateTasks.spec.js +++ b/test/generateTasks.spec.js @@ -1,39 +1,34 @@ -import os from 'os' import path from 'path' import normalize from 'normalize-path' import { generateTasks } from '../lib/generateTasks' -import { resolveGitRepo } from '../lib/resolveGitRepo' -const normalizePath = (path) => normalize(path) +// Windows filepaths +const normalizePath = (input) => normalize(path.resolve('/', input)) + +const cwd = '/repo' const files = [ - 'test.js', - 'deeper/test.js', - 'deeper/test2.js', - 'even/deeper/test.js', - '.hidden/test.js', - - 'test.css', - 'deeper/test1.css', - 'deeper/test2.css', - 'even/deeper/test.css', - '.hidden/test.css', - - 'test.txt', - 'deeper/test.txt', - 'deeper/test2.txt', - 'even/deeper/test.txt', - '.hidden/test.txt', + '/repo/test.js', + '/repo/deeper/test.js', + '/repo/deeper/test2.js', + '/repo/even/deeper/test.js', + '/repo/.hidden/test.js', + + '/repo/test.css', + '/repo/deeper/test1.css', + '/repo/deeper/test2.css', + '/repo/even/deeper/test.css', + '/repo/.hidden/test.css', + + '/repo/test.txt', + '/repo/deeper/test.txt', + '/repo/deeper/test2.txt', + '/repo/even/deeper/test.txt', + '/repo/.hidden/test.txt', ] -// Mocks get hoisted -jest.mock('../lib/resolveGitRepo.js') -const gitDir = path.join(os.tmpdir(), 'tmp-lint-staged') -resolveGitRepo.mockResolvedValue({ gitDir }) -const cwd = gitDir - const config = { '*.js': 'root-js', '**/*.js': 'any-js', @@ -50,17 +45,16 @@ describe('generateTasks', () => { config: { '*': 'lint', }, - gitDir, files, }) + task.fileList.forEach((file) => { expect(path.isAbsolute(file)).toBe(true) }) }) it('should not match non-children files', async () => { - const relPath = path.join(process.cwd(), '..') - const result = await generateTasks({ config, cwd, gitDir: relPath, files }) + const result = await generateTasks({ config, cwd: '/test', files }) const linter = result.find((item) => item.pattern === '*.js') expect(linter).toEqual({ pattern: '*.js', @@ -70,7 +64,7 @@ describe('generateTasks', () => { }) it('should return an empty file list for linters with no matches.', async () => { - const result = await generateTasks({ config, cwd, gitDir, files }) + const result = await generateTasks({ config, cwd, files }) result.forEach((task) => { if (task.commands === 'unknown-js' || task.commands === 'parent-dir-css-or-js') { @@ -82,74 +76,74 @@ describe('generateTasks', () => { }) it('should match pattern "*.js"', async () => { - const result = await generateTasks({ config, cwd, gitDir, files }) + const result = await generateTasks({ config, cwd, files }) const linter = result.find((item) => item.pattern === '*.js') expect(linter).toEqual({ pattern: '*.js', commands: 'root-js', fileList: [ - `${gitDir}/test.js`, - `${gitDir}/deeper/test.js`, - `${gitDir}/deeper/test2.js`, - `${gitDir}/even/deeper/test.js`, - `${gitDir}/.hidden/test.js`, + `/repo/test.js`, + `/repo/deeper/test.js`, + `/repo/deeper/test2.js`, + `/repo/even/deeper/test.js`, + `/repo/.hidden/test.js`, ].map(normalizePath), }) }) it('should match pattern "**/*.js"', async () => { - const result = await generateTasks({ config, cwd, gitDir, files }) + const result = await generateTasks({ config, cwd, files }) const linter = result.find((item) => item.pattern === '**/*.js') expect(linter).toEqual({ pattern: '**/*.js', commands: 'any-js', fileList: [ - `${gitDir}/test.js`, - `${gitDir}/deeper/test.js`, - `${gitDir}/deeper/test2.js`, - `${gitDir}/even/deeper/test.js`, - `${gitDir}/.hidden/test.js`, + `/repo/test.js`, + `/repo/deeper/test.js`, + `/repo/deeper/test2.js`, + `/repo/even/deeper/test.js`, + `/repo/.hidden/test.js`, ].map(normalizePath), }) }) it('should match pattern "deeper/*.js"', async () => { - const result = await generateTasks({ config, cwd, gitDir, files }) + const result = await generateTasks({ config, cwd, files }) const linter = result.find((item) => item.pattern === 'deeper/*.js') expect(linter).toEqual({ pattern: 'deeper/*.js', commands: 'deeper-js', - fileList: [`${gitDir}/deeper/test.js`, `${gitDir}/deeper/test2.js`].map(normalizePath), + fileList: [`/repo/deeper/test.js`, `/repo/deeper/test2.js`].map(normalizePath), }) }) it('should match pattern ".hidden/*.js"', async () => { - const result = await generateTasks({ config, cwd, gitDir, files }) + const result = await generateTasks({ config, cwd, files }) const linter = result.find((item) => item.pattern === '.hidden/*.js') expect(linter).toEqual({ pattern: '.hidden/*.js', commands: 'hidden-js', - fileList: [`${gitDir}/.hidden/test.js`].map(normalizePath), + fileList: [`/repo/.hidden/test.js`].map(normalizePath), }) }) it('should match pattern "*.{css,js}"', async () => { - const result = await generateTasks({ config, cwd, gitDir, files }) + const result = await generateTasks({ config, cwd, files }) const linter = result.find((item) => item.pattern === '*.{css,js}') expect(linter).toEqual({ pattern: '*.{css,js}', commands: 'root-css-or-js', fileList: [ - `${gitDir}/test.js`, - `${gitDir}/deeper/test.js`, - `${gitDir}/deeper/test2.js`, - `${gitDir}/even/deeper/test.js`, - `${gitDir}/.hidden/test.js`, - `${gitDir}/test.css`, - `${gitDir}/deeper/test1.css`, - `${gitDir}/deeper/test2.css`, - `${gitDir}/even/deeper/test.css`, - `${gitDir}/.hidden/test.css`, + `/repo/test.js`, + `/repo/deeper/test.js`, + `/repo/deeper/test2.js`, + `/repo/even/deeper/test.js`, + `/repo/.hidden/test.js`, + `/repo/test.css`, + `/repo/deeper/test1.css`, + `/repo/deeper/test2.css`, + `/repo/even/deeper/test.css`, + `/repo/.hidden/test.css`, ].map(normalizePath), }) }) @@ -160,7 +154,7 @@ describe('generateTasks', () => { 'test{1..2}.css': 'lint', }, cwd, - gitDir, + files, }) @@ -169,42 +163,40 @@ describe('generateTasks', () => { expect(linter).toEqual({ pattern: 'test{1..2}.css', commands: 'lint', - fileList: [`${gitDir}/deeper/test1.css`, `${gitDir}/deeper/test2.css`].map(normalizePath), + fileList: [`/repo/deeper/test1.css`, `/repo/deeper/test2.css`].map(normalizePath), }) }) it('should not match files in parent directory by default', async () => { const result = await generateTasks({ config, - cwd: path.join(gitDir, 'deeper'), - gitDir, + cwd: '/repo/deeper', files, }) const linter = result.find((item) => item.pattern === '*.js') expect(linter).toEqual({ pattern: '*.js', commands: 'root-js', - fileList: [`${gitDir}/deeper/test.js`, `${gitDir}/deeper/test2.js`].map(normalizePath), + fileList: [`/repo/deeper/test.js`, `/repo/deeper/test2.js`].map(normalizePath), }) }) it('should match files in parent directory when pattern starts with "../"', async () => { const result = await generateTasks({ config, - cwd: path.join(gitDir, 'deeper'), - gitDir, + cwd: '/repo/deeper', files, }) const linter = result.find((item) => item.pattern === '../*.{css,js}') expect(linter).toEqual({ commands: 'parent-dir-css-or-js', - fileList: [`${gitDir}/test.js`, `${gitDir}/test.css`].map(normalizePath), + fileList: [`/repo/test.js`, `/repo/test.css`].map(normalizePath), pattern: '../*.{css,js}', }) }) it('should be able to return relative paths for "*.{css,js}"', async () => { - const result = await generateTasks({ config, cwd, gitDir, files, relative: true }) + const result = await generateTasks({ config, cwd, files, relative: true }) const linter = result.find((item) => item.pattern === '*.{css,js}') expect(linter).toEqual({ pattern: '*.{css,js}', @@ -220,7 +212,7 @@ describe('generateTasks', () => { 'deeper/test2.css', 'even/deeper/test.css', '.hidden/test.css', - ].map(normalizePath), + ], }) }) }) diff --git a/test/getConfigGroups.spec.js b/test/getConfigGroups.spec.js new file mode 100644 index 000000000..29af85aa7 --- /dev/null +++ b/test/getConfigGroups.spec.js @@ -0,0 +1,56 @@ +import makeConsoleMock from 'consolemock' + +import { getConfigGroups } from '../lib/getConfigGroups' +import { loadConfig } from '../lib/loadConfig' + +jest.mock('../lib/loadConfig', () => ({ + // config not found + loadConfig: jest.fn(async () => ({})), +})) + +const globalConsoleTemp = console + +const config = { + '*.js': 'my-task', +} + +describe('getConfigGroups', () => { + beforeEach(() => { + console = makeConsoleMock() + }) + + afterEach(() => { + console.printHistory() + console = globalConsoleTemp + }) + + it('should throw when config path not found', async () => { + await expect(getConfigGroups({ configPath: '/' })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Configuration could not be found"` + ) + }) + + it('should throw when config not found', async () => { + await expect( + getConfigGroups({ files: ['/foo.js'] }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Configuration could not be found"`) + }) + + it('should find config files for all staged files', async () => { + // '/foo.js' and '/bar.js' + loadConfig.mockResolvedValueOnce({ config, filepath: '/.lintstagedrc.json' }) + // '/deeper/foo.js' + loadConfig.mockResolvedValueOnce({ config, filepath: '/deeper/.lintstagedrc.json' }) + // '/even/deeper/foo.js' + loadConfig.mockResolvedValueOnce({ config, filepath: '/deeper/.lintstagedrc.json' }) + + const configGroups = await getConfigGroups({ + files: ['/foo.js', '/bar.js', '/deeper/foo.js', '/even/deeper/foo.js'], + }) + + expect(configGroups).toEqual({ + '/.lintstagedrc.json': { config, files: ['/foo.js', '/bar.js'] }, + '/deeper/.lintstagedrc.json': { config, files: ['/deeper/foo.js', '/even/deeper/foo.js'] }, + }) + }) +}) diff --git a/test/getStagedFiles.spec.js b/test/getStagedFiles.spec.js index 20f6b9888..15866460d 100644 --- a/test/getStagedFiles.spec.js +++ b/test/getStagedFiles.spec.js @@ -1,13 +1,21 @@ +import path from 'path' + +import normalize from 'normalize-path' + import { getStagedFiles } from '../lib/getStagedFiles' import { execGit } from '../lib/execGit' jest.mock('../lib/execGit') +// Windows filepaths +const normalizePath = (input) => normalize(path.resolve('/', input)) + describe('getStagedFiles', () => { it('should return array of file names', async () => { execGit.mockImplementationOnce(async () => 'foo.js\u0000bar.js\u0000') - const staged = await getStagedFiles() - expect(staged).toEqual(['foo.js', 'bar.js']) + const staged = await getStagedFiles({ cwd: '/' }) + // Windows filepaths + expect(staged).toEqual([normalizePath('/foo.js'), normalizePath('/bar.js')]) }) it('should return empty array when no staged files', async () => { @@ -20,7 +28,7 @@ describe('getStagedFiles', () => { execGit.mockImplementationOnce(async () => { throw new Error('fatal: not a git repository (or any of the parent directories): .git') }) - const staged = await getStagedFiles() + const staged = await getStagedFiles({}) expect(staged).toEqual(null) }) }) diff --git a/test/index.spec.js b/test/index.spec.js index 81bd5193d..c07c23845 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -1,5 +1,3 @@ -import path from 'path' - import { lilconfig } from 'lilconfig' import makeConsoleMock from 'consolemock' @@ -7,10 +5,6 @@ jest.unmock('execa') import { getStagedFiles } from '../lib/getStagedFiles' import lintStaged from '../lib/index' -import { InvalidOptionsError } from '../lib/symbols' -import { validateOptions } from '../lib/validateOptions' - -import { replaceSerializer } from './utils/replaceSerializer' const mockLilConfig = (result) => { lilconfig.mockImplementationOnce(() => ({ @@ -76,7 +70,7 @@ describe('lintStaged', () => { }) it('should use use the console if no logger is passed', async () => { - expect.assertions(2) + expect.assertions(1) mockLilConfig({ config: {} }) @@ -84,276 +78,13 @@ describe('lintStaged', () => { const mockedConsole = makeConsoleMock() console = mockedConsole - await expect(lintStaged()).rejects.toMatchInlineSnapshot( - `[Error: Configuration should not be empty]` - ) - - expect(mockedConsole.printHistory()).toMatchInlineSnapshot(`""`) - - console = previousConsole - }) - - it('should output config in debug mode', async () => { - expect.assertions(1) - - const config = { '*': 'mytask' } - mockLilConfig({ config }) - - await lintStaged({ debug: true, quiet: true }, logger) - - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - LOG Running lint-staged with the following config: - LOG { - '*': 'mytask' - }" - `) - }) - - it('should not output config in normal mode', async () => { - expect.assertions(1) - - const config = { '*': 'mytask' } - mockLilConfig({ config }) - - await lintStaged({ quiet: true }, logger) - - expect(logger.printHistory()).toMatchInlineSnapshot(`""`) - }) - - it('should throw when invalid options are provided', async () => { - expect.assertions(2) - - validateOptions.mockImplementationOnce(async () => { - throw InvalidOptionsError - }) - - await expect(lintStaged({ '*': 'mytask' }, logger)).rejects.toMatchInlineSnapshot( - `[Error: Invalid Options]` - ) - - expect(logger.printHistory()).toMatchInlineSnapshot(`""`) - }) - - it('should throw when invalid config is provided', async () => { - const config = {} - mockLilConfig({ config }) - - await expect(lintStaged({ quiet: true }, logger)).rejects.toMatchInlineSnapshot( - `[Error: Configuration should not be empty]` - ) - - expect(logger.printHistory()).toMatchInlineSnapshot(`""`) - }) - - it('should load JSON config file', async () => { - expect.assertions(1) - - await lintStaged( - { - configPath: path.join(__dirname, '__mocks__', 'my-config.json'), - debug: true, - quiet: true, - }, - logger - ) - - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - LOG Running lint-staged with the following config: - LOG { - '*': 'mytask' - }" - `) - }) - - it('should load YAML config file', async () => { - expect.assertions(1) - - await lintStaged( - { - configPath: path.join(__dirname, '__mocks__', 'my-config.yml'), - debug: true, - quiet: true, - }, - logger - ) - - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - LOG Running lint-staged with the following config: - LOG { - '*': 'mytask' - }" - `) - }) - - it('should load CommonJS config file from absolute path', async () => { - expect.assertions(1) - - await lintStaged( - { - configPath: path.join(__dirname, '__mocks__', 'advanced-config.js'), - debug: true, - quiet: true, - }, - logger - ) - - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - LOG Running lint-staged with the following config: - LOG { - '*.css': [Function: *.css], - '*.js': [Function: *.js] - }" - `) - }) - - it('should load CommonJS config file from relative path', async () => { - expect.assertions(1) - - await lintStaged( - { - configPath: path.join('test', '__mocks__', 'advanced-config.js'), - debug: true, - quiet: true, - }, - logger - ) - - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - LOG Running lint-staged with the following config: - LOG { - '*.css': [Function: *.css], - '*.js': [Function: *.js] - }" - `) - }) - - it('should load CommonJS config file from .cjs file', async () => { - expect.assertions(1) - - await lintStaged( - { - configPath: path.join('test', '__mocks__', 'my-config.cjs'), - debug: true, - quiet: true, - }, - logger - ) - - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - LOG Running lint-staged with the following config: - LOG { - '*': 'mytask' - }" - `) - }) - - it('should load EMS config file from .mjs file', async () => { - expect.assertions(1) - - await lintStaged( - { - configPath: path.join('test', '__mocks__', 'esm-config.mjs'), - debug: true, - quiet: true, - }, - logger - ) + await lintStaged() - expect(logger.printHistory()).toMatchInlineSnapshot(` + expect(mockedConsole.printHistory()).toMatchInlineSnapshot(` " - LOG Running lint-staged with the following config: - LOG { - '*': 'mytask' - }" - `) - }) - - it('should load EMS config file from .js file', async () => { - expect.assertions(1) - - await lintStaged( - { - configPath: path.join('test', '__mocks__', 'esm-config-in-js.js'), - debug: true, - quiet: true, - }, - logger - ) - - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - LOG Running lint-staged with the following config: - LOG { - '*': 'mytask' - }" - `) - }) - - it('should use config object', async () => { - expect.assertions(1) - - const config = { '*': 'node -e "process.exit(1)"' } - - await lintStaged({ config, quiet: true }, logger) - - expect(logger.printHistory()).toMatchInlineSnapshot(`""`) - }) - - it('should load a CJS module when specified', async () => { - expect.assertions(1) - - jest.mock('my-lint-staged-config') - - await lintStaged({ configPath: 'my-lint-staged-config', quiet: true, debug: true }, logger) - - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - LOG Running lint-staged with the following config: - LOG { - '*': 'mytask' - }" - `) - }) - - it('should print helpful error message when config file is not found', async () => { - expect.assertions(2) - - mockLilConfig(null) - - await expect(lintStaged({ quiet: true }, logger)).rejects.toMatchInlineSnapshot( - `[Error: Configuration could not be found]` - ) - - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - ERROR Configuration could not be found." + ERROR ✖ Failed to get staged files!" `) - }) - - it('should print helpful error message when explicit config file is not found', async () => { - expect.assertions(3) - - const nonExistentConfig = 'fake-config-file.yml' - // Serialize Windows, Linux and MacOS paths consistently - expect.addSnapshotSerializer( - replaceSerializer( - /ENOENT: no such file or directory, open '([^']+)'/, - `ENOENT: no such file or directory, open '${nonExistentConfig}'` - ) - ) - - await expect( - lintStaged({ configPath: nonExistentConfig, quiet: true }, logger) - ).rejects.toThrowError() - - expect(logger.printHistory()).toMatch('ENOENT') - expect(logger.printHistory()).toMatch('Configuration could not be found') + console = previousConsole }) }) diff --git a/test/integration.test.js b/test/integration.test.js index d044a3a13..5f2716f1a 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -7,6 +7,7 @@ import normalize from 'normalize-path' jest.unmock('lilconfig') jest.unmock('execa') + jest.mock('../lib/resolveConfig', () => ({ /** Unfortunately necessary due to non-ESM tests. */ resolveConfig: (configPath) => { @@ -18,6 +19,11 @@ jest.mock('../lib/resolveConfig', () => ({ }, })) +jest.mock('../lib/dynamicImport', () => ({ + // 'pathToFileURL' is not supported with Jest + Babel + dynamicImport: jest.fn().mockImplementation(async (input) => require(input)), +})) + import { execGit as execGitBase } from '../lib/execGit' import lintStaged from '../lib/index' @@ -27,6 +33,30 @@ import { isWindowsActions, normalizeWindowsNewlines } from './utils/crossPlatfor jest.setTimeout(20000) +// Replace path like `../../git/lint-staged` with `/lint-staged` +const replaceConfigPathSerializer = replaceSerializer( + /((?:\.\.\/)+).*\/lint-staged/gm, + `/lint-staged` +) + +// Hide filepath from test snapshot because it's not important and varies in CI +const replaceFilepathSerializer = replaceSerializer( + /prettier --write (.*)?$/gm, + `prettier --write ` +) + +// Awkwardly merge three serializers +expect.addSnapshotSerializer({ + test: (val) => + ansiSerializer.test(val) || + replaceConfigPathSerializer.test(val) || + replaceFilepathSerializer.test(val), + print: (val, serialize) => + replaceFilepathSerializer.print( + replaceConfigPathSerializer.print(ansiSerializer.print(val, serialize)) + ), +}) + const testJsFilePretty = `module.exports = { foo: "bar", }; @@ -46,19 +76,28 @@ const fixJsConfig = { config: { '*.js': 'prettier --write' } } let tmpDir let cwd +const ensureDir = async (inputPath) => fs.ensureDir(path.dirname(inputPath)) + // Get file content, coercing Windows `\r\n` newlines to `\n` const readFile = async (filename, dir = cwd) => { - const file = await fs.readFile(path.resolve(dir, filename), { encoding: 'utf-8' }) + const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename) + const file = await fs.readFile(filepath, { encoding: 'utf-8' }) return normalizeWindowsNewlines(file) } // Append to file, creating if it doesn't exist -const appendFile = async (filename, content, dir = cwd) => - fs.appendFile(path.resolve(dir, filename), content) +const appendFile = async (filename, content, dir = cwd) => { + const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename) + await ensureDir(filepath) + await fs.appendFile(filepath, content) +} // Write (over) file, creating if it doesn't exist -const writeFile = async (filename, content, dir = cwd) => - fs.writeFile(path.resolve(dir, filename), content) +const writeFile = async (filename, content, dir = cwd) => { + const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename) + await ensureDir(filepath) + fs.writeFile(filepath, content) +} // Wrap execGit to always pass `gitOps` const execGit = async (args, options = {}) => execGitBase(args, { cwd, ...options }) @@ -258,10 +297,12 @@ describe('lint-staged', () => { LOG [STARTED] Hiding unstaged changes to partially staged files... LOG [SUCCESS] Hiding unstaged changes to partially staged files... LOG [STARTED] Running tasks... - LOG [STARTED] Running tasks for *.js + LOG [STARTED] /lint-staged — 1 file + LOG [STARTED] *.js — 1 file LOG [STARTED] prettier --list-different LOG [SUCCESS] prettier --list-different - LOG [SUCCESS] Running tasks for *.js + LOG [SUCCESS] *.js — 1 file + LOG [SUCCESS] /lint-staged — 1 file LOG [SUCCESS] Running tasks... LOG [STARTED] Applying modifications... LOG [SUCCESS] Applying modifications... @@ -749,10 +790,12 @@ describe('lint-staged', () => { LOG [STARTED] Preparing... LOG [SUCCESS] Preparing... LOG [STARTED] Running tasks... - LOG [STARTED] Running tasks for *.js + LOG [STARTED] /lint-staged — 1 file + LOG [STARTED] *.js — 1 file LOG [STARTED] git stash drop LOG [SUCCESS] git stash drop - LOG [SUCCESS] Running tasks for *.js + LOG [SUCCESS] *.js — 1 file + LOG [SUCCESS] /lint-staged — 1 file LOG [SUCCESS] Running tasks... LOG [STARTED] Applying modifications... LOG [SUCCESS] Applying modifications... @@ -787,12 +830,14 @@ describe('lint-staged', () => { LOG [STARTED] Preparing... LOG [SUCCESS] Preparing... LOG [STARTED] Running tasks... - LOG [STARTED] Running tasks for *.js + LOG [STARTED] /lint-staged — 1 file + LOG [STARTED] *.js — 1 file LOG [STARTED] prettier --write LOG [SUCCESS] prettier --write LOG [STARTED] git add LOG [SUCCESS] git add - LOG [SUCCESS] Running tasks for *.js + LOG [SUCCESS] *.js — 1 file + LOG [SUCCESS] /lint-staged — 1 file LOG [SUCCESS] Running tasks... LOG [STARTED] Applying modifications... ERROR [FAILED] Prevented an empty git commit! @@ -935,10 +980,12 @@ describe('lint-staged', () => { LOG [STARTED] Preparing... LOG [SUCCESS] Preparing... LOG [STARTED] Running tasks... - LOG [STARTED] Running tasks for *.js + LOG [STARTED] /lint-staged — 1 file + LOG [STARTED] *.js — 1 file LOG [STARTED] prettier --write LOG [SUCCESS] prettier --write - LOG [SUCCESS] Running tasks for *.js + LOG [SUCCESS] *.js — 1 file + LOG [SUCCESS] /lint-staged — 1 file LOG [SUCCESS] Running tasks... LOG [STARTED] Applying modifications... LOG [SUCCESS] Applying modifications..." @@ -969,19 +1016,6 @@ describe('lint-staged', () => { }) ).rejects.toThrowError() - // Hide filepath from test snapshot because it's not important and varies in CI - const replaceFilepathSerializer = replaceSerializer( - /prettier --write (.*)?$/gm, - `prettier --write FILEPATH` - ) - - // Awkwardly merge two serializers - expect.addSnapshotSerializer({ - test: (val) => ansiSerializer.test(val) || replaceFilepathSerializer.test(val), - print: (val, serialize) => - replaceFilepathSerializer.print(ansiSerializer.print(val, serialize)), - }) - expect(console.printHistory()).toMatchInlineSnapshot(` " WARN ⚠ Skipping backup because \`--no-stash\` was used. @@ -991,10 +1025,12 @@ describe('lint-staged', () => { LOG [STARTED] Hiding unstaged changes to partially staged files... LOG [SUCCESS] Hiding unstaged changes to partially staged files... LOG [STARTED] Running tasks... - LOG [STARTED] Running tasks for *.js - LOG [STARTED] prettier --write FILEPATH - LOG [SUCCESS] prettier --write FILEPATH - LOG [SUCCESS] Running tasks for *.js + LOG [STARTED] /lint-staged — 1 file + LOG [STARTED] *.js — 1 file + LOG [STARTED] prettier --write + LOG [SUCCESS] prettier --write + LOG [SUCCESS] *.js — 1 file + LOG [SUCCESS] /lint-staged — 1 file LOG [SUCCESS] Running tasks... LOG [STARTED] Applying modifications... LOG [SUCCESS] Applying modifications... @@ -1074,6 +1110,43 @@ describe('lint-staged', () => { expect(await readFile('test.js')).toEqual(testJsFilePretty) expect(await readFile('test2.js')).toEqual(testJsFilePretty) }) + + it('should support multiple configuration files', async () => { + // Add some empty files + await writeFile('file.js', '') + await writeFile('deeper/file.js', '') + await writeFile('deeper/even/file.js', '') + await writeFile('deeper/even/deeper/file.js', '') + await writeFile('a/very/deep/file/path/file.js', '') + + const echoJSConfig = (echo) => + `module.exports = { '*.js': (files) => files.map((f) => \`echo ${echo} > \${f}\`) }` + + await writeFile('.lintstagedrc.js', echoJSConfig('level-0')) + await writeFile('deeper/.lintstagedrc.js', echoJSConfig('level-1')) + await writeFile('deeper/even/.lintstagedrc.cjs', echoJSConfig('level-2')) + + // Stage all files + await execGit(['add', '.']) + + // Run lint-staged with `--shell` so that tasks do their thing + await gitCommit({ shell: true }) + + // 'file.js' matched '.lintstagedrc.json' + expect(await readFile('file.js')).toMatch('level-0') + + // 'deeper/file.js' matched 'deeper/.lintstagedrc.json' + expect(await readFile('deeper/file.js')).toMatch('level-1') + + // 'deeper/even/file.js' matched 'deeper/even/.lintstagedrc.json' + expect(await readFile('deeper/even/file.js')).toMatch('level-2') + + // 'deeper/even/deeper/file.js' matched from parent 'deeper/even/.lintstagedrc.json' + expect(await readFile('deeper/even/deeper/file.js')).toMatch('level-2') + + // 'a/very/deep/file/path/file.js' matched '.lintstagedrc.json' + expect(await readFile('a/very/deep/file/path/file.js')).toMatch('level-0') + }) }) describe('lintStaged', () => { @@ -1106,10 +1179,12 @@ describe('lintStaged', () => { LOG [STARTED] Preparing... LOG [SUCCESS] Preparing... LOG [STARTED] Running tasks... - LOG [STARTED] Running tasks for *.js + LOG [STARTED] /lint-staged — 1 file + LOG [STARTED] *.js — 1 file LOG [STARTED] prettier --list-different LOG [SUCCESS] prettier --list-different - LOG [SUCCESS] Running tasks for *.js + LOG [SUCCESS] *.js — 1 file + LOG [SUCCESS] /lint-staged — 1 file LOG [SUCCESS] Running tasks... LOG [STARTED] Applying modifications... LOG [SUCCESS] Applying modifications..." diff --git a/test/loadConfig.spec.js b/test/loadConfig.spec.js index 2123012ae..2a533a19c 100644 --- a/test/loadConfig.spec.js +++ b/test/loadConfig.spec.js @@ -1,3 +1,9 @@ +import path from 'path' + +import makeConsoleMock from 'consolemock' + +import { loadConfig } from '../lib/loadConfig' + jest.mock('../lib/resolveConfig', () => ({ /** Unfortunately necessary due to non-ESM tests. */ resolveConfig: (configPath) => { @@ -9,10 +15,170 @@ jest.mock('../lib/resolveConfig', () => ({ }, })) -import { dynamicImport } from '../lib/loadConfig.js' +jest.unmock('execa') + +/** + * This converts paths into `file://` urls, but this doesn't + * work with `import()` when using babel + jest. + */ +jest.mock('url', () => ({ + pathToFileURL: (path) => path, +})) + +// TODO: Never run tests in the project's WC because this might change source files git status + +describe('loadConfig', () => { + const logger = makeConsoleMock() + + beforeEach(() => { + logger.clearHistory() + }) + + it('should load JSON config file', async () => { + expect.assertions(1) + + const { config } = await loadConfig( + { configPath: path.join(__dirname, '__mocks__', 'my-config.json') }, + logger + ) + + expect(config).toMatchInlineSnapshot(` + Object { + "*": "mytask", + } + `) + }) + + it('should load YAML config file', async () => { + expect.assertions(1) + + const { config } = await loadConfig( + { configPath: path.join(__dirname, '__mocks__', 'my-config.yml') }, + logger + ) + + expect(config).toMatchInlineSnapshot(` + Object { + "*": "mytask", + } + `) + }) + + it('should load CommonJS config file from absolute path', async () => { + expect.assertions(1) + + const { config } = await loadConfig( + { configPath: path.join(__dirname, '__mocks__', 'advanced-config.js') }, + logger + ) + + expect(config).toMatchInlineSnapshot(` + Object { + "*.css": [Function], + "*.js": [Function], + } + `) + }) + + it('should load CommonJS config file from relative path', async () => { + expect.assertions(1) + + const { config } = await loadConfig( + { configPath: path.join('test', '__mocks__', 'advanced-config.js') }, + logger + ) + + expect(config).toMatchInlineSnapshot(` + Object { + "*.css": [Function], + "*.js": [Function], + } + `) + }) + + it('should load CommonJS config file from .cjs file', async () => { + expect.assertions(1) + + const { config } = await loadConfig( + { configPath: path.join('test', '__mocks__', 'my-config.cjs') }, + logger + ) + + expect(config).toMatchInlineSnapshot(` + Object { + "*": "mytask", + } + `) + }) + + it('should load EMS config file from .mjs file', async () => { + expect.assertions(1) + + const { config } = await loadConfig( + { + configPath: path.join('test', '__mocks__', 'esm-config.mjs'), + debug: true, + quiet: true, + }, + logger + ) + + expect(config).toMatchInlineSnapshot(` + Object { + "*": "mytask", + } + `) + }) + + it('should load EMS config file from .js file', async () => { + expect.assertions(1) + + const { config } = await loadConfig( + { + configPath: path.join('test', '__mocks__', 'esm-config-in-js.js'), + debug: true, + quiet: true, + }, + logger + ) + + expect(config).toMatchInlineSnapshot(` + Object { + "*": "mytask", + } + `) + }) + + it('should load a CJS module when specified', async () => { + expect.assertions(1) + + jest.mock('my-lint-staged-config') + + const { config } = await loadConfig( + { configPath: 'my-lint-staged-config', quiet: true, debug: true }, + logger + ) + + expect(config).toMatchInlineSnapshot(` + Object { + "*": "mytask", + } + `) + }) + + it('should return empty object when config file is not found', async () => { + expect.assertions(1) + + const result = await loadConfig({ cwd: '/' }) + + expect(result).toMatchInlineSnapshot(`Object {}`) + }) + + it('should return empty object when explicit config file is not found', async () => { + expect.assertions(1) + + const result = await loadConfig({ configPath: 'fake-config-file.yml' }, logger) -describe('dynamicImport', () => { - it('should log errors into console', () => { - expect(() => dynamicImport('not-found.js')).rejects.toThrowError(`Cannot find module`) + expect(result).toMatchInlineSnapshot(`Object {}`) }) }) diff --git a/test/runAll.spec.js b/test/runAll.spec.js index 32404eb85..6adfde7de 100644 --- a/test/runAll.spec.js +++ b/test/runAll.spec.js @@ -15,6 +15,17 @@ jest.mock('../lib/getStagedFiles') jest.mock('../lib/gitWorkflow') jest.mock('../lib/resolveGitRepo') +jest.mock('../lib/resolveConfig', () => ({ + /** Unfortunately necessary due to non-ESM tests. */ + resolveConfig: (configPath) => { + try { + return require.resolve(configPath) + } catch { + return configPath + } + }, +})) + getStagedFiles.mockImplementation(async () => []) resolveGitRepo.mockImplementation(async () => { @@ -22,6 +33,8 @@ resolveGitRepo.mockImplementation(async () => { return { gitConfigDir: normalize(path.resolve(cwd, '.git')), gitDir: normalize(cwd) } }) +const configPath = '.lintstagedrc.json' + describe('runAll', () => { const globalConsoleTemp = console @@ -39,7 +52,38 @@ describe('runAll', () => { it('should resolve the promise with no tasks', async () => { expect.assertions(1) - await expect(runAll({ config: {} })).resolves.toMatchInlineSnapshot(` + await expect(runAll({ configObject: {}, configPath })).resolves.toMatchInlineSnapshot(` + Object { + "errors": Set {}, + "hasPartiallyStagedFiles": null, + "output": Array [ + "→ No staged files found.", + ], + "quiet": false, + "shouldBackup": true, + } + `) + }) + + it('should throw when failed to find staged files', async () => { + expect.assertions(1) + getStagedFiles.mockImplementationOnce(async () => null) + await expect( + runAll({ configObject: {}, configPath }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`) + }) + + it('should throw when failed to find staged files and quiet', async () => { + expect.assertions(1) + getStagedFiles.mockImplementationOnce(async () => null) + await expect( + runAll({ configObject: {}, configPath, quiet: true }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`) + }) + + it('should print output when no staged files', async () => { + expect.assertions(1) + await expect(runAll({ configObject: {}, configPath })).resolves.toMatchInlineSnapshot(` Object { "errors": Set {}, "hasPartiallyStagedFiles": null, @@ -54,7 +98,8 @@ describe('runAll', () => { it('should not print output when no staged files and quiet', async () => { expect.assertions(1) - await expect(runAll({ config: {}, quiet: true })).resolves.toMatchInlineSnapshot(` + await expect(runAll({ configObject: {}, configPath, quiet: true })).resolves + .toMatchInlineSnapshot(` Object { "errors": Set {}, "hasPartiallyStagedFiles": null, @@ -67,37 +112,39 @@ describe('runAll', () => { it('should resolve the promise with no files', async () => { expect.assertions(1) - await runAll({ config: { '*.js': ['echo "sample"'] } }) + await runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath }) expect(console.printHistory()).toMatchInlineSnapshot(`""`) }) it('should use an injected logger', async () => { expect.assertions(1) const logger = makeConsoleMock() - await runAll({ config: { '*.js': ['echo "sample"'] }, debug: true }, logger) + await runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath, debug: true }, logger) expect(logger.printHistory()).toMatchInlineSnapshot(`""`) }) it('should exit without output when no staged files match configured tasks and quiet', async () => { expect.assertions(1) getStagedFiles.mockImplementationOnce(async () => ['sample.js']) - await runAll({ config: { '*.css': ['echo "sample"'] }, quiet: true }) + await runAll({ configObject: { '*.css': ['echo "sample"'] }, configPath, quiet: true }) expect(console.printHistory()).toMatchInlineSnapshot(`""`) }) it('should not skip tasks if there are files', async () => { expect.assertions(1) getStagedFiles.mockImplementationOnce(async () => ['sample.js']) - await runAll({ config: { '*.js': ['echo "sample"'] } }) + await runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath }) expect(console.printHistory()).toMatchInlineSnapshot(` " LOG [STARTED] Preparing... LOG [SUCCESS] Preparing... LOG [STARTED] Running tasks... - LOG [STARTED] Running tasks for *.js + LOG [STARTED] — 1 file + LOG [STARTED] *.js — 1 file LOG [STARTED] echo \\"sample\\" LOG [SUCCESS] echo \\"sample\\" - LOG [SUCCESS] Running tasks for *.js + LOG [SUCCESS] *.js — 1 file + LOG [SUCCESS] — 1 file LOG [SUCCESS] Running tasks... LOG [STARTED] Applying modifications... LOG [SUCCESS] Applying modifications... @@ -118,7 +165,7 @@ describe('runAll', () => { })) await expect( - runAll({ config: { '*.js': ['echo "sample"'] } }) + runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`) expect(console.printHistory()).toMatchInlineSnapshot(` @@ -126,7 +173,7 @@ describe('runAll', () => { LOG [STARTED] Preparing... ERROR [FAILED] test LOG [STARTED] Running tasks... - INFO [SKIPPED] Skipped because of previous git error. + INFO [SKIPPED] Running tasks... LOG [STARTED] Applying modifications... INFO [SKIPPED] [SKIPPED] ✖ lint-staged failed due to a git error. @@ -150,7 +197,7 @@ describe('runAll', () => { ) await expect( - runAll({ config: { '*.js': ['echo "sample"'] } }) + runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`) expect(console.printHistory()).toMatchInlineSnapshot(` @@ -158,9 +205,11 @@ describe('runAll', () => { LOG [STARTED] Preparing... LOG [SUCCESS] Preparing... LOG [STARTED] Running tasks... - LOG [STARTED] Running tasks for *.js + LOG [STARTED] — 1 file + LOG [STARTED] *.js — 1 file LOG [STARTED] echo \\"sample\\" ERROR [FAILED] echo \\"sample\\" [1] + ERROR [FAILED] echo \\"sample\\" [1] LOG [SUCCESS] Running tasks... LOG [STARTED] Applying modifications... INFO [SKIPPED] Skipped because of errors from tasks. @@ -187,7 +236,7 @@ describe('runAll', () => { ) await expect( - runAll({ config: { '*.js': ['echo "sample"'] } }) + runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`) expect(console.printHistory()).toMatchInlineSnapshot(` @@ -195,9 +244,11 @@ describe('runAll', () => { LOG [STARTED] Preparing... LOG [SUCCESS] Preparing... LOG [STARTED] Running tasks... - LOG [STARTED] Running tasks for *.js + LOG [STARTED] — 1 file + LOG [STARTED] *.js — 1 file LOG [STARTED] echo \\"sample\\" ERROR [FAILED] echo \\"sample\\" [SIGINT] + ERROR [FAILED] echo \\"sample\\" [SIGINT] LOG [SUCCESS] Running tasks... LOG [STARTED] Applying modifications... INFO [SKIPPED] Skipped because of errors from tasks. @@ -226,7 +277,13 @@ describe('runAll', () => { try { // Run lint-staged in `innerCwd` with relative option // This means the sample task will receive `foo.js` - await runAll({ config: { '*.js': mockTask }, stash: false, relative: true, cwd: innerCwd }) + await runAll({ + configObject: { '*.js': mockTask }, + configPath, + stash: false, + relative: true, + cwd: innerCwd, + }) } catch {} // eslint-disable-line no-empty // task received relative `foo.js` diff --git a/test/validateConfig.spec.js b/test/validateConfig.spec.js index b828df61d..970bd5c97 100644 --- a/test/validateConfig.spec.js +++ b/test/validateConfig.spec.js @@ -2,6 +2,8 @@ import makeConsoleMock from 'consolemock' import { validateConfig } from '../lib/validateConfig' +const configPath = '.lintstagedrc.json' + describe('validateConfig', () => { let logger @@ -12,7 +14,7 @@ describe('validateConfig', () => { it('should throw and should print validation errors for invalid config 1', () => { const invalidConfig = 'test' - expect(() => validateConfig(invalidConfig, logger)).toThrowErrorMatchingSnapshot() + expect(() => validateConfig(invalidConfig, configPath, logger)).toThrowErrorMatchingSnapshot() }) it('should throw and should print validation errors for invalid config', () => { @@ -20,13 +22,17 @@ describe('validateConfig', () => { foo: false, } - expect(() => validateConfig(invalidConfig, logger)).toThrowErrorMatchingSnapshot() + expect(() => validateConfig(invalidConfig, configPath, logger)).toThrowErrorMatchingSnapshot() + }) + + it('should throw for empty config', () => { + expect(() => validateConfig({}, configPath, logger)).toThrowErrorMatchingSnapshot() }) it('should wrap function config into object', () => { const functionConfig = (stagedFiles) => [`eslint --fix ${stagedFiles}', 'git add`] - expect(validateConfig(functionConfig, logger)).toEqual({ + expect(validateConfig(functionConfig, configPath, logger)).toEqual({ '*': functionConfig, }) expect(logger.printHistory()).toEqual('') @@ -37,7 +43,7 @@ describe('validateConfig', () => { '*.js': ['eslint --fix', 'git add'], } - expect(() => validateConfig(validSimpleConfig, logger)).not.toThrow() + expect(() => validateConfig(validSimpleConfig, configPath, logger)).not.toThrow() expect(logger.printHistory()).toEqual('') }) @@ -50,7 +56,7 @@ describe('validateConfig', () => { '*.css': [(filenames) => filenames.map((filename) => `eslint --fix ${filename}`)], } - expect(() => validateConfig(functionTask, logger)).not.toThrow() + expect(() => validateConfig(functionTask, configPath, logger)).not.toThrow() expect(logger.printHistory()).toEqual('') }) @@ -68,7 +74,7 @@ describe('validateConfig', () => { subTaskConcurrency: 10, } - expect(() => validateConfig(advancedConfig, logger)).toThrowErrorMatchingSnapshot() + expect(() => validateConfig(advancedConfig, configPath, logger)).toThrowErrorMatchingSnapshot() expect(logger.printHistory()).toMatchSnapshot() }) @@ -77,7 +83,7 @@ describe('validateConfig', () => { concurrent: 'my command', } - expect(() => validateConfig(stillValidConfig, logger)).not.toThrow() + expect(() => validateConfig(stillValidConfig, configPath, logger)).not.toThrow() expect(logger.printHistory()).toEqual('') }) })