rsnext/test/e2e/app-dir/app-routes/app-custom-routes.test.ts
Quentin abe8b1e0a8
Improve performance of String.prototype.split uses (#56746)
This PR adds the optional `limit` parameter on String.prototype.split uses.

> If provided, splits the string at each occurrence of the specified separator, but stops when limit entries have been placed in the array. Any leftover text is not included in the array at all.

[MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split#syntax)

While the performance gain may not be significant for small texts, it can be huge for large ones.

I made a benchmark on the following repository : https://github.com/Yovach/benchmark-nodejs

On my machine, I get the following results:
`node index.js`
> normal 1: 570.092ms
> normal 50: 2.284s
> normal 100: 3.543s

`node index-optimized.js`
> optmized 1: 644.301ms
> optmized 50: 929.39ms
> optmized 100: 1.020s

The "benchmarks" numbers are : 
- "lorem-1" file contains 1 paragraph of "lorem ipsum"
- "lorem-50" file contains 50 paragraphes of "lorem ipsum"
- "lorem-100" file contains 100 paragraphes of "lorem ipsum"
2023-10-19 00:25:15 +00:00

650 lines
20 KiB
TypeScript

import { createNextDescribe } from 'e2e-utils'
import { check } from 'next-test-utils'
import { Readable } from 'stream'
import {
withRequestMeta,
getRequestMeta,
cookieWithRequestMeta,
} from './helpers'
const basePath = process.env.BASE_PATH ?? ''
createNextDescribe(
'app-custom-routes',
{
files: __dirname,
},
({ next, isNextDeploy, isNextDev, isNextStart }) => {
describe('works with api prefix correctly', () => {
it('statically generates correctly with no dynamic usage', async () => {
if (isNextStart) {
expect(
await next.readFile('.next/server/app/api/hello.json.body')
).toBeTruthy()
expect(
await next.readFile('.next/server/app/api/hello.json.meta')
).toBeTruthy()
}
expect(
JSON.parse(await next.render(basePath + '/api/hello.json'))
).toEqual({
pathname: '/api/hello.json',
})
})
it('does not statically generate with dynamic usage', async () => {
if (isNextStart) {
expect(
await next
.readFile('.next/server/app/api/dynamic.body')
.catch(() => '')
).toBeFalsy()
expect(
await next
.readFile('.next/server/app/api/dynamic.meta')
.catch(() => '')
).toBeFalsy()
}
expect(
JSON.parse(await next.render(basePath + '/api/dynamic'))
).toEqual({
pathname: '/api/dynamic',
query: {},
})
})
})
describe('works with generateStaticParams correctly', () => {
it.each([
'/static/first/data.json',
'/static/second/data.json',
'/static/three/data.json',
])('responds correctly on %s', async (path) => {
expect(JSON.parse(await next.render(basePath + path))).toEqual({
params: { slug: path.split('/', 3)[2] },
now: expect.any(Number),
})
if (isNextStart) {
await check(async () => {
expect(
await next.readFile(`.next/server/app/${path}.body`)
).toBeTruthy()
expect(
await next.readFile(`.next/server/app/${path}.meta`)
).toBeTruthy()
return 'success'
}, 'success')
}
})
it.each([
'/revalidate-1/first/data.json',
'/revalidate-1/second/data.json',
'/revalidate-1/three/data.json',
])('revalidates correctly on %s', async (path) => {
const data = JSON.parse(await next.render(basePath + path))
expect(data).toEqual({
params: { slug: path.split('/', 3)[2] },
now: expect.any(Number),
})
await check(async () => {
expect(data).not.toEqual(
JSON.parse(await next.render(basePath + path))
)
return 'success'
}, 'success')
if (isNextStart) {
await check(async () => {
expect(
await next.readFile(`.next/server/app/${path}.body`)
).toBeTruthy()
expect(
await next.readFile(`.next/server/app/${path}.meta`)
).toBeTruthy()
return 'success'
}, 'success')
}
})
})
describe('basic fetch request with a response', () => {
describe.each(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])(
'made via a %s request',
(method) => {
it.each(['/basic/endpoint', '/basic/vercel/endpoint'])(
'responds correctly on %s',
async (path) => {
const res = await next.fetch(basePath + path, { method })
expect(res.status).toEqual(200)
expect(await res.text()).toContain('hello, world')
const meta = getRequestMeta(res.headers)
expect(meta.method).toEqual(method)
}
)
}
)
describe('route groups', () => {
it('routes to the correct handler', async () => {
const res = await next.fetch(basePath + '/basic/endpoint/nested')
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.pathname).toEqual('/basic/endpoint/nested')
})
})
describe('request', () => {
it('can read query parameters', async () => {
const res = await next.fetch(basePath + '/advanced/query?ping=pong')
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.ping).toEqual('pong')
})
it('can read query parameters (edge)', async () => {
const res = await next.fetch(
basePath + '/edge/advanced/query?ping=pong'
)
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.ping).toEqual('pong')
})
})
describe('response', () => {
// TODO-APP: re-enable when rewrites are supported again
it.skip('supports the NextResponse.rewrite() helper', async () => {
const res = await next.fetch(basePath + '/hooks/rewrite')
expect(res.status).toEqual(200)
// This is running in the edge runtime, so we expect not to see this
// header.
expect(res.headers.has('x-middleware-rewrite')).toBeFalse()
expect(await res.text()).toContain('hello, world')
})
it('supports the NextResponse.redirect() helper', async () => {
const res = await next.fetch(basePath + '/hooks/redirect/response', {
// "Manually" perform the redirect, we want to inspect the
// redirection response, so don't actually follow it.
redirect: 'manual',
})
expect(res.status).toEqual(307)
expect(res.headers.get('location')).toEqual('https://nextjs.org/')
expect(await res.text()).toBeEmpty()
})
it('supports the NextResponse.json() helper', async () => {
const meta = { ping: 'pong' }
const res = await next.fetch(basePath + '/hooks/json', {
headers: withRequestMeta(meta),
})
expect(res.status).toEqual(200)
expect(res.headers.get('content-type')).toEqual('application/json')
expect(await res.json()).toEqual(meta)
})
})
})
describe('body', () => {
// we can't stream a body to a function currently only stream response
if (!isNextDeploy) {
it('can handle handle a streaming request and streaming response', async () => {
const body = new Array(10).fill(JSON.stringify({ ping: 'pong' }))
let index = 0
const stream = new Readable({
read() {
if (index >= body.length) return this.push(null)
this.push(body[index] + '\n')
index++
},
})
const res = await next.fetch(basePath + '/advanced/body/streaming', {
method: 'POST',
body: stream,
})
expect(res.status).toEqual(200)
expect(await res.text()).toEqual(body.join('\n') + '\n')
})
}
it('can handle handle a streaming request and streaming response (edge)', async () => {
const body = new Array(10).fill(JSON.stringify({ ping: 'pong' }))
let index = 0
const stream = new Readable({
read() {
if (index >= body.length) return this.push(null)
this.push(body[index] + '\n')
index++
},
})
const res = await next.fetch(
basePath + '/edge/advanced/body/streaming',
{
method: 'POST',
body: stream,
}
)
expect(res.status).toEqual(200)
expect(await res.text()).toEqual(body.join('\n') + '\n')
})
it('can read a JSON encoded body', async () => {
const body = { ping: 'pong' }
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'POST',
body: JSON.stringify(body),
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
it('can read a JSON encoded body (edge)', async () => {
const body = { ping: 'pong' }
const res = await next.fetch(basePath + '/edge/advanced/body/json', {
method: 'POST',
body: JSON.stringify(body),
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
it('can read a JSON encoded body for DELETE requests', async () => {
const body = { name: 'foo' }
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'DELETE',
body: JSON.stringify(body),
})
expect(res.status).toEqual(200)
expect(await res.text()).toEqual('delete foo')
})
it('can read a JSON encoded body for OPTIONS requests', async () => {
const body = { name: 'bar' }
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'OPTIONS',
body: JSON.stringify(body),
})
expect(res.status).toEqual(200)
expect(await res.text()).toEqual('options bar')
})
// we can't stream a body to a function currently only stream response
if (!isNextDeploy) {
it('can read a streamed JSON encoded body', async () => {
const body = { ping: 'pong' }
const encoded = JSON.stringify(body)
let index = 0
const stream = new Readable({
async read() {
if (index >= encoded.length) return this.push(null)
this.push(encoded[index])
index++
},
})
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'POST',
body: stream,
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
}
it('can read a streamed JSON encoded body (edge)', async () => {
const body = { ping: 'pong' }
const encoded = JSON.stringify(body)
let index = 0
const stream = new Readable({
async read() {
if (index >= encoded.length) return this.push(null)
this.push(encoded[index])
index++
},
})
const res = await next.fetch(basePath + '/edge/advanced/body/json', {
method: 'POST',
body: stream,
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
it('can read the text body', async () => {
const body = 'hello, world'
const res = await next.fetch(basePath + '/advanced/body/text', {
method: 'POST',
body,
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
it('can read the text body (edge)', async () => {
const body = 'hello, world'
const res = await next.fetch(basePath + '/edge/advanced/body/text', {
method: 'POST',
body,
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
})
describe('context', () => {
it('provides params to routes with dynamic parameters', async () => {
const res = await next.fetch(basePath + '/basic/vercel/endpoint')
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.params).toEqual({ tenantID: 'vercel' })
})
it('provides params to routes with catch-all routes', async () => {
const res = await next.fetch(
basePath + '/basic/vercel/some/other/resource'
)
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.params).toEqual({
tenantID: 'vercel',
resource: ['some', 'other', 'resource'],
})
})
it('does not provide params to routes without dynamic parameters', async () => {
const res = await next.fetch(basePath + '/basic/endpoint')
expect(res.ok).toBeTrue()
const meta = getRequestMeta(res.headers)
expect(meta.params).toEqual(null)
})
})
describe('hooks', () => {
describe('headers', () => {
it('gets the correct values', async () => {
const res = await next.fetch(basePath + '/hooks/headers', {
headers: withRequestMeta({ ping: 'pong' }),
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.ping).toEqual('pong')
})
})
describe('cookies', () => {
it('gets the correct values', async () => {
const res = await next.fetch(basePath + '/hooks/cookies', {
headers: cookieWithRequestMeta({ ping: 'pong' }),
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.ping).toEqual('pong')
})
})
describe('cookies().has()', () => {
it('gets the correct values', async () => {
const res = await next.fetch(basePath + '/hooks/cookies/has')
expect(res.status).toEqual(200)
expect(await res.json()).toEqual({ hasCookie: true })
})
})
describe('redirect', () => {
it('can respond correctly', async () => {
const res = await next.fetch(basePath + '/hooks/redirect', {
// "Manually" perform the redirect, we want to inspect the
// redirection response, so don't actually follow it.
redirect: 'manual',
})
expect(res.status).toEqual(307)
expect(res.headers.get('location')).toEqual('https://nextjs.org/')
expect(await res.text()).toBeEmpty()
})
})
describe('permanentRedirect', () => {
it('can respond correctly', async () => {
const res = await next.fetch(basePath + '/hooks/permanent-redirect', {
// "Manually" perform the redirect, we want to inspect the
// redirection response, so don't actually follow it.
redirect: 'manual',
})
expect(res.status).toEqual(308)
expect(res.headers.get('location')).toEqual('https://nextjs.org/')
expect(await res.text()).toBeEmpty()
})
})
describe('notFound', () => {
it('can respond correctly', async () => {
const res = await next.fetch(basePath + '/hooks/not-found')
expect(res.status).toEqual(404)
expect(await res.text()).toBeEmpty()
})
})
})
describe('error conditions', () => {
it('responds with 400 (Bad Request) when the requested method is not a valid HTTP method', async () => {
const res = await next.fetch(basePath + '/status/405', {
method: 'HEADER',
})
expect(res.status).toEqual(400)
expect(await res.text()).toBeEmpty()
})
it('responds with 405 (Method Not Allowed) when method is not implemented', async () => {
const res = await next.fetch(basePath + '/status/405', {
method: 'POST',
})
expect(res.status).toEqual(405)
expect(await res.text()).toBeEmpty()
})
it('responds with 500 (Internal Server Error) when the handler throws an error', async () => {
const res = await next.fetch(basePath + '/status/500')
expect(res.status).toEqual(500)
expect(await res.text()).toBeEmpty()
})
it('responds with 500 (Internal Server Error) when the handler calls NextResponse.next()', async () => {
const error =
'https://nextjs.org/docs/messages/next-response-next-in-app-route-handler'
// Precondition. We shouldn't have seen this before. This ensures we're
// testing that the specific route throws this error in the console.
expect(next.cliOutput).not.toContain(error)
const res = await next.fetch(basePath + '/status/500/next')
expect(res.status).toEqual(500)
expect(await res.text()).toBeEmpty()
if (!isNextDeploy) {
await check(() => {
expect(next.cliOutput).toContain(error)
return 'yes'
}, 'yes')
}
})
})
describe('automatic implementations', () => {
it('implements HEAD on routes with GET already implemented', async () => {
const res = await next.fetch(basePath + '/methods/head', {
method: 'HEAD',
})
expect(res.status).toEqual(200)
expect(await res.text()).toBeEmpty()
})
it('implements OPTIONS on routes', async () => {
const res = await next.fetch(basePath + '/methods/options', {
method: 'OPTIONS',
})
expect(res.status).toEqual(204)
expect(await res.text()).toBeEmpty()
expect(res.headers.get('allow')).toEqual(
'DELETE, GET, HEAD, OPTIONS, POST'
)
})
})
describe('edge functions', () => {
it('returns response using edge runtime', async () => {
const res = await next.fetch(basePath + '/edge')
expect(res.status).toEqual(200)
expect(await res.text()).toContain('hello, world')
})
it('returns a response when headers are accessed', async () => {
const meta = { ping: 'pong' }
const res = await next.fetch(basePath + '/edge/headers', {
headers: withRequestMeta(meta),
})
expect(res.status).toEqual(200)
expect(await res.json()).toEqual(meta)
})
})
describe('dynamic = "force-static"', () => {
it('strips search, headers, and domain from request', async () => {
const res = await next.fetch(basePath + '/dynamic?query=true', {
headers: {
accept: 'application/json',
cookie: 'session=true',
},
})
const url = 'http://localhost:3000/dynamic'
expect(res.status).toEqual(200)
expect(await res.json()).toEqual({
nextUrl: {
href: url,
search: '',
searchParams: null,
clone: url,
},
req: {
url,
headers: null,
},
headers: null,
cookies: null,
})
})
})
describe('customized metadata routes', () => {
it('should work if conflict with metadata routes convention', async () => {
const res = await next.fetch(basePath + '/robots.txt')
expect(res.status).toEqual(200)
expect(await res.text()).toBe(
'User-agent: *\nAllow: /\n\nSitemap: https://www.example.com/sitemap.xml'
)
})
})
if (isNextDev) {
describe('invalid exports', () => {
beforeAll(async () => {
await next.patchFile(
'app/default/route.ts',
`\
export { GET as default } from '../../handlers/hello'
`
)
})
afterAll(async () => {
await next.deleteFile('app/default/route.ts')
})
it('should print an error when exporting a default handler in dev', async () => {
await check(async () => {
const res = await next.fetch(basePath + '/default')
// Ensure we get a 405 (Method Not Allowed) response when there is no
// exported handler for the GET method.
expect(res.status).toEqual(405)
expect(next.cliOutput).toMatch(
/Detected default export in '.+\/route\.ts'\. Export a named export for each HTTP method instead\./
)
expect(next.cliOutput).toMatch(
/No HTTP methods exported in '.+\/route\.ts'\. Export a named export for each HTTP method\./
)
return 'yes'
}, 'yes')
})
})
}
describe('no response returned', () => {
it('should print an error when no response is returned', async () => {
await next.fetch(basePath + '/no-response', { method: 'POST' })
await check(() => {
expect(next.cliOutput).toMatch(
/No response is returned from route handler '.+\/route\.ts'\. Ensure you return a `Response` or a `NextResponse` in all branches of your handler\./
)
return 'yes'
}, 'yes')
})
})
}
)