diff --git a/CHANGELOG.md b/CHANGELOG.md index cecb1ed5096b..d5912db61c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[jest-changed-files]` Support Sapling ([#13941](https://github.com/facebook/jest/pull/13941)) - `[jest-cli, jest-config, @jest/core, jest-haste-map, @jest/reporters, jest-runner, jest-runtime, @jest/types]` Add `workerThreads` configuration option to allow using [worker threads](https://nodejs.org/dist/latest/docs/api/worker_threads.html) for parallelization ([#13939](https://github.com/facebook/jest/pull/13939)) - `[jest-worker]` Add `start` method to worker farms ([#13937](https://github.com/facebook/jest/pull/13937)) diff --git a/e2e/Utils.ts b/e2e/Utils.ts index bbb00d1d0a9d..a51b158cc837 100644 --- a/e2e/Utils.ts +++ b/e2e/Utils.ts @@ -336,3 +336,36 @@ export const testIfHg = (...args: Parameters) => { test.skip(...args); } }; + +// Certain environments (like CITGM and GH Actions) do not come with sapling installed +let slIsInstalled: boolean | null = null; +export const testIfSl = (...args: Parameters) => { + if (slIsInstalled === null) { + slIsInstalled = which.sync('sl', {nothrow: true}) !== null; + } + + if (slIsInstalled) { + test(...args); + } else { + console.warn('Sapling (sl) is not installed - skipping some tests'); + test.skip(...args); + } +}; + +export const testIfSlAndHg = (...args: Parameters) => { + if (slIsInstalled === null) { + slIsInstalled = which.sync('sl', {nothrow: true}) !== null; + } + if (hgIsInstalled === null) { + hgIsInstalled = which.sync('hg', {nothrow: true}) !== null; + } + + if (slIsInstalled && hgIsInstalled) { + test(...args); + } else { + console.warn( + 'Sapling (sl) or Mercurial (hg) is not installed - skipping some tests', + ); + test.skip(...args); + } +}; diff --git a/e2e/__tests__/jestChangedFiles.test.ts b/e2e/__tests__/jestChangedFiles.test.ts index 3d6f4c0c158a..a96737a494e1 100644 --- a/e2e/__tests__/jestChangedFiles.test.ts +++ b/e2e/__tests__/jestChangedFiles.test.ts @@ -7,16 +7,25 @@ import {tmpdir} from 'os'; import * as path from 'path'; +import * as fs from 'graceful-fs'; import semver = require('semver'); import slash = require('slash'); import {findRepos, getChangedFilesForRoots} from 'jest-changed-files'; -import {cleanup, run, testIfHg, writeFiles} from '../Utils'; +import { + cleanup, + run, + testIfHg, + testIfSl, + testIfSlAndHg, + writeFiles, +} from '../Utils'; import runJest from '../runJest'; const DIR = path.resolve(tmpdir(), 'jest-changed-files-test-dir'); const GIT = 'git -c user.name=jest_test -c user.email=jest_test@test.com'; const HG = 'hg --config ui.username=jest_test'; +const SL = 'sl --config ui.username=jest_test'; const gitVersionSupportsInitialBranch = (() => { const {stdout} = run(`${GIT} --version`); @@ -129,42 +138,56 @@ test('gets git SCM roots and dedupes them', async () => { ); }); -testIfHg('gets mixed git and hg SCM roots and dedupes them', async () => { - writeFiles(DIR, { - 'first-repo/file1.txt': 'file1', - 'first-repo/nested-dir/file2.txt': 'file2', - 'first-repo/nested-dir/second-nested-dir/file3.txt': 'file3', - 'second-repo/file1.txt': 'file1', - 'second-repo/nested-dir/file2.txt': 'file2', - 'second-repo/nested-dir/second-nested-dir/file3.txt': 'file3', - }); - - gitInit(path.resolve(DIR, 'first-repo')); - run(`${HG} init`, path.resolve(DIR, 'second-repo')); - - const roots = [ - '', - 'first-repo/nested-dir', - 'first-repo/nested-dir/second-nested-dir', - 'second-repo/nested-dir', - 'second-repo/nested-dir/second-nested-dir', - ].map(filename => path.resolve(DIR, filename)); - - const repos = await findRepos(roots); - const hgRepos = Array.from(repos.hg); - const gitRepos = Array.from(repos.git); - - // NOTE: This test can break if you have a .git or .hg repo initialized - // inside your os tmp directory. - expect(gitRepos).toHaveLength(1); - expect(hgRepos).toHaveLength(1); - expect(slash(gitRepos[0])).toMatch( - /\/jest-changed-files-test-dir\/first-repo\/?$/, - ); - expect(slash(hgRepos[0])).toMatch( - /\/jest-changed-files-test-dir\/second-repo\/?$/, - ); -}); +testIfSlAndHg( + 'gets mixed git, hg, and sl SCM roots and dedupes them', + async () => { + writeFiles(DIR, { + 'first-repo/file1.txt': 'file1', + 'first-repo/nested-dir/file2.txt': 'file2', + 'first-repo/nested-dir/second-nested-dir/file3.txt': 'file3', + 'second-repo/file1.txt': 'file1', + 'second-repo/nested-dir/file2.txt': 'file2', + 'second-repo/nested-dir/second-nested-dir/file3.txt': 'file3', + 'third-repo/file1.txt': 'file1', + 'third-repo/nested-dir/file2.txt': 'file2', + 'third-repo/nested-dir/second-nested-dir/file3.txt': 'file3', + }); + + gitInit(path.resolve(DIR, 'first-repo')); + run(`${HG} init`, path.resolve(DIR, 'second-repo')); + run(`${SL} init --git`, path.resolve(DIR, 'third-repo')); + + const roots = [ + '', + 'first-repo/nested-dir', + 'first-repo/nested-dir/second-nested-dir', + 'second-repo/nested-dir', + 'second-repo/nested-dir/second-nested-dir', + 'third-repo/nested-dir', + 'third-repo/nested-dir/second-nested-dir', + ].map(filename => path.resolve(DIR, filename)); + + const repos = await findRepos(roots); + const hgRepos = Array.from(repos.hg); + const gitRepos = Array.from(repos.git); + const slRepos = Array.from(repos.sl); + + // NOTE: This test can break if you have a .git or .hg repo initialized + // inside your os tmp directory. + expect(gitRepos).toHaveLength(1); + expect(hgRepos).toHaveLength(1); + expect(slRepos).toHaveLength(1); + expect(slash(gitRepos[0])).toMatch( + /\/jest-changed-files-test-dir\/first-repo\/?$/, + ); + expect(slash(hgRepos[0])).toMatch( + /\/jest-changed-files-test-dir\/second-repo\/?$/, + ); + expect(slash(slRepos[0])).toMatch( + /\/jest-changed-files-test-dir\/third-repo\/?$/, + ); + }, +); test('gets changed files for git', async () => { writeFiles(DIR, { @@ -496,3 +519,183 @@ testIfHg('handles a bad revision for "changedSince", for hg', async () => { expect(stderr).toContain('Test suite failed to run'); expect(stderr).toContain("abort: unknown revision 'blablabla'"); }); + +testIfSl('gets sl SCM roots and dedupes them', async () => { + fs.mkdirSync(path.resolve(DIR, 'first-repo'), {recursive: true}); + writeFiles(DIR, { + 'first-repo/file1.txt': 'file1', + 'first-repo/nested-dir/file2.txt': 'file2', + 'first-repo/nested-dir/second-nested-dir/file3.txt': 'file3', + 'second-repo/file1.txt': 'file1', + 'second-repo/nested-dir/file2.txt': 'file2', + 'second-repo/nested-dir/second-nested-dir/file3.txt': 'file3', + }); + + run(`${SL} init --git`, path.resolve(DIR, 'first-repo')); + run(`${SL} init --git`, path.resolve(DIR, 'second-repo')); + + const roots = [ + '', + 'first-repo/nested-dir', + 'first-repo/nested-dir/second-nested-dir', + 'second-repo/nested-dir', + 'second-repo/nested-dir/second-nested-dir', + ].map(filename => path.resolve(DIR, filename)); + + const repos = await findRepos(roots); + expect(repos.git.size).toBe(0); + expect(repos.hg.size).toBe(0); + + const slRepos = Array.from(repos.sl); + + // it's not possible to match the exact path because it will resolve + // differently on different platforms. + // NOTE: This test can break if you have a .sl repo initialized inside your + // os tmp directory. + expect(slRepos).toHaveLength(2); + expect(slash(slRepos[0])).toMatch( + /\/jest-changed-files-test-dir\/first-repo\/?$/, + ); + expect(slash(slRepos[1])).toMatch( + /\/jest-changed-files-test-dir\/second-repo\/?$/, + ); +}); + +testIfSl('gets changed files for sl', async () => { + // file1.txt is used to make a multi-line commit message + // with `sl commit -l file1.txt`. + // This is done to ensure that `changedFiles` only returns files + // and not parts of commit messages. + writeFiles(DIR, { + 'file1.txt': 'file1\n\nextra-line', + 'nested-dir/file2.txt': 'file2', + 'nested-dir/second-nested-dir/file3.txt': 'file3', + }); + + run(`${SL} init --git`, DIR); + + const roots = ['', 'nested-dir', 'nested-dir/second-nested-dir'].map( + filename => path.resolve(DIR, filename), + ); + + let {changedFiles: files} = await getChangedFilesForRoots(roots, {}); + expect( + Array.from(files) + .map(filePath => path.basename(filePath)) + .sort(), + ).toEqual(['file1.txt', 'file2.txt', 'file3.txt']); + + run(`${SL} add .`, DIR); + run(`${SL} commit -l file1.txt`, DIR); + + ({changedFiles: files} = await getChangedFilesForRoots(roots, {})); + expect(Array.from(files)).toEqual([]); + + ({changedFiles: files} = await getChangedFilesForRoots(roots, { + lastCommit: true, + })); + expect( + Array.from(files) + .map(filePath => path.basename(filePath)) + .sort(), + ).toEqual(['file1.txt', 'file2.txt', 'file3.txt']); + + writeFiles(DIR, { + 'file1.txt': 'modified file1', + }); + + ({changedFiles: files} = await getChangedFilesForRoots(roots, {})); + expect( + Array.from(files) + .map(filePath => path.basename(filePath)) + .sort(), + ).toEqual(['file1.txt']); + + run(`${SL} commit -m "test2"`, DIR); + + writeFiles(DIR, { + 'file4.txt': 'file4', + }); + + ({changedFiles: files} = await getChangedFilesForRoots(roots, { + withAncestor: true, + })); + // Returns files from current uncommitted state + the last commit + expect( + Array.from(files) + .map(filePath => path.basename(filePath)) + .sort(), + ).toEqual(['file1.txt', 'file4.txt']); + + run(`${SL} add file4.txt`, DIR); + run(`${SL} commit -m "test3"`, DIR); + + ({changedFiles: files} = await getChangedFilesForRoots(roots, { + changedSince: '.~2', + })); + // Returns files from the last 2 commits + expect( + Array.from(files) + .map(filePath => path.basename(filePath)) + .sort(), + ).toEqual(['file1.txt', 'file4.txt']); + + run(`${SL} bookmark main_branch`, DIR); + // Back up and develop on a different branch + run(`${SL}`, DIR); + run(`${SL} go prev(2)`, DIR); + + writeFiles(DIR, { + 'file5.txt': 'file5', + }); + run(`${SL} add file5.txt`, DIR); + run(`${SL} commit -m "test4"`, DIR); + + ({changedFiles: files} = await getChangedFilesForRoots(roots, { + changedSince: 'main_branch', + })); + // Returns files from this branch but not ones that only exist on main + expect( + Array.from(files) + .map(filePath => path.basename(filePath)) + .sort(), + ).toEqual(['file5.txt']); +}); + +testIfSl('monitors only root paths for sl', async () => { + writeFiles(DIR, { + 'file1.txt': 'file1', + 'nested-dir/file2.txt': 'file2', + 'nested-dir/second-nested-dir/file3.txt': 'file3', + }); + + run(`${SL} init --git`, DIR); + + const roots = [path.resolve(DIR, 'nested-dir')]; + + const {changedFiles: files} = await getChangedFilesForRoots(roots, {}); + expect( + Array.from(files) + .map(filePath => path.basename(filePath)) + .sort(), + ).toEqual(['file2.txt', 'file3.txt']); +}); + +testIfSl('handles a bad revision for "changedSince", for sl', async () => { + writeFiles(DIR, { + '.watchmanconfig': '', + '__tests__/file1.test.js': "require('../file1'); test('file1', () => {});", + 'file1.js': 'module.exports = {}', + 'package.json': '{}', + }); + + run(`${SL} init --git`, DIR); + run(`${SL} add .`, DIR); + run(`${SL} commit -m "first"`, DIR); + + const {exitCode, stderr} = runJest(DIR, ['--changedSince=blablabla']); + + expect(exitCode).toBe(1); + expect(stderr).toContain('Test suite failed to run'); + expect(stderr).toContain("abort: unknown revision 'blablabla'"); +}); diff --git a/e2e/__tests__/onlyChanged.test.ts b/e2e/__tests__/onlyChanged.test.ts index 2d50441d7ec6..be77ee63a5c6 100644 --- a/e2e/__tests__/onlyChanged.test.ts +++ b/e2e/__tests__/onlyChanged.test.ts @@ -8,7 +8,7 @@ import {tmpdir} from 'os'; import * as path from 'path'; import semver = require('semver'); -import {cleanup, run, testIfHg, writeFiles} from '../Utils'; +import {cleanup, run, testIfHg, testIfSl, writeFiles} from '../Utils'; import runJest from '../runJest'; const DIR = path.resolve(tmpdir(), 'jest_only_changed'); @@ -354,6 +354,50 @@ testIfHg('gets changed files for hg', async () => { expect(stderr).toMatch(/PASS __tests__(\/|\\)file3.test.js/); }); +const SL = 'sl --config ui.username=jest_test'; +testIfSl('gets changed files for sl', async () => { + writeFiles(DIR, { + '.watchmanconfig': '', + '__tests__/file1.test.js': "require('../file1'); test('file1', () => {});", + 'file1.js': 'module.exports = {}', + 'package.json': JSON.stringify({jest: {testEnvironment: 'node'}}), + }); + + run(`${SL} init --git`, DIR); + run(`${SL} add .`, DIR); + run(`${SL} commit -m "test"`, DIR); + + let stdout; + let stderr; + + ({stdout} = runJest(DIR, ['-o'])); + expect(stdout).toMatch('No tests found related to files changed'); + + writeFiles(DIR, { + '__tests__/file2.test.js': "require('../file2'); test('file2', () => {});", + 'file2.js': 'module.exports = {}', + 'file3.js': "require('./file2')", + }); + + ({stderr} = runJest(DIR, ['-o'])); + expect(stderr).toMatch(/PASS __tests__(\/|\\)file2.test.js/); + + run(`${SL} add .`, DIR); + run(`${SL} commit -m "test2"`, DIR); + + writeFiles(DIR, { + '__tests__/file3.test.js': "require('../file3'); test('file3', () => {});", + }); + + ({stdout, stderr} = runJest(DIR, ['-o'])); + expect(stderr).toMatch(/PASS __tests__(\/|\\)file3.test.js/); + expect(stderr).not.toMatch(/PASS __tests__(\/|\\)file2.test.js/); + + ({stdout, stderr} = runJest(DIR, ['-o', '--changedFilesWithAncestor'])); + expect(stderr).toMatch(/PASS __tests__(\/|\\)file2.test.js/); + expect(stderr).toMatch(/PASS __tests__(\/|\\)file3.test.js/); +}); + test('path on Windows is case-insensitive', () => { if (process.platform !== 'win32') { // This test is Windows specific, skip it on other platforms. diff --git a/packages/jest-changed-files/src/index.ts b/packages/jest-changed-files/src/index.ts index 0063a69ee7b4..df3cb0723c34 100644 --- a/packages/jest-changed-files/src/index.ts +++ b/packages/jest-changed-files/src/index.ts @@ -9,6 +9,7 @@ import pLimit = require('p-limit'); import git from './git'; import hg from './hg'; +import sl from './sl'; import type {ChangedFilesPromise, Options, Repos, SCMAdapter} from './types'; type RootPromise = ReturnType; @@ -25,6 +26,7 @@ const mutex = pLimit(5); const findGitRoot = (dir: string) => mutex(() => git.getRoot(dir)); const findHgRoot = (dir: string) => mutex(() => hg.getRoot(dir)); +const findSlRoot = (dir: string) => mutex(() => sl.getRoot(dir)); export const getChangedFilesForRoots = async ( roots: Array, @@ -42,8 +44,12 @@ export const getChangedFilesForRoots = async ( hg.findChangedFiles(repo, changedFilesOptions), ); + const slPromises = Array.from(repos.sl).map(repo => + sl.findChangedFiles(repo, changedFilesOptions), + ); + const changedFiles = ( - await Promise.all(gitPromises.concat(hgPromises)) + await Promise.all([...gitPromises, ...hgPromises, ...slPromises]) ).reduce((allFiles, changedFilesInTheRepo) => { for (const file of changedFilesInTheRepo) { allFiles.add(file); @@ -69,8 +75,11 @@ export const findRepos = async (roots: Array): Promise => { ), ); + const slRepos = await Promise.all(roots.map(findSlRoot)); + return { git: new Set(gitRepos.filter(notEmpty)), hg: new Set(hgRepos.filter(notEmpty)), + sl: new Set(slRepos.filter(notEmpty)), }; }; diff --git a/packages/jest-changed-files/src/sl.ts b/packages/jest-changed-files/src/sl.ts new file mode 100644 index 000000000000..03f7c76dff93 --- /dev/null +++ b/packages/jest-changed-files/src/sl.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as path from 'path'; +import {types} from 'util'; +import execa = require('execa'); +import type {SCMAdapter} from './types'; + +/** + * Disable any configuration settings that might change Sapling's default output. + * More info in `sl help environment`. _HG_PLAIN is intentional + */ +const env = {...process.env, HGPLAIN: '1'}; + +const adapter: SCMAdapter = { + findChangedFiles: async (cwd, options) => { + const includePaths = options.includePaths ?? []; + + const args = ['status', '-amnu']; + if (options.withAncestor === true) { + args.push('--rev', 'first(min(!public() & ::.)^+.^)'); + } else if ( + options.changedSince != null && + options.changedSince.length > 0 + ) { + args.push('--rev', `ancestor(., ${options.changedSince})`); + } else if (options.lastCommit === true) { + args.push('--change', '.'); + } + args.push(...includePaths); + + let result: execa.ExecaReturnValue; + + try { + result = await execa('sl', args, {cwd, env}); + } catch (e) { + if (types.isNativeError(e)) { + const err = e as execa.ExecaError; + // TODO: Should we keep the original `message`? + err.message = err.stderr; + } + + throw e; + } + + return result.stdout + .split('\n') + .filter(s => s !== '') + .map(changedPath => path.resolve(cwd, changedPath)); + }, + + getRoot: async cwd => { + try { + const result = await execa('sl', ['root'], {cwd, env}); + + return result.stdout; + } catch { + return null; + } + }, +}; + +export default adapter; diff --git a/packages/jest-changed-files/src/types.ts b/packages/jest-changed-files/src/types.ts index a841b75558c3..b6beef4ca758 100644 --- a/packages/jest-changed-files/src/types.ts +++ b/packages/jest-changed-files/src/types.ts @@ -13,7 +13,7 @@ export type Options = { }; type Paths = Set; -export type Repos = {git: Paths; hg: Paths}; +export type Repos = {git: Paths; hg: Paths; sl: Paths}; export type ChangedFiles = {repos: Repos; changedFiles: Paths}; export type ChangedFilesPromise = Promise;