mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
6ba71046b0
Node.js ships with a built-in `fetch` now so `node-fetch` is no longer necessary. Mostly motivated by tracing Node.js deprecation warnings which originated from `node-fetch` by calling the deprecated `url.parse`. Call sites keep working through a compatibility type on `fetchViaHTTP` that translates node-fetch-only options: Instead of `agent` we pass to `http(s)` directly, `timeout` becomes `AbortSignal.timeout`, and Node.js readable streams are accepted as bodies with `duplex: 'half'` set automatically. The `abort-controller` polyfill is dropped since its signal type predates the current AbortSignal and undici would not honor it. `node-fetch` stays installed because `scripts/generate-release-log.mjs`, `scripts/reset-project.mjs`, and `scripts/update-google-fonts.js` still import it (follow-up material). Fixture apps will be migrated separately.
1834 lines
70 KiB
TypeScript
1834 lines
70 KiB
TypeScript
import http from 'http'
|
|
import fs from 'fs-extra'
|
|
import { join } from 'path'
|
|
import assert from 'assert'
|
|
import sizeOf from 'image-size'
|
|
import {
|
|
check,
|
|
findPort,
|
|
getDeploymentId,
|
|
getDistDir,
|
|
listClientChunks,
|
|
retry,
|
|
shouldUseTurbopack,
|
|
waitFor,
|
|
} from 'next-test-utils'
|
|
import { nextTestSetup, isNextDev } from 'e2e-utils'
|
|
import isAnimated from 'next/dist/compiled/is-animated'
|
|
import type { NextInstance } from '../../lib/next-modes/base'
|
|
|
|
function toQueryString(query: Record<string, any> | null | undefined): string {
|
|
if (!query) return ''
|
|
const params = new URLSearchParams()
|
|
for (const [k, v] of Object.entries(query)) {
|
|
if (v !== undefined && v !== null) params.set(k, String(v))
|
|
}
|
|
return params.toString()
|
|
}
|
|
|
|
type SetupTestsCtx = {
|
|
nextConfigImages?: Partial<import('next').NextConfig['images']>
|
|
nextConfigExperimental?: Partial<import('next').NextConfig['experimental']>
|
|
}
|
|
|
|
type RunTestsCtx = {
|
|
next: NextInstance
|
|
w: number
|
|
q: number
|
|
nextConfigImages?: Partial<import('next').NextConfig['images']>
|
|
nextConfigExperimental?: Partial<import('next').NextConfig['experimental']>
|
|
}
|
|
|
|
type NextFetchOptions = Parameters<NextInstance['fetch']>[1]
|
|
|
|
let infiniteRedirect = 0
|
|
const largeSize = 1080 // defaults defined in server/config.ts
|
|
const animatedWarnText =
|
|
'is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.'
|
|
|
|
export async function serveSlowImage() {
|
|
const port = await findPort()
|
|
const server = http.createServer(async (req, res) => {
|
|
const parsedUrl = new URL(req.url, 'http://localhost')
|
|
const delay = Number(parsedUrl.searchParams.get('delay')) || 0
|
|
const status = Number(parsedUrl.searchParams.get('status')) || 200
|
|
const location = parsedUrl.searchParams.get('location')
|
|
|
|
console.log('delaying image for', delay)
|
|
await waitFor(delay)
|
|
|
|
res.statusCode = status
|
|
|
|
if (infiniteRedirect > 0 && infiniteRedirect < 1000) {
|
|
infiniteRedirect++
|
|
res.statusCode = 308
|
|
console.log('infinite redirect', location)
|
|
res.setHeader('location', '/')
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
if (status === 301 && location) {
|
|
console.log('redirecting to location', location)
|
|
res.setHeader('location', location)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
if (status === 399) {
|
|
res.end('invalid status')
|
|
return
|
|
}
|
|
res.setHeader('content-type', 'image/png')
|
|
res.end(await fs.readFile(join(__dirname, 'app/public/test.png')))
|
|
})
|
|
|
|
await new Promise((resolve) => {
|
|
server.listen(port, () => resolve(true))
|
|
})
|
|
console.log(`Started slow image server at ::${port}`)
|
|
return {
|
|
stop() {
|
|
server.close()
|
|
},
|
|
port,
|
|
}
|
|
}
|
|
|
|
export async function fsToJson(dir: string, output = {}) {
|
|
const files = await fs.readdir(dir).catch((e: Error) => e)
|
|
if (!Array.isArray(files)) {
|
|
return output
|
|
}
|
|
for (let file of files) {
|
|
const fsPath = join(dir, file)
|
|
const stat = await fs.stat(fsPath)
|
|
if (stat.isDirectory()) {
|
|
output[file] = {}
|
|
await fsToJson(fsPath, output[file])
|
|
} else {
|
|
output[file] = stat.mtime.toISOString()
|
|
}
|
|
}
|
|
return output
|
|
}
|
|
|
|
export async function expectWidth(res, w, { expectAnimated = false } = {}) {
|
|
const buffer = Buffer.from(await res.arrayBuffer())
|
|
const d = sizeOf(buffer)
|
|
expect(d.width).toBe(w)
|
|
const lengthStr = res.headers.get('Content-Length')
|
|
expect(lengthStr).toBe(Buffer.byteLength(buffer).toString())
|
|
expect(isAnimated(buffer)).toBe(expectAnimated)
|
|
}
|
|
|
|
export const cleanImagesDir = async (imagesDir) => {
|
|
console.warn('Cleaning', imagesDir)
|
|
await fs.remove(imagesDir)
|
|
}
|
|
|
|
async function getDirSize(dir: string): Promise<number> {
|
|
let totalSize = 0
|
|
const entries = await fs.readdir(dir).catch(() => [] as string[])
|
|
for (const entry of entries) {
|
|
const entryPath = join(dir, entry)
|
|
const stat = await fs.stat(entryPath).catch(() => null)
|
|
if (!stat) continue
|
|
if (stat.isDirectory()) {
|
|
totalSize += await getDirSize(entryPath)
|
|
} else {
|
|
totalSize += stat.size
|
|
}
|
|
}
|
|
return totalSize
|
|
}
|
|
|
|
async function expectAvifSmallerThanWebp(
|
|
next: NextInstance,
|
|
w: number,
|
|
q: number
|
|
) {
|
|
const qs = toQueryString({ url: '/mountains.jpg', w, q })
|
|
const res1 = await next.fetch(`/_next/image?${qs}`, {
|
|
headers: { accept: 'image/avif' },
|
|
})
|
|
expect(res1.status).toBe(200)
|
|
expect(res1.headers.get('Content-Type')).toBe('image/avif')
|
|
|
|
const res2 = await next.fetch(`/_next/image?${qs}`, {
|
|
headers: { accept: 'image/webp' },
|
|
})
|
|
expect(res2.status).toBe(200)
|
|
expect(res2.headers.get('Content-Type')).toBe('image/webp')
|
|
|
|
const res3 = await next.fetch(`/_next/image?${qs}`, {
|
|
headers: { accept: 'image/jpeg' },
|
|
})
|
|
expect(res3.status).toBe(200)
|
|
expect(res3.headers.get('Content-Type')).toBe('image/jpeg')
|
|
|
|
const avif = (await res1.arrayBuffer()).byteLength
|
|
const webp = (await res2.arrayBuffer()).byteLength
|
|
const jpeg = (await res3.arrayBuffer()).byteLength
|
|
|
|
expect(webp).toBeLessThan(jpeg)
|
|
expect(avif).toBeLessThanOrEqual(webp)
|
|
}
|
|
|
|
async function fetchWithDuration(
|
|
next: NextInstance,
|
|
pathname: string,
|
|
query?: Record<string, any>,
|
|
opts?: NextFetchOptions
|
|
) {
|
|
console.warn('Fetching', pathname, query)
|
|
const qs = toQueryString(query)
|
|
const url = qs ? `${pathname}?${qs}` : pathname
|
|
const start = Date.now()
|
|
const res = await next.fetch(url, opts)
|
|
const buffer = Buffer.from(await res.arrayBuffer())
|
|
const duration = Date.now() - start
|
|
return { duration, buffer, res }
|
|
}
|
|
|
|
export function runTests(ctx: RunTestsCtx) {
|
|
const { next, nextConfigImages } = ctx
|
|
const isDev = isNextDev
|
|
const {
|
|
contentDispositionType = 'attachment',
|
|
domains = [],
|
|
formats = [],
|
|
minimumCacheTTL = 14400,
|
|
maximumRedirects = 3,
|
|
dangerouslyAllowLocalIP,
|
|
} = nextConfigImages || {}
|
|
const avifEnabled = formats[0] === 'image/avif'
|
|
let slowImageServer: Awaited<ReturnType<typeof serveSlowImage>>
|
|
let imagesDir: string
|
|
beforeAll(async () => {
|
|
slowImageServer = await serveSlowImage()
|
|
imagesDir = join(next.testDir, getDistDir(), 'cache', 'images')
|
|
})
|
|
afterAll(async () => {
|
|
slowImageServer.stop()
|
|
})
|
|
|
|
if (domains.length > 0 && dangerouslyAllowLocalIP) {
|
|
it('should normalize invalid status codes', async () => {
|
|
const url = `http://localhost:${slowImageServer.port}/slow.png?status=399`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const opts: NonNullable<NextFetchOptions> = {
|
|
headers: { accept: 'image/webp' },
|
|
redirect: 'manual',
|
|
}
|
|
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(500)
|
|
})
|
|
|
|
it('should timeout for upstream image exceeding 7 seconds', async () => {
|
|
const url = `http://localhost:${slowImageServer.port}/slow.png?delay=${8000}`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(504)
|
|
})
|
|
}
|
|
|
|
if (domains.length > 0) {
|
|
it('should follow redirect from http to https when maximumRedirects > 0', async () => {
|
|
const url = `http://image-optimization-test.vercel.app/frog.png`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(maximumRedirects > 0 ? 200 : 508)
|
|
})
|
|
|
|
it('should follow redirect when dangerouslyAllowLocalIP enabled', async () => {
|
|
const url = `http://localhost:${slowImageServer.port}?status=301&location=%2Fslow.png`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
let expectedStatus = dangerouslyAllowLocalIP ? 200 : 400
|
|
if (maximumRedirects === 0) {
|
|
expectedStatus = 508
|
|
}
|
|
expect(res.status).toBe(expectedStatus)
|
|
})
|
|
|
|
it('should return 508 after redirecting too many times', async () => {
|
|
infiniteRedirect = 1
|
|
const url = `http://localhost:${slowImageServer.port}`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(508)
|
|
infiniteRedirect = 0
|
|
})
|
|
}
|
|
|
|
it('should return home page', async () => {
|
|
const res = await next.fetch('/')
|
|
expect(await res.text()).toMatch(/Image Optimizer Home/m)
|
|
})
|
|
|
|
it('should handle non-ascii characters in image url', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/äöüščří.png' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('should maintain icns', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.icns' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toContain('image/x-icns')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.icns"`
|
|
)
|
|
await expectWidth(res, 256)
|
|
})
|
|
|
|
it('should maintain jxl', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.jxl' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toContain('image/jxl')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.jxl"`
|
|
)
|
|
await expectWidth(res, 800)
|
|
})
|
|
|
|
it('should maintain heic', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.heic' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toContain('image/heic')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.heic"`
|
|
)
|
|
await expectWidth(res, 400)
|
|
})
|
|
|
|
it('should maintain jp2', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.jp2' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toContain('image/jp2')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.jp2"`
|
|
)
|
|
await expectWidth(res, 1)
|
|
})
|
|
|
|
it('should maintain animated gif', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/animated.gif' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('content-type')).toContain('image/gif')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="animated.gif"`
|
|
)
|
|
await expectWidth(res, 50, { expectAnimated: true })
|
|
expect(next.cliOutput).toContain(animatedWarnText)
|
|
})
|
|
|
|
it('should maintain animated png', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/animated.png' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('content-type')).toContain('image/png')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="animated.png"`
|
|
)
|
|
await expectWidth(res, 100, { expectAnimated: true })
|
|
expect(next.cliOutput).toContain(animatedWarnText)
|
|
})
|
|
|
|
it('should maintain animated png 2', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/animated2.png' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('content-type')).toContain('image/png')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="animated2.png"`
|
|
)
|
|
await expectWidth(res, 1105, { expectAnimated: true })
|
|
expect(next.cliOutput).toContain(animatedWarnText)
|
|
})
|
|
|
|
it('should maintain animated webp', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/animated.webp' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('content-type')).toContain('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="animated.webp"`
|
|
)
|
|
await expectWidth(res, 400, { expectAnimated: true })
|
|
expect(next.cliOutput).toContain(animatedWarnText)
|
|
})
|
|
|
|
it('should not forward cookie header', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/api/conditional-cookie' }
|
|
const opts = { headers: { accept: 'image/webp', cookie: '1' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
// the cookie is not forwarded so the internal handler responds 401
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
if (ctx.nextConfigImages?.dangerouslyAllowSVG) {
|
|
it('should maintain vector svg', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.svg' }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Length')).toBe('603')
|
|
expect(res.headers.get('Content-Type')).toContain('image/svg+xml')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
// SVG is compressible so will have accept-encoding set from
|
|
// compression
|
|
expect(res.headers.get('Vary')).toMatch(/^Accept(,|$)/)
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.svg"`
|
|
)
|
|
const actual = await res.text()
|
|
const expected = await fs.readFile(
|
|
join(next.testDir, 'public', 'test.svg'),
|
|
'utf8'
|
|
)
|
|
expect(actual).toMatch(expected)
|
|
})
|
|
} else {
|
|
it('should not allow vector svg', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.svg' }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toContain('valid but image type is not allowed')
|
|
})
|
|
|
|
it('should not allow svg with application header', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/api/application.svg' }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toContain(
|
|
"The requested resource isn't a valid image"
|
|
)
|
|
})
|
|
|
|
it('should not allow svg with comma header', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/api/comma.svg' }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toContain(
|
|
"The requested resource isn't a valid image"
|
|
)
|
|
})
|
|
|
|
it('should not allow svg with uppercase header', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/api/uppercase.svg' }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toContain(
|
|
"The requested resource isn't a valid image"
|
|
)
|
|
})
|
|
|
|
it('should not allow svg with wrong header', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/api/wrong-header.svg' }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toContain(
|
|
'"url" parameter is valid but image type is not allowed'
|
|
)
|
|
})
|
|
}
|
|
|
|
it('should not allow pdf format', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.pdf' }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toContain(
|
|
"The requested resource isn't a valid image"
|
|
)
|
|
})
|
|
|
|
it.each([
|
|
['html', '/test.html'],
|
|
['ppm', '/test.ppm'],
|
|
['pgm', '/test.pgm'],
|
|
['pam', '/test.pam'],
|
|
['pfm', '/test.pfm'],
|
|
['csv', '/test.csv'],
|
|
['vips', '/test.vips'],
|
|
['hdr', '/test.hdr'],
|
|
['exr', '/test.exr'],
|
|
['fits', '/test.fits'],
|
|
['j2c', '/test.j2c'],
|
|
['psd', '/test.psd'],
|
|
['tga', '/test.tga'],
|
|
['cur', '/test.cur'],
|
|
['dds', '/test.dds'],
|
|
['ktx', '/test.ktx'],
|
|
])(
|
|
'should not allow %s because detectContentType returns null',
|
|
async (_name, url) => {
|
|
const query = { w: ctx.w, q: ctx.q, url }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
"The requested resource isn't a valid image."
|
|
)
|
|
}
|
|
)
|
|
|
|
it('should maintain ico format', async () => {
|
|
const query = { w: ctx.w, q: ctx.q, url: `/test.ico` }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toContain('image/x-icon')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toMatch(/^Accept(,|$)/)
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.ico"`
|
|
)
|
|
const actual = await res.text()
|
|
const expected = await fs.readFile(
|
|
join(next.testDir, 'public', 'test.ico'),
|
|
'utf8'
|
|
)
|
|
expect(actual).toMatch(expected)
|
|
})
|
|
|
|
it('should maintain jpg format for old Safari', async () => {
|
|
const accept =
|
|
'image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5'
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.jpg' }
|
|
const opts = { headers: { accept } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toContain('image/jpeg')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.jpeg"`
|
|
)
|
|
})
|
|
|
|
it('should maintain png format for old Safari', async () => {
|
|
const accept =
|
|
'image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5'
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.png' }
|
|
const opts = { headers: { accept } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toContain('image/png')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.png"`
|
|
)
|
|
})
|
|
|
|
it('should downlevel webp format to jpeg for old Safari', async () => {
|
|
const accept =
|
|
'image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5'
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.webp' }
|
|
const opts = { headers: { accept } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toContain('image/jpeg')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.jpeg"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should downlevel avif format to jpeg for old Safari', async () => {
|
|
const accept =
|
|
'image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5'
|
|
const query = { w: ctx.w, q: ctx.q, url: '/test.avif' }
|
|
const opts = { headers: { accept } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toContain('image/jpeg')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.jpeg"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should fail when url is missing', async () => {
|
|
const query = { w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"url" parameter is required`)
|
|
})
|
|
|
|
it('should fail when w is missing', async () => {
|
|
const query = { url: '/test.png', q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"w" parameter (width) is required`)
|
|
})
|
|
|
|
it('should fail when q is missing', async () => {
|
|
const query = { url: '/test.png', w: ctx.w }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"q" parameter (quality) is required`)
|
|
})
|
|
|
|
it('should fail when q is greater than 100', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: 101 }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"q" parameter (quality) must be an integer between 1 and 100`
|
|
)
|
|
})
|
|
|
|
it('should fail when q is less than 1', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: 0 }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"q" parameter (quality) must be an integer between 1 and 100`
|
|
)
|
|
})
|
|
|
|
if (ctx?.nextConfigImages?.qualities) {
|
|
it('should fail when q is not in config', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: 13 }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"q" parameter (quality) of 13 is not allowed`
|
|
)
|
|
})
|
|
}
|
|
|
|
it('should fail when w is 0', async () => {
|
|
const query = { url: '/test.png', w: 0, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"w" parameter (width) must be an integer greater than 0`
|
|
)
|
|
})
|
|
|
|
it('should fail when w is less than 0', async () => {
|
|
const query = { url: '/test.png', w: -100, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"w" parameter (width) must be an integer greater than 0`
|
|
)
|
|
})
|
|
|
|
it('should fail when w is not a number', async () => {
|
|
const query = { url: '/test.png', w: 'foo', q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"w" parameter (width) must be an integer greater than 0`
|
|
)
|
|
})
|
|
|
|
it('should fail when w is not an integer', async () => {
|
|
const query = { url: '/test.png', w: 99.9, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"w" parameter (width) must be an integer greater than 0`
|
|
)
|
|
})
|
|
|
|
it('should fail when q is not a number', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: 'foo' }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"q" parameter (quality) must be an integer between 1 and 100`
|
|
)
|
|
})
|
|
|
|
it('should fail when q is not an integer', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: 99.9 }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"q" parameter (quality) must be an integer between 1 and 100`
|
|
)
|
|
})
|
|
|
|
it('should fail when domain is not defined in next.config.js', async () => {
|
|
const url = `http://vercel.com/button`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"url" parameter is not allowed`)
|
|
})
|
|
|
|
it('should fail when width is not in next.config.js', async () => {
|
|
const query = { url: '/test.png', w: 1000, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"w" parameter (width) of 1000 is not allowed`
|
|
)
|
|
})
|
|
|
|
it('should emit blur svg when width is 8 in dev but not prod', async () => {
|
|
const query = { url: '/test.png', w: 8, q: 70 }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
if (isDev) {
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/svg+xml')
|
|
expect(await res.text()).toMatch(
|
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 320'><filter id='b' color-interpolation-filters='sRGB'><feGaussianBlur stdDeviation='20'/><feColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/><feFlood x='0' y='0' width='100%' height='100%'/><feComposite operator='out' in='s'/><feComposite in2='SourceGraphic'/><feGaussianBlur stdDeviation='20'/></filter><image width='100%' height='100%' x='0' y='0' preserveAspectRatio='none' style='filter: url(#b);' href='data:image/webp;base64`
|
|
)
|
|
} else {
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"w" parameter (width) of 8 is not allowed`)
|
|
}
|
|
})
|
|
|
|
it('should emit blur svg when width is less than 8 in dev but not prod', async () => {
|
|
const query = { url: '/test.png', w: 3, q: 70 }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
if (isDev) {
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/svg+xml')
|
|
expect(await res.text()).toMatch(
|
|
`svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'><filter id='b' color-interpolation-filters='sRGB'><feGaussianBlur stdDeviation='20'/><feColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/><feFlood x='0' y='0' width='100%' height='100%'/><feComposite operator='out' in='s'/><feComposite in2='SourceGraphic'/><feGaussianBlur stdDeviation='20'/></filter><image width='100%' height='100%' x='0' y='0' preserveAspectRatio='none' style='filter: url(#b);' href='data:image/webp;base64`
|
|
)
|
|
} else {
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"w" parameter (width) of 3 is not allowed`)
|
|
}
|
|
})
|
|
|
|
it('should resize relative url and webp Firefox accept header', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp,*/*' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize relative url and png accept header', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/png' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/png')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.png"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize relative url with invalid accept header as png', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/invalid' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/png')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.png"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize relative url with invalid accept header as gif', async () => {
|
|
const query = { url: '/test.gif', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/invalid' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/gif')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.gif"`
|
|
)
|
|
// FIXME: await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize relative url with invalid accept header as tiff', async () => {
|
|
const query = { url: '/test.tiff', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/invalid' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/tiff')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.tiff"`
|
|
)
|
|
// FIXME: await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize jpeg', async () => {
|
|
const query = { url: '/test.jpg', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize webp (not animated)', async () => {
|
|
const query = { url: '/test.webp', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize gif (not animated)', async () => {
|
|
const query = { url: '/test.gif', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize tiff', async () => {
|
|
const query = { url: '/test.tiff', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize avif', async () => {
|
|
const query = { url: '/test.avif', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize relative url and old Chrome accept header as webp', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: ctx.q }
|
|
const opts = {
|
|
headers: { accept: 'image/webp,image/apng,image/*,*/*;q=0.8' },
|
|
}
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
if (avifEnabled) {
|
|
it('should resize relative url and new Chrome accept header as avif', async () => {
|
|
const query = { url: '/test.png', w: ctx.w, q: ctx.q }
|
|
const opts = {
|
|
headers: {
|
|
accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
|
},
|
|
}
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/avif')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.avif"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should resize avif and maintain format', async () => {
|
|
const query = { url: '/test.avif', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/avif' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/avif')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.avif"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should compress avif smaller than webp at q=100', async () => {
|
|
await expectAvifSmallerThanWebp(next, ctx.w, 100)
|
|
})
|
|
|
|
it('should compress avif smaller than webp at q=75', async () => {
|
|
await expectAvifSmallerThanWebp(next, ctx.w, 75)
|
|
})
|
|
|
|
it('should compress avif smaller than webp at q=50', async () => {
|
|
await expectAvifSmallerThanWebp(next, ctx.w, 50)
|
|
})
|
|
}
|
|
|
|
if (domains.length > 0 && dangerouslyAllowLocalIP) {
|
|
it('should resize absolute url from localhost', async () => {
|
|
const url = `http://localhost:${new URL(next.url).port}/test.png`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should automatically detect image type when content-type is octet-stream', async () => {
|
|
const url = '/png-as-octet-stream'
|
|
const resOrig = await next.fetch(url)
|
|
expect(resOrig.status).toBe(200)
|
|
expect(resOrig.headers.get('Content-Type')).toBe(
|
|
'application/octet-stream'
|
|
)
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="png-as-octet-stream.webp"`
|
|
)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
|
|
it('should use cache and stale-while-revalidate when query is the same for external image', async () => {
|
|
if (
|
|
ctx.nextConfigExperimental?.isrFlushToDisk === false ||
|
|
ctx.nextConfigImages?.maximumDiskCacheSize === 0
|
|
) {
|
|
return // this test is not applicable when we don't write the cache
|
|
}
|
|
await cleanImagesDir(imagesDir)
|
|
const delay = 500
|
|
|
|
const url = `http://localhost:${slowImageServer.port}/slow.png?delay=${delay}`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
|
|
const one = await fetchWithDuration(next, '/_next/image', query, opts)
|
|
expect(one.duration).toBeGreaterThan(delay)
|
|
expect(one.res.status).toBe(200)
|
|
expect(one.res.headers.get('X-Nextjs-Cache')).toBe('MISS')
|
|
expect(one.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(one.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="slow.webp"`
|
|
)
|
|
const etagOne = one.res.headers.get('etag')
|
|
|
|
let json1
|
|
|
|
await check(async () => {
|
|
json1 = await fsToJson(imagesDir)
|
|
return Object.keys(json1).some((dir) => {
|
|
return Object.keys(json1[dir]).some((file) => file.includes(etagOne))
|
|
})
|
|
? 'success'
|
|
: 'fail'
|
|
}, 'success')
|
|
|
|
const two = await fetchWithDuration(next, '/_next/image', query, opts)
|
|
expect(two.res.status).toBe(200)
|
|
expect(two.res.headers.get('X-Nextjs-Cache')).toBe('HIT')
|
|
expect(two.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(two.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="slow.webp"`
|
|
)
|
|
const json2 = await fsToJson(imagesDir)
|
|
expect(json2).toStrictEqual(json1)
|
|
|
|
if (ctx.nextConfigImages?.minimumCacheTTL) {
|
|
// Wait until expired so we can confirm image is regenerated
|
|
await waitFor(ctx.nextConfigImages.minimumCacheTTL * 1000)
|
|
|
|
const [three, four] = await Promise.all([
|
|
fetchWithDuration(next, '/_next/image', query, opts),
|
|
fetchWithDuration(next, '/_next/image', query, opts),
|
|
])
|
|
|
|
expect(three.duration).toBeLessThan(one.duration)
|
|
expect(three.res.status).toBe(200)
|
|
expect(three.res.headers.get('X-Nextjs-Cache')).toBe('STALE')
|
|
expect(three.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(three.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="slow.webp"`
|
|
)
|
|
|
|
expect(four.duration).toBeLessThan(one.duration)
|
|
expect(four.res.status).toBe(200)
|
|
expect(four.res.headers.get('X-Nextjs-Cache')).toBe('STALE')
|
|
expect(four.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(four.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="slow.webp"`
|
|
)
|
|
|
|
await check(async () => {
|
|
const json4 = await fsToJson(imagesDir)
|
|
try {
|
|
assert.deepStrictEqual(json4, json1)
|
|
return 'fail'
|
|
} catch (err) {
|
|
return 'success'
|
|
}
|
|
}, 'success')
|
|
|
|
const five = await fetchWithDuration(next, '/_next/image', query, opts)
|
|
// expect(five.duration).toBeLessThan(one.duration) // TODO: investigate why this timing varies randomly
|
|
expect(five.res.status).toBe(200)
|
|
expect(five.res.headers.get('X-Nextjs-Cache')).toBe('HIT')
|
|
expect(five.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(five.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="slow.webp"`
|
|
)
|
|
await check(async () => {
|
|
const json5 = await fsToJson(imagesDir)
|
|
try {
|
|
assert.deepStrictEqual(json5, json1)
|
|
return 'fail'
|
|
} catch (err) {
|
|
return 'success'
|
|
}
|
|
}, 'success')
|
|
}
|
|
})
|
|
}
|
|
|
|
it('should fail when url has file protocol', async () => {
|
|
const url = `file://example.vercel.sh:${new URL(next.url).port}/test.png`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"url" parameter is invalid`)
|
|
})
|
|
|
|
it('should fail when url has ftp protocol', async () => {
|
|
const url = `ftp://example.vercel.sh:${new URL(next.url).port}/test.png`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"url" parameter is invalid`)
|
|
})
|
|
|
|
it('should fail when url is too long', async () => {
|
|
const query = { url: `/${'a'.repeat(4000)}`, w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"url" parameter is too long`)
|
|
})
|
|
|
|
it('should fail when url is protocol relative', async () => {
|
|
const query = { url: `//example.vercel.sh`, w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(
|
|
`"url" parameter cannot be a protocol-relative URL (//)`
|
|
)
|
|
})
|
|
|
|
describe('recursive url is not allowed', () => {
|
|
it('should fail with relative next image url', async () => {
|
|
const query = {
|
|
url: `/_next/image?url=test.pngw=1&q=75`,
|
|
w: ctx.w,
|
|
q: ctx.q,
|
|
}
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"url" parameter cannot be recursive`)
|
|
})
|
|
|
|
it('should fail with encoded relative image url', async () => {
|
|
const query = {
|
|
url: '%2F_next%2Fimage%3Furl%3Dtest.pngw%3D1%26q%3D1',
|
|
w: ctx.w,
|
|
q: ctx.q,
|
|
}
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"url" parameter is invalid`)
|
|
})
|
|
|
|
if (domains.length > 0) {
|
|
it('should pass with absolute next image url', async () => {
|
|
const fullUrl =
|
|
'https://image-optimization-test.vercel.app/_next/image?url=%2Ffrog.jpg&w=1024&q=75'
|
|
const query = { url: fullUrl, w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(200)
|
|
await expectWidth(res, ctx.w)
|
|
})
|
|
} else {
|
|
it('should fail with absolute next image url', async () => {
|
|
const fullUrl =
|
|
'https://image-optimization-test.vercel.app/_next/image?url=%2Ffrog.jpg&w=1024&q=75'
|
|
const query = { url: fullUrl, w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"url" parameter is not allowed`)
|
|
})
|
|
}
|
|
|
|
it('should fail with relative image url with assetPrefix', async () => {
|
|
const fullUrl = '/assets/_next/image?url=%2Ftest.png&w=128&q=75'
|
|
const query = { url: fullUrl, w: ctx.w, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe(`"url" parameter cannot be recursive`)
|
|
})
|
|
})
|
|
|
|
it('should fail when internal url is not an image', async () => {
|
|
const url = `/api/no-header`
|
|
const query = { url, w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toContain(
|
|
"The requested resource isn't a valid image"
|
|
)
|
|
})
|
|
|
|
if (domains.length > 0 && dangerouslyAllowLocalIP) {
|
|
it('should fail when url fails to load an image', async () => {
|
|
const url = `http://localhost:${new URL(next.url).port}/not-an-image`
|
|
const query = { w: ctx.w, url, q: ctx.q }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`)
|
|
expect(res.status).toBe(404)
|
|
expect(await res.text()).toBe(
|
|
`"url" parameter is valid but upstream response is invalid`
|
|
)
|
|
})
|
|
}
|
|
|
|
it('should use cache and stale-while-revalidate when query is the same for internal image', async () => {
|
|
if (
|
|
ctx.nextConfigExperimental?.isrFlushToDisk === false ||
|
|
ctx.nextConfigImages?.maximumDiskCacheSize === 0
|
|
) {
|
|
return // this test is not applicable when we don't write the cache
|
|
}
|
|
await cleanImagesDir(imagesDir)
|
|
|
|
const query = {
|
|
url: '/api/stateful/test.png',
|
|
w: ctx.w,
|
|
q: ctx.q,
|
|
}
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
|
|
const one = await fetchWithDuration(next, '/_next/image', query, opts)
|
|
expect(one.res.status).toBe(200)
|
|
expect(one.res.headers.get('X-Nextjs-Cache')).toBe('MISS')
|
|
expect(one.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(one.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
const etagOne = one.res.headers.get('etag')
|
|
|
|
let json1
|
|
await check(async () => {
|
|
json1 = await fsToJson(imagesDir)
|
|
return Object.keys(json1).some((dir) => {
|
|
return Object.keys(json1[dir]).some((file) => file.includes(etagOne))
|
|
})
|
|
? 'success'
|
|
: 'fail'
|
|
}, 'success')
|
|
|
|
const two = await fetchWithDuration(next, '/_next/image', query, opts)
|
|
expect(two.res.status).toBe(200)
|
|
expect(two.res.headers.get('X-Nextjs-Cache')).toBe('HIT')
|
|
expect(two.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(two.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
const json2 = await fsToJson(imagesDir)
|
|
expect(json2).toStrictEqual(json1)
|
|
|
|
if (ctx.nextConfigImages?.minimumCacheTTL) {
|
|
// Wait until expired so we can confirm image is regenerated
|
|
await waitFor(ctx.nextConfigImages.minimumCacheTTL * 1000)
|
|
|
|
const [three, four] = await Promise.all([
|
|
fetchWithDuration(next, '/_next/image', query, opts),
|
|
fetchWithDuration(next, '/_next/image', query, opts),
|
|
])
|
|
|
|
expect(three.res.status).toBe(200)
|
|
expect(three.res.headers.get('X-Nextjs-Cache')).toBe('STALE')
|
|
expect(three.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(three.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
|
|
expect(four.res.status).toBe(200)
|
|
expect(four.res.headers.get('X-Nextjs-Cache')).toBe('STALE')
|
|
expect(four.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(four.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await check(async () => {
|
|
const json3 = await fsToJson(imagesDir)
|
|
try {
|
|
assert.deepStrictEqual(json3, json1)
|
|
return 'fail'
|
|
} catch (err) {
|
|
return 'success'
|
|
}
|
|
}, 'success')
|
|
|
|
const five = await fetchWithDuration(next, '/_next/image', query, opts)
|
|
// expect(five.duration).toBeLessThan(one.duration) // TODO: investigate why this timing varies randomly
|
|
expect(five.res.status).toBe(200)
|
|
expect(five.res.headers.get('X-Nextjs-Cache')).toBe('HIT')
|
|
expect(five.res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(five.res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await check(async () => {
|
|
const json5 = await fsToJson(imagesDir)
|
|
try {
|
|
assert.deepStrictEqual(json5, json1)
|
|
return 'fail'
|
|
} catch (err) {
|
|
return 'success'
|
|
}
|
|
}, 'success')
|
|
}
|
|
})
|
|
|
|
if (ctx.nextConfigImages?.dangerouslyAllowSVG) {
|
|
it('should use cached image file when parameters are the same for svg', async () => {
|
|
await cleanImagesDir(imagesDir)
|
|
|
|
const query = { url: '/test.svg', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
|
|
const res1 = await next.fetch(
|
|
`/_next/image?${toQueryString(query)}`,
|
|
opts
|
|
)
|
|
expect(res1.status).toBe(200)
|
|
expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS')
|
|
expect(res1.headers.get('Content-Type')).toBe('image/svg+xml')
|
|
expect(res1.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.svg"`
|
|
)
|
|
const etagOne = res1.headers.get('etag')
|
|
|
|
let json1
|
|
await check(async () => {
|
|
json1 = await fsToJson(imagesDir)
|
|
return Object.keys(json1).some((dir) => {
|
|
return Object.keys(json1[dir]).some((file) => file.includes(etagOne))
|
|
})
|
|
? 'success'
|
|
: 'fail'
|
|
}, 'success')
|
|
|
|
const res2 = await next.fetch(
|
|
`/_next/image?${toQueryString(query)}`,
|
|
opts
|
|
)
|
|
expect(res2.status).toBe(200)
|
|
expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT')
|
|
expect(res2.headers.get('Content-Type')).toBe('image/svg+xml')
|
|
expect(res2.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.svg"`
|
|
)
|
|
const json2 = await fsToJson(imagesDir)
|
|
expect(json2).toStrictEqual(json1)
|
|
})
|
|
}
|
|
|
|
it('should use cached image file when parameters are the same for animated gif', async () => {
|
|
if (
|
|
ctx.nextConfigExperimental?.isrFlushToDisk === false ||
|
|
ctx.nextConfigImages?.maximumDiskCacheSize === 0
|
|
) {
|
|
return // this test is not applicable when we don't write the cache
|
|
}
|
|
await cleanImagesDir(imagesDir)
|
|
|
|
const query = { url: '/animated.gif', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
|
|
const res1 = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res1.status).toBe(200)
|
|
expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS')
|
|
expect(res1.headers.get('Content-Type')).toBe('image/gif')
|
|
expect(res1.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="animated.gif"`
|
|
)
|
|
|
|
let json1
|
|
await check(async () => {
|
|
json1 = await fsToJson(imagesDir)
|
|
return Object.keys(json1).length === 1 ? 'success' : 'fail'
|
|
}, 'success')
|
|
|
|
const res2 = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res2.status).toBe(200)
|
|
expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT')
|
|
expect(res2.headers.get('Content-Type')).toBe('image/gif')
|
|
expect(res2.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="animated.gif"`
|
|
)
|
|
const json2 = await fsToJson(imagesDir)
|
|
expect(json2).toStrictEqual(json1)
|
|
})
|
|
|
|
it('should set 304 status without body when etag matches if-none-match', async () => {
|
|
const query = { url: '/test.jpg', w: ctx.w, q: ctx.q }
|
|
const opts1 = { headers: { accept: 'image/webp' } }
|
|
|
|
const res1 = await next.fetch(`/_next/image?${toQueryString(query)}`, opts1)
|
|
expect(res1.status).toBe(200)
|
|
expect(res1.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res1.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res1.headers.get('Vary')).toBe('Accept')
|
|
const etag = res1.headers.get('Etag')
|
|
expect(etag).toBeTruthy()
|
|
expect(res1.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res1, ctx.w)
|
|
|
|
// undici injects Cache-Control: no-cache into requests with
|
|
// conditional headers, which would defeat the etag freshness check.
|
|
const opts2 = {
|
|
headers: { accept: 'image/webp', 'if-none-match': etag },
|
|
cache: 'force-cache' as const,
|
|
}
|
|
const res2 = await next.fetch(`/_next/image?${toQueryString(query)}`, opts2)
|
|
expect(res2.status).toBe(304)
|
|
expect(res2.headers.get('Content-Type')).toBeFalsy()
|
|
expect(res2.headers.get('Etag')).toBe(etag)
|
|
expect(res2.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res2.headers.get('Vary')).toBe('Accept')
|
|
expect(res2.headers.get('Content-Disposition')).toBeFalsy()
|
|
expect((await res2.arrayBuffer()).byteLength).toBe(0)
|
|
|
|
if (ctx?.nextConfigImages?.qualities) {
|
|
const q = ctx.nextConfigImages.qualities[0]
|
|
const query3 = { url: '/test.jpg', w: ctx.w, q }
|
|
const res3 = await next.fetch(
|
|
`/_next/image?${toQueryString(query3)}`,
|
|
opts2
|
|
)
|
|
expect(res3.status).toBe(200)
|
|
expect(res3.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res3.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res3.headers.get('Vary')).toBe('Accept')
|
|
expect(res3.headers.get('Etag')).toBeTruthy()
|
|
expect(res3.headers.get('Etag')).not.toBe(etag)
|
|
expect(res3.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res3, ctx.w)
|
|
}
|
|
})
|
|
|
|
it('should maintain bmp', async () => {
|
|
const json1 = await fsToJson(imagesDir)
|
|
expect(json1).toBeTruthy()
|
|
|
|
const query = { url: '/test.bmp', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/invalid' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/bmp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
// bmp is compressible so will have accept-encoding set from
|
|
// compression
|
|
expect(res.headers.get('Vary')).toMatch(/^Accept(,|$)/)
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.bmp"`
|
|
)
|
|
|
|
if (
|
|
ctx.nextConfigExperimental?.isrFlushToDisk === false ||
|
|
ctx.nextConfigImages?.maximumDiskCacheSize === 0
|
|
) {
|
|
expect(json1).toEqual({})
|
|
expect(await fsToJson(imagesDir)).toEqual({})
|
|
} else {
|
|
await check(async () => {
|
|
try {
|
|
assert.deepStrictEqual(await fsToJson(imagesDir), json1)
|
|
return 'expected change, but matched'
|
|
} catch (_) {
|
|
return 'success'
|
|
}
|
|
}, 'success')
|
|
}
|
|
})
|
|
|
|
it('should not resize if requested width is larger than original source image', async () => {
|
|
const query = { url: '/test.jpg', w: largeSize, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
|
|
)
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
expect(res.headers.get('etag')).toBeTruthy()
|
|
expect(res.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res, 400)
|
|
})
|
|
|
|
it('should set cache-control to immutable for static images', async () => {
|
|
if (!isDev) {
|
|
const file = (await listClientChunks(join(next.testDir, '.next'))).find(
|
|
(f) => /\/test\.[0-9a-z_-]+\.jpg$/.test(f)
|
|
)
|
|
expect(file).toBeString()
|
|
const query: Record<string, string> = {
|
|
url: `/_next/${file}`,
|
|
w: String(ctx.w),
|
|
q: String(ctx.q),
|
|
}
|
|
const assetToken = getDeploymentId(next.testDir, false).assetToken
|
|
if (assetToken) {
|
|
query.dpl = assetToken
|
|
}
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
|
|
const res1 = await next.fetch(
|
|
`/_next/image?${toQueryString(query)}`,
|
|
opts
|
|
)
|
|
expect(res1.status).toBe(200)
|
|
expect(res1.headers.get('Cache-Control')).toBe(
|
|
'public, max-age=315360000, immutable'
|
|
)
|
|
expect(res1.headers.get('Vary')).toBe('Accept')
|
|
expect(res1.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res1, ctx.w)
|
|
|
|
// Ensure subsequent request also has immutable header
|
|
const res2 = await next.fetch(
|
|
`/_next/image?${toQueryString(query)}`,
|
|
opts
|
|
)
|
|
expect(res2.status).toBe(200)
|
|
expect(res2.headers.get('Cache-Control')).toBe(
|
|
'public, max-age=315360000, immutable'
|
|
)
|
|
expect(res2.headers.get('Vary')).toBe('Accept')
|
|
expect(res2.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="test.webp"`
|
|
)
|
|
await expectWidth(res2, ctx.w)
|
|
}
|
|
})
|
|
|
|
it("should error if the resource isn't a valid image", async () => {
|
|
const query = { url: '/text.txt', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(400)
|
|
expect(await res.text()).toBe("The requested resource isn't a valid image.")
|
|
})
|
|
|
|
it('should error if the image file does not exist', async () => {
|
|
const query = { url: '/does_not_exist.jpg', w: ctx.w, q: ctx.q }
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const res = await next.fetch(`/_next/image?${toQueryString(query)}`, opts)
|
|
expect(res.status).toBe(404)
|
|
expect(await res.text()).toBe(
|
|
'"url" parameter is valid but internal response is invalid'
|
|
)
|
|
})
|
|
|
|
if (domains.length > 0 && dangerouslyAllowLocalIP) {
|
|
it('should handle concurrent requests', async () => {
|
|
await cleanImagesDir(imagesDir)
|
|
const delay = 500
|
|
const query = {
|
|
url: `http://localhost:${slowImageServer.port}/slow.png?delay=${delay}`,
|
|
w: ctx.w,
|
|
q: ctx.q,
|
|
}
|
|
const opts = { headers: { accept: 'image/webp,*/*' } }
|
|
const [res1, res2, res3] = await Promise.all([
|
|
next.fetch(`/_next/image?${toQueryString(query)}`, opts),
|
|
next.fetch(`/_next/image?${toQueryString(query)}`, opts),
|
|
next.fetch(`/_next/image?${toQueryString(query)}`, opts),
|
|
])
|
|
|
|
if (res1.status !== 200) {
|
|
console.error(await res1.text())
|
|
}
|
|
|
|
expect(res1.status).toBe(200)
|
|
expect(res2.status).toBe(200)
|
|
expect(res3.status).toBe(200)
|
|
|
|
expect(res1.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res1.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="slow.webp"`
|
|
)
|
|
expect(res2.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res2.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="slow.webp"`
|
|
)
|
|
expect(res3.headers.get('Content-Type')).toBe('image/webp')
|
|
expect(res3.headers.get('Content-Disposition')).toBe(
|
|
`${contentDispositionType}; filename="slow.webp"`
|
|
)
|
|
|
|
await expectWidth(res1, ctx.w)
|
|
await expectWidth(res2, ctx.w)
|
|
await expectWidth(res3, ctx.w)
|
|
|
|
const length =
|
|
ctx.nextConfigExperimental?.isrFlushToDisk === false ||
|
|
ctx.nextConfigImages?.maximumDiskCacheSize === 0
|
|
? 0
|
|
: 1
|
|
|
|
await check(async () => {
|
|
const json1 = await fsToJson(imagesDir)
|
|
return Object.keys(json1).length === length ? 'success' : 'fail'
|
|
}, 'success')
|
|
|
|
const xCache = [res1, res2, res3]
|
|
.map((r) => r.headers.get('X-Nextjs-Cache'))
|
|
.sort((a, b) => b.localeCompare(a))
|
|
|
|
// Since the first request is a miss it blocks
|
|
// until the cache be populated so all concurrent
|
|
// requests receive the same response
|
|
expect(xCache).toEqual(['MISS', 'MISS', 'MISS'])
|
|
})
|
|
}
|
|
|
|
if (typeof ctx.nextConfigImages?.maximumDiskCacheSize !== 'undefined') {
|
|
const { maximumDiskCacheSize } = ctx.nextConfigImages
|
|
it(`should handle maximumDiskCacheSize ${maximumDiskCacheSize}`, async () => {
|
|
const opts = { headers: { accept: 'image/webp' } }
|
|
const requests = [
|
|
{ url: '/test.png', w: largeSize },
|
|
{ url: '/test.jpg', w: largeSize },
|
|
{ url: '/test.gif', w: largeSize },
|
|
{ url: '/test.bmp', w: largeSize },
|
|
{ url: '/test.webp', w: largeSize },
|
|
{ url: '/test.avif', w: largeSize },
|
|
{ url: '/test.tiff', w: largeSize },
|
|
{ url: '/test.ico', w: largeSize },
|
|
{ url: '/animated.gif', w: largeSize },
|
|
{ url: '/animated.png', w: largeSize },
|
|
{ url: '/animated2.png', w: largeSize },
|
|
]
|
|
await cleanImagesDir(imagesDir)
|
|
const json1 = await fsToJson(imagesDir)
|
|
expect(Object.keys(json1).length).toEqual(0)
|
|
for (const { url, w } of requests) {
|
|
const query = { url, w, q: ctx.q }
|
|
const res = await next.fetch(
|
|
`/_next/image?${toQueryString(query)}`,
|
|
opts
|
|
)
|
|
expect(res.status).toBe(200)
|
|
await res.arrayBuffer()
|
|
// eslint-disable-next-line no-loop-func
|
|
await retry(async () => {
|
|
const size = await getDirSize(imagesDir)
|
|
expect(size).toBeLessThanOrEqual(maximumDiskCacheSize)
|
|
})
|
|
}
|
|
|
|
const json2 = await fsToJson(imagesDir)
|
|
const json2Length = Object.keys(json2).length
|
|
if (maximumDiskCacheSize === 0) {
|
|
expect(json2Length).toEqual(0)
|
|
} else {
|
|
expect(json2Length).toBeGreaterThan(0)
|
|
}
|
|
|
|
const res = await next.fetch(
|
|
`/_next/image?${toQueryString({ url: '/mountains.jpg', w: ctx.w, q: ctx.q })}`,
|
|
opts
|
|
)
|
|
expect(res.status).toBe(200)
|
|
|
|
await retry(async () => {
|
|
const json3 = await fsToJson(imagesDir)
|
|
const json3Length = Object.keys(json3).length
|
|
if (maximumDiskCacheSize === 0) {
|
|
expect(json3Length).toEqual(0)
|
|
} else {
|
|
expect(json3Length).toBeGreaterThan(0)
|
|
expect(json3).not.toStrictEqual(json2)
|
|
}
|
|
const size = await getDirSize(imagesDir)
|
|
expect(size).toBeLessThanOrEqual(maximumDiskCacheSize)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
export const setupTests = (ctx: SetupTestsCtx) => {
|
|
const maybeSkipTurbopackProd =
|
|
shouldUseTurbopack() && !isNextDev ? describe.skip : describe
|
|
|
|
if (!ctx.nextConfigImages) {
|
|
maybeSkipTurbopackProd('w/o next.config.js', () => {
|
|
const size = 384
|
|
const { next, isNextDeploy } = nextTestSetup({
|
|
files: join(__dirname, 'app'),
|
|
nextConfig: ctx.nextConfigExperimental
|
|
? { experimental: ctx.nextConfigExperimental }
|
|
: undefined,
|
|
// The image optimizer suite asserts on the local Next.js image
|
|
// pipeline (custom upstream HTTP server, on-disk cache, response
|
|
// headers from `/_next/image`). Vercel's deploy serves images
|
|
// through its own image CDN with different headers, paths, and
|
|
// cache semantics, so these assertions don't apply.
|
|
skipDeployment: true,
|
|
})
|
|
if (isNextDeploy) return
|
|
|
|
runTests({
|
|
next,
|
|
w: size,
|
|
q: 75,
|
|
nextConfigExperimental: ctx.nextConfigExperimental,
|
|
})
|
|
})
|
|
}
|
|
|
|
maybeSkipTurbopackProd('with next.config.js', () => {
|
|
const w = isNextDev ? 400 : 399
|
|
const mergedImages = {
|
|
dangerouslyAllowLocalIP: true,
|
|
domains: [
|
|
'localhost',
|
|
'127.0.0.1',
|
|
'example.com',
|
|
'assets.vercel.com',
|
|
'image-optimization-test.vercel.app',
|
|
],
|
|
formats: ['image/avif', 'image/webp'] as any,
|
|
deviceSizes: isNextDev ? [largeSize] : [w, largeSize],
|
|
...(isNextDev ? { imageSizes: [w] } : {}),
|
|
qualities: [50, 75, 100],
|
|
...ctx.nextConfigImages,
|
|
}
|
|
|
|
const { next, isNextDeploy } = nextTestSetup({
|
|
files: join(__dirname, 'app'),
|
|
nextConfig: {
|
|
images: mergedImages,
|
|
...(ctx.nextConfigExperimental
|
|
? { experimental: ctx.nextConfigExperimental }
|
|
: {}),
|
|
},
|
|
// The image optimizer suite asserts on the local Next.js image
|
|
// pipeline (custom upstream HTTP server, on-disk cache, response
|
|
// headers from `/_next/image`). Vercel's deploy serves images
|
|
// through its own image CDN with different headers, paths, and
|
|
// cache semantics, so these assertions don't apply.
|
|
skipDeployment: true,
|
|
})
|
|
if (isNextDeploy) return
|
|
|
|
runTests({
|
|
next,
|
|
w,
|
|
q: 100,
|
|
nextConfigImages: mergedImages,
|
|
nextConfigExperimental: ctx.nextConfigExperimental,
|
|
})
|
|
})
|
|
}
|