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.
This commit is contained in:
Tim Neutkens
2026-05-11 13:55:47 +02:00
committed by GitHub
parent cd6f40c62f
commit 8141dcf12e
2936 changed files with 39978 additions and 48664 deletions
+3 -3
View File
@@ -14,12 +14,12 @@ Use this skill when the user asks about PR status, CI failures, or review commen
## Workflow
1. Run `node scripts/pr-status.js --wait` in the background (timeout 1 min), then read `scripts/pr-status/index.md`.
2. Analyze each `job-{id}.md` and `thread-{N}.md` file for failures and review feedback.
1. Run `node scripts/pr-status.js --wait` in the background (timeout 1 min), then read `scripts/pr-status/results/index.md`.
2. Analyze each `job-{id}.md` and `thread-{N}.md` file in `scripts/pr-status/results/` for failures and review feedback.
3. Prioritize blocking jobs first: build, lint, types, then test jobs.
4. Treat failures as real until disproven; check the "Known Flaky Tests" section before calling anything flaky.
5. Reproduce locally with the same mode and env vars as CI.
6. After addressing review comments, reply to the thread describing what was done, then resolve it. Use `reply-and-resolve-thread` to do both in one step, or use `reply-thread` + `resolve-thread` separately. See `thread-N.md` files for ready-to-use commands.
6. After addressing review comments, reply to the thread describing what was done, then resolve it. Use `reply-and-resolve-thread` to do both in one step, or use `reply-thread` + `resolve-thread` separately. See `scripts/pr-status/results/thread-N.md` files for ready-to-use commands.
7. When the only remaining failures are known flaky tests and no code changes are needed, retrigger the failing CI jobs with `gh run rerun <run-id> --failed`. Then wait 5 minutes and go back to step 1. Repeat this loop up to 5 times.
## Quick Commands
+1 -1
View File
@@ -45,6 +45,6 @@ Or do both in one step:
node scripts/pr-status.js reply-and-resolve-thread <threadNodeId> "Done -- <description of changes>"
```
The ready-to-use commands with the correct thread IDs are at the bottom of each `thread-N.md` file in `scripts/pr-status/`.
The ready-to-use commands with the correct thread IDs are at the bottom of each `thread-N.md` file in `scripts/pr-status/results/`.
**Important:** Always reply with a description of the actions taken before resolving. This gives the reviewer context about what changed.
-2
View File
@@ -32,8 +32,6 @@ export default globalIgnores([
'packages/next-codemod/**/*.d.ts',
'packages/next-env/**/*.d.ts',
'packages/create-next-app/templates/**/*',
'test/integration/eslint/**/*.js',
'test/integration/script-loader/**/*.js',
'test/development/basic/legacy-decorators/**/*.js',
'test/production/emit-decorator-metadata/**/*.js',
'!test/**/*.test.*',
+10 -231
View File
@@ -388,59 +388,6 @@ jobs:
stepName: 'test-turbopack-dev-react-${{ matrix.react }}-${{ matrix.group }}'
secrets: inherit
test-turbopack-integration:
name: test turbopack integration
needs:
[
'optimize-ci',
'changes',
'build-native',
'build-next',
'fetch-test-timings',
]
if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
strategy:
fail-fast: false
matrix:
group:
- 1/13
- 2/13
- 3/13
- 4/13
- 5/13
- 6/13
- 7/13
- 8/13
- 9/13
- 10/13
- 11/13
- 12/13
- 13/13
# Empty value uses default
# TODO: Run with React 18.
# Integration tests use the installed React version in next/package.json.
# We can't easily switch like we do for e2e tests.
# Skipping this dimension until we can figure out a way to test multiple React versions.
react: ['']
uses: ./.github/workflows/build_reusable.yml
with:
nodeVersion: 20.9.0
afterBuild: |
export IS_TURBOPACK_TEST=1
export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
export RUST_BACKTRACE=1
node run-tests.js \
--timings \
--require-timings \
-g ${{ matrix.group }} \
--type integration
testTimingsArtifact: 'test-timings'
stepName: 'test-turbopack-integration-react-${{ matrix.react }}-${{ matrix.group }}'
secrets: inherit
test-turbopack-production:
name: test turbopack production
needs:
@@ -523,52 +470,6 @@ jobs:
stepName: 'test-rspack-dev-react-${{ matrix.react }}-${{ matrix.group }}'
secrets: inherit
test-rspack-integration:
name: test rspack development integration
needs:
[
'optimize-ci',
'changes',
'build-next',
'build-native',
'fetch-test-timings',
]
if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' && needs.changes.outputs.rspack == 'true' }}
strategy:
fail-fast: false
matrix:
group: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
# Empty value uses default
# TODO: Run with React 18.
# Integration tests use the installed React version in next/package.json.
# We can't easily switch like we do for e2e tests.
# Skipping this dimension until we can figure out a way to test multiple React versions.
react: ['']
uses: ./.github/workflows/build_reusable.yml
with:
nodeVersion: 20.9.0
afterBuild: |
export NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/rspack-dev-tests-manifest.json"
export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
# rspack flags
export NEXT_RSPACK=1
export NEXT_TEST_USE_RSPACK=1
# HACK: Despite the name, this environment variable is only used to gate
# tests, so it's applicable to rspack
export TURBOPACK_DEV=1
node run-tests.js \
--timings \
--require-timings \
-g ${{ matrix.group }} \
--type integration
testTimingsArtifact: 'test-timings'
stepName: 'test-rspack-integration-react-${{ matrix.react }}-${{ matrix.group }}'
secrets: inherit
test-rspack-production:
name: test rspack production
needs:
@@ -611,52 +512,6 @@ jobs:
stepName: 'test-rspack-production-react-${{ matrix.react }}-${{ matrix.group }}'
secrets: inherit
test-rspack-production-integration:
name: test rspack production integration
needs:
[
'optimize-ci',
'changes',
'build-next',
'build-native',
'fetch-test-timings',
]
if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' && needs.changes.outputs.rspack == 'true' }}
strategy:
fail-fast: false
matrix:
group: [1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 7/7]
# Empty value uses default
# TODO: Run with React 18.
# Integration tests use the installed React version in next/package.json.
# We can't easily switch like we do for e2e tests.
# Skipping this dimension until we can figure out a way to test multiple React versions.
react: ['']
uses: ./.github/workflows/build_reusable.yml
with:
nodeVersion: 20.9.0
afterBuild: |
export NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/rspack-build-tests-manifest.json"
export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
# rspack flags
export NEXT_RSPACK=1
export NEXT_TEST_USE_RSPACK=1
# HACK: Despite the name, this environment variable is only used to gate
# tests, so it's applicable to rspack
export TURBOPACK_BUILD=1
node run-tests.js \
--timings \
--require-timings \
-g ${{ matrix.group }} \
--type integration
testTimingsArtifact: 'test-timings'
stepName: 'test-rspack-production-integration-react-${{ matrix.react }}-${{ matrix.group }}'
secrets: inherit
test-next-swc-wasm:
name: test next-swc wasm
needs: ['optimize-ci', 'changes', 'build-next']
@@ -795,7 +650,7 @@ jobs:
strategy:
fail-fast: false
matrix:
group: [1/5, 2/5, 3/5, 4/5, 5/5]
group: [1/10, 2/10, 3/10, 4/10, 5/10, 6/10, 7/10, 8/10, 9/10, 10/10]
uses: ./.github/workflows/build_reusable.yml
with:
@@ -807,7 +662,7 @@ jobs:
--group ${{ matrix.group }} \
--preview-builds-base-url "${{ vars.PREVIEW_BUILDS_BASE_URL }}"
stepName: 'test-new-tests-dev-${{matrix.group}}'
timeout_minutes: 60 # Increase the default timeout as tests are intentionally run multiple times to detect flakes
timeout_minutes: 120 # Increase the default timeout as tests are intentionally run multiple times to detect flakes
secrets: inherit
@@ -821,7 +676,7 @@ jobs:
strategy:
fail-fast: false
matrix:
group: [1/5, 2/5, 3/5, 4/5, 5/5]
group: [1/10, 2/10, 3/10, 4/10, 5/10, 6/10, 7/10, 8/10, 9/10, 10/10]
uses: ./.github/workflows/build_reusable.yml
with:
@@ -833,7 +688,7 @@ jobs:
--group ${{ matrix.group }} \
--preview-builds-base-url "${{ vars.PREVIEW_BUILDS_BASE_URL }}"
stepName: 'test-new-tests-start-${{matrix.group}}'
timeout_minutes: 60 # Increase the default timeout as tests are intentionally run multiple times to detect flakes
timeout_minutes: 120 # Increase the default timeout as tests are intentionally run multiple times to detect flakes
secrets: inherit
test-new-tests-deploy:
@@ -969,15 +824,16 @@ jobs:
with:
nodeVersion: 20.9.0
afterBuild: |
export NEXT_TEST_MODE=start
export IS_WEBPACK_TEST=1
export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
node run-tests.js \
node run-tests.js --type production \
--concurrency 4 \
test/production/pages-dir/production/test/index.test.ts \
test/integration/css-client-nav/test/index.test.ts \
test/integration/rewrites-has-condition/test/index.test.ts \
test/integration/create-next-app/index.test.ts \
test/integration/create-next-app/package-manager/pnpm.test.ts
test/e2e/css-client-nav/css-client-nav.test.ts \
test/e2e/rewrites-has-condition/rewrites-has-condition.test.ts \
test/production/create-next-app/index.test.ts \
test/production/create-next-app/package-manager/pnpm.test.ts
stepName: 'test-integration-windows'
runs_on_labels: '["windows-latest-8-core-oss"]'
buildNativeTarget: 'x86_64-pc-windows-msvc'
@@ -1040,58 +896,6 @@ jobs:
stepName: 'test-prod-react-${{ matrix.react }}-${{ matrix.group }}'
secrets: inherit
test-integration:
name: test integration
needs:
[
'optimize-ci',
'changes',
'build-native',
'build-next',
'fetch-test-timings',
]
if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
strategy:
fail-fast: false
matrix:
group:
- 1/13
- 2/13
- 3/13
- 4/13
- 5/13
- 6/13
- 7/13
- 8/13
- 9/13
- 10/13
- 11/13
- 12/13
- 13/13
# Empty value uses default
# TODO: Run with React 18.
# Integration tests use the installed React version in next/package.json.
# We can't easily switch like we do for e2e tests.
# Skipping this dimension until we can figure out a way to test multiple React versions.
react: ['']
uses: ./.github/workflows/build_reusable.yml
with:
nodeVersion: 20.9.0
afterBuild: |
export IS_WEBPACK_TEST=1
export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
node run-tests.js \
--timings \
--require-timings \
-g ${{ matrix.group }} \
--type integration
testTimingsArtifact: 'test-timings'
stepName: 'test-integration-${{ matrix.group }}-react-${{ matrix.react }}'
secrets: inherit
test-firefox-safari:
name: test firefox and safari
needs: ['optimize-ci', 'changes', 'build-native', 'build-next']
@@ -1125,28 +929,6 @@ jobs:
stepName: 'test-firefox-safari'
secrets: inherit
# Manifest generated via: https://gist.github.com/wyattjoh/2ceaebd82a5bcff4819600fd60126431
test-cache-components-integration:
name: test cache components integration
needs: ['optimize-ci', 'changes', 'build-native', 'build-next']
if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }}
uses: ./.github/workflows/build_reusable.yml
with:
nodeVersion: 20.9.0
afterBuild: |
export __NEXT_CACHE_COMPONENTS=true
export __NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS=true
export __NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=true
export NEXT_EXTERNAL_TESTS_FILTERS="test/cache-components-tests-manifest.json"
export IS_WEBPACK_TEST=1
node run-tests.js \
--timings \
--type integration
stepName: 'test-cache-components-integration'
secrets: inherit
test-cache-components-dev:
name: test cache components dev
needs:
@@ -1356,11 +1138,9 @@ jobs:
'test-next-config-ts-native-ts-prod',
'test-dev',
'test-prod',
'test-integration',
'test-firefox-safari',
'test-cache-components-dev',
'test-cache-components-prod',
'test-cache-components-integration',
'test-node-streams-cache-components-dev',
'test-node-streams-cache-components-prod',
'test-node-streams-dev',
@@ -1370,7 +1150,6 @@ jobs:
'rustdoc-check',
'test-next-swc-wasm',
'test-turbopack-dev',
'test-turbopack-integration',
'test-new-tests-dev',
'test-new-tests-start',
'test-new-tests-deploy',
-4
View File
@@ -60,8 +60,6 @@ test/e2e/app-dir/server-source-maps/fixtures/default/internal-pkg/sourcemapped.j
test/e2e/app-dir/server-source-maps/fixtures/default/external-pkg/sourcemapped.js
test/development/mcp-server/fixtures/default-template/app/build-error/page.tsx
test/development/mcp-server/fixtures/compilation-errors-app/app/syntax-error/page.tsx
# Tested against auto-generated content that isn't formatted
test/integration/typescript-app-type-declarations/next-env.strictRouteTypes.d.ts
# turbopack crates, disable for some tests and precompiled dependencies.
/turbopack/crates/*/js/src/compiled
@@ -91,8 +89,6 @@ readme.md
test/development/app-dir/hmr-symlink/app/symlink-chain/page.tsx
test/development/app-dir/hmr-symlink/app/symlink-link/page.tsx
test/development/app-dir/ssr-in-rsc/node_modules/random-react-library
test/integration/build-trace-extra-entries/app/node_modules/pkg-behind-symlink
test/integration/edge-runtime-configurable-guards/node_modules/lib
test/production/app-dir/symbolic-file-links/src/i18n.ts
test/production/app-dir/symbolic-file-links/src/app/layout.tsx
test/production/app-dir/symbolic-file-links/src/app/page.tsx
+1 -1
View File
@@ -338,7 +338,7 @@ pub fn get_sorted_routes(normalized_pages: &[String]) -> Result<Vec<String>, Url
// segment Eg you can't have pages/[post]/abc.js and
// pages/[hello]/something-else.js Only 1 dynamic segment per nesting level
// So in the case that is test/integration/dynamic-routing it'll be this:
// So in the case that is test/e2e/dynamic-routing it'll be this:
// pages/[post]/comments.js
// pages/blog/[post]/comment/[id].js
// Both are fine because `pages/[post]` and `pages/blog` are on the same level
-1
View File
@@ -82,7 +82,6 @@
"next-with-deps": "./scripts/next-with-deps.sh",
"next": "cross-env NEXT_PRIVATE_LOCAL_DEV=1 NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=\"--trace-deprecation --enable-source-maps\" next",
"next-no-sourcemaps": "echo 'No longer supported. Use `pnpm next --disable-source-maps` instead'; exit 1;",
"clean-trace-jaeger": "node scripts/rm.mjs test/integration/basic/.next && TRACE_TARGET=JAEGER pnpm next build test/integration/basic",
"debug": "cross-env NEXT_PRIVATE_LOCAL_DEV=1 NEXT_TELEMETRY_DISABLED=1 node --inspect --trace-deprecation --enable-source-maps packages/next/dist/bin/next",
"debug-brk": "cross-env NEXT_PRIVATE_LOCAL_DEV=1 NEXT_TELEMETRY_DISABLED=1 node --inspect-brk --trace-deprecation --enable-source-maps packages/next/dist/bin/next",
"pnpm:devPreinstall": "node scripts/create-next-bin-placeholder.mjs",
@@ -21,33 +21,45 @@ export function getPkgManager(): PackageManager {
}
/**
* Get the major version of pnpm being used.
* Get the full version string for the given package manager.
* Returns null if unable to determine the version.
*
* First tries to parse from npm_config_user_agent (e.g., "pnpm/9.13.2 npm/? ..."),
* then falls back to spawning `pnpm --version --silent`.
* then falls back to spawning `<packageManager> --version`.
*/
export function getPnpmMajorVersion(): number | null {
// Try to get version from user agent first (e.g., "pnpm/9.13.2 npm/? node/v20.x linux x64")
export function getPackageManagerVersion(
packageManager: PackageManager
): string | null {
const userAgent = process.env.npm_config_user_agent || ''
const pnpmVersionMatch = userAgent.match(/pnpm\/(\d+)/)
if (pnpmVersionMatch) {
return parseInt(pnpmVersionMatch[1], 10)
const userAgentMatch = userAgent.match(
new RegExp(`${packageManager}/([\\d.]+[\\w.-]*)`)
)
if (userAgentMatch) {
return userAgentMatch[1]
}
// Fall back to spawning pnpm --version
try {
const version = execSync('pnpm --version --silent', {
const version = execSync(`${packageManager} --version`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
}).trim()
const majorVersion = parseInt(version.split('.')[0], 10)
if (!Number.isNaN(majorVersion)) {
return majorVersion
if (/^\d+\.\d+\.\d+/.test(version)) {
return version
}
} catch {
// pnpm not available or failed to run
// package manager not available or failed to run
}
return null
}
/**
* Get the major version of pnpm being used.
* Returns null if unable to determine the version.
*/
export function getPnpmMajorVersion(): number | null {
const version = getPackageManagerVersion('pnpm')
if (!version) return null
const major = parseInt(version.split('.')[0], 10)
return Number.isNaN(major) ? null : major
}
+38 -15
View File
@@ -1,7 +1,10 @@
import { install } from "../helpers/install";
import { runTypegen } from "../helpers/typegen";
import { copy } from "../helpers/copy";
import { getPnpmMajorVersion } from "../helpers/get-pkg-manager";
import {
getPackageManagerVersion,
getPnpmMajorVersion,
} from "../helpers/get-pkg-manager";
import { async as glob } from "fast-glob";
import os from "os";
@@ -211,6 +214,24 @@ export const installTemplate = async ({
const version = process.env.NEXT_PRIVATE_TEST_VERSION ?? pkg.version;
const bundlerFlags = bundler === Bundler.Webpack ? " --webpack" : "";
// When isolated tests pack workspace packages and pass their tarball
// paths via `NEXT_TEST_PKG_PATHS` (set by `run-tests.js`), prefer those
// paths over `version` so siblings like `next-rspack` and
// `eslint-config-next` install from their own tarball — not from the
// `next` tarball, which would cause npm to extract `next`'s contents
// into the sibling's `node_modules/` entry.
const testPkgPaths: Map<string, string> | null = (() => {
const env = process.env.NEXT_TEST_PKG_PATHS;
if (!env) return null;
try {
return new Map<string, string>(JSON.parse(env));
} catch {
return null;
}
})();
const resolvePkgVersion = (name: string): string =>
testPkgPaths?.get(name) ?? version;
/** Create a package.json for the new project and write it to disk. */
const packageJson: any = {
name: appName,
@@ -229,24 +250,13 @@ export const installTemplate = async ({
dependencies: {
react: nextjsReactPeerVersion,
"react-dom": nextjsReactPeerVersion,
next: version,
next: resolvePkgVersion("next"),
},
devDependencies: {},
};
if (bundler === Bundler.Rspack) {
const NEXT_PRIVATE_TEST_VERSION = process.env.NEXT_PRIVATE_TEST_VERSION;
if (
NEXT_PRIVATE_TEST_VERSION &&
path.isAbsolute(NEXT_PRIVATE_TEST_VERSION)
) {
packageJson.dependencies["next-rspack"] = path.resolve(
path.dirname(NEXT_PRIVATE_TEST_VERSION),
"../next-rspack/packed.tgz",
);
} else {
packageJson.dependencies["next-rspack"] = version;
}
packageJson.dependencies["next-rspack"] = resolvePkgVersion("next-rspack");
}
if (reactCompiler) {
@@ -280,7 +290,7 @@ export const installTemplate = async ({
packageJson.devDependencies = {
...packageJson.devDependencies,
eslint: "^9",
"eslint-config-next": version,
"eslint-config-next": resolvePkgVersion("eslint-config-next"),
};
}
@@ -346,6 +356,19 @@ export const installTemplate = async ({
}
}
// Pin the package manager version via corepack so the project always uses the
// same version the project was created with. This avoids subtle differences
// between the pnpm/yarn/bun version on a contributor's PATH and the one used
// to scaffold the project (e.g. a pnpm-workspace.yaml written for pnpm v10+
// but installed with pnpm v9 on PATH).
// See: https://nodejs.org/api/packages.html#packagemanager
if (packageManager !== "npm") {
const packageManagerVersion = getPackageManagerVersion(packageManager);
if (packageManagerVersion) {
packageJson.packageManager = `${packageManager}@${packageManagerVersion}`;
}
}
if (packageManager === "bun") {
// Equivalent to pnpm's `ignoredBuiltDependencies`, added in bun 1.3.2.
// - https://bun.com/blog/bun-v1.3.2#faster-bun-install
+1
View File
@@ -40,6 +40,7 @@
"dev": "pnpm build",
"build": "swc -d dist src && pnpm types",
"types": "tsc --skipLibCheck --declaration --emitDeclarationOnly --esModuleInterop --declarationDir dist --rootDir src",
"pack-for-isolated-tests": "pnpm pack --out ./packed.tgz",
"prepublishOnly": "cd ../../ && turbo run build"
},
"exports": {
@@ -181,7 +181,7 @@ export default class HotReloaderRspack extends HotReloaderWebpack {
if (key === 'edge-server' && !this.isEdgeServerCacheEnabled) return
// TODO: Rspack does not store middleware entries in persistent cache, causing
// test/integration/middleware-src/test/index.test.ts to fail. This is a temporary
// test/e2e/middleware-src/middleware-src.test.ts to fail. This is a temporary
// workaround to skip middleware entry caching until Rspack properly supports it.
if (page === '/middleware') {
return
@@ -211,7 +211,7 @@ export function getSortedRoutes(
// Eg you can't have pages/[post]/abc.js and pages/[hello]/something-else.js
// Only 1 dynamic segment per nesting level
// So in the case that is test/integration/dynamic-routing it'll be this:
// So in the case that is test/e2e/dynamic-routing it'll be this:
// pages/[post]/comments.js
// pages/blog/[post]/comment/[id].js
// Both are fine because `pages/[post]` and `pages/blog` are on the same level
+1 -1
View File
@@ -86,7 +86,7 @@ module.exports = function (task) {
targets: {
// Ideally, should be same version defined in packages/next/package.json#engines
// Currently a few minors behind due to babel class transpiling
// which fails "test/integration/mixed-ssg-serverprops-error/test/index.test.js"
// which fails "test/production/mixed-ssg-serverprops-error/test/index.test.js"
node: '16.8.0',
},
},
+5 -18
View File
@@ -263,7 +263,6 @@ const testFilters = {
production: new RegExp('^(test/(production|e2e))'),
unit: new RegExp('^(test/unit|packages/.*/src|packages/next-codemod)'),
examples: 'examples/',
integration: 'test/integration/',
e2e: 'test/e2e/',
}
@@ -631,9 +630,9 @@ ${ENDGROUP}`)
((options.type && options.type !== 'unit') ||
tests.some((test) => !testFilters.unit.test(test.file)))
) {
// For isolated next tests (e2e, dev, prod) and integration tests we create
// a starter Next.js install to re-use to speed up tests to avoid having to
// run `pnpm install` each time.
// For isolated next tests (e2e, dev, prod) we create a starter Next.js
// install to re-use to speed up tests to avoid having to run `pnpm install`
// each time.
console.log(`${GROUP}Creating shared Next.js install`)
const reactVersion =
process.env.NEXT_TEST_REACT_VERSION || nextjsReactPeerVersion
@@ -969,28 +968,16 @@ ${ENDGROUP}`)
}
}
const directorySemas = new Map()
const results = await Promise.allSettled(
tests.map(async (test) => {
const dirName = path.dirname(test.file)
let dirSema = directorySemas.get(dirName)
// we only restrict 1 test per directory for
// legacy integration tests
if (/^test[/\\]integration/.test(test.file) && dirSema === undefined) {
directorySemas.set(dirName, (dirSema = new Sema(1)))
}
// TODO: Use explicit resource managment instead of this acquire/release pattern
// once CI runs with Node.js 24+.
if (dirSema) await dirSema.acquire()
// TODO: Use explicit resource management instead of this acquire/release
// pattern once CI runs with Node.js 24+.
await sema.acquire()
try {
await runTest(test)
} finally {
sema.release()
if (dirSema) dirSema.release()
}
})
)
+1 -4
View File
@@ -164,10 +164,7 @@ export default async function getChangedTests() {
devTests.add(file)
prodTests.add(file)
deployTests.add(file)
} else if (file.startsWith('test/integration/')) {
devTests.add(file)
prodTests.add(file)
} else if (file.startsWith('test/prod')) {
} else if (file.startsWith('test/production')) {
prodTests.add(file)
} else if (file.startsWith('test/development')) {
devTests.add(file)
+426
View File
@@ -0,0 +1,426 @@
#!/usr/bin/env node
const { execFileSync, spawn } = require('child_process')
const fs = require('fs/promises')
const path = require('path')
const OWNER = 'vercel'
const REPO = 'next.js'
const WORKFLOW_NAME = 'build-and-test'
const OUTPUT_ROOT = path.join(__dirname, 'pr-logs')
const FAILED_CONCLUSIONS = new Set(['failure', 'timed_out', 'startup_failure'])
const LOG_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s?/
function execGh(args) {
try {
return execFileSync('gh', args, {
encoding: 'utf8',
maxBuffer: 50 * 1024 * 1024,
}).trim()
} catch (error) {
const command = `gh ${args.join(' ')}`
console.error(`Command failed: ${command}`)
console.error(error.stderr || error.message)
throw error
}
}
function execGhJson(args) {
const output = execGh(args)
return JSON.parse(output)
}
function execGhLines(args) {
const output = execGh(args)
if (!output) return []
return output
.split('\n')
.filter((line) => line.trim())
.map((line) => JSON.parse(line))
}
function execGhTextAsync(args) {
return new Promise((resolve, reject) => {
const child = spawn('gh', args, {
stdio: ['ignore', 'pipe', 'pipe'],
})
const chunks = []
let stderr = ''
child.stdout.on('data', (chunk) => chunks.push(chunk))
child.stderr.on('data', (chunk) => {
stderr += chunk
})
child.on('close', (code) => {
if (code !== 0) {
const error = new Error(`Command failed: gh ${args.join(' ')}`)
error.stderr = stderr.trim()
reject(error)
return
}
resolve(Buffer.concat(chunks).toString('utf8'))
})
child.on('error', reject)
})
}
function stripLogTimestamps(logs) {
return logs
.split('\n')
.map((line) => line.replace(LOG_TIMESTAMP_RE, ''))
.join('\n')
}
function sanitizeFilename(name) {
return String(name)
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 100)
}
function formatDuration(startedAt, completedAt) {
if (!startedAt || !completedAt) return 'N/A'
const start = new Date(startedAt)
const end = new Date(completedAt)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 'N/A'
const totalSeconds = Math.floor((end - start) / 1000)
if (totalSeconds < 0) return 'N/A'
if (totalSeconds < 60) return `${totalSeconds}s`
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}m ${seconds}s`
}
function formatStatus(job) {
return job.conclusion ? `${job.status}/${job.conclusion}` : job.status
}
function usage() {
console.log(`Usage: node scripts/pr-logs.js [PR_NUMBER] [--wait] [--failed-only]
Downloads CI job logs for the latest "${WORKFLOW_NAME}" run on the current PR.
Options:
--wait Wait for the current run to finish before downloading logs
--failed-only Only download logs for failed jobs
--help Show this help text
`)
}
function getBranchInfo(prNumberArg) {
if (prNumberArg) {
try {
const data = execGhJson([
'pr',
'view',
String(prNumberArg),
'--json',
'number,headRefName',
])
return {
prNumber: String(data.number),
branchName: data.headRefName,
}
} catch {
console.error(`Failed to fetch PR #${prNumberArg}`)
process.exit(1)
}
}
try {
const data = execGhJson(['pr', 'view', '--json', 'number,headRefName'])
return {
prNumber: String(data.number),
branchName: data.headRefName,
}
} catch {
console.error(
'Could not detect a PR from the current branch. Pass a PR number explicitly.'
)
process.exit(1)
}
}
function getLatestWorkflowRun(branchName) {
const route = `repos/${OWNER}/${REPO}/actions/runs?branch=${encodeURIComponent(branchName)}&per_page=20`
const runs = execGhLines([
'api',
route,
'--jq',
`.workflow_runs[] | select(.name == "${WORKFLOW_NAME}") | {id, name, status, conclusion, run_attempt, html_url, created_at, updated_at}`,
])
return runs[0] || null
}
function getRunMetadata(runId) {
return execGhJson([
'api',
`repos/${OWNER}/${REPO}/actions/runs/${runId}`,
'--jq',
'{id, name, status, conclusion, run_attempt, html_url, created_at, updated_at}',
])
}
function getJobsForRunAttempt(runId, runAttempt) {
const jobs = []
let page = 1
while (true) {
const pageJobs = execGhLines([
'api',
`repos/${OWNER}/${REPO}/actions/runs/${runId}/attempts/${runAttempt}/jobs?per_page=100&page=${page}`,
'--jq',
'.jobs[] | {id, name, status, conclusion, started_at, completed_at, html_url}',
])
if (pageJobs.length === 0) {
break
}
jobs.push(...pageJobs)
if (pageJobs.length < 100) {
break
}
page += 1
}
return jobs
}
async function downloadJobLog(jobId) {
const logs = await execGhTextAsync([
'api',
`repos/${OWNER}/${REPO}/actions/jobs/${jobId}/logs`,
])
return stripLogTimestamps(logs)
}
async function mapLimit(items, limit, mapper) {
const queue = [...items]
const workers = Array.from(
{ length: Math.min(limit, queue.length) },
async () => {
while (queue.length > 0) {
const item = queue.shift()
await mapper(item)
}
}
)
await Promise.all(workers)
}
function buildOutputDir(prNumber, runId, runAttempt) {
return path.join(
OUTPUT_ROOT,
`pr-${prNumber}`,
`run-${runId}-attempt-${runAttempt}`
)
}
function buildIndexLog(branchInfo, runMetadata, jobs, results, outputDir) {
const lines = [
`PR: #${branchInfo.prNumber}`,
`Branch: ${branchInfo.branchName}`,
`Workflow: ${runMetadata.name}`,
`Run: ${runMetadata.id} (attempt ${runMetadata.run_attempt})`,
`Status: ${
runMetadata.conclusion
? `${runMetadata.status}/${runMetadata.conclusion}`
: runMetadata.status
}`,
`Created: ${runMetadata.created_at}`,
`Updated: ${runMetadata.updated_at || 'N/A'}`,
`URL: ${runMetadata.html_url}`,
`Output: ${outputDir}`,
'',
`Jobs considered: ${jobs.length}`,
`Logs downloaded: ${results.filter((result) => result.downloaded).length}`,
`Logs skipped: ${results.filter((result) => result.skippedReason).length}`,
`Log download errors: ${results.filter((result) => result.error).length}`,
'',
'Jobs:',
'',
]
for (const result of results) {
const duration = formatDuration(
result.job.started_at,
result.job.completed_at
)
lines.push(
`- ${result.job.id} | ${formatStatus(result.job)} | ${duration} | ${result.job.name}`
)
if (result.fileName) {
lines.push(` file: ${result.fileName}`)
}
if (result.skippedReason) {
lines.push(` skipped: ${result.skippedReason}`)
}
if (result.error) {
lines.push(` error: ${result.error}`)
}
}
return `${lines.join('\n')}\n`
}
async function main() {
const args = process.argv.slice(2)
if (args.includes('--help')) {
usage()
return
}
const waitFlag = args.includes('--wait')
const failedOnly = args.includes('--failed-only')
const prNumberArg = args.find((arg) => !arg.startsWith('--'))
console.log('Resolving PR...')
const branchInfo = getBranchInfo(prNumberArg)
console.log(`Using PR #${branchInfo.prNumber} (${branchInfo.branchName})`)
console.log('Finding latest workflow run...')
let runMetadata = getLatestWorkflowRun(branchInfo.branchName)
if (!runMetadata) {
console.error(
`No "${WORKFLOW_NAME}" workflow runs found for branch ${branchInfo.branchName}.`
)
process.exit(1)
}
if (
waitFlag &&
(runMetadata.status === 'queued' || runMetadata.status === 'in_progress')
) {
console.log(`Waiting for run ${runMetadata.id} to complete...`)
try {
execFileSync(
'gh',
[
'run',
'watch',
String(runMetadata.id),
'--compact',
'-R',
`${OWNER}/${REPO}`,
],
{ stdio: 'inherit' }
)
} catch {
// gh run watch exits non-zero when the run fails, which is expected here.
}
runMetadata = getRunMetadata(runMetadata.id)
}
console.log(
`Fetching jobs for run ${runMetadata.id} (attempt ${runMetadata.run_attempt})...`
)
const allJobs = getJobsForRunAttempt(runMetadata.id, runMetadata.run_attempt)
const jobs = failedOnly
? allJobs.filter((job) => FAILED_CONCLUSIONS.has(job.conclusion))
: allJobs
const outputDir = buildOutputDir(
branchInfo.prNumber,
runMetadata.id,
runMetadata.run_attempt
)
await fs.rm(outputDir, { recursive: true, force: true })
await fs.mkdir(outputDir, { recursive: true })
if (jobs.length === 0) {
const indexLog = buildIndexLog(branchInfo, runMetadata, jobs, [], outputDir)
await fs.writeFile(path.join(outputDir, 'index.log'), indexLog)
console.log(
`No matching jobs found. Wrote summary to ${outputDir}/index.log`
)
return
}
const results = new Array(jobs.length)
console.log(`Downloading logs for ${jobs.length} job(s)...`)
await mapLimit(
jobs.map((job, index) => ({ job, index })),
4,
async (entry) => {
const { job, index } = entry
const fileName = `job-${job.id}-${sanitizeFilename(job.name || 'job')}.log`
const filePath = path.join(outputDir, fileName)
if (job.status === 'queued') {
results[index] = {
job,
downloaded: false,
skippedReason: 'job is still queued',
}
return
}
if (job.conclusion === 'skipped') {
results[index] = {
job,
downloaded: false,
skippedReason: 'job was skipped',
}
return
}
try {
const logs = await downloadJobLog(job.id)
await fs.writeFile(filePath, logs)
results[index] = {
job,
downloaded: true,
fileName,
}
} catch (error) {
const message =
error.stderr || error.message || 'Unknown error while fetching logs'
await fs.writeFile(
filePath,
`Failed to download logs for job ${job.id} (${job.name})\n\n${message}\n`
)
results[index] = {
job,
downloaded: false,
fileName,
error: message.replace(/\s+/g, ' ').trim(),
}
}
}
)
const indexLog = buildIndexLog(
branchInfo,
runMetadata,
jobs,
results,
outputDir
)
await fs.writeFile(path.join(outputDir, 'index.log'), indexLog)
console.log(`Done. Logs written to ${outputDir}`)
console.log(`Summary: ${outputDir}/index.log`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
+204 -66
View File
@@ -2,7 +2,10 @@ const { execSync, execFileSync, spawn } = require('child_process')
const fs = require('fs/promises')
const path = require('path')
const OUTPUT_DIR = path.join(__dirname, 'pr-status')
const OUTPUT_ROOT = path.join(__dirname, 'pr-status')
const RESULTS_DIR = path.join(OUTPUT_ROOT, 'results')
const INTERMEDIATE_DIR = path.join(OUTPUT_ROOT, 'intermediate')
const ANSI_RE = new RegExp(String.raw`\u001B\[[0-9;]*[A-Za-z]`, 'g')
// ============================================================================
// Helper Functions
@@ -88,6 +91,14 @@ function sanitizeFilename(name) {
.substring(0, 100)
}
function resultPath(...segments) {
return path.join(RESULTS_DIR, ...segments)
}
function intermediatePath(...segments) {
return path.join(INTERMEDIATE_DIR, ...segments)
}
function escapeMarkdownTableCell(text) {
if (!text) return ''
// Escape pipe characters and newlines for markdown table cells
@@ -102,6 +113,34 @@ function stripTimestamps(logContent) {
return logContent.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s/gm, '')
}
function stripAnsi(text) {
return String(text || '').replace(ANSI_RE, '')
}
function truncate(text, maxLength) {
const value = String(text || '')
if (value.length <= maxLength) return value
return `${value.substring(0, maxLength)}...`
}
function normalizeTestPath(testName) {
const normalized = String(testName || '').replace(/\\/g, '/')
const match = normalized.match(/(?:^|\/)(test\/.*)$/)
return match ? match[1] : normalized
}
function formatFailedAssertionName(assertion) {
const ancestors = assertion?.ancestorTitles || []
const title = assertion?.title || assertion?.fullName || 'Unknown test'
return [...ancestors, title].filter(Boolean).join(' > ')
}
function collectFailureMessages(testResult) {
return (testResult?.assertionResults || [])
.flatMap((assertion) => assertion.failureMessages || [])
.join('\n\n')
}
function isBot(username) {
if (!username) return false
return username.endsWith('-bot') || username.endsWith('[bot]')
@@ -131,11 +170,18 @@ function getJobEnvVarsFromWorkflow() {
const afterBuild = match[3]
const exports = []
for (const line of afterBuild.split('\n')) {
const exportMatch = line.match(
/^\s*export\s+([\w]+)=["']?([^"'\s]+)["']?/
)
const exportMatch = line.match(/^\s*export\s+([\w]+)=(.*)$/)
if (exportMatch) {
exports.push(`${exportMatch[1]}=${exportMatch[2]}`)
let value = exportMatch[2].trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.substring(1, value.length - 1)
}
if (value) {
exports.push(`${exportMatch[1]}=${value}`)
}
}
}
if (exports.length > 0) {
@@ -539,6 +585,82 @@ function extractTestCaseGroups(logContent) {
return groups
}
function extractStructuredTestFiles(testResults) {
const testsByPath = new Map()
for (const result of testResults) {
const resultTestFiles = result.testResults || []
for (const testResult of resultTestFiles) {
const failedAssertions = (testResult.assertionResults || [])
.filter((assertion) => assertion.status === 'failed')
.map((assertion) => ({
name: formatFailedAssertionName(assertion),
message: stripAnsi(
(assertion.failureMessages || []).join('\n\n') || testResult.message
).trim(),
}))
const failed =
testResult.status === 'failed' ||
failedAssertions.length > 0 ||
(resultTestFiles.length === 1 && (result.numFailedTests || 0) > 0)
if (!failed) continue
const testPath = normalizeTestPath(testResult.name)
if (!testsByPath.has(testPath)) {
testsByPath.set(testPath, {
testPath,
attempts: [],
rawOutput: null,
})
}
testsByPath.get(testPath).attempts.push({
status: testResult.status || 'unknown',
processEnv: result.processEnv || {},
summary: {
failed: result.numFailedTests || failedAssertions.length,
passed: result.numPassedTests || 0,
total: result.numTotalTests || 0,
},
message: stripAnsi(
testResult.message || collectFailureMessages(testResult)
).trim(),
failedAssertions,
})
}
}
return [...testsByPath.values()].sort((a, b) =>
a.testPath.localeCompare(b.testPath)
)
}
function mergeRawTestOutputs(structuredTestFiles, rawTestGroups) {
const testsByPath = new Map(
structuredTestFiles.map((testFile) => [testFile.testPath, testFile])
)
for (const rawGroup of rawTestGroups) {
const existing = testsByPath.get(rawGroup.testPath)
if (existing) {
existing.rawOutput = rawGroup.content
} else {
testsByPath.set(rawGroup.testPath, {
testPath: rawGroup.testPath,
attempts: [],
rawOutput: rawGroup.content,
})
}
}
return [...testsByPath.values()].sort((a, b) =>
a.testPath.localeCompare(b.testPath)
)
}
function extractSections(logContent) {
// Split the log into sections at ##[group] and ##[endgroup] boundaries
const sections = []
@@ -871,7 +993,7 @@ function generateIndexMd(
return lines.join('\n')
}
function generateJobMd(jobMetadata, testResults, testGroups, sections) {
function generateJobMd(jobMetadata, testResults, testFiles, sections) {
const duration = formatDuration(
jobMetadata.started_at,
jobMetadata.completed_at
@@ -898,13 +1020,14 @@ function generateJobMd(jobMetadata, testResults, testGroups, sections) {
const sectionNum = i + 1
const filename = `job-${jobMetadata.id}-section-${sectionNum}.txt`
const errorPrefix = section.hasError ? '[error] ' : ''
const linkPath = `../intermediate/${filename}`
if (section.name) {
lines.push(
`- ${errorPrefix}[${section.name} (${section.lineCount} lines)](${filename})`
`- ${errorPrefix}[${section.name} (${section.lineCount} lines)](${linkPath})`
)
} else {
lines.push(`- ${errorPrefix}[${section.lineCount} lines](${filename})`)
lines.push(`- ${errorPrefix}[${section.lineCount} lines](${linkPath})`)
}
}
lines.push('')
@@ -927,10 +1050,10 @@ function generateJobMd(jobMetadata, testResults, testGroups, sections) {
for (const assertion of testResult.assertionResults) {
if (assertion.status === 'failed') {
allFailedTests.push({
testFile: testResult.name,
testName: assertion.fullName || assertion.title,
testFile: normalizeTestPath(testResult.name),
testName: formatFailedAssertionName(assertion),
error:
assertion.failureMessages?.[0]?.substring(0, 100) ||
stripAnsi(assertion.failureMessages?.[0] || '').trim() ||
'Unknown',
})
}
@@ -959,28 +1082,24 @@ function generateJobMd(jobMetadata, testResults, testGroups, sections) {
)
for (const test of allFailedTests) {
const shortFile = test.testFile.replace(/.*\/next\.js\/next\.js\//, '')
const shortError = test.error
.replace(/\n/g, ' ')
.substring(0, 60)
.replace(/\|/g, '\\|')
const shortError = truncate(
test.error.replace(/\n/g, ' ').replace(/\|/g, '\\|'),
60
)
lines.push(
`| ${escapeMarkdownTableCell(shortFile)} | ${escapeMarkdownTableCell(test.testName)} | ${shortError}... |`
`| ${escapeMarkdownTableCell(test.testFile)} | ${escapeMarkdownTableCell(test.testName)} | ${shortError} |`
)
}
lines.push('')
}
}
if (testGroups.length > 0) {
if (testFiles.length > 0) {
lines.push('## Individual Test Files', '')
const seenPaths = new Set()
for (const group of testGroups) {
if (seenPaths.has(group.testPath)) continue
seenPaths.add(group.testPath)
const sanitizedName = sanitizeFilename(group.testPath)
for (const testFile of testFiles) {
const sanitizedName = sanitizeFilename(testFile.testPath)
lines.push(
`- [${group.testPath}](job-${jobMetadata.id}-test-${sanitizedName}.md)`
`- [${testFile.testPath}](job-${jobMetadata.id}-test-${sanitizedName}.md)`
)
}
}
@@ -988,28 +1107,52 @@ function generateJobMd(jobMetadata, testResults, testGroups, sections) {
return lines.join('\n')
}
function generateTestMd(jobMetadata, testPath, content, testResultJson) {
function generateTestMd(jobMetadata, testFile) {
const lines = [
`# Test: ${testPath}`,
`# Test: ${testFile.testPath}`,
'',
`Job: [${jobMetadata.name}](job-${jobMetadata.id}.md)`,
'',
'## Output',
'',
'```',
content,
'```',
]
if (testResultJson) {
if (testFile.attempts.length > 0) {
lines.push(
'## Structured Results',
'',
'## Test Results JSON',
'',
'```json',
JSON.stringify(testResultJson, null, 2),
'```'
`Attempts: ${testFile.attempts.length}`,
''
)
for (let i = 0; i < testFile.attempts.length; i++) {
const attempt = testFile.attempts[i]
lines.push(`### Attempt ${i + 1}`, '', `Status: ${attempt.status}`)
if (attempt.processEnv?.NEXT_TEST_MODE) {
lines.push(`Mode: ${attempt.processEnv.NEXT_TEST_MODE}`)
}
lines.push(
`Failed: ${attempt.summary.failed}`,
`Passed: ${attempt.summary.passed}`,
`Total: ${attempt.summary.total}`,
''
)
if (attempt.failedAssertions.length > 0) {
for (const assertion of attempt.failedAssertions) {
lines.push(`#### ${assertion.name}`, '')
lines.push('```', assertion.message || 'Unknown failure', '```', '')
}
} else if (attempt.message) {
lines.push('```', attempt.message, '```', '')
} else {
lines.push('_No structured failure details captured._', '')
}
}
}
if (testFile.rawOutput && testFile.attempts.length === 0) {
lines.push('## Raw Job Output', '', '```', testFile.rawOutput, '```')
}
return lines.join('\n')
@@ -1266,8 +1409,9 @@ async function getFlakyTests(currentBranch, runsToCheck = 5) {
async function runAnalysis(prNumberArg, skipFlakyCheck) {
// Step 1: Delete and recreate output directory
console.log('Cleaning output directory...')
await fs.rm(OUTPUT_DIR, { recursive: true, force: true })
await fs.mkdir(OUTPUT_DIR, { recursive: true })
await fs.rm(OUTPUT_ROOT, { recursive: true, force: true })
await fs.mkdir(RESULTS_DIR, { recursive: true })
await fs.mkdir(INTERMEDIATE_DIR, { recursive: true })
// Step 2: Get branch info
console.log('Getting branch info...')
@@ -1353,7 +1497,7 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
for (let i = 0; i < reviewData.reviewThreads.length; i++) {
const thread = reviewData.reviewThreads[i]
await fs.writeFile(
path.join(OUTPUT_DIR, `thread-${i + 1}.md`),
resultPath(`thread-${i + 1}.md`),
generateThreadMd(thread, i)
)
}
@@ -1361,7 +1505,7 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
for (const review of reviewData.reviews) {
if (review.body && review.body.trim()) {
await fs.writeFile(
path.join(OUTPUT_DIR, `review-${review.id}.md`),
resultPath(`review-${review.id}.md`),
generateReviewMd(review)
)
}
@@ -1369,7 +1513,7 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
// Write individual comment files
for (const comment of reviewData.prComments) {
await fs.writeFile(
path.join(OUTPUT_DIR, `comment-${comment.id}.md`),
resultPath(`comment-${comment.id}.md`),
generateCommentMd(comment)
)
}
@@ -1384,7 +1528,7 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
skipped: [],
}
await fs.writeFile(
path.join(OUTPUT_DIR, 'index.md'),
resultPath('index.md'),
generateIndexMd(
branchInfo,
runMetadata,
@@ -1440,36 +1584,30 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
const section = sections[i]
const sectionNum = i + 1
await fs.writeFile(
path.join(OUTPUT_DIR, `job-${id}-section-${sectionNum}.txt`),
intermediatePath(`job-${id}-section-${sectionNum}.txt`),
section.content
)
}
// Extract test case groups
const testGroups = extractTestCaseGroups(logs)
const testFiles = mergeRawTestOutputs(
extractStructuredTestFiles(testResults),
extractTestCaseGroups(logs)
)
// Write individual test files
for (const group of testGroups) {
const sanitizedName = sanitizeFilename(group.testPath)
// Find matching test result JSON for this test
const matchingResult = testResults.find((r) =>
r.testResults?.some((tr) => tr.name?.includes(group.testPath))
)
const testMd = generateTestMd(
jobMetadata,
group.testPath,
group.content,
matchingResult
)
for (const testFile of testFiles) {
const sanitizedName = sanitizeFilename(testFile.testPath)
const testMd = generateTestMd(jobMetadata, testFile)
await fs.writeFile(
path.join(OUTPUT_DIR, `job-${id}-test-${sanitizedName}.md`),
resultPath(`job-${id}-test-${sanitizedName}.md`),
testMd
)
}
// Generate job markdown
const jobMd = generateJobMd(jobMetadata, testResults, testGroups, sections)
await fs.writeFile(path.join(OUTPUT_DIR, `job-${id}.md`), jobMd)
const jobMd = generateJobMd(jobMetadata, testResults, testFiles, sections)
await fs.writeFile(resultPath(`job-${id}.md`), jobMd)
}
// Step 7: Write PR review files if we have PR data
@@ -1479,7 +1617,7 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
for (let i = 0; i < reviewData.reviewThreads.length; i++) {
const thread = reviewData.reviewThreads[i]
await fs.writeFile(
path.join(OUTPUT_DIR, `thread-${i + 1}.md`),
resultPath(`thread-${i + 1}.md`),
generateThreadMd(thread, i)
)
}
@@ -1487,7 +1625,7 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
for (const review of reviewData.reviews) {
if (review.body?.trim()) {
await fs.writeFile(
path.join(OUTPUT_DIR, `review-${review.id}.md`),
resultPath(`review-${review.id}.md`),
generateReviewMd(review)
)
}
@@ -1495,7 +1633,7 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
// Write individual comment files
for (const comment of reviewData.prComments) {
await fs.writeFile(
path.join(OUTPUT_DIR, `comment-${comment.id}.md`),
resultPath(`comment-${comment.id}.md`),
generateCommentMd(comment)
)
}
@@ -1507,7 +1645,7 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
flakyTests = await getFlakyTests(branchInfo.branchName, 5)
if (flakyTests.size > 0) {
await fs.writeFile(
path.join(OUTPUT_DIR, 'flaky-tests.json'),
resultPath('flaky-tests.json'),
JSON.stringify([...flakyTests].sort(), null, 2)
)
}
@@ -1530,9 +1668,9 @@ async function runAnalysis(prNumberArg, skipFlakyCheck) {
jobEnvMap,
flakyTests
)
await fs.writeFile(path.join(OUTPUT_DIR, 'index.md'), indexMd)
await fs.writeFile(resultPath('index.md'), indexMd)
console.log(`\nDone! Output written to ${OUTPUT_DIR}/index.md`)
console.log(`\nDone! Output written to ${RESULTS_DIR}/index.md`)
return { runId: latestRun.id, isRunInProgress }
}
+2 -2
View File
@@ -42,7 +42,7 @@ const CHANGE_ITEM_GROUPS = {
],
cna: [
'packages/create-next-app',
'test/integration/create-next-app',
'test/production/create-next-app',
'examples/basic-css',
'examples/mdx-pages',
'examples/with-sass',
@@ -52,7 +52,7 @@ const CHANGE_ITEM_GROUPS = {
'next-swc': [
'packages/next-swc',
'scripts/normalize-version-bump.js',
'test/integration/create-next-app',
'test/production/create-next-app',
'scripts/send-trace-to-jaeger',
],
}
@@ -320,6 +320,8 @@
"test/e2e/new-link-behavior/material-ui.test.ts",
"test/e2e/next-form/default/app-dir.test.ts",
"test/e2e/next-form/default/pages-dir.test.ts",
"test/e2e/next-image-new/app-dir/app-dir-static.test.ts",
"test/e2e/next-image-new/app-dir/app-dir.test.ts",
"test/e2e/next-link-errors/next-link-errors.test.ts",
"test/e2e/nonce-head-manager/index.test.ts",
"test/e2e/og-api/index.test.ts",
@@ -337,6 +339,7 @@
"test/e2e/react-version/react-version.test.ts",
"test/e2e/streaming-ssr-edge/streaming-ssr-edge.test.ts",
"test/e2e/switchable-runtime/index.test.ts",
"test/e2e/telemetry/page-features.test.ts",
"test/e2e/testmode/testmode.test.ts",
"test/e2e/use-link-status/index.test.ts",
"test/e2e/url/url.test.ts",
@@ -345,6 +348,7 @@
"test/integration/app-types/app-types.test.ts",
"test/production/app-dir-edge-runtime-with-wasm/index.test.ts",
"test/production/app-dir-prevent-304-caching/index.test.ts",
"test/production/app-dynamic-error/app-dynamic-error.test.ts",
"test/production/app-dir/actions-tree-shaking/basic/basic-edge.test.ts",
"test/production/app-dir/actions-tree-shaking/mixed-module-actions/mixed-module-actions-edge.test.ts",
"test/production/app-dir/actions-tree-shaking/reexport/reexport-edge.test.ts",
@@ -0,0 +1,32 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('App asPath', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should not have any changes in asPath after a bundle rebuild', async () => {
const browser = await next.browser('/')
const text = await browser.elementByCss('body').text()
expect(text).toBe(
'{ "url": { "query": {}, "pathname": "/", "asPath": "/" } }'
)
const originalContent = await next.readFile('pages/_app.js')
const editedContent = originalContent.replace(
'find this',
'replace with this'
)
await next.patchFile('pages/_app.js', editedContent)
await retry(async () => {
const newContent = await browser.elementByCss('body').text()
expect(newContent).toBe(
'{ "url": { "query": {}, "pathname": "/", "asPath": "/" } }'
)
})
})
})
@@ -0,0 +1,15 @@
import { nextTestSetup } from 'e2e-utils'
import { waitForNoRedbox } from 'next-test-utils'
describe('App assetPrefix config', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should render correctly with assetPrefix: "/"', async () => {
const browser = await next.browser('/')
await waitForNoRedbox(browser)
const title = await browser.elementById('title').text()
expect(title).toBe('IndexPage')
})
})
@@ -1,34 +1,21 @@
/* eslint-env jest */
import fs from 'fs-extra'
import { join } from 'path'
import webdriver from 'next-webdriver'
import { killApp, findPort, launchApp, check } from 'next-test-utils'
const appDir = join(__dirname, '../')
const appPage = join(appDir, 'pages/_app.js')
const documentPage = join(appDir, 'pages/_document.js')
let appPort
let app
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('_app/_document add HMR', () => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
const { next } = nextTestSetup({
files: __dirname,
})
afterAll(() => killApp(app))
// TODO: figure out why test fails.
it.skip('should HMR when _app is added', async () => {
const browser = await webdriver(appPort, '/')
const browser = await next.browser('/')
try {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).not.toContain('custom _app')
expect(html).toContain('index page')
await fs.writeFile(
appPage,
await next.patchFile(
'pages/_app.js',
`
export default function MyApp({ Component, pageProps }) {
return (
@@ -41,33 +28,31 @@ describe('_app/_document add HMR', () => {
`
)
await check(async () => {
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
return html.includes('custom _app') && html.includes('index page')
? 'success'
: html
}, 'success')
expect(html).toContain('custom _app')
expect(html).toContain('index page')
})
} finally {
await fs.remove(appPage)
await check(async () => {
await next.deleteFile('pages/_app.js')
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
return !html.includes('custom _app') && html.includes('index page')
? 'restored'
: html
}, 'restored')
expect(html).not.toContain('custom _app')
expect(html).toContain('index page')
})
}
})
// TODO: Figure out why test fails.
it.skip('should HMR when _document is added', async () => {
const browser = await webdriver(appPort, '/')
const browser = await next.browser('/')
try {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).not.toContain('custom _document')
expect(html).toContain('index page')
await fs.writeFile(
documentPage,
await next.patchFile(
'pages/_document.js',
`
import Document, { Html, Head, Main, NextScript } from 'next/document'
@@ -92,24 +77,21 @@ describe('_app/_document add HMR', () => {
}
export default MyDocument
`
)
await check(async () => {
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
return html.includes('custom _document') && html.includes('index page')
? 'success'
: html
}, 'success')
expect(html).toContain('custom _document')
expect(html).toContain('index page')
})
} finally {
await fs.remove(documentPage)
await check(async () => {
await next.deleteFile('pages/_document.js')
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
return !html.includes('custom _document') && html.includes('index page')
? 'restored'
: html
}, 'restored')
expect(html).not.toContain('custom _document')
expect(html).toContain('index page')
})
}
})
})
@@ -0,0 +1,96 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('_app/_document removal HMR', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should HMR when _app is removed', async () => {
const indexContent = await next.readFile('pages/index.js')
try {
const browser = await next.browser('/')
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('custom _app')
const appContent = await next.readFile('pages/_app.js')
await next.deleteFile('pages/_app.js')
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('index page')
expect(html).not.toContain('custom _app')
})
await next.patchFile(
'pages/index.js',
`
export default function Page() {
return <p>index page updated</p>
}
`
)
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('index page updated')
expect(html).not.toContain('custom _app')
})
await next.patchFile('pages/_app.js', appContent)
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('index page updated')
expect(html).toContain('custom _app')
})
} finally {
await next.patchFile('pages/index.js', indexContent)
}
})
it('should HMR when _document is removed', async () => {
const indexContent = await next.readFile('pages/index.js')
try {
const browser = await next.browser('/')
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('custom _document')
const documentContent = await next.readFile('pages/_document.js')
await next.deleteFile('pages/_document.js')
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('index page')
expect(html).not.toContain('custom _document')
})
await next.patchFile(
'pages/index.js',
`
export default function Page() {
return <p>index page updated</p>
}
`
)
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('index page updated')
expect(html).not.toContain('custom _document')
})
await next.patchFile('pages/_document.js', documentContent)
await retry(async () => {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('index page updated')
expect(html).toContain('custom _document')
})
} finally {
await next.patchFile('pages/index.js', indexContent)
}
})
})
@@ -0,0 +1,12 @@
import { nextTestSetup } from 'e2e-utils'
describe('Document and App', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should not have any missing key warnings', async () => {
const html = await next.render('/')
expect(html).toMatch(/<div>Hello World!!!<\/div>/)
})
})
@@ -0,0 +1,12 @@
import { nextTestSetup } from 'e2e-utils'
;(process.env.IS_TURBOPACK_TEST ? describe.skip : describe)(
'babel-next-image',
() => {
const { next } = nextTestSetup({ files: __dirname })
it('should work with babel and next/image', async () => {
const res = await next.fetch('/')
expect(res.status).toBe(200)
})
}
)
@@ -0,0 +1,22 @@
import { nextTestSetup } from 'e2e-utils'
// The isolated test install for this suite (fresh `pnpm install` of a packed
// Next.js tarball + starting `next dev`) can exceed the default 120s jest
// beforeAll timeout on cold caches.
jest.setTimeout(5 * 60 * 1000)
// Skipped for Turbopack as this test is webpack-specific
;(process.env.IS_TURBOPACK_TEST ? describe.skip : describe)(
'Handles a broken webpack plugin (precompile)',
() => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should render error correctly', async () => {
const text = await next.render('/')
expect(text).toContain('Internal Server Error')
expect(next.cliOutput).toMatch('Error: oops')
})
}
)
@@ -1,56 +1,34 @@
/* eslint-env jest */
import {
findPort,
killApp,
launchApp,
renderViaHTTP,
} from '../../../lib/next-test-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
const context: Record<string, any> = {}
const appDir = join(__dirname, '../')
const navigateTo = async (browser, selector) =>
await browser
.waitForElementByCss('#' + selector + '-link')
.click()
.waitForElementByCss('#' + selector)
const getAnnouncedTitle = async (browser) =>
await browser.waitForElementByCss('#__next-route-announcer__').text()
const getDocumentTitle = async (browser) => await browser.eval('document.title')
const getMainHeadingTitle = async (browser) =>
await browser.elementByCss('h1').text()
import { nextTestSetup } from 'e2e-utils'
describe('Client Navigation accessibility', () => {
beforeAll(async () => {
context.appPort = await findPort()
context.server = await launchApp(appDir, context.appPort)
const prerender = [
'/page-with-h1-and-title, /page-with-h1, /page-with-title, /page-without-h1-or-title',
]
await Promise.all(
prerender.map((route) => renderViaHTTP(context.appPort, route))
)
const { next } = nextTestSetup({
files: __dirname,
})
afterAll(() => killApp(context.server))
const navigateTo = async (browser: any, selector: string) =>
await browser
.waitForElementByCss('#' + selector + '-link')
.click()
.waitForElementByCss('#' + selector)
const getAnnouncedTitle = async (browser: any) =>
await browser.waitForElementByCss('#__next-route-announcer__').text()
const getDocumentTitle = async (browser: any) =>
await browser.eval('document.title')
const getMainHeadingTitle = async (browser: any) =>
await browser.elementByCss('h1').text()
describe('<RouteAnnouncer />', () => {
it('should not have the initial route announced', async () => {
const browser = await webdriver(context.appPort, '/')
const browser = await next.browser('/')
const title = await getAnnouncedTitle(browser)
expect(title).toBe('')
})
it('has aria-live="assertive" and role="alert"', async () => {
const browser = await webdriver(context.appPort, '/')
const browser = await next.browser('/')
const routeAnnouncer = await browser.waitForElementByCss(
'#__next-route-announcer__'
)
@@ -59,58 +37,53 @@ describe('Client Navigation accessibility', () => {
expect(ariaLiveValue).toBe('assertive')
expect(roleValue).toBe('alert')
await browser.close()
})
describe('There is a title but no h1 tag', () => {
it('has the innerText equal to the value of document.title', async () => {
const browser = await webdriver(context.appPort, '/')
const browser = await next.browser('/')
await navigateTo(browser, 'page-with-title')
const routeAnnouncerValue = await getAnnouncedTitle(browser)
const title = await getDocumentTitle(browser)
expect(routeAnnouncerValue).toBe(title)
await browser.close()
})
})
describe('There is no title but a h1 tag', () => {
it('has the innerText equal to the value of h1', async () => {
const browser = await webdriver(context.appPort, '/')
const browser = await next.browser('/')
await navigateTo(browser, 'page-with-h1')
const routeAnnouncerValue = await getAnnouncedTitle(browser)
const h1Value = await getMainHeadingTitle(browser)
expect(routeAnnouncerValue).toBe(h1Value)
await browser.close()
})
})
describe('There is a title and a h1 tag', () => {
it('has the innerText equal to the value of h1', async () => {
const browser = await webdriver(context.appPort, '/')
const browser = await next.browser('/')
await navigateTo(browser, 'page-with-h1-and-title')
const routeAnnouncerValue = await getAnnouncedTitle(browser)
const title = await getDocumentTitle(browser)
expect(routeAnnouncerValue).toBe(title)
await browser.close()
})
})
describe('There is no title and no h1 tag', () => {
it('has the innerText equal to the value of the pathname', async () => {
const browser = await webdriver(context.appPort, '/')
const browser = await next.browser('/')
await navigateTo(browser, 'page-without-h1-or-title')
const routeAnnouncerValue = await getAnnouncedTitle(browser)
const pathname = '/page-without-h1-or-title'
expect(routeAnnouncerValue).toBe(pathname)
await browser.close()
})
})
})
@@ -0,0 +1,13 @@
import { nextTestSetup } from 'e2e-utils'
describe('Compression', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should compress responses by default', async () => {
const res = await next.fetch('/')
expect(res.headers.get('content-encoding')).toMatch(/gzip/)
})
})
@@ -1,41 +1,22 @@
/* eslint-env jest */
import { nextTestSetup } from 'e2e-utils'
import { retry, waitForRedbox, getRedboxSource } from 'next-test-utils'
import {
waitForRedbox,
findPort,
getRedboxSource,
killApp,
launchApp,
retry,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
const appDir = join(__dirname, '../')
// Webpack specific config tests.
// Webpack specific config test.
;(process.env.IS_TURBOPACK_TEST ? describe.skip : describe)(
'devtool set in development mode in next config',
() => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should warn and revert when a devtool is set in development mode', async () => {
let stderr = ''
const appPort = await findPort()
const app = await launchApp(appDir, appPort, {
onStderr(msg) {
stderr += msg || ''
},
})
await retry(async () => {
expect(stderr).toMatch(/Reverting webpack devtool to /)
expect(next.cliOutput).toMatch(/Reverting webpack devtool to /)
})
const browser = await webdriver(appPort, '/')
const browser = await next.browser('/')
await waitForRedbox(browser)
if (process.platform === 'win32') {
// TODO: add win32 snapshot
} else {
if (process.platform !== 'win32') {
expect(await getRedboxSource(browser)).toMatchInlineSnapshot(`
"pages/index.js (5:11) @ Index.useEffect
@@ -49,8 +30,6 @@ const appDir = join(__dirname, '../')
`)
}
await browser.close()
await killApp(app)
})
}
)
@@ -0,0 +1,23 @@
import { nextTestSetup } from 'e2e-utils'
describe('Configuration with next.config.mjs', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should disable X-Powered-By header support', async () => {
const res = await next.fetch('/')
expect(res.headers.get('X-Powered-By')).not.toBe('Next.js')
})
it('correctly imports a package that defines `module` but no `main` in package.json', async () => {
const $ = await next.render$('/module-only-content')
expect($('#messageInAPackage').text()).toBe('OK')
})
it('should have env variables available on the client', async () => {
const browser = await next.browser('/next-config')
const envValue = await browser.elementByCss('#env').text()
expect(envValue).toBe('hello')
})
})
@@ -0,0 +1,424 @@
import { nextTestSetup } from 'e2e-utils'
import {
waitForRedbox,
waitForNoRedbox,
getRedboxHeader,
retry,
} from 'next-test-utils'
const reactDependencies = {
react: '19.3.0-canary-fef12a01-20260413',
'react-dom': '19.3.0-canary-fef12a01-20260413',
}
const blogIsr = `export default function Blog({ posts }) {
return posts.map(p => (<div key={p}>{p}</div>))
}
export async function getStaticProps() {
return {
props: { posts: ["my isr post"] },
revalidate: 10,
}
}`
const blogGspRevalidateFalse = `export default function Blog({ posts }) {
return posts.map(p => (<div key={p}>{p}</div>))
}
export async function getStaticProps() {
return {
props: { posts: ["my gsp post"] },
revalidate: false,
}
}`
const blogGspNoRevalidate = `export default function Blog({ posts }) {
return posts.map(p => (<div key={p}>{p}</div>))
}
export async function getStaticProps() {
return {
props: { posts: ["my gsp post"] },
}
}`
const blogGssp = `export default function Blog({ posts }) {
return posts.map(p => (<div key={p}>{p}</div>))
}
export async function getServerSideProps() {
return {
props: { posts: ["my ssr post"] },
}
}`
const postsSlug = (fallback: string) =>
`export default function Post(props) {
return <h1>Hello from {props.slug}</h1>
}
export async function getStaticPaths({ params }) {
return {
paths: [
{ params: { slug: 'one' } },
],
fallback: ${fallback},
}
}
export async function getStaticProps({ params }) {
return {
props: { slug: params.slug },
}
}`
describe('config-output-export', () => {
describe('static homepage', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
})
it('should work with static homepage', async () => {
const response = await next.fetch('/')
expect(response.status).toBe(200)
expect(await response.text()).toContain(
'<div id="__next">Hello World</div>'
)
})
})
describe('"i18n" config', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'next.config.js': `module.exports = ${JSON.stringify({
output: 'export',
i18n: { locales: ['en'], defaultLocale: 'en' },
})}`,
},
})
it('should error with "i18n" config', async () => {
await retry(async () => {
expect(next.cliOutput).toContain(
'Specified "i18n" cannot be used with "output: export".'
)
})
})
})
describe('when hasNextSupport = false', () => {
describe('"rewrites" config', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'next.config.js': `module.exports = ${JSON.stringify({
output: 'export',
rewrites: [{ source: '/from', destination: '/to' }],
})}`,
},
})
it('should error with "rewrites" config', async () => {
await retry(async () => {
expect(next.cliOutput).toContain(
'Specified "rewrites" will not automatically work with "output: export".'
)
})
})
})
describe('"redirects" config', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'next.config.js': `module.exports = ${JSON.stringify({
output: 'export',
redirects: [
{ source: '/from', destination: '/to', permanent: true },
],
})}`,
},
})
it('should error with "redirects" config', async () => {
await retry(async () => {
expect(next.cliOutput).toContain(
'Specified "redirects" will not automatically work with "output: export".'
)
})
})
})
describe('"headers" config', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'next.config.js': `module.exports = ${JSON.stringify({
output: 'export',
headers: [
{
source: '/foo',
headers: [{ key: 'x-foo', value: 'val' }],
},
],
})}`,
},
})
it('should error with "headers" config', async () => {
await retry(async () => {
expect(next.cliOutput).toContain(
'Specified "headers" will not automatically work with "output: export".'
)
})
})
})
})
describe('api routes function', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'pages/api/wow.js': 'export default (_, res) => res.end("wow")',
},
})
it('should error with api routes function', async () => {
const response = await next.fetch('/api/wow')
expect(response.status).toBe(404)
await retry(async () => {
expect(next.cliOutput).toContain(
'API Routes cannot be used with "output: export".'
)
})
})
})
describe('middleware function', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'middleware.js':
'export function middleware(req) { console.log("[mw]",request.url) }',
},
})
it('should error with middleware function', async () => {
const response = await next.fetch('/api/mw')
expect(response.status).toBe(404)
expect(next.cliOutput).not.toContain('[mw]')
await retry(async () => {
expect(next.cliOutput).toContain(
'Middleware cannot be used with "output: export".'
)
})
})
})
describe('getStaticProps with revalidate 10 (ISR)', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'pages/blog.js': blogIsr,
},
})
it('should error with getStaticProps and revalidate 10 seconds (ISR)', async () => {
const browser = await next.browser('/blog')
await waitForRedbox(browser)
expect(await getRedboxHeader(browser)).toContain(
'ISR cannot be used with "output: export".'
)
expect(next.cliOutput).toContain(
'ISR cannot be used with "output: export".'
)
})
})
describe('getStaticProps with revalidate false', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'pages/blog.js': blogGspRevalidateFalse,
},
})
it('should work with getStaticProps and revalidate false', async () => {
const browser = await next.browser('/blog')
await waitForNoRedbox(browser)
})
})
describe('getStaticProps without revalidate', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'pages/blog.js': blogGspNoRevalidate,
},
})
it('should work with getStaticProps and without revalidate', async () => {
const browser = await next.browser('/blog')
await waitForNoRedbox(browser)
})
})
describe('getServerSideProps', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'pages/blog.js': blogGssp,
},
})
it('should error with getServerSideProps without fallback', async () => {
const browser = await next.browser('/blog')
await waitForRedbox(browser)
expect(await getRedboxHeader(browser)).toContain(
'getServerSideProps cannot be used with "output: export".'
)
expect(next.cliOutput).toContain(
'getServerSideProps cannot be used with "output: export".'
)
})
})
describe('getStaticPaths with fallback true', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'pages/posts/[slug].js': postsSlug('true'),
},
})
it('should error with getStaticPaths and fallback true', async () => {
const browser = await next.browser('/posts/one')
await waitForRedbox(browser)
expect(await getRedboxHeader(browser)).toContain(
'getStaticPaths with "fallback: true" cannot be used with "output: export".'
)
expect(next.cliOutput).toContain(
'getStaticPaths with "fallback: true" cannot be used with "output: export".'
)
})
})
describe('getStaticPaths with fallback blocking', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'pages/posts/[slug].js': postsSlug("'blocking'"),
},
})
it('should error with getStaticPaths and fallback blocking', async () => {
const browser = await next.browser('/posts/one')
await waitForRedbox(browser)
expect(await getRedboxHeader(browser)).toContain(
'getStaticPaths with "fallback: blocking" cannot be used with "output: export".'
)
expect(next.cliOutput).toContain(
'getStaticPaths with "fallback: blocking" cannot be used with "output: export".'
)
})
})
describe('getStaticPaths with fallback false', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
overrideFiles: {
'pages/posts/[slug].js': postsSlug('false'),
},
})
it('should work with getStaticPaths and fallback false', async () => {
const browser = await next.browser('/posts/one')
const h1 = await browser.elementByCss('h1')
expect(await h1.text()).toContain('Hello from one')
await waitForNoRedbox(browser)
})
})
})
describe('config-output-export with hasNextSupport', () => {
describe('"rewrites" config', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
env: { NOW_BUILDER: '1' },
overrideFiles: {
'next.config.js': `module.exports = ${JSON.stringify({
output: 'export',
rewrites: [{ source: '/from', destination: '/to' }],
})}`,
},
})
it('should not error with "rewrites" config', async () => {
expect(next.cliOutput).not.toContain(
'Specified "rewrites" will not automatically work with "output: export".'
)
})
})
describe('"redirects" config', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
env: { NOW_BUILDER: '1' },
overrideFiles: {
'next.config.js': `module.exports = ${JSON.stringify({
output: 'export',
redirects: [{ source: '/from', destination: '/to', permanent: true }],
})}`,
},
})
it('should not error with "redirects" config', async () => {
expect(next.cliOutput).not.toContain(
'Specified "redirects" will not automatically work with "output: export".'
)
})
})
describe('"headers" config', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: reactDependencies,
env: { NOW_BUILDER: '1' },
overrideFiles: {
'next.config.js': `module.exports = ${JSON.stringify({
output: 'export',
headers: [
{
source: '/foo',
headers: [{ key: 'x-foo', value: 'val' }],
},
],
})}`,
},
})
it('should not error with "headers" config', async () => {
expect(next.cliOutput).not.toContain(
'Specified "headers" will not automatically work with "output: export".'
)
})
})
})
+29
View File
@@ -0,0 +1,29 @@
import cheerio from 'cheerio'
import { nextTestSetup } from 'e2e-utils'
describe('Configuration', () => {
const { next } = nextTestSetup({ files: __dirname })
async function get$(path: string) {
const html = await next.render(path)
return cheerio.load(html)
}
it('should disable X-Powered-By header support', async () => {
const res = await next.fetch('/')
const header = res.headers.get('X-Powered-By')
expect(header).not.toBe('Next.js')
})
test('correctly imports a package that defines `module` but no `main` in package.json', async () => {
const $ = await get$('/module-only-content')
expect($('#messageInAPackage').text()).toBe('OK')
})
it('should have env variables available on the client', async () => {
const browser = await next.browser('/next-config')
const envValue = await browser.elementByCss('#env').text()
expect(envValue).toBe('hello')
await browser.close()
})
})
+3
View File
@@ -0,0 +1,3 @@
.frameworkClass {
background: blue;
}
@@ -0,0 +1,57 @@
/* eslint-env jest */
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import { join } from 'path'
describe('Has CSS Module in computed styles in Development', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures', 'dev-module'),
})
it('should have CSS for page', async () => {
const browser = await next.browser('/')
const currentColor = await browser.eval(
`window.getComputedStyle(document.querySelector('#verify-red')).color`
)
expect(currentColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
})
})
describe('Can hot reload CSS Module without losing state', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures', 'hmr-module'),
patchFileDelay: 500,
})
it('should update CSS color without remounting <input>', async () => {
const browser = await next.browser('/')
const desiredText = 'hello world'
await browser.elementById('text-input').type(desiredText)
expect(await browser.elementById('text-input').getValue()).toBe(desiredText)
const currentColor = await browser.eval(
`window.getComputedStyle(document.querySelector('#verify-red')).color`
)
expect(currentColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
await next.patchFile(
'pages/index.module.css',
(content) => content.replace('color: red', 'color: purple'),
async () => {
await retry(async () => {
const refreshedColor = await browser.eval(
`window.getComputedStyle(document.querySelector('#verify-red')).color`
)
expect(refreshedColor).toMatchInlineSnapshot(`"rgb(128, 0, 128)"`)
})
expect(await browser.elementById('text-input').getValue()).toBe(
desiredText
)
}
)
})
})
@@ -0,0 +1,111 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import { join } from 'path'
describe('Can hot reload CSS without losing state', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures/multi-page'),
})
it('should update CSS color without remounting <input>', async () => {
const browser = await next.browser('/page1')
const desiredText = 'hello world'
await browser.elementById('text-input').type(desiredText)
expect(await browser.elementById('text-input').getValue()).toBe(desiredText)
const currentColor = await browser.eval(
`window.getComputedStyle(document.querySelector('.red-text')).color`
)
expect(currentColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
const cssPath = 'styles/global1.css'
const originalCss = await next.readFile(cssPath)
await next.patchFile(
cssPath,
originalCss.replace('color: red', 'color: purple')
)
try {
await retry(async () => {
const color = await browser.eval(
`window.getComputedStyle(document.querySelector('.red-text')).color`
)
expect(color).toBe('rgb(128, 0, 128)')
})
expect(await browser.elementById('text-input').getValue()).toBe(
desiredText
)
} finally {
await next.patchFile(cssPath, originalCss)
}
})
})
describe('Has CSS in computed styles in Development', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures/multi-page'),
})
it('should have CSS for page', async () => {
const browser = await next.browser('/page2')
const currentColor = await browser.eval(
`window.getComputedStyle(document.querySelector('.blue-text')).color`
)
expect(currentColor).toMatchInlineSnapshot(`"rgb(0, 0, 255)"`)
})
})
describe('Body is not hidden when unused in Development', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures/unused'),
})
it('should have body visible', async () => {
const browser = await next.browser('/')
const currentDisplay = await browser.eval(
`window.getComputedStyle(document.querySelector('body')).display`
)
expect(currentDisplay).toBe('block')
})
})
describe('Body is not hidden when broken in Development', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures/unused'),
})
it('should have body visible', async () => {
await next.patchFile(
'pages/index.js',
(content) => content!.replace('<div />', '<div>'),
async () => {
const browser = await next.browser('/')
await retry(async () => {
const currentDisplay = await browser.eval(
`window.getComputedStyle(document.querySelector('body')).display`
)
expect(currentDisplay).toBe('block')
})
}
)
})
})
describe('React Lifecyce Order (dev)', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures/transition-react'),
})
it('should have the correct color on mount after navigation', async () => {
const browser = await next.browser('/')
await browser.waitForElementByCss('#link-other').click()
await retry(async () => {
const text = await browser.elementByCss('#red-title').text()
expect(text).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
})
})
})
@@ -0,0 +1,59 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import { join } from 'path'
describe('Has CSS Module in computed styles in Development', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures/dev-module'),
})
it('should have CSS for page', async () => {
const browser = await next.browser('/')
const currentColor = await browser.eval(
`window.getComputedStyle(document.querySelector('#verify-red')).color`
)
expect(currentColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
})
})
describe('Can hot reload CSS Module without losing state', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures/hmr-module'),
})
it('should update CSS color without remounting <input>', async () => {
const browser = await next.browser('/')
const desiredText = 'hello world'
await browser.elementById('text-input').type(desiredText)
expect(await browser.elementById('text-input').getValue()).toBe(desiredText)
const currentColor = await browser.eval(
`window.getComputedStyle(document.querySelector('#verify-red')).color`
)
expect(currentColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
const cssFilePath = 'pages/index.module.css'
const originalContent = await next.readFile(cssFilePath)
try {
await next.patchFile(
cssFilePath,
originalContent.replace('color: red', 'color: purple')
)
await retry(async () => {
const refreshedColor = await browser.eval(
`window.getComputedStyle(document.querySelector('#verify-red')).color`
)
expect(refreshedColor).toMatchInlineSnapshot(`"rgb(128, 0, 128)"`)
})
expect(await browser.elementById('text-input').getValue()).toBe(
desiredText
)
} finally {
await next.patchFile(cssFilePath, originalContent)
}
})
})

Some files were not shown because too many files have changed in this diff Show More