import path from 'path' import assert from 'assert' import { flushAllTraces, setGlobal, trace } from 'next/dist/trace' import { PHASE_DEVELOPMENT_SERVER } from 'next/constants' import { NextInstance, NextInstanceOpts } from '../next-modes/base' import { NextDevInstance } from '../next-modes/next-dev' import { NextStartInstance } from '../next-modes/next-start' import { NextDeployInstance } from '../next-modes/next-deploy' import { shouldUseTurbopack } from '../next-test-utils' export type { NextInstance } const individualTestTimeout = 60 * 1000 // Keep a higher timeout for setup hooks (e.g. initial createNext/startup), // but enforce 60s per test case via wrapped `it`/`test` for non-dev modes. let setupTimeout = (process.platform === 'win32' ? 240 : 120) * 1000 if (process.env.NEXT_E2E_TEST_TIMEOUT) { const parsedTimeout = Number.parseInt(process.env.NEXT_E2E_TEST_TIMEOUT, 10) if (!Number.isNaN(parsedTimeout)) { setupTimeout = parsedTimeout } } jest.setTimeout(setupTimeout) type E2ETestGlobal = typeof globalThis & { __NEXT_E2E_TEST_CONFIG_PATCHED__?: boolean __NEXT_E2E_WRAPPED_TEST_FNS__?: WeakMap } const wrapJestTestFn = (fn: T): T => { const e2eGlobal = global as E2ETestGlobal const wrappedFns = e2eGlobal.__NEXT_E2E_WRAPPED_TEST_FNS__ ?? (e2eGlobal.__NEXT_E2E_WRAPPED_TEST_FNS__ = new WeakMap()) const existing = wrappedFns.get(fn) if (existing) return existing as T const wrapped = new Proxy(fn, { apply(target, thisArg, argArray: unknown[]) { const args = [...argArray] if ( args.length >= 2 && typeof args[1] === 'function' && args[2] === undefined ) { args[2] = individualTestTimeout } const result = Reflect.apply(target, thisArg, args) return typeof result === 'function' ? wrapJestTestFn(result) : result }, get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver) return typeof value === 'function' ? wrapJestTestFn(value) : value }, }) wrappedFns.set(fn, wrapped) return wrapped as T } const testsFolder = path.join(__dirname, '..', '..') let testFile const testFileRegex = /\.test\.(js|tsx?)/ const visitedModules = new Set() const checkParent = (mod) => { if (!mod?.parent || visitedModules.has(mod)) return testFile = mod.parent.filename || '' visitedModules.add(mod) if (!testFileRegex.test(testFile)) { checkParent(mod.parent) } } checkParent(module) process.env.TEST_FILE_PATH = testFile let testMode = process.env.NEXT_TEST_MODE if (!testFileRegex.test(testFile)) { throw new Error( `e2e-utils imported from non-test file ${testFile} (must end with .test.(js,ts,tsx)` ) } const testFolderModes = ['e2e', 'development', 'production'] const testModeFromFile = testFolderModes.find((mode) => testFile.startsWith(path.join(testsFolder, mode)) ) if (testModeFromFile === 'e2e') { const validE2EModes = ['dev', 'start', 'deploy'] if (!process.env.NEXT_TEST_JOB && !testMode) { require('console').warn( 'Warn: no NEXT_TEST_MODE set, using default of start' ) testMode = 'start' } assert( validE2EModes.includes(testMode!), `NEXT_TEST_MODE must be one of ${validE2EModes.join( ', ' )} for e2e tests but received ${testMode}` ) } else if (testModeFromFile === 'development') { testMode = 'dev' } else if (testModeFromFile === 'production') { testMode = 'start' } const e2eGlobal = global as E2ETestGlobal if (!e2eGlobal.__NEXT_E2E_TEST_CONFIG_PATCHED__) { if (testMode !== 'dev') { if (typeof global.it === 'function') { global.it = wrapJestTestFn(global.it) as jest.It } if (typeof global.test === 'function') { global.test = wrapJestTestFn(global.test) as jest.It } if (process.env.NEXT_TEST_CI) { jest.retryTimes(1) } } e2eGlobal.__NEXT_E2E_TEST_CONFIG_PATCHED__ = true } if (testMode === 'dev') { ;(global as any).isNextDev = true } else if (testMode === 'deploy') { ;(global as any).isNextDeploy = true } else { ;(global as any).isNextStart = true } /** * Whether the test is running in development mode. * Based on `process.env.NEXT_TEST_MODE` and the test directory. */ export const isNextDev = testMode === 'dev' /** * Whether the test is running in deploy mode. * Based on `process.env.NEXT_TEST_MODE`. */ export const isNextDeploy = testMode === 'deploy' /** * Whether the test is running in start mode. * Default mode. `true` when both `isNextDev` and `isNextDeploy` are false. */ export const isNextStart = !isNextDev && !isNextDeploy if (!process.env.NEXT_TEST_WASM && process.env.NEXT_TEST_WASM_AFTER_JEST) { process.env.NEXT_TEST_WASM = process.env.NEXT_TEST_WASM_AFTER_JEST } export const isRspack = !!process.env.NEXT_RSPACK const isNextTestWasm = !!process.env.NEXT_TEST_WASM if (!testMode) { throw new Error( `No 'NEXT_TEST_MODE' set in environment, this is required for e2e-utils` ) } require('console').warn( `Using test mode: ${testMode} in test folder ${testModeFromFile}` ) /** * FileRef is wrapper around a file path that is meant be copied * to the location where the next instance is being created */ export class FileRef { public fsPath: string constructor(path: string) { this.fsPath = path } } /** * FileRef is wrapper around a file path that is meant be copied * to the location where the next instance is being created */ export class PatchedFileRef { public fsPath: string public cb: (content: string) => string constructor(path: string, cb: (content: string) => string) { this.fsPath = path this.cb = cb } } let nextInstance: NextInstance | undefined = undefined if (typeof afterAll === 'function') { afterAll(async () => { if (nextInstance) { await nextInstance.destroy() throw new Error( `next instance not destroyed before exiting, make sure to call .destroy() after the tests after finished` ) } }) } const setupTracing = () => { if (!process.env.NEXT_TEST_TRACE) return setGlobal('distDir', './test/.trace') // This is a hacky way to use tracing utils even for tracing test utils. // We want the same treatment as DEVELOPMENT_SERVER - adds a reasonable treshold for logs size. setGlobal('phase', PHASE_DEVELOPMENT_SERVER) } /** * Sets up and manages a Next.js instance in the configured * test mode. The next instance will be isolated from the monorepo * to prevent relying on modules that shouldn't be */ export async function createNext( opts: NextInstanceOpts & { skipStart?: boolean; patchFileDelay?: number } ): Promise { try { if (nextInstance) { throw new Error(`createNext called without destroying previous instance`) } setupTracing() return await trace('createNext').traceAsyncFn(async (rootSpan) => { const useTurbo = isNextTestWasm ? false : (opts?.turbo ?? shouldUseTurbopack()) if (testMode === 'dev') { // next dev rootSpan.traceChild('init next dev instance').traceFn(() => { nextInstance = new NextDevInstance({ ...opts, turbo: useTurbo, }) }) } else if (testMode === 'deploy') { // Vercel rootSpan.traceChild('init next deploy instance').traceFn(() => { nextInstance = new NextDeployInstance({ ...opts, }) }) } else { // next build + next start rootSpan.traceChild('init next start instance').traceFn(() => { nextInstance = new NextStartInstance({ ...opts, }) }) } nextInstance = nextInstance! nextInstance.on('destroy', () => { nextInstance = undefined }) await nextInstance.setup(rootSpan) if (!opts.skipStart) { await rootSpan .traceChild('start next instance') .traceAsyncFn(async () => { await nextInstance!.start() }) } return nextInstance! }) } catch (err) { require('console').error('Failed to create next instance', err) try { await nextInstance?.destroy() } catch (_) {} nextInstance = undefined // Throw instead of process exit to ensure that Jest reports the tests as failed. throw err } finally { flushAllTraces() } } export function nextTestSetup( options: Parameters[0] & { skipDeployment?: boolean dir?: string } ): { isNextDev: boolean isNextDeploy: boolean isNextStart: boolean isTurbopack: boolean isRspack: boolean next: NextInstance skipped: boolean } { let skipped = false if (options.skipDeployment) { // When the environment is running for deployment tests. if (isNextDeploy) { // eslint-disable-next-line jest/no-focused-tests it.only('should skip next deploy', () => {}) // No tests are run. skipped = true } } let next: NextInstance | undefined if (!skipped) { beforeAll(async () => { next = await createNext(options) }) afterAll(async () => { // Gracefully destroy the instance if `createNext` success. // If next instance is not available, it's likely beforeAll hook failed and unnecessarily throws another error // by attempting to destroy on undefined. await next?.destroy() }) } const nextProxy = new Proxy({} as NextInstance, { get: function (_target, property) { if (!next) { throw new Error( 'next instance is not initialized yet, make sure you call methods on next instance in test body.' ) } const prop = next[property] return typeof prop === 'function' ? prop.bind(next) : prop }, set: function (_target, key, value) { if (!next) { throw new Error( 'next instance is not initialized yet, make sure you call methods on next instance in test body.' ) } next[key] = value return true }, }) return { get isNextDev() { return isNextDev }, get isNextDeploy() { return isNextDeploy }, get isNextStart() { return isNextStart }, get isTurbopack() { return Boolean(!isNextTestWasm && (options.turbo ?? shouldUseTurbopack())) }, get isRspack() { return isRspack }, get next() { return nextProxy }, skipped, } }