diff --git a/packages/jest-config/src/__tests__/normalize.test.ts b/packages/jest-config/src/__tests__/normalize.test.ts index 319fdaa1520b..4a8cc08c3439 100644 --- a/packages/jest-config/src/__tests__/normalize.test.ts +++ b/packages/jest-config/src/__tests__/normalize.test.ts @@ -1630,60 +1630,6 @@ describe('testPathPattern', () => { expect(options.testPathPattern).toBe('a/b|c/d'); }); - - describe('posix', () => { - it('should not escape the pattern', async () => { - const argv = { - [opt.property]: ['a\\/b', 'a/b', 'a\\b', 'a\\\\b'], - } as Config.Argv; - const {options} = await normalize(initialOptions, argv); - - expect(options.testPathPattern).toBe('a\\/b|a/b|a\\b|a\\\\b'); - }); - }); - - describe('win32', () => { - beforeEach(() => { - jest.mock( - 'path', - () => jest.requireActual('path').win32, - ); - ( - require('jest-resolve') as typeof import('jest-resolve') - ).default.findNodeModule = findNodeModule; - }); - - afterEach(() => { - jest.resetModules(); - }); - - it('preserves any use of "\\"', async () => { - const argv = {[opt.property]: ['a\\b', 'c\\\\d']} as Config.Argv; - const {options} = await ( - require('../normalize') as typeof import('../normalize') - ).default(initialOptions, argv); - - expect(options.testPathPattern).toBe('a\\b|c\\\\d'); - }); - - it('replaces POSIX path separators', async () => { - const argv = {[opt.property]: ['a/b']} as Config.Argv; - const {options} = await ( - require('../normalize') as typeof import('../normalize') - ).default(initialOptions, argv); - - expect(options.testPathPattern).toBe('a\\\\b'); - }); - - it('replaces POSIX paths in multiple args', async () => { - const argv = {[opt.property]: ['a/b', 'c/d']} as Config.Argv; - const {options} = await ( - require('../normalize') as typeof import('../normalize') - ).default(initialOptions, argv); - - expect(options.testPathPattern).toBe('a\\\\b|c\\\\d'); - }); - }); }); } diff --git a/packages/jest-config/src/__tests__/validatePattern.test.ts b/packages/jest-config/src/__tests__/validatePattern.test.ts deleted file mode 100644 index e664f70bfa7b..000000000000 --- a/packages/jest-config/src/__tests__/validatePattern.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 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 validatePattern from '../validatePattern'; - -describe('validate pattern function', () => { - it('without passed args returns true', () => { - const isValid = validatePattern(); - - expect(isValid).toBeTruthy(); - }); - - it('returns true for empty pattern', () => { - const isValid = validatePattern(''); - - expect(isValid).toBeTruthy(); - }); - - it('returns true for valid pattern', () => { - const isValid = validatePattern('abc+'); - - expect(isValid).toBeTruthy(); - }); - - it('returns false for invalid pattern', () => { - const isValid = validatePattern('\\'); - - expect(isValid).toBeFalsy(); - }); -}); diff --git a/packages/jest-config/src/normalize.ts b/packages/jest-config/src/normalize.ts index 8f36190a03cb..983d77edf2e6 100644 --- a/packages/jest-config/src/normalize.ts +++ b/packages/jest-config/src/normalize.ts @@ -22,6 +22,7 @@ import Resolver, { resolveWatchPlugin, } from 'jest-resolve'; import { + TestPathPatterns, clearLine, replacePathSepForGlob, requireOrImportModule, @@ -49,7 +50,6 @@ import { replaceRootDirInPath, resolve, } from './utils'; -import validatePattern from './validatePattern'; const ERROR = `${BULLET}Validation Error`; const PRESET_EXTENSIONS = ['.json', '.js', '.cjs', '.mjs']; @@ -401,14 +401,11 @@ const buildTestPathPattern = (argv: Config.Argv): string => { patterns.push(...argv.testPathPattern); } - const replacePosixSep = (pattern: string) => - path.sep === '/' ? pattern : pattern.replace(/\//g, '\\\\'); - - const testPathPattern = patterns.map(replacePosixSep).join('|'); - if (validatePattern(testPathPattern)) { - return testPathPattern; + const testPathPatterns = new TestPathPatterns(patterns); + if (testPathPatterns.isValid()) { + return testPathPatterns.regexString; } else { - showTestPathPatternError(testPathPattern); + showTestPathPatternError(testPathPatterns.regexString); return ''; } }; diff --git a/packages/jest-config/src/validatePattern.ts b/packages/jest-config/src/validatePattern.ts deleted file mode 100644 index 90f3a2c3abe3..000000000000 --- a/packages/jest-config/src/validatePattern.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 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. - */ - -export default function validatePattern(pattern?: string): boolean { - if (pattern) { - try { - // eslint-disable-next-line no-new - new RegExp(pattern, 'i'); - } catch { - return false; - } - } - - return true; -} diff --git a/packages/jest-util/src/TestPathPatterns.ts b/packages/jest-util/src/TestPathPatterns.ts new file mode 100644 index 000000000000..142d86192a58 --- /dev/null +++ b/packages/jest-util/src/TestPathPatterns.ts @@ -0,0 +1,74 @@ +/** + * 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 {replacePathSepForRegex} from 'jest-regex-util'; + +export default class TestPathPatterns { + readonly patterns: Array; + + private _regexString: string | null = null; + + constructor(patterns: Array) { + this.patterns = patterns; + } + + get regexString(): string { + if (this._regexString !== null) { + return this._regexString; + } + const regexString = this.patterns + .map(replacePathSepForRegex) + .join('|'); + this._regexString = regexString; + return regexString; + } + + private get regex(): RegExp { + return new RegExp(this.regexString, 'i'); + } + + /** + * Return true if there are any patterns. + */ + isSet(): boolean { + return this.patterns.length > 0; + } + + /** + * Return true if the patterns form a valid regex. + */ + isValid(): boolean { + try { + // @ts-expect-error noUnusedLocals + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _ = this.regex; + return true; + } catch { + return false; + } + } + + /** + * Return true if the given ABSOLUTE path matches the patterns. + * + * Throws an error if the patterns form an invalid regex (see `isValid`). + */ + isMatch(path: string): boolean { + return this.regex.test(path); + } + + /** + * Return a human-friendly version of the pattern regex. + * + * Does no normalization or anything, just a naive joining of the regex, + * for simplicity. + */ + toPretty(): string { + const regex = this.patterns.map(p => p.replace(/\//g, '\\/')).join('|'); + return `/${regex}/i`; + } +} diff --git a/packages/jest-util/src/__tests__/TestPathPatterns.test.ts b/packages/jest-util/src/__tests__/TestPathPatterns.test.ts new file mode 100644 index 000000000000..b5be21943629 --- /dev/null +++ b/packages/jest-util/src/__tests__/TestPathPatterns.test.ts @@ -0,0 +1,134 @@ +/** + * 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 type * as path from 'path'; +import TestPathPatterns from '../TestPathPatterns'; + +const mockSep = jest.fn(); +jest.mock('path', () => { + return { + ...(jest.requireActual('path') as typeof path), + get sep() { + return mockSep() || '/'; + }, + }; +}); +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('TestPathPatterns', () => { + describe('isSet', () => { + it('returns false if no patterns specified', () => { + const testPathPatterns = new TestPathPatterns([]); + expect(testPathPatterns.isSet()).toBe(false); + }); + + it('returns true if patterns specified', () => { + const testPathPatterns = new TestPathPatterns(['a']); + expect(testPathPatterns.isSet()).toBe(true); + }); + }); + + describe('isValid', () => { + it('returns true for empty patterns', () => { + const testPathPatterns = new TestPathPatterns([]); + expect(testPathPatterns.isValid()).toBe(true); + }); + + it('returns true for valid patterns', () => { + const testPathPatterns = new TestPathPatterns(['abc+', 'z.*']); + expect(testPathPatterns.isValid()).toBe(true); + }); + + it('returns false for at least one invalid pattern', () => { + const testPathPatterns = new TestPathPatterns(['abc+', '(', 'z.*']); + expect(testPathPatterns.isValid()).toBe(false); + }); + }); + + describe('isMatch', () => { + it('returns true with no patterns', () => { + const testPathPatterns = new TestPathPatterns([]); + expect(testPathPatterns.isMatch('/a/b')).toBe(true); + }); + + it('returns true for same path', () => { + const testPathPatterns = new TestPathPatterns(['/a/b']); + expect(testPathPatterns.isMatch('/a/b')).toBe(true); + }); + + it('returns true for same path with case insensitive', () => { + const testPathPatternsUpper = new TestPathPatterns(['/A/B']); + expect(testPathPatternsUpper.isMatch('/a/b')).toBe(true); + expect(testPathPatternsUpper.isMatch('/A/B')).toBe(true); + + const testPathPatternsLower = new TestPathPatterns(['/a/b']); + expect(testPathPatternsLower.isMatch('/A/B')).toBe(true); + expect(testPathPatternsLower.isMatch('/a/b')).toBe(true); + }); + + it('returns true for contained path', () => { + const testPathPatterns = new TestPathPatterns(['b/c']); + expect(testPathPatterns.isMatch('/a/b/c/d')).toBe(true); + }); + + it('returns true for partial file match', () => { + const testPathPatterns = new TestPathPatterns(['aaa']); + expect(testPathPatterns.isMatch('/foo/..aaa..')).toBe(true); + expect(testPathPatterns.isMatch('/foo/..aaa')).toBe(true); + expect(testPathPatterns.isMatch('/foo/aaa..')).toBe(true); + }); + + it('returns true for path suffix', () => { + const testPathPatterns = new TestPathPatterns(['c/d']); + expect(testPathPatterns.isMatch('/a/b/c/d')).toBe(true); + }); + + it('returns true if regex matches', () => { + const testPathPatterns = new TestPathPatterns(['ab*c?']); + + expect(testPathPatterns.isMatch('/foo/a')).toBe(true); + expect(testPathPatterns.isMatch('/foo/ab')).toBe(true); + expect(testPathPatterns.isMatch('/foo/abb')).toBe(true); + expect(testPathPatterns.isMatch('/foo/ac')).toBe(true); + expect(testPathPatterns.isMatch('/foo/abc')).toBe(true); + expect(testPathPatterns.isMatch('/foo/abbc')).toBe(true); + + expect(testPathPatterns.isMatch('/foo/bc')).toBe(false); + }); + + it('returns true if match any paths', () => { + const testPathPatterns = new TestPathPatterns(['a/b', 'c/d']); + + expect(testPathPatterns.isMatch('/foo/a/b')).toBe(true); + expect(testPathPatterns.isMatch('/foo/c/d')).toBe(true); + + expect(testPathPatterns.isMatch('/foo/a')).toBe(false); + expect(testPathPatterns.isMatch('/foo/b/c')).toBe(false); + }); + + it('does not normalize Windows paths on POSIX', () => { + mockSep.mockReturnValue('/'); + const testPathPatterns = new TestPathPatterns(['a\\z', 'a\\\\z']); + expect(testPathPatterns.isMatch('/foo/a/z')).toBe(false); + }); + + it('normalizes paths for Windows', () => { + mockSep.mockReturnValue('\\'); + const testPathPatterns = new TestPathPatterns(['a/b']); + expect(testPathPatterns.isMatch('\\foo\\a\\b')).toBe(true); + }); + }); + + describe('toPretty', () => { + it('renders a human-readable string', () => { + const testPathPatterns = new TestPathPatterns(['a/b', 'c/d']); + expect(testPathPatterns.toPretty()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/jest-util/src/__tests__/__snapshots__/TestPathPatterns.test.ts.snap b/packages/jest-util/src/__tests__/__snapshots__/TestPathPatterns.test.ts.snap new file mode 100644 index 000000000000..e845164fb162 --- /dev/null +++ b/packages/jest-util/src/__tests__/__snapshots__/TestPathPatterns.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TestPathPatterns toPretty renders a human-readable string 1`] = `"/a\\/b|c\\/d/i"`; diff --git a/packages/jest-util/src/index.ts b/packages/jest-util/src/index.ts index 531acfe4aa3a..850d96c213f1 100644 --- a/packages/jest-util/src/index.ts +++ b/packages/jest-util/src/index.ts @@ -21,6 +21,7 @@ export {default as deepCyclicCopy} from './deepCyclicCopy'; export {default as convertDescriptorToString} from './convertDescriptorToString'; export {specialChars}; export {default as replacePathSepForGlob} from './replacePathSepForGlob'; +export {default as TestPathPatterns} from './TestPathPatterns'; export {default as testPathPatternToRegExp} from './testPathPatternToRegExp'; export {default as globsToMatcher} from './globsToMatcher'; export {preRunMessage};