InlineSnapshots.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. 'use strict';
  2. Object.defineProperty(exports, '__esModule', {
  3. value: true
  4. });
  5. exports.saveInlineSnapshots = saveInlineSnapshots;
  6. var path = _interopRequireWildcard(require('path'));
  7. var _util = require('util');
  8. var fs = _interopRequireWildcard(require('graceful-fs'));
  9. var _semver = _interopRequireDefault(require('semver'));
  10. var _utils = require('./utils');
  11. function _interopRequireDefault(obj) {
  12. return obj && obj.__esModule ? obj : {default: obj};
  13. }
  14. function _getRequireWildcardCache(nodeInterop) {
  15. if (typeof WeakMap !== 'function') return null;
  16. var cacheBabelInterop = new WeakMap();
  17. var cacheNodeInterop = new WeakMap();
  18. return (_getRequireWildcardCache = function (nodeInterop) {
  19. return nodeInterop ? cacheNodeInterop : cacheBabelInterop;
  20. })(nodeInterop);
  21. }
  22. function _interopRequireWildcard(obj, nodeInterop) {
  23. if (!nodeInterop && obj && obj.__esModule) {
  24. return obj;
  25. }
  26. if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
  27. return {default: obj};
  28. }
  29. var cache = _getRequireWildcardCache(nodeInterop);
  30. if (cache && cache.has(obj)) {
  31. return cache.get(obj);
  32. }
  33. var newObj = {};
  34. var hasPropertyDescriptor =
  35. Object.defineProperty && Object.getOwnPropertyDescriptor;
  36. for (var key in obj) {
  37. if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) {
  38. var desc = hasPropertyDescriptor
  39. ? Object.getOwnPropertyDescriptor(obj, key)
  40. : null;
  41. if (desc && (desc.get || desc.set)) {
  42. Object.defineProperty(newObj, key, desc);
  43. } else {
  44. newObj[key] = obj[key];
  45. }
  46. }
  47. }
  48. newObj.default = obj;
  49. if (cache) {
  50. cache.set(obj, newObj);
  51. }
  52. return newObj;
  53. }
  54. var Symbol = globalThis['jest-symbol-do-not-touch'] || globalThis.Symbol;
  55. var Symbol = globalThis['jest-symbol-do-not-touch'] || globalThis.Symbol;
  56. var jestWriteFile =
  57. globalThis[Symbol.for('jest-native-write-file')] || fs.writeFileSync;
  58. var Symbol = globalThis['jest-symbol-do-not-touch'] || globalThis.Symbol;
  59. var jestReadFile =
  60. globalThis[Symbol.for('jest-native-read-file')] || fs.readFileSync;
  61. /**
  62. * Copyright (c) Meta Platforms, Inc. and affiliates.
  63. *
  64. * This source code is licensed under the MIT license found in the
  65. * LICENSE file in the root directory of this source tree.
  66. */
  67. // prettier-ignore
  68. const generate = // @ts-expect-error requireOutside Babel transform
  69. require(require.resolve('@babel/generator', {
  70. [(globalThis['jest-symbol-do-not-touch'] || globalThis.Symbol).for('jest-resolve-outside-vm-option')]: true
  71. })).default;
  72. const {
  73. isAwaitExpression,
  74. templateElement,
  75. templateLiteral,
  76. traverse,
  77. traverseFast
  78. } = require(require.resolve('@babel/types', { // @ts-expect-error requireOutside Babel transform
  79. [(globalThis['jest-symbol-do-not-touch'] || globalThis.Symbol).for(
  80. 'jest-resolve-outside-vm-option'
  81. )]: true
  82. }));
  83. // @ts-expect-error requireOutside Babel transform
  84. const {parseSync} = require(require.resolve('@babel/core', {
  85. [(globalThis['jest-symbol-do-not-touch'] || globalThis.Symbol).for(
  86. 'jest-resolve-outside-vm-option'
  87. )]: true
  88. }));
  89. function saveInlineSnapshots(snapshots, rootDir, prettierPath) {
  90. let prettier = null;
  91. if (prettierPath) {
  92. try {
  93. // @ts-expect-error requireOutside Babel transform
  94. prettier = require(require.resolve(prettierPath, {
  95. [(globalThis['jest-symbol-do-not-touch'] || globalThis.Symbol).for(
  96. 'jest-resolve-outside-vm-option'
  97. )]: true
  98. }));
  99. if (_semver.default.gte(prettier.version, '3.0.0')) {
  100. throw new Error(
  101. 'Jest: Inline Snapshots are not supported when using Prettier 3.0.0 or above.\nSee https://jestjs.io/docs/configuration/#prettierpath-string for alternatives.'
  102. );
  103. }
  104. } catch (error) {
  105. if (!_util.types.isNativeError(error)) {
  106. throw error;
  107. }
  108. if (error.code !== 'MODULE_NOT_FOUND') {
  109. throw error;
  110. }
  111. }
  112. }
  113. const snapshotsByFile = groupSnapshotsByFile(snapshots);
  114. for (const sourceFilePath of Object.keys(snapshotsByFile)) {
  115. saveSnapshotsForFile(
  116. snapshotsByFile[sourceFilePath],
  117. sourceFilePath,
  118. rootDir,
  119. prettier && _semver.default.gte(prettier.version, '1.5.0')
  120. ? prettier
  121. : undefined
  122. );
  123. }
  124. }
  125. const saveSnapshotsForFile = (snapshots, sourceFilePath, rootDir, prettier) => {
  126. const sourceFile = jestReadFile(sourceFilePath, 'utf8');
  127. // TypeScript projects may not have a babel config; make sure they can be parsed anyway.
  128. const presets = [require.resolve('babel-preset-current-node-syntax')];
  129. const plugins = [];
  130. if (/\.([cm]?ts|tsx)$/.test(sourceFilePath)) {
  131. plugins.push([
  132. require.resolve('@babel/plugin-syntax-typescript'),
  133. {
  134. isTSX: sourceFilePath.endsWith('x')
  135. },
  136. // unique name to make sure Babel does not complain about a possible duplicate plugin.
  137. 'TypeScript syntax plugin added by Jest snapshot'
  138. ]);
  139. }
  140. // Record the matcher names seen during traversal and pass them down one
  141. // by one to formatting parser.
  142. const snapshotMatcherNames = [];
  143. let ast = null;
  144. try {
  145. ast = parseSync(sourceFile, {
  146. filename: sourceFilePath,
  147. plugins,
  148. presets,
  149. root: rootDir
  150. });
  151. } catch (error) {
  152. // attempt to recover from missing jsx plugin
  153. if (error.message.includes('@babel/plugin-syntax-jsx')) {
  154. try {
  155. const jsxSyntaxPlugin = [
  156. require.resolve('@babel/plugin-syntax-jsx'),
  157. {},
  158. // unique name to make sure Babel does not complain about a possible duplicate plugin.
  159. 'JSX syntax plugin added by Jest snapshot'
  160. ];
  161. ast = parseSync(sourceFile, {
  162. filename: sourceFilePath,
  163. plugins: [...plugins, jsxSyntaxPlugin],
  164. presets,
  165. root: rootDir
  166. });
  167. } catch {
  168. throw error;
  169. }
  170. } else {
  171. throw error;
  172. }
  173. }
  174. if (!ast) {
  175. throw new Error(`jest-snapshot: Failed to parse ${sourceFilePath}`);
  176. }
  177. traverseAst(snapshots, ast, snapshotMatcherNames);
  178. // substitute in the snapshots in reverse order, so slice calculations aren't thrown off.
  179. const sourceFileWithSnapshots = snapshots.reduceRight(
  180. (sourceSoFar, nextSnapshot) => {
  181. const {node} = nextSnapshot;
  182. if (
  183. !node ||
  184. typeof node.start !== 'number' ||
  185. typeof node.end !== 'number'
  186. ) {
  187. throw new Error('Jest: no snapshot insert location found');
  188. }
  189. // A hack to prevent unexpected line breaks in the generated code
  190. node.loc.end.line = node.loc.start.line;
  191. return (
  192. sourceSoFar.slice(0, node.start) +
  193. generate(node, {
  194. retainLines: true
  195. }).code.trim() +
  196. sourceSoFar.slice(node.end)
  197. );
  198. },
  199. sourceFile
  200. );
  201. const newSourceFile = prettier
  202. ? runPrettier(
  203. prettier,
  204. sourceFilePath,
  205. sourceFileWithSnapshots,
  206. snapshotMatcherNames
  207. )
  208. : sourceFileWithSnapshots;
  209. if (newSourceFile !== sourceFile) {
  210. jestWriteFile(sourceFilePath, newSourceFile);
  211. }
  212. };
  213. const groupSnapshotsBy = createKey => snapshots =>
  214. snapshots.reduce((object, inlineSnapshot) => {
  215. const key = createKey(inlineSnapshot);
  216. return {
  217. ...object,
  218. [key]: (object[key] || []).concat(inlineSnapshot)
  219. };
  220. }, {});
  221. const groupSnapshotsByFrame = groupSnapshotsBy(({frame: {line, column}}) =>
  222. typeof line === 'number' && typeof column === 'number'
  223. ? `${line}:${column - 1}`
  224. : ''
  225. );
  226. const groupSnapshotsByFile = groupSnapshotsBy(({frame: {file}}) => file);
  227. const indent = (snapshot, numIndents, indentation) => {
  228. const lines = snapshot.split('\n');
  229. // Prevent re-indentation of inline snapshots.
  230. if (
  231. lines.length >= 2 &&
  232. lines[1].startsWith(indentation.repeat(numIndents + 1))
  233. ) {
  234. return snapshot;
  235. }
  236. return lines
  237. .map((line, index) => {
  238. if (index === 0) {
  239. // First line is either a 1-line snapshot or a blank line.
  240. return line;
  241. } else if (index !== lines.length - 1) {
  242. // Do not indent empty lines.
  243. if (line === '') {
  244. return line;
  245. }
  246. // Not last line, indent one level deeper than expect call.
  247. return indentation.repeat(numIndents + 1) + line;
  248. } else {
  249. // The last line should be placed on the same level as the expect call.
  250. return indentation.repeat(numIndents) + line;
  251. }
  252. })
  253. .join('\n');
  254. };
  255. const traverseAst = (snapshots, ast, snapshotMatcherNames) => {
  256. const groupedSnapshots = groupSnapshotsByFrame(snapshots);
  257. const remainingSnapshots = new Set(snapshots.map(({snapshot}) => snapshot));
  258. traverseFast(ast, node => {
  259. if (node.type !== 'CallExpression') return;
  260. const {arguments: args, callee} = node;
  261. if (
  262. callee.type !== 'MemberExpression' ||
  263. callee.property.type !== 'Identifier' ||
  264. callee.property.loc == null
  265. ) {
  266. return;
  267. }
  268. const {line, column} = callee.property.loc.start;
  269. const snapshotsForFrame = groupedSnapshots[`${line}:${column}`];
  270. if (!snapshotsForFrame) {
  271. return;
  272. }
  273. if (snapshotsForFrame.length > 1) {
  274. throw new Error(
  275. 'Jest: Multiple inline snapshots for the same call are not supported.'
  276. );
  277. }
  278. const inlineSnapshot = snapshotsForFrame[0];
  279. inlineSnapshot.node = node;
  280. snapshotMatcherNames.push(callee.property.name);
  281. const snapshotIndex = args.findIndex(
  282. ({type}) => type === 'TemplateLiteral' || type === 'StringLiteral'
  283. );
  284. const {snapshot} = inlineSnapshot;
  285. remainingSnapshots.delete(snapshot);
  286. const replacementNode = templateLiteral(
  287. [
  288. templateElement({
  289. raw: (0, _utils.escapeBacktickString)(snapshot)
  290. })
  291. ],
  292. []
  293. );
  294. if (snapshotIndex > -1) {
  295. args[snapshotIndex] = replacementNode;
  296. } else {
  297. args.push(replacementNode);
  298. }
  299. });
  300. if (remainingSnapshots.size) {
  301. throw new Error("Jest: Couldn't locate all inline snapshots.");
  302. }
  303. };
  304. const runPrettier = (
  305. prettier,
  306. sourceFilePath,
  307. sourceFileWithSnapshots,
  308. snapshotMatcherNames
  309. ) => {
  310. // Resolve project configuration.
  311. // For older versions of Prettier, do not load configuration.
  312. const config = prettier.resolveConfig
  313. ? prettier.resolveConfig.sync(sourceFilePath, {
  314. editorconfig: true
  315. })
  316. : null;
  317. // Prioritize parser found in the project config.
  318. // If not found detect the parser for the test file.
  319. // For older versions of Prettier, fallback to a simple parser detection.
  320. // @ts-expect-error - `inferredParser` is `string`
  321. const inferredParser =
  322. (config && typeof config.parser === 'string' && config.parser) ||
  323. (prettier.getFileInfo
  324. ? prettier.getFileInfo.sync(sourceFilePath).inferredParser
  325. : simpleDetectParser(sourceFilePath));
  326. if (!inferredParser) {
  327. throw new Error(
  328. `Could not infer Prettier parser for file ${sourceFilePath}`
  329. );
  330. }
  331. // Snapshots have now been inserted. Run prettier to make sure that the code is
  332. // formatted, except snapshot indentation. Snapshots cannot be formatted until
  333. // after the initial format because we don't know where the call expression
  334. // will be placed (specifically its indentation), so we have to do two
  335. // prettier.format calls back-to-back.
  336. return prettier.format(
  337. prettier.format(sourceFileWithSnapshots, {
  338. ...config,
  339. filepath: sourceFilePath
  340. }),
  341. {
  342. ...config,
  343. filepath: sourceFilePath,
  344. parser: createFormattingParser(snapshotMatcherNames, inferredParser)
  345. }
  346. );
  347. };
  348. // This parser formats snapshots to the correct indentation.
  349. const createFormattingParser =
  350. (snapshotMatcherNames, inferredParser) => (text, parsers, options) => {
  351. // Workaround for https://github.com/prettier/prettier/issues/3150
  352. options.parser = inferredParser;
  353. const ast = parsers[inferredParser](text, options);
  354. traverse(ast, (node, ancestors) => {
  355. if (node.type !== 'CallExpression') return;
  356. const {arguments: args, callee} = node;
  357. if (
  358. callee.type !== 'MemberExpression' ||
  359. callee.property.type !== 'Identifier' ||
  360. !snapshotMatcherNames.includes(callee.property.name) ||
  361. !callee.loc ||
  362. callee.computed
  363. ) {
  364. return;
  365. }
  366. let snapshotIndex;
  367. let snapshot;
  368. for (let i = 0; i < args.length; i++) {
  369. const node = args[i];
  370. if (node.type === 'TemplateLiteral') {
  371. snapshotIndex = i;
  372. snapshot = node.quasis[0].value.raw;
  373. }
  374. }
  375. if (snapshot === undefined) {
  376. return;
  377. }
  378. const parent = ancestors[ancestors.length - 1].node;
  379. const startColumn =
  380. isAwaitExpression(parent) && parent.loc
  381. ? parent.loc.start.column
  382. : callee.loc.start.column;
  383. const useSpaces = !options.useTabs;
  384. snapshot = indent(
  385. snapshot,
  386. Math.ceil(
  387. useSpaces
  388. ? startColumn / (options.tabWidth ?? 1)
  389. : // Each tab is 2 characters.
  390. startColumn / 2
  391. ),
  392. useSpaces ? ' '.repeat(options.tabWidth ?? 1) : '\t'
  393. );
  394. const replacementNode = templateLiteral(
  395. [
  396. templateElement({
  397. raw: snapshot
  398. })
  399. ],
  400. []
  401. );
  402. args[snapshotIndex] = replacementNode;
  403. });
  404. return ast;
  405. };
  406. const simpleDetectParser = filePath => {
  407. const extname = path.extname(filePath);
  408. if (/\.tsx?$/.test(extname)) {
  409. return 'typescript';
  410. }
  411. return 'babel';
  412. };