Files
vercel__next.js/test/e2e/image-optimizer/image-optimizer.test.ts
Jamiboy Mohammad 2ad16804ac test: enable verified assets deploy tests (#98527)
## Summary

Enable the same 11 previously selected deployment-test scopes across 11
assets test files, now in a stack rooted on canary. Remove 11
`skipDeployment` options and their obsolete skip guards. Other mode,
bundler, middleware, and Cache Components exclusions remain in place.

This preserves the selection with passing evidence from the previous
deployment runs. No additional candidate scopes are enabled; excluded
variants are not counted as deployment coverage.

## Verification

- All selected test registration names and assertion bodies match the
previous enabled revision, checked by AST comparison.
- Verified that the canary diff contains only the inventoried exclusions
and their obsolete skip plumbing; other exclusions are preserved.
- Formatting and lint passed; 77 gate infrastructure unit tests passed.
- Full local bootstrap was blocked by missing package-level dependencies
in the temporary worktree. Fresh deployment execution on these rewritten
commits remains to be verified in CI.

<details>
<summary>Preserved scope inventory (11)</summary>

- ID 286: `test/e2e/app-dir/app-css-pageextensions/index.test.ts` —
`describe('app dir - css with pageextensions', () => {
  const { next } = nextTestSetup({
    files: __dirname,
    dependencies: {
      '@picocss/pico': '1.5.7',
      sass: 'latest',
    },
  })

  describe('css support with pageextensions', () => {
describe('page in app directory with pageextention, css should work', ()
=> {
      it('should support global css inside layout', async () => {
        const browser = await next.browser('/css-pageextensions')
        expect(
          await browser.eval(
`window.getComputedStyle(document.querySelector('h1')).color`
          )
        ).toBe('rgb(255, 0, 0)')
      })
    })
  })
})`
- ID 292: `test/e2e/app-dir/dynamic-css/index.test.ts` — `describe('app
dir - dynamic css', () => {
  const { next } = nextTestSetup({
    files: __dirname,
  })

it('should preload all chunks of dynamic component during SSR', async ()
=> {
    const $ = await next.render$('/ssr')
const cssLinks = $('link[rel="stylesheet"][data-precedence="dynamic"]')
    expect(cssLinks.attr('href')).toContain('.css')

    const preloadJsChunks = $('link[rel="preload"]')
    expect(preloadJsChunks.attr('as')).toBe('script')
    expect(preloadJsChunks.attr('fetchpriority')).toContain(`low`)
  })

it('should only apply corresponding css for page loaded that /ssr',
async () => {
    const browser = await next.browser('/ssr')
    await retry(async () => {
      expect(
        await browser.eval(
`window.getComputedStyle(document.querySelector('.text')).color`
        )
      ).toBe('rgb(255, 0, 0)')
// Default border width, which is not effected by bar.css that is not
loaded in /ssr
      expect(
        await browser.eval(
`window.getComputedStyle(document.querySelector('.text')).borderWidth`
        )
      ).toBe('0px')
    })
  })

it('should only apply corresponding css for page loaded in edge
runtime', async () => {
    const browser = await next.browser('/ssr/edge')
    await retry(async () => {
      expect(
        await browser.eval(
`window.getComputedStyle(document.querySelector('.text')).color`
        )
      ).toBe('rgb(255, 0, 0)')
// Default border width, which is not effected by bar.css that is not
loaded in /ssr
      expect(
        await browser.eval(
`window.getComputedStyle(document.querySelector('.text')).borderWidth`
        )
      ).toBe('0px')
    })
  })

it('should only apply corresponding css for page loaded that /another',
async () => {
    const browser = await next.browser('/another')
    await retry(async () => {
      expect(
        await browser.eval(
`window.getComputedStyle(document.querySelector('.text')).color`
        )
      ).not.toBe('rgb(255, 0, 0)')
// Default border width, which is not effected by bar.css that is not
loaded in /ssr
      expect(
        await browser.eval(
`window.getComputedStyle(document.querySelector('.text')).borderWidth`
        )
      ).toBe('1px')
    })
  })

it('should not throw with accessing to ALS in preload css', async () =>
{
    const output = next.cliOutput
    expect(output).not.toContain('was called outside a request scope')
  })
})`
- ID 293: `test/e2e/app-dir/emotion-js/index.test.ts` — `describe('app
dir - emotion-js', () => {
  const { next } = nextTestSetup({
    files: __dirname,
    dependencies: {
      '@emotion/react': 'latest',
      '@emotion/cache': 'latest',
    },
  })

it('should render emotion-js css with compiler.emotion option
correctly', async () => {
    const browser = await next.browser('/')
    const el = browser.elementByCss('h1')
    expect(await el.text()).toBe('Blue')
    await check(
      async () =>
        await browser.eval(
          `window.getComputedStyle(document.querySelector('h1')).color`
        ),
      'rgb(0, 0, 255)'
    )

    const el2 = browser.elementByCss('p')
    expect(await el2.text()).toBe('Red')
    await check(
      async () =>
        await browser.eval(
          `window.getComputedStyle(document.querySelector('p')).color`
        ),
      'rgb(255, 0, 0)'
    )
  })
})`
- ID 294:
`test/e2e/app-dir/global-error/with-style-import/index.test.ts` —
`describe('app dir - global error - with style import', () => {
  const { next, isNextDev } = nextTestSetup({
    files: __dirname,
  })

  it('should render global error with correct styles', async () => {
    const browser = await next.browser('/')

    if (isNextDev) {
      await testDev(browser, /Root Layout Error/)
      return
    }

    const h2 = await browser.elementByCss('h2')
expect(await h2.getComputedCss('color')).toBe('rgb(255, 255, 0)') //
yellow
  })
})`
- ID 301: `test/e2e/app-dir/not-found/css-precedence/index.test.ts` —
`describe('not-found app dir css', () => {
  const { next } = nextTestSetup({
    files: __dirname,
    dependencies: {
      sass: 'latest',
    },
  })

it('should load css while navigation between not-found and page', async
() => {
    const browser = await next.browser('/')
    await check(
      async () =>
        await browser.eval(

`window.getComputedStyle(document.querySelector('#go-to-404')).backgroundColor`
        ),
      'rgb(0, 128, 0)'
    )
    await browser.elementByCss('#go-to-404').click()
    await browser.waitForElementByCss('#go-to-index')
    await check(
      async () =>
        await browser.eval(

`window.getComputedStyle(document.querySelector('#go-to-index')).backgroundColor`
        ),
      'rgb(0, 128, 0)'
    )
    await browser.elementByCss('#go-to-index').click()
    await browser.waitForElementByCss('#go-to-404')
    await check(
      async () =>
        await browser.eval(

`window.getComputedStyle(document.querySelector('#go-to-404')).backgroundColor`
        ),
      'rgb(0, 128, 0)'
    )
  })
})`
- ID 308:
`test/e2e/app-dir/turbopack-postcss-multiple-configs/turbopack-postcss-multiple-configs.test.ts`
— `describe('turbopack-postcss-multiple-configs', () => {
  const { next, isTurbopack } = nextTestSetup({
    files: __dirname,
// Per-directory PostCSS config resolution is a Turbopack-only feature
// (turbopackLocalPostcssConfig). Webpack does not support this feature
and
// does not accept function-valued PostCSS plugins, so skip
non-Turbopack runs.
    skipStart: true,
  })

  if (!isTurbopack) {
    it('should only run with Turbopack', () => {})
    return
  }

  beforeAll(async () => {
    await next.start()
  })

// Each directory's postcss.config.js passes a unique color option to
the
  // shared plugin, which replaces `color: red` with the given color.
  // In production mode the CSS minifier may shorten named colors to hex
  // (e.g. blue → #00f), so we match on patterns that cover both forms.
  const DIR_COLORS: Record<number, string | RegExp> = {
    1: /blue|#00f/,
    2: /purple|#800080/,
    3: /orange|#ffa500/,
    4: /cyan|#0ff/,
    5: /magenta|#f0f/,
  }

  const DIRS = 5
  const FILES_PER_DIR = 3

it('should render all elements with CSS module classes applied', async
() => {
    const $ = await next.render$('/')

    for (let dir = 1; dir <= DIRS; dir++) {
      for (let file = 1; file <= FILES_PER_DIR; file++) {
        const padded = String(file).padStart(2, '0')
        const id = `dir${dir}-file${padded}`
        const el = $(`#${id}`)
        expect(el.length).toBe(1)
        expect(el.text().trim()).toBe(`dir${dir} file${padded}`)
        expect(el.attr('class')).toBeTruthy()
      }
    }
  })

it('should apply per-directory PostCSS transforms with distinct colors',
async () => {
    const cssContent = await collectCss(next)

    // Each directory's PostCSS config passes a unique color option.
    // Verify every expected color appears in the output.
    for (const [, pattern] of Object.entries(DIR_COLORS)) {
      expect(cssContent).toMatch(pattern)
    }

    // No original `color: red` should remain — all were transformed.
    expect(cssContent).not.toMatch(/color\s*:\s*red/)

// The old hardcoded green should NOT appear, proving options are used.
    expect(cssContent).not.toMatch(/green|#0f0|#008000/)
  })
})`
- ID 317: `test/e2e/image-optimizer/image-optimizer.test.ts` —
`describe('Server support for trailingSlash in next.config.js', () => {
    const { next } = nextTestSetup({
      files: join(__dirname, 'app'),
      nextConfig: {
        trailingSlash: true,
        images: {
          imageSizes: [8, 16, 32, 48, 64, 96, 128, 256, 384],
          qualities: [70, 75],
        },
      },
    })

it('should return successful response for original loader', async () =>
{
      const query = { url: '/test.png', w: 8, q: 70 }
const res = await next.fetch(`/_next/image/?${toQueryString(query)}`)
      expect(res.status).toBe(200)
    })
  })`
- ID 329: `test/e2e/next-image-legacy/default/default-static.test.ts` —
`describe('Static Image Component Tests', () => {
  const { next, isTurbopack } = nextTestSetup({
    files: __dirname,
  })

  let browser: Playwright
  let html: string

  beforeAll(async () => {
    html = await next.render('/static-img')
    browser = await next.browser('/static-img')
  })

it('Should allow an image with a static src to omit height and width',
async () => {
    expect(await browser.elementById('basic-static')).toBeTruthy()
    expect(await browser.elementById('blur-png')).toBeTruthy()
    expect(await browser.elementById('blur-webp')).toBeTruthy()
    expect(await browser.elementById('blur-avif')).toBeTruthy()
    expect(await browser.elementById('blur-jpg')).toBeTruthy()
    expect(await browser.elementById('static-svg')).toBeTruthy()
    expect(await browser.elementById('static-gif')).toBeTruthy()
    expect(await browser.elementById('static-bmp')).toBeTruthy()
    expect(await browser.elementById('static-ico')).toBeTruthy()
    expect(await browser.elementById('static-unoptimized')).toBeTruthy()
  })
  ;(isNextStart ? it : it.skip)(
    'Should use immutable cache-control header for static import',
    async () => {
      await browser.eval(
        `document.getElementById("basic-static").scrollIntoView()`
      )
      await new Promise((resolve) => setTimeout(resolve, 1000))
      const url = await browser.eval(
        `document.getElementById("basic-static").src`
      )
      const res = await fetch(url)
      expect(res.headers.get('cache-control')).toBe(
        'public, max-age=315360000, immutable'
      )
    }
  )
  ;(isNextStart ? it : it.skip)(
    'Should use immutable cache-control header even when unoptimized',
    async () => {
      await browser.eval(
        `document.getElementById("static-unoptimized").scrollIntoView()`
      )
      await new Promise((resolve) => setTimeout(resolve, 1000))
      const url = await browser.eval(
        `document.getElementById("static-unoptimized").src`
      )
      const res = await fetch(url)
      expect(res.headers.get('cache-control')).toBe(
        'public, max-age=31536000, immutable'
      )
    }
  )

it('Should automatically provide an image height and width', async () =>
{
    expect(html).toContain('width:400px;height:300px')
  })

it('Should allow provided width and height to override intrinsic', async
() => {
    expect(html).toContain('width:200px;height:200px')
    expect(html).not.toContain('width:400px;height:400px')
  })

it('Should add a blur placeholder to statically imported jpg', async ()
=> {
    const $ = cheerio.load(html)
    const style = $('#basic-static').attr('style')
    if (isNextDev && !isTurbopack) {
// In webpack dev, `next/legacy/image` emits a dynamic blur URL via the
// image optimizer route instead of an inlined base64 data URL, to avoid
      // slowing down the dev server (see
// `packages/next/src/build/webpack/loaders/next-image-loader/blur.ts`).
      expect(replaceBlurUrl(style)).toMatchInlineSnapshot(

`"position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0%
0%;filter:blur(20px);background-image:url("<REPLACED_BLUR_URL>")"`
      )
    } else {
      expect(replaceDataUrl(style)).toMatchInlineSnapshot(

`"position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0%
0%;filter:blur(20px);background-image:url("data:<REPLACED>")"`
      )
    }
  })

it('Should add a blur placeholder to statically imported png', async ()
=> {
    const $ = cheerio.load(html)
    const style = $('#basic-static')[2].attribs.style
    if (isTurbopack) {
      expect(style).toMatchInlineSnapshot(

`"position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0%
0%;filter:blur(20px);background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAICAYAAAA870V8AAAARUlEQVR42l3MoQ0AQQhE0XG7xWwIJSBIKBRJOZRBEXOWnPjimQ8AXC3ce+nuPOcQEcHuppkRVcWZYWYSIkJV5XvvN9j4AFZHJTnjDHb/AAAAAElFTkSuQmCC")"`
      )
    } else if (isNextDev) {
// In webpack dev, `next/legacy/image` emits a dynamic blur URL via the
// image optimizer route instead of an inlined base64 data URL, to avoid
      // slowing down the dev server (see
// `packages/next/src/build/webpack/loaders/next-image-loader/blur.ts`).
      expect(replaceBlurUrl(style)).toMatchInlineSnapshot(

`"position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0%
0%;filter:blur(20px);background-image:url("<REPLACED_BLUR_URL>")"`
      )
    } else {
// In webpack start, the exact base64 output of the blur placeholder
// depends on the environment's sharp/libvips version, so normalize the
      // data URL contents to only assert the data URL prefix.
      expect(replaceDataUrl(style)).toMatchInlineSnapshot(

`"position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0%
0%;filter:blur(20px);background-image:url("data:<REPLACED>")"`
      )
    }
  })

  it('should load direct imported image', async () => {
const src = await
browser.elementById('basic-static').getAttribute('src')
    expect(src).toMatch(

/_next\/image\?url=%2F_next%2Fstatic%2F(immutable%2F)?media%2Ftest-rect(.+)\.jpg&w=828&q=75/
    )
    const fullSrc = new URL(src, next.url)
    const res = await fetch(fullSrc)
    expect(res.status).toBe(200)
  })

  it('should load staticprops imported image', async () => {
    const src = await browser
      .elementById('basic-staticprop')
      .getAttribute('src')
    expect(src).toMatch(

/_next\/image\?url=%2F_next%2Fstatic%2F(immutable%2F)?media%2Fexif-rotation(.+)\.jpg&w=256&q=75/
    )
    const fullSrc = new URL(src, next.url)
    const res = await fetch(fullSrc)
    expect(res.status).toBe(200)
  })
})`
- ID 345: `test/e2e/next-image-svgo-webpack/svgo-webpack.test.ts` —
`describe('svgo-webpack loader', () => {
  const { next } = nextTestSetup({
    files: __dirname,
    dependencies: {
      '@svgr/webpack': '8.1.0',
    },
  })

it('should render an SVG that is transformed by @svgr/webpack into a
React component (pages router)', async () => {
    const browser = await next.browser('/pages')
    expect(await browser.elementByCss('svg')).toBeDefined()
  })

it('should render an SVG that is transformed by @svgr/webpack into a
React component (app router)', async () => {
    const browser = await next.browser('/')
    expect(await browser.elementByCss('svg')).toBeDefined()
  })
})`
- ID 347: `test/e2e/styled-jsx-dynamic/index.test.ts` —
`describe('styled-jsx dynamic styles SSR', () => {
  const { next } = nextTestSetup({
    files: __dirname,
  })

// Dynamic styled-jsx (with interpolated expressions) produces numeric
class
// names at runtime via the DJB2 hash in styled-jsx's computeId
function.
// This pattern matches production deployments where all jsx class names
// are numeric (e.g. jsx-2267428885) rather than hex
(jsx-f36313d9f07883b7).
it('should contain dynamic styled-jsx styles during SSR', async () => {
    const html = await next.render('/')

    // Dynamic styled-jsx produces numeric class names at runtime
    const numericClasses = html.match(/\bjsx-\d+\b/g) || []
    console.log('Numeric jsx classes:', [...new Set(numericClasses)])
    expect(numericClasses.length).toBeGreaterThan(0)

    // All dynamic styles should be present as inline <style> tags
    expect(html).toMatch(/color:.*?green/) // main page
    expect(html).toMatch(/color:.*?blue/) // DynamicStyled
    expect(html).toMatch(/background-color:.*?navy/) // header
    expect(html).toMatch(/color:.*?purple/) // footer
  })
})`
- ID 348: `test/e2e/styled-jsx/index.test.ts` — `describe('styled-jsx',
() => {
  const { next } = nextTestSetup({
    files: __dirname,
    dependencies: {
      'styled-jsx': '5.0.0', // styled-jsx on user side
    },
  })

  it('should contain styled-jsx styles during SSR', async () => {
    const html = await next.render('/')
    expect(html).toMatch(/color:.*?red/)
    expect(html).toMatch(/color:.*?cyan/)
  })

  it('should render styles during CSR', async () => {
    const browser = await next.browser('/')
    const color = await browser.eval(
      `getComputedStyle(document.querySelector('button')).color`
    )

    expect(color).toMatch('0, 255, 255')
  })

  it('should render styles inside TypeScript', async () => {
    const browser = await next.browser('/typescript')
    const color = await browser.eval(
      `getComputedStyle(document.querySelector('button')).color`
    )

    expect(color).toMatch('255, 0, 0')
  })
})`

</details>

<details>
<summary>Deployment evidence for the additional scopes</summary>

- `test/e2e/image-optimizer/image-optimizer.test.ts` — `describe('Server
support for trailingSlash in next.config.js', () => {`:
[normal](https://github.com/vercel/next.js/actions/runs/34426784928/job/102715630953),
[cache](https://github.com/vercel/next.js/actions/runs/34426784928/job/102715630856).
- `test/e2e/next-image-legacy/default/default-static.test.ts` —
`describe('Static Image Component Tests', () => {`:
[normal](https://github.com/vercel/next.js/actions/runs/34426784928/job/102715630923),
[cache](https://github.com/vercel/next.js/actions/runs/34426784928/job/102715630827).

</details>

<!-- NEXT_JS_LLM -->
2026-09-15 10:29:07 -07:00

533 lines
17 KiB
TypeScript

import { join } from 'path'
import { nextTestSetup, isNextDev, isNextStart } from 'e2e-utils'
import { check } from 'next-test-utils'
import { cleanImagesDir, expectWidth, fsToJson } from './util'
function toQueryString(query: Record<string, any>): string {
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()
}
const largeSize = 1080
describe('Image Optimizer', () => {
describe('config checks', () => {
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'app'),
skipStart: true,
skipDeployment: true,
})
if (skipped) return
const configChecks: Array<{
name: string
config: string
expected: string | string[]
}> = [
{
name: 'should error when domains length exceeds 50',
config: JSON.stringify({
images: { domains: new Array(51).fill('google.com') },
}),
expected:
'Array must contain at most 50 element(s) at "images.domains"',
},
{
name: 'should error when localPatterns length exceeds 25',
config: JSON.stringify({
images: {
localPatterns: Array.from({ length: 26 }).map(() => ({
pathname: '/foo/**',
})),
},
}),
expected:
'Array must contain at most 25 element(s) at "images.localPatterns"',
},
{
name: 'should error when localPatterns has invalid prop',
config: JSON.stringify({
images: {
localPatterns: [{ pathname: '/foo/**', foo: 'bar' }],
},
}),
expected: `Unrecognized key(s) in object: 'foo' at "images.localPatterns[0]"`,
},
{
name: 'should error when remotePatterns length exceeds 50',
config: JSON.stringify({
images: {
remotePatterns: Array.from({ length: 51 }).map(() => ({
hostname: 'example.com',
})),
},
}),
expected:
'Array must contain at most 50 element(s) at "images.remotePatterns"',
},
{
name: 'should error when remotePatterns has invalid prop',
config: JSON.stringify({
images: {
remotePatterns: [{ hostname: 'example.com', foo: 'bar' }],
},
}),
expected: `Unrecognized key(s) in object: 'foo' at "images.remotePatterns[0]"`,
},
{
name: 'should error when remotePatterns is missing hostname',
config: JSON.stringify({
images: { remotePatterns: [{ protocol: 'https' }] },
}),
expected: `"images.remotePatterns[0].hostname" is missing, expected string`,
},
{
name: 'should error when sizes length exceeds 25',
config: JSON.stringify({
images: { deviceSizes: new Array(51).fill(1024) },
}),
expected:
'Array must contain at most 25 element(s) at "images.deviceSizes"',
},
{
name: 'should error when deviceSizes contains invalid widths',
config: JSON.stringify({
images: { deviceSizes: [0, 12000, 64, 128, 256] },
}),
expected: [
'Number must be greater than or equal to 1 at "images.deviceSizes[0]"',
'Number must be less than or equal to 10000 at "images.deviceSizes[1]"',
],
},
{
name: 'should error when imageSizes contains invalid widths',
config: JSON.stringify({
images: { imageSizes: [0, 16, 64, 12000] },
}),
expected: [
'Number must be greater than or equal to 1 at "images.imageSizes[0]"',
'Number must be less than or equal to 10000 at "images.imageSizes[3]"',
],
},
{
name: 'should error when qualities length exceeds 20',
config: JSON.stringify({
images: {
qualities: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
20, 21,
],
},
}),
expected:
'Array must contain at most 20 element(s) at "images.qualities"',
},
{
name: 'should error when qualities array has a value thats not an integer',
config: JSON.stringify({
images: { qualities: [1, 2, 3, 9.9] },
}),
expected: 'Expected integer, received float at "images.qualities[3]"',
},
{
name: 'should error when qualities array is empty',
config: JSON.stringify({
images: { qualities: [] },
}),
expected:
'Array must contain at least 1 element(s) at "images.qualities"',
},
{
name: 'should error when loader contains invalid value',
config: JSON.stringify({
images: { loader: 'notreal' },
}),
expected: `Expected 'default' | 'imgix' | 'cloudinary' | 'akamai' | 'custom', received 'notreal' at "images.loader"`,
},
{
name: 'should error when images.formats contains invalid values',
config: JSON.stringify({
images: { formats: ['image/avif', 'jpeg'] },
}),
expected: `Expected 'image/avif' | 'image/webp', received 'jpeg' at "images.formats[1]"`,
},
{
name: 'should error when images.loader is assigned but images.path is not',
config: JSON.stringify({
images: { loader: 'imgix' },
}),
expected:
'Specified images.loader property (imgix) also requires images.path property to be assigned to a URL prefix.',
},
{
name: 'should error when images.loader and images.loaderFile are both assigned',
config: JSON.stringify({
images: {
loader: 'imgix',
path: 'https://example.com',
loaderFile: './dummy.js',
},
}),
expected:
'Specified images.loader property (imgix) cannot be used with images.loaderFile property. Please set images.loader to "custom".',
},
{
name: 'should error when images.loaderFile does not exist',
config: JSON.stringify({
images: { loaderFile: './fakefile.js' },
}),
expected: 'Specified images.loaderFile does not exist at',
},
{
name: 'should error when images.dangerouslyAllowSVG is not a boolean',
config: JSON.stringify({
images: { dangerouslyAllowSVG: 'foo' },
}),
expected:
'Expected boolean, received string at "images.dangerouslyAllowSVG"',
},
{
name: 'should error when images.contentSecurityPolicy is not a string',
config: JSON.stringify({
images: { contentSecurityPolicy: 1 },
}),
expected:
'Expected string, received number at "images.contentSecurityPolicy"',
},
{
name: 'should error when assetPrefix is provided but is invalid',
config: JSON.stringify({
assetPrefix: 'httpbad',
images: { formats: ['image/webp'] },
}),
expected: [
'Invalid assetPrefix provided. Original error:',
'Invalid URL',
],
},
{
name: 'should error when images.remotePatterns is invalid',
config: JSON.stringify({
images: { remotePatterns: 'testing' },
}),
expected: 'Expected array, received string at "images.remotePatterns"',
},
{
name: 'should error when images.remotePatterns URL has invalid protocol',
config: `{ images: { remotePatterns: [new URL('file://example.com/**')] } }`,
expected:
'Specified images.remotePatterns must have protocol "http" or "https" received "file"',
},
{
name: 'should error when images.contentDispositionType is not valid',
config: JSON.stringify({
images: { contentDispositionType: 'nope' },
}),
expected: `Expected 'inline' | 'attachment', received 'nope' at "images.contentDispositionType"`,
},
{
name: 'should error when images.minimumCacheTTL is not valid',
config: JSON.stringify({
images: { minimumCacheTTL: -1 },
}),
expected:
'Number must be greater than or equal to 0 at "images.minimumCacheTTL"',
},
{
name: 'should error when images.unoptimized is not a boolean',
config: JSON.stringify({
images: { unoptimized: 'yup' },
}),
expected: 'Expected boolean, received string at "images.unoptimized"',
},
]
for (const { name, config, expected } of configChecks) {
it(name, async () => {
const outputBefore = next.cliOutput.length
await next.patchFile('next.config.js', `module.exports = ${config}`)
await next.build()
const newOutput = next.cliOutput.slice(outputBefore)
const expectations = Array.isArray(expected) ? expected : [expected]
for (const exp of expectations) {
expect(newOutput).toContain(exp)
}
await next.patchFile(
'next.config.js',
'// prettier-ignore\nmodule.exports = {}'
)
})
}
})
describe('Server support for trailingSlash in next.config.js', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'app'),
nextConfig: {
trailingSlash: true,
images: {
imageSizes: [8, 16, 32, 48, 64, 96, 128, 256, 384],
qualities: [70, 75],
},
},
})
it('should return successful response for original loader', async () => {
const query = { url: '/test.png', w: 8, q: 70 }
const res = await next.fetch(`/_next/image/?${toQueryString(query)}`)
expect(res.status).toBe(200)
})
})
;(isNextStart ? describe : describe.skip)(
'Server support for headers in next.config.js',
() => {
const size = 96
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'app'),
skipDeployment: true,
})
if (skipped) return
beforeAll(async () => {
await next.patchFile(
'next.config.js',
`module.exports = {
async headers() {
return [
{
source: '/test.png',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=14400, must-revalidate',
},
],
},
]
},
}`
)
})
afterAll(async () => {
await next.patchFile(
'next.config.js',
'// prettier-ignore\nmodule.exports = { /* replaceme */ }'
)
})
it('should set max-age header', async () => {
const query = { url: '/test.png', w: size, q: 75 }
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('Cache-Control')).toBe(
'public, max-age=14400, must-revalidate'
)
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="test.webp"'
)
const imagesDir = join(next.testDir, '.next', 'cache', 'images')
await check(async () => {
const files = await fsToJson(imagesDir)
let found = false
const maxAge = '14400'
Object.keys(files).forEach((dir) => {
if (
Object.keys(files[dir]).some((file) =>
file.includes(`${maxAge}.`)
)
) {
found = true
}
})
return found ? 'success' : 'failed'
}, 'success')
})
it('should not set max-age header when not matching next.config.js', async () => {
const query = { url: '/test.jpg', w: size, q: 75 }
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('Cache-Control')).toBe(
'public, max-age=14400, must-revalidate'
)
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="test.webp"'
)
})
}
)
;(isNextDev ? describe : describe.skip)(
'dev support next.config.js cloudinary loader',
() => {
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'app'),
nextConfig: {
images: {
loader: 'cloudinary',
path: 'https://example.com/act123/',
},
},
skipDeployment: true,
})
if (skipped) return
it('should 404 when loader is not default', async () => {
const size = 384
const query = { w: size, q: 90, url: '/test.svg' }
const opts = { headers: { accept: 'image/webp' } }
const res = await next.fetch(
`/_next/image?${toQueryString(query)}`,
opts
)
expect(res.status).toBe(404)
})
}
)
;(isNextDev ? describe : describe.skip)(
'images.unoptimized in next.config.js',
() => {
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'app'),
nextConfig: {
images: { unoptimized: true },
},
skipDeployment: true,
})
if (skipped) return
it('should 404 when unoptimized', async () => {
const size = 384
const query = { w: size, q: 75, url: '/test.jpg' }
const opts = { headers: { accept: 'image/webp' } }
const res = await next.fetch(
`/_next/image?${toQueryString(query)}`,
opts
)
expect(res.status).toBe(404)
})
}
)
;(isNextDev ? describe : describe.skip)(
'experimental.imgOptMaxInputPixels in next.config.js',
() => {
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'app'),
nextConfig: {
experimental: { imgOptMaxInputPixels: 100 },
},
skipDeployment: true,
})
if (skipped) return
it('should fallback to source image when input exceeds imgOptMaxInputPixels', async () => {
const size = 256
const query = { w: size, q: 75, url: '/test.jpg' }
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/jpeg')
})
}
)
;(isNextStart ? describe : describe.skip)(
'External rewrite support with for serving static content in images',
() => {
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'app'),
nextConfig: {
async rewrites() {
return [
{
source: '/:base(next-js)/:rest*',
destination:
'https://assets.vercel.com/image/upload/v1538361091/repositories/:base/:rest*',
},
]
},
},
skipDeployment: true,
})
if (skipped) return
it('should return response when image is served from an external rewrite', async () => {
const imagesDir = join(next.testDir, '.next', 'cache', 'images')
await cleanImagesDir(imagesDir)
const query = { url: '/next-js/next-js-bg.png', w: 64, q: 75 }
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=31536000, must-revalidate'
)
expect(res.headers.get('Vary')).toBe('Accept')
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="next-js-bg.webp"'
)
await check(async () => {
const files = await fsToJson(imagesDir)
let found = false
const maxAge = '31536000'
Object.keys(files).forEach((dir) => {
if (
Object.keys(files[dir]).some((file) =>
file.includes(`${maxAge}.`)
)
) {
found = true
}
})
return found ? 'success' : 'failed'
}, 'success')
await expectWidth(res, 64)
})
}
)
;(isNextDev ? describe : describe.skip)(
'dev support for dynamic blur placeholder',
() => {
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'app'),
nextConfig: {
images: {
deviceSizes: [largeSize],
imageSizes: [],
},
},
skipDeployment: true,
})
if (skipped) return
it('should support width 8 per BLUR_IMG_SIZE with next dev', 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
)
expect(res.status).toBe(200)
await expectWidth(res, 320)
})
}
)
})