Skip to content

Commit ddc4812

Browse files
committed
Add internal toDisplayRedbox and toDisplayCollapsedRedbox inline 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.
1 parent 09aa303 commit ddc4812

File tree

7 files changed

+221
-41
lines changed

7 files changed

+221
-41
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@
154154
"eslint-v8": "npm:eslint@^8.57.0",
155155
"event-stream": "4.0.1",
156156
"execa": "2.0.3",
157+
"expect": "29.7.0",
157158
"expect-type": "0.14.2",
158159
"express": "4.17.0",
159160
"faker": "5.5.3",
@@ -176,6 +177,7 @@
176177
"jest-environment-jsdom": "29.7.0",
177178
"jest-extended": "4.0.2",
178179
"jest-junit": "16.0.0",
180+
"jest-snapshot": "30.0.0-alpha.6",
179181
"json5": "2.2.3",
180182
"kleur": "^4.1.0",
181183
"ky": "0.19.1",

pnpm-lock.yaml

Lines changed: 12 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/development/app-dir/dynamic-io-dev-errors/dynamic-io-dev-errors.test.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,39 @@ describe('Dynamic IO Dev Errors', () => {
2222
it('should show a red box error on the SSR render', async () => {
2323
const browser = await next.browser('/error')
2424

25-
await openRedbox(browser)
26-
27-
expect(await getRedboxDescription(browser)).toMatchInlineSnapshot(
28-
`"[ 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"`
29-
)
25+
if (isTurbopack) {
26+
await expect(browser).toDisplayCollapsedRedbox(`
27+
{
28+
"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",
29+
"source": "app/error/page.tsx (2:23) @ Page
30+
31+
1 | export default async function Page() {
32+
> 2 | const random = Math.random()
33+
| ^
34+
3 | return <div id="another-random">{random}</div>
35+
4 | }
36+
5 |",
37+
"stack": "JSON.parse
38+
<anonymous> (0:0)",
39+
}
40+
`)
41+
} else {
42+
await expect(browser).toDisplayCollapsedRedbox(`
43+
{
44+
"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",
45+
"source": "app/error/page.tsx (2:23) @ random
46+
47+
1 | export default async function Page() {
48+
> 2 | const random = Math.random()
49+
| ^
50+
3 | return <div id="another-random">{random}</div>
51+
4 | }
52+
5 |",
53+
"stack": "JSON.parse
54+
<anonymous> (0:0)",
55+
}
56+
`)
57+
}
3058
})
3159

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

140-
await retry(async () => {
141-
assertNoRedbox(browser)
142-
})
168+
await assertNoRedbox(browser)
143169
})
144170
})

test/e2e/app-dir/server-source-maps/server-source-maps.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ describe('app-dir - server source maps', () => {
163163

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

168168
if (isNextDev) {
169169
await retry(() => {
@@ -198,6 +198,42 @@ describe('app-dir - server source maps', () => {
198198
"\n digest: '"
199199
)
200200
expect(cliOutput).toMatch(/digest: '\d+'/)
201+
202+
if (isTurbopack) {
203+
await expect(browser).toDisplayRedbox(`
204+
{
205+
"description": "Error: Boom",
206+
"source": "app/ssr-throw/Thrower.js (4:9) @ throwError
207+
208+
2 |
209+
3 | function throwError() {
210+
> 4 | throw new Error('Boom')
211+
| ^
212+
5 | }
213+
6 |
214+
7 | export function Thrower() {",
215+
"stack": "Thrower
216+
app/ssr-throw/Thrower.js (8:3)",
217+
}
218+
`)
219+
} else {
220+
await expect(browser).toDisplayRedbox(`
221+
{
222+
"description": "Error: Boom",
223+
"source": "app/ssr-throw/Thrower.js (4:9) @ throwError
224+
225+
2 |
226+
3 | function throwError() {
227+
> 4 | throw new Error('Boom')
228+
| ^
229+
5 | }
230+
6 |
231+
7 | export function Thrower() {",
232+
"stack": "throwError
233+
app/ssr-throw/Thrower.js (8:3)",
234+
}
235+
`)
236+
}
201237
} else {
202238
// TODO: Test `next build` with `--enable-source-maps`.
203239
}

test/jest-setup-after-env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @ts-ignore
22
import * as matchers from 'jest-extended'
33
import '@testing-library/jest-dom'
4+
import './lib/add-redbox-matchers'
45
expect.extend(matchers)
56

67
// A default max-timeout of 90 seconds is allowed

test/lib/add-redbox-matchers.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { MatcherContext } from 'expect'
2+
import { toMatchInlineSnapshot } from 'jest-snapshot'
3+
import {
4+
assertHasRedbox,
5+
getRedboxCallStack,
6+
getRedboxDescription,
7+
getRedboxSource,
8+
openRedbox,
9+
} from './next-test-utils'
10+
import type { BrowserInterface } from './browsers/base'
11+
12+
declare global {
13+
namespace jest {
14+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- module augmentation needs to match generic params even if unused
15+
interface Matchers<R> {
16+
/**
17+
* Inline snapshot matcher for a Redbox that's popped up by default.
18+
* When a Redbox is hidden at first and requires manual display by clicking the toast,
19+
* use {@link toDisplayCollapsedRedbox} instead.
20+
* @param inlineSnapshot - The snapshot to compare against.
21+
*/
22+
toDisplayRedbox(inlineSnapshot?: string): Promise<void>
23+
24+
/**
25+
* Inline snapshot matcher for a Redbox that's collapsed by default.
26+
* When a Redbox is immediately displayed ,
27+
* use {@link toDisplayRedbox} instead.
28+
* @param inlineSnapshot - The snapshot to compare against.
29+
*/
30+
toDisplayCollapsedRedbox(inlineSnapshot?: string): Promise<void>
31+
}
32+
}
33+
}
34+
35+
interface RedboxSnapshot {
36+
stack: string
37+
description: string
38+
}
39+
40+
async function createRedboxSnaspshot(
41+
browser: BrowserInterface
42+
): Promise<RedboxSnapshot> {
43+
const redbox = {
44+
description: await getRedboxDescription(browser).catch(() => '<empty>'),
45+
source: await getRedboxSource(browser).catch(() => '<empty>'),
46+
stack: await getRedboxCallStack(browser).catch(() => '<empty>'),
47+
// TODO: message, etc.
48+
}
49+
50+
return redbox
51+
}
52+
53+
expect.extend({
54+
async toDisplayRedbox(
55+
this: MatcherContext,
56+
browser: BrowserInterface,
57+
expectedRedboxSnapshot?: string
58+
) {
59+
// Otherwise jest uses the async stack trace which makes it impossible to know the actual callsite of `toMatchSpeechInlineSnapshot`.
60+
// @ts-expect-error -- Not readonly
61+
this.error = new Error()
62+
// Abort test on first mismatch.
63+
// Subsequent actions will be based on an incorrect state otherwise and almost always fail as well.
64+
// TODO: Actually, we may want to proceed. Kinda nice to also do more assertions later.
65+
this.dontThrow = () => {}
66+
67+
try {
68+
await assertHasRedbox(browser)
69+
} catch {
70+
// argument length is relevant.
71+
// Jest will update absent snapshots but fail if you specify a snapshot even if undefined.
72+
if (expectedRedboxSnapshot === undefined) {
73+
return toMatchInlineSnapshot.call(this, '<no redbox found>')
74+
} else {
75+
return toMatchInlineSnapshot.call(
76+
this,
77+
'<no redbox found>',
78+
expectedRedboxSnapshot
79+
)
80+
}
81+
}
82+
83+
const redbox = await createRedboxSnaspshot(browser)
84+
85+
// argument length is relevant.
86+
// Jest will update absent snapshots but fail if you specify a snapshot even if undefined.
87+
if (expectedRedboxSnapshot === undefined) {
88+
return toMatchInlineSnapshot.call(this, redbox)
89+
} else {
90+
return toMatchInlineSnapshot.call(this, redbox, expectedRedboxSnapshot)
91+
}
92+
},
93+
async toDisplayCollapsedRedbox(
94+
this: MatcherContext,
95+
browser: BrowserInterface,
96+
expectedRedboxSnapshot?: string
97+
) {
98+
// Otherwise jest uses the async stack trace which makes it impossible to know the actual callsite of `toMatchSpeechInlineSnapshot`.
99+
// @ts-expect-error -- Not readonly
100+
this.error = new Error()
101+
// Abort test on first mismatch.
102+
// Subsequent actions will be based on an incorrect state otherwise and almost always fail as well.
103+
// TODO: Actually, we may want to proceed. Kinda nice to also do more assertions later.
104+
this.dontThrow = () => {}
105+
106+
try {
107+
await openRedbox(browser)
108+
} catch {
109+
// argument length is relevant.
110+
// Jest will update absent snapshots but fail if you specify a snapshot even if undefined.
111+
if (expectedRedboxSnapshot === undefined) {
112+
return toMatchInlineSnapshot.call(this, '<no redbox to open>')
113+
} else {
114+
return toMatchInlineSnapshot.call(
115+
this,
116+
'<no redbox to open>',
117+
expectedRedboxSnapshot
118+
)
119+
}
120+
}
121+
122+
const redbox = await createRedboxSnaspshot(browser)
123+
124+
// argument length is relevant.
125+
// Jest will update absent snapshots but fail if you specify a snapshot even if undefined.
126+
if (expectedRedboxSnapshot === undefined) {
127+
return toMatchInlineSnapshot.call(this, redbox)
128+
} else {
129+
return toMatchInlineSnapshot.call(this, redbox, expectedRedboxSnapshot)
130+
}
131+
},
132+
})

0 commit comments

Comments
 (0)