diff --git a/test/e2e/on-request-error/_testing/utils.js b/test/e2e/on-request-error/_testing/utils.js new file mode 100644 index 0000000000000..00c17414ee643 --- /dev/null +++ b/test/e2e/on-request-error/_testing/utils.js @@ -0,0 +1,7 @@ +export async function getOutputLogJson(next, outputLogPath) { + if (!(await next.hasFile(outputLogPath))) { + return {} + } + const content = await next.readFile(outputLogPath) + return JSON.parse(content) +} diff --git a/test/e2e/on-request-error/basic/basic.test.ts b/test/e2e/on-request-error/basic/basic.test.ts index bcb350c7f37c2..3e018fbf84037 100644 --- a/test/e2e/on-request-error/basic/basic.test.ts +++ b/test/e2e/on-request-error/basic/basic.test.ts @@ -1,5 +1,6 @@ import { nextTestSetup } from 'e2e-utils' import { retry } from 'next-test-utils' +import { getOutputLogJson } from '../_testing/utils' describe('on-request-error - basic', () => { const { next, skipped } = nextTestSetup({ @@ -16,14 +17,6 @@ describe('on-request-error - basic', () => { const outputLogPath = 'output-log.json' - async function getOutputLogJson() { - if (!(await next.hasFile(outputLogPath))) { - return {} - } - const content = await next.readFile(outputLogPath) - return JSON.parse(content) - } - async function validateErrorRecord({ errorMessage, url, @@ -37,14 +30,15 @@ describe('on-request-error - basic', () => { }) { // Assert the instrumentation is called await retry(async () => { - const recordLogs = next.cliOutput + const recordLogLines = next.cliOutput .split('\n') .filter((log) => log.includes('[instrumentation] write-log')) - const expectedLog = recordLogs.find((log) => log.includes(errorMessage)) - expect(expectedLog).toBeDefined() + expect(recordLogLines).toEqual( + expect.arrayContaining([expect.stringContaining(errorMessage)]) + ) }, 5000) - const json = await getOutputLogJson() + const json = await getOutputLogJson(next, outputLogPath) const record = json[errorMessage] const { payload } = record diff --git a/test/e2e/on-request-error/dynamic-routes/app/app-page/dynamic/[id]/page.js b/test/e2e/on-request-error/dynamic-routes/app/app-page/dynamic/[id]/page.js new file mode 100644 index 0000000000000..72230748dac61 --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/app/app-page/dynamic/[id]/page.js @@ -0,0 +1,5 @@ +export default function Page() { + throw new Error('server-dynamic-page-node-error') +} + +export const dynamic = 'force-dynamic' diff --git a/test/e2e/on-request-error/dynamic-routes/app/app-page/suspense/page.js b/test/e2e/on-request-error/dynamic-routes/app/app-page/suspense/page.js new file mode 100644 index 0000000000000..7b1a46fe1650c --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/app/app-page/suspense/page.js @@ -0,0 +1,18 @@ +import { Suspense } from 'react' + +export default function Page() { + return ( + + + + ) +} + +function Inner() { + if (typeof window === 'undefined') { + throw new Error('server-suspense-page-node-error') + } + return 'inner' +} + +export const dynamic = 'force-dynamic' diff --git a/test/e2e/on-request-error/dynamic-routes/app/app-route/dynamic/[id]/route.js b/test/e2e/on-request-error/dynamic-routes/app/app-route/dynamic/[id]/route.js new file mode 100644 index 0000000000000..8a0aa34f4d3c3 --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/app/app-route/dynamic/[id]/route.js @@ -0,0 +1,5 @@ +export function GET() { + throw new Error('server-dynamic-route-node-error') +} + +export const dynamic = 'force-dynamic' diff --git a/test/e2e/on-request-error/dynamic-routes/app/layout.js b/test/e2e/on-request-error/dynamic-routes/app/layout.js new file mode 100644 index 0000000000000..8525f5f8c0b2a --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/on-request-error/dynamic-routes/app/write-log/route.js b/test/e2e/on-request-error/dynamic-routes/app/write-log/route.js new file mode 100644 index 0000000000000..62b11b6617cd8 --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/app/write-log/route.js @@ -0,0 +1,40 @@ +import fs from 'fs' +import fsp from 'fs/promises' +import path from 'path' + +const dir = path.join(path.dirname(new URL(import.meta.url).pathname), '../..') +const logPath = path.join(dir, 'output-log.json') + +export async function POST(req) { + let payloadString = '' + const decoder = new TextDecoder() + const reader = req.clone().body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + + payloadString += decoder.decode(value) + } + + const payload = JSON.parse(payloadString) + + const json = fs.existsSync(logPath) + ? JSON.parse(await fsp.readFile(logPath, 'utf8')) + : {} + + if (!json[payload.message]) { + json[payload.message] = { + payload, + count: 1, + } + } else { + json[payload.message].count++ + } + + await fsp.writeFile(logPath, JSON.stringify(json, null, 2), 'utf8') + + console.log(`[instrumentation] write-log:${payload.message}`) + return new Response(null, { status: 204 }) +} diff --git a/test/e2e/on-request-error/dynamic-routes/dynamic-routes.test.ts b/test/e2e/on-request-error/dynamic-routes/dynamic-routes.test.ts new file mode 100644 index 0000000000000..d2f0e98a02379 --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/dynamic-routes.test.ts @@ -0,0 +1,147 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' +import { getOutputLogJson } from '../_testing/utils' + +describe('on-request-error - dynamic-routes', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + env: { + __NEXT_EXPERIMENTAL_INSTRUMENTATION: '1', + }, + }) + + if (skipped) { + return + } + + const outputLogPath = 'output-log.json' + + async function getErrorRecord({ errorMessage }: { errorMessage: string }) { + // Assert the instrumentation is called + await retry(async () => { + const recordLogLines = next.cliOutput + .split('\n') + .filter((log) => log.includes('[instrumentation] write-log')) + expect(recordLogLines).toEqual( + expect.arrayContaining([expect.stringContaining(errorMessage)]) + ) + }, 5000) + + const json = await getOutputLogJson(next, outputLogPath) + const record = json[errorMessage] + + return record + } + + beforeAll(async () => { + await next.patchFile(outputLogPath, '{}') + }) + + describe('app router', () => { + it('should catch app router dynamic page error with search params', async () => { + await next.fetch('/app-page/dynamic/123?apple=dope') + const record = await getErrorRecord({ + errorMessage: 'server-dynamic-page-node-error', + }) + expect(record).toMatchObject({ + payload: { + message: 'server-dynamic-page-node-error', + request: { + url: '/app-page/dynamic/123?apple=dope', + }, + context: { + routerKind: 'App Router', + routeType: 'render', + routePath: '/app-page/dynamic/[id]', + }, + }, + }) + }) + + it('should catch app router dynamic routes error with search params', async () => { + await next.fetch('/app-route/dynamic/123?apple=dope') + const record = await getErrorRecord({ + errorMessage: 'server-dynamic-route-node-error', + }) + expect(record).toMatchObject({ + payload: { + message: 'server-dynamic-route-node-error', + request: { + url: '/app-route/dynamic/123?apple=dope', + }, + context: { + routerKind: 'App Router', + routeType: 'route', + routePath: '/app-route/dynamic/[id]', + }, + }, + }) + }) + + it('should catch suspense rendering page error in node runtime', async () => { + await next.fetch('/app-page/suspense') + const record = await getErrorRecord({ + errorMessage: 'server-suspense-page-node-error', + }) + + expect(record).toMatchObject({ + payload: { + message: 'server-suspense-page-node-error', + request: { + url: '/app-page/suspense', + }, + context: { + routerKind: 'App Router', + routeType: 'render', + routePath: '/app-page/suspense', + }, + }, + }) + }) + }) + + describe('pages router', () => { + it('should catch pages router dynamic page error with search params', async () => { + await next.fetch('/pages-page/dynamic/123?apple=dope') + const record = await getErrorRecord({ + errorMessage: 'pages-page-node-error', + }) + + expect(record).toMatchObject({ + payload: { + message: 'pages-page-node-error', + request: { + url: '/pages-page/dynamic/123?apple=dope', + }, + context: { + routerKind: 'Pages Router', + routeType: 'render', + routePath: '/pages-page/dynamic/[id]', + }, + }, + }) + }) + + it('should catch pages router dynamic API route error with search params', async () => { + await next.fetch('/api/dynamic/123?apple=dope') + const record = await getErrorRecord({ + errorMessage: 'pages-api-node-error', + }) + + expect(record).toMatchObject({ + payload: { + message: 'pages-api-node-error', + request: { + url: '/api/dynamic/123?apple=dope', + }, + context: { + routerKind: 'Pages Router', + routeType: 'route', + routePath: '/api/dynamic/[id]', + }, + }, + }) + }) + }) +}) diff --git a/test/e2e/on-request-error/dynamic-routes/instrumentation.js b/test/e2e/on-request-error/dynamic-routes/instrumentation.js new file mode 100644 index 0000000000000..b9944b351236c --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/instrumentation.js @@ -0,0 +1,13 @@ +export function onRequestError(err, request, context) { + fetch(`http://localhost:${process.env.PORT}/write-log`, { + method: 'POST', + body: JSON.stringify({ + message: err.message, + request, + context, + }), + headers: { + 'Content-Type': 'application/json', + }, + }) +} diff --git a/test/e2e/on-request-error/dynamic-routes/next.config.js b/test/e2e/on-request-error/dynamic-routes/next.config.js new file mode 100644 index 0000000000000..c4cf84a76553b --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + instrumentationHook: true, + }, +} diff --git a/test/e2e/on-request-error/dynamic-routes/pages/api/dynamic/[id].js b/test/e2e/on-request-error/dynamic-routes/pages/api/dynamic/[id].js new file mode 100644 index 0000000000000..7c25dccfe8f2f --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/pages/api/dynamic/[id].js @@ -0,0 +1,3 @@ +export default function handler() { + throw new Error('pages-api-node-error') +} diff --git a/test/e2e/on-request-error/dynamic-routes/pages/pages-page/dynamic/[id].js b/test/e2e/on-request-error/dynamic-routes/pages/pages-page/dynamic/[id].js new file mode 100644 index 0000000000000..95eedf546498b --- /dev/null +++ b/test/e2e/on-request-error/dynamic-routes/pages/pages-page/dynamic/[id].js @@ -0,0 +1,9 @@ +export default function Page() { + throw new Error('pages-page-node-error') +} + +export function getServerSideProps() { + return { + props: {}, + } +} diff --git a/test/e2e/on-request-error/server-action-error/server-action-error.test.ts b/test/e2e/on-request-error/server-action-error/server-action-error.test.ts index 492759a3a0cc0..b092494fcfc7a 100644 --- a/test/e2e/on-request-error/server-action-error/server-action-error.test.ts +++ b/test/e2e/on-request-error/server-action-error/server-action-error.test.ts @@ -1,5 +1,6 @@ import { nextTestSetup } from 'e2e-utils' import { retry } from 'next-test-utils' +import { getOutputLogJson } from '../_testing/utils' describe('on-request-error - server-action-error', () => { const { next, skipped } = nextTestSetup({ @@ -16,14 +17,6 @@ describe('on-request-error - server-action-error', () => { const outputLogPath = 'output-log.json' - async function getOutputLogJson() { - if (!(await next.hasFile(outputLogPath))) { - return {} - } - const content = await next.readFile(outputLogPath) - return JSON.parse(content) - } - async function validateErrorRecord({ errorMessage, url, @@ -42,7 +35,7 @@ describe('on-request-error - server-action-error', () => { ) }, 5000) - const json = await getOutputLogJson() + const json = await getOutputLogJson(next, outputLogPath) const record = json[errorMessage] // Assert error is recorded in the output log