import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import { createProxyServer } from 'next/experimental/testmode/proxy'
import { outdent } from 'outdent'
import { createSandbox } from '../../../lib/development-sandbox'
import * as Log from './utils/log'
const runtimes = ['nodejs', 'edge']
describe.each(runtimes)('after() in %s runtime', (runtimeValue) => {
const { next, isNextDeploy, skipped } = nextTestSetup({
files: __dirname,
// `patchFile` and reading runtime logs are not supported in a deployed environment
skipDeployment: true,
})
if (skipped) return
const pathPrefix = '/' + runtimeValue
let currentCliOutputIndex = 0
const ignorePreviousLogs = () => {
currentCliOutputIndex = next.cliOutput.length
}
const resetLogIsolation = () => {
currentCliOutputIndex = 0
}
const getLogs = () => {
if (next.cliOutput.length < currentCliOutputIndex) {
// cliOutput shrank since we started the test, so something (like a `sandbox`) reset the logs
currentCliOutputIndex = 0
}
return Log.readCliLogs(next.cliOutput.slice(currentCliOutputIndex))
}
beforeEach(() => {
ignorePreviousLogs()
})
it('runs in dynamic pages', async () => {
const response = await next.fetch(pathPrefix + '/123/dynamic')
expect(response.status).toBe(200)
await retry(() => {
expect(getLogs()).toContainEqual({ source: '[layout] /[id]' })
expect(getLogs()).toContainEqual({
source: '[page] /[id]/dynamic',
value: '123',
assertions: {
'cache() works in after()': true,
},
})
})
})
it('runs in dynamic route handlers', async () => {
const res = await next.fetch(pathPrefix + '/route')
expect(res.status).toBe(200)
await retry(() => {
expect(getLogs()).toContainEqual({ source: '[route handler] /route' })
})
})
it('runs in server actions', async () => {
const browser = await next.browser(pathPrefix + '/123/with-action')
expect(getLogs()).toContainEqual({
source: '[layout] /[id]',
})
await browser.elementByCss('button[type="submit"]').click()
await retry(() => {
expect(getLogs()).toContainEqual({
source: '[action] /[id]/with-action',
value: '123',
assertions: {
// cache() does not currently work in actions, and after() shouldn't affect that
'cache() works in after()': false,
},
})
})
// TODO: server seems to close before the response fully returns?
})
it('runs callbacks from nested after calls', async () => {
await next.browser(pathPrefix + '/nested-after')
await retry(() => {
for (const id of [1, 2, 3]) {
expect(getLogs()).toContainEqual({
source: `[page] /nested-after (after #${id})`,
assertions: {
'cache() works in after()': true,
},
})
}
})
})
describe('interrupted RSC renders', () => {
// This is currently broken with Turbopack.
// https://github.com/vercel/next.js/pull/75989
it('runs callbacks if redirect() was called', async () => {
await next.browser(pathPrefix + '/interrupted/calls-redirect')
await retry(() => {
expect(getLogs()).toContainEqual({
source: '[page] /interrupted/calls-redirect',
})
expect(getLogs()).toContainEqual({
source: '[page] /interrupted/redirect-target',
})
})
})
it('runs callbacks if notFound() was called', async () => {
await next.browser(pathPrefix + '/interrupted/calls-not-found')
expect(getLogs()).toContainEqual({
source: '[page] /interrupted/calls-not-found',
})
})
it('runs callbacks if a user error was thrown in the RSC render', async () => {
await next.browser(pathPrefix + '/interrupted/throws-error')
expect(getLogs()).toContainEqual({
source: '[page] /interrupted/throws-error',
})
})
it('runs callbacks if a request is aborted before the page finishes streaming', async () => {
const abortController = new AbortController()
const res = await next.fetch(
pathPrefix + '/interrupted/incomplete-stream/hang',
{ signal: abortController.signal }
)
expect(res.status).toBe(200)
const textDecoder = new TextDecoder()
for await (const rawChunk of res.body) {
const chunk =
typeof rawChunk === 'string' ? rawChunk : textDecoder.decode(rawChunk)
// we found the loading fallback for the part that hangs forever, so we know we won't progress any further
if (chunk.includes('Loading...')) {
break
}
}
abortController.abort()
await retry(() => {
expect(getLogs()).toContainEqual({
source: '[page] /interrupted/incomplete-stream/hang',
})
})
})
it('runs callbacks if the browser disconnects before the page finishes streaming', async () => {
// `next.browser()` always waits for the `load` event, which we don't want here.
// (because the page hangs forever while streaming and will thus never fire `load`)
// but we can't easily bypass that, so go to a dummy page first
const browser = await next.browser(
pathPrefix + '/interrupted/incomplete-stream/start'
)
expect(await browser.elementByCss('h1').text()).toEqual('Start')
// navigate to a page that hangs forever while streaming...
// NOTE: this needs to be a soft navigation (using Link), playwright seems to hang otherwise
await browser.elementByCss('a').click()
await retry(async () => {
expect(await browser.hasElementByCssSelector('#loading-fallback')).toBe(
true
)
})
// ...but navigate away before streaming is finished (it hangs forever, so it will never finish)
await browser.get(
new URL(pathPrefix + '/interrupted/incomplete-stream/end', next.url)
.href
)
expect(await browser.elementByCss('h1').text()).toEqual('End')
await retry(async () => {
expect(getLogs()).toContainEqual({
source: '[page] /interrupted/incomplete-stream/hang',
})
})
})
})
it('runs in middleware', async () => {
const requestId = `${Date.now()}`
const res = await next.fetch(
pathPrefix + `/middleware/redirect-source?requestId=${requestId}`,
{
redirect: 'follow',
headers: {
cookie: 'testCookie=testValue',
},
}
)
expect(res.status).toBe(200)
await retry(() => {
expect(getLogs()).toContainEqual({
source: '[middleware] /middleware/redirect-source',
requestId,
cookies: { testCookie: 'testValue' },
})
})
})
if (!isNextDeploy) {
it('only runs callbacks after the response is fully sent', async () => {
const pageStartedFetching = promiseWithResolvers<void>()
pageStartedFetching.promise.catch(() => {})
const shouldSendResponse = promiseWithResolvers<void>()
shouldSendResponse.promise.catch(() => {})
const abort = (error: Error) => {
pageStartedFetching.reject(
new Error('pageStartedFetching was aborted', { cause: error })
)
shouldSendResponse.reject(
new Error('shouldSendResponse was aborted', {
cause: error,
})
)
}
const proxyServer = await createProxyServer({
async onFetch(_, request) {
if (request.url === 'https://example.test/delayed-request') {
pageStartedFetching.resolve()
await shouldSendResponse.promise
return new Response('')
}
},
})
try {
const pendingReq = next
.fetch(pathPrefix + '/delay', {
headers: { 'Next-Test-Proxy-Port': String(proxyServer.port) },
})
.then(
async (res) => {
if (res.status !== 200) {
const err = new Error(
`Got non-200 response (${res.status}) for ${res.url}, aborting`
)
abort(err)
throw err
}
return res
},
(err) => {
abort(err)
throw err
}
)
await Promise.race([
pageStartedFetching.promise,
pendingReq, // if the page throws before it starts fetching, we want to catch that
timeoutPromise(
10_000,
'Timeout while waiting for the page to call fetch'
),
])
// we blocked the request from completing, so there should be no logs yet,
// because after() shouldn't run callbacks until the request is finished.
expect(getLogs()).not.toContainEqual({
source: '[page] /delay (Page)',
})
expect(getLogs()).not.toContainEqual({
source: '[page] /delay (Inner)',
})
shouldSendResponse.resolve()
await pendingReq.then((res) => res.text())
// the request is finished, so after() should run, and the logs should appear now.
await retry(() => {
expect(getLogs()).toContainEqual({
source: '[page] /delay (Page)',
})
expect(getLogs()).toContainEqual({
source: '[page] /delay (Inner)',
})
})
} finally {
proxyServer.close()
}
})
}
it('runs in generateMetadata()', async () => {
await next.browser(pathPrefix + '/123/with-metadata')
expect(getLogs()).toContainEqual({
source: '[metadata] /[id]/with-metadata',
value: '123',
})
})
it('does not allow modifying cookies in a callback', async () => {
const EXPECTED_ERROR =
/An error occurred in a function passed to `after\(\)`: .+?: Cookies can only be modified in a Server Action or Route Handler\./
const browser = await next.browser(pathPrefix + '/123/setting-cookies')
// after() from render
expect(next.cliOutput).toMatch(EXPECTED_ERROR)
const cookie1 = await browser.elementById('cookie').text()
expect(cookie1).toEqual('Cookie: null')
const cliOutputIndex = next.cliOutput.length
try {
await browser.elementByCss('button[type="submit"]').click()
await retry(async () => {
const cookie1 = await browser.elementById('cookie').text()
expect(cookie1).toEqual('Cookie: "action"')
const newLogs = next.cliOutput.slice(cliOutputIndex)
// // after() from action
expect(newLogs).toMatch(EXPECTED_ERROR)
})
} finally {
await browser.eval('document.cookie = "testCookie=;path=/;max-age=-1"')
}
})
describe('uses waitUntil from request context if available', () => {
it.each([
{
name: 'in a page',
path: '/provided-request-context/page',
expectedLog: { source: '[page] /provided-request-context/page' },
},
{
name: 'in a route handler',
path: '/provided-request-context/route',
expectedLog: {
source: '[route handler] /provided-request-context/route',
},
},
{
name: 'in middleware',
path: '/provided-request-context/middleware',
expectedLog: {
source: '[middleware] /provided-request-context/middleware',
},
},
])('$name', async ({ path, expectedLog }) => {
resetLogIsolation() // sandbox resets `next.cliOutput` to empty
await using _sandbox = await createSandbox(
next,
new Map([
[
// this needs to be injected as early as possible, before the server tries to read the context
// (which may be even before we load the page component in dev mode)
'instrumentation.js',
outdent`
import { injectRequestContext } from './utils/provided-request-context'
export function register() {
if (process.env.NEXT_RUNTIME === 'edge') {
// these tests only run 'next dev/start', and for edge things,
// instrumentation runs *again* inside the sandbox.
// we don't want that, because the sandbox wouldn't have access to globals from outside
// and thus wouldn't normally see the request context
return;
}
injectRequestContext();
}
`,
],
])
)
await next.browser(pathPrefix + path)
await retry(() => {
const logs = getLogs()
expect(logs).toContainEqual(
'waitUntil from "@next/request-context" was called'
)
expect(logs).toContainEqual(expectedLog)
})
})
})
})
function promiseWithResolvers<T>() {
let resolve: (value: T) => void = undefined!
let reject: (error: unknown) => void = undefined!
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return { promise, resolve, reject }
}
function timeoutPromise(duration: number, message = 'Timeout') {
return new Promise<never>((_, reject) =>
AbortSignal.timeout(duration).addEventListener('abort', () =>
reject(new Error(message))
)
)
}