Skip to content

Commit

Permalink
Add internal toDisplayRedbox and toDisplayCollapsedRedbox inline …
Browse files Browse the repository at this point in the history
…snapshot matchers

These are meant to replace `assertHasRedbox` over time.
We want that every redbox insertion in the future asserts on the full error (message, stack, codeframe) in both browser
and terminal.

We'll slowly expand usage of these matchers until all use cases are covered at which point the old, granular helpers are removed.

The end goal is full confidence in our error display without sacrificing DX for people focused on the error message itself.

The downside of inline snapshot matcher that we can't have fine-grained TODO comments.
But that's only a concern for the few working on working on the error display infra.
The goal here is to encourage using these helpers so the priorities of the few working on error infra is lowest.

The most annoying fact is the need for forking assertions between Turbopack and Webpack.
All the more reason for us to fix the off-by-one column issues between Turbopack and Webpack.
  • Loading branch information
eps1lon committed Dec 10, 2024
1 parent 09aa303 commit ddc4812
Show file tree
Hide file tree
Showing 7 changed files with 221 additions and 41 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
"eslint-v8": "npm:eslint@^8.57.0",
"event-stream": "4.0.1",
"execa": "2.0.3",
"expect": "29.7.0",
"expect-type": "0.14.2",
"express": "4.17.0",
"faker": "5.5.3",
Expand All @@ -176,6 +177,7 @@
"jest-environment-jsdom": "29.7.0",
"jest-extended": "4.0.2",
"jest-junit": "16.0.0",
"jest-snapshot": "30.0.0-alpha.6",
"json5": "2.2.3",
"kleur": "^4.1.0",
"ky": "0.19.1",
Expand Down
43 changes: 12 additions & 31 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,39 @@ describe('Dynamic IO Dev Errors', () => {
it('should show a red box error on the SSR render', async () => {
const browser = await next.browser('/error')

await openRedbox(browser)

expect(await getRedboxDescription(browser)).toMatchInlineSnapshot(
`"[ Server ] Error: Route "/error" used \`Math.random()\` outside of \`"use cache"\` and without explicitly calling \`await connection()\` beforehand. See more info here: https://nextjs.org/docs/messages/next-prerender-random"`
)
if (isTurbopack) {
await expect(browser).toDisplayCollapsedRedbox(`
{
"description": "[ Server ] Error: Route "/error" used \`Math.random()\` outside of \`"use cache"\` and without explicitly calling \`await connection()\` beforehand. See more info here: https://nextjs.org/docs/messages/next-prerender-random",
"source": "app/error/page.tsx (2:23) @ Page
1 | export default async function Page() {
> 2 | const random = Math.random()
| ^
3 | return <div id="another-random">{random}</div>
4 | }
5 |",
"stack": "JSON.parse
<anonymous> (0:0)",
}
`)
} else {
await expect(browser).toDisplayCollapsedRedbox(`
{
"description": "[ Server ] Error: Route "/error" used \`Math.random()\` outside of \`"use cache"\` and without explicitly calling \`await connection()\` beforehand. See more info here: https://nextjs.org/docs/messages/next-prerender-random",
"source": "app/error/page.tsx (2:23) @ random
1 | export default async function Page() {
> 2 | const random = Math.random()
| ^
3 | return <div id="another-random">{random}</div>
4 | }
5 |",
"stack": "JSON.parse
<anonymous> (0:0)",
}
`)
}
})

it('should show a red box error on client navigations', async () => {
Expand Down Expand Up @@ -137,8 +165,6 @@ describe('Dynamic IO Dev Errors', () => {
`
)

await retry(async () => {
assertNoRedbox(browser)
})
await assertNoRedbox(browser)
})
})
38 changes: 37 additions & 1 deletion test/e2e/app-dir/server-source-maps/server-source-maps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe('app-dir - server source maps', () => {

it('thrown SSR errors', async () => {
const outputIndex = next.cliOutput.length
await next.render('/ssr-throw')
const browser = await next.browser('/ssr-throw')

if (isNextDev) {
await retry(() => {
Expand Down Expand Up @@ -198,6 +198,42 @@ describe('app-dir - server source maps', () => {
"\n digest: '"
)
expect(cliOutput).toMatch(/digest: '\d+'/)

if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"description": "Error: Boom",
"source": "app/ssr-throw/Thrower.js (4:9) @ throwError
2 |
3 | function throwError() {
> 4 | throw new Error('Boom')
| ^
5 | }
6 |
7 | export function Thrower() {",
"stack": "Thrower
app/ssr-throw/Thrower.js (8:3)",
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"description": "Error: Boom",
"source": "app/ssr-throw/Thrower.js (4:9) @ throwError
2 |
3 | function throwError() {
> 4 | throw new Error('Boom')
| ^
5 | }
6 |
7 | export function Thrower() {",
"stack": "throwError
app/ssr-throw/Thrower.js (8:3)",
}
`)
}
} else {
// TODO: Test `next build` with `--enable-source-maps`.
}
Expand Down
1 change: 1 addition & 0 deletions test/jest-setup-after-env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-ignore
import * as matchers from 'jest-extended'
import '@testing-library/jest-dom'
import './lib/add-redbox-matchers'
expect.extend(matchers)

// A default max-timeout of 90 seconds is allowed
Expand Down
132 changes: 132 additions & 0 deletions test/lib/add-redbox-matchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { MatcherContext } from 'expect'
import { toMatchInlineSnapshot } from 'jest-snapshot'
import {
assertHasRedbox,
getRedboxCallStack,
getRedboxDescription,
getRedboxSource,
openRedbox,
} from './next-test-utils'
import type { BrowserInterface } from './browsers/base'

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.
* @param inlineSnapshot - The snapshot to compare against.
*/
toDisplayRedbox(inlineSnapshot?: string): Promise<void>

/**
* Inline snapshot matcher for a Redbox that's collapsed by default.
* When a Redbox is immediately displayed ,
* use {@link toDisplayRedbox} instead.
* @param inlineSnapshot - The snapshot to compare against.
*/
toDisplayCollapsedRedbox(inlineSnapshot?: string): Promise<void>
}
}
}

interface RedboxSnapshot {
stack: string
description: string
}

async function createRedboxSnaspshot(
browser: BrowserInterface
): Promise<RedboxSnapshot> {
const redbox = {
description: await getRedboxDescription(browser).catch(() => '<empty>'),
source: await getRedboxSource(browser).catch(() => '<empty>'),
stack: await getRedboxCallStack(browser).catch(() => '<empty>'),
// TODO: message, etc.
}

return redbox
}

expect.extend({
async toDisplayRedbox(
this: MatcherContext,
browser: BrowserInterface,
expectedRedboxSnapshot?: string
) {
// 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 assertHasRedbox(browser)
} catch {
// 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, '<no redbox found>')
} else {
return toMatchInlineSnapshot.call(
this,
'<no redbox found>',
expectedRedboxSnapshot
)
}
}

const redbox = await createRedboxSnaspshot(browser)

// 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,
browser: BrowserInterface,
expectedRedboxSnapshot?: string
) {
// 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 {
// 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, '<no redbox to open>')
} else {
return toMatchInlineSnapshot.call(
this,
'<no redbox to open>',
expectedRedboxSnapshot
)
}
}

const redbox = await createRedboxSnaspshot(browser)

// 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)
}
},
})
Loading

0 comments on commit ddc4812

Please sign in to comment.