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
424 lines
13 KiB
TypeScript
424 lines
13 KiB
TypeScript
import type { MatcherContext } from 'expect'
|
|
import { toMatchInlineSnapshot } from 'jest-snapshot'
|
|
import {
|
|
waitForRedbox,
|
|
getRedboxCallStack,
|
|
getRedboxCause,
|
|
getRedboxComponentStack,
|
|
getRedboxDescription,
|
|
getRedboxEnvironmentLabel,
|
|
getRedboxErrorCode,
|
|
getRedboxSource,
|
|
getRedboxLabel,
|
|
getRedboxTotalErrorCount,
|
|
openRedbox,
|
|
} from './next-test-utils'
|
|
import type { Playwright } from 'next-webdriver'
|
|
import { NextInstance } from 'e2e-utils'
|
|
|
|
declare global {
|
|
namespace jest {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- module augmentation needs to match generic params even if unused
|
|
interface Matchers<R> {
|
|
/**
|
|
* Inline snapshot matcher for a Redbox that's popped up by default.
|
|
* When a Redbox is hidden at first and requires manual display by clicking the toast,
|
|
* use {@link toDisplayCollapsedRedbox} instead.
|
|
*
|
|
*
|
|
* If the project root appears in the snapshot, pass in the `NextInstance`
|
|
* as well to normalize the snapshot e.g. `await expect({ browser, next }).toDisplayRedbox()`.
|
|
*
|
|
* Unintented content in the snapshot should be reported to the Next.js DX team.
|
|
* `<FIXME-internal-frame>` in the snapshot would be unintended.
|
|
* `<FIXME-project-root>` in the snapshot would be unintended.
|
|
* `<FIXME-file-protocol>` in the snapshot would be unintended.
|
|
* `<FIXME-next-dist-dir>` in the snapshot would be unintended.
|
|
* Any node_modules in the snapshot would be unintended.
|
|
* Differences in the snapshot between Turbopack and Webpack would be unintended.
|
|
*
|
|
* @param inlineSnapshot - The snapshot to compare against.
|
|
*/
|
|
toDisplayRedbox(
|
|
inlineSnapshot?: string,
|
|
opts?: ErrorSnapshotOptions
|
|
): Promise<void>
|
|
|
|
/**
|
|
* Inline snapshot matcher for a Redbox that's collapsed by default.
|
|
* When a Redbox is immediately displayed,
|
|
* use {@link toDisplayRedbox} instead.
|
|
*
|
|
* If the project root appears in the snapshot, pass in the `NextInstance`
|
|
* as well to normalize the snapshot e.g. `await expect({ browser, next }).toDisplayCollapsedRedbox()`.
|
|
*
|
|
* Unintented content in the snapshot should be reported to the Next.js DX team.
|
|
* `<FIXME-internal-frame>` in the snapshot would be unintended.
|
|
* `<FIXME-project-root>` in the snapshot would be unintended.
|
|
* `<FIXME-file-protocol>` in the snapshot would be unintended.
|
|
* `<FIXME-next-dist-dir>` in the snapshot would be unintended.
|
|
* Any node_modules in the snapshot would be unintended.
|
|
* Differences in the snapshot between Turbopack and Webpack would be unintended.
|
|
*
|
|
* @param inlineSnapshot - The snapshot to compare against.
|
|
*/
|
|
toDisplayCollapsedRedbox(
|
|
inlineSnapshot?: string,
|
|
opts?: ErrorSnapshotOptions
|
|
): Promise<void>
|
|
}
|
|
}
|
|
}
|
|
|
|
interface ErrorSnapshotOptions {
|
|
label?: boolean
|
|
}
|
|
|
|
interface SanitizedCauseEntry {
|
|
label: string | null
|
|
message?: string
|
|
source: string | null
|
|
stack: string[]
|
|
}
|
|
|
|
export interface ErrorSnapshot {
|
|
environmentLabel: string | null
|
|
label: string | null
|
|
description?: string
|
|
componentStack?: string
|
|
cause?: SanitizedCauseEntry[]
|
|
code?: string
|
|
source: string | null
|
|
stack: string[] | null
|
|
}
|
|
|
|
/**
|
|
* Focus source to just the header, errored line, and cursor.
|
|
* Strips surrounding context lines.
|
|
*/
|
|
function focusSource(
|
|
source: string | null,
|
|
next: NextInstance | null
|
|
): string | null {
|
|
if (source === null) return null
|
|
|
|
let focusedSource = ''
|
|
const sourceFrameLines = source.split('\n')
|
|
for (let i = 0; i < sourceFrameLines.length; i++) {
|
|
const sourceFrameLine = sourceFrameLines[i].trimEnd()
|
|
if (sourceFrameLine === '') {
|
|
continue
|
|
}
|
|
|
|
if (sourceFrameLine.startsWith('>')) {
|
|
// This is where the cursor will point
|
|
// Include the cursor and nothing below since it's just surrounding code.
|
|
focusedSource += '\n' + sourceFrameLine
|
|
focusedSource += '\n' + sourceFrameLines[i + 1]
|
|
break
|
|
}
|
|
const isCodeFrameLine = /^ {2}\s*\d+ \|/.test(sourceFrameLine)
|
|
if (!isCodeFrameLine) {
|
|
focusedSource += '\n' + sourceFrameLine
|
|
}
|
|
}
|
|
|
|
focusedSource = focusedSource.trim()
|
|
|
|
if (next !== null) {
|
|
focusedSource = focusedSource.replaceAll(
|
|
next.testDir,
|
|
'<FIXME-project-root>'
|
|
)
|
|
}
|
|
|
|
// This is the processed path the nextjs file from node_modules,
|
|
// likely not being processed properly and it's not deterministic among tests.
|
|
// e.g. it could be a encoded url of loader path:
|
|
// ../packages/next/dist/build/webpack/loaders/next-app-loader/index.js...
|
|
const sourceLines = focusedSource.split('\n')
|
|
if (
|
|
sourceLines[0].startsWith('./node_modules/.pnpm/next@file+') ||
|
|
sourceLines[0].startsWith('./node_modules/.pnpm/file+') ||
|
|
// e.g. "next-app-loader?<SEARCH PARAMS>" (in rspack, the loader doesn't seem to be prefixed with node_modules)
|
|
/^next-[a-zA-Z0-9\-_]+?-loader\?/.test(sourceLines[0])
|
|
) {
|
|
focusedSource = `<FIXME-nextjs-internal-source>\n${sourceLines.slice(1).join('\n')}`
|
|
}
|
|
|
|
return focusedSource
|
|
}
|
|
|
|
/**
|
|
* Sanitize stack frames: collapse internal frames, replace project root.
|
|
*/
|
|
function sanitizeStack(
|
|
stack: string[] | null,
|
|
next: NextInstance | null
|
|
): string[] | null {
|
|
if (stack === null) return null
|
|
|
|
const sanitized: string[] = []
|
|
let foundInternalFrame = false
|
|
for (const frame of stack) {
|
|
const isInternalFrame = / .\/dist\//.test(frame)
|
|
if (isInternalFrame) {
|
|
if (!foundInternalFrame) {
|
|
sanitized.push('<FIXME-internal-frame>')
|
|
}
|
|
foundInternalFrame = true
|
|
} else if (frame.includes('file://')) {
|
|
sanitized.push('<FIXME-file-protocol>')
|
|
} else if (frame.includes('.next/')) {
|
|
sanitized.push('<FIXME-next-dist-dir>')
|
|
} else if (next !== null) {
|
|
sanitized.push(frame.replace(next.testDir, '<FIXME-project-root>'))
|
|
} else {
|
|
sanitized.push(frame)
|
|
}
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
async function createErrorSnapshot(
|
|
browser: Playwright,
|
|
next: NextInstance | null,
|
|
{ label: includeLabel = true }: ErrorSnapshotOptions = {}
|
|
): Promise<ErrorSnapshot> {
|
|
const [
|
|
label,
|
|
environmentLabel,
|
|
description,
|
|
source,
|
|
stack,
|
|
componentStack,
|
|
cause,
|
|
code,
|
|
] = await Promise.all([
|
|
includeLabel ? getRedboxLabel(browser) : null,
|
|
getRedboxEnvironmentLabel(browser),
|
|
getRedboxDescription(browser),
|
|
getRedboxSource(browser),
|
|
getRedboxCallStack(browser),
|
|
getRedboxComponentStack(browser),
|
|
getRedboxCause(browser),
|
|
getRedboxErrorCode(browser),
|
|
])
|
|
|
|
// We don't need to test the codeframe logic everywhere.
|
|
// Here we focus on the cursor position of the top most frame
|
|
// From
|
|
//
|
|
// pages/index.js (3:11) @ eval
|
|
//
|
|
// 1 | export default function Page() {
|
|
// 2 | [1, 2, 3].map(() => {
|
|
// > 3 | throw new Error("anonymous error!");
|
|
// | ^
|
|
// 4 | })
|
|
// 5 | }
|
|
//
|
|
// to
|
|
//
|
|
// pages/index.js (3:11) @ Page
|
|
// > 3 | throw new Error("anonymous error!");
|
|
// | ^
|
|
const focusedSource = focusSource(source, next)
|
|
|
|
let sanitizedDescription = description
|
|
|
|
if (sanitizedDescription) {
|
|
sanitizedDescription = sanitizedDescription
|
|
.replace(/{imported module [^}]+}/, '<turbopack-module-id>')
|
|
.replace(/\w+_WEBPACK_IMPORTED_MODULE_\w+/, '<webpack-module-id>')
|
|
|
|
if (next !== null) {
|
|
sanitizedDescription = sanitizedDescription.replace(
|
|
next.testDir,
|
|
'<FIXME-project-root>'
|
|
)
|
|
}
|
|
}
|
|
|
|
const snapshot: ErrorSnapshot = {
|
|
environmentLabel,
|
|
label: label ?? '<FIXME-excluded-label>',
|
|
source: focusedSource,
|
|
stack: sanitizeStack(stack, next),
|
|
}
|
|
|
|
if (sanitizedDescription !== null) {
|
|
snapshot.description = sanitizedDescription
|
|
}
|
|
|
|
// Hydration diffs are only relevant to some specific errors
|
|
// so we hide them from the snapshots unless they are present.
|
|
if (componentStack !== null) {
|
|
snapshot.componentStack = componentStack
|
|
}
|
|
|
|
if (code !== null) {
|
|
snapshot.code = code
|
|
}
|
|
|
|
// Error.cause chain is only relevant when present.
|
|
if (cause !== null) {
|
|
snapshot.cause = cause.map((entry) => {
|
|
const causeEntry: SanitizedCauseEntry = {
|
|
label: entry.label,
|
|
source: focusSource(entry.source, next),
|
|
stack: sanitizeStack(entry.stack, next) ?? [],
|
|
}
|
|
if (entry.message !== null) {
|
|
causeEntry.message = entry.message
|
|
}
|
|
return causeEntry
|
|
})
|
|
}
|
|
|
|
return snapshot
|
|
}
|
|
|
|
export type RedboxSnapshot = ErrorSnapshot | ErrorSnapshot[]
|
|
|
|
export async function createRedboxSnapshot(
|
|
browser: Playwright,
|
|
next: NextInstance | null,
|
|
opts?: ErrorSnapshotOptions
|
|
): Promise<RedboxSnapshot> {
|
|
const errorTally = await getRedboxTotalErrorCount(browser)
|
|
const errorSnapshots: ErrorSnapshot[] = []
|
|
|
|
for (let errorIndex = 0; errorIndex < errorTally; errorIndex++) {
|
|
const errorSnapshot = await createErrorSnapshot(browser, next, opts)
|
|
errorSnapshots.push(errorSnapshot)
|
|
|
|
if (errorIndex < errorTally - 1) {
|
|
// Go to next error
|
|
await browser
|
|
.waitForElementByCss('[data-nextjs-dialog-error-next]')
|
|
.click()
|
|
// TODO: Wait for suspended content if the click triggered it.
|
|
await browser.waitForElementByCss(
|
|
`[data-nextjs-dialog-error-index="${errorIndex + 1}"]`
|
|
)
|
|
}
|
|
}
|
|
|
|
return errorSnapshots.length === 1
|
|
? // Most of the Redbox tests will just show a single error.
|
|
// We optimize display for that case.
|
|
errorSnapshots[0]
|
|
: errorSnapshots
|
|
}
|
|
|
|
expect.extend({
|
|
async toDisplayRedbox(
|
|
this: MatcherContext,
|
|
browserOrContext: Playwright | { browser: Playwright; next: NextInstance },
|
|
expectedRedboxSnapshot?: string,
|
|
opts?: ErrorSnapshotOptions
|
|
) {
|
|
let browser: Playwright
|
|
let next: NextInstance | null
|
|
if ('browser' in browserOrContext && 'next' in browserOrContext) {
|
|
browser = browserOrContext.browser
|
|
next = browserOrContext.next
|
|
} else {
|
|
browser = browserOrContext
|
|
next = null
|
|
}
|
|
|
|
// Otherwise jest uses the async stack trace which makes it impossible to know the actual callsite of `toMatchSpeechInlineSnapshot`.
|
|
// @ts-expect-error -- Not readonly
|
|
this.error = new Error()
|
|
// Abort test on first mismatch.
|
|
// Subsequent actions will be based on an incorrect state otherwise and almost always fail as well.
|
|
// TODO: Actually, we may want to proceed. Kinda nice to also do more assertions later.
|
|
this.dontThrow = () => {}
|
|
|
|
try {
|
|
await waitForRedbox(browser)
|
|
} catch (cause) {
|
|
// argument length is relevant.
|
|
// Jest will update absent snapshots but fail if you specify a snapshot even if undefined.
|
|
if (expectedRedboxSnapshot === undefined) {
|
|
return toMatchInlineSnapshot.call(this, String(cause.message))
|
|
} else {
|
|
return toMatchInlineSnapshot.call(
|
|
this,
|
|
String(cause.message),
|
|
expectedRedboxSnapshot
|
|
)
|
|
}
|
|
}
|
|
|
|
const redbox = await createRedboxSnapshot(browser, next, opts)
|
|
|
|
// argument length is relevant.
|
|
// Jest will update absent snapshots but fail if you specify a snapshot even if undefined.
|
|
if (expectedRedboxSnapshot === undefined) {
|
|
return toMatchInlineSnapshot.call(this, redbox)
|
|
} else {
|
|
return toMatchInlineSnapshot.call(this, redbox, expectedRedboxSnapshot)
|
|
}
|
|
},
|
|
async toDisplayCollapsedRedbox(
|
|
this: MatcherContext,
|
|
browserOrContext: Playwright | { browser: Playwright; next: NextInstance },
|
|
expectedRedboxSnapshot?: string,
|
|
opts?: ErrorSnapshotOptions
|
|
) {
|
|
let browser: Playwright
|
|
let next: NextInstance | null
|
|
if ('browser' in browserOrContext && 'next' in browserOrContext) {
|
|
browser = browserOrContext.browser
|
|
next = browserOrContext.next
|
|
} else {
|
|
browser = browserOrContext
|
|
next = null
|
|
}
|
|
|
|
// Otherwise jest uses the async stack trace which makes it impossible to know the actual callsite of `toMatchSpeechInlineSnapshot`.
|
|
// @ts-expect-error -- Not readonly
|
|
this.error = new Error()
|
|
// Abort test on first mismatch.
|
|
// Subsequent actions will be based on an incorrect state otherwise and almost always fail as well.
|
|
// TODO: Actually, we may want to proceed. Kinda nice to also do more assertions later.
|
|
this.dontThrow = () => {}
|
|
|
|
try {
|
|
await openRedbox(browser)
|
|
} catch (cause) {
|
|
// argument length is relevant.
|
|
// Jest will update absent snapshots but fail if you specify a snapshot even if undefined.
|
|
if (expectedRedboxSnapshot === undefined) {
|
|
return toMatchInlineSnapshot.call(
|
|
this,
|
|
String(cause.message)
|
|
// Should switch to `toDisplayRedbox` not `waitForRedbox`
|
|
.replace('waitForRedbox', 'toDisplayRedbox')
|
|
)
|
|
} else {
|
|
return toMatchInlineSnapshot.call(
|
|
this,
|
|
String(cause.message)
|
|
// Should switch to `toDisplayRedbox` not `waitForRedbox`
|
|
.replace('waitForRedbox', 'toDisplayRedbox'),
|
|
expectedRedboxSnapshot
|
|
)
|
|
}
|
|
}
|
|
|
|
const redbox = await createRedboxSnapshot(browser, next, opts)
|
|
|
|
// argument length is relevant.
|
|
// Jest will update absent snapshots but fail if you specify a snapshot even if undefined.
|
|
if (expectedRedboxSnapshot === undefined) {
|
|
return toMatchInlineSnapshot.call(this, redbox)
|
|
} else {
|
|
return toMatchInlineSnapshot.call(this, redbox, expectedRedboxSnapshot)
|
|
}
|
|
},
|
|
})
|