mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.*',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
+28
-46
@@ -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')
|
||||
})
|
||||
}
|
||||
)
|
||||
+23
-50
@@ -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/)
|
||||
})
|
||||
})
|
||||
+10
-31
@@ -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')
|
||||
})
|
||||
})
|
||||
Generated
Vendored
Generated
Vendored
Generated
Vendored
@@ -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".'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
.frameworkClass {
|
||||
background: blue;
|
||||
}
|
||||
Generated
Vendored
Generated
Vendored
@@ -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
Reference in New Issue
Block a user