next.js/test/integration/i18n-support/test/index.test.ts
index.test.ts629 lines18.9 KB
import http from 'http'
import fs from 'fs-extra'
import { join } from 'path'
import cheerio from 'cheerio'
import { runTests, locales, nonDomainLocales } from './shared'
import webdriver from 'next-webdriver'
import {
  nextBuild,
  nextStart,
  findPort,
  killApp,
  fetchViaHTTP,
  File,
  launchApp,
  check,
} from 'next-test-utils'
import assert from 'assert'

const appDir = join(__dirname, '../')
const nextConfig = new File(join(appDir, 'next.config.js'))
const ctx: Record<string, any> = {
  basePath: '',
  appDir,
}

describe('i18n Support', () => {
  beforeAll(async () => {
    ctx.externalPort = await findPort()
    ctx.externalApp = http.createServer((req, res) => {
      res.statusCode = 200
      res.end(JSON.stringify({ url: req.url, external: true }))
    })
    await new Promise<void>((resolve, reject) => {
      ctx.externalApp.listen(ctx.externalPort, (err) =>
        err ? reject(err) : resolve()
      )
    })
  })
  afterAll(() => ctx.externalApp.close())
  ;(process.env.TURBOPACK_BUILD ? describe.skip : describe)(
    'development mode',
    () => {
      const curCtx: Record<string, any> = {
        ...ctx,
        isDev: true,
      }
      beforeAll(async () => {
        await fs.remove(join(appDir, '.next'))
        nextConfig.replace(/__EXTERNAL_PORT__/g, ctx.externalPort)
        curCtx.appPort = await findPort()
        curCtx.app = await launchApp(appDir, curCtx.appPort)
        curCtx.buildId = 'development'
      })
      afterAll(async () => {
        await killApp(curCtx.app)
        nextConfig.restore()
      })

      runTests(curCtx)
    }
  )
  ;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
    'production mode',
    () => {
      beforeAll(async () => {
        await fs.remove(join(appDir, '.next'))
        nextConfig.replace(/__EXTERNAL_PORT__/g, ctx.externalPort)
        await nextBuild(appDir)
        ctx.appPort = await findPort()
        ctx.app = await nextStart(appDir, ctx.appPort)
        ctx.buildPagesDir = join(appDir, '.next/server/pages')
        ctx.buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8')
      })
      afterAll(async () => {
        await killApp(ctx.app)
        nextConfig.restore()
      })

      runTests(ctx)

      it('should have pre-rendered /500 correctly', async () => {
        for (const locale of locales) {
          const content = await fs.readFile(
            join(appDir, '.next/server/pages/', locale, '500.html'),
            'utf8'
          )
          expect(content).toContain('500')
          expect(content).toMatch(/Internal Server Error/i)
        }
      })
    }
  )

  describe('with localeDetection disabled', () => {
    ;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
      'production mode',
      () => {
        beforeAll(async () => {
          await fs.remove(join(appDir, '.next'))
          nextConfig.replace('// localeDetection', 'localeDetection')

          await nextBuild(appDir)
          ctx.appPort = await findPort()
          ctx.app = await nextStart(appDir, ctx.appPort)
        })
        afterAll(async () => {
          nextConfig.restore()
          await killApp(ctx.app)
        })

        it('should have localeDetection in routes-manifest', async () => {
          const routesManifest = await fs.readJSON(
            join(appDir, '.next/routes-manifest.json')
          )

          expect(routesManifest.i18n).toEqual({
            localeDetection: false,
            locales: [
              'en-US',
              'nl-NL',
              'nl-BE',
              'nl',
              'fr-BE',
              'fr',
              'en',
              'go',
              'go-BE',
              'do',
              'do-BE',
            ],
            defaultLocale: 'en-US',
            domains: [
              {
                http: true,
                domain: 'example.do',
                defaultLocale: 'do',
                locales: ['do-BE'],
              },
              {
                domain: 'example.com',
                defaultLocale: 'go',
                locales: ['go-BE'],
              },
            ],
          })
        })

        it('should not detect locale from accept-language', async () => {
          const res = await fetchViaHTTP(
            ctx.appPort,
            '/',
            {},
            {
              redirect: 'manual',
              headers: {
                'accept-language': 'fr',
              },
            }
          )

          expect(res.status).toBe(200)
          const $ = cheerio.load(await res.text())
          expect($('html').attr('lang')).toBe('en-US')
          expect($('#router-locale').text()).toBe('en-US')
          expect(JSON.parse($('#router-locales').text())).toEqual(locales)
          expect($('#router-pathname').text()).toBe('/')
          expect($('#router-as-path').text()).toBe('/')
        })

        it('should ignore the invalid accept-language header', async () => {
          nextConfig.replace('localeDetection: false', 'localeDetection: true')
          const res = await fetchViaHTTP(
            ctx.appPort,
            '/',
            {},
            {
              headers: {
                'accept-language': 'ldfir;',
              },
            }
          )

          expect(res.status).toBe(200)
          const $ = cheerio.load(await res.text())
          expect($('html').attr('lang')).toBe('en-US')
          expect($('#router-locale').text()).toBe('en-US')
          expect(JSON.parse($('#router-locales').text())).toEqual(locales)
          expect($('#router-pathname').text()).toBe('/')
          expect($('#router-as-path').text()).toBe('/')
        })

        it('should set locale from detected path', async () => {
          for (const locale of nonDomainLocales) {
            const res = await fetchViaHTTP(
              ctx.appPort,
              `/${locale}`,
              {},
              {
                redirect: 'manual',
                headers: {
                  'accept-language': 'en-US,en;q=0.9',
                },
              }
            )

            expect(res.status).toBe(200)
            const $ = cheerio.load(await res.text())
            expect($('html').attr('lang')).toBe(locale)
            expect($('#router-locale').text()).toBe(locale)
            expect(JSON.parse($('#router-locales').text())).toEqual(locales)
            expect($('#router-pathname').text()).toBe('/')
            expect($('#router-as-path').text()).toBe('/')
          }
        })
      }
    )
  })

  describe('with trailingSlash: true', () => {
    const runSlashTests = (curCtx) => {
      if (!curCtx.isDev) {
        it('should preload all locales data correctly', async () => {
          const browser = await webdriver(
            curCtx.appPort,
            `${curCtx.basePath}/mixed`
          )

          await browser.eval(`(function() {
            document.querySelector('#to-gsp-en-us').scrollIntoView()
            document.querySelector('#to-gsp-nl-nl').scrollIntoView()
            document.querySelector('#to-gsp-fr').scrollIntoView()
          })()`)

          await check(async () => {
            const hrefs = await browser.eval(
              `Object.keys(window.next.router.sdc)`
            )
            hrefs.sort()

            const baseURL = await browser.url()
            assert.deepEqual(
              hrefs.map((href) =>
                new URL(href, baseURL).pathname
                  .replace(ctx.basePath, '')
                  .replace(/^\/_next\/data\/[^/]+/, '')
              ),
              ['/en-US/gsp.json', '/fr.json', '/fr/gsp.json', '/nl-NL/gsp.json']
            )
            return 'yes'
          }, 'yes')
        })

        it('should have correct locale domain hrefs', async () => {
          const res = await fetchViaHTTP(
            curCtx.appPort,
            '/do-BE/frank/',
            undefined,
            {
              redirect: 'manual',
            }
          )
          expect(res.status).toBe(200)

          const html = await res.text()
          const $ = cheerio.load(html)

          expect($('#to-fallback-hello')[0].attribs.href).toBe(
            'http://example.do/do-BE/gsp/fallback/hello/'
          )
          expect($('#to-no-fallback-first')[0].attribs.href).toBe(
            'http://example.do/do-BE/gsp/no-fallback/first/'
          )
        })
      }

      it('should redirect correctly', async () => {
        for (const locale of nonDomainLocales) {
          const res = await fetchViaHTTP(curCtx.appPort, '/', undefined, {
            redirect: 'manual',
            headers: {
              'accept-language': locale,
            },
          })

          if (locale === 'en-US') {
            expect(res.status).toBe(200)
          } else {
            expect(res.status).toBe(307)

            const parsed = new URL(res.headers.get('location'), res.url)
            expect(parsed.pathname).toBe(`/${locale}/`)
            expect(Object.fromEntries(parsed.searchParams.entries())).toEqual(
              {}
            )
          }
        }
      })

      it('should serve pages correctly with locale prefix', async () => {
        for (const locale of nonDomainLocales) {
          for (const [pathname, asPath] of [
            ['/', '/'],
            ['/links', '/links/'],
            ['/auto-export', '/auto-export/'],
            ['/gsp', '/gsp/'],
            ['/gsp/fallback/[slug]', '/gsp/fallback/always/'],
            ['/gssp', '/gssp/'],
            ['/gssp/[slug]', '/gssp/first/'],
          ]) {
            const res = await fetchViaHTTP(
              curCtx.appPort,
              `${locale === 'en-US' ? '' : `/${locale}`}${asPath}`,
              undefined,
              {
                redirect: 'manual',
              }
            )
            expect(res.status).toBe(200)

            const $ = cheerio.load(await res.text())

            expect($('#router-pathname').text()).toBe(pathname)
            expect($('#router-as-path').text()).toBe(asPath)
            expect($('#router-locale').text()).toBe(locale)
            expect(JSON.parse($('#router-locales').text())).toEqual(locales)
            expect($('#router-default-locale').text()).toBe('en-US')
          }
        }
      })

      it('should return 404 error for repeating locales', async () => {
        const defaultLocale = 'en-US'
        for (const locale of nonDomainLocales) {
          for (const asPath of [
            '/gsp/fallback/always/',
            '/post/comment/',
            '/gssp/first/',
          ]) {
            const res = await fetchViaHTTP(
              curCtx.appPort,
              `/${locale}/${defaultLocale}${asPath}`,
              undefined,
              {
                redirect: 'manual',
              }
            )
            expect(res.status).toBe(404)
            const $ = cheerio.load(await res.text())
            const props = JSON.parse($('#props').text())
            expect($('#not-found').text().length > 0).toBe(true)
            expect(props).toEqual({
              is404: true,
              locale,
              locales,
              defaultLocale,
            })
          }
        }
      })

      it('should navigate between pages correctly', async () => {
        for (const locale of nonDomainLocales) {
          const localePath = `/${locale !== 'en-US' ? `${locale}/` : ''}`
          const browser = await webdriver(curCtx.appPort, localePath)

          await browser.eval('window.beforeNav = 1')
          await browser.elementByCss('#to-gsp').click()
          await browser.waitForElementByCss('#gsp')

          expect(await browser.elementByCss('#router-pathname').text()).toBe(
            '/gsp'
          )
          expect(await browser.elementByCss('#router-as-path').text()).toBe(
            '/gsp/'
          )
          expect(await browser.elementByCss('#router-locale').text()).toBe(
            locale
          )
          expect(await browser.eval('window.beforeNav')).toBe(1)
          expect(await browser.eval('window.location.pathname')).toBe(
            `${localePath}gsp/`
          )

          await browser.back().waitForElementByCss('#index')

          expect(await browser.elementByCss('#router-pathname').text()).toBe(
            '/'
          )
          expect(await browser.elementByCss('#router-as-path').text()).toBe('/')
          expect(await browser.elementByCss('#router-locale').text()).toBe(
            locale
          )
          expect(await browser.eval('window.beforeNav')).toBe(1)
          expect(await browser.eval('window.location.pathname')).toBe(
            `${localePath}`
          )

          await browser.elementByCss('#to-gssp-slug').click()
          await browser.waitForElementByCss('#gssp')

          expect(await browser.elementByCss('#router-pathname').text()).toBe(
            '/gssp/[slug]'
          )
          expect(await browser.elementByCss('#router-as-path').text()).toBe(
            '/gssp/first/'
          )
          expect(await browser.elementByCss('#router-locale').text()).toBe(
            locale
          )
          expect(await browser.eval('window.beforeNav')).toBe(1)
          expect(await browser.eval('window.location.pathname')).toBe(
            `${localePath}gssp/first/`
          )

          await browser.back().waitForElementByCss('#index')
          await browser.elementByCss('#to-api-post').click()

          await browser.waitForCondition(
            'window.location.pathname === "/api/post/asdf/"'
          )
          const body = await browser.elementByCss('body').text()
          const json = JSON.parse(body)
          expect(json.post).toBe(true)
        }
      })
    }

    ;(process.env.TURBOPACK_BUILD ? describe.skip : describe)(
      'development mode',
      () => {
        const curCtx: Record<string, any> = {
          ...ctx,
          isDev: true,
        }
        beforeAll(async () => {
          await fs.remove(join(appDir, '.next'))
          nextConfig.replace('// trailingSlash', 'trailingSlash')

          curCtx.appPort = await findPort()
          curCtx.app = await launchApp(appDir, curCtx.appPort)
        })
        afterAll(async () => {
          nextConfig.restore()
          await killApp(curCtx.app)
        })

        runSlashTests(curCtx)
      }
    )
    ;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
      'production mode',
      () => {
        const curCtx = {
          ...ctx,
        }
        beforeAll(async () => {
          await fs.remove(join(appDir, '.next'))
          nextConfig.replace('// trailingSlash', 'trailingSlash')

          await nextBuild(appDir)
          curCtx.appPort = await findPort()
          curCtx.app = await nextStart(appDir, curCtx.appPort)
        })
        afterAll(async () => {
          nextConfig.restore()
          await killApp(curCtx.app)
        })

        runSlashTests(curCtx)
      }
    )
  })

  describe('with trailingSlash: false', () => {
    const runSlashTests = (curCtx) => {
      it('should redirect correctly', async () => {
        for (const locale of nonDomainLocales) {
          const res = await fetchViaHTTP(curCtx.appPort, '/', undefined, {
            redirect: 'manual',
            headers: {
              'accept-language': locale,
            },
          })

          if (locale === 'en-US') {
            expect(res.status).toBe(200)
          } else {
            expect(res.status).toBe(307)

            const parsed = new URL(res.headers.get('location'), res.url)
            expect(parsed.pathname).toBe(`/${locale}`)
            expect(Object.fromEntries(parsed.searchParams.entries())).toEqual(
              {}
            )
          }
        }
      })
    }

    ;(process.env.TURBOPACK_BUILD ? describe.skip : describe)(
      'development mode',
      () => {
        const curCtx: Record<string, any> = {
          ...ctx,
          isDev: true,
        }
        beforeAll(async () => {
          await fs.remove(join(appDir, '.next'))
          nextConfig.replace('// trailingSlash: true', 'trailingSlash: false')

          curCtx.appPort = await findPort()
          curCtx.app = await launchApp(appDir, curCtx.appPort)
        })
        afterAll(async () => {
          nextConfig.restore()
          await killApp(curCtx.app)
        })

        runSlashTests(curCtx)
      }
    )
    ;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
      'production mode',
      () => {
        const curCtx = { ...ctx }
        beforeAll(async () => {
          await fs.remove(join(appDir, '.next'))
          nextConfig.replace('// trailingSlash: true', 'trailingSlash: false')

          await nextBuild(appDir)
          curCtx.appPort = await findPort()
          curCtx.app = await nextStart(appDir, curCtx.appPort)
        })
        afterAll(async () => {
          nextConfig.restore()
          await killApp(curCtx.app)
        })

        runSlashTests(curCtx)
      }
    )
  })
  ;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
    'production mode',
    () => {
      it('should show proper error for duplicate defaultLocales', async () => {
        nextConfig.write(`
      module.exports = {
        i18n: {
          locales: ['en', 'fr', 'nl'],
          defaultLocale: 'en',
          domains: [
            {
              domain: 'example.com',
              defaultLocale: 'en'
            },
            {
              domain: 'fr.example.com',
              defaultLocale: 'fr',
            },
            {
              domain: 'french.example.com',
              defaultLocale: 'fr',
            }
          ]
        }
      }
    `)

        const { code, stderr } = await nextBuild(appDir, undefined, {
          stderr: true,
        })
        nextConfig.restore()
        expect(code).toBe(1)
        expect(stderr).toContain(
          'Both fr.example.com and french.example.com configured the defaultLocale fr but only one can'
        )
      })

      it('should show proper error for duplicate locales', async () => {
        nextConfig.write(`
      module.exports = {
        i18n: {
          locales: ['en', 'fr', 'nl', 'eN', 'fr'],
          defaultLocale: 'en',
        }
      }
    `)

        const { code, stderr } = await nextBuild(appDir, undefined, {
          stderr: true,
        })
        nextConfig.restore()
        expect(code).toBe(1)
        expect(stderr).toContain(
          'Specified i18n.locales contains the following duplicate locales:'
        )
        expect(stderr).toContain(`eN, fr`)
      })

      it('should show proper error for invalid locale domain', async () => {
        nextConfig.write(`
      module.exports = {
        i18n: {
          locales: ['en', 'fr', 'nl', 'eN', 'fr'],
          domains: [
            {
              domain: 'hello:3000',
              defaultLocale: 'en',
            }
          ],
          defaultLocale: 'en',
        }
      }
    `)

        const { code, stderr } = await nextBuild(appDir, undefined, {
          stderr: true,
        })
        nextConfig.restore()
        expect(code).toBe(1)
        expect(stderr).toContain(
          `i18n domain: "hello:3000" is invalid it should be a valid domain without protocol (https://) or port (:3000) e.g. example.vercel.sh`
        )
      })
    }
  )
})
Quest for Codev2.0.0
/
SIGN IN