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
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
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))
|
|
)
|
|
)
|
|
}
|