From 35748154c45b16815ba865b893450a61aebf277d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 10 Nov 2021 13:21:15 +0100 Subject: [PATCH] Experimental next/jest config helper --- jest.config.js | 18 +-- .../next/build/jest/__mocks__/fileMock.js | 1 + .../next/build/jest/__mocks__/styleMock.js | 1 + packages/next/build/jest/jest.ts | 130 ++++++++++++++++++ packages/next/build/jest/object-proxy.js | 37 +++++ packages/next/build/load-jsconfig.ts | 83 +++++++++++ .../swc/{jest.js => jest-transformer.js} | 47 +++---- packages/next/build/swc/options.js | 19 ++- packages/next/build/webpack-config.ts | 38 +---- packages/next/jest.js | 2 +- packages/next/shared/lib/constants.ts | 1 + 11 files changed, 301 insertions(+), 76 deletions(-) create mode 100644 packages/next/build/jest/__mocks__/fileMock.js create mode 100644 packages/next/build/jest/__mocks__/styleMock.js create mode 100644 packages/next/build/jest/jest.ts create mode 100644 packages/next/build/jest/object-proxy.js create mode 100644 packages/next/build/load-jsconfig.ts rename packages/next/build/swc/{jest.js => jest-transformer.js} (68%) diff --git a/jest.config.js b/jest.config.js index 10152d4f930725f..fc8f761e22838f7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,16 +1,16 @@ -const path = require('path') +const nextJest = require('next/jest') -module.exports = { +const createJestConfig = nextJest() + +// Any custom config you want to pass to Jest +const customJestConfig = { testMatch: ['**/*.test.js', '**/*.test.ts', '**/*.test.tsx'], setupFilesAfterEnv: ['/jest-setup-after-env.ts'], verbose: true, rootDir: 'test', modulePaths: ['/lib'], - transformIgnorePatterns: ['/node_modules/', '/next[/\\\\]dist/', '/.next/'], - transform: { - '.+\\.(t|j)sx?$': [ - // this matches our SWC options used in https://github.com/vercel/next.js/blob/canary/packages/next/taskfile-swc.js - path.join(__dirname, './packages/next/jest.js'), - ], - }, + transformIgnorePatterns: ['/next[/\\\\]dist/'], } + +// createJestConfig is exported in this way to ensure that next/jest can load the Next.js config which is async +module.exports = createJestConfig(customJestConfig) diff --git a/packages/next/build/jest/__mocks__/fileMock.js b/packages/next/build/jest/__mocks__/fileMock.js new file mode 100644 index 000000000000000..0e56c5b5f76550e --- /dev/null +++ b/packages/next/build/jest/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub' diff --git a/packages/next/build/jest/__mocks__/styleMock.js b/packages/next/build/jest/__mocks__/styleMock.js new file mode 100644 index 000000000000000..4ba52ba2c8df675 --- /dev/null +++ b/packages/next/build/jest/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/packages/next/build/jest/jest.ts b/packages/next/build/jest/jest.ts new file mode 100644 index 000000000000000..e8fbc59a603c7a0 --- /dev/null +++ b/packages/next/build/jest/jest.ts @@ -0,0 +1,130 @@ +import { loadEnvConfig } from '@next/env' +import { resolve, join } from 'path' +import loadConfig from '../../server/config' +import { PHASE_TEST } from '../../shared/lib/constants' +// import loadJsConfig from '../load-jsconfig' +import * as Log from '../output/log' + +async function getConfig(dir: string) { + const conf = await loadConfig(PHASE_TEST, dir) + return conf +} + +/** + * Loads closest package.json in the directory hierarchy + */ +function loadClosestPackageJson(dir: string, attempts = 1): any { + if (attempts > 5) { + throw new Error("Can't resolve main package.json file") + } + var mainPath = attempts === 1 ? './' : Array(attempts).join('../') + try { + return require(join(dir, mainPath + 'package.json')) + } catch (e) { + return loadClosestPackageJson(dir, attempts + 1) + } +} + +/* +// Usage in jest.config.js +const nextJest = require('next/jest'); + +// Optionally provide path to Next.js app which will enable loading next.config.js and .env files +const createJestConfig = nextJest({ dir }) + +// Any custom config you want to pass to Jest +const customJestConfig = { + setupFilesAfterEnv: ['/jest.setup.js'], +} + +// createJestConfig is exported in this way to ensure that next/jest can load the Next.js config which is async +module.exports = createJestConfig(customJestConfig) +*/ +module.exports = function nextJest(options: { dir?: string } = {}) { + // createJestConfig + return (customJestConfig: any) => { + // Function that is provided as the module.exports of jest.config.js + // Will be called and awaited by Jest + return async () => { + let nextConfig + let paths + let resolvedBaseUrl + let isEsmProject = false + if (options.dir) { + const resolvedDir = resolve(options.dir) + const packageConfig = loadClosestPackageJson(resolvedDir) + isEsmProject = packageConfig.type === 'module' + + nextConfig = await getConfig(resolvedDir) + loadEnvConfig(resolvedDir, false, Log) + // TODO: revisit when bug in SWC is fixed that strips `.css` + // const result = await loadJsConfig(resolvedDir, nextConfig) + // paths = result?.jsConfig?.compilerOptions?.paths + // resolvedBaseUrl = result.resolvedBaseUrl + } + // Ensure provided async config is supported + const resolvedJestConfig = + typeof customJestConfig === 'function' + ? await customJestConfig() + : customJestConfig + + return { + ...resolvedJestConfig, + + moduleNameMapper: { + // Handle CSS imports (with CSS modules) + // https://jestjs.io/docs/webpack#mocking-css-modules + '^.+\\.module\\.(css|sass|scss)$': + require.resolve('./object-proxy.js'), + + // Handle CSS imports (without CSS modules) + '^.+\\.(css|sass|scss)$': require.resolve('./__mocks__/styleMock.js'), + + // Handle image imports + '^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$': require.resolve( + `./__mocks__/fileMock.js` + ), + + // Custom config will be able to override the default mappings + ...(resolvedJestConfig.moduleNameMapper || {}), + }, + testPathIgnorePatterns: [ + // Don't look for tests in node_modules + '/node_modules/', + // Don't look for tests in the the Next.js build output + '/.next/', + // Custom config can append to testPathIgnorePatterns but not modify it + // This is to ensure `.next` and `node_modules` are always excluded + ...(resolvedJestConfig.testPathIgnorePatterns || []), + ], + + transform: { + // Use SWC to compile tests + '^.+\\.(js|jsx|ts|tsx)$': [ + require.resolve('../swc/jest-transformer'), + { + styledComponents: + nextConfig && nextConfig.experimental.styledComponents, + paths, + resolvedBaseUrl: resolvedBaseUrl, + isEsmProject, + }, + ], + // Allow for appending/overriding the default transforms + ...(resolvedJestConfig.transform || {}), + }, + + transformIgnorePatterns: [ + // To match Next.js behavior node_modules is not transformed + '/node_modules/', + // CSS modules are mocked so they don't need to be transformed + '^.+\\.module\\.(css|sass|scss)$', + + // Custom config can append to transformIgnorePatterns but not modify it + // This is to ensure `node_modules` and .module.css/sass/scss are always excluded + ...(resolvedJestConfig.transformIgnorePatterns || []), + ], + } + } + } +} diff --git a/packages/next/build/jest/object-proxy.js b/packages/next/build/jest/object-proxy.js new file mode 100644 index 000000000000000..c2aff62c9e44a81 --- /dev/null +++ b/packages/next/build/jest/object-proxy.js @@ -0,0 +1,37 @@ +/* +The MIT License (MIT) + +Copyright (c) 2015 Keyan Zhang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// This file is largely based on https://github.com/keyz/identity-obj-proxy +// Excludes the polyfill for below Node.js 6 +export default new Proxy( + {}, + { + get: function getter(target, key) { + if (key === '__esModule') { + return false + } + return key + }, + } +) diff --git a/packages/next/build/load-jsconfig.ts b/packages/next/build/load-jsconfig.ts new file mode 100644 index 000000000000000..f47b4c47cdf0fc5 --- /dev/null +++ b/packages/next/build/load-jsconfig.ts @@ -0,0 +1,83 @@ +import path from 'path' +import { fileExists } from '../lib/file-exists' +import { NextConfigComplete } from '../server/config-shared' +import * as Log from './output/log' +import { getTypeScriptConfiguration } from '../lib/typescript/getTypeScriptConfiguration' +import { readFileSync } from 'fs' +import isError from '../lib/is-error' +import { codeFrameColumns } from 'next/dist/compiled/babel/code-frame' + +let TSCONFIG_WARNED = false + +function parseJsonFile(filePath: string) { + const JSON5 = require('next/dist/compiled/json5') + const contents = readFileSync(filePath, 'utf8') + + // Special case an empty file + if (contents.trim() === '') { + return {} + } + + try { + return JSON5.parse(contents) + } catch (err) { + if (!isError(err)) throw err + const codeFrame = codeFrameColumns( + String(contents), + { + start: { + line: (err as Error & { lineNumber?: number }).lineNumber || 0, + column: (err as Error & { columnNumber?: number }).columnNumber || 0, + }, + }, + { message: err.message, highlightCode: true } + ) + throw new Error(`Failed to parse "${filePath}":\n${codeFrame}`) + } +} + +export default async function loadJsConfig( + dir: string, + config: NextConfigComplete +) { + let typeScriptPath: string | undefined + try { + typeScriptPath = require.resolve('typescript', { paths: [dir] }) + } catch (_) {} + const tsConfigPath = path.join(dir, config.typescript.tsconfigPath) + const useTypeScript = Boolean( + typeScriptPath && (await fileExists(tsConfigPath)) + ) + + let jsConfig + // jsconfig is a subset of tsconfig + if (useTypeScript) { + if ( + config.typescript.tsconfigPath !== 'tsconfig.json' && + TSCONFIG_WARNED === false + ) { + TSCONFIG_WARNED = true + Log.info(`Using tsconfig file: ${config.typescript.tsconfigPath}`) + } + + const ts = (await Promise.resolve( + require(typeScriptPath!) + )) as typeof import('typescript') + const tsConfig = await getTypeScriptConfiguration(ts, tsConfigPath, true) + jsConfig = { compilerOptions: tsConfig.options } + } + + const jsConfigPath = path.join(dir, 'jsconfig.json') + if (!useTypeScript && (await fileExists(jsConfigPath))) { + jsConfig = parseJsonFile(jsConfigPath) + } + + let resolvedBaseUrl + if (jsConfig?.compilerOptions?.baseUrl) { + resolvedBaseUrl = path.resolve(dir, jsConfig.compilerOptions.baseUrl) + } + return { + jsConfig, + resolvedBaseUrl, + } +} diff --git a/packages/next/build/swc/jest.js b/packages/next/build/swc/jest-transformer.js similarity index 68% rename from packages/next/build/swc/jest.js rename to packages/next/build/swc/jest-transformer.js index 63c6b6fe0454522..9ed1e33e937073a 100644 --- a/packages/next/build/swc/jest.js +++ b/packages/next/build/swc/jest-transformer.js @@ -34,41 +34,30 @@ console.warn( '"next/jest" is currently experimental. https://nextjs.org/docs/messages/experimental-jest-transformer' ) -/** - * Loads closest package.json in the directory hierarchy - */ -function loadClosestPackageJson(attempts = 1) { - if (attempts > 5) { - throw new Error("Can't resolve main package.json file") - } - var mainPath = attempts === 1 ? './' : Array(attempts).join('../') - try { - return require(mainPath + 'package.json') - } catch (e) { - return loadClosestPackageJson(attempts + 1) - } -} - -const packageConfig = loadClosestPackageJson() -const isEsmProject = packageConfig.type === 'module' - // Jest use the `vm` [Module API](https://nodejs.org/api/vm.html#vm_class_vm_module) for ESM. // see https://github.com/facebook/jest/issues/9430 const isSupportEsm = 'Module' in vm module.exports = { - process(src, filename, jestOptions) { - if (!/\.[jt]sx?$/.test(filename)) { - return src - } + createTransformer: (inputOptions) => ({ + process(src, filename, jestOptions) { + if (!/\.[jt]sx?$/.test(filename)) { + return src + } - let swcTransformOpts = getJestSWCOptions({ - filename, - esm: isSupportEsm && isEsm(filename, jestOptions), - }) + let swcTransformOpts = getJestSWCOptions({ + filename, + styledComponents: inputOptions.styledComponents, + paths: inputOptions.paths, + baseUrl: inputOptions.resolvedBaseUrl, + esm: + isSupportEsm && + isEsm(Boolean(inputOptions.isEsmProject), filename, jestOptions), + }) - return transformSync(src, { ...swcTransformOpts, filename }) - }, + return transformSync(src, { ...swcTransformOpts, filename }) + }, + }), } function getJestConfig(jestConfig) { @@ -79,7 +68,7 @@ function getJestConfig(jestConfig) { jestConfig } -function isEsm(filename, jestOptions) { +function isEsm(isEsmProject, filename, jestOptions) { return ( (/\.jsx?$/.test(filename) && isEsmProject) || getJestConfig(jestOptions).extensionsToTreatAsEsm?.find((ext) => diff --git a/packages/next/build/swc/options.js b/packages/next/build/swc/options.js index b205dc26821e8c7..e3d454f00165119 100644 --- a/packages/next/build/swc/options.js +++ b/packages/next/build/swc/options.js @@ -7,12 +7,20 @@ function getBaseSWCOptions({ hasReactRefresh, globalWindow, styledComponents, + paths, + baseUrl, }) { const isTSFile = filename.endsWith('.ts') const isTypeScript = isTSFile || filename.endsWith('.tsx') return { jsc: { + ...(baseUrl && paths + ? { + baseUrl, + paths, + } + : {}), parser: { syntax: isTypeScript ? 'typescript' : 'ecmascript', dynamicImport: true, @@ -51,12 +59,21 @@ function getBaseSWCOptions({ } } -export function getJestSWCOptions({ filename, esm }) { +export function getJestSWCOptions({ + filename, + esm, + styledComponents, + paths, + baseUrl, +}) { let baseOptions = getBaseSWCOptions({ filename, development: false, hasReactRefresh: false, globalWindow: false, + styledComponents, + paths, + baseUrl, }) const isNextDist = nextDistPath.test(filename) diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index e01cb621e866705..2e41f6b87fb2e46 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -56,6 +56,7 @@ import type { Span } from '../trace' import isError from '../lib/is-error' import { getRawPageExtensions } from './utils' import browserslist from 'browserslist' +import loadJsConfig from './load-jsconfig' function getSupportedBrowsers( dir: string, @@ -553,42 +554,7 @@ export default async function getBaseWebpackConfig( } as ClientEntries) : undefined - let typeScriptPath: string | undefined - try { - typeScriptPath = require.resolve('typescript', { paths: [dir] }) - } catch (_) {} - const tsConfigPath = path.join(dir, config.typescript.tsconfigPath) - const useTypeScript = Boolean( - typeScriptPath && (await fileExists(tsConfigPath)) - ) - - let jsConfig - // jsconfig is a subset of tsconfig - if (useTypeScript) { - if ( - config.typescript.tsconfigPath !== 'tsconfig.json' && - TSCONFIG_WARNED === false - ) { - TSCONFIG_WARNED = true - Log.info(`Using tsconfig file: ${config.typescript.tsconfigPath}`) - } - - const ts = (await Promise.resolve( - require(typeScriptPath!) - )) as typeof import('typescript') - const tsConfig = await getTypeScriptConfiguration(ts, tsConfigPath, true) - jsConfig = { compilerOptions: tsConfig.options } - } - - const jsConfigPath = path.join(dir, 'jsconfig.json') - if (!useTypeScript && (await fileExists(jsConfigPath))) { - jsConfig = parseJsonFile(jsConfigPath) - } - - let resolvedBaseUrl - if (jsConfig?.compilerOptions?.baseUrl) { - resolvedBaseUrl = path.resolve(dir, jsConfig.compilerOptions.baseUrl) - } + const { jsConfig, resolvedBaseUrl } = await loadJsConfig(dir, config) function getReactProfilingInProduction() { if (reactProductionProfiling) { diff --git a/packages/next/jest.js b/packages/next/jest.js index 338e34d677b321f..adeca0379d6596a 100644 --- a/packages/next/jest.js +++ b/packages/next/jest.js @@ -1 +1 @@ -module.exports = require('./dist/build/swc/jest') +module.exports = require('./dist/build/jest/jest') diff --git a/packages/next/shared/lib/constants.ts b/packages/next/shared/lib/constants.ts index ba3a5f7e93c9ec9..2940fc4c49ee10d 100644 --- a/packages/next/shared/lib/constants.ts +++ b/packages/next/shared/lib/constants.ts @@ -2,6 +2,7 @@ export const PHASE_EXPORT = 'phase-export' export const PHASE_PRODUCTION_BUILD = 'phase-production-build' export const PHASE_PRODUCTION_SERVER = 'phase-production-server' export const PHASE_DEVELOPMENT_SERVER = 'phase-development-server' +export const PHASE_TEST = 'phase-test' export const PAGES_MANIFEST = 'pages-manifest.json' export const BUILD_MANIFEST = 'build-manifest.json' export const EXPORT_MARKER = 'export-marker.json'