diff --git a/CHANGELOG.md b/CHANGELOG.md index 87390ddbfaa8..88e314d92508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - `[@jest/fake-timers]` [**BREAKING**] Upgrade `@sinonjs/fake-timers` to v11 ([#14544](https://github.com/jestjs/jest/pull/14544)) - `[@jest/schemas]` Upgrade `@sinclair/typebox` to v0.31 ([#14072](https://github.com/jestjs/jest/pull/14072)) - `[jest-snapshot]` [**BREAKING**] Add support for [Error causes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) in snapshots ([#13965](https://github.com/facebook/jest/pull/13965)) +- `[jest-snapshot]` Support Prettier 3 ([#14566](https://github.com/facebook/jest/pull/14566)) - `[pretty-format]` [**BREAKING**] Do not render empty string children (`''`) in React plugin ([#14470](https://github.com/facebook/jest/pull/14470)) ### Fixes diff --git a/constraints.pro b/constraints.pro index 62235e22de6b..d40b057ca82b 100644 --- a/constraints.pro +++ b/constraints.pro @@ -22,7 +22,9 @@ gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange2, Depende % @types/node in the root need to stay on ~14.14.45 '@types/node', % upgrading the entire repository is a breaking change - 'glob' + 'glob', + % repository and snapshot + 'prettier' ]). % Enforces that a dependency doesn't appear in both `dependencies` and `devDependencies` diff --git a/docs/Configuration.md b/docs/Configuration.md index 813dd8c33c45..8dc961201f12 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1145,42 +1145,6 @@ Default: `'prettier'` Sets the path to the [`prettier`](https://prettier.io/) node module used to update inline snapshots. -
-Prettier version 3 is not supported! - -You can either pass `prettierPath: null` in your config to disable using prettier if you don't need it, or use v2 of Prettier solely for Jest. - -```json title="package.json" -{ - "devDependencies": { - "prettier-2": "npm:prettier@^2" - } -} -``` - -```js tab -/** @type {import('jest').Config} */ -const config = { - prettierPath: require.resolve('prettier-2'), -}; - -module.exports = config; -``` - -```ts tab -import type {Config} from 'jest'; - -const config: Config = { - prettierPath: require.resolve('prettier-2'), -}; - -export default config; -``` - -We hope to support Prettier v3 seamlessly out of the box in a future version of Jest. See [this](https://github.com/jestjs/jest/issues/14305) tracking issue. - -
- ### `projects` \[array<string | ProjectConfig>] Default: `undefined` diff --git a/e2e/__tests__/toMatchInlineSnapshotWithPretttier3.test.ts b/e2e/__tests__/toMatchInlineSnapshotWithPretttier3.test.ts index 2cc01798b60e..ef05f993528a 100644 --- a/e2e/__tests__/toMatchInlineSnapshotWithPretttier3.test.ts +++ b/e2e/__tests__/toMatchInlineSnapshotWithPretttier3.test.ts @@ -28,10 +28,10 @@ afterAll(() => { cleanup(JEST_CONFIG_PATH); }); -test('throws correct error', () => { +test('supports passing `null` as `prettierPath`', () => { writeFiles(DIR, { 'jest.config.js': ` - module.exports = {prettierPath: require.resolve('prettier')}; + module.exports = {prettierPath: null}; `, }); writeFiles(TESTS_DIR, { @@ -42,16 +42,14 @@ test('throws correct error', () => { `, }); const {stderr, exitCode} = runJest(DIR, ['--ci=false']); - expect(stderr).toContain( - 'Jest: Inline Snapshots are not supported when using Prettier 3.0.0 or above.', - ); - expect(exitCode).toBe(1); + expect(stderr).toContain('Snapshots: 1 written, 1 total'); + expect(exitCode).toBe(0); }); -test('supports passing `null` as `prettierPath`', () => { +test('supports passing `prettier-2` as `prettierPath`', () => { writeFiles(DIR, { 'jest.config.js': ` - module.exports = {prettierPath: null}; + module.exports = {prettierPath: require.resolve('prettier-2')}; `, }); writeFiles(TESTS_DIR, { @@ -66,10 +64,10 @@ test('supports passing `null` as `prettierPath`', () => { expect(exitCode).toBe(0); }); -test('supports passing `prettier-2` as `prettierPath`', () => { +test('supports passing `prettier` as `prettierPath`', () => { writeFiles(DIR, { 'jest.config.js': ` - module.exports = {prettierPath: require.resolve('prettier-2')}; + module.exports = {prettierPath: require.resolve('prettier')}; `, }); writeFiles(TESTS_DIR, { diff --git a/packages/jest-snapshot/package.json b/packages/jest-snapshot/package.json index 8987424c1284..26593541288a 100644 --- a/packages/jest-snapshot/package.json +++ b/packages/jest-snapshot/package.json @@ -36,7 +36,8 @@ "jest-util": "workspace:*", "natural-compare": "^1.4.0", "pretty-format": "workspace:*", - "semver": "^7.5.3" + "semver": "^7.5.3", + "synckit": "^0.8.5" }, "devDependencies": { "@babel/preset-flow": "^7.7.2", @@ -46,11 +47,12 @@ "@types/babel__core": "^7.1.14", "@types/graceful-fs": "^4.1.3", "@types/natural-compare": "^1.4.0", - "@types/prettier": "^2.1.5", + "@types/prettier-v2": "npm:@types/prettier@^2.1.5", "@types/semver": "^7.1.0", "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", - "prettier": "^2.1.1", + "prettier": "^3.0.3", + "prettier-v2": "npm:prettier@^2.1.5", "tsd-lite": "^0.8.0" }, "engines": { diff --git a/packages/jest-snapshot/src/InlineSnapshots.ts b/packages/jest-snapshot/src/InlineSnapshots.ts index ebdf2724fcce..8a1b86cae44b 100644 --- a/packages/jest-snapshot/src/InlineSnapshots.ts +++ b/packages/jest-snapshot/src/InlineSnapshots.ts @@ -7,65 +7,53 @@ import * as path from 'path'; import {types} from 'util'; -import type {ParseResult, PluginItem} from '@babel/core'; -import type { - Expression, - File, - Node, - Program, - TraversalAncestors, -} from '@babel/types'; import * as fs from 'graceful-fs'; import type { CustomParser as PrettierCustomParser, BuiltInParserName as PrettierParserName, -} from 'prettier'; +} from 'prettier-v2'; import semver = require('semver'); -import type {Frame} from 'jest-message-util'; -import {escapeBacktickString} from './utils'; - -// prettier-ignore -const generate = ( - // @ts-expect-error requireOutside Babel transform - requireOutside('@babel/generator') as typeof import('@babel/generator') -).default; -const { - isAwaitExpression, - templateElement, - templateLiteral, - traverse, - traverseFast, -} = - // @ts-expect-error requireOutside Babel transform - requireOutside('@babel/types') as typeof import('@babel/types'); -// @ts-expect-error requireOutside Babel transform -const {parseSync} = requireOutside( - '@babel/core', -) as typeof import('@babel/core'); - -type Prettier = typeof import('prettier'); +import {createSyncFn} from 'synckit'; +import type {InlineSnapshot} from './types'; +import { + groupSnapshotsByFile, + processInlineSnapshotsWithBabel, + processPrettierAst, +} from './utils'; + +type Prettier = typeof import('prettier-v2'); +type WorkerFn = ( + prettierPath: string, + filepath: string, + sourceFileWithSnapshots: string, + snapshotMatcherNames: Array, +) => string; -export type InlineSnapshot = { - snapshot: string; - frame: Frame; - node?: Expression; -}; +const cachedPrettier = new Map(); export function saveInlineSnapshots( snapshots: Array, rootDir: string, prettierPath: string | null, ): void { - let prettier: Prettier | null = null; - if (prettierPath) { + let prettier: Prettier | undefined = prettierPath + ? (cachedPrettier.get(`module|${prettierPath}`) as Prettier) + : undefined; + let workerFn: WorkerFn | undefined = prettierPath + ? (cachedPrettier.get(`worker|${prettierPath}`) as WorkerFn) + : undefined; + if (prettierPath && !prettier) { try { - // @ts-expect-error requireOutside Babel transform - prettier = requireOutside(prettierPath) as Prettier; + prettier = + // @ts-expect-error requireOutside + requireOutside(prettierPath) as Prettier; + cachedPrettier.set(`module|${prettierPath}`, prettier); if (semver.gte(prettier.version, '3.0.0')) { - throw new Error( - 'Jest: Inline Snapshots are not supported when using Prettier 3.0.0 or above.\nSee https://jestjs.io/docs/configuration/#prettierpath-string for alternatives.', + workerFn = createSyncFn( + require.resolve(/*webpackIgnore: true*/ './worker'), ); + cachedPrettier.set(`worker|${prettierPath}`, workerFn); } } catch (error) { if (!types.isNativeError(error)) { @@ -81,220 +69,36 @@ export function saveInlineSnapshots( const snapshotsByFile = groupSnapshotsByFile(snapshots); for (const sourceFilePath of Object.keys(snapshotsByFile)) { - saveSnapshotsForFile( - snapshotsByFile[sourceFilePath], - sourceFilePath, - rootDir, - prettier && semver.gte(prettier.version, '1.5.0') ? prettier : undefined, - ); - } -} - -const saveSnapshotsForFile = ( - snapshots: Array, - sourceFilePath: string, - rootDir: string, - prettier: Prettier | undefined, -) => { - const sourceFile = fs.readFileSync(sourceFilePath, 'utf8'); - - // TypeScript projects may not have a babel config; make sure they can be parsed anyway. - const presets = [require.resolve('babel-preset-current-node-syntax')]; - const plugins: Array = []; - if (/\.([cm]?ts|tsx)$/.test(sourceFilePath)) { - plugins.push([ - require.resolve('@babel/plugin-syntax-typescript'), - {isTSX: sourceFilePath.endsWith('x')}, - // unique name to make sure Babel does not complain about a possible duplicate plugin. - 'TypeScript syntax plugin added by Jest snapshot', - ]); - } - - // Record the matcher names seen during traversal and pass them down one - // by one to formatting parser. - const snapshotMatcherNames: Array = []; - - let ast: ParseResult | null = null; - - try { - ast = parseSync(sourceFile, { - filename: sourceFilePath, - plugins, - presets, - root: rootDir, - }); - } catch (error: any) { - // attempt to recover from missing jsx plugin - if (error.message.includes('@babel/plugin-syntax-jsx')) { - try { - const jsxSyntaxPlugin: PluginItem = [ - require.resolve('@babel/plugin-syntax-jsx'), - {}, - // unique name to make sure Babel does not complain about a possible duplicate plugin. - 'JSX syntax plugin added by Jest snapshot', - ]; - ast = parseSync(sourceFile, { - filename: sourceFilePath, - plugins: [...plugins, jsxSyntaxPlugin], - presets, - root: rootDir, - }); - } catch { - throw error; - } - } else { - throw error; - } - } - - if (!ast) { - throw new Error(`jest-snapshot: Failed to parse ${sourceFilePath}`); - } - traverseAst(snapshots, ast, snapshotMatcherNames); - - // substitute in the snapshots in reverse order, so slice calculations aren't thrown off. - const sourceFileWithSnapshots = snapshots.reduceRight( - (sourceSoFar, nextSnapshot) => { - const {node} = nextSnapshot; - if ( - !node || - typeof node.start !== 'number' || - typeof node.end !== 'number' - ) { - throw new Error('Jest: no snapshot insert location found'); - } + const {sourceFileWithSnapshots, snapshotMatcherNames, sourceFile} = + processInlineSnapshotsWithBabel( + snapshotsByFile[sourceFilePath], + sourceFilePath, + rootDir, + ); - // A hack to prevent unexpected line breaks in the generated code - node.loc!.end.line = node.loc!.start.line; + let newSourceFile = sourceFileWithSnapshots; - return ( - sourceSoFar.slice(0, node.start) + - generate(node, {retainLines: true}).code.trim() + - sourceSoFar.slice(node.end) + if (workerFn) { + newSourceFile = workerFn( + prettierPath!, + sourceFilePath, + sourceFileWithSnapshots, + snapshotMatcherNames, ); - }, - sourceFile, - ); - - const newSourceFile = prettier - ? runPrettier( + } else if (prettier && semver.gte(prettier.version, '1.5.0')) { + newSourceFile = runPrettier( prettier, sourceFilePath, sourceFileWithSnapshots, snapshotMatcherNames, - ) - : sourceFileWithSnapshots; - - if (newSourceFile !== sourceFile) { - fs.writeFileSync(sourceFilePath, newSourceFile); - } -}; - -const groupSnapshotsBy = - (createKey: (inlineSnapshot: InlineSnapshot) => string) => - (snapshots: Array) => - snapshots.reduce>>( - (object, inlineSnapshot) => { - const key = createKey(inlineSnapshot); - return {...object, [key]: (object[key] || []).concat(inlineSnapshot)}; - }, - {}, - ); - -const groupSnapshotsByFrame = groupSnapshotsBy(({frame: {line, column}}) => - typeof line === 'number' && typeof column === 'number' - ? `${line}:${column - 1}` - : '', -); -const groupSnapshotsByFile = groupSnapshotsBy(({frame: {file}}) => file); - -const indent = (snapshot: string, numIndents: number, indentation: string) => { - const lines = snapshot.split('\n'); - // Prevent re-indentation of inline snapshots. - if ( - lines.length >= 2 && - lines[1].startsWith(indentation.repeat(numIndents + 1)) - ) { - return snapshot; - } - - return lines - .map((line, index) => { - if (index === 0) { - // First line is either a 1-line snapshot or a blank line. - return line; - } else if (index === lines.length - 1) { - // The last line should be placed on the same level as the expect call. - return indentation.repeat(numIndents) + line; - } else { - // Do not indent empty lines. - if (line === '') { - return line; - } - - // Not last line, indent one level deeper than expect call. - return indentation.repeat(numIndents + 1) + line; - } - }) - .join('\n'); -}; - -const traverseAst = ( - snapshots: Array, - ast: File | Program, - snapshotMatcherNames: Array, -) => { - const groupedSnapshots = groupSnapshotsByFrame(snapshots); - const remainingSnapshots = new Set(snapshots.map(({snapshot}) => snapshot)); - - traverseFast(ast, (node: Node) => { - if (node.type !== 'CallExpression') return; - - const {arguments: args, callee} = node; - if ( - callee.type !== 'MemberExpression' || - callee.property.type !== 'Identifier' || - callee.property.loc == null - ) { - return; - } - const {line, column} = callee.property.loc.start; - const snapshotsForFrame = groupedSnapshots[`${line}:${column}`]; - if (!snapshotsForFrame) { - return; - } - if (snapshotsForFrame.length > 1) { - throw new Error( - 'Jest: Multiple inline snapshots for the same call are not supported.', ); } - const inlineSnapshot = snapshotsForFrame[0]; - inlineSnapshot.node = node; - snapshotMatcherNames.push(callee.property.name); - - const snapshotIndex = args.findIndex( - ({type}) => type === 'TemplateLiteral' || type === 'StringLiteral', - ); - - const {snapshot} = inlineSnapshot; - remainingSnapshots.delete(snapshot); - const replacementNode = templateLiteral( - [templateElement({raw: escapeBacktickString(snapshot)})], - [], - ); - - if (snapshotIndex > -1) { - args[snapshotIndex] = replacementNode; - } else { - args.push(replacementNode); + if (newSourceFile !== sourceFile) { + fs.writeFileSync(sourceFilePath, newSourceFile); } - }); - - if (remainingSnapshots.size > 0) { - throw new Error("Jest: Couldn't locate all inline snapshots."); } -}; +} const runPrettier = ( prettier: Prettier, @@ -313,7 +117,7 @@ const runPrettier = ( // For older versions of Prettier, fallback to a simple parser detection. // @ts-expect-error - `inferredParser` is `string` const inferredParser: PrettierParserName | null | undefined = - (config && typeof config.parser === 'string' && config.parser) || + (typeof config?.parser === 'string' && config.parser) || (prettier.getFileInfo ? prettier.getFileInfo.sync(sourceFilePath).inferredParser : simpleDetectParser(sourceFilePath)); @@ -353,61 +157,7 @@ const createFormattingParser = options.parser = inferredParser; const ast = parsers[inferredParser](text, options); - traverse(ast, (node: Node, ancestors: TraversalAncestors) => { - if (node.type !== 'CallExpression') return; - - const {arguments: args, callee} = node; - if ( - callee.type !== 'MemberExpression' || - callee.property.type !== 'Identifier' || - !snapshotMatcherNames.includes(callee.property.name) || - !callee.loc || - callee.computed - ) { - return; - } - - let snapshotIndex: number | undefined; - let snapshot: string | undefined; - for (let i = 0; i < args.length; i++) { - const node = args[i]; - if (node.type === 'TemplateLiteral') { - snapshotIndex = i; - snapshot = node.quasis[0].value.raw; - } - } - if (snapshot === undefined) { - return; - } - - const parent = ancestors[ancestors.length - 1].node; - const startColumn = - isAwaitExpression(parent) && parent.loc - ? parent.loc.start.column - : callee.loc.start.column; - - const useSpaces = !options.useTabs; - snapshot = indent( - snapshot, - Math.ceil( - useSpaces - ? startColumn / (options.tabWidth ?? 1) - : // Each tab is 2 characters. - startColumn / 2, - ), - useSpaces ? ' '.repeat(options.tabWidth ?? 1) : '\t', - ); - - const replacementNode = templateLiteral( - [ - templateElement({ - raw: snapshot, - }), - ], - [], - ); - args[snapshotIndex!] = replacementNode; - }); + processPrettierAst(ast, options, snapshotMatcherNames); return ast; }; diff --git a/packages/jest-snapshot/src/State.ts b/packages/jest-snapshot/src/State.ts index 16864e023dcd..e865abd89058 100644 --- a/packages/jest-snapshot/src/State.ts +++ b/packages/jest-snapshot/src/State.ts @@ -8,8 +8,8 @@ import * as fs from 'graceful-fs'; import type {Config} from '@jest/types'; import {getStackTraceLines, getTopFrame} from 'jest-message-util'; -import {InlineSnapshot, saveInlineSnapshots} from './InlineSnapshots'; -import type {SnapshotData, SnapshotFormat} from './types'; +import {saveInlineSnapshots} from './InlineSnapshots'; +import type {InlineSnapshot, SnapshotData, SnapshotFormat} from './types'; import { addExtraLineBreaks, getSnapshotData, diff --git a/packages/jest-snapshot/src/__tests__/InlineSnapshots.test.ts b/packages/jest-snapshot/src/__tests__/InlineSnapshots.test.ts index 67541646894d..47e2153cd919 100644 --- a/packages/jest-snapshot/src/__tests__/InlineSnapshots.test.ts +++ b/packages/jest-snapshot/src/__tests__/InlineSnapshots.test.ts @@ -8,23 +8,16 @@ import {tmpdir} from 'os'; import * as path from 'path'; import * as fs from 'graceful-fs'; -import prettier = require('prettier'); import type {Frame} from 'jest-message-util'; import {saveInlineSnapshots} from '../InlineSnapshots'; +const prettier = require('prettier') as typeof import('prettier-v2'); + jest.mock('prettier', () => { const realPrettier = - jest.requireActual('prettier'); + jest.requireActual('prettier-v2'); const mockPrettier = { - format: (text, opts) => - realPrettier.format(text, { - pluginSearchDirs: [ - (require('path') as typeof import('path')).dirname( - require.resolve('prettier'), - ), - ], - ...opts, - }), + format: realPrettier.format, getFileInfo: { sync: () => ({ignored: false, inferredParser: 'babel'}), } as unknown as typeof prettier.getFileInfo, diff --git a/packages/jest-snapshot/src/types.ts b/packages/jest-snapshot/src/types.ts index 541c191193a7..ed052f4491ce 100644 --- a/packages/jest-snapshot/src/types.ts +++ b/packages/jest-snapshot/src/types.ts @@ -5,7 +5,9 @@ * LICENSE file in the root directory of this source tree. */ +import type {Expression} from '@babel/types'; import type {MatcherContext} from 'expect'; +import type {Frame} from 'jest-message-util'; import type {PrettyFormatOptions} from 'pretty-format'; import type SnapshotState from './State'; @@ -74,3 +76,9 @@ export interface SnapshotMatchers, T> { } export type SnapshotFormat = Omit; + +export type InlineSnapshot = { + snapshot: string; + frame: Frame; + node?: Expression; +}; diff --git a/packages/jest-snapshot/src/utils.ts b/packages/jest-snapshot/src/utils.ts index 74c499c2a2a2..4d0ece527a74 100644 --- a/packages/jest-snapshot/src/utils.ts +++ b/packages/jest-snapshot/src/utils.ts @@ -6,6 +6,14 @@ */ import * as path from 'path'; +import type {ParseResult, PluginItem} from '@babel/core'; +import type { + File, + Node, + Program, + TemplateLiteral, + TraversalAncestors, +} from '@babel/types'; import chalk = require('chalk'); import * as fs from 'graceful-fs'; import naturalCompare = require('natural-compare'); @@ -15,7 +23,7 @@ import { format as prettyFormat, } from 'pretty-format'; import {getSerializers} from './plugins'; -import type {SnapshotData} from './types'; +import type {InlineSnapshot, SnapshotData} from './types'; export const SNAPSHOT_VERSION = '1'; const SNAPSHOT_VERSION_REGEXP = /^\/\/ Jest Snapshot v(.+),/; @@ -270,3 +278,290 @@ export const deepMerge = (target: any, source: any): any => { return target; }; + +const indent = ( + snapshot: string, + numIndents: number, + indentation: string, +): string => { + const lines = snapshot.split('\n'); + // Prevent re-indentation of inline snapshots. + if ( + lines.length >= 2 && + lines[1].startsWith(indentation.repeat(numIndents + 1)) + ) { + return snapshot; + } + + return lines + .map((line, index) => { + if (index === 0) { + // First line is either a 1-line snapshot or a blank line. + return line; + } else if (index === lines.length - 1) { + // The last line should be placed on the same level as the expect call. + return indentation.repeat(numIndents) + line; + } else { + // Do not indent empty lines. + if (line === '') { + return line; + } + + // Not last line, indent one level deeper than expect call. + return indentation.repeat(numIndents + 1) + line; + } + }) + .join('\n'); +}; + +const generate = // @ts-expect-error requireOutside Babel transform + (requireOutside('@babel/generator') as typeof import('@babel/generator')) + .default; + +// @ts-expect-error requireOutside Babel transform +const {parseSync, types} = requireOutside( + '@babel/core', +) as typeof import('@babel/core'); +const { + isAwaitExpression, + templateElement, + templateLiteral, + traverseFast, + traverse, +} = types; + +export const processInlineSnapshotsWithBabel = ( + snapshots: Array, + sourceFilePath: string, + rootDir: string, +): { + snapshotMatcherNames: Array; + sourceFile: string; + sourceFileWithSnapshots: string; +} => { + const sourceFile = fs.readFileSync(sourceFilePath, 'utf8'); + + // TypeScript projects may not have a babel config; make sure they can be parsed anyway. + const presets = [require.resolve('babel-preset-current-node-syntax')]; + const plugins: Array = []; + if (/\.([cm]?ts|tsx)$/.test(sourceFilePath)) { + plugins.push([ + require.resolve('@babel/plugin-syntax-typescript'), + {isTSX: sourceFilePath.endsWith('x')}, + // unique name to make sure Babel does not complain about a possible duplicate plugin. + 'TypeScript syntax plugin added by Jest snapshot', + ]); + } + + // Record the matcher names seen during traversal and pass them down one + // by one to formatting parser. + const snapshotMatcherNames: Array = []; + + let ast: ParseResult | null = null; + + try { + ast = parseSync(sourceFile, { + filename: sourceFilePath, + plugins, + presets, + root: rootDir, + }); + } catch (error: any) { + // attempt to recover from missing jsx plugin + if (error.message.includes('@babel/plugin-syntax-jsx')) { + try { + const jsxSyntaxPlugin: PluginItem = [ + require.resolve('@babel/plugin-syntax-jsx'), + {}, + // unique name to make sure Babel does not complain about a possible duplicate plugin. + 'JSX syntax plugin added by Jest snapshot', + ]; + ast = parseSync(sourceFile, { + filename: sourceFilePath, + plugins: [...plugins, jsxSyntaxPlugin], + presets, + root: rootDir, + }); + } catch { + throw error; + } + } else { + throw error; + } + } + + if (!ast) { + throw new Error(`jest-snapshot: Failed to parse ${sourceFilePath}`); + } + traverseAst(snapshots, ast, snapshotMatcherNames); + + return { + snapshotMatcherNames, + sourceFile, + // substitute in the snapshots in reverse order, so slice calculations aren't thrown off. + sourceFileWithSnapshots: snapshots.reduceRight( + (sourceSoFar, nextSnapshot) => { + const {node} = nextSnapshot; + if ( + !node || + typeof node.start !== 'number' || + typeof node.end !== 'number' + ) { + throw new Error('Jest: no snapshot insert location found'); + } + + // A hack to prevent unexpected line breaks in the generated code + node.loc!.end.line = node.loc!.start.line; + + return ( + sourceSoFar.slice(0, node.start) + + generate(node, {retainLines: true}).code.trim() + + sourceSoFar.slice(node.end) + ); + }, + sourceFile, + ), + }; +}; + +export const processPrettierAst = ( + ast: File, + options: Record | null, + snapshotMatcherNames: Array, + keepNode?: boolean, +): void => { + traverse(ast, (node: Node, ancestors: TraversalAncestors) => { + if (node.type !== 'CallExpression') return; + + const {arguments: args, callee} = node; + if ( + callee.type !== 'MemberExpression' || + callee.property.type !== 'Identifier' || + !snapshotMatcherNames.includes(callee.property.name) || + !callee.loc || + callee.computed + ) { + return; + } + + let snapshotIndex: number | undefined; + let snapshot: string | undefined; + for (let i = 0; i < args.length; i++) { + const node = args[i]; + if (node.type === 'TemplateLiteral') { + snapshotIndex = i; + snapshot = node.quasis[0].value.raw; + } + } + if (snapshot === undefined) { + return; + } + + const parent = ancestors[ancestors.length - 1].node; + const startColumn = + isAwaitExpression(parent) && parent.loc + ? parent.loc.start.column + : callee.loc.start.column; + + const useSpaces = !options?.useTabs; + snapshot = indent( + snapshot, + Math.ceil( + useSpaces + ? startColumn / (options?.tabWidth ?? 1) + : // Each tab is 2 characters. + startColumn / 2, + ), + useSpaces ? ' '.repeat(options?.tabWidth ?? 1) : '\t', + ); + + if (keepNode) { + (args[snapshotIndex!] as TemplateLiteral).quasis[0].value.raw = snapshot; + } else { + const replacementNode = templateLiteral( + [ + templateElement({ + raw: snapshot, + }), + ], + [], + ); + args[snapshotIndex!] = replacementNode; + } + }); +}; + +const groupSnapshotsBy = + (createKey: (inlineSnapshot: InlineSnapshot) => string) => + (snapshots: Array) => + snapshots.reduce>>( + (object, inlineSnapshot) => { + const key = createKey(inlineSnapshot); + return {...object, [key]: (object[key] || []).concat(inlineSnapshot)}; + }, + {}, + ); + +const groupSnapshotsByFrame = groupSnapshotsBy(({frame: {line, column}}) => + typeof line === 'number' && typeof column === 'number' + ? `${line}:${column - 1}` + : '', +); +export const groupSnapshotsByFile = groupSnapshotsBy(({frame: {file}}) => file); + +const traverseAst = ( + snapshots: Array, + ast: File | Program, + snapshotMatcherNames: Array, +) => { + const groupedSnapshots = groupSnapshotsByFrame(snapshots); + const remainingSnapshots = new Set(snapshots.map(({snapshot}) => snapshot)); + + traverseFast(ast, (node: Node) => { + if (node.type !== 'CallExpression') return; + + const {arguments: args, callee} = node; + if ( + callee.type !== 'MemberExpression' || + callee.property.type !== 'Identifier' || + callee.property.loc == null + ) { + return; + } + const {line, column} = callee.property.loc.start; + const snapshotsForFrame = groupedSnapshots[`${line}:${column}`]; + if (!snapshotsForFrame) { + return; + } + if (snapshotsForFrame.length > 1) { + throw new Error( + 'Jest: Multiple inline snapshots for the same call are not supported.', + ); + } + const inlineSnapshot = snapshotsForFrame[0]; + inlineSnapshot.node = node; + + snapshotMatcherNames.push(callee.property.name); + + const snapshotIndex = args.findIndex( + ({type}) => type === 'TemplateLiteral' || type === 'StringLiteral', + ); + + const {snapshot} = inlineSnapshot; + remainingSnapshots.delete(snapshot); + const replacementNode = templateLiteral( + [templateElement({raw: escapeBacktickString(snapshot)})], + [], + ); + + if (snapshotIndex > -1) { + args[snapshotIndex] = replacementNode; + } else { + args.push(replacementNode); + } + }); + + if (remainingSnapshots.size > 0) { + throw new Error("Jest: Couldn't locate all inline snapshots."); + } +}; diff --git a/packages/jest-snapshot/src/worker.ts b/packages/jest-snapshot/src/worker.ts new file mode 100644 index 000000000000..74dfab8fdcf2 --- /dev/null +++ b/packages/jest-snapshot/src/worker.ts @@ -0,0 +1,63 @@ +/** + * 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 {runAsWorker} from 'synckit'; +import {processPrettierAst} from './utils'; + +let prettier: typeof import('prettier'); + +runAsWorker( + async ( + prettierPath: string, + filepath: string, + sourceFileWithSnapshots: string, + snapshotMatcherNames: Array, + ) => { + // @ts-expect-error requireOutside + prettier ??= requireOutside(/*webpackIgnore: true*/ prettierPath); + + const config = await prettier.resolveConfig(filepath, { + editorconfig: true, + }); + + const inferredParser: string | null = + (typeof config?.parser === 'string' && config.parser) || + (await prettier.getFileInfo(filepath)).inferredParser; + + if (!inferredParser) { + throw new Error(`Could not infer Prettier parser for file ${filepath}`); + } + + sourceFileWithSnapshots = await prettier.format(sourceFileWithSnapshots, { + ...config, + filepath, + parser: inferredParser, + }); + + // @ts-expect-error private API + const {ast} = await prettier.__debug.parse(sourceFileWithSnapshots, { + ...config, + filepath, + originalText: sourceFileWithSnapshots, + parser: inferredParser, + }); + processPrettierAst(ast, config, snapshotMatcherNames, true); + // Snapshots have now been inserted. Run prettier to make sure that the code is + // formatted, except snapshot indentation. Snapshots cannot be formatted until + // after the initial format because we don't know where the call expression + // will be placed (specifically its indentation), so we have to do two + // prettier.format calls back-to-back. + return /** @ts-expect-error private API */ ( + await prettier.__debug.formatAST(ast, { + ...config, + filepath, + originalText: sourceFileWithSnapshots, + parser: inferredParser, + }) + ).formatted; + }, +); diff --git a/scripts/buildUtils.mjs b/scripts/buildUtils.mjs index 50f78804e7cd..e4cf23490d19 100644 --- a/scripts/buildUtils.mjs +++ b/scripts/buildUtils.mjs @@ -217,6 +217,8 @@ export function createWebpackConfigs() { } : pkg.name === 'jest-repl' ? {repl: path.resolve(packageDir, './src/cli/repl.ts')} + : pkg.name === 'jest-snapshot' + ? {worker: path.resolve(packageDir, './src/worker.ts')} : {}; const extraEntryPoints = diff --git a/yarn.lock b/yarn.lock index 559ac8d0c59e..fbcd0d08a277 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3888,6 +3888,20 @@ __metadata: languageName: node linkType: hard +"@pkgr/utils@npm:^2.3.1": + version: 2.4.2 + resolution: "@pkgr/utils@npm:2.4.2" + dependencies: + cross-spawn: ^7.0.3 + fast-glob: ^3.3.0 + is-glob: ^4.0.3 + open: ^9.1.0 + picocolors: ^1.0.0 + tslib: ^2.6.0 + checksum: 24e04c121269317d259614cd32beea3af38277151c4002df5883c4be920b8e3490bb897748e844f9d46bf68230f86dabd4e8f093773130e7e60529a769a132fc + languageName: node + linkType: hard + "@pnpm/config.env-replace@npm:^1.1.0": version: 1.1.0 resolution: "@pnpm/config.env-replace@npm:1.1.0" @@ -5155,7 +5169,7 @@ __metadata: languageName: node linkType: hard -"@types/prettier@npm:^2.1.5": +"@types/prettier-v2@npm:@types/prettier@^2.1.5, @types/prettier@npm:^2.1.5": version: 2.7.3 resolution: "@types/prettier@npm:2.7.3" checksum: 705384209cea6d1433ff6c187c80dcc0b95d99d5c5ce21a46a9a58060c527973506822e428789d842761e0280d25e3359300f017fbe77b9755bc772ab3dc2f83 @@ -6738,6 +6752,13 @@ __metadata: languageName: node linkType: hard +"big-integer@npm:^1.6.44": + version: 1.6.51 + resolution: "big-integer@npm:1.6.51" + checksum: 3d444173d1b2e20747e2c175568bedeebd8315b0637ea95d75fd27830d3b8e8ba36c6af40374f36bdaea7b5de376dcada1b07587cb2a79a928fccdb6e6e3c518 + languageName: node + linkType: hard + "big.js@npm:^5.2.2": version: 5.2.2 resolution: "big.js@npm:5.2.2" @@ -6846,6 +6867,15 @@ __metadata: languageName: node linkType: hard +"bplist-parser@npm:^0.2.0": + version: 0.2.0 + resolution: "bplist-parser@npm:0.2.0" + dependencies: + big-integer: ^1.6.44 + checksum: d5339dd16afc51de6c88f88f58a45b72ed6a06aa31f5557d09877575f220b7c1d3fbe375da0b62e6a10d4b8ed80523567e351f24014f5bc886ad523758142cdd + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -6947,6 +6977,15 @@ __metadata: languageName: node linkType: hard +"bundle-name@npm:^3.0.0": + version: 3.0.0 + resolution: "bundle-name@npm:3.0.0" + dependencies: + run-applescript: ^5.0.0 + checksum: edf2b1fbe6096ed32e7566947ace2ea937ee427391744d7510a2880c4b9a5b3543d3f6c551236a29e5c87d3195f8e2912516290e638c15bcbede7b37cc375615 + languageName: node + linkType: hard + "byte-size@npm:^7.0.1": version: 7.0.1 resolution: "byte-size@npm:7.0.1" @@ -8490,6 +8529,28 @@ __metadata: languageName: node linkType: hard +"default-browser-id@npm:^3.0.0": + version: 3.0.0 + resolution: "default-browser-id@npm:3.0.0" + dependencies: + bplist-parser: ^0.2.0 + untildify: ^4.0.0 + checksum: 279c7ad492542e5556336b6c254a4eaf31b2c63a5433265655ae6e47301197b6cfb15c595a6fdc6463b2ff8e1a1a1ed3cba56038a60e1527ba4ab1628c6b9941 + languageName: node + linkType: hard + +"default-browser@npm:^4.0.0": + version: 4.0.0 + resolution: "default-browser@npm:4.0.0" + dependencies: + bundle-name: ^3.0.0 + default-browser-id: ^3.0.0 + execa: ^7.1.1 + titleize: ^3.0.0 + checksum: 40c5af984799042b140300be5639c9742599bda76dc9eba5ac9ad5943c83dd36cebc4471eafcfddf8e0ec817166d5ba89d56f08e66a126c7c7908a179cead1a7 + languageName: node + linkType: hard + "default-gateway@npm:^6.0.3": version: 6.0.3 resolution: "default-gateway@npm:6.0.3" @@ -8522,6 +8583,13 @@ __metadata: languageName: node linkType: hard +"define-lazy-prop@npm:^3.0.0": + version: 3.0.0 + resolution: "define-lazy-prop@npm:3.0.0" + checksum: 54884f94caac0791bf6395a3ec530ce901cf71c47b0196b8754f3fd17edb6c0e80149c1214429d851873bb0d689dbe08dcedbb2306dc45c8534a5934723851b6 + languageName: node + linkType: hard + "define-properties@npm:^1.1.3, define-properties@npm:^1.1.4, define-properties@npm:^1.2.0": version: 1.2.0 resolution: "define-properties@npm:1.2.0" @@ -9829,6 +9897,23 @@ __metadata: languageName: node linkType: hard +"execa@npm:^7.1.1": + version: 7.2.0 + resolution: "execa@npm:7.2.0" + dependencies: + cross-spawn: ^7.0.3 + get-stream: ^6.0.1 + human-signals: ^4.3.0 + is-stream: ^3.0.0 + merge-stream: ^2.0.0 + npm-run-path: ^5.1.0 + onetime: ^6.0.0 + signal-exit: ^3.0.7 + strip-final-newline: ^3.0.0 + checksum: 14fd17ba0ca8c87b277584d93b1d9fc24f2a65e5152b31d5eb159a3b814854283eaae5f51efa9525e304447e2f757c691877f7adff8fde5746aae67eb1edd1cc + languageName: node + linkType: hard + "exit@npm:^0.1.2": version: 0.1.2 resolution: "exit@npm:0.1.2" @@ -11472,6 +11557,13 @@ __metadata: languageName: node linkType: hard +"human-signals@npm:^4.3.0": + version: 4.3.1 + resolution: "human-signals@npm:4.3.1" + checksum: 6f12958df3f21b6fdaf02d90896c271df00636a31e2bbea05bddf817a35c66b38a6fdac5863e2df85bd52f34958997f1f50350ff97249e1dff8452865d5235d1 + languageName: node + linkType: hard + "humanize-ms@npm:^1.2.1": version: 1.2.1 resolution: "humanize-ms@npm:1.2.1" @@ -11929,6 +12021,15 @@ __metadata: languageName: node linkType: hard +"is-docker@npm:^3.0.0": + version: 3.0.0 + resolution: "is-docker@npm:3.0.0" + bin: + is-docker: cli.js + checksum: b698118f04feb7eaf3338922bd79cba064ea54a1c3db6ec8c0c8d8ee7613e7e5854d802d3ef646812a8a3ace81182a085dfa0a71cc68b06f3fa794b9783b3c90 + languageName: node + linkType: hard + "is-extendable@npm:^0.1.0": version: 0.1.1 resolution: "is-extendable@npm:0.1.1" @@ -11987,6 +12088,17 @@ __metadata: languageName: node linkType: hard +"is-inside-container@npm:^1.0.0": + version: 1.0.0 + resolution: "is-inside-container@npm:1.0.0" + dependencies: + is-docker: ^3.0.0 + bin: + is-inside-container: cli.js + checksum: c50b75a2ab66ab3e8b92b3bc534e1ea72ca25766832c0623ac22d134116a98bcf012197d1caabe1d1c4bd5f84363d4aa5c36bb4b585fbcaf57be172cd10a1a03 + languageName: node + linkType: hard + "is-installed-globally@npm:^0.4.0": version: 0.4.0 resolution: "is-installed-globally@npm:0.4.0" @@ -12199,6 +12311,13 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "is-stream@npm:3.0.0" + checksum: 172093fe99119ffd07611ab6d1bcccfe8bc4aa80d864b15f43e63e54b7abc71e779acd69afdb854c4e2a67fdc16ae710e370eda40088d1cfc956a50ed82d8f16 + languageName: node + linkType: hard + "is-string@npm:^1.0.5, is-string@npm:^1.0.7": version: 1.0.7 resolution: "is-string@npm:1.0.7" @@ -12959,7 +13078,7 @@ __metadata: "@types/babel__core": ^7.1.14 "@types/graceful-fs": ^4.1.3 "@types/natural-compare": ^1.4.0 - "@types/prettier": ^2.1.5 + "@types/prettier-v2": "npm:@types/prettier@^2.1.5" "@types/semver": ^7.1.0 ansi-regex: ^5.0.1 ansi-styles: ^5.0.0 @@ -12973,9 +13092,11 @@ __metadata: jest-message-util: "workspace:*" jest-util: "workspace:*" natural-compare: ^1.4.0 - prettier: ^2.1.1 + prettier: ^3.0.3 + prettier-v2: "npm:prettier@^2.1.5" pretty-format: "workspace:*" semver: ^7.5.3 + synckit: ^0.8.5 tsd-lite: ^0.8.0 languageName: unknown linkType: soft @@ -15190,6 +15311,13 @@ __metadata: languageName: node linkType: hard +"mimic-fn@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-fn@npm:4.0.0" + checksum: 995dcece15ee29aa16e188de6633d43a3db4611bcf93620e7e62109ec41c79c0f34277165b8ce5e361205049766e371851264c21ac64ca35499acb5421c2ba56 + languageName: node + linkType: hard + "mimic-response@npm:^3.1.0": version: 3.1.0 resolution: "mimic-response@npm:3.1.0" @@ -15815,6 +15943,15 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^5.1.0": + version: 5.1.0 + resolution: "npm-run-path@npm:5.1.0" + dependencies: + path-key: ^4.0.0 + checksum: dc184eb5ec239d6a2b990b43236845332ef12f4e0beaa9701de724aa797fe40b6bbd0157fb7639d24d3ab13f5d5cf22d223a19c6300846b8126f335f788bee66 + languageName: node + linkType: hard + "npm-to-yarn@npm:^2.0.0": version: 2.0.0 resolution: "npm-to-yarn@npm:2.0.0" @@ -16010,6 +16147,15 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^6.0.0": + version: 6.0.0 + resolution: "onetime@npm:6.0.0" + dependencies: + mimic-fn: ^4.0.0 + checksum: 0846ce78e440841335d4e9182ef69d5762e9f38aa7499b19f42ea1c4cd40f0b4446094c455c713f9adac3f4ae86f613bb5e30c99e52652764d06a89f709b3788 + languageName: node + linkType: hard + "open@npm:^6.2.0": version: 6.4.0 resolution: "open@npm:6.4.0" @@ -16030,6 +16176,18 @@ __metadata: languageName: node linkType: hard +"open@npm:^9.1.0": + version: 9.1.0 + resolution: "open@npm:9.1.0" + dependencies: + default-browser: ^4.0.0 + define-lazy-prop: ^3.0.0 + is-inside-container: ^1.0.0 + is-wsl: ^2.2.0 + checksum: 3993c0f61d51fed8ac290e99c9c3cf45d3b6cfb3e2aa2b74cafd312c3486c22fd81df16ac8f3ab91dd8a4e3e729a16fc2480cfc406c4833416cf908acf1ae7c9 + languageName: node + linkType: hard + "opener@npm:^1.5.2": version: 1.5.2 resolution: "opener@npm:1.5.2" @@ -16470,6 +16628,13 @@ __metadata: languageName: node linkType: hard +"path-key@npm:^4.0.0": + version: 4.0.0 + resolution: "path-key@npm:4.0.0" + checksum: 8e6c314ae6d16b83e93032c61020129f6f4484590a777eed709c4a01b50e498822b00f76ceaf94bc64dbd90b327df56ceadce27da3d83393790f1219e07721d7 + languageName: node + linkType: hard + "path-parse@npm:^1.0.6, path-parse@npm:^1.0.7": version: 1.0.7 resolution: "path-parse@npm:1.0.7" @@ -17123,7 +17288,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^2.1.1, prettier@npm:^2.8.3": +"prettier-v2@npm:prettier@^2.1.5, prettier@npm:^2.1.1, prettier@npm:^2.8.3": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: @@ -17132,6 +17297,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3.0.3": + version: 3.0.3 + resolution: "prettier@npm:3.0.3" + bin: + prettier: bin/prettier.cjs + checksum: e10b9af02b281f6c617362ebd2571b1d7fc9fb8a3bd17e371754428cda992e5e8d8b7a046e8f7d3e2da1dcd21aa001e2e3c797402ebb6111b5cd19609dd228e0 + languageName: node + linkType: hard + "pretty-bytes@npm:^5.3.0": version: 5.6.0 resolution: "pretty-bytes@npm:5.6.0" @@ -18490,6 +18664,15 @@ __metadata: languageName: node linkType: hard +"run-applescript@npm:^5.0.0": + version: 5.0.0 + resolution: "run-applescript@npm:5.0.0" + dependencies: + execa: ^5.0.0 + checksum: d00c2dbfa5b2d774de7451194b8b125f40f65fc183de7d9dcae97f57f59433586d3c39b9001e111c38bfa24c3436c99df1bb4066a2a0c90d39a8c4cd6889af77 + languageName: node + linkType: hard + "run-async@npm:^2.4.0": version: 2.4.1 resolution: "run-async@npm:2.4.1" @@ -19477,6 +19660,13 @@ __metadata: languageName: node linkType: hard +"strip-final-newline@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-final-newline@npm:3.0.0" + checksum: 23ee263adfa2070cd0f23d1ac14e2ed2f000c9b44229aec9c799f1367ec001478469560abefd00c5c99ee6f0b31c137d53ec6029c53e9f32a93804e18c201050 + languageName: node + linkType: hard + "strip-indent@npm:^3.0.0": version: 3.0.0 resolution: "strip-indent@npm:3.0.0" @@ -19613,6 +19803,16 @@ __metadata: languageName: node linkType: hard +"synckit@npm:^0.8.5": + version: 0.8.5 + resolution: "synckit@npm:0.8.5" + dependencies: + "@pkgr/utils": ^2.3.1 + tslib: ^2.5.0 + checksum: 8a9560e5d8f3d94dc3cf5f7b9c83490ffa30d320093560a37b88f59483040771fd1750e76b9939abfbb1b5a23fd6dfbae77f6b338abffe7cae7329cd9b9bb86b + languageName: node + linkType: hard + "tapable@npm:^1.0.0": version: 1.1.3 resolution: "tapable@npm:1.1.3" @@ -19819,6 +20019,13 @@ __metadata: languageName: node linkType: hard +"titleize@npm:^3.0.0": + version: 3.0.0 + resolution: "titleize@npm:3.0.0" + checksum: 71fbbeabbfb36ccd840559f67f21e356e1d03da2915b32d2ae1a60ddcc13a124be2739f696d2feb884983441d159a18649e8d956648d591bdad35c430a6b6d28 + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -20043,10 +20250,10 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.0, tslib@npm:^2.6.0": - version: 2.6.0 - resolution: "tslib@npm:2.6.0" - checksum: c01066038f950016a18106ddeca4649b4d76caa76ec5a31e2a26e10586a59fceb4ee45e96719bf6c715648e7c14085a81fee5c62f7e9ebee68e77a5396e5538f +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.0, tslib@npm:^2.5.0, tslib@npm:^2.6.0": + version: 2.6.2 + resolution: "tslib@npm:2.6.2" + checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad languageName: node linkType: hard @@ -20532,6 +20739,13 @@ __metadata: languageName: node linkType: hard +"untildify@npm:^4.0.0": + version: 4.0.0 + resolution: "untildify@npm:4.0.0" + checksum: 39ced9c418a74f73f0a56e1ba4634b4d959422dff61f4c72a8e39f60b99380c1b45ed776fbaa0a4101b157e4310d873ad7d114e8534ca02609b4916bb4187fb9 + languageName: node + linkType: hard + "upath@npm:^1.2.0": version: 1.2.0 resolution: "upath@npm:1.2.0"