UNPKG

jest-preview

Version:

Preview your Jest tests in a browser

561 lines (544 loc) 23.1 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var fs = require('fs'); var path = require('path'); var crypto = require('crypto'); var child_process = require('child_process'); var url = require('url'); var camelcase = require('camelcase'); var slash = require('slash'); var core = require('@svgr/core'); var chalk = require('chalk'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs); var path__default = /*#__PURE__*/_interopDefaultLegacy(path); var crypto__default = /*#__PURE__*/_interopDefaultLegacy(crypto); var camelcase__default = /*#__PURE__*/_interopDefaultLegacy(camelcase); var slash__default = /*#__PURE__*/_interopDefaultLegacy(slash); var chalk__default = /*#__PURE__*/_interopDefaultLegacy(chalk); const CACHE_FOLDER = './node_modules/.cache/jest-preview'; const SASS_LOAD_PATHS_CONFIG = 'cache-sass-load-paths.config'; // Create cache folder if it doesn't exist function createCacheFolderIfNeeded() { if (!fs__default["default"].existsSync(CACHE_FOLDER)) { fs__default["default"].mkdirSync(CACHE_FOLDER, { recursive: true, }); } } // https://github.com/vitejs/vite/blob/c29613013ca1c6d9c77b97e2253ed1f07e40a544/packages/vite/src/node/plugins/css.ts#L97-L98 const cssLangs = `\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)`; const cssModuleRE = new RegExp(`\\.module${cssLangs}`); function isSass(filename) { return /.(sass|scss)$/.test(filename); } function isLess(filename) { return /.(less)$/.test(filename); } // TODO: Add styl, stylus... function isPreProcessor(filename) { return isSass(filename) || isLess(filename); } function havePostCss() { // TODO: Since we executing postcssrc() twice, the overall speed is slow // We can try to process the PostCSS here to reduce the number of executions // TODO: Does this break on Windows? const checkHavePostCssFileContent = `const postcssrc = require('postcss-load-config'); postcssrc().then(({ plugins, options }) => { console.log(true) }) .catch(error=>{ if (!/No PostCSS Config found/.test(error.message)) { throw new Error("Failed to load PostCSS config", error) } console.log(false) });`; const tempFileName = createTempFile(checkHavePostCssFileContent); const result = child_process.spawnSync('node', [tempFileName]); fs__default["default"].unlink(tempFileName, (error) => { if (error) throw error; }); const stderr = result.stderr.toString('utf-8').trim(); if (stderr) console.error(stderr); if (result.error) throw result.error; return result.stdout.toString().trim() === 'true'; } function getRelativeFilename(filename) { return slash__default["default"](filename.split(process.cwd())[1]); } function processFile(src, filename) { // /Users/your-name/your-project/src/assets/image.png => /src/assets/image.png const relativeFilenameStringified = JSON.stringify(getRelativeFilename(filename)); // TODO: To support https://github.com/jpkleemans/vite-svg-loader and https://github.com/pd4d10/vite-plugin-svgr (already supported) as well if (filename.match(/\.svg$/)) { // Based on how SVGR generates a component name: // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 const pascalCaseFilename = camelcase__default["default"](path__default["default"].parse(filename).name, { pascalCase: true, }); const componentName = `Svg${pascalCaseFilename}`; try { const svgComponent = core.transform.sync(src, { // Do not insert `import * as React from "react";` jsxRuntime: 'automatic', }, { componentName }); // We need to transpile jsx to vanilla jsx so Jest can understand // @babel/core is bundled with jest // I guess @babel/plugin-transform-react-jsx is installed by default? TODO: To validate this assumption // TODO: Do we have any other option to transpile jsx to vanilla jsx? // vite-plugin-svgr uses esbuild https://github.com/pd4d10/vite-plugin-svgr/blob/main/src/index.ts // How about add esbuild as dependency then use esbuild to transpile jsx to vanilla jsx? const babel = require('@babel/core'); const result = babel.transformSync(svgComponent, { plugins: ['@babel/plugin-transform-react-jsx'], }); // TODO: This is workaround to remove "export default". We might comeback to find a better solution const componentCodeWithoutExport = result.code .split('\n') .slice(0, -1) // Remove the last line .join('\n'); return { // TODO: To render actual SVG to the snapshot code: `const React = require('react') ${componentCodeWithoutExport} module.exports = { __esModule: true, default: ${relativeFilenameStringified}, ReactComponent: ${componentName} };`, }; } catch (error) { // In case of there is any error, fallback to a span with filename return { code: `const React = require('react'); module.exports = { __esModule: true, default: ${relativeFilenameStringified}, ReactComponent: React.forwardRef(function ${componentName}(props, ref) { return { $$typeof: Symbol.for('react.element'), type: 'span', ref: ref, key: null, props: Object.assign({}, props, { children: ${relativeFilenameStringified} }) }; }), };`, }; } } return { code: `module.exports = { __esModule: true, default: ${relativeFilenameStringified}, };`, }; } // We keep processFileCRA for backward compatible reason function processFileCRA(src, filename) { // TODO: To add deprecation warning here (using chalk) return processFile(src, filename); } // TODO: We need to re-architect the CSS transform as follow // pre-processor (sass, stylus, less) => process(??) => post-processor (css modules, tailwindcss) // Reference to https://github.com/vitejs/vite/blob/c29613013ca1c6d9c77b97e2253ed1f07e40a544/packages/vite/src/node/plugins/css.ts#L652-L673 function processCss(src, filename) { const relativeFilename = getRelativeFilename(filename); console.time(`Processing ${relativeFilename}`); let cssSrc = src; const isModule = cssModuleRE.test(filename); const isPreProcessorFile = isPreProcessor(filename); const haveTailwindCss = fs__default["default"].existsSync(path__default["default"].join(process.cwd(), 'tailwind.config.js')) || fs__default["default"].existsSync(path__default["default"].join(process.cwd(), 'tailwind.config.cjs')); const usePostCssExplicitly = havePostCss(); // Pure CSS if (!isModule && !isPreProcessorFile && !usePostCssExplicitly && !haveTailwindCss) { // Transform to a javascript module that load a <link rel="stylesheet"> tag to the page. console.timeEnd(`Processing ${relativeFilename}`); return { code: `const relativeCssPath = "${relativeFilename}"; const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = relativeCssPath; document.head.appendChild(link); module.exports = JSON.stringify(relativeCssPath);`, }; } // Pre-processor (sass, stylus, less) if (isSass(filename)) { cssSrc = processSass(filename); } if (isLess(filename)) { cssSrc = processLess(filename); } // Process PostCSS (postcss.config.js can be present or not) if (usePostCssExplicitly || haveTailwindCss || isModule) { console.timeEnd(`Processing ${relativeFilename}`); return processPostCss(cssSrc, filename, { useConfigFile: usePostCssExplicitly, isModule, haveTailwindCss, }); } console.timeEnd(`Processing ${relativeFilename}`); return { code: `const style = document.createElement('style'); style.appendChild(document.createTextNode(${JSON.stringify(cssSrc)})); document.head.appendChild(style); module.exports = {}`, }; } // TODO: MEDIUM PRIORITY To research about getCacheKey // Reference: // - https://jestjs.io/docs/code-transformation#writing-custom-transformers // - https://github.com/swc-project/jest/blob/17cf883b46c050a485975d8ce96a05277cf6032f/index.ts#L37-L52 // const cacheKeyFunction = createCacheKey(); // export function getCacheKey(src: string, filename: string, ...rest): string { // const baseCacheKey = cacheKeyFunction(src, filename, ...rest); // return crypto.createHash('md5').update(baseCacheKey).digest('hex'); // } // August 22, 2022: Actually, we can take css/file transform content into account for getCacheKey. // So, each time they change, the cache will be invalidated // We cannot create async transformer if we are using CommonJS // ( Reference: https://github.com/facebook/jest/issues/11081#issuecomment-791259034 // https://github.com/facebook/jest/issues/11458 // Also, there is a inconsistency in jest docs about should `process` be required // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object // A transformer must be an object with at least a process function // https://jestjs.io/docs/code-transformation#writing-custom-transformers // As can be seen, only process or processAsync is mandatory to implement) // Convert from //cssModulesExportedTokens||| {"scssModule":"_scssModule_ujx1w_1"} // --- // css||| ._scssModule_ujx1w_1 { // color: #ff0000; // } // --- // To this // { "cssModulesExportedTokens": { "scssModule":"_scssModule_ujx1w_1"}, "css":"._scssModule_ujx1w_1 { color: #ff0000 }"} function parsePostCssExternalOutput(output) { const lines = output.trim().split('---'); const result = { cssModulesExportedTokens: '', css: '', }; for (const line of lines) { const [key, value] = line.trim().split('|||'); if (key === 'cssModulesExportedTokens') { result.cssModulesExportedTokens = value; } if (key === 'css') { result.css = value; } } return result; } function createTempFile(content) { createCacheFolderIfNeeded(); const tempFileName = path__default["default"].join(CACHE_FOLDER, crypto__default["default"].randomBytes(16).toString('hex')); fs__default["default"].writeFileSync(tempFileName, content); return tempFileName; } function processPostCss(src, filename, options = { useConfigFile: true, isModule: false, haveTailwindCss: false }) { var _a; // TODO: SO SLOWWWWW. How can we speedup this? // It currently takes about 0.35 seconds to process one CSS file with PostCSS // - getCacheKey // - cache result of `postcssrc()` => very hard, since each css file, it must read the config again // - somehow speedup `spawnSync`? // - Do not execute postcssrc() twice const cssModulesPluginsContent = `require('postcss-modules')({ getJSON: (cssFileName, json, outputFileName) => { console.log('cssModulesExportedTokens|||', JSON.stringify(json)); console.log('---') }, // Use custom scoped name to prevent different hash between operating systems // Because new line characters can be different between operating systems. Reference: https://stackoverflow.com/a/10805198 // Original hash function: https://github.com/madyankin/postcss-modules/blob/7d5965d4df201ef301421a5e35805d1b47f3c914/src/generateScopedName.js#L6 generateScopedName: function (name, filename, css) { const stringHash = require('string-hash'); const i = css.indexOf('.' + name); const line = css.substr(0, i).split(/[\\r\\n|\\n|\\r]/).length; // This is not how the real app work, might be an issue if we try to make the snapshot interactive // https://github.com/nvh95/jest-preview/issues/84#issuecomment-1146578932 const removedNewLineCharactersCss = css.replace(/(\\r\\n|\\n|\\r)/g, ''); const hash = stringHash(removedNewLineCharactersCss).toString(36).substr(0, 5); return '_' + name + '_' + hash + '_' + line; }, })`; let processPostCssFileContent = `const postcss = require('postcss'); const postcssrc = require('postcss-load-config'); const isModule = ${options.isModule} const cssSrc = ${JSON.stringify(src)}; // TODO: We have to re-execute "postcssrc()" every CSS file. // Can we do better? Singleton? postcssrc().then(({ plugins, options }) => { plugins.unshift(require('postcss-import')()) if (isModule) { plugins.push( ${cssModulesPluginsContent}, ) } postcss(plugins) .process(cssSrc, { ...options, from: ${JSON.stringify(filename)} }) .then((result) => { console.log('css|||', result.css); console.log('---') }); });`; if (!options.useConfigFile) { processPostCssFileContent = `const postcss = require('postcss'); const isModule = ${options.isModule}; const haveTailwindCss = ${options.haveTailwindCss}; const cssSrc = ${JSON.stringify(src)}; let plugins = []; if (isModule) { plugins.unshift(require('postcss-import')()) plugins.push( ${cssModulesPluginsContent}, ) } if (haveTailwindCss) { // tailwindCss auto resolve config // https://github.com/tailwindlabs/tailwindcss/blob/cef02e2dc395ed5b5d31b72183cf7504b3bd76c1/src/util/resolveConfigPath.js#L45-L52 plugins.push(require("tailwindcss")()) } postcss(plugins) .process(cssSrc, { from: ${JSON.stringify(filename)} }) .then((result) => { console.log('css|||', result.css); console.log('---') });`; } const tempFileName = createTempFile(processPostCssFileContent); // We have to write this file to disk since Windows cannot process the command with long arguments const result = child_process.spawnSync('node', [tempFileName]); fs__default["default"].unlink(tempFileName, (error) => { if (error) throw error; }); // TODO: What happens if we do not pass `utf-8`? const stderr = (_a = result.stderr) === null || _a === void 0 ? void 0 : _a.toString('utf-8').trim(); if (stderr) console.error(stderr); if (result.error) throw result.error; const output = parsePostCssExternalOutput(result.stdout.toString()); return { code: `const style = document.createElement("style"); style.type = "text/css"; const styleContent = ${JSON.stringify(output.css)}; style.appendChild(document.createTextNode(styleContent.replace(/\\\\/g, ''))); document.head.appendChild(style); module.exports = ${output.cssModulesExportedTokens || '{}'}`, }; } function processSass(filename) { let sass; try { sass = require('sass'); } catch (err) { console.log(err); throw new Error('Sass not found. Please install sass and try again.'); } const sassLoadPathsConfigPath = path__default["default"].join(CACHE_FOLDER, SASS_LOAD_PATHS_CONFIG); let sassLoadPathsConfig; if (fs__default["default"].existsSync(sassLoadPathsConfigPath)) { const sassLoadPathsString = fs__default["default"] .readFileSync(path__default["default"].join(CACHE_FOLDER, SASS_LOAD_PATHS_CONFIG), 'utf8') .trim(); sassLoadPathsConfig = JSON.parse(sassLoadPathsString); } else { sassLoadPathsConfig = []; } let cssResult; // An importer that redirects relative URLs starting with "~" to `node_modules` // Reference: https://sass-lang.com/documentation/js-api/interfaces/FileImporter const tildeImporter = (url$1) => { if (!url$1.startsWith('~')) return null; return new URL( // TODO: Search in node_modules by require.resolve (monorepo) // E.g: input: ~animate-sass/animate // output: file:/Users/yourname/oss/jest-preview/node_modules/animate-sass/animate // => require.resolve('animate-sass') + animate path__default["default"].join(url.pathToFileURL('node_modules').href, url$1.substring(1))); }; if (sass.compile) { cssResult = sass.compile(filename, { loadPaths: sassLoadPathsConfig, importers: [ { findFileUrl(url) { return tildeImporter(url); }, }, ], }).css; } // Because sass.compile is only introduced since sass version 1.45.0 // For older versions, we have to use the legacy API: renderSync else if (sass.renderSync) { cssResult = sass .renderSync({ file: filename, includePaths: sassLoadPathsConfig, importer: [ function (url) { return tildeImporter(url); }, ], }) .css.toString(); } else { throw new Error('Cannot compile sass to css: No compile method is available.'); } return cssResult; } function processLess(filename) { console.log('processLess', filename); try { require('less'); } catch (err) { console.log(err); throw new Error('Less not found. Please install less and try again.'); } const processLessFileContent = `const less = require('less'); const fs = require('fs'); const path = require('path'); const cssContent = fs.readFileSync(${JSON.stringify(filename)}, 'utf8'); less.render(cssContent, { filename: ${JSON.stringify(filename)}}).then((output) => { console.log(output.css); });`; const tempFileName = createTempFile(processLessFileContent); const result = child_process.spawnSync('node', [tempFileName]); fs__default["default"].unlink(tempFileName, (error) => { if (error) throw error; }); const stderr = result.stderr.toString('utf-8').trim(); if (stderr) console.error(stderr); if (result.error) throw result.error; return result.stdout.toString(); } function debug() { if (!fs__default["default"].existsSync(CACHE_FOLDER)) { fs__default["default"].mkdirSync(CACHE_FOLDER, { recursive: true, }); } fs__default["default"].writeFileSync(path__default["default"].join(CACHE_FOLDER, 'index.html'), document.documentElement.outerHTML); } function jestPreviewConfigure({ externalCss, autoPreview = false, publicFolder, sassLoadPaths, } = { autoPreview: false, sassLoadPaths: [], }) { if (autoPreview) { autoRunPreview(); } if (!fs__default["default"].existsSync(CACHE_FOLDER)) { fs__default["default"].mkdirSync(CACHE_FOLDER, { recursive: true, }); } let sassLoadPathsConfig = []; // Save sassLoadPathsConfig to cache, so we can use it in the transformer if (sassLoadPaths) { sassLoadPathsConfig = sassLoadPaths.map((path) => `${process.cwd()}/${path}`); createCacheFolderIfNeeded(); fs__default["default"].writeFileSync(path__default["default"].join(CACHE_FOLDER, SASS_LOAD_PATHS_CONFIG), JSON.stringify(sassLoadPathsConfig)); } externalCss === null || externalCss === void 0 ? void 0 : externalCss.forEach((cssFile) => { console.log(chalk__default["default"].red('externalCss is deprecated and has no effects.\n', 'Please import css files directly in your setup file.\n', 'See the migration guide at www.jest-preview.com/blog/deprecate-externalCss')); }); if (publicFolder) { createCacheFolderIfNeeded(); fs__default["default"].writeFileSync(path__default["default"].join(CACHE_FOLDER, 'cache-public.config'), publicFolder, { encoding: 'utf-8', flag: 'w', }); } } function patchJestFunction(it) { const originalIt = it; const itWithPreview = (name, callback, timeout) => { let callbackWithPreview; if (!callback) { callbackWithPreview = undefined; } else { callbackWithPreview = async function (...args) { try { // @ts-expect-error Just forward the args return await callback(...args); } catch (error) { debug(); throw error; } }; } return originalIt(name, callbackWithPreview, timeout); }; return itWithPreview; } function autoRunPreview() { const originalIt = it; let itWithPreview = patchJestFunction(it); itWithPreview.each = originalIt.each; itWithPreview.only = patchJestFunction(originalIt.only); itWithPreview.skip = originalIt.skip; itWithPreview.todo = originalIt.todo; itWithPreview.concurrent = patchJestFunction(originalIt.concurrent); // Overwrite global it/ test // Is there any use cases that `it` and `test` is undefined? it = itWithPreview; test = itWithPreview; fit = itWithPreview.only; } async function configureNextJestPreview(createFinalJestConfig) { const config = await createFinalJestConfig(); // Use transforms from `jest-preview` if (config.transform) { config.transform['^.+\\.(css|scss|sass|less)$'] = 'jest-preview/transforms/css'; config.transform['^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)'] = 'jest-preview/transforms/file'; } // Don't ignore Module CSS/Sass/SCSS if (config.transformIgnorePatterns) { config.transformIgnorePatterns = config.transformIgnorePatterns.filter((pattern) => pattern !== '^.+\\.module\\.(css|sass|scss)$'); } // Don't mock Module CSS/Sass/SCSS, normal CSS/Sass/SCSS, or images if (config.moduleNameMapper) { delete config.moduleNameMapper['^.+\\.module\\.(css|sass|scss)$']; delete config.moduleNameMapper['^.+\\.(css|sass|scss)$']; delete config.moduleNameMapper['^.+\\.(png|jpg|jpeg|gif|webp|avif|ico|bmp|svg)$']; } return config; } var index = { debug, }; exports.configureNextJestPreview = configureNextJestPreview; exports.debug = debug; exports["default"] = index; exports.jestPreviewConfigure = jestPreviewConfigure; exports.processCss = processCss; exports.processFile = processFile; exports.processFileCRA = processFileCRA;