first commit
Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled

This commit is contained in:
Arian Tron
2026-03-10 19:37:31 +03:30
commit 61f56f997c
27684 changed files with 2784175 additions and 0 deletions

381
test/lib/e2e-utils/index.ts Normal file
View File

@@ -0,0 +1,381 @@
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<Function, Function>
}
const wrapJestTestFn = <T extends Function>(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<NextInstance> {
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<typeof createNext>[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<NextInstance>({} 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,
}
}

42
test/lib/e2e-utils/ppr.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Measures and separates static and dynamic content in a response stream
* by splitting on PPR boundary sentinel markers.
*
* This function is used in Partial Prerendering (PPR) tests to analyze the output
* of Next.js pages and verify that static and dynamic content are properly separated.
*
* @param fn - A function that returns a Promise resolving to a readable stream
* containing HTML with PPR boundary markers
* @returns An object containing the separated static and dynamic chunks
*
* @example
* const [staticPart, dynamicPart] = await splitResponseWithPPRSentinel(async () => {
* return fetch('/some-ppr-page').then(res => res.body)
* })
* console.log(staticPart) // Content before the boundary
* console.log(dynamicPart) // Content after the boundary
*/
export async function splitResponseWithPPRSentinel(
fn: () => Promise<NodeJS.ReadableStream>
): Promise<[staticPart: string, dynamicPart: string]> {
const stream = await fn()
const chunks: string[] = []
await new Promise<void>((resolve, reject) => {
stream.on('data', (chunk: Buffer | string) => {
if (typeof chunk !== 'string') {
chunk = chunk.toString('utf8')
}
chunks.push(chunk)
})
stream.on('end', resolve)
stream.on('error', reject)
})
// Combine all chunks and split on the PPR boundary sentinel
// The sentinel marks the boundary between static and dynamic content
const parts = chunks.join('').split('<!-- PPR_BOUNDARY_SENTINEL -->', 2)
return [parts[0] ?? '', parts[1] ?? '']
}

View File

@@ -0,0 +1,178 @@
import {
Request as PlaywrightRequest,
Response as PlaywrightResponse,
} from 'playwright'
import { inspect } from 'node:util'
import { Playwright } from '../browsers/playwright'
export type RequestMatcherObject = {
pathname: string
search?: string
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'HEAD' | 'OPTIONS'
}
export type RequestMatcher =
| RequestMatcherObject
| ((request: PlaywrightRequest) => Promise<boolean>)
export type RequestTracker = ReturnType<typeof createRequestTracker>
/** Allows capturing responses to requests that happened as a result of running a callback. */
export function createRequestTracker(browser: Playwright) {
/** Run a callback and capture requests with the given `method` and `pathname`. */
async function captureResponse<T>(
action: () => Promise<T>,
{
request: requestMatcher,
timeoutMs = 5000,
}: {
request: RequestMatcher
timeoutMs?: number
}
): Promise<[result: T, captured: PlaywrightResponse]> {
const isMatchingRequest = async (request: PlaywrightRequest) => {
if (typeof requestMatcher === 'function') {
// This can be async to allow matching on things like `request.allHeaders()` which is a promise.
return await requestMatcher(request)
} else {
const url = new URL(request.url())
if (requestMatcher.pathname !== url.pathname) {
return false
}
if (requestMatcher.search !== undefined) {
if (url.search !== requestMatcher.search) {
return false
}
}
if (requestMatcher.method !== undefined) {
if (request.method() !== requestMatcher.method) {
return false
}
}
return true
}
}
const responseCtrl = promiseWithResolvers<PlaywrightResponse>()
let isSettled = false
let capturedRequest: PlaywrightRequest | undefined
const cleanups: (() => void)[] = []
// Make sure we clean up all event listeners and timers after we're done.
responseCtrl.promise.finally(() => {
isSettled = true
cleanups.forEach((cb) => cb())
})
// Listen for requests that match the criteria.
const onRequest = async (request: PlaywrightRequest) => {
if (!(await isMatchingRequest(request))) {
return
}
// If `capturedRequest` is already set, then we've got multiple requests that match the criteria.
// This is currently not supported, though if needed, we could extend the API
// to allow capturing multiple requests and returning them as an array.
if (capturedRequest) {
const criteriaDescription =
typeof requestMatcher === 'function'
? 'the specified criteria'
: inspect(requestMatcher)
return responseCtrl.reject(
new Error(
[
`Captured multiple requests that match ${criteriaDescription} during a \`captureResponse\` call:`,
...[capturedRequest, request].map(
(req) => ` - ${req.method} ${req.url}`
),
'This is currently not supported.',
].join('\n')
)
)
}
// We found a request that matches our criteria. Now we'll wait for a response.
capturedRequest = request
console.log(
`[request-tracker] request: ${request.method()} ${request.url()}` +
(['POST', 'PUT', 'PATCH'].includes(request.method())
? ` (content-type: ${request.headers()['content-type']})`
: '')
)
const onResponse = (response: PlaywrightResponse) => {
if (isSettled) {
return
}
if (response.request() === request) {
// We found a response to our request. We're done.
console.log(`[request-tracker] response: ${response.status()}`)
return responseCtrl.resolve(response)
}
}
browser.on('response', onResponse)
cleanups.push(() => browser.off('response', onResponse))
}
// Install the handler before running the action callback to avoid races.
browser.on('request', onRequest)
cleanups.push(() => browser.off('request', onRequest))
// Run the action callback. We expect this to result in requests being initiated.
// If this doesn't happen before the specified timeout, we'll error below.
const actionPromise = Promise.resolve().then(action)
const resultPromise = Promise.all([
actionPromise,
responseCtrl.promise,
] as const)
actionPromise.then(
() => {
// The action callback and the request it triggered can finish before this gets the chance to run.
// In that case, we don't need to install the timeout at all.
if (isSettled) {
return
}
// After the action callback resolves, start a timer.
// If we don't capture a request/response pair within that time limit, error.
const abortTimeoutId = setTimeout(() => {
if (isSettled) {
return
}
return responseCtrl.reject(
new Error(
capturedRequest === undefined
? `Did not intercept a request within ${timeoutMs}ms of the action callback finishing`
: `Did not intercept a response within ${timeoutMs}ms of the action callback finishing`
)
)
}, timeoutMs)
cleanups.push(() => clearTimeout(abortTimeoutId))
},
() => {
// If the action callback errored, we don't want to reject the response promise,
// because then jest would unnecessarily print both errors.
// We're returning a `Promise.all` that'll reject because `actionPromise` rejected,
// so we can just quietly resolve this with `null` -- it will never reach the caller anyway.
return responseCtrl.resolve(null!)
}
)
return resultPromise
}
return { captureResponse }
}
function promiseWithResolvers<T>() {
let resolve: (value: T) => void = undefined!
let reject: (error: unknown) => void = undefined!
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return { promise, resolve, reject }
}