Files
Tim NeutkensandGitHub 8141dcf12e Convert test/integration to isolated tests (#93247)
### What?

Converts every test under `test/integration/` to an isolated test
running through `nextTestSetup` (under `test/e2e/`, `test/production/`,
`test/development/`, or `test/unit/`), then deletes `test/integration/`
along with the legacy CI orchestration that was specific to it.

- `test/integration/` removed entirely (~327 test suites)
- New isolated suites added across the existing folders:
  - `test/e2e/` — 175
  - `test/production/` — 130
  - `test/development/` — 43
  - `test/unit/` — 1
- `.github/workflows/build_and_test.yml` and `run-tests.js` no longer
have any `integration` branches
- `nextTestSetup` gained a `baseUrl` option on `next.browser()` so a
small number of tests that drive their own proxy/static-export server
can keep using `next.browser(...)` instead of importing `next-webdriver`
directly

### Why?

`test/integration/` predated `nextTestSetup` and ran tests directly
against the source checkout via custom helpers (`launchApp`,
`nextBuild`, `nextStart`, `runNextCommand`, `webdriver`, `fetchViaHTTP`,
…). Each suite hand-rolled its own dev/start/build orchestration,
fixture mutation, and process management.

The isolated test model used by the rest of the repo gives each suite an
isolated working directory containing a packed `next.tgz` install, a
uniform `next.start()` / `next.build()` / `next.fetch()` /
`next.browser()` API, and the same lifecycle for dev, start, and deploy
modes — so a single set of assertions covers all three. Deploy-mode
skips and per-feature gates are expressed declaratively
(`skipDeployment`, `disableAutoSkewProtection`, `if (skipped) return`)
instead of branching on `process.env`.

Removing `test/integration/` lets us:

- Delete the bespoke orchestration code in the CI workflow and
`run-tests.js`
- Run every converted suite consistently in dev, start, and deploy modes
(where applicable)
- Reproduce every test locally with the same `pnpm
test-{dev,start}-{turbo,webpack}` commands; no separate `integration`
path
- Open the door to running `test/production` against deployments in the
future (the converted suites already declare `skipDeployment` so they
can be flipped on)

### How?

Mechanical conversion per suite, with targeted clean-ups:

1. **Per-suite conversion.** Each
`test/integration/<name>/test/index.test.{js,ts}` was rewritten into a
single `<name>.test.ts` under the right folder based on what the
original exercised:
   - `launchApp` / dev-only assertions → `test/development/`
- `nextBuild` + `nextStart` / start-only assertions → `test/production/`
   - Both → `test/e2e/`
- The one pure jsdom render check (`link-without-router`) → `test/unit/`

2. **API mapping.** Custom helpers were replaced by `nextTestSetup`
equivalents: `launchApp` → `next.start()`, `nextBuild` → `next.build()`,
`runNextCommand` → `next.runCommand`, `fetchViaHTTP` → `next.fetch`,
`webdriver(...)` → `next.browser(...)`. Fixture mutations switched from
raw `fs.writeFile`/`fs.rename` to `next.patchFile` (with the 3-arg
`runWithTempContent` callback when the change has a defined scope) and
`next.deleteFile`.

3. **Deploy-mode handling.** Suites that can't run in deploy mode (use
`patchFile` / `next.build()` / depend on local CLI output) declare
`skipDeployment: true` and early-return on the `skipped` boolean. Suites
where Vercel's edge mutates URLs (`&dpl=`, immutable assets) declare
`disableAutoSkewProtection: true`.

4. **`next.browser({ baseUrl })`.** A handful of tests
(`prerender-export`, `cdn-cache-busting`, `preload-viewport`, both
`react-virtualized` suites) need to drive a separate server (a
static-export server or an `http-proxy` instance) rather than the
Next.js process. Instead of importing `next-webdriver` directly, those
tests now pass `{ baseUrl: <port|url> }` to `next.browser()`. For the
proxy cases, the proxy was moved into `server.js` inside the fixture and
`http-proxy` declared via the `dependencies` option of `nextTestSetup`,
so the test runs with a fully isolated dependency graph.

5. **CI clean-up.** With `test/integration` gone, the `test
integration*` jobs and `integration-tests-manifest`-related logic in
`.github/workflows/build_and_test.yml` were removed, and `run-tests.js`
no longer has the `integration` test-folder branch.

6. **Validation.** The PR was iterated against multiple full CI runs;
the remaining failures on the latest run are pre-existing flakes
(segment-cache 60s `act` timeouts in turbopack-prod) or transient
infrastructure issues unrelated to the conversion.
2026-05-11 13:55:47 +02:00

597 lines
18 KiB
TypeScript

/* eslint-disable jest/no-standalone-expect */
import { spawn, ChildProcess } from 'child_process'
import { join } from 'path'
import { nextTestSetup } from 'e2e-utils'
import {
retry,
waitFor,
getClientBuildManifestLoaderChunkUrlPath,
} from 'next-test-utils'
describe('Prefetching Links in viewport', () => {
const { next, isTurbopack, skipped } = nextTestSetup({
files: __dirname,
skipStart: true,
skipDeployment: true,
dependencies: {
'http-proxy': '1.18.1',
},
})
if (skipped) return
let proxyChild: ChildProcess
let proxyPort: number
let buildId: string
async function getDataRequests(): Promise<string[]> {
const res = await fetch(`http://localhost:${proxyPort}/_test/data-requests`)
const data = (await res.json()) as { nextDataRequests: string[] }
return data.nextDataRequests
}
async function resetDataRequests() {
await fetch(`http://localhost:${proxyPort}/_test/data-requests/reset`)
}
async function setStall(on: boolean) {
await fetch(
`http://localhost:${proxyPort}/_test/stall/${on ? 'on' : 'off'}`
)
}
beforeAll(async () => {
await next.build()
buildId = await next.readFile('.next/BUILD_ID')
await next.start({ skipBuild: true })
proxyChild = spawn(
process.execPath,
[join(next.testDir, 'server.js'), next.url, '0'],
{ stdio: ['ignore', 'pipe', 'inherit'] }
)
proxyPort = await new Promise<number>((resolve, reject) => {
let buf = ''
const onData = (chunk: Buffer) => {
buf += chunk.toString()
const m = buf.match(/__PORT__:(\d+)/)
if (m) {
proxyChild.stdout!.off('data', onData)
resolve(Number(m[1]))
}
}
proxyChild.stdout!.on('data', onData)
proxyChild.once('exit', (code) => {
reject(new Error(`proxy server exited early with code ${code}`))
})
})
})
afterAll(async () => {
proxyChild?.kill()
})
it('should de-dupe inflight SSG requests', async () => {
await resetDataRequests()
const browser = await next.browser('/', { baseUrl: proxyPort })
await browser.eval(function navigate() {
;(window as any).next.router.push('/ssg/slow')
;(window as any).next.router.push('/ssg/slow')
;(window as any).next.router.push('/ssg/slow')
})
await browser.waitForElementByCss('#content')
const dataRequests = await getDataRequests()
expect(
dataRequests.filter((reqUrl) => reqUrl.includes('/ssg/slow.json')).length
).toBe(2)
})
it('should handle timed out prefetch correctly', async () => {
try {
await setStall(true)
const browser = await next.browser('/', { baseUrl: proxyPort })
await browser.elementByCss('#scroll-to-another').click()
// wait for preload to timeout
await waitFor(6 * 1000)
await browser
.elementByCss('#link-another')
.click()
.waitForElementByCss('#another')
expect(await browser.elementByCss('#another').text()).toBe('Hello world')
} finally {
await setStall(false)
}
})
it('should prefetch with link in viewport onload', async () => {
let browser
try {
browser = await next.browser('/', { baseUrl: proxyPort })
await retry(async () => {
const links = await browser.elementsByCss('link[rel=prefetch]')
const hrefs = await Promise.all(
links.map((link) => link.getAttribute('href'))
)
let chunk = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/first'
)
expect(hrefs).toEqual(
expect.arrayContaining([expect.stringContaining(chunk)])
)
})
} finally {
if (browser) await browser.close()
}
})
it('should prefetch with non-bot UA', async () => {
let browser
try {
browser = await next.browser(
`/bot-user-agent?useragent=${encodeURIComponent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36'
)}`,
{ baseUrl: proxyPort }
)
await retry(async () => {
const links = await browser.elementsByCss('link[rel=prefetch]')
expect(links).toHaveLength(1)
})
} finally {
if (browser) await browser.close()
}
})
it('should not prefetch with bot UA', async () => {
let browser
try {
browser = await next.browser(
`/bot-user-agent?useragent=${encodeURIComponent(
'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
)}`,
{ baseUrl: proxyPort }
)
const links = await browser.elementsByCss('link[rel=prefetch]')
expect(links).toHaveLength(0)
} finally {
if (browser) await browser.close()
}
})
it('should prefetch rewritten href with link in viewport onload', async () => {
let browser
try {
browser = await next.browser('/rewrite-prefetch', { baseUrl: proxyPort })
await retry(async () => {
const links = await browser.elementsByCss('link[rel=prefetch]')
const hrefs = await Promise.all(
links.map((link) => link.getAttribute('href'))
)
let chunk = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/ssg/dynamic/[slug]'
)
expect(hrefs).toEqual(
expect.arrayContaining([expect.stringContaining(chunk)])
)
})
const hrefs = await browser.eval(`Object.keys(window.next.router.sdc)`)
expect(hrefs.map((href) => new URL(href).pathname)).toEqual([
`/_next/data/${buildId}/ssg/dynamic/one.json`,
])
} finally {
if (browser) await browser.close()
}
})
it('should prefetch with link in viewport when href changes', async () => {
let browser
try {
browser = await next.browser('/', { baseUrl: proxyPort })
await browser.elementByCss('button').click()
await waitFor(2 * 1000)
const links = await browser.elementsByCss('link[rel=prefetch]')
let foundFirst = false
let foundAnother = false
let chunkFirst = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/first'
)
let chunkAnother = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/another'
)
for (const link of links) {
const href = await link.getAttribute('href')
if (href.includes(chunkAnother)) foundAnother = true
if (href.includes(chunkFirst)) foundFirst = true
}
expect(foundFirst).toBe(true)
expect(foundAnother).toBe(true)
} finally {
if (browser) await browser.close()
}
})
it('should prefetch with link in viewport on scroll', async () => {
let browser
try {
browser = await next.browser('/', { baseUrl: proxyPort })
await browser.elementByCss('#scroll-to-another').click()
await retry(async () => {
const links = await browser.elementsByCss('link[rel=prefetch]')
const hrefs = await Promise.all(
links.map((link) => link.getAttribute('href'))
)
let chunk = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/another'
)
expect(hrefs).toEqual(
expect.arrayContaining([expect.stringContaining(chunk)])
)
})
} finally {
if (browser) await browser.close()
}
})
it('should prefetch with link in viewport and inject script on hover', async () => {
let browser
try {
browser = await next.browser('/', { baseUrl: proxyPort })
await browser.elementByCss('#scroll-to-another').click()
await retry(async () => {
const links = await browser.elementsByCss('link[rel=prefetch]')
const hrefs = await Promise.all(
links.map((link) => link.getAttribute('href'))
)
let chunk = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/another'
)
expect(hrefs).toEqual(
expect.arrayContaining([expect.stringContaining(chunk)])
)
})
await browser.elementByCss('#link-another').moveTo()
await retry(async () => {
const scripts = await browser.elementsByCss('script:not([async])')
const srcProps = await Promise.all(
scripts.map((script) => script.getAttribute('src'))
)
let chunk = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/another'
)
expect(srcProps).toEqual(
expect.arrayContaining([expect.stringContaining(chunk)])
)
})
} finally {
if (browser) await browser.close()
}
})
it('should inject script on hover with prefetching disabled', async () => {
let browser
try {
browser = await next.browser('/prefetch-disabled', {
baseUrl: proxyPort,
})
let chunkAnother = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/another'
)
await retry(async () => {
const links = await browser.elementsByCss('link[rel=prefetch]')
const hrefs = await Promise.all(
links.map((link) => link.getAttribute('href'))
)
expect(hrefs).toEqual(
expect.not.arrayContaining([expect.stringContaining(chunkAnother)])
)
})
async function hasAnotherScript() {
const scripts = await browser.elementsByCss('script:not([async])')
let scriptFound = false
for (const aScript of scripts) {
const href = await aScript.getAttribute('src')
if (href.includes(chunkAnother)) {
scriptFound = true
break
}
}
return scriptFound
}
expect(await hasAnotherScript()).toBe(false)
await browser.elementByCss('#link-another').moveTo()
await waitFor(2 * 1000)
expect(await hasAnotherScript()).toBe(true)
} finally {
if (browser) await browser.close()
}
})
it('should inject script on hover with prefetching disabled and fetch data', async () => {
let browser
try {
browser = await next.browser('/prefetch-disabled-ssg', {
baseUrl: proxyPort,
})
let chunkBasic = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/ssg/basic'
)
async function hasSsgScript() {
const scripts = await browser.elementsByCss('script:not([async])')
let scriptFound = false
for (const aScript of scripts) {
const href = await aScript.getAttribute('src')
if (href.includes(chunkBasic)) {
scriptFound = true
break
}
}
return scriptFound
}
await waitFor(2 * 1000)
expect(await hasSsgScript()).toBe(false)
const hrefs = await browser.eval(`Object.keys(window.next.router.sdc)`)
expect(hrefs.map((href) => new URL(href).pathname)).toEqual([])
await browser.elementByCss('#link-ssg').moveTo()
await waitFor(2 * 1000)
expect(await hasSsgScript()).toBe(true)
const hrefs2 = await browser.eval(`Object.keys(window.next.router.sdc)`)
expect(hrefs2.map((href) => new URL(href).pathname)).toEqual([
`/_next/data/${buildId}/ssg/basic.json`,
])
} finally {
if (browser) await browser.close()
}
})
it('should inject a <script> tag when onMouseEnter (even with invalid ref)', async () => {
let browser
try {
browser = await next.browser('/invalid-ref', { baseUrl: proxyPort })
await browser.elementByCss('#btn-link').moveTo()
await retry(async () => {
const scripts = await browser.elementsByCss('script:not([async])')
const srcProps = await Promise.all(
scripts.map((script) => script.getAttribute('src'))
)
let chunk = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/another'
)
expect(srcProps).toEqual(
expect.arrayContaining([expect.stringContaining(chunk)])
)
})
} finally {
if (browser) await browser.close()
}
})
it('should not have unhandledRejection when failing to prefetch on link', async () => {
const browser = await next.browser('/', { baseUrl: proxyPort })
await browser.eval(`(function() {
window.addEventListener('unhandledrejection', function (err) {
window.hadUnhandledReject = true;
})
window.next.router.push('/invalid-prefetch');
})()`)
expect(await browser.eval('window.hadUnhandledReject')).toBeFalsy()
await browser.waitForElementByCss('#invalid-link')
await browser.elementByCss('#invalid-link').moveTo()
expect(await browser.eval('window.hadUnhandledReject')).toBeFalsy()
})
it('should not prefetch when prefetch is explicitly set to false', async () => {
const browser = await next.browser('/opt-out', { baseUrl: proxyPort })
await retry(async () => {
const links = await browser.elementsByCss('link[rel=prefetch]')
const hrefs = await Promise.all(
links.map((link) => link.getAttribute('href'))
)
let chunk = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/another'
)
expect(hrefs).toEqual(
expect.not.arrayContaining([expect.stringContaining(chunk)])
)
})
})
;(isTurbopack ? it.skip : it)(
'should not prefetch already loaded scripts',
async () => {
const browser = await next.browser('/', { baseUrl: proxyPort })
const scriptSrcs = await browser.eval(`(function() {
return Array.from(document.querySelectorAll('script'))
.map(function(el) {
return el.src && new URL(el.src).pathname
}).filter(Boolean)
})()`)
await browser.eval('next.router.prefetch("/")')
const linkHrefs = await browser.eval(`(function() {
return Array.from(document.querySelectorAll('link'))
.map(function(el) {
return el.href && new URL(el.href).pathname
}).filter(Boolean)
})()`)
expect(scriptSrcs.some((src) => src.includes('pages/index-'))).toBe(true)
expect(linkHrefs.some((href) => href.includes('pages/index-'))).toBe(
false
)
}
)
it('should not duplicate prefetches', async () => {
const browser = await next.browser('/multi-prefetch', {
baseUrl: proxyPort,
})
const links = await browser.elementsByCss('link[rel=prefetch]')
const hrefs = []
for (const link of links) {
const href = await link.getAttribute('href')
hrefs.push(href)
}
hrefs.sort()
expect(hrefs).toEqual([...new Set(hrefs)])
let chunk = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/dynamic/[hello]'
)
expect(hrefs.some((e) => e.includes(chunk))).toBe(true)
})
it('should not re-prefetch for an already prefetched page', async () => {
const browser = await next.browser('/', { baseUrl: proxyPort })
await retry(async () => {
const links = await browser.elementsByCss('link[rel=prefetch]')
const hrefs = await Promise.all(
links.map((link) => link.getAttribute('href'))
)
let chunk = getClientBuildManifestLoaderChunkUrlPath(
next.testDir,
'/first'
)
expect(hrefs).toEqual(
expect.arrayContaining([expect.stringContaining(chunk)])
)
})
await browser.eval(`(function() {
window.calledPrefetch = false
window.next.router.prefetch = function() {
window.calledPrefetch = true
return Promise.resolve()
}
window.next.router.push('/de-duped')
})()`)
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toMatch(/to \/first/)
})
const calledPrefetch = await browser.eval(`window.calledPrefetch`)
expect(calledPrefetch).toBe(false)
})
it('should prefetch with a different asPath for a prefetched page', async () => {
const browser = await next.browser('/', { baseUrl: proxyPort })
await browser.eval(`(function() {
window.calledPrefetch = false
window.next.router.prefetch = function() {
window.calledPrefetch = true
return Promise.resolve()
}
window.next.router.push('/not-de-duped')
})()`)
await waitFor(2 * 1000)
const calledPrefetch = await browser.eval(`window.calledPrefetch`)
expect(calledPrefetch).toBe(true)
})
it('should correctly omit pre-generated dynamic pages from SSG manifest', async () => {
const content = await next.readFile(
join('.next', 'static', buildId, '_ssgManifest.js')
)
let self: Record<string, any> = {}
// eslint-disable-next-line no-eval
eval(content)
expect([...self.__SSG_MANIFEST].sort()).toMatchInlineSnapshot(`
[
"/[...rest]",
"/ssg/basic",
"/ssg/catch-all/[...slug]",
"/ssg/dynamic-nested/[slug1]/[slug2]",
"/ssg/dynamic/[slug]",
"/ssg/slow",
]
`)
})
it('should prefetch data files', async () => {
const browser = await next.browser('/ssg/fixture', { baseUrl: proxyPort })
await waitFor(2 * 1000)
const hrefs = await browser.eval(`Object.keys(window.next.router.sdc)`)
hrefs.sort()
expect(hrefs.map((href) => new URL(href).pathname)).toEqual([
`/_next/data/${buildId}/ssg/basic.json`,
`/_next/data/${buildId}/ssg/catch-all/foo.json`,
`/_next/data/${buildId}/ssg/catch-all/foo/bar.json`,
`/_next/data/${buildId}/ssg/catch-all/one.json`,
`/_next/data/${buildId}/ssg/catch-all/one/two.json`,
`/_next/data/${buildId}/ssg/dynamic-nested/foo/bar.json`,
`/_next/data/${buildId}/ssg/dynamic-nested/one/two.json`,
`/_next/data/${buildId}/ssg/dynamic/one.json`,
`/_next/data/${buildId}/ssg/dynamic/two.json`,
])
})
it('should prefetch data files when mismatched', async () => {
const browser = await next.browser('/ssg/fixture/mismatch', {
baseUrl: proxyPort,
})
await waitFor(2 * 1000)
const hrefs = await browser.eval(`Object.keys(window.next.router.sdc)`)
hrefs.sort()
expect(hrefs.map((href) => new URL(href).pathname)).toEqual([
`/_next/data/${buildId}/ssg/catch-all/foo.json`,
`/_next/data/${buildId}/ssg/catch-all/foo/bar.json`,
`/_next/data/${buildId}/ssg/catch-all/one.json`,
`/_next/data/${buildId}/ssg/catch-all/one/two.json`,
`/_next/data/${buildId}/ssg/dynamic-nested/foo/bar.json`,
`/_next/data/${buildId}/ssg/dynamic-nested/one/two.json`,
`/_next/data/${buildId}/ssg/dynamic/one.json`,
`/_next/data/${buildId}/ssg/dynamic/two.json`,
])
})
})