From 32806dacff2357695c07ea3708e6742cadaeb82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iiro=20J=C3=A4ppinen?= Date: Wed, 8 Jun 2022 18:44:56 +0300 Subject: [PATCH] test: split integration tests into separate files and improve isolation --- test/__mocks__/execa.js | 15 - test/__mocks__/lilconfig.js | 7 - test/__mocks__/log-symbols.js | 6 - test/__mocks__/npm-which.js | 14 - test/__mocks__/pidtree.js | 3 - test/gitWorkflow.spec.js | 237 --- test/integration.test.js | 1406 ----------------- test/integration/__fixtures__/configs.js | 3 + test/integration/__fixtures__/files.js | 13 + test/integration/__mocks__/dynamicImport.js | 4 + test/integration/__mocks__/resolveConfig.js | 10 + .../__utils__/addConfigFileSerializer.js | 29 + test/integration/__utils__/createTempDir.js | 18 + .../__utils__/isWindows.js} | 3 - .../__utils__/normalizeWindowsNewlines.js | 2 + .../__utils__}/replaceSerializer.js | 0 .../__utils__/withGitIntegration.js | 103 ++ test/integration/allow-empty.test.js | 66 + test/integration/basic-functionality.test.js | 214 +++ test/integration/binary-files.test.js | 33 + test/integration/diff-options.test.js | 75 + test/integration/file-resurrection.test.js | 96 ++ test/integration/files-outside-cwd.test.js | 49 + test/integration/git-amend.test.js | 47 + test/integration/git-lock-file.test.js | 79 + test/integration/git-submodules.test.js | 52 + test/integration/git-worktree.test.js | 38 + test/integration/gitWorkFlow.test.js | 221 +++ test/integration/merge-conflict.test.js | 172 ++ .../integration/multiple-config-files.test.js | 136 ++ test/integration/no-initial-commit.test.js | 39 + test/integration/no-stash.test.js | 102 ++ test/integration/non-ascii.test.js | 46 + test/integration/not-inside-git-repo.test.js | 33 + test/integration/parent-globs.test.js | 49 + .../partially-staged-changes.test.js | 141 ++ test/integration/symlinked-config.test.js | 34 + test/integration/untracked-files.test.js | 64 + test/{ => unit}/__mocks__/advanced-config.js | 0 test/{ => unit}/__mocks__/esm-config-in-js.js | 0 test/{ => unit}/__mocks__/esm-config.mjs | 0 test/{ => unit}/__mocks__/gitWorkflow.js | 0 test/{ => unit}/__mocks__/my-config.cjs | 0 test/{ => unit}/__mocks__/my-config.json | 0 test/{ => unit}/__mocks__/my-config.yml | 0 .../__mocks__/my-lint-staged-config/index.cjs | 0 .../my-lint-staged-config/package.json | 0 .../__snapshots__/validateConfig.spec.js.snap | 0 .../__utils__/mockExecaReturnValue.js} | 12 +- test/{ => unit}/chunkFiles.spec.js | 4 +- test/{ => unit}/dynamicImport.spec.js | 2 +- test/{ => unit}/execGit.spec.js | 10 +- test/{ => unit}/file.spec.js | 2 +- test/{ => unit}/generateTasks.spec.js | 4 +- test/{ => unit}/getRenderer.spec.js | 2 +- test/{ => unit}/getStagedFiles.spec.js | 8 +- test/{ => unit}/index.spec.js | 21 +- test/{ => unit}/index2.spec.js | 16 +- test/{ => unit}/index3.spec.js | 12 +- test/{ => unit}/loadConfig.spec.js | 20 +- test/{ => unit}/makeCmdTasks.spec.js | 8 +- test/{ => unit}/parseGitZOutput.spec.js | 2 +- test/{ => unit}/printTaskOutput.spec.js | 2 +- test/{ => unit}/resolveGitRepo.spec.js | 9 +- test/{ => unit}/resolveTaskFn.spec.js | 41 +- .../{ => unit}/resolveTaskFn.unmocked.spec.js | 4 +- test/{ => unit}/runAll.spec.js | 42 +- test/{ => unit}/searchConfigs.spec.js | 16 +- test/{ => unit}/state.spec.js | 4 +- test/{ => unit}/validateBraces.spec.js | 2 +- test/{ => unit}/validateConfig.spec.js | 2 +- test/{ => unit}/validateOptions.spec.js | 9 +- test/utils/tempDir.js | 17 - 73 files changed, 2119 insertions(+), 1811 deletions(-) delete mode 100644 test/__mocks__/execa.js delete mode 100644 test/__mocks__/lilconfig.js delete mode 100644 test/__mocks__/log-symbols.js delete mode 100644 test/__mocks__/npm-which.js delete mode 100644 test/__mocks__/pidtree.js delete mode 100644 test/gitWorkflow.spec.js delete mode 100644 test/integration.test.js create mode 100644 test/integration/__fixtures__/configs.js create mode 100644 test/integration/__fixtures__/files.js create mode 100644 test/integration/__mocks__/dynamicImport.js create mode 100644 test/integration/__mocks__/resolveConfig.js create mode 100644 test/integration/__utils__/addConfigFileSerializer.js create mode 100644 test/integration/__utils__/createTempDir.js rename test/{utils/crossPlatform.js => integration/__utils__/isWindows.js} (68%) create mode 100644 test/integration/__utils__/normalizeWindowsNewlines.js rename test/{utils => integration/__utils__}/replaceSerializer.js (100%) create mode 100644 test/integration/__utils__/withGitIntegration.js create mode 100644 test/integration/allow-empty.test.js create mode 100644 test/integration/basic-functionality.test.js create mode 100644 test/integration/binary-files.test.js create mode 100644 test/integration/diff-options.test.js create mode 100644 test/integration/file-resurrection.test.js create mode 100644 test/integration/files-outside-cwd.test.js create mode 100644 test/integration/git-amend.test.js create mode 100644 test/integration/git-lock-file.test.js create mode 100644 test/integration/git-submodules.test.js create mode 100644 test/integration/git-worktree.test.js create mode 100644 test/integration/gitWorkFlow.test.js create mode 100644 test/integration/merge-conflict.test.js create mode 100644 test/integration/multiple-config-files.test.js create mode 100644 test/integration/no-initial-commit.test.js create mode 100644 test/integration/no-stash.test.js create mode 100644 test/integration/non-ascii.test.js create mode 100644 test/integration/not-inside-git-repo.test.js create mode 100644 test/integration/parent-globs.test.js create mode 100644 test/integration/partially-staged-changes.test.js create mode 100644 test/integration/symlinked-config.test.js create mode 100644 test/integration/untracked-files.test.js rename test/{ => unit}/__mocks__/advanced-config.js (100%) rename test/{ => unit}/__mocks__/esm-config-in-js.js (100%) rename test/{ => unit}/__mocks__/esm-config.mjs (100%) rename test/{ => unit}/__mocks__/gitWorkflow.js (100%) rename test/{ => unit}/__mocks__/my-config.cjs (100%) rename test/{ => unit}/__mocks__/my-config.json (100%) rename test/{ => unit}/__mocks__/my-config.yml (100%) rename test/{ => unit}/__mocks__/my-lint-staged-config/index.cjs (100%) rename test/{ => unit}/__mocks__/my-lint-staged-config/package.json (100%) rename test/{ => unit}/__snapshots__/validateConfig.spec.js.snap (100%) rename test/{utils/createExecaReturnValue.js => unit/__utils__/mockExecaReturnValue.js} (67%) rename test/{ => unit}/chunkFiles.spec.js (94%) rename test/{ => unit}/dynamicImport.spec.js (75%) rename test/{ => unit}/execGit.spec.js (76%) rename test/{ => unit}/file.spec.js (87%) rename test/{ => unit}/generateTasks.spec.js (98%) rename test/{ => unit}/getRenderer.spec.js (96%) rename test/{ => unit}/getStagedFiles.spec.js (93%) rename test/{ => unit}/index.spec.js (79%) rename test/{ => unit}/index2.spec.js (87%) rename test/{ => unit}/index3.spec.js (93%) rename test/{ => unit}/loadConfig.spec.js (85%) rename test/{ => unit}/makeCmdTasks.spec.js (95%) rename test/{ => unit}/parseGitZOutput.spec.js (88%) rename test/{ => unit}/printTaskOutput.spec.js (84%) rename test/{ => unit}/resolveGitRepo.spec.js (90%) rename test/{ => unit}/resolveTaskFn.spec.js (92%) rename test/{ => unit}/resolveTaskFn.unmocked.spec.js (89%) rename test/{ => unit}/runAll.spec.js (91%) rename test/{ => unit}/searchConfigs.spec.js (89%) rename test/{ => unit}/state.spec.js (93%) rename test/{ => unit}/validateBraces.spec.js (97%) rename test/{ => unit}/validateConfig.spec.js (97%) rename test/{ => unit}/validateOptions.spec.js (94%) delete mode 100644 test/utils/tempDir.js diff --git a/test/__mocks__/execa.js b/test/__mocks__/execa.js deleted file mode 100644 index 4534fd3e0..000000000 --- a/test/__mocks__/execa.js +++ /dev/null @@ -1,15 +0,0 @@ -import { createExecaReturnValue } from '../utils/createExecaReturnValue' - -export const execa = jest.fn(() => - createExecaReturnValue({ - stdout: 'a-ok', - stderr: '', - code: 0, - cmd: 'mock cmd', - failed: false, - killed: false, - signal: null, - }) -) - -export const execaCommand = execa diff --git a/test/__mocks__/lilconfig.js b/test/__mocks__/lilconfig.js deleted file mode 100644 index da03c4a60..000000000 --- a/test/__mocks__/lilconfig.js +++ /dev/null @@ -1,7 +0,0 @@ -const actual = jest.requireActual('lilconfig') - -function lilconfig(name, options) { - return actual.lilconfig(name, options) -} - -module.exports.lilconfig = jest.fn(lilconfig) diff --git a/test/__mocks__/log-symbols.js b/test/__mocks__/log-symbols.js deleted file mode 100644 index bb911219e..000000000 --- a/test/__mocks__/log-symbols.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - info: 'i', - success: '√', - warning: '‼', - error: '×', -} diff --git a/test/__mocks__/npm-which.js b/test/__mocks__/npm-which.js deleted file mode 100644 index b8e76f648..000000000 --- a/test/__mocks__/npm-which.js +++ /dev/null @@ -1,14 +0,0 @@ -const mockFn = jest.fn((path) => { - if (path.includes('missing')) { - throw new Error(`not found: ${path}`) - } - return path -}) - -module.exports = function npmWhich() { - return { - sync: mockFn, - } -} - -module.exports.mockFn = mockFn diff --git a/test/__mocks__/pidtree.js b/test/__mocks__/pidtree.js deleted file mode 100644 index 26ca35289..000000000 --- a/test/__mocks__/pidtree.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = () => { - return Promise.resolve([]) -} diff --git a/test/gitWorkflow.spec.js b/test/gitWorkflow.spec.js deleted file mode 100644 index 6d51a48e1..000000000 --- a/test/gitWorkflow.spec.js +++ /dev/null @@ -1,237 +0,0 @@ -import path from 'path' - -import fs from 'fs-extra' -import normalize from 'normalize-path' - -import { execGit as execGitBase } from '../lib/execGit' -import { writeFile } from '../lib/file' -import { GitWorkflow } from '../lib/gitWorkflow' -import { getInitialState } from '../lib/state' - -import { createTempDir } from './utils/tempDir' -import { normalizeWindowsNewlines } from './utils/crossPlatform' - -jest.mock('../lib/file.js') -jest.unmock('execa') - -jest.setTimeout(20000) - -let tmpDir, cwd - -/** Append to file, creating if it doesn't exist */ -const appendFile = async (filename, content, dir = cwd) => - fs.appendFile(path.resolve(dir, filename), content) - -const readFile = async (filename, dir = cwd) => - fs.readFile(path.resolve(dir, filename), { encoding: 'utf-8' }) - -/** Wrap execGit to always pass `gitOps` */ -const execGit = async (args) => execGitBase(args, { cwd }) - -/** Initialize git repo for test */ -const initGitRepo = async () => { - await execGit('init') - await execGit(['config', 'user.name', '"test"']) - await execGit(['config', 'user.email', '"test@test.com"']) - await appendFile('README.md', '# Test\n') - await execGit(['add', 'README.md']) - await execGit(['commit', '-m initial commit']) -} - -describe('gitWorkflow', () => { - beforeEach(async () => { - tmpDir = await createTempDir() - cwd = normalize(tmpDir) - await initGitRepo() - }) - - afterEach(async () => { - await fs.remove(tmpDir) - }) - - describe('prepare', () => { - it('should handle errors', async () => { - const gitWorkflow = new GitWorkflow({ - gitDir: cwd, - gitConfigDir: path.resolve(cwd, './.git'), - }) - jest.doMock('execa', () => Promise.reject({})) - const ctx = getInitialState() - // mock a simple failure - gitWorkflow.getPartiallyStagedFiles = () => ['foo'] - gitWorkflow.getHiddenFilepath = () => { - throw new Error('test') - } - await expect(gitWorkflow.prepare(ctx, false)).rejects.toThrowErrorMatchingInlineSnapshot( - `"test"` - ) - expect(ctx).toMatchInlineSnapshot(` - Object { - "errors": Set { - Symbol(GitError), - }, - "events": EventEmitter { - "_events": Object {}, - "_eventsCount": 0, - "_maxListeners": undefined, - Symbol(kCapture): false, - }, - "hasPartiallyStagedFiles": true, - "output": Array [], - "quiet": false, - "shouldBackup": null, - } - `) - }) - }) - - describe('cleanup', () => { - it('should handle errors', async () => { - const gitWorkflow = new GitWorkflow({ - gitDir: cwd, - gitConfigDir: path.resolve(cwd, './.git'), - }) - const ctx = getInitialState() - await expect(gitWorkflow.cleanup(ctx)).rejects.toThrowErrorMatchingInlineSnapshot( - `"lint-staged automatic backup is missing!"` - ) - expect(ctx).toMatchInlineSnapshot(` - Object { - "errors": Set { - Symbol(GetBackupStashError), - Symbol(GitError), - }, - "events": EventEmitter { - "_events": Object {}, - "_eventsCount": 0, - "_maxListeners": undefined, - Symbol(kCapture): false, - }, - "hasPartiallyStagedFiles": null, - "output": Array [], - "quiet": false, - "shouldBackup": null, - } - `) - }) - }) - - describe('getPartiallyStagedFiles', () => { - it('should return unquoted files', async () => { - const gitWorkflow = new GitWorkflow({ - gitDir: cwd, - gitConfigDir: path.resolve(cwd, './.git'), - }) - await appendFile('file with spaces.txt', 'staged content') - await appendFile('file_without_spaces.txt', 'staged content') - await execGit(['add', 'file with spaces.txt']) - await execGit(['add', 'file_without_spaces.txt']) - await appendFile('file with spaces.txt', 'not staged content') - await appendFile('file_without_spaces.txt', 'not staged content') - - expect(await gitWorkflow.getPartiallyStagedFiles()).toStrictEqual([ - 'file with spaces.txt', - 'file_without_spaces.txt', - ]) - }) - it('should include to and from for renamed files', async () => { - const gitWorkflow = new GitWorkflow({ - gitDir: cwd, - gitConfigDir: path.resolve(cwd, './.git'), - }) - await appendFile('original.txt', 'test content') - await execGit(['add', 'original.txt']) - await execGit(['commit', '-m "Add original.txt"']) - await appendFile('original.txt', 'additional content') - await execGit(['mv', 'original.txt', 'renamed.txt']) - - expect(await gitWorkflow.getPartiallyStagedFiles()).toStrictEqual([ - 'renamed.txt\u0000original.txt', - ]) - }) - }) - - describe('hideUnstagedChanges', () => { - it('should handle errors', async () => { - const gitWorkflow = new GitWorkflow({ - gitDir: cwd, - gitConfigDir: path.resolve(cwd, './.git'), - }) - const totallyRandom = `totally_random_file-${Date.now().toString()}` - gitWorkflow.partiallyStagedFiles = [totallyRandom] - const ctx = getInitialState() - await expect(gitWorkflow.hideUnstagedChanges(ctx)).rejects.toThrowError( - `pathspec '${totallyRandom}' did not match any file(s) known to git` - ) - expect(ctx).toMatchInlineSnapshot(` - Object { - "errors": Set { - Symbol(GitError), - Symbol(HideUnstagedChangesError), - }, - "events": EventEmitter { - "_events": Object {}, - "_eventsCount": 0, - "_maxListeners": undefined, - Symbol(kCapture): false, - }, - "hasPartiallyStagedFiles": null, - "output": Array [], - "quiet": false, - "shouldBackup": null, - } - `) - }) - - it('should checkout renamed file when hiding changes', async () => { - const gitWorkflow = new GitWorkflow({ - gitDir: cwd, - gitConfigDir: path.resolve(cwd, './.git'), - }) - - const origContent = await readFile('README.md') - await execGit(['mv', 'README.md', 'TEST.md']) - await appendFile('TEST.md', 'added content') - - gitWorkflow.partiallyStagedFiles = await gitWorkflow.getPartiallyStagedFiles() - const ctx = getInitialState() - await gitWorkflow.hideUnstagedChanges(ctx) - - /** @todo `git mv` in GitHub Windows runners seem to add `\r\n` newlines in this case. */ - expect(normalizeWindowsNewlines(await readFile('TEST.md'))).toStrictEqual(origContent) - }) - }) - - describe('restoreMergeStatus', () => { - it('should handle error when restoring merge state fails', async () => { - const gitWorkflow = new GitWorkflow({ - gitDir: cwd, - gitConfigDir: path.resolve(cwd, './.git'), - }) - gitWorkflow.mergeHeadBuffer = true - writeFile.mockImplementation(() => Promise.reject('test')) - const ctx = getInitialState() - await expect(gitWorkflow.restoreMergeStatus(ctx)).rejects.toThrowErrorMatchingInlineSnapshot( - `"Merge state could not be restored due to an error!"` - ) - expect(ctx).toMatchInlineSnapshot(` - Object { - "errors": Set { - Symbol(GitError), - Symbol(RestoreMergeStatusError), - }, - "events": EventEmitter { - "_events": Object {}, - "_eventsCount": 0, - "_maxListeners": undefined, - Symbol(kCapture): false, - }, - "hasPartiallyStagedFiles": null, - "output": Array [], - "quiet": false, - "shouldBackup": null, - } - `) - }) - }) -}) diff --git a/test/integration.test.js b/test/integration.test.js deleted file mode 100644 index afc0bda45..000000000 --- a/test/integration.test.js +++ /dev/null @@ -1,1406 +0,0 @@ -import path from 'path' - -import makeConsoleMock from 'consolemock' -import fs from 'fs-extra' -import ansiSerializer from 'jest-snapshot-serializer-ansi' -import normalize from 'normalize-path' - -jest.unmock('lilconfig') -jest.unmock('execa') - -jest.mock('../lib/resolveConfig', () => ({ - /** Unfortunately necessary due to non-ESM tests. */ - resolveConfig: (configPath) => { - try { - return require.resolve(configPath) - } catch { - return configPath - } - }, -})) - -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' - -import { replaceSerializer } from './utils/replaceSerializer' -import { createTempDir } from './utils/tempDir' -import { isWindows, isWindowsActions, normalizeWindowsNewlines } from './utils/crossPlatform' - -jest.setTimeout(20000) -jest.retryTimes(2) - -// 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", -}; -` - -const testJsFileUgly = `module.exports = { - 'foo': 'bar' -} -` - -const testJsFileUnfixable = `const obj = { - 'foo': 'bar' -` - -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 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) => { - 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) => { - const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename) - await ensureDir(filepath) - await fs.writeFile(filepath, content) -} - -// Wrap execGit to always pass `gitOps` -const execGit = async (args, options = {}) => execGitBase(args, { cwd, ...options }) - -/** - * Execute lintStaged before git commit to emulate lint-staged cli. - * The Node.js API doesn't throw on failures, but will return `false`. - */ -const gitCommit = async (options, args = ['-m test']) => { - const passed = await lintStaged({ cwd, ...options }) - if (!passed) throw new Error('lint-staged failed') - await execGit(['commit', ...args], { cwd, ...options }) -} - -describe('lint-staged', () => { - it('should fail when not in a git directory', async () => { - const nonGitDir = await createTempDir() - const logger = makeConsoleMock() - await expect(lintStaged({ ...fixJsConfig, cwd: nonGitDir }, logger)).resolves.toEqual(false) - expect(logger.printHistory()).toMatchInlineSnapshot(` - " - ERROR ✖ Current directory is not a git directory!" - `) - await fs.remove(nonGitDir) - }) - - it('should fail without output when not in a git directory and quiet', async () => { - const nonGitDir = await createTempDir() - const logger = makeConsoleMock() - await expect( - lintStaged({ ...fixJsConfig, cwd: nonGitDir, quiet: true }, logger) - ).resolves.toEqual(false) - expect(logger.printHistory()).toMatchInlineSnapshot(`""`) - await fs.remove(nonGitDir) - }) -}) - -const globalConsoleTemp = console - -// Tests should be resilient to `git config init.defaultBranch` that is _not_ "master" -let defaultBranchName = 'UNSET' - -describe('lint-staged', () => { - beforeAll(() => { - console = makeConsoleMock() - }) - - beforeEach(async () => { - tmpDir = await createTempDir() - cwd = normalize(tmpDir) - // Init repository with initial commit - await execGit('init') - await execGit(['config', 'user.name', '"test"']) - await execGit(['config', 'user.email', '"test@test.com"']) - if (isWindowsActions()) await execGit(['config', 'core.autocrlf', 'input']) - await appendFile('README.md', '# Test\n') - await execGit(['add', 'README.md']) - await execGit(['commit', '-m initial commit']) - - if (defaultBranchName === 'UNSET') { - defaultBranchName = await execGit(['rev-parse', '--abbrev-ref', 'HEAD']) - } - }) - - afterEach(async () => { - console.clearHistory() - await fs.remove(tmpDir) - }) - - afterAll(() => { - console = globalConsoleTemp - }) - - it('should exit early with no staged files', async () => { - expect(() => lintStaged({ config: { '*.js': 'echo success' }, cwd })).resolves - }) - - it('Should commit entire staged file when no errors from linter', async () => { - // Stage pretty file - await appendFile('test file.js', testJsFilePretty) - await execGit(['add', 'test file.js']) - - // Run lint-staged with `prettier --list-different` and commit pretty file - await gitCommit({ config: { '*.js': 'prettier --list-different' } }) - - // Nothing is wrong, so a new commit is created - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - expect(await readFile('test file.js')).toEqual(testJsFilePretty) - }) - - it('Should commit entire staged file when no errors and linter modifies file', async () => { - // Stage multiple ugly files - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - await appendFile('test2.js', testJsFileUgly) - await execGit(['add', 'test2.js']) - - // Run lint-staged with `prettier --write` and commit pretty file - await gitCommit(fixJsConfig) - - // Nothing is wrong, so a new commit is created and file is pretty - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - expect(await readFile('test.js')).toEqual(testJsFilePretty) - expect(await readFile('test2.js')).toEqual(testJsFilePretty) - }) - - it('Should fail to commit entire staged file when errors from linter', async () => { - // Stage ugly file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - const status = await execGit(['status']) - - // Run lint-staged with `prettier --list-different` to break the linter - try { - await gitCommit({ config: { '*.js': 'prettier --list-different' } }) - } catch (error) { - expect(error.message).toMatchInlineSnapshot(`"lint-staged failed"`) - } - - // Something was wrong so the repo is returned to original state - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - expect(await execGit(['status'])).toEqual(status) - expect(await readFile('test.js')).toEqual(testJsFileUgly) - }) - - it('Should fail to commit entire staged file when errors from linter and linter modifies files', async () => { - // Add unfixable file to commit so `prettier --write` breaks - await appendFile('test.js', testJsFileUnfixable) - await execGit(['add', 'test.js']) - const status = await execGit(['status']) - - // Run lint-staged with `prettier --write` to break the linter - try { - await gitCommit(fixJsConfig) - } catch (error) { - expect(error.message).toMatchInlineSnapshot(`"lint-staged failed"`) - } - - // Something was wrong so the repo is returned to original state - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - expect(await execGit(['status'])).toEqual(status) - expect(await readFile('test.js')).toEqual(testJsFileUnfixable) - }) - - it('Should fail to commit entire staged file when there are unrecoverable merge conflicts', async () => { - // Stage file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Run lint-staged with action that does horrible things to the file, causing a merge conflict - const testFile = path.resolve(cwd, 'test.js') - await expect( - gitCommit({ - config: { - '*.js': () => { - fs.writeFileSync(testFile, Buffer.from(testJsFileUnfixable, 'binary')) - return `prettier --write ${testFile}` - }, - }, - }) - ).rejects.toThrowError() - - expect(console.printHistory()).toMatch( - 'Unstaged changes could not be restored due to a merge conflict!' - ) - - // Something was wrong so the repo is returned to original state - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - // Git status is a bit messed up because the horrible things we did - // in the config above were done before creating the initial backup stash, - // and thus included in it. - expect(await execGit(['status', '--porcelain'])).toMatchInlineSnapshot(`"AM test.js"`) - }) - - it('Should commit partial change from partially staged file when no errors from linter', async () => { - // Stage pretty file - await appendFile('test.js', testJsFilePretty) - await execGit(['add', 'test.js']) - - // Edit pretty file but do not stage changes - const appended = `\nconsole.log("test");\n` - await appendFile('test.js', appended) - - // Run lint-staged with `prettier --list-different` and commit pretty file - await gitCommit({ config: { '*.js': 'prettier --list-different' } }) - - expect(console.printHistory()).toMatchInlineSnapshot(` - " - LOG [STARTED] Preparing lint-staged... - LOG [SUCCESS] Preparing lint-staged... - LOG [STARTED] Hiding unstaged changes to partially staged files... - LOG [SUCCESS] Hiding unstaged changes to partially staged files... - LOG [STARTED] Running tasks for staged files... - LOG [STARTED] Config object — 1 file - LOG [STARTED] *.js — 1 file - LOG [STARTED] prettier --list-different - LOG [SUCCESS] prettier --list-different - LOG [SUCCESS] *.js — 1 file - LOG [SUCCESS] Config object — 1 file - LOG [SUCCESS] Running tasks for staged files... - LOG [STARTED] Applying modifications from tasks... - LOG [SUCCESS] Applying modifications from tasks... - LOG [STARTED] Restoring unstaged changes to partially staged files... - LOG [SUCCESS] Restoring unstaged changes to partially staged files... - LOG [STARTED] Cleaning up temporary files... - LOG [SUCCESS] Cleaning up temporary files..." - `) - - // Nothing is wrong, so a new commit is created and file is pretty - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - - // Latest commit contains pretty file - // `git show` strips empty line from here here - expect(await execGit(['show', 'HEAD:test.js'])).toEqual(testJsFilePretty.trim()) - - // Since edit was not staged, the file is still modified - const status = await execGit(['status']) - expect(status).toMatch('modified: test.js') - expect(status).toMatch('no changes added to commit') - /** @todo `git` in GitHub Windows runners seem to add `\r\n` newlines in this case. */ - expect(normalizeWindowsNewlines(await readFile('test.js'))).toEqual(testJsFilePretty + appended) - }) - - it('Should commit partial change from partially staged file when no errors from linter and linter modifies file', async () => { - // Stage ugly file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Edit ugly file but do not stage changes - const appended = '\n\nconsole.log("test");\n' - await appendFile('test.js', appended) - - // Run lint-staged with `prettier --write` and commit pretty file - await gitCommit(fixJsConfig) - - // Nothing is wrong, so a new commit is created and file is pretty - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - - // Latest commit contains pretty file - // `git show` strips empty line from here here - expect(await execGit(['show', 'HEAD:test.js'])).toEqual(testJsFilePretty.trim()) - - // Nothing is staged - const status = await execGit(['status']) - expect(status).toMatch('modified: test.js') - expect(status).toMatch('no changes added to commit') - - // File is pretty, and has been edited - expect(await readFile('test.js')).toEqual(testJsFilePretty + appended) - }) - - it('Should fail to commit partial change from partially staged file when errors from linter', async () => { - // Stage ugly file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Edit ugly file but do not stage changes - const appended = '\nconsole.log("test");\n' - await appendFile('test.js', appended) - const status = await execGit(['status']) - - // Run lint-staged with `prettier --list-different` to break the linter - await expect( - gitCommit({ config: { '*.js': 'prettier --list-different' } }) - ).rejects.toThrowError() - - const output = console.printHistory() - expect(output).toMatch('Reverting to original state because of errors') - - // Something was wrong so the repo is returned to original state - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - expect(await execGit(['status'])).toEqual(status) - expect(await readFile('test.js')).toEqual(testJsFileUgly + appended) - }) - - it('Should fail to commit partial change from partially staged file when errors from linter and linter modifies files', async () => { - // Add unfixable file to commit so `prettier --write` breaks - await appendFile('test.js', testJsFileUnfixable) - await execGit(['add', 'test.js']) - - // Edit unfixable file but do not stage changes - const appended = '\nconsole.log("test");\n' - await appendFile('test.js', appended) - const status = await execGit(['status']) - - // Run lint-staged with `prettier --write` to break the linter - try { - await gitCommit(fixJsConfig) - } catch (error) { - expect(error.message).toMatchInlineSnapshot(`"lint-staged failed"`) - } - - // Something was wrong so the repo is returned to original state - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - expect(await execGit(['status'])).toEqual(status) - expect(await readFile('test.js')).toEqual(testJsFileUnfixable + appended) - }) - - it('Should clear unstaged changes when linter applies same changes', async () => { - // Stage ugly file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Replace ugly file with pretty but do not stage changes - await fs.remove(path.join(cwd, 'test.js')) - await appendFile('test.js', testJsFilePretty) - - // Run lint-staged with `prettier --write` and commit pretty file - await gitCommit(fixJsConfig) - - // Nothing is wrong, so a new commit is created and file is pretty - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - - // Latest commit contains pretty file - // `git show` strips empty line from here here - expect(await execGit(['show', 'HEAD:test.js'])).toEqual(testJsFilePretty.trim()) - - // Nothing is staged - expect(await execGit(['status'])).toMatch('nothing to commit, working tree clean') - - // File is pretty, and has been edited - expect(await readFile('test.js')).toEqual(testJsFilePretty) - }) - - it('Should fail when linter creates a .git/index.lock', async () => { - // Stage ugly file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Edit ugly file but do not stage changes - const appended = '\n\nconsole.log("test");\n' - await appendFile('test.js', appended) - expect(await readFile('test.js')).toEqual(testJsFileUgly + appended) - const diff = await execGit(['diff']) - - // Run lint-staged with `prettier --write` and commit pretty file - // The task creates a git lock file and runs `git add` to simulate failure - await expect( - gitCommit({ - shell: isWindows, - config: { - '*.js': (files) => [ - `${isWindows ? 'type nul >' : 'touch'} ${cwd}/.git/index.lock`, - `prettier --write ${files.join(' ')}`, - `git add ${files.join(' ')}`, - ], - }, - }) - ).rejects.toThrowError() - - const output = console.printHistory() - expect(output).toMatch('Another git process seems to be running in this repository') - - // Something was wrong so new commit wasn't created - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - - // But local modifications are gone - expect(await execGit(['diff'])).not.toEqual(diff) - expect(await execGit(['diff'])).toMatchInlineSnapshot(` - "diff --git a/test.js b/test.js - index 1eff6a0..8baadc8 100644 - --- a/test.js - +++ b/test.js - @@ -1,3 +1,3 @@ - module.exports = { - - 'foo': 'bar' - -} - + foo: \\"bar\\", - +};" - `) - - expect(await readFile('test.js')).not.toEqual(testJsFileUgly + appended) - expect(await readFile('test.js')).toEqual(testJsFilePretty) - - // Remove lock file - await fs.remove(`${cwd}/.git/index.lock`) - - // Luckily there is a stash - expect(await execGit(['stash', 'list'])).toMatchInlineSnapshot( - `"stash@{0}: lint-staged automatic backup"` - ) - await execGit(['reset', '--hard']) - await execGit(['stash', 'pop', '--index']) - - expect(await execGit(['diff'])).toEqual(diff) - expect(await readFile('test.js')).toEqual(testJsFileUgly + appended) - }) - - it('should handle merge conflicts', async () => { - await execGit(['config', 'merge.conflictstyle', 'merge']) - - const fileInBranchA = `module.exports = "foo";\n` - const fileInBranchB = `module.exports = 'bar'\n` - const fileInBranchBFixed = `module.exports = "bar";\n` - - // Create one branch - await execGit(['checkout', '-b', 'branch-a']) - await appendFile('test.js', fileInBranchA) - await execGit(['add', '.']) - await gitCommit(fixJsConfig, ['-m commit a']) - expect(await readFile('test.js')).toEqual(fileInBranchA) - - await execGit(['checkout', defaultBranchName]) - - // Create another branch - await execGit(['checkout', '-b', 'branch-b']) - await appendFile('test.js', fileInBranchB) - await execGit(['add', '.']) - await gitCommit(fixJsConfig, ['-m commit b']) - expect(await readFile('test.js')).toEqual(fileInBranchBFixed) - - // Merge first branch - await execGit(['checkout', defaultBranchName]) - await execGit(['merge', 'branch-a']) - expect(await readFile('test.js')).toEqual(fileInBranchA) - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('commit a') - - // Merge second branch, causing merge conflict - try { - await execGit(['merge', 'branch-b']) - } catch (error) { - expect(error.message).toMatch('Merge conflict in test.js') - } - - expect(await readFile('test.js')).toMatchInlineSnapshot(` - "<<<<<<< HEAD - module.exports = \\"foo\\"; - ======= - module.exports = \\"bar\\"; - >>>>>>> branch-b - " - `) - - // Fix conflict and commit using lint-staged - await writeFile('test.js', fileInBranchB) - expect(await readFile('test.js')).toEqual(fileInBranchB) - await execGit(['add', '.']) - - // Do not use `gitCommit` wrapper here - await lintStaged({ ...fixJsConfig, cwd, quiet: true }) - await execGit(['commit', '--no-edit']) - - // Nothing is wrong, so a new commit is created and file is pretty - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('4') - const log = await execGit(['log', '-1', '--pretty=%B']) - expect(log).toMatch(`Merge branch 'branch-b`) - expect(log).toMatch(`Conflicts:`) - expect(log).toMatch(`test.js`) - expect(await readFile('test.js')).toEqual(fileInBranchBFixed) - }) - - it('should handle merge conflict when task errors', async () => { - await execGit(['config', 'merge.conflictstyle', 'merge']) - - const fileInBranchA = `module.exports = "foo";\n` - const fileInBranchB = `module.exports = 'bar'\n` - const fileInBranchBFixed = `module.exports = "bar";\n` - - // Create one branch - await execGit(['checkout', '-b', 'branch-a']) - await appendFile('test.js', fileInBranchA) - await execGit(['add', '.']) - await gitCommit(fixJsConfig, ['-m commit a']) - expect(await readFile('test.js')).toEqual(fileInBranchA) - - await execGit(['checkout', defaultBranchName]) - - // Create another branch - await execGit(['checkout', '-b', 'branch-b']) - await appendFile('test.js', fileInBranchB) - await execGit(['add', '.']) - await gitCommit(fixJsConfig, ['-m commit b']) - expect(await readFile('test.js')).toEqual(fileInBranchBFixed) - - // Merge first branch - await execGit(['checkout', defaultBranchName]) - await execGit(['merge', 'branch-a']) - expect(await readFile('test.js')).toEqual(fileInBranchA) - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('commit a') - - // Merge second branch, causing merge conflict - await expect(execGit(['merge', 'branch-b'])).rejects.toThrowError('Merge conflict in test.js') - - expect(await readFile('test.js')).toMatchInlineSnapshot(` - "<<<<<<< HEAD - module.exports = \\"foo\\"; - ======= - module.exports = \\"bar\\"; - >>>>>>> branch-b - " - `) - - // Fix conflict and commit using lint-staged - await writeFile('test.js', fileInBranchB) - expect(await readFile('test.js')).toEqual(fileInBranchB) - await execGit(['add', '.']) - - // Do not use `gitCommit` wrapper here - await expect( - lintStaged({ config: { '*.js': 'prettier --list-different' }, cwd, quiet: true }) - ).resolves.toEqual(false) // Did not pass so returns `false` - - // Something went wrong, so lintStaged failed and merge is still going - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['status'])).toMatch('All conflicts fixed but you are still merging') - expect(await readFile('test.js')).toEqual(fileInBranchB) - }) - - it('should keep untracked files', async () => { - // Stage pretty file - await appendFile('test.js', testJsFilePretty) - await execGit(['add', 'test.js']) - - // Add untracked files - await appendFile('test-untracked.js', testJsFilePretty) - await appendFile('.gitattributes', 'binary\n') - await writeFile('binary', Buffer.from('Hello, World!', 'binary')) - - // Run lint-staged with `prettier --list-different` and commit pretty file - await gitCommit({ config: { '*.js': 'prettier --list-different' } }) - - // Nothing is wrong, so a new commit is created - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - expect(await readFile('test.js')).toEqual(testJsFilePretty) - expect(await readFile('test-untracked.js')).toEqual(testJsFilePretty) - expect(Buffer.from(await readFile('binary'), 'binary').toString()).toEqual('Hello, World!') - }) - - it('should keep untracked files when taks fails', async () => { - // Stage unfixable file - await appendFile('test.js', testJsFileUnfixable) - await execGit(['add', 'test.js']) - - // Add untracked files - await appendFile('test-untracked.js', testJsFilePretty) - await appendFile('.gitattributes', 'binary\n') - await writeFile('binary', Buffer.from('Hello, World!', 'binary')) - - // Run lint-staged with `prettier --list-different` and commit pretty file - await expect( - gitCommit({ config: { '*.js': 'prettier --list-different' } }) - ).rejects.toThrowError() - - // Something was wrong so the repo is returned to original state - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - expect(await readFile('test.js')).toEqual(testJsFileUnfixable) - expect(await readFile('test-untracked.js')).toEqual(testJsFilePretty) - expect(Buffer.from(await readFile('binary'), 'binary').toString()).toEqual('Hello, World!') - }) - - it('should work when amending previous commit with unstaged changes', async () => { - // Edit file from previous commit - await appendFile('README.md', '\n## Amended\n') - await execGit(['add', 'README.md']) - - // Edit again, but keep it unstaged - await appendFile('README.md', '\n## Edited\n') - await appendFile('test-untracked.js', testJsFilePretty) - - // Run lint-staged with `prettier --list-different` and commit pretty file - await gitCommit({ config: { '*.{js,md}': 'prettier --list-different' } }, [ - '--amend', - '--no-edit', - ]) - - // Nothing is wrong, so the commit was amended - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - expect(await readFile('README.md')).toMatchInlineSnapshot(` - "# Test - - ## Amended - - ## Edited - " - `) - expect(await readFile('test-untracked.js')).toEqual(testJsFilePretty) - const status = await execGit(['status']) - expect(status).toMatch('modified: README.md') - expect(status).toMatch('test-untracked.js') - expect(status).toMatch('no changes added to commit') - }) - - it('should not resurrect removed files due to git bug when tasks pass', async () => { - const readmeFile = path.resolve(cwd, 'README.md') - await fs.remove(readmeFile) // Remove file from previous commit - await appendFile('test.js', testJsFilePretty) - await execGit(['add', 'test.js']) - await lintStaged({ cwd, config: { '*.{js,md}': 'prettier --list-different' } }) - const exists = await fs.exists(readmeFile) - expect(exists).toEqual(false) - }) - - it('should not resurrect removed files in complex case', async () => { - // Add file to index, and remove it from disk - await appendFile('test.js', testJsFilePretty) - await execGit(['add', 'test.js']) - const testFile = path.resolve(cwd, 'test.js') - await fs.remove(testFile) - - // Rename file in index, and remove it from disk - const readmeFile = path.resolve(cwd, 'README.md') - const readme = await readFile(readmeFile) - await fs.remove(readmeFile) - await execGit(['add', readmeFile]) - const newReadmeFile = path.resolve(cwd, 'README_NEW.md') - await appendFile(newReadmeFile, readme) - await execGit(['add', newReadmeFile]) - await fs.remove(newReadmeFile) - - const status = await execGit(['status', '--porcelain']) - expect(status).toMatchInlineSnapshot(` - "RD README.md -> README_NEW.md - AD test.js" - `) - - await lintStaged({ cwd, config: { '*.{js,md}': 'prettier --list-different' } }) - expect(await fs.exists(testFile)).toEqual(false) - expect(await fs.exists(newReadmeFile)).toEqual(false) - expect(await execGit(['status', '--porcelain'])).toEqual(status) - }) - - it('should not resurrect removed files due to git bug when tasks fail', async () => { - const readmeFile = path.resolve(cwd, 'README.md') - await fs.remove(readmeFile) // Remove file from previous commit - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - await expect( - lintStaged({ allowEmpty: true, cwd, config: { '*.{js,md}': 'prettier --list-different' } }) - ).resolves.toEqual(false) - const exists = await fs.exists(readmeFile) - expect(exists).toEqual(false) - }) - - it('should handle binary files', async () => { - // mark file as binary - await appendFile('.gitattributes', 'binary\n') - - // Stage pretty file - await writeFile('binary', Buffer.from('Hello, World!', 'binary')) - await execGit(['add', 'binary']) - - // Run lint-staged with `prettier --list-different` and commit pretty file - await gitCommit({ config: { '*.js': 'prettier --list-different' } }) - - // Nothing is wrong, so a new commit is created - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - expect(Buffer.from(await readFile('binary'), 'binary').toString()).toEqual('Hello, World!') - }) - - it('should run chunked tasks when necessary', async () => { - // Stage two files - await appendFile('test.js', testJsFilePretty) - await execGit(['add', 'test.js']) - await appendFile('test2.js', testJsFilePretty) - await execGit(['add', 'test2.js']) - - // Run lint-staged with `prettier --list-different` and commit pretty file - // Set maxArgLength low enough so that chunking is used - await gitCommit({ config: { '*.js': 'prettier --list-different' }, maxArgLength: 10 }) - - // Nothing is wrong, so a new commit is created - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - expect(await readFile('test.js')).toEqual(testJsFilePretty) - expect(await readFile('test2.js')).toEqual(testJsFilePretty) - }) - - it('should fail when backup stash is missing', async () => { - await appendFile('test.js', testJsFilePretty) - await execGit(['add', 'test.js']) - - // Remove backup stash during run - await expect( - gitCommit({ config: { '*.js': () => 'git stash drop' }, shell: true }) - ).rejects.toThrowError() - - expect(console.printHistory()).toMatchInlineSnapshot(` - " - LOG [STARTED] Preparing lint-staged... - LOG [SUCCESS] Preparing lint-staged... - LOG [STARTED] Running tasks for staged files... - LOG [STARTED] Config object — 1 file - LOG [STARTED] *.js — 1 file - LOG [STARTED] git stash drop - LOG [SUCCESS] git stash drop - LOG [SUCCESS] *.js — 1 file - LOG [SUCCESS] Config object — 1 file - LOG [SUCCESS] Running tasks for staged files... - LOG [STARTED] Applying modifications from tasks... - LOG [SUCCESS] Applying modifications from tasks... - LOG [STARTED] Cleaning up temporary files... - ERROR [FAILED] lint-staged automatic backup is missing!" - `) - }) - - it('should fail when task reverts staged changes, to prevent an empty git commit', async () => { - // Create and commit a pretty file without running lint-staged - // This way the file will be available for the next step - await appendFile('test.js', testJsFilePretty) - await execGit(['add', 'test.js']) - await execGit(['commit', '-m committed pretty file']) - - // Edit file to be ugly - await fs.remove(path.resolve(cwd, 'test.js')) - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Run lint-staged with prettier --write to automatically fix the file - // Since prettier reverts all changes, the commit should fail - // use the old syntax with manual `git add` to provide a warning message - await expect( - gitCommit({ config: { '*.js': ['prettier --write', 'git add'] } }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`) - - expect(console.printHistory()).toMatchInlineSnapshot(` - " - WARN ⚠ Some of your tasks use \`git add\` command. Please remove it from the config since all modifications made by tasks will be automatically added to the git commit index. - - LOG [STARTED] Preparing lint-staged... - LOG [SUCCESS] Preparing lint-staged... - LOG [STARTED] Running tasks for staged files... - LOG [STARTED] Config object — 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] *.js — 1 file - LOG [SUCCESS] Config object — 1 file - LOG [SUCCESS] Running tasks for staged files... - LOG [STARTED] Applying modifications from tasks... - ERROR [FAILED] Prevented an empty git commit! - LOG [STARTED] Reverting to original state because of errors... - LOG [SUCCESS] Reverting to original state because of errors... - LOG [STARTED] Cleaning up temporary files... - LOG [SUCCESS] Cleaning up temporary files... - WARN - ⚠ lint-staged prevented an empty git commit. - Use the --allow-empty option to continue, or check your task configuration - " - `) - - // Something was wrong so the repo is returned to original state - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('committed pretty file') - expect(await readFile('test.js')).toEqual(testJsFileUgly) - }) - - it('should create commit when task reverts staged changed and --allow-empty is used', async () => { - // Create and commit a pretty file without running lint-staged - // This way the file will be available for the next step - await appendFile('test.js', testJsFilePretty) - await execGit(['add', 'test.js']) - await execGit(['commit', '-m committed pretty file']) - - // Edit file to be ugly - await writeFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Run lint-staged with prettier --write to automatically fix the file - // Here we also pass '--allow-empty' to gitCommit because this part is not the full lint-staged - await gitCommit({ allowEmpty: true, config: { '*.js': 'prettier --write' } }, [ - '-m test', - '--allow-empty', - ]) - - // Nothing was wrong so the empty commit is created - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('3') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - expect(await execGit(['diff', '-1'])).toEqual('') - expect(await readFile('test.js')).toEqual(testJsFilePretty) - }) - - it('should handle git submodules', async () => { - // create a new repo for the git submodule to a temp path - let submoduleDir = path.resolve(cwd, 'submodule-temp') - await fs.ensureDir(submoduleDir) - await execGit('init', { cwd: submoduleDir }) - await execGit(['config', 'user.name', '"test"'], { cwd: submoduleDir }) - await execGit(['config', 'user.email', '"test@test.com"'], { cwd: submoduleDir }) - await appendFile('README.md', '# Test\n', submoduleDir) - await execGit(['add', 'README.md'], { cwd: submoduleDir }) - await execGit(['commit', '-m initial commit'], { cwd: submoduleDir }) - - // Add the newly-created repo as a submodule in a new path. - // This simulates adding it from a remote - await execGit(['submodule', 'add', '--force', './submodule-temp', './submodule']) - submoduleDir = path.resolve(cwd, 'submodule') - // Set these again for Windows git in CI - await execGit(['config', 'user.name', '"test"'], { cwd: submoduleDir }) - await execGit(['config', 'user.email', '"test@test.com"'], { cwd: submoduleDir }) - - // Stage pretty file - await appendFile('test.js', testJsFilePretty, submoduleDir) - await execGit(['add', 'test.js'], { cwd: submoduleDir }) - - // Run lint-staged with `prettier --list-different` and commit pretty file - await lintStaged({ config: { '*.js': 'prettier --list-different' }, cwd: submoduleDir }) - await execGit(['commit', '-m test'], { cwd: submoduleDir }) - - // Nothing is wrong, so a new commit is created - expect(await execGit(['rev-list', '--count', 'HEAD'], { cwd: submoduleDir })).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'], { cwd: submoduleDir })).toMatch('test') - expect(await readFile('test.js', submoduleDir)).toEqual(testJsFilePretty) - }) - - it('should handle git worktrees', async () => { - // create a new branch and add it as worktree - const workTreeDir = path.resolve(cwd, 'worktree') - await execGit(['branch', 'test']) - await execGit(['worktree', 'add', workTreeDir, 'test']) - - // Stage pretty file - await appendFile('test.js', testJsFilePretty, workTreeDir) - await execGit(['add', 'test.js'], { cwd: workTreeDir }) - - // Run lint-staged with `prettier --list-different` and commit pretty file - await lintStaged({ config: { '*.js': 'prettier --list-different' }, cwd: workTreeDir }) - await execGit(['commit', '-m test'], { cwd: workTreeDir }) - - // Nothing is wrong, so a new commit is created - expect(await execGit(['rev-list', '--count', 'HEAD'], { cwd: workTreeDir })).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'], { cwd: workTreeDir })).toMatch('test') - expect(await readFile('test.js', workTreeDir)).toEqual(testJsFilePretty) - }) - - test.each([['on'], ['off']])( - 'should handle files with non-ascii characters when core.quotepath is %s', - async (quotePath) => { - await execGit(['config', 'core.quotepath', quotePath]) - - // Stage multiple ugly files - await appendFile('привет.js', testJsFileUgly) - await execGit(['add', 'привет.js']) - - await appendFile('你好.js', testJsFileUgly) - await execGit(['add', '你好.js']) - - await appendFile('👋.js', testJsFileUgly) - await execGit(['add', '👋.js']) - - // Run lint-staged with `prettier --write` and commit pretty files - await gitCommit(fixJsConfig) - - // Nothing is wrong, so a new commit is created and files are pretty - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - expect(await readFile('привет.js')).toEqual(testJsFilePretty) - expect(await readFile('你好.js')).toEqual(testJsFilePretty) - expect(await readFile('👋.js')).toEqual(testJsFilePretty) - } - ) - - it('should skip backup and revert with --no-backup', async () => { - // Stage pretty file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Run lint-staged with --no-stash - await gitCommit({ - ...fixJsConfig, - stash: false, - }) - - expect(console.printHistory()).toMatchInlineSnapshot(` - " - WARN ⚠ Skipping backup because \`--no-stash\` was used. - - LOG [STARTED] Preparing lint-staged... - LOG [SUCCESS] Preparing lint-staged... - LOG [STARTED] Running tasks for staged files... - LOG [STARTED] Config object — 1 file - LOG [STARTED] *.js — 1 file - LOG [STARTED] prettier --write - LOG [SUCCESS] prettier --write - LOG [SUCCESS] *.js — 1 file - LOG [SUCCESS] Config object — 1 file - LOG [SUCCESS] Running tasks for staged files... - LOG [STARTED] Applying modifications from tasks... - LOG [SUCCESS] Applying modifications from tasks..." - `) - - // Nothing is wrong, so a new commit is created - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - expect(await readFile('test.js')).toEqual(testJsFilePretty) - }) - - it('should abort commit without reverting with --no-stash 1', async () => { - await execGit(['config', 'merge.conflictstyle', 'merge']) - - // Stage file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Run lint-staged with action that does horrible things to the file, causing a merge conflict - const testFile = path.resolve(cwd, 'test.js') - await expect( - gitCommit({ - config: { - '*.js': () => { - fs.writeFileSync(testFile, Buffer.from(testJsFileUnfixable, 'binary')) - return `prettier --write ${testFile}` - }, - }, - stash: false, - }) - ).rejects.toThrowError() - - expect(console.printHistory()).toMatchInlineSnapshot(` - " - WARN ⚠ Skipping backup because \`--no-stash\` was used. - - LOG [STARTED] Preparing lint-staged... - LOG [SUCCESS] Preparing lint-staged... - LOG [STARTED] Hiding unstaged changes to partially staged files... - LOG [SUCCESS] Hiding unstaged changes to partially staged files... - LOG [STARTED] Running tasks for staged files... - LOG [STARTED] Config object — 1 file - LOG [STARTED] *.js — 1 file - LOG [STARTED] prettier --write - LOG [SUCCESS] prettier --write - LOG [SUCCESS] *.js — 1 file - LOG [SUCCESS] Config object — 1 file - LOG [SUCCESS] Running tasks for staged files... - LOG [STARTED] Applying modifications from tasks... - LOG [SUCCESS] Applying modifications from tasks... - LOG [STARTED] Restoring unstaged changes to partially staged files... - ERROR [FAILED] Unstaged changes could not be restored due to a merge conflict! - ERROR - ✖ lint-staged failed due to a git error." - `) - - // Something was wrong so the commit was aborted - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - expect(await execGit(['status', '--porcelain'])).toMatchInlineSnapshot(`"UU test.js"`) - // Without revert, the merge conflict is left in-place - expect(await readFile('test.js')).toMatchInlineSnapshot(` - "<<<<<<< ours - module.exports = { - foo: \\"bar\\", - }; - ======= - const obj = { - 'foo': 'bar' - >>>>>>> theirs - " - `) - }) - - it('should abort commit without reverting with --no-stash 2', async () => { - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - await appendFile('test2.js', testJsFileUnfixable) - await execGit(['add', 'test2.js']) - - // Run lint-staged with --no-stash - await expect( - gitCommit({ - ...fixJsConfig, - stash: false, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`) - - const output = console.printHistory() - expect(output).toMatch('Skipping backup because `--no-stash` was used') - - // Something was wrong, so the commit was aborted - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') - expect(await readFile('test.js')).toEqual(testJsFilePretty) // file was still fixed - expect(await readFile('test2.js')).toEqual(testJsFileUnfixable) - }) - - it('should handle files that begin with dash', async () => { - await appendFile('--looks-like-flag.js', testJsFileUgly) - await execGit(['add', '--', '--looks-like-flag.js']) - await expect(gitCommit(fixJsConfig)).resolves.toEqual(undefined) - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await readFile('--looks-like-flag.js')).toEqual(testJsFilePretty) - }) - - it('should work when a branch named stash exists', async () => { - // create a new branch called stash - await execGit(['branch', 'stash']) - - // Stage multiple ugly files - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - await appendFile('test2.js', testJsFileUgly) - await execGit(['add', 'test2.js']) - - // Run lint-staged with `prettier --write` and commit pretty file - await gitCommit(fixJsConfig) - - // Nothing is wrong, so a new commit is created and file is pretty - expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') - expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') - 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') - }) - - it('should support multiple configuration files with --relative', 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 = `module.exports = { '*.js': (files) => files.map((f) => \`echo \${f} > \${f}\`) }` - - await writeFile('.lintstagedrc.js', echoJSConfig) - await writeFile('deeper/.lintstagedrc.js', echoJSConfig) - await writeFile('deeper/even/.lintstagedrc.cjs', echoJSConfig) - - // Stage all files - await execGit(['add', '.']) - - // Run lint-staged with `--shell` so that tasks do their thing - await gitCommit({ relative: true, shell: true }) - - // 'file.js' is relative to '.' - expect(await readFile('file.js')).toMatch('file.js') - - // 'deeper/file.js' is relative to 'deeper/' - expect(await readFile('deeper/file.js')).toMatch('file.js') - - // 'deeper/even/file.js' is relative to 'deeper/even/' - expect(await readFile('deeper/even/file.js')).toMatch('file.js') - - // 'deeper/even/deeper/file.js' is relative to parent 'deeper/even/' - expect(await readFile('deeper/even/deeper/file.js')).toMatch(normalize('deeper/file.js')) - - // 'a/very/deep/file/path/file.js' is relative to root '.' - expect(await readFile('a/very/deep/file/path/file.js')).toMatch( - normalize('a/very/deep/file/path/file.js') - ) - }) - - it('should ignore multiple configs files outside cwd', 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 - // Run in 'deeper/' so that root config is ignored - await gitCommit({ shell: true, cwd: path.join(cwd, 'deeper') }) - - // 'file.js' was ignored - expect(await readFile('file.js')).toEqual('') - - // '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' was ignored - expect(await readFile('a/very/deep/file/path/file.js')).toEqual('') - }) - - it('should work with symlinked config file', async () => { - await appendFile('test.js', testJsFileUgly) - - await writeFile('.config/.lintstagedrc.json', JSON.stringify(fixJsConfig.config)) - await fs.ensureSymlink( - path.join(cwd, '.config/.lintstagedrc.json'), - path.join(cwd, '.lintstagedrc.json') - ) - - await execGit(['add', '.']) - - await gitCommit() - - expect(await readFile('test.js')).toEqual(testJsFilePretty) // file was fixed - }) - - it('should support parent globs', 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', '') - - // Include single-level parent glob in deeper config - await writeFile( - 'deeper/even/.lintstagedrc.cjs', - `module.exports = { '../*.js': (files) => files.map((f) => \`echo level-2 > \${f}\`) }` - ) - - // Stage all files - await execGit(['add', '.']) - - // Run lint-staged with `--shell` so that tasks do their thing - // Run in 'deeper/' so that root config is ignored - await gitCommit({ shell: true, cwd: path.join(cwd, 'deeper/even') }) - - // Two levels above, no match - expect(await readFile('file.js')).toEqual('') - - // One level above, match - expect(await readFile('deeper/file.js')).toMatch('level-2') - - // Not directly in the above-level, no match - expect(await readFile('deeper/even/file.js')).toEqual('') - expect(await readFile('deeper/even/deeper/file.js')).toEqual('') - expect(await readFile('a/very/deep/file/path/file.js')).toEqual('') - }) - - it('should not care about staged file outside current cwd with another staged file', async () => { - await writeFile('file.js', testJsFileUgly) - await writeFile('deeper/file.js', testJsFileUgly) - await writeFile('deeper/.lintstagedrc.json', JSON.stringify(fixJsConfig.config)) - await execGit(['add', '.']) - - // Run lint-staged in "deeper/"" - expect(await gitCommit({ cwd: path.join(cwd, 'deeper') })).resolves - - // File inside deeper/ was fixed - expect(await readFile('deeper/file.js')).toEqual(testJsFilePretty) - // ...but file outside was not - expect(await readFile('file.js')).toEqual(testJsFileUgly) - }) - - it('should not care about staged file outside current cwd without any other staged files', async () => { - await writeFile('file.js', testJsFileUgly) - await writeFile('deeper/.lintstagedrc.json', JSON.stringify(fixJsConfig.config)) - await execGit(['add', '.']) - - // Run lint-staged in "deeper/"" - expect(await gitCommit({ cwd: path.join(cwd, 'deeper') })).resolves - - expect(console.printHistory()).toMatch('No staged files match any configured task') - - // File outside deeper/ was not fixed - expect(await readFile('file.js')).toEqual(testJsFileUgly) - }) - - it('should support overriding file list using --diff', async () => { - // Commit ugly file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - await execGit(['commit', '-m', 'ugly'], { cwd }) - - const hashes = (await execGit(['log', '--format=format:%H'])).trim().split('\n') - expect(hashes).toHaveLength(2) - - // Run lint-staged with `--diff` between the two commits. - // Nothing is staged at this point, so don't rung `gitCommit` - const passed = await lintStaged({ - config: { '*.js': 'prettier --list-different' }, - cwd, - debug: true, - diff: `${hashes[1]}...${hashes[0]}`, - stash: false, - }) - - // Lint-staged failed because commit diff contains ugly file - expect(passed).toEqual(false) - - expect(console.printHistory()).toMatch('prettier --list-different:') - expect(console.printHistory()).toMatch('test.js') - }) - - it('should support overriding default --diff-filter', async () => { - // Stage ugly file - await appendFile('test.js', testJsFileUgly) - await execGit(['add', 'test.js']) - - // Run lint-staged with `--diff-filter=D` to include only deleted files. - const passed = await lintStaged({ - config: { '*.js': 'prettier --list-different' }, - cwd, - diffFilter: 'D', - stash: false, - }) - - // Lint-staged passed because no matching (deleted) files - expect(passed).toEqual(true) - - expect(console.printHistory()).toMatch('No staged files found') - }) -}) - -describe('lintStaged', () => { - it('Should skip backup when run on an empty git repo without an initial commit', async () => { - const globalConsoleTemp = console - console = makeConsoleMock() - const tmpDir = await createTempDir() - const cwd = normalize(tmpDir) - - await execGit('init', { cwd }) - await execGit(['config', 'user.name', '"test"'], { cwd }) - await execGit(['config', 'user.email', '"test@test.com"'], { cwd }) - await appendFile('test.js', testJsFilePretty, cwd) - await execGit(['add', 'test.js'], { cwd }) - - await expect(execGit(['log', '-1'], { cwd })).rejects.toThrowError( - 'does not have any commits yet' - ) - - await gitCommit({ - config: { '*.js': 'prettier --list-different' }, - cwd, - debut: true, - }) - - expect(console.printHistory()).toMatchInlineSnapshot(` - " - WARN ⚠ Skipping backup because there’s no initial commit yet. - - LOG [STARTED] Preparing lint-staged... - LOG [SUCCESS] Preparing lint-staged... - LOG [STARTED] Running tasks for staged files... - LOG [STARTED] Config object — 1 file - LOG [STARTED] *.js — 1 file - LOG [STARTED] prettier --list-different - LOG [SUCCESS] prettier --list-different - LOG [SUCCESS] *.js — 1 file - LOG [SUCCESS] Config object — 1 file - LOG [SUCCESS] Running tasks for staged files... - LOG [STARTED] Applying modifications from tasks... - LOG [SUCCESS] Applying modifications from tasks..." - `) - - // Nothing is wrong, so the initial commit is created - expect(await execGit(['rev-list', '--count', 'HEAD'], { cwd })).toEqual('1') - expect(await execGit(['log', '-1', '--pretty=%B'], { cwd })).toMatch('test') - expect(await readFile('test.js', cwd)).toEqual(testJsFilePretty) - console = globalConsoleTemp - }) -}) diff --git a/test/integration/__fixtures__/configs.js b/test/integration/__fixtures__/configs.js new file mode 100644 index 000000000..2f66f159d --- /dev/null +++ b/test/integration/__fixtures__/configs.js @@ -0,0 +1,3 @@ +export const prettierListDifferent = { '*.js': 'prettier --list-different' } + +export const prettierWrite = { '*.js': 'prettier --write' } diff --git a/test/integration/__fixtures__/files.js b/test/integration/__fixtures__/files.js new file mode 100644 index 000000000..155ab2d58 --- /dev/null +++ b/test/integration/__fixtures__/files.js @@ -0,0 +1,13 @@ +export const prettyJS = `module.exports = { + foo: "bar", +}; +` + +export const uglyJS = `module.exports = { + 'foo': 'bar' +} +` + +export const invalidJS = `const obj = { + 'foo': 'bar' +` diff --git a/test/integration/__mocks__/dynamicImport.js b/test/integration/__mocks__/dynamicImport.js new file mode 100644 index 000000000..5fd38b13d --- /dev/null +++ b/test/integration/__mocks__/dynamicImport.js @@ -0,0 +1,4 @@ +jest.mock('../../../lib/dynamicImport.js', () => ({ + // 'pathToFileURL' is not supported with Jest + Babel + dynamicImport: jest.fn().mockImplementation(async (input) => require(input)), +})) diff --git a/test/integration/__mocks__/resolveConfig.js b/test/integration/__mocks__/resolveConfig.js new file mode 100644 index 000000000..842f9e17a --- /dev/null +++ b/test/integration/__mocks__/resolveConfig.js @@ -0,0 +1,10 @@ +/** Unfortunately necessary due to non-ESM tests. */ +jest.mock('../../../lib/resolveConfig.js', () => ({ + resolveConfig: (configPath) => { + try { + return require.resolve(configPath) + } catch { + return configPath + } + }, +})) diff --git a/test/integration/__utils__/addConfigFileSerializer.js b/test/integration/__utils__/addConfigFileSerializer.js new file mode 100644 index 000000000..4a3b24fd1 --- /dev/null +++ b/test/integration/__utils__/addConfigFileSerializer.js @@ -0,0 +1,29 @@ +import ansiSerializer from 'jest-snapshot-serializer-ansi' + +import { replaceSerializer } from './replaceSerializer' + +// Replace path like `../../git/lint-staged` with `/lint-staged` +const replaceConfigPathSerializer = replaceSerializer( + /] .*\/lint-staged.* — /gm, + `] / — ` +) + +// Hide filepath from test snapshot because it's not important and varies in CI +const replaceFilepathSerializer = replaceSerializer( + /prettier --write (.*)?$/gm, + `prettier --write ` +) + +export const addConfigFileSerializer = () => { + // 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)) + ), + }) +} diff --git a/test/integration/__utils__/createTempDir.js b/test/integration/__utils__/createTempDir.js new file mode 100644 index 000000000..a0e08eb19 --- /dev/null +++ b/test/integration/__utils__/createTempDir.js @@ -0,0 +1,18 @@ +import { tmpdir } from 'node:os' +import path from 'node:path' +import { randomBytes } from 'node:crypto' +import { promises as fs } from 'node:fs' + +import normalize from 'normalize-path' +import { ensureDir } from 'fs-extra' + +/** + * Create temporary random directory and return its path + * @returns {Promise} + */ +export const createTempDir = async () => { + const tempDir = await fs.realpath(tmpdir()) + const dirname = path.join(tempDir, `lint-staged-${randomBytes(16).toString('hex')}`) + await ensureDir(dirname) + return normalize(dirname) +} diff --git a/test/utils/crossPlatform.js b/test/integration/__utils__/isWindows.js similarity index 68% rename from test/utils/crossPlatform.js rename to test/integration/__utils__/isWindows.js index a6d8a2f63..0a7d2fd09 100644 --- a/test/utils/crossPlatform.js +++ b/test/integration/__utils__/isWindows.js @@ -7,6 +7,3 @@ export const isWindowsActions = () => { const { GITHUB_ACTIONS, RUNNER_OS } = process.env return GITHUB_ACTIONS === 'true' && RUNNER_OS === 'Windows' } - -/** Replace Windows `\r\n` newlines with `\n` */ -export const normalizeWindowsNewlines = (input) => input.replace(/(\r\n|\r|\n)/gm, '\n') diff --git a/test/integration/__utils__/normalizeWindowsNewlines.js b/test/integration/__utils__/normalizeWindowsNewlines.js new file mode 100644 index 000000000..9ef07c672 --- /dev/null +++ b/test/integration/__utils__/normalizeWindowsNewlines.js @@ -0,0 +1,2 @@ +/** Replace Windows `\r\n` newlines with `\n` */ +export const normalizeWindowsNewlines = (input) => input.replace(/(\r\n|\r|\n)/gm, '\n') diff --git a/test/utils/replaceSerializer.js b/test/integration/__utils__/replaceSerializer.js similarity index 100% rename from test/utils/replaceSerializer.js rename to test/integration/__utils__/replaceSerializer.js diff --git a/test/integration/__utils__/withGitIntegration.js b/test/integration/__utils__/withGitIntegration.js new file mode 100644 index 000000000..670940fc4 --- /dev/null +++ b/test/integration/__utils__/withGitIntegration.js @@ -0,0 +1,103 @@ +import path from 'node:path' + +import makeConsoleMock from 'consolemock' +import fs from 'fs-extra' + +import { execGit as execGitBase } from '../../../lib/execGit.js' +import lintStaged from '../../../lib/index.js' + +import { createTempDir } from './createTempDir.js' +import { isWindowsActions } from './isWindows' +import { normalizeWindowsNewlines } from './normalizeWindowsNewlines.js' + +const ensureDir = async (inputPath) => fs.ensureDir(path.parse(inputPath).dir) + +const getGitUtils = (cwd) => { + if (!cwd || cwd === process.cwd()) { + throw new Error('Do not run integration tests without an explicit Working Directory!') + } + + // Get file content, coercing Windows `\r\n` newlines to `\n` + const readFile = async (filename, dir = cwd) => { + 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) => { + 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) => { + const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename) + await ensureDir(filepath) + await fs.writeFile(filepath, content) + } + + // Remove file + const removeFile = async (filename, dir = cwd) => { + const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename) + await fs.remove(filepath) + } + + // Wrap execGit to always pass `gitOps` + const execGit = async (args, options = {}) => execGitBase(args, { cwd, ...options }) + + // Execute lintStaged before git commit to emulate lint-staged cli + const gitCommit = async (options, dir = cwd) => { + const globalConsoleTemp = console + const logger = makeConsoleMock() + + // Override global console because of Listr2 + console = logger + + const passed = await lintStaged({ ...options?.lintStaged, cwd: dir }, logger) + + // Restore global console + console = globalConsoleTemp + + if (!passed) throw new Error(logger.printHistory()) + + const gitCommitArgs = Array.isArray(options?.gitCommit) ? options.gitCommit : ['-m test'] + await execGit(['commit', ...gitCommitArgs], { cwd: dir }) + + return logger.printHistory() + } + + return { appendFile, execGit, gitCommit, readFile, removeFile, writeFile } +} + +export const withGitIntegration = + (testCase, { initialCommit = true } = {}) => + async () => { + const cwd = await createTempDir() + + const utils = getGitUtils(cwd) + + // Init repository with initial commit + await utils.execGit('init') + + if (isWindowsActions()) { + await utils.execGit(['config', 'core.autocrlf', 'input']) + } + + await utils.execGit(['config', 'user.name', '"test"']) + await utils.execGit(['config', 'user.email', '"test@test.com"']) + await utils.execGit(['config', 'merge.conflictstyle', 'merge']) + + if (initialCommit) { + await utils.appendFile('README.md', '# Test\n') + await utils.execGit(['add', 'README.md']) + await utils.execGit(['commit', '-m initial commit']) + } + + try { + await testCase({ ...utils, cwd }) + } finally { + await fs.remove(cwd) + } + } diff --git a/test/integration/allow-empty.test.js b/test/integration/allow-empty.test.js new file mode 100644 index 000000000..33a71b32d --- /dev/null +++ b/test/integration/allow-empty.test.js @@ -0,0 +1,66 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import * as fileFixtures from './__fixtures__/files.js' +import * as configFixtures from './__fixtures__/configs.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'fails when task reverts staged changes without `--allow-empty`, to prevent an empty git commit', + withGitIntegration(async ({ execGit, gitCommit, readFile, removeFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + // Create and commit a pretty file without running lint-staged + // This way the file will be available for the next step + await writeFile('test.js', fileFixtures.prettyJS) + await execGit(['add', '.']) + await execGit(['commit', '-m committed pretty file']) + + // Edit file to be ugly + await removeFile('test.js') + await writeFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + // Run lint-staged with prettier --write to automatically fix the file + // Since prettier reverts all changes, the commit should fail + await expect(gitCommit()).rejects.toThrowError('lint-staged prevented an empty git commit.') + + // Something was wrong so the repo is returned to original state + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('committed pretty file') + expect(await readFile('test.js')).toEqual(fileFixtures.uglyJS) + }) + ) + + test( + 'creates commit when task reverts staged changed and --allow-empty is used', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + // Create and commit a pretty file without running lint-staged + // This way the file will be available for the next step + await appendFile('test.js', fileFixtures.prettyJS) + await execGit(['add', '.']) + await execGit(['commit', '-m committed pretty file']) + + // Edit file to be ugly + await writeFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + // Run lint-staged with prettier --write to automatically fix the file + // Here we also pass '--allow-empty' to gitCommit because this part is not the full lint-staged + await gitCommit({ lintStaged: { allowEmpty: true }, gitCommit: ['-m test', '--allow-empty'] }) + + // Nothing was wrong so the empty commit is created + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('3') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + expect(await execGit(['diff', '-1'])).toEqual('') + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS) + }) + ) +}) diff --git a/test/integration/basic-functionality.test.js b/test/integration/basic-functionality.test.js new file mode 100644 index 000000000..82c621218 --- /dev/null +++ b/test/integration/basic-functionality.test.js @@ -0,0 +1,214 @@ +import './__mocks__/resolveConfig.js' + +import path from 'node:path' + +import { jest } from '@jest/globals' +import fs from 'fs-extra' + +import { addConfigFileSerializer } from './__utils__/addConfigFileSerializer.js' +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import * as fileFixtures from './__fixtures__/files.js' +import * as configFixtures from './__fixtures__/configs.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + addConfigFileSerializer() + + test( + 'commits entire staged file when no errors from linter', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierListDifferent)) + + // Stage pretty file + await writeFile('test file.js', fileFixtures.prettyJS) + await execGit(['add', 'test file.js']) + + // Run lint-staged with `prettier --list-different` and commit pretty file + await gitCommit() + + // Nothing is wrong, so a new commit is created + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + expect(await readFile('test file.js')).toEqual(fileFixtures.prettyJS) + }) + ) + + test( + 'commits entire staged file when no errors and linter modifies file', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + // Stage multiple ugly files + await writeFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + await writeFile('test2.js', fileFixtures.uglyJS) + await execGit(['add', 'test2.js']) + + // Run lint-staged with `prettier --write` and commit pretty file + await gitCommit() + + // Nothing is wrong, so a new commit is created and file is pretty + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS) + expect(await readFile('test2.js')).toEqual(fileFixtures.prettyJS) + }) + ) + + test( + 'fails to commit entire staged file when errors from linter', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierListDifferent)) + + // Stage ugly file + await writeFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + const status = await execGit(['status']) + + // Run lint-staged with `prettier --list-different` to break the linter + await expect(gitCommit()).rejects.toThrowError( + 'Reverting to original state because of errors' + ) + + // Something was wrong so the repo is returned to original state + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + expect(await execGit(['status'])).toEqual(status) + expect(await readFile('test.js')).toEqual(fileFixtures.uglyJS) + }) + ) + + test( + 'fails to commit entire staged file when errors from linter and linter modifies files', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + // Add unfixable file to commit so `prettier --write` breaks + await writeFile('test.js', fileFixtures.invalidJS) + await execGit(['add', 'test.js']) + const status = await execGit(['status']) + + // Run lint-staged with `prettier --write` to break the linter + await expect(gitCommit()).rejects.toThrowError( + 'Reverting to original state because of errors' + ) + + // Something was wrong so the repo is returned to original state + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + expect(await execGit(['status'])).toEqual(status) + expect(await readFile('test.js')).toEqual(fileFixtures.invalidJS) + }) + ) + + test( + 'clears unstaged changes when linter applies same changes', + withGitIntegration(async ({ appendFile, cwd, execGit, gitCommit, readFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + // Stage ugly file + await appendFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + // Replace ugly file with pretty but do not stage changes + await fs.remove(path.join(cwd, 'test.js')) + await appendFile('test.js', fileFixtures.prettyJS) + + // Run lint-staged with `prettier --write` and commit pretty file + await gitCommit() + + // Nothing is wrong, so a new commit is created and file is pretty + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + + // Latest commit contains pretty file + // `git show` strips empty line from here here + expect(await execGit(['show', 'HEAD:test.js'])).toEqual(fileFixtures.prettyJS.trim()) + + // Nothing is staged + expect(await execGit(['status'])).toMatch('nothing added to commit') + + // File is pretty, and has been edited + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS) + }) + ) + + test( + 'runs chunked tasks when necessary', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierListDifferent)) + + // Stage two files + await writeFile('test.js', fileFixtures.prettyJS) + await execGit(['add', 'test.js']) + await writeFile('test2.js', fileFixtures.prettyJS) + await execGit(['add', 'test2.js']) + + // Run lint-staged with `prettier --list-different` and commit pretty file + // Set maxArgLength low enough so that chunking is used + await gitCommit({ lintStaged: { maxArgLength: 10 } }) + + // Nothing is wrong, so a new commit is created + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS) + expect(await readFile('test2.js')).toEqual(fileFixtures.prettyJS) + }) + ) + + test( + 'fails when backup stash is missing', + withGitIntegration(async ({ execGit, gitCommit, writeFile }) => { + await writeFile('test.js', fileFixtures.prettyJS) + await execGit(['add', 'test.js']) + + await expect( + gitCommit({ + lintStaged: { + // Remove backup stash during run + config: { '*.js': () => 'git stash drop' }, + }, + }) + ).rejects.toThrowError('lint-staged automatic backup is missing') + }) + ) + + test( + 'handles files that begin with dash', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + await writeFile('--looks-like-flag.js', fileFixtures.uglyJS) + await execGit(['add', '--', '--looks-like-flag.js']) + + await gitCommit() + + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await readFile('--looks-like-flag.js')).toEqual(fileFixtures.prettyJS) + }) + ) + + test( + 'works when a branch named stash exists', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierListDifferent)) + + // create a new branch called stash + await execGit(['branch', 'stash']) + + await writeFile('test.js', fileFixtures.prettyJS) + await execGit(['add', 'test.js']) + + // Run lint-staged with `prettier --write` and commit pretty file + await gitCommit() + + // Nothing is wrong, so a new commit is created and file is pretty + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS) + }) + ) +}) diff --git a/test/integration/binary-files.test.js b/test/integration/binary-files.test.js new file mode 100644 index 000000000..c61ca93d7 --- /dev/null +++ b/test/integration/binary-files.test.js @@ -0,0 +1,33 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import * as configFixtures from './__fixtures__/configs.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'handles binary files', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierListDifferent)) + + // mark file as binary + await writeFile('.gitattributes', 'binary\n') + + // Stage pretty file + await writeFile('binary', Buffer.from('Hello, World!', 'binary')) + await execGit(['add', 'binary']) + + // Run lint-staged with `prettier --list-different` and commit pretty file + await gitCommit() + + // Nothing is wrong, so a new commit is created + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + expect(Buffer.from(await readFile('binary'), 'binary').toString()).toEqual('Hello, World!') + }) + ) +}) diff --git a/test/integration/diff-options.test.js b/test/integration/diff-options.test.js new file mode 100644 index 000000000..ba3e8611b --- /dev/null +++ b/test/integration/diff-options.test.js @@ -0,0 +1,75 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' +import makeConsoleMock from 'consolemock' + +import lintStaged from '../../lib/index.js' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import * as fileFixtures from './__fixtures__/files.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'supports overriding file list using --diff', + withGitIntegration(async ({ appendFile, cwd, execGit }) => { + const globalConsoleTemp = console + console = makeConsoleMock() + + // Commit ugly file + await appendFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + await execGit(['commit', '-m', 'ugly'], { cwd }) + + const hashes = (await execGit(['log', '--format=format:%H'])).trim().split('\n') + expect(hashes).toHaveLength(2) + + // Run lint-staged with `--diff` between the two commits. + // Nothing is staged at this point, so don't rung `gitCommit` + const passed = await lintStaged({ + config: { '*.js': 'prettier --list-different' }, + cwd, + debug: true, + diff: `${hashes[1]}...${hashes[0]}`, + stash: false, + }) + + // Lint-staged failed because commit diff contains ugly file + expect(passed).toEqual(false) + + expect(console.printHistory()).toMatch('prettier --list-different:') + expect(console.printHistory()).toMatch('test.js') + + console = globalConsoleTemp + }) + ) + + test( + 'supports overriding default --diff-filter', + withGitIntegration(async ({ appendFile, cwd, execGit }) => { + const globalConsoleTemp = console + console = makeConsoleMock() + + // Stage ugly file + await appendFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + // Run lint-staged with `--diff-filter=D` to include only deleted files. + const passed = await lintStaged({ + config: { '*.js': 'prettier --list-different' }, + cwd, + diffFilter: 'D', + stash: false, + }) + + // Lint-staged passed because no matching (deleted) files + expect(passed).toEqual(true) + + expect(console.printHistory()).toMatch('No staged files found') + + console = globalConsoleTemp + }) + ) +}) diff --git a/test/integration/file-resurrection.test.js b/test/integration/file-resurrection.test.js new file mode 100644 index 000000000..49b74e93a --- /dev/null +++ b/test/integration/file-resurrection.test.js @@ -0,0 +1,96 @@ +import './__mocks__/resolveConfig.js' + +import path from 'node:path' + +import { jest } from '@jest/globals' +import fs from 'fs-extra' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import { prettierListDifferent } from './__fixtures__/configs.js' +import { prettyJS, uglyJS } from './__fixtures__/files.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'does not resurrect removed files due to git bug when tasks pass', + withGitIntegration(async ({ cwd, execGit, gitCommit, removeFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + await removeFile('README.md') // Remove file from previous commit + await writeFile('test.js', prettyJS) + await execGit(['add', 'test.js']) + + await gitCommit() + + expect(await fs.exists(path.join(cwd, 'README.md'))).toEqual(false) + }) + ) + + test( + 'does not resurrect removed files in complex case', + withGitIntegration(async ({ cwd, execGit, gitCommit, readFile, removeFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + // Add file to index, and remove it from disk + await writeFile('test.js', prettyJS) + await execGit(['add', 'test.js']) + await removeFile('test.js') + + // Rename file in index, and remove it from disk + const readme = await readFile('README.md') + await removeFile('README.md') + await execGit(['add', 'README.md']) + await writeFile('README_NEW.md', readme) + await execGit(['add', 'README_NEW.md']) + await removeFile('README_NEW.md') + + expect(await execGit(['status', '--porcelain'])).toMatchInlineSnapshot(` + "RD README.md -> README_NEW.md + AD test.js + ?? .lintstagedrc.json" + `) + + await gitCommit() + + expect(await execGit(['status', '--porcelain'])).toMatchInlineSnapshot(` + " D README_NEW.md + D test.js + ?? .lintstagedrc.json" + `) + + expect(await fs.exists(path.join(cwd, 'test.js'))).toEqual(false) + expect(await fs.exists(path.join(cwd, 'README_NEW.md'))).toEqual(false) + }) + ) + + test( + 'does not resurrect removed files due to git bug when tasks fail', + withGitIntegration(async ({ cwd, execGit, gitCommit, removeFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + await removeFile('README.md') // Remove file from previous commit + await writeFile('test.js', uglyJS) + await execGit(['add', 'test.js']) + + expect(await execGit(['status', '--porcelain'])).toMatchInlineSnapshot(` + " D README.md + A test.js + ?? .lintstagedrc.json" + `) + + await expect(gitCommit({ lintStaged: { allowEmpty: true } })).rejects.toThrowError( + 'Reverting to original state because of errors...' + ) + + expect(await execGit(['status', '--porcelain'])).toMatchInlineSnapshot(` + " D README.md + A test.js + ?? .lintstagedrc.json" + `) + + expect(await fs.exists(path.join(cwd, 'README.md'))).toEqual(false) + }) + ) +}) diff --git a/test/integration/files-outside-cwd.test.js b/test/integration/files-outside-cwd.test.js new file mode 100644 index 000000000..1beb1cea3 --- /dev/null +++ b/test/integration/files-outside-cwd.test.js @@ -0,0 +1,49 @@ +import './__mocks__/resolveConfig.js' + +import path from 'node:path' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import { prettierWrite } from './__fixtures__/configs.js' +import { prettyJS, uglyJS } from './__fixtures__/files.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'does not care about staged file outside current cwd with another staged file', + withGitIntegration(async ({ cwd, execGit, gitCommit, readFile, writeFile }) => { + await writeFile('file.js', uglyJS) + await writeFile('deeper/file.js', uglyJS) + await writeFile('deeper/.lintstagedrc.json', JSON.stringify(prettierWrite)) + await execGit(['add', '.']) + + // Run lint-staged in "deeper/"" + await gitCommit({ cwd: path.join(cwd, 'deeper') }) + + // File inside deeper/ was fixed + expect(await readFile('deeper/file.js')).toEqual(prettyJS) + // ...but file outside was not + expect(await readFile('file.js')).toEqual(uglyJS) + }) + ) + + test( + 'not care about staged file outside current cwd without any other staged files', + withGitIntegration(async ({ cwd, execGit, gitCommit, readFile, writeFile }) => { + await writeFile('file.js', uglyJS) + await writeFile('deeper/.lintstagedrc.json', JSON.stringify(prettierWrite)) + await execGit(['add', '.']) + + // Run lint-staged in "deeper/"" + await expect(gitCommit({ cwd: path.join(cwd, 'deeper') })).resolves.toMatch( + `No staged files match any configured task` + ) + + // File outside deeper/ was not fixed + expect(await readFile('file.js')).toEqual(uglyJS) + }) + ) +}) diff --git a/test/integration/git-amend.test.js b/test/integration/git-amend.test.js new file mode 100644 index 000000000..77f5c49a8 --- /dev/null +++ b/test/integration/git-amend.test.js @@ -0,0 +1,47 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import { prettierListDifferent } from './__fixtures__/configs.js' +import * as fileFixtures from './__fixtures__/files.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'works when amending previous commit with unstaged changes', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + // Edit file from previous commit + await appendFile('README.md', '\n## Amended\n') + await execGit(['add', 'README.md']) + + // Edit again, but keep it unstaged + await appendFile('README.md', '\n## Edited\n') + await appendFile('test-untracked.js', fileFixtures.prettyJS) + + // Run lint-staged with `prettier --list-different` and commit pretty file + await gitCommit({ gitCommit: ['--amend', '--no-edit'] }) + + // Nothing is wrong, so the commit was amended + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + expect(await readFile('README.md')).toMatchInlineSnapshot(` + "# Test + + ## Amended + + ## Edited + " + `) + expect(await readFile('test-untracked.js')).toEqual(fileFixtures.prettyJS) + const status = await execGit(['status']) + expect(status).toMatch('modified: README.md') + expect(status).toMatch('test-untracked.js') + expect(status).toMatch('no changes added to commit') + }) + ) +}) diff --git a/test/integration/git-lock-file.test.js b/test/integration/git-lock-file.test.js new file mode 100644 index 000000000..307fdd4e8 --- /dev/null +++ b/test/integration/git-lock-file.test.js @@ -0,0 +1,79 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import { prettyJS, uglyJS } from './__fixtures__/files.js' +import { isWindows } from './__utils__/isWindows.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'fails when linter creates a .git/index.lock', + withGitIntegration(async ({ appendFile, cwd, execGit, gitCommit, readFile, removeFile }) => { + // Stage ugly file + await appendFile('test.js', uglyJS) + await execGit(['add', 'test.js']) + + // Edit ugly file but do not stage changes + const appended = '\n\nconsole.log("test");\n' + await appendFile('test.js', appended) + expect(await readFile('test.js')).toEqual(uglyJS + appended) + const diff = await execGit(['diff']) + + // Run lint-staged with `prettier --write` and commit pretty file + // The task creates a git lock file and runs `git add` to simulate failure + await expect( + gitCommit({ + lintStaged: { + shell: isWindows, + config: { + '*.js': (files) => [ + `${isWindows ? 'type nul >' : 'touch'} ${cwd}/.git/index.lock`, + `prettier --write ${files.join(' ')}`, + `git add ${files.join(' ')}`, + ], + }, + }, + }) + ).rejects.toThrowError(".git/index.lock': File exists") + + // Something was wrong so new commit wasn't created + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + + // But local modifications are gone + expect(await execGit(['diff'])).not.toEqual(diff) + expect(await execGit(['diff'])).toMatchInlineSnapshot(` + "diff --git a/test.js b/test.js + index 1eff6a0..8baadc8 100644 + --- a/test.js + +++ b/test.js + @@ -1,3 +1,3 @@ + module.exports = { + - 'foo': 'bar' + -} + + foo: \\"bar\\", + +};" + `) + + expect(await readFile('test.js')).not.toEqual(uglyJS + appended) + expect(await readFile('test.js')).toEqual(prettyJS) + + // Remove lock file + await removeFile(`.git/index.lock`) + + // Luckily there is a stash + expect(await execGit(['stash', 'list'])).toMatchInlineSnapshot( + `"stash@{0}: lint-staged automatic backup"` + ) + await execGit(['reset', '--hard']) + await execGit(['stash', 'pop', '--index']) + + expect(await execGit(['diff'])).toEqual(diff) + expect(await readFile('test.js')).toEqual(uglyJS + appended) + }) + ) +}) diff --git a/test/integration/git-submodules.test.js b/test/integration/git-submodules.test.js new file mode 100644 index 000000000..99f25b0bf --- /dev/null +++ b/test/integration/git-submodules.test.js @@ -0,0 +1,52 @@ +import './__mocks__/resolveConfig.js' + +import path from 'node:path' + +import { jest } from '@jest/globals' +import fs from 'fs-extra' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import { prettierListDifferent } from './__fixtures__/configs.js' +import { prettyJS } from './__fixtures__/files.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'handles git submodules', + withGitIntegration(async ({ appendFile, cwd, execGit, gitCommit, readFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + // create a new repo for the git submodule to a temp path + let submoduleDir = path.resolve(cwd, 'submodule-temp') + await fs.ensureDir(submoduleDir) + await execGit('init', { cwd: submoduleDir }) + await execGit(['config', 'user.name', '"test"'], { cwd: submoduleDir }) + await execGit(['config', 'user.email', '"test@test.com"'], { cwd: submoduleDir }) + await appendFile('README.md', '# Test\n', submoduleDir) + await execGit(['add', 'README.md'], { cwd: submoduleDir }) + await execGit(['commit', '-m initial commit'], { cwd: submoduleDir }) + + // Add the newly-created repo as a submodule in a new path. + // This simulates adding it from a remote + await execGit(['submodule', 'add', '--force', './submodule-temp', './submodule']) + submoduleDir = path.resolve(cwd, 'submodule') + // Set these again for Windows git in CI + await execGit(['config', 'user.name', '"test"'], { cwd: submoduleDir }) + await execGit(['config', 'user.email', '"test@test.com"'], { cwd: submoduleDir }) + + // Stage pretty file + await appendFile('test.js', prettyJS, submoduleDir) + await execGit(['add', 'test.js'], { cwd: submoduleDir }) + + // Run lint-staged with `prettier --list-different` and commit pretty file + await gitCommit(undefined, submoduleDir) + + // Nothing is wrong, so a new commit is created + expect(await execGit(['rev-list', '--count', 'HEAD'], { cwd: submoduleDir })).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'], { cwd: submoduleDir })).toMatch('test') + expect(await readFile('test.js', submoduleDir)).toEqual(prettyJS) + }) + ) +}) diff --git a/test/integration/git-worktree.test.js b/test/integration/git-worktree.test.js new file mode 100644 index 000000000..b8bef8fb1 --- /dev/null +++ b/test/integration/git-worktree.test.js @@ -0,0 +1,38 @@ +import './__mocks__/resolveConfig.js' + +import path from 'node:path' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import { prettierListDifferent } from './__fixtures__/configs.js' +import * as fileFixtures from './__fixtures__/files.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'handles git worktrees', + withGitIntegration(async ({ appendFile, cwd, execGit, gitCommit, readFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + // create a new branch and add it as worktree + const workTreeDir = path.resolve(cwd, 'worktree') + await execGit(['branch', 'test']) + await execGit(['worktree', 'add', workTreeDir, 'test']) + + // Stage pretty file + await appendFile('test.js', fileFixtures.prettyJS, workTreeDir) + await execGit(['add', 'test.js'], { cwd: workTreeDir }) + + // Run lint-staged with `prettier --list-different` and commit pretty file + await gitCommit(undefined, workTreeDir) + + // Nothing is wrong, so a new commit is created + expect(await execGit(['rev-list', '--count', 'HEAD'], { cwd: workTreeDir })).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'], { cwd: workTreeDir })).toMatch('test') + expect(await readFile('test.js', workTreeDir)).toEqual(fileFixtures.prettyJS) + }) + ) +}) diff --git a/test/integration/gitWorkFlow.test.js b/test/integration/gitWorkFlow.test.js new file mode 100644 index 000000000..40873432c --- /dev/null +++ b/test/integration/gitWorkFlow.test.js @@ -0,0 +1,221 @@ +import './__mocks__/resolveConfig.js' + +import path from 'node:path' + +import { jest as jestGlobals } from '@jest/globals' + +import { writeFile } from '../../lib/file.js' +import { GitWorkflow } from '../../lib/gitWorkflow.js' +import { getInitialState } from '../../lib/state.js' + +import { normalizeWindowsNewlines } from './__utils__/normalizeWindowsNewlines.js' +import { withGitIntegration } from './__utils__/withGitIntegration.js' + +jestGlobals.mock('../../lib/file.js', () => { + return { + // notice `jestGlobals` vs `jest` here... this will be changed with `jest.unstable_mockModule` + writeFile: jest.fn(() => Promise.resolve()), + } +}) + +jestGlobals.setTimeout(20000) +jestGlobals.retryTimes(2) + +describe('gitWorkflow', () => { + describe('prepare', () => { + it( + 'should handle errors', + withGitIntegration(async ({ cwd }) => { + const gitWorkflow = new GitWorkflow({ gitDir: cwd, gitConfigDir: path.join(cwd, './.git') }) + + jest.doMock('execa', () => Promise.reject({})) + const ctx = getInitialState() + // mock a simple failure + gitWorkflow.getPartiallyStagedFiles = () => ['foo'] + gitWorkflow.getHiddenFilepath = () => { + throw new Error('test') + } + await expect(gitWorkflow.prepare(ctx, false)).rejects.toThrowErrorMatchingInlineSnapshot( + `"test"` + ) + expect(ctx).toMatchInlineSnapshot(` + Object { + "errors": Set { + Symbol(GitError), + }, + "events": EventEmitter { + "_events": Object {}, + "_eventsCount": 0, + "_maxListeners": undefined, + Symbol(kCapture): false, + }, + "hasPartiallyStagedFiles": true, + "output": Array [], + "quiet": false, + "shouldBackup": null, + } + `) + }) + ) + }) + + describe('cleanup', () => { + it( + 'should handle errors', + withGitIntegration(async ({ cwd }) => { + const gitWorkflow = new GitWorkflow({ gitDir: cwd, gitConfigDir: path.join(cwd, './.git') }) + + const ctx = getInitialState() + await expect(gitWorkflow.cleanup(ctx)).rejects.toThrowErrorMatchingInlineSnapshot( + `"lint-staged automatic backup is missing!"` + ) + expect(ctx).toMatchInlineSnapshot(` + Object { + "errors": Set { + Symbol(GetBackupStashError), + Symbol(GitError), + }, + "events": EventEmitter { + "_events": Object {}, + "_eventsCount": 0, + "_maxListeners": undefined, + Symbol(kCapture): false, + }, + "hasPartiallyStagedFiles": null, + "output": Array [], + "quiet": false, + "shouldBackup": null, + } + `) + }) + ) + }) + + describe('getPartiallyStagedFiles', () => { + it( + 'should return unquoted files', + withGitIntegration(async ({ appendFile, cwd, execGit }) => { + const gitWorkflow = new GitWorkflow({ + gitDir: cwd, + gitConfigDir: path.join(cwd, './.git'), + }) + await appendFile('file with spaces.txt', 'staged content') + await appendFile('file_without_spaces.txt', 'staged content') + await execGit(['add', 'file with spaces.txt']) + await execGit(['add', 'file_without_spaces.txt']) + await appendFile('file with spaces.txt', 'not staged content') + await appendFile('file_without_spaces.txt', 'not staged content') + + expect(await gitWorkflow.getPartiallyStagedFiles()).toStrictEqual([ + 'file with spaces.txt', + 'file_without_spaces.txt', + ]) + }) + ) + + it( + 'should include to and from for renamed files', + withGitIntegration(async ({ appendFile, cwd, execGit }) => { + const gitWorkflow = new GitWorkflow({ gitDir: cwd, gitConfigDir: path.join(cwd, './.git') }) + + await appendFile('original.txt', 'test content') + await execGit(['add', 'original.txt']) + await execGit(['commit', '-m "Add original.txt"']) + await appendFile('original.txt', 'additional content') + await execGit(['mv', 'original.txt', 'renamed.txt']) + + expect(await gitWorkflow.getPartiallyStagedFiles()).toStrictEqual([ + 'renamed.txt\u0000original.txt', + ]) + }) + ) + }) + + describe('hideUnstagedChanges', () => { + it( + 'should handle errors', + withGitIntegration(async ({ cwd }) => { + const gitWorkflow = new GitWorkflow({ gitDir: cwd, gitConfigDir: path.join(cwd, './.git') }) + + const totallyRandom = `totally_random_file-${Date.now().toString()}` + gitWorkflow.partiallyStagedFiles = [totallyRandom] + const ctx = getInitialState() + await expect(gitWorkflow.hideUnstagedChanges(ctx)).rejects.toThrowError( + `pathspec '${totallyRandom}' did not match any file(s) known to git` + ) + expect(ctx).toMatchInlineSnapshot(` + Object { + "errors": Set { + Symbol(GitError), + Symbol(HideUnstagedChangesError), + }, + "events": EventEmitter { + "_events": Object {}, + "_eventsCount": 0, + "_maxListeners": undefined, + Symbol(kCapture): false, + }, + "hasPartiallyStagedFiles": null, + "output": Array [], + "quiet": false, + "shouldBackup": null, + } + `) + }) + ) + + it( + 'should checkout renamed file when hiding changes', + withGitIntegration(async ({ appendFile, cwd, execGit, readFile }) => { + const gitWorkflow = new GitWorkflow({ gitDir: cwd, gitConfigDir: path.join(cwd, './.git') }) + + const origContent = await readFile('README.md') + await execGit(['mv', 'README.md', 'TEST.md']) + await appendFile('TEST.md', 'added content') + + gitWorkflow.partiallyStagedFiles = await gitWorkflow.getPartiallyStagedFiles() + const ctx = getInitialState() + await gitWorkflow.hideUnstagedChanges(ctx) + + /** @todo `git mv` in GitHub Windows runners seem to add `\r\n` newlines in this case. */ + expect(normalizeWindowsNewlines(await readFile('TEST.md'))).toStrictEqual(origContent) + }) + ) + }) + + describe('restoreMergeStatus', () => { + it( + 'should handle error when restoring merge state fails', + withGitIntegration(async ({ cwd }) => { + const gitWorkflow = new GitWorkflow({ gitDir: cwd, gitConfigDir: path.join(cwd, './.git') }) + + gitWorkflow.mergeHeadBuffer = true + writeFile.mockImplementation(() => Promise.reject('test')) + const ctx = getInitialState() + await expect( + gitWorkflow.restoreMergeStatus(ctx) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Merge state could not be restored due to an error!"` + ) + expect(ctx).toMatchInlineSnapshot(` + Object { + "errors": Set { + Symbol(GitError), + Symbol(RestoreMergeStatusError), + }, + "events": EventEmitter { + "_events": Object {}, + "_eventsCount": 0, + "_maxListeners": undefined, + Symbol(kCapture): false, + }, + "hasPartiallyStagedFiles": null, + "output": Array [], + "quiet": false, + "shouldBackup": null, + } + `) + }) + ) + }) +}) diff --git a/test/integration/merge-conflict.test.js b/test/integration/merge-conflict.test.js new file mode 100644 index 000000000..fe173686f --- /dev/null +++ b/test/integration/merge-conflict.test.js @@ -0,0 +1,172 @@ +import './__mocks__/resolveConfig.js' + +import fs from 'node:fs' +import path from 'node:path' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import * as fileFixtures from './__fixtures__/files.js' +import { prettierListDifferent, prettierWrite } from './__fixtures__/configs.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'handles merge conflicts', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile, writeFile }) => { + const fileInBranchA = `module.exports = "foo";\n` + const fileInBranchB = `module.exports = 'bar'\n` + const fileInBranchBFixed = `module.exports = "bar";\n` + + // Create one branch + await execGit(['checkout', '-b', 'branch-a']) + await appendFile('test.js', fileInBranchA) + await appendFile('.lintstagedrc.json', JSON.stringify(prettierWrite)) + await execGit(['add', '.']) + + await gitCommit({ gitCommit: ['-m commit a'] }) + + expect(await readFile('test.js')).toEqual(fileInBranchA) + + await execGit(['checkout', 'master']) + + // Create another branch + await execGit(['checkout', '-b', 'branch-b']) + await appendFile('test.js', fileInBranchB) + await appendFile('.lintstagedrc.json', JSON.stringify(prettierWrite)) + await execGit(['add', '.']) + await gitCommit({ gitCommit: ['-m commit b'] }) + expect(await readFile('test.js')).toEqual(fileInBranchBFixed) + + // Merge first branch + await execGit(['checkout', 'master']) + await execGit(['merge', 'branch-a']) + expect(await readFile('test.js')).toEqual(fileInBranchA) + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('commit a') + + // Merge second branch, causing merge conflict + await expect(execGit(['merge', 'branch-b'])).rejects.toThrowError('Merge conflict in test.js') + + expect(await readFile('test.js')).toMatchInlineSnapshot(` + "<<<<<<< HEAD + module.exports = \\"foo\\"; + ======= + module.exports = \\"bar\\"; + >>>>>>> branch-b + " + `) + + // Fix conflict and commit using lint-staged + await writeFile('test.js', fileInBranchB) + expect(await readFile('test.js')).toEqual(fileInBranchB) + await execGit(['add', '.']) + + await gitCommit({ gitCommit: ['--no-edit'] }) + + // Nothing is wrong, so a new commit is created and file is pretty + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('4') + const log = await execGit(['log', '-1', '--pretty=%B']) + expect(log).toMatch(`Merge branch 'branch-b`) + expect(log).toMatch(`Conflicts:`) + expect(log).toMatch(`test.js`) + expect(await readFile('test.js')).toEqual(fileInBranchBFixed) + }) + ) + + test( + 'handles merge conflict when task errors', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile, writeFile }) => { + const fileInBranchA = `module.exports = "foo";\n` + const fileInBranchB = `module.exports = 'bar'\n` + const fileInBranchBFixed = `module.exports = "bar";\n` + + // Create one branch + await execGit(['checkout', '-b', 'branch-a']) + await appendFile('test.js', fileInBranchA) + await writeFile('.lintstagedrc.json', JSON.stringify(prettierWrite)) + await execGit(['add', '.']) + + await gitCommit({ gitCommit: ['-m commit a'] }) + + expect(await readFile('test.js')).toEqual(fileInBranchA) + + await execGit(['checkout', 'master']) + + // Create another branch + await execGit(['checkout', '-b', 'branch-b']) + await writeFile('.lintstagedrc.json', JSON.stringify(prettierWrite)) + await appendFile('test.js', fileInBranchB) + await execGit(['add', '.']) + + await gitCommit({ gitCommit: ['-m commit b'] }) + + expect(await readFile('test.js')).toEqual(fileInBranchBFixed) + + // Merge first branch + await execGit(['checkout', 'master']) + await execGit(['merge', 'branch-a']) + expect(await readFile('test.js')).toEqual(fileInBranchA) + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('commit a') + + // Merge second branch, causing merge conflict + await expect(execGit(['merge', 'branch-b'])).rejects.toThrowError('Merge conflict in test.js') + + expect(await readFile('test.js')).toMatchInlineSnapshot(` + "<<<<<<< HEAD + module.exports = \\"foo\\"; + ======= + module.exports = \\"bar\\"; + >>>>>>> branch-b + " + `) + + // Fix conflict and commit using lint-staged + await writeFile('test.js', fileInBranchB) + expect(await readFile('test.js')).toEqual(fileInBranchB) + await execGit(['add', '.']) + + await writeFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + await expect(gitCommit()).rejects.toThrowError( + 'Reverting to original state because of errors' + ) + + // Something went wrong, so lintStaged failed and merge is still going + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['status'])).toMatch('All conflicts fixed but you are still merging') + expect(await readFile('test.js')).toEqual(fileInBranchB) + }) + ) + + test( + 'fails to commit entire staged file when there are unrecoverable merge conflicts', + withGitIntegration(async ({ appendFile, cwd, execGit, gitCommit }) => { + // Stage file + await appendFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + // Run lint-staged with action that does horrible things to the file, causing a merge conflict + await expect( + gitCommit({ + lintStaged: { + config: { + '*.js': async () => { + const testFile = path.join(cwd, 'test.js') + fs.writeFileSync(testFile, Buffer.from(fileFixtures.invalidJS, 'binary')) + return `prettier --write ${testFile}` + }, + }, + }, + }) + ).rejects.toThrowError('Unstaged changes could not be restored due to a merge conflict!') + + // Something was wrong so the repo is returned to original state + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + + expect(await execGit(['status', '--porcelain'])).toMatchInlineSnapshot(`"AM test.js"`) + }) + ) +}) diff --git a/test/integration/multiple-config-files.test.js b/test/integration/multiple-config-files.test.js new file mode 100644 index 000000000..25520795b --- /dev/null +++ b/test/integration/multiple-config-files.test.js @@ -0,0 +1,136 @@ +import './__mocks__/resolveConfig.js' +import './__mocks__/dynamicImport.js' + +import path from 'node:path' + +import { jest as jestGlobals } from '@jest/globals' +import normalize from 'normalize-path' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' + +jestGlobals.setTimeout(20000) +jestGlobals.retryTimes(2) + +describe('lint-staged', () => { + test( + 'supports multiple configuration files', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + // 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.js', echoJSConfig('level-2')) + + // Stage all files + await execGit(['add', '.']) + + // Run lint-staged with `--shell` so that tasks do their thing + await gitCommit({ lintStaged: { 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') + }) + ) + + test( + 'supports multiple configuration files with --relative', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + // 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 = `module.exports = { '*.js': (files) => files.map((f) => \`echo \${f} > \${f}\`) }` + + await writeFile('.lintstagedrc.js', echoJSConfig) + await writeFile('deeper/.lintstagedrc.js', echoJSConfig) + await writeFile('deeper/even/.lintstagedrc.js', echoJSConfig) + + // Stage all files + await execGit(['add', '.']) + + // Run lint-staged with `--shell` so that tasks do their thing + await gitCommit({ lintStaged: { relative: true, shell: true } }) + + // 'file.js' is relative to '.' + expect(await readFile('file.js')).toMatch('file.js') + + // 'deeper/file.js' is relative to 'deeper/' + expect(await readFile('deeper/file.js')).toMatch('file.js') + + // 'deeper/even/file.js' is relative to 'deeper/even/' + expect(await readFile('deeper/even/file.js')).toMatch('file.js') + + // 'deeper/even/deeper/file.js' is relative to parent 'deeper/even/' + expect(await readFile('deeper/even/deeper/file.js')).toMatch(normalize('deeper/file.js')) + + // 'a/very/deep/file/path/file.js' is relative to root '.' + expect(await readFile('a/very/deep/file/path/file.js')).toMatch( + normalize('a/very/deep/file/path/file.js') + ) + }) + ) + + test( + 'ignores multiple configs files outside cwd', + withGitIntegration(async ({ cwd, execGit, gitCommit, readFile, writeFile }) => { + // 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.js', echoJSConfig('level-2')) + + // Stage all files + await execGit(['add', '.']) + + // Run lint-staged with `--shell` so that tasks do their thing + // Run in 'deeper/' so that root config is ignored + await gitCommit({ lintStaged: { shell: true } }, path.join(cwd, 'deeper')) + + // 'file.js' was ignored + expect(await readFile('file.js')).toEqual('') + + // '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' was ignored + expect(await readFile('a/very/deep/file/path/file.js')).toEqual('') + }) + ) +}) diff --git a/test/integration/no-initial-commit.test.js b/test/integration/no-initial-commit.test.js new file mode 100644 index 000000000..507289301 --- /dev/null +++ b/test/integration/no-initial-commit.test.js @@ -0,0 +1,39 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import { prettierListDifferent } from './__fixtures__/configs.js' +import { prettyJS } from './__fixtures__/files.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'skips backup when run on an empty git repo without an initial commit', + withGitIntegration( + async ({ appendFile, execGit, gitCommit, readFile, cwd }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + await appendFile('test.js', prettyJS, cwd) + await execGit(['add', 'test.js'], { cwd }) + + await expect(execGit(['log', '-1'], { cwd })).rejects.toThrowErrorMatchingInlineSnapshot( + `"fatal: your current branch 'master' does not have any commits yet"` + ) + + expect(await gitCommit({ lintStaged: { debug: true } })).toMatch( + 'Skipping backup because there’s no initial commit yet' + ) + + // Nothing is wrong, so the initial commit is created + expect(await execGit(['rev-list', '--count', 'HEAD'], { cwd })).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'], { cwd })).toMatch('test') + expect(await readFile('test.js', cwd)).toEqual(prettyJS) + }, + // By default `withGitIntegration` creates the initial commit + { initialCommit: false } + ) + ) +}) diff --git a/test/integration/no-stash.test.js b/test/integration/no-stash.test.js new file mode 100644 index 000000000..7a26ce471 --- /dev/null +++ b/test/integration/no-stash.test.js @@ -0,0 +1,102 @@ +import './__mocks__/resolveConfig.js' + +import fs from 'node:fs' +import path from 'node:path' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import * as fileFixtures from './__fixtures__/files.js' +import * as configFixtures from './__fixtures__/configs.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'skips backup and revert with --no-stash', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + // Stage pretty file + await writeFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + // Run lint-staged with --no-stash + const stdout = await gitCommit({ lintStaged: { stash: false } }) + + expect(stdout).toMatch('Skipping backup because `--no-stash` was used') + + // Nothing is wrong, so a new commit is created + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS) + }) + ) + + test( + 'aborts commit without reverting with --no-stash, when merge conflict', + withGitIntegration(async ({ cwd, execGit, gitCommit, readFile, writeFile }) => { + // Stage file + await writeFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + // Run lint-staged with action that does horrible things to the file, causing a merge conflict + await expect( + gitCommit({ + lintStaged: { + stash: false, + config: { + '*.js': async () => { + const testFile = path.join(cwd, 'test.js') + fs.writeFileSync(testFile, Buffer.from(fileFixtures.invalidJS, 'binary')) + return `prettier --write ${testFile}` + }, + }, + }, + }) + ).rejects.toThrowError('Unstaged changes could not be restored due to a merge conflict!') + + // Something was wrong so the commit was aborted + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + expect(await execGit(['status', '--porcelain'])).toMatchInlineSnapshot(`"UU test.js"`) + + // Without revert, the merge conflict is left in-place + expect(await readFile('test.js')).toMatchInlineSnapshot(` + "<<<<<<< ours + module.exports = { + foo: \\"bar\\", + }; + ======= + const obj = { + 'foo': 'bar' + >>>>>>> theirs + " + `) + }) + ) + + test( + 'aborts commit without reverting with --no-stash, when invalid syntax in file', + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + await writeFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + await writeFile('test2.js', fileFixtures.invalidJS) + await execGit(['add', 'test2.js']) + + // Run lint-staged with --no-stash + await expect(gitCommit({ lintStaged: { stash: false } })).rejects.toThrowError( + 'SyntaxError: Unexpected token' + ) + + // Something was wrong, so the commit was aborted + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS) // file was still fixed + expect(await readFile('test2.js')).toEqual(fileFixtures.invalidJS) + }) + ) +}) diff --git a/test/integration/non-ascii.test.js b/test/integration/non-ascii.test.js new file mode 100644 index 000000000..db8d66bb7 --- /dev/null +++ b/test/integration/non-ascii.test.js @@ -0,0 +1,46 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import * as fileFixtures from './__fixtures__/files.js' +import * as configFixtures from './__fixtures__/configs.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + const getQuotePathTest = (state) => + withGitIntegration(async ({ execGit, gitCommit, readFile, writeFile }) => { + // Run lint-staged with `prettier --write` and commit pretty files + await writeFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + await execGit(['config', 'core.quotepath', state]) + + // Stage multiple ugly files + await writeFile('привет.js', fileFixtures.uglyJS) + await execGit(['add', 'привет.js']) + + await writeFile('你好.js', fileFixtures.uglyJS) + await execGit(['add', '你好.js']) + + await writeFile('👋.js', fileFixtures.uglyJS) + await execGit(['add', '👋.js']) + + await gitCommit() + + // Nothing is wrong, so a new commit is created and files are pretty + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + expect(await readFile('привет.js')).toEqual(fileFixtures.prettyJS) + expect(await readFile('你好.js')).toEqual(fileFixtures.prettyJS) + expect(await readFile('👋.js')).toEqual(fileFixtures.prettyJS) + }) + + test('handles files with non-ascii characters when core.quotepath is on', getQuotePathTest('on')) + + test( + 'handles files with non-ascii characters when core.quotepath is off', + getQuotePathTest('off') + ) +}) diff --git a/test/integration/not-inside-git-repo.test.js b/test/integration/not-inside-git-repo.test.js new file mode 100644 index 000000000..0b1ff2481 --- /dev/null +++ b/test/integration/not-inside-git-repo.test.js @@ -0,0 +1,33 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' +import makeConsoleMock from 'consolemock' +import fs from 'fs-extra' + +import lintStaged from '../../lib/index.js' + +import { createTempDir } from './__utils__/createTempDir.js' +import { prettierWrite } from './__fixtures__/configs.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test('fails when not in a git directory', async () => { + const nonGitDir = await createTempDir() + const logger = makeConsoleMock() + await expect(lintStaged({ ...prettierWrite, cwd: nonGitDir }, logger)).resolves.toEqual(false) + expect(logger.printHistory()).toMatch('Current directory is not a git directory') + await fs.remove(nonGitDir) + }) + + test('fails without output when not in a git directory and quiet', async () => { + const nonGitDir = await createTempDir() + const logger = makeConsoleMock() + await expect( + lintStaged({ ...prettierWrite, cwd: nonGitDir, quiet: true }, logger) + ).resolves.toEqual(false) + expect(logger.printHistory()).toBeFalsy() + await fs.remove(nonGitDir) + }) +}) diff --git a/test/integration/parent-globs.test.js b/test/integration/parent-globs.test.js new file mode 100644 index 000000000..51229f88d --- /dev/null +++ b/test/integration/parent-globs.test.js @@ -0,0 +1,49 @@ +import './__mocks__/resolveConfig.js' +import './__mocks__/dynamicImport.js' + +import path from 'node:path' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'works with parent glob "../*.js"', + withGitIntegration(async ({ cwd, execGit, gitCommit, readFile, writeFile }) => { + // 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', '') + + // Include single-level parent glob in deeper config + await writeFile( + 'deeper/even/.lintstagedrc.js', + `module.exports = { '../*.js': (files) => files.map((f) => \`echo level-2 > \${f}\`) }` + ) + + // Stage all files + await execGit(['add', '.']) + + // Run lint-staged with `--shell` so that tasks do their thing + // Run in 'deeper/' so that root config is ignored + await gitCommit({ lintStaged: { shell: true } }, path.join(cwd, 'deeper/even')) + + // Two levels above, no match + expect(await readFile('file.js')).toEqual('') + + // One level above, match + expect(await readFile('deeper/file.js')).toMatch('level-2') + + // Not directly in the above-level, no match + expect(await readFile('deeper/even/file.js')).toEqual('') + expect(await readFile('deeper/even/deeper/file.js')).toEqual('') + expect(await readFile('a/very/deep/file/path/file.js')).toEqual('') + }) + ) +}) diff --git a/test/integration/partially-staged-changes.test.js b/test/integration/partially-staged-changes.test.js new file mode 100644 index 000000000..0eca154dc --- /dev/null +++ b/test/integration/partially-staged-changes.test.js @@ -0,0 +1,141 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' + +import { normalizeWindowsNewlines } from './__utils__/normalizeWindowsNewlines.js' +import { addConfigFileSerializer } from './__utils__/addConfigFileSerializer.js' +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import * as fileFixtures from './__fixtures__/files.js' +import * as configFixtures from './__fixtures__/configs.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +addConfigFileSerializer() + +describe('lint-staged', () => { + test( + 'commits partial change from partially staged file when no errors from linter', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierListDifferent)) + + // Stage pretty file + await appendFile('test.js', fileFixtures.prettyJS) + await execGit(['add', 'test.js']) + + // Edit pretty file but do not stage changes + const appended = `\nconsole.log("test");\n` + await appendFile('test.js', appended) + + const output = await gitCommit() + + expect(output).toMatch('[SUCCESS] Hiding unstaged changes to partially staged files...') + expect(output).toMatch('[SUCCESS] Applying modifications from tasks...') + expect(output).toMatch('[SUCCESS] Restoring unstaged changes to partially staged files...') + + // Nothing is wrong, so a new commit is created and file is pretty + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + + // Latest commit contains pretty file + // `git show` strips empty line from here here + expect(await execGit(['show', 'HEAD:test.js'])).toEqual(fileFixtures.prettyJS.trim()) + + // Since edit was not staged, the file is still modified + const status = await execGit(['status']) + expect(status).toMatch('modified: test.js') + expect(status).toMatch('no changes added to commit') + + /** @todo `git` in GitHub Windows runners seem to add `\r\n` newlines in this case. */ + expect(normalizeWindowsNewlines(await readFile('test.js'))).toEqual( + fileFixtures.prettyJS + appended + ) + }) + ) + + test( + 'commits partial change from partially staged file when no errors from linter and linter modifies file', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + // Stage ugly file + await appendFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + // Edit ugly file but do not stage changes + const appended = '\n\nconsole.log("test");\n' + await appendFile('test.js', appended) + + await gitCommit() + + // Nothing is wrong, so a new commit is created and file is pretty + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + + // Latest commit contains pretty file + // `git show` strips empty line from here here + expect(await execGit(['show', 'HEAD:test.js'])).toEqual(fileFixtures.prettyJS.trim()) + + // Nothing is staged + const status = await execGit(['status']) + expect(status).toMatch('modified: test.js') + expect(status).toMatch('no changes added to commit') + + // File is pretty, and has been edited + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS + appended) + }) + ) + + test( + 'fails to commit partial change from partially staged file when errors from linter', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierListDifferent)) + + // Stage ugly file + await appendFile('test.js', fileFixtures.uglyJS) + await execGit(['add', 'test.js']) + + // Edit ugly file but do not stage changes + const appended = '\nconsole.log("test");\n' + await appendFile('test.js', appended) + const status = await execGit(['status']) + + // Run lint-staged with `prettier --list-different` to break the linter + await expect(gitCommit(configFixtures.prettierListDifferent)).rejects.toThrowError( + 'Reverting to original state because of errors' + ) + + // Something was wrong so the repo is returned to original state + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + expect(await execGit(['status'])).toEqual(status) + expect(await readFile('test.js')).toEqual(fileFixtures.uglyJS + appended) + }) + ) + + test( + 'fails to commit partial change from partially staged file when errors from linter and linter modifies files', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(configFixtures.prettierWrite)) + + // Add unfixable file to commit so `prettier --write` breaks + await appendFile('test.js', fileFixtures.invalidJS) + await execGit(['add', 'test.js']) + + // Edit unfixable file but do not stage changes + const appended = '\nconsole.log("test");\n' + await appendFile('test.js', appended) + const status = await execGit(['status']) + + await expect(gitCommit()).rejects.toThrowError( + 'Reverting to original state because of errors' + ) + + // Something was wrong so the repo is returned to original state + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + expect(await execGit(['status'])).toEqual(status) + expect(await readFile('test.js')).toEqual(fileFixtures.invalidJS + appended) + }) + ) +}) diff --git a/test/integration/symlinked-config.test.js b/test/integration/symlinked-config.test.js new file mode 100644 index 000000000..e9c515953 --- /dev/null +++ b/test/integration/symlinked-config.test.js @@ -0,0 +1,34 @@ +import './__mocks__/resolveConfig.js' + +import path from 'node:path' + +import { jest } from '@jest/globals' +import fs from 'fs-extra' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import { prettierWrite } from './__fixtures__/configs.js' +import * as fileFixtures from './__fixtures__/files.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'works with symlinked config file', + withGitIntegration(async ({ appendFile, cwd, execGit, gitCommit, readFile }) => { + await appendFile('test.js', fileFixtures.uglyJS) + + await appendFile('.config/.lintstagedrc.json', JSON.stringify(prettierWrite)) + await fs.ensureSymlink( + path.join(cwd, '.config/.lintstagedrc.json'), + path.join(cwd, '.lintstagedrc.json') + ) + + await execGit(['add', '.']) + + await gitCommit() + + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS) // file was fixed + }) + ) +}) diff --git a/test/integration/untracked-files.test.js b/test/integration/untracked-files.test.js new file mode 100644 index 000000000..b5df9fb9e --- /dev/null +++ b/test/integration/untracked-files.test.js @@ -0,0 +1,64 @@ +import './__mocks__/resolveConfig.js' + +import { jest } from '@jest/globals' + +import { withGitIntegration } from './__utils__/withGitIntegration.js' +import * as fileFixtures from './__fixtures__/files.js' +import { prettierListDifferent } from './__fixtures__/configs.js' + +jest.setTimeout(20000) +jest.retryTimes(2) + +describe('lint-staged', () => { + test( + 'ignores untracked files', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile, writeFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + // Stage pretty file + await appendFile('test.js', fileFixtures.prettyJS) + await execGit(['add', 'test.js']) + + // Add untracked files + await appendFile('test-untracked.js', fileFixtures.prettyJS) + await appendFile('.gitattributes', 'binary\n') + await writeFile('binary', Buffer.from('Hello, World!', 'binary')) + + // Run lint-staged with `prettier --list-different` and commit pretty file + await gitCommit() + + // Nothing is wrong, so a new commit is created + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test') + expect(await readFile('test.js')).toEqual(fileFixtures.prettyJS) + expect(await readFile('test-untracked.js')).toEqual(fileFixtures.prettyJS) + expect(Buffer.from(await readFile('binary'), 'binary').toString()).toEqual('Hello, World!') + }) + ) + + test( + 'ingores untracked files when task fails', + withGitIntegration(async ({ appendFile, execGit, gitCommit, readFile, writeFile }) => { + await appendFile('.lintstagedrc.json', JSON.stringify(prettierListDifferent)) + + // Stage unfixable file + await appendFile('test.js', fileFixtures.invalidJS) + await execGit(['add', 'test.js']) + + // Add untracked files + await appendFile('test-untracked.js', fileFixtures.prettyJS) + await appendFile('.gitattributes', 'binary\n') + await writeFile('binary', Buffer.from('Hello, World!', 'binary')) + + // Run lint-staged with `prettier --list-different` and commit pretty file + await expect(gitCommit()).rejects.toThrowError() + + // Something was wrong so the repo is returned to original state + expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1') + expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit') + expect(await readFile('test.js')).toEqual(fileFixtures.invalidJS) + expect(await readFile('test-untracked.js')).toEqual(fileFixtures.prettyJS) + expect(Buffer.from(await readFile('binary'), 'binary').toString()).toEqual('Hello, World!') + }) + ) +}) diff --git a/test/__mocks__/advanced-config.js b/test/unit/__mocks__/advanced-config.js similarity index 100% rename from test/__mocks__/advanced-config.js rename to test/unit/__mocks__/advanced-config.js diff --git a/test/__mocks__/esm-config-in-js.js b/test/unit/__mocks__/esm-config-in-js.js similarity index 100% rename from test/__mocks__/esm-config-in-js.js rename to test/unit/__mocks__/esm-config-in-js.js diff --git a/test/__mocks__/esm-config.mjs b/test/unit/__mocks__/esm-config.mjs similarity index 100% rename from test/__mocks__/esm-config.mjs rename to test/unit/__mocks__/esm-config.mjs diff --git a/test/__mocks__/gitWorkflow.js b/test/unit/__mocks__/gitWorkflow.js similarity index 100% rename from test/__mocks__/gitWorkflow.js rename to test/unit/__mocks__/gitWorkflow.js diff --git a/test/__mocks__/my-config.cjs b/test/unit/__mocks__/my-config.cjs similarity index 100% rename from test/__mocks__/my-config.cjs rename to test/unit/__mocks__/my-config.cjs diff --git a/test/__mocks__/my-config.json b/test/unit/__mocks__/my-config.json similarity index 100% rename from test/__mocks__/my-config.json rename to test/unit/__mocks__/my-config.json diff --git a/test/__mocks__/my-config.yml b/test/unit/__mocks__/my-config.yml similarity index 100% rename from test/__mocks__/my-config.yml rename to test/unit/__mocks__/my-config.yml diff --git a/test/__mocks__/my-lint-staged-config/index.cjs b/test/unit/__mocks__/my-lint-staged-config/index.cjs similarity index 100% rename from test/__mocks__/my-lint-staged-config/index.cjs rename to test/unit/__mocks__/my-lint-staged-config/index.cjs diff --git a/test/__mocks__/my-lint-staged-config/package.json b/test/unit/__mocks__/my-lint-staged-config/package.json similarity index 100% rename from test/__mocks__/my-lint-staged-config/package.json rename to test/unit/__mocks__/my-lint-staged-config/package.json diff --git a/test/__snapshots__/validateConfig.spec.js.snap b/test/unit/__snapshots__/validateConfig.spec.js.snap similarity index 100% rename from test/__snapshots__/validateConfig.spec.js.snap rename to test/unit/__snapshots__/validateConfig.spec.js.snap diff --git a/test/utils/createExecaReturnValue.js b/test/unit/__utils__/mockExecaReturnValue.js similarity index 67% rename from test/utils/createExecaReturnValue.js rename to test/unit/__utils__/mockExecaReturnValue.js index 766008243..a34853f19 100644 --- a/test/utils/createExecaReturnValue.js +++ b/test/unit/__utils__/mockExecaReturnValue.js @@ -1,4 +1,14 @@ -export function createExecaReturnValue(value, executionTime) { +const MOCK_DEFAULT_VALUE = { + stdout: 'a-ok', + stderr: '', + code: 0, + cmd: 'mock cmd', + failed: false, + killed: false, + signal: null, +} + +export const mockExecaReturnValue = (value = MOCK_DEFAULT_VALUE, executionTime) => { const returnValue = { ...value } let triggerResolve let resolveTimeout diff --git a/test/chunkFiles.spec.js b/test/unit/chunkFiles.spec.js similarity index 94% rename from test/chunkFiles.spec.js rename to test/unit/chunkFiles.spec.js index bdd2d689a..db6491975 100644 --- a/test/chunkFiles.spec.js +++ b/test/unit/chunkFiles.spec.js @@ -1,8 +1,8 @@ -import path from 'path' +import path from 'node:path' import normalize from 'normalize-path' -import { chunkFiles } from '../lib/chunkFiles' +import { chunkFiles } from '../../lib/chunkFiles.js' describe('chunkFiles', () => { const files = ['example.js', 'foo.js', 'bar.js', 'foo/bar.js'] diff --git a/test/dynamicImport.spec.js b/test/unit/dynamicImport.spec.js similarity index 75% rename from test/dynamicImport.spec.js rename to test/unit/dynamicImport.spec.js index 9abc014bf..0172798b9 100644 --- a/test/dynamicImport.spec.js +++ b/test/unit/dynamicImport.spec.js @@ -1,4 +1,4 @@ -import { dynamicImport } from '../lib/dynamicImport' +import { dynamicImport } from '../../lib/dynamicImport.js' describe('dynamicImport', () => { it('should log errors into console', () => { diff --git a/test/execGit.spec.js b/test/unit/execGit.spec.js similarity index 76% rename from test/execGit.spec.js rename to test/unit/execGit.spec.js index 17f9592ca..a3a8f038a 100644 --- a/test/execGit.spec.js +++ b/test/unit/execGit.spec.js @@ -1,8 +1,14 @@ -import path from 'path' +import path from 'node:path' import { execa } from 'execa' -import { execGit, GIT_GLOBAL_OPTIONS } from '../lib/execGit' +import { execGit, GIT_GLOBAL_OPTIONS } from '../../lib/execGit.js' + +import { mockExecaReturnValue } from './__utils__/mockExecaReturnValue.js' + +jest.mock('execa', () => ({ + execa: jest.fn(() => mockExecaReturnValue()), +})) test('GIT_GLOBAL_OPTIONS', () => { expect(GIT_GLOBAL_OPTIONS).toMatchInlineSnapshot(` diff --git a/test/file.spec.js b/test/unit/file.spec.js similarity index 87% rename from test/file.spec.js rename to test/unit/file.spec.js index 93f3430c9..03f2abe2d 100644 --- a/test/file.spec.js +++ b/test/unit/file.spec.js @@ -1,4 +1,4 @@ -import { unlink, readFile } from '../lib/file' +import { unlink, readFile } from '../../lib/file.js' describe('unlink', () => { it('should throw when second argument is false and file is not found', async () => { diff --git a/test/generateTasks.spec.js b/test/unit/generateTasks.spec.js similarity index 98% rename from test/generateTasks.spec.js rename to test/unit/generateTasks.spec.js index 227c478e0..c25645268 100644 --- a/test/generateTasks.spec.js +++ b/test/unit/generateTasks.spec.js @@ -1,8 +1,8 @@ -import path from 'path' +import path from 'node:path' import normalize from 'normalize-path' -import { generateTasks } from '../lib/generateTasks' +import { generateTasks } from '../../lib/generateTasks.js' // Windows filepaths const normalizePath = (input) => normalize(path.resolve('/', input)) diff --git a/test/getRenderer.spec.js b/test/unit/getRenderer.spec.js similarity index 96% rename from test/getRenderer.spec.js rename to test/unit/getRenderer.spec.js index dc9c57235..ac544e61d 100644 --- a/test/getRenderer.spec.js +++ b/test/unit/getRenderer.spec.js @@ -1,4 +1,4 @@ -import { getRenderer } from '../lib/getRenderer' +import { getRenderer } from '../../lib/getRenderer.js' describe('getRenderer', () => { it('should return silent renderers when quiet', () => { diff --git a/test/getStagedFiles.spec.js b/test/unit/getStagedFiles.spec.js similarity index 93% rename from test/getStagedFiles.spec.js rename to test/unit/getStagedFiles.spec.js index 620b8ebd2..40f693939 100644 --- a/test/getStagedFiles.spec.js +++ b/test/unit/getStagedFiles.spec.js @@ -1,11 +1,11 @@ -import path from 'path' +import path from 'node:path' import normalize from 'normalize-path' -import { getStagedFiles } from '../lib/getStagedFiles' -import { execGit } from '../lib/execGit' +import { getStagedFiles } from '../../lib/getStagedFiles.js' +import { execGit } from '../../lib/execGit.js' -jest.mock('../lib/execGit') +jest.mock('../../lib/execGit.js') // Windows filepaths const normalizePath = (input) => normalize(path.resolve('/', input)) diff --git a/test/index.spec.js b/test/unit/index.spec.js similarity index 79% rename from test/index.spec.js rename to test/unit/index.spec.js index c07c23845..e4041c72e 100644 --- a/test/index.spec.js +++ b/test/unit/index.spec.js @@ -1,10 +1,17 @@ import { lilconfig } from 'lilconfig' import makeConsoleMock from 'consolemock' +import { getStagedFiles } from '../../lib/getStagedFiles.js' +import lintStaged from '../../lib/index.js' + jest.unmock('execa') -import { getStagedFiles } from '../lib/getStagedFiles' -import lintStaged from '../lib/index' +jest.mock('lilconfig', () => { + const actual = jest.requireActual('lilconfig') + return { + lilconfig: jest.fn((name, options) => actual.lilconfig(name, options)), + } +}) const mockLilConfig = (result) => { lilconfig.mockImplementationOnce(() => ({ @@ -16,13 +23,13 @@ const mockLilConfig = (result) => { * This converts paths into `file://` urls, but this doesn't * work with `import()` when using babel + jest. */ -jest.mock('url', () => ({ +jest.mock('node:url', () => ({ pathToFileURL: (path) => path, })) -jest.mock('../lib/getStagedFiles') -jest.mock('../lib/gitWorkflow') -jest.mock('../lib/resolveConfig', () => ({ +jest.mock('../../lib/getStagedFiles.js') +jest.mock('../../lib/gitWorkflow.js') +jest.mock('../../lib/resolveConfig.js', () => ({ /** Unfortunately necessary due to non-ESM tests. */ resolveConfig: (configPath) => { try { @@ -32,7 +39,7 @@ jest.mock('../lib/resolveConfig', () => ({ } }, })) -jest.mock('../lib/validateOptions', () => ({ +jest.mock('../../lib/validateOptions.js', () => ({ validateOptions: jest.fn().mockImplementation(async () => {}), })) diff --git a/test/index2.spec.js b/test/unit/index2.spec.js similarity index 87% rename from test/index2.spec.js rename to test/unit/index2.spec.js index ee75539b2..2c88c1708 100644 --- a/test/index2.spec.js +++ b/test/unit/index2.spec.js @@ -1,20 +1,26 @@ -import path from 'path' +import path from 'node:path' import { Listr } from 'listr2' import makeConsoleMock from 'consolemock' -import lintStaged from '../lib/index' +import lintStaged from '../../lib/index.js' + +import { mockExecaReturnValue } from './__utils__/mockExecaReturnValue.js' + +jest.mock('execa', () => ({ + execa: jest.fn(() => mockExecaReturnValue()), +})) jest.mock('listr2') const MOCK_CONFIG_FILE = path.join(__dirname, '__mocks__', 'my-config.json') const MOCK_STAGED_FILE = path.resolve(__dirname, '__mocks__', 'sample.js') -jest.mock('../lib/getStagedFiles', () => ({ +jest.mock('../../lib/getStagedFiles.js', () => ({ getStagedFiles: async () => [MOCK_STAGED_FILE], })) -jest.mock('../lib/resolveConfig', () => ({ +jest.mock('../../lib/resolveConfig.js', () => ({ /** Unfortunately necessary due to non-ESM tests. */ resolveConfig: (configPath) => { try { @@ -25,7 +31,7 @@ jest.mock('../lib/resolveConfig', () => ({ }, })) -jest.mock('../lib/resolveGitRepo', () => ({ +jest.mock('../../lib/resolveGitRepo.js', () => ({ resolveGitRepo: async () => ({ gitDir: 'foo', gitConfigDir: 'bar' }), })) diff --git a/test/index3.spec.js b/test/unit/index3.spec.js similarity index 93% rename from test/index3.spec.js rename to test/unit/index3.spec.js index 36697799a..051984544 100644 --- a/test/index3.spec.js +++ b/test/unit/index3.spec.js @@ -1,15 +1,15 @@ import makeConsoleMock from 'consolemock' -import lintStaged from '../lib/index' -import { runAll } from '../lib/runAll' -import { getInitialState } from '../lib/state' -import { ApplyEmptyCommitError, ConfigNotFoundError, GitError } from '../lib/symbols' +import lintStaged from '../../lib/index.js' +import { runAll } from '../../lib/runAll.js' +import { getInitialState } from '../../lib/state.js' +import { ApplyEmptyCommitError, ConfigNotFoundError, GitError } from '../../lib/symbols.js' -jest.mock('../lib/validateOptions.js', () => ({ +jest.mock('../../lib/validateOptions.js', () => ({ validateOptions: jest.fn(async () => {}), })) -jest.mock('../lib/runAll.js', () => ({ +jest.mock('../../lib/runAll.js', () => ({ runAll: jest.fn(async () => {}), })) diff --git a/test/loadConfig.spec.js b/test/unit/loadConfig.spec.js similarity index 85% rename from test/loadConfig.spec.js rename to test/unit/loadConfig.spec.js index 44888364f..b2b3f523c 100644 --- a/test/loadConfig.spec.js +++ b/test/unit/loadConfig.spec.js @@ -1,11 +1,11 @@ -import path from 'path' +import path from 'node:path' import makeConsoleMock from 'consolemock' -import { loadConfig } from '../lib/loadConfig' +import { loadConfig } from '../../lib/loadConfig.js' -jest.mock('../lib/resolveConfig', () => ({ - /** Unfortunately necessary due to non-ESM tests. */ +/** Unfortunately necessary due to non-ESM tests. */ +jest.mock('../../lib/resolveConfig.js', () => ({ resolveConfig: (configPath) => { try { return require.resolve(configPath) @@ -15,8 +15,6 @@ jest.mock('../lib/resolveConfig', () => ({ }, })) -jest.unmock('execa') - /** * This converts paths into `file://` urls, but this doesn't * work with `import()` when using babel + jest. @@ -25,8 +23,6 @@ jest.mock('node: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() @@ -84,7 +80,7 @@ describe('loadConfig', () => { expect.assertions(1) const { config } = await loadConfig( - { configPath: path.join('test', '__mocks__', 'advanced-config.js') }, + { configPath: path.join('test', 'unit', '__mocks__', 'advanced-config.js') }, logger ) @@ -100,7 +96,7 @@ describe('loadConfig', () => { expect.assertions(1) const { config } = await loadConfig( - { configPath: path.join('test', '__mocks__', 'my-config.cjs') }, + { configPath: path.join('test', 'unit', '__mocks__', 'my-config.cjs') }, logger ) @@ -116,7 +112,7 @@ describe('loadConfig', () => { const { config } = await loadConfig( { - configPath: path.join('test', '__mocks__', 'esm-config.mjs'), + configPath: path.join('test', 'unit', '__mocks__', 'esm-config.mjs'), debug: true, quiet: true, }, @@ -135,7 +131,7 @@ describe('loadConfig', () => { const { config } = await loadConfig( { - configPath: path.join('test', '__mocks__', 'esm-config-in-js.js'), + configPath: path.join('test', 'unit', '__mocks__', 'esm-config-in-js.js'), debug: true, quiet: true, }, diff --git a/test/makeCmdTasks.spec.js b/test/unit/makeCmdTasks.spec.js similarity index 95% rename from test/makeCmdTasks.spec.js rename to test/unit/makeCmdTasks.spec.js index 999052faf..75ca7eae8 100644 --- a/test/makeCmdTasks.spec.js +++ b/test/unit/makeCmdTasks.spec.js @@ -1,6 +1,12 @@ import { execa } from 'execa' -import { makeCmdTasks } from '../lib/makeCmdTasks' +import { makeCmdTasks } from '../../lib/makeCmdTasks.js' + +import { mockExecaReturnValue } from './__utils__/mockExecaReturnValue.js' + +jest.mock('execa', () => ({ + execa: jest.fn(() => mockExecaReturnValue()), +})) describe('makeCmdTasks', () => { const gitDir = process.cwd() diff --git a/test/parseGitZOutput.spec.js b/test/unit/parseGitZOutput.spec.js similarity index 88% rename from test/parseGitZOutput.spec.js rename to test/unit/parseGitZOutput.spec.js index 6a24a3564..28be8390d 100644 --- a/test/parseGitZOutput.spec.js +++ b/test/unit/parseGitZOutput.spec.js @@ -1,4 +1,4 @@ -import { parseGitZOutput } from '../lib/parseGitZOutput' +import { parseGitZOutput } from '../../lib/parseGitZOutput.js' describe('parseGitZOutput', () => { it('should split string from `git -z` control character', () => { diff --git a/test/printTaskOutput.spec.js b/test/unit/printTaskOutput.spec.js similarity index 84% rename from test/printTaskOutput.spec.js rename to test/unit/printTaskOutput.spec.js index 12ac36999..f7ba4ce7e 100644 --- a/test/printTaskOutput.spec.js +++ b/test/unit/printTaskOutput.spec.js @@ -1,4 +1,4 @@ -import { printTaskOutput } from '../lib/printTaskOutput' +import { printTaskOutput } from '../../lib/printTaskOutput.js' const logger = { error: jest.fn(() => {}), diff --git a/test/resolveGitRepo.spec.js b/test/unit/resolveGitRepo.spec.js similarity index 90% rename from test/resolveGitRepo.spec.js rename to test/unit/resolveGitRepo.spec.js index 8dcd33a68..b98dcc093 100644 --- a/test/resolveGitRepo.spec.js +++ b/test/unit/resolveGitRepo.spec.js @@ -1,8 +1,8 @@ -import path from 'path' +import path from 'node:path' import normalize from 'normalize-path' -import { determineGitDir, resolveGitRepo } from '../lib/resolveGitRepo' +import { determineGitDir, resolveGitRepo } from '../../lib/resolveGitRepo.js' /** * resolveGitRepo runs execa, so the mock needs to be disabled for these tests @@ -16,8 +16,9 @@ describe('resolveGitRepo', () => { expect(gitDir).toEqual(cwd) }) + const expected = normalize(path.join(path.dirname(__dirname), '../')) + it('should resolve to the parent dir when .git is in the parent dir', async () => { - const expected = normalize(path.dirname(__dirname)) const processCwdBkp = process.cwd process.cwd = () => __dirname const { gitDir } = await resolveGitRepo() @@ -26,7 +27,6 @@ describe('resolveGitRepo', () => { }) it('should resolve to the parent dir when .git is in the parent dir even when the GIT_DIR environment variable is set', async () => { - const expected = normalize(path.dirname(__dirname)) const processCwdBkp = process.cwd process.cwd = () => __dirname process.env.GIT_DIR = 'wrong/path/.git' // refer to https://github.com/DonJayamanne/gitHistoryVSCode/issues/233#issuecomment-375769718 @@ -36,7 +36,6 @@ describe('resolveGitRepo', () => { }) it('should resolve to the parent dir when .git is in the parent dir even when the GIT_WORK_TREE environment variable is set', async () => { - const expected = normalize(path.dirname(__dirname)) const processCwdBkp = process.cwd process.cwd = () => __dirname process.env.GIT_WORK_TREE = './wrong/path/' diff --git a/test/resolveTaskFn.spec.js b/test/unit/resolveTaskFn.spec.js similarity index 92% rename from test/resolveTaskFn.spec.js rename to test/unit/resolveTaskFn.spec.js index cbcf93ba1..1aeb64ed8 100644 --- a/test/resolveTaskFn.spec.js +++ b/test/unit/resolveTaskFn.spec.js @@ -1,25 +1,32 @@ -import { execa } from 'execa' +import { execa, execaCommand } from 'execa' import pidTree from 'pidtree' -import { resolveTaskFn } from '../lib/resolveTaskFn' -import { getInitialState } from '../lib/state' -import { TaskError } from '../lib/symbols' +import { resolveTaskFn } from '../../lib/resolveTaskFn.js' +import { getInitialState } from '../../lib/state.js' +import { TaskError } from '../../lib/symbols.js' -import { createExecaReturnValue } from './utils/createExecaReturnValue' +import { mockExecaReturnValue } from './__utils__/mockExecaReturnValue.js' jest.useFakeTimers() +jest.mock('execa', () => ({ + execa: jest.fn(() => mockExecaReturnValue()), + execaCommand: jest.fn(() => mockExecaReturnValue()), +})) + jest.mock('pidtree', () => jest.fn(async () => [])) const defaultOpts = { files: ['test.js'] } const mockExecaImplementationOnce = (value) => { - execa.mockImplementationOnce(() => createExecaReturnValue(value)) + execa.mockImplementationOnce(() => mockExecaReturnValue(value)) + execaCommand.mockImplementationOnce(() => mockExecaReturnValue(value)) } describe('resolveTaskFn', () => { beforeEach(() => { execa.mockClear() + execaCommand.mockClear() }) it('should support non npm scripts', async () => { @@ -67,8 +74,8 @@ describe('resolveTaskFn', () => { }) await taskFn() - expect(execa).toHaveBeenCalledTimes(1) - expect(execa).lastCalledWith('node --arg=true ./myscript.js test.js', { + expect(execaCommand).toHaveBeenCalledTimes(1) + expect(execaCommand).lastCalledWith('node --arg=true ./myscript.js test.js', { cwd: process.cwd(), preferLocal: true, reject: false, @@ -85,8 +92,8 @@ describe('resolveTaskFn', () => { }) await taskFn() - expect(execa).toHaveBeenCalledTimes(1) - expect(execa).lastCalledWith('node --arg=true ./myscript.js test.js', { + expect(execaCommand).toHaveBeenCalledTimes(1) + expect(execaCommand).lastCalledWith('node --arg=true ./myscript.js test.js', { cwd: process.cwd(), preferLocal: true, reject: false, @@ -103,8 +110,8 @@ describe('resolveTaskFn', () => { }) await taskFn() - expect(execa).toHaveBeenCalledTimes(1) - expect(execa).lastCalledWith('node --arg=true ./myscript.js test.js', { + expect(execaCommand).toHaveBeenCalledTimes(1) + expect(execaCommand).lastCalledWith('node --arg=true ./myscript.js test.js', { cwd: process.cwd(), preferLocal: true, reject: false, @@ -359,7 +366,7 @@ describe('resolveTaskFn', () => { it('should not kill long running tasks without errors in context', async () => { execa.mockImplementationOnce(() => - createExecaReturnValue( + mockExecaReturnValue( { stdout: 'a-ok', stderr: '', @@ -384,7 +391,7 @@ describe('resolveTaskFn', () => { it('should ignore pid-tree errors', async () => { execa.mockImplementationOnce(() => - createExecaReturnValue( + mockExecaReturnValue( { stdout: 'a-ok', stderr: '', @@ -415,7 +422,7 @@ describe('resolveTaskFn', () => { it('should kill a long running task when error event is emitted', async () => { execa.mockImplementationOnce(() => - createExecaReturnValue( + mockExecaReturnValue( { stdout: 'a-ok', stderr: '', @@ -444,7 +451,7 @@ describe('resolveTaskFn', () => { expect.assertions(3) execa.mockImplementationOnce(() => - createExecaReturnValue( + mockExecaReturnValue( { stdout: 'a-ok', stderr: '', @@ -489,7 +496,7 @@ describe('resolveTaskFn', () => { expect.assertions(3) execa.mockImplementationOnce(() => - createExecaReturnValue( + mockExecaReturnValue( { stdout: 'a-ok', stderr: '', diff --git a/test/resolveTaskFn.unmocked.spec.js b/test/unit/resolveTaskFn.unmocked.spec.js similarity index 89% rename from test/resolveTaskFn.unmocked.spec.js rename to test/unit/resolveTaskFn.unmocked.spec.js index ecef88ec0..f3a4759ae 100644 --- a/test/resolveTaskFn.unmocked.spec.js +++ b/test/unit/resolveTaskFn.unmocked.spec.js @@ -1,5 +1,5 @@ -import { resolveTaskFn } from '../lib/resolveTaskFn' -import { getInitialState } from '../lib/state' +import { resolveTaskFn } from '../../lib/resolveTaskFn.js' +import { getInitialState } from '../../lib/state.js' jest.unmock('execa') diff --git a/test/runAll.spec.js b/test/unit/runAll.spec.js similarity index 91% rename from test/runAll.spec.js rename to test/unit/runAll.spec.js index d09788075..fc8a6f885 100644 --- a/test/runAll.spec.js +++ b/test/unit/runAll.spec.js @@ -1,24 +1,28 @@ -import path from 'path' +import path from 'node:path' import makeConsoleMock from 'consolemock' import { execa } from 'execa' import normalize from 'normalize-path' -import { getStagedFiles } from '../lib/getStagedFiles' -import { GitWorkflow } from '../lib/gitWorkflow' -import { resolveGitRepo } from '../lib/resolveGitRepo' -import { runAll } from '../lib/runAll' -import { ConfigNotFoundError, GitError } from '../lib/symbols' -import * as searchConfigsNS from '../lib/searchConfigs' +import { getStagedFiles } from '../../lib/getStagedFiles.js' +import { GitWorkflow } from '../../lib/gitWorkflow.js' +import { resolveGitRepo } from '../../lib/resolveGitRepo.js' +import { runAll } from '../../lib/runAll.js' +import { ConfigNotFoundError, GitError } from '../../lib/symbols.js' +import * as searchConfigsNS from '../../lib/searchConfigs.js' -import { createExecaReturnValue } from './utils/createExecaReturnValue' +import { mockExecaReturnValue } from './__utils__/mockExecaReturnValue.js' -jest.mock('../lib/file') -jest.mock('../lib/getStagedFiles') -jest.mock('../lib/gitWorkflow') -jest.mock('../lib/resolveGitRepo') +jest.mock('execa', () => ({ + execa: jest.fn(() => mockExecaReturnValue()), +})) + +jest.mock('../../lib/file.js') +jest.mock('../../lib/getStagedFiles.js') +jest.mock('../../lib/gitWorkflow.js') +jest.mock('../../lib/resolveGitRepo.js') -jest.mock('../lib/resolveConfig', () => ({ +jest.mock('../../lib/resolveConfig.js', () => ({ /** Unfortunately necessary due to non-ESM tests. */ resolveConfig: (configPath) => { try { @@ -181,7 +185,7 @@ describe('runAll', () => { expect.assertions(2) getStagedFiles.mockImplementationOnce(async () => ['sample.js']) GitWorkflow.mockImplementationOnce(() => ({ - ...jest.requireActual('../lib/gitWorkflow'), + ...jest.requireActual('../../lib/gitWorkflow.js'), prepare: (ctx) => { ctx.errors.add(GitError) throw new Error('test') @@ -211,7 +215,7 @@ describe('runAll', () => { expect.assertions(2) getStagedFiles.mockImplementationOnce(async () => ['sample.js']) execa.mockImplementation(() => - createExecaReturnValue({ + mockExecaReturnValue({ stdout: '', stderr: 'Linter finished with error', code: 1, @@ -248,7 +252,7 @@ describe('runAll', () => { expect.assertions(2) getStagedFiles.mockImplementationOnce(async () => ['sample.js']) execa.mockImplementation(() => - createExecaReturnValue({ + mockExecaReturnValue({ stdout: '', stderr: '', code: 0, @@ -392,6 +396,12 @@ describe('runAll', () => { } }) + it('should warn when "git add" was used in commands', async () => { + getStagedFiles.mockImplementationOnce(async () => ['sample.js']) + await runAll({ configObject: { '*.js': ['git add'] } }).catch(() => {}) + expect(console.printHistory()).toMatch('Some of your tasks use `git add` command') + }) + it('should warn when --no-stash was used', async () => { await runAll({ configObject: { '*.js': ['echo "sample"'] }, stash: false }) expect(console.printHistory()).toMatch('Skipping backup because `--no-stash` was used') diff --git a/test/searchConfigs.spec.js b/test/unit/searchConfigs.spec.js similarity index 89% rename from test/searchConfigs.spec.js rename to test/unit/searchConfigs.spec.js index eb229fe6a..9d0a2ac8e 100644 --- a/test/searchConfigs.spec.js +++ b/test/unit/searchConfigs.spec.js @@ -1,12 +1,12 @@ -import path from 'path' +import path from 'node:path' import normalize from 'normalize-path' -import { execGit } from '../lib/execGit.js' -import { loadConfig } from '../lib/loadConfig.js' -import { searchConfigs } from '../lib/searchConfigs.js' +import { execGit } from '../../lib/execGit.js' +import { loadConfig } from '../../lib/loadConfig.js' +import { searchConfigs } from '../../lib/searchConfigs.js' -jest.mock('../lib/resolveConfig', () => ({ +jest.mock('../../lib/resolveConfig', () => ({ /** Unfortunately necessary due to non-ESM tests. */ resolveConfig: (configPath) => { try { @@ -17,15 +17,15 @@ jest.mock('../lib/resolveConfig', () => ({ }, })) -jest.mock('../lib/execGit.js', () => ({ +jest.mock('../../lib/execGit.js', () => ({ execGit: jest.fn(async () => { /** Mock fails by default */ return '' }), })) -jest.mock('../lib/loadConfig.js', () => { - const { searchPlaces } = jest.requireActual('../lib/loadConfig.js') +jest.mock('../../lib/loadConfig.js', () => { + const { searchPlaces } = jest.requireActual('../../lib/loadConfig.js') return { searchPlaces, diff --git a/test/state.spec.js b/test/unit/state.spec.js similarity index 93% rename from test/state.spec.js rename to test/unit/state.spec.js index f356ba45f..a8284affa 100644 --- a/test/state.spec.js +++ b/test/unit/state.spec.js @@ -3,8 +3,8 @@ import { cleanupSkipped, restoreOriginalStateSkipped, restoreUnstagedChangesSkipped, -} from '../lib/state' -import { GitError, RestoreOriginalStateError } from '../lib/symbols' +} from '../../lib/state.js' +import { GitError, RestoreOriginalStateError } from '../../lib/symbols.js' describe('applyModificationsSkipped', () => { it('should return false when backup is disabled', () => { diff --git a/test/validateBraces.spec.js b/test/unit/validateBraces.spec.js similarity index 97% rename from test/validateBraces.spec.js rename to test/unit/validateBraces.spec.js index b70b96a80..6872021f5 100644 --- a/test/validateBraces.spec.js +++ b/test/unit/validateBraces.spec.js @@ -1,6 +1,6 @@ import makeConsoleMock from 'consolemock' -import { validateBraces, BRACES_REGEXP } from '../lib/validateBraces' +import { validateBraces, BRACES_REGEXP } from '../../lib/validateBraces.js' describe('BRACES_REGEXP', () => { it(`should match '*.{js}'`, () => { diff --git a/test/validateConfig.spec.js b/test/unit/validateConfig.spec.js similarity index 97% rename from test/validateConfig.spec.js rename to test/unit/validateConfig.spec.js index 970bd5c97..6d10cdcbd 100644 --- a/test/validateConfig.spec.js +++ b/test/unit/validateConfig.spec.js @@ -1,6 +1,6 @@ import makeConsoleMock from 'consolemock' -import { validateConfig } from '../lib/validateConfig' +import { validateConfig } from '../../lib/validateConfig.js' const configPath = '.lintstagedrc.json' diff --git a/test/validateOptions.spec.js b/test/unit/validateOptions.spec.js similarity index 94% rename from test/validateOptions.spec.js rename to test/unit/validateOptions.spec.js index ea0e369b0..e7590c59e 100644 --- a/test/validateOptions.spec.js +++ b/test/unit/validateOptions.spec.js @@ -1,10 +1,11 @@ -import { constants, promises as fs } from 'fs' -import path from 'path' +import { constants } from 'node:fs' +import fs from 'node:fs/promises' +import path from 'node:path' import makeConsoleMock from 'consolemock' -import { validateOptions } from '../lib/validateOptions' -import { InvalidOptionsError } from '../lib/symbols' +import { validateOptions } from '../../lib/validateOptions.js' +import { InvalidOptionsError } from '../../lib/symbols.js' describe('validateOptions', () => { const mockAccess = jest.spyOn(fs, 'access') diff --git a/test/utils/tempDir.js b/test/utils/tempDir.js deleted file mode 100644 index a66a898f5..000000000 --- a/test/utils/tempDir.js +++ /dev/null @@ -1,17 +0,0 @@ -import os from 'os' -import path from 'path' - -import fs from 'fs-extra' - -const osTmpDir = fs.realpathSync(process.env.RUNNER_TEMP || os.tmpdir()) - -/** - * Create temporary random directory and return its path - * @returns {Promise} - */ -export const createTempDir = async () => { - const random = Date.now().toString(36) + Math.random().toString(36).substr(2) - const dirname = path.resolve(osTmpDir, `lint-staged-${random}`) - await fs.ensureDir(dirname) - return dirname -}