diff --git a/.claude/commands/ci-failures.md b/.claude/commands/ci-failures.md deleted file mode 100644 index 8c85a5a32aae30..00000000000000 --- a/.claude/commands/ci-failures.md +++ /dev/null @@ -1,83 +0,0 @@ -# Check CI Failures - -Analyze failing tests from PR CI runs. - -## Usage - -``` -/ci-failures -``` - -Automatically detects PR from current branch. - -## Instructions - -1. Run the script to fetch CI failure data: - - ```bash - node scripts/ci-failures.js - ``` - - This fetches workflow runs, failed jobs, and logs, then generates markdown files. - -2. Read the generated index file for a summary: - - ```bash - # Read scripts/ci-failures/index.md - ``` - - The index shows all failed jobs with links to details. - -3. Spawn parallel haiku subagents to analyze the failing jobs (limit to 3-4 to avoid rate limits) - - **Agent prompt template** (copy-paste for each agent): - - ``` - Analyze CI results for these jobs: scripts/ci-failures/job-{id1}.md scripts/ci-failures/job-{id2}.md - For each failing test, extract: - 1. TEST FILE: (full path, e.g., test/production/required-server-files-ssr-404/test/index.test.ts) - 2. TEST NAME: (the specific test case name)1 - 3. JOB TYPE: (the kind of the job, e.g. turbopack production, webpack dev, rust check) - 4. EXPECTED: (exact expected value from assertion) - 5. RECEIVED: (exact received value from assertion) - 6. CATEGORY: (assertion|timeout|routing|source-map|build|cli-output) - 7. ROOT CAUSE: (one sentence hypothesis) - 8. LOG FILE: (analysed log file that led to conclusion) - Return structured findings grouped by TEST FILE, not by job. - - Also extract other failures that are not related to tests. - Identify if they are likely transient. - - ``` - -4. **Deduplicate by test file** before summarizing: - - Group all failures by TEST FILE path, not by CI job name - - If multiple jobs fail the same test file, count them but report once - - Identify systemic issues (same test failing across many jobs) - -5. Analyze failures and create a summary **grouped by test file**: - - | Test File | Type. | Issue (Expected vs Received) | Jobs | Priority | - | --------------------------------------------------- | -------------- | ----------------------------------- | ---- | -------- | - | `test/production/required-server-files-ssr-404/...` | Turbopack prod | `"second"` vs `"[slug]"` (routing) | 3 | HIGH | - | `test/integration/server-side-dev-errors/...` | webpack dev | source map paths wrong | 5 | HIGH | - | `test/e2e/app-dir/disable-logging-route/...` | prod | "Compiling" appearing when disabled | 2 | MEDIUM | - | N/A | rust check | Formatting incorrect | 2 | MEDIUM | - -6. Recommend fixes: - - **HIGH priority**: Show specific expected vs actual values, include test file path - - **MEDIUM priority**: Identify root cause pattern - - **LOW priority**: Mark as likely flaky/transient - -- Do not try to fix these failures. -- If failures would require complex analysis and there are multiple problems, only do some basic analysis and point out that further investigation is needed and could be performant when requested. - -## Failure Categories - -- **Infrastructure/Transient**: Network errors, 503s, timeouts unrelated to code -- **Assertion Failures**: Wrong output, path mismatches, snapshot differences -- **Build Failures**: Compilation errors, missing dependencies -- **Timeout**: Tests hanging, usually indicates async issues or missing server responses -- **Port Binding**: EADDRINUSE errors, parallel test conflicts -- **Routing/SSR**: Dynamic params not resolved, wrong status codes, JSON parse errors -- **Source Maps**: `webpack-internal://` paths, wrong line numbers, missing code frames -- **CLI Output**: Missing warnings, wrong log order, "Ready" printed before errors diff --git a/.claude/commands/pr-status.md b/.claude/commands/pr-status.md new file mode 100644 index 00000000000000..1c2018d01431c9 --- /dev/null +++ b/.claude/commands/pr-status.md @@ -0,0 +1,120 @@ +# PR Status + +Analyze PR status including CI failures and review comments. + +## Usage + +``` +/pr-status [PR number] +``` + +- Without argument: Automatically detects PR from current branch +- With PR number: Analyzes the specified PR (e.g., `/pr-status 89049`) + +## Instructions + +1. Run the script to fetch PR status data: + + ```bash + node scripts/pr-status.js $ARGUMENTS + ``` + + This fetches workflow runs, failed jobs, logs, and PR review comments, then generates markdown files. + +2. Read the generated index file for a summary: + + ```bash + # Read scripts/pr-status/index.md + ``` + + The index shows failed jobs, PR reviews, and inline review comments with links to details. + +3. Spawn parallel haiku subagents to analyze the failing jobs (limit to 3-4 to avoid rate limits) + - **Agent prompt template** (copy-paste for each agent): + + ``` + Analyze CI results for these jobs: scripts/pr-status/job-{id1}.md scripts/pr-status/job-{id2}.md + For each failing test, extract: + 1. TEST FILE: (full path, e.g., test/production/required-server-files-ssr-404/test/index.test.ts) + 2. TEST NAME: (the specific test case name) + 3. JOB TYPE: (the kind of the job, e.g. turbopack production, webpack dev, rust check) + 4. EXPECTED: (exact expected value from assertion) + 5. RECEIVED: (exact received value from assertion) + 6. CATEGORY: (assertion|timeout|routing|source-map|build|cli-output) + 7. ROOT CAUSE: (one sentence hypothesis) + 8. LOG FILE: (analysed log file that led to conclusion) + Return structured findings grouped by TEST FILE, not by job. + + Also extract other failures that are not related to tests. + Identify if they are likely transient. + + ``` + +4. Spawn parallel haiku subagents to analyze review comments (if any review threads exist): + - **Agent prompt template**: + + ``` + Analyze PR review comments from these files: scripts/pr-status/thread-{i}.md scripts/pr-status/review-{id}.md + For each review thread/comment, extract: + 1. FILE: (the file path being reviewed) + 2. REVIEWER: (who left the comment) + 3. STATUS: (Open/Resolved for threads, APPROVED/CHANGES_REQUESTED/COMMENTED for reviews) + 4. TYPE: (code-style|bug|design|question|suggestion|nitpick|blocker) + 5. SUMMARY: (one sentence summary of the feedback) + 6. ACTION REQUIRED: (yes/no - does this require changes?) + 7. PRIORITY: (high if CHANGES_REQUESTED or blocker, medium if open suggestion, low if resolved or nitpick) + Return findings grouped by file path. + ``` + +5. **Deduplicate by test file** before summarizing: + - Group all failures by TEST FILE path, not by CI job name + - If multiple jobs fail the same test file, count them but report once + - Identify systemic issues (same test failing across many jobs) + +6. Analyze failures and review comments, create a summary: + + **CI Failures (grouped by test file):** + + | Test File | Type | Issue (Expected vs Received) | Jobs | Priority | + | --------------------------------------------------- | -------------- | ----------------------------------- | ---- | -------- | + | `test/production/required-server-files-ssr-404/...` | Turbopack prod | `"second"` vs `"[slug]"` (routing) | 3 | HIGH | + | `test/integration/server-side-dev-errors/...` | webpack dev | source map paths wrong | 5 | HIGH | + | `test/e2e/app-dir/disable-logging-route/...` | prod | "Compiling" appearing when disabled | 2 | MEDIUM | + | N/A | rust check | Formatting incorrect | 2 | MEDIUM | + + **Review Feedback (grouped by file):** + + | File | Reviewer | Type | Summary | Action Required | Priority | + | ---------------- | -------- | ---------- | -------------------------- | --------------- | -------- | + | `src/server/...` | reviewer | suggestion | Consider using async/await | yes | MEDIUM | + | `test/e2e/...` | reviewer | nitpick | Typo in comment | no | LOW | + | N/A | reviewer | blocker | Missing error handling | yes | HIGH | + +7. Recommend fixes: + - **HIGH priority**: Show specific expected vs actual values, include test file path, address blocker review comments + - **MEDIUM priority**: Identify root cause pattern, address open suggestions + - **LOW priority**: Mark as likely flaky/transient, note resolved/nitpick comments + +- Do not try to fix these failures or address review comments without user confirmation. +- If failures would require complex analysis and there are multiple problems, only do some basic analysis and point out that further investigation is needed and could be performed when requested. + +## Failure Categories + +- **Infrastructure/Transient**: Network errors, 503s, timeouts unrelated to code +- **Assertion Failures**: Wrong output, path mismatches, snapshot differences +- **Build Failures**: Compilation errors, missing dependencies +- **Timeout**: Tests hanging, usually indicates async issues or missing server responses +- **Port Binding**: EADDRINUSE errors, parallel test conflicts +- **Routing/SSR**: Dynamic params not resolved, wrong status codes, JSON parse errors +- **Source Maps**: `webpack-internal://` paths, wrong line numbers, missing code frames +- **CLI Output**: Missing warnings, wrong log order, "Ready" printed before errors + +## Review Comment Categories + +- **code-style**: Formatting, naming conventions, code organization +- **bug**: Potential bugs or logic errors +- **design**: Architectural or design concerns +- **question**: Questions about implementation or intent +- **suggestion**: Non-blocking improvements +- **nitpick**: Minor issues that don't require changes +- **blocker**: Must be addressed before merge diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index dad94c974da853..6dbcc5065d8088 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -625,7 +625,7 @@ jobs: secrets: inherit test-new-tests-dev: - name: Test new tests for flakes (dev) + name: Test new and changed tests for flakes (dev) needs: ['optimize-ci', 'changes', 'build-native', 'build-next'] # test-new-tests-if if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }} @@ -650,7 +650,7 @@ jobs: secrets: inherit test-new-tests-start: - name: Test new tests for flakes (prod) + name: Test new and changed tests for flakes (prod) needs: ['optimize-ci', 'changes', 'build-native', 'build-next'] # test-new-tests-if if: ${{ needs.optimize-ci.outputs.skip == 'false' && needs.changes.outputs.docs-only == 'false' }} @@ -674,7 +674,7 @@ jobs: secrets: inherit test-new-tests-deploy: - name: Test new tests when deployed + name: Test new and changed tests when deployed needs: ['optimize-ci', 'test-prod', 'test-new-tests-dev', 'test-new-tests-start'] # test-new-tests-if @@ -700,7 +700,7 @@ jobs: secrets: inherit test-new-tests-deploy-cache-components: - name: Test new tests when deployed (cache components) + name: Test new and changed tests when deployed (cache components) needs: [ 'optimize-ci', diff --git a/AGENTS.md b/AGENTS.md index c9300541106850..ae8f2b7fbcc201 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,28 +181,23 @@ pnpm prettier-fix # Fix formatting only pnpm types # TypeScript type checking ``` -## Investigating CI Test Failures +## PR Status (CI Failures and Reviews) -**Use `/ci-failures` for automated analysis** - analyzes failing jobs in parallel and groups by test file. +When the user asks about CI failures, PR reviews, or the status of a PR, run the pr-status script: -**CI Analysis Tips:** +```bash +node scripts/pr-status.js # Auto-detects PR from current branch +node scripts/pr-status.js # Analyze specific PR by number +``` -- Don't spawn too many parallel agents hitting GitHub API (causes rate limits) -- Prioritize blocking jobs first: lint, types, then test jobs -- Use `gh api` for logs (works on in-progress runs), not `gh run view --log` +This fetches CI workflow runs, failed jobs, logs, and PR review comments, generating markdown files in `scripts/pr-status/`. -**Quick triage:** +**Use `/pr-status` for automated analysis** - analyzes failing jobs and review comments in parallel, groups failures by test file. -```bash -# List failed jobs for a PR -gh pr checks | grep fail - -# Get failed job names -gh run view --json jobs --jq '.jobs[] | select(.conclusion == "failure") | .name' +**CI Analysis Tips:** -# Search job logs for errors (completed runs only - use gh api for in-progress) -gh run view --job --log 2>&1 | grep -E "FAIL|Error|error:" | head -30 -``` +- Prioritize blocking jobs first: build, lint, types, then test jobs +- Prioritize CI failures over review comments **Common failure patterns:** diff --git a/crates/next-core/src/next_client/transforms.rs b/crates/next-core/src/next_client/transforms.rs index 1f65778643fc65..994533508f3cea 100644 --- a/crates/next-core/src/next_client/transforms.rs +++ b/crates/next-core/src/next_client/transforms.rs @@ -10,8 +10,8 @@ use crate::{ next_config::NextConfig, next_shared::transforms::{ debug_fn_name::get_debug_fn_name_rule, get_import_type_bytes_rule, - get_next_dynamic_transform_rule, get_next_font_transform_rule, get_next_image_rule, - get_next_lint_transform_rule, get_next_modularize_imports_rule, + get_import_type_json_rule, get_next_dynamic_transform_rule, get_next_font_transform_rule, + get_next_image_rule, get_next_lint_transform_rule, get_next_modularize_imports_rule, get_next_pages_transforms_rule, get_server_actions_transform_rule, next_cjs_optimizer::get_next_cjs_optimizer_rule, next_disallow_re_export_all_in_page::get_next_disallow_export_all_in_page_rule, @@ -117,5 +117,7 @@ pub async fn get_next_client_transforms_rules( rules.push(get_import_type_bytes_rule()); } + rules.push(get_import_type_json_rule()); + Ok(rules) } diff --git a/crates/next-core/src/next_server/transforms.rs b/crates/next-core/src/next_server/transforms.rs index b62b6771c30493..a900b4705e624a 100644 --- a/crates/next-core/src/next_server/transforms.rs +++ b/crates/next-core/src/next_server/transforms.rs @@ -12,10 +12,11 @@ use crate::{ next_config::NextConfig, next_server::context::ServerContextType, next_shared::transforms::{ - get_import_type_bytes_rule, get_next_dynamic_transform_rule, get_next_font_transform_rule, - get_next_image_rule, get_next_lint_transform_rule, get_next_modularize_imports_rule, - get_next_pages_transforms_rule, get_next_track_dynamic_imports_transform_rule, - get_server_actions_transform_rule, next_cjs_optimizer::get_next_cjs_optimizer_rule, + get_import_type_bytes_rule, get_import_type_json_rule, get_next_dynamic_transform_rule, + get_next_font_transform_rule, get_next_image_rule, get_next_lint_transform_rule, + get_next_modularize_imports_rule, get_next_pages_transforms_rule, + get_next_track_dynamic_imports_transform_rule, get_server_actions_transform_rule, + next_cjs_optimizer::get_next_cjs_optimizer_rule, next_disallow_re_export_all_in_page::get_next_disallow_export_all_in_page_rule, next_edge_node_api_assert::next_edge_node_api_assert, next_middleware_dynamic_assert::get_middleware_dynamic_assert_rule, @@ -219,6 +220,8 @@ pub async fn get_next_server_transforms_rules( rules.push(get_import_type_bytes_rule()); } + rules.push(get_import_type_json_rule()); + Ok(rules) } diff --git a/crates/next-core/src/next_shared/transforms/mod.rs b/crates/next-core/src/next_shared/transforms/mod.rs index 62f661e2a50806..918083a1199f31 100644 --- a/crates/next-core/src/next_shared/transforms/mod.rs +++ b/crates/next-core/src/next_shared/transforms/mod.rs @@ -33,7 +33,7 @@ use turbo_tasks::ResolvedVc; use turbo_tasks_fs::FileSystemPath; use turbopack::module_options::{ModuleRule, ModuleRuleEffect, ModuleType, RuleCondition}; use turbopack_core::reference_type::{ - EcmaScriptModulesReferenceSubType, ImportWithType, ReferenceType, UrlReferenceSubType, + EcmaScriptModulesReferenceSubType, ReferenceType, UrlReferenceSubType, }; use turbopack_ecmascript::{CustomTransformer, EcmascriptInputTransform}; @@ -129,12 +129,21 @@ pub(crate) fn get_import_type_bytes_rule() -> ModuleRule { // Move this into turbopack once the feature is standardized ModuleRule::new( RuleCondition::ReferenceType(ReferenceType::EcmaScriptModules( - EcmaScriptModulesReferenceSubType::ImportWithType(ImportWithType::Bytes), + EcmaScriptModulesReferenceSubType::ImportWithType("bytes".into()), )), vec![ModuleRuleEffect::ModuleType(ModuleType::InlinedBytesJs)], ) } +pub(crate) fn get_import_type_json_rule() -> ModuleRule { + ModuleRule::new( + RuleCondition::ReferenceType(ReferenceType::EcmaScriptModules( + EcmaScriptModulesReferenceSubType::ImportWithType("json".into()), + )), + vec![ModuleRuleEffect::ModuleType(ModuleType::Json)], + ) +} + pub(crate) enum EcmascriptTransformStage { Preprocess, Main, diff --git a/docs/01-app/03-api-reference/08-turbopack.mdx b/docs/01-app/03-api-reference/08-turbopack.mdx index 246c3a0fabb744..6b6b42915c4727 100644 --- a/docs/01-app/03-api-reference/08-turbopack.mdx +++ b/docs/01-app/03-api-reference/08-turbopack.mdx @@ -78,7 +78,7 @@ Turbopack in Next.js has **zero-configuration** for the common use cases. Below | **CSS Modules** | **Supported** | `.module.css` files work natively (Lightning CSS). | | **CSS Nesting** | **Supported** | Lightning CSS supports [modern CSS nesting](https://lightningcss.dev/). | | **@import syntax** | **Supported** | Combine multiple CSS files. | -| **PostCSS** | **Supported** | Automatically processes `postcss.config.js` in a Node.js worker pool. Useful for Tailwind, Autoprefixer, etc. | +| **PostCSS** | **Supported** | Automatically processes PostCSS config files (`postcss.config.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts`) in a Node.js worker pool. Useful for Tailwind, Autoprefixer, etc. | | **Sass / SCSS** | **Supported** (Next.js) | For Next.js, Sass is supported out of the box. Custom Sass functions (`sassOptions.functions`) are not supported because Turbopack's Rust-based architecture cannot directly execute JavaScript functions, unlike webpack's Node.js environment. Use webpack if you need this feature. In the future, Turbopack standalone usage will likely require a loader config. | | **Less** | Planned via plugins | Not yet supported by default. Will likely require a loader config once custom loaders are stable. | | **Lightning CSS** | **In Use** | Handles CSS transformations. Some low-usage CSS Modules features (like `:local/:global` as standalone pseudo-classes) are not yet supported. [See below for more details.](#unsupported-and-unplanned-features) | diff --git a/lerna.json b/lerna.json index 22613c40c9a994..24259ca9430d53 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.2.0-canary.11" + "version": "16.2.0-canary.13" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 76c45bec5819c2..1d99b321e8f402 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index f8123e3fa30d23..5555374e1cd93b 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.2.0-canary.11", + "@next/eslint-plugin-next": "16.2.0-canary.13", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 564cf8fd7aa286..bc57bd0870782c 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index f9a83ce13467c4..0057d58b21f8bb 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index 0f9243d9090a94..5f3fbfeab44cbf 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 01b1dfa663eeed..3607e6638150cf 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 7996b02f9d501c..a0fb0110f88bd0 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index e37da5b92152c2..d983e78a37ec38 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 4a62b28c426a28..9aa5531cc2c556 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 35565cb46f5875..a5f129f7f17c8f 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index e428b25ff90185..9b9b4f54eee85e 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 75f9521fa94042..80db40653b48de 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index 130d6c7925e3c0..23070404358354 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index b840826b52cbeb..254254644601d5 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 94844c44cf6136..ce31ea5d59fd95 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "private": true, "files": [ "native/" diff --git a/packages/next/errors.json b/packages/next/errors.json index c74753bd720140..be6799ba2d8225 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -982,5 +982,7 @@ "981": "resolvedPathname must be set in request metadata", "982": "`serializeResumeDataCache` should not be called in edge runtime.", "983": "Invariant: global-error module is required but not found in loader tree", - "984": "LRUCache: calculateSize returned %s, but size must be > 0. Items with size 0 would never be evicted, causing unbounded cache growth." + "984": "LRUCache: calculateSize returned %s, but size must be > 0. Items with size 0 would never be evicted, causing unbounded cache growth.", + "985": "No response is returned from route handler '%s'. Expected a Response object but received '%s' (method: %s, url: %s). Ensure you return a \\`Response\\` or a \\`NextResponse\\` in all branches of your handler.", + "986": "Server Action arguments list is too long (%s). Maximum allowed is %s." } diff --git a/packages/next/package.json b/packages/next/package.json index 25c39d35dff82d..b400d6fbe85f3a 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -97,7 +97,7 @@ ] }, "dependencies": { - "@next/env": "16.2.0-canary.11", + "@next/env": "16.2.0-canary.13", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", @@ -162,11 +162,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.2.0-canary.11", - "@next/polyfill-module": "16.2.0-canary.11", - "@next/polyfill-nomodule": "16.2.0-canary.11", - "@next/react-refresh-utils": "16.2.0-canary.11", - "@next/swc": "16.2.0-canary.11", + "@next/font": "16.2.0-canary.13", + "@next/polyfill-module": "16.2.0-canary.13", + "@next/polyfill-nomodule": "16.2.0-canary.13", + "@next/react-refresh-utils": "16.2.0-canary.13", + "@next/swc": "16.2.0-canary.13", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.51.1", "@rspack/core": "1.6.7", diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 7d0b4ff0626efc..f7be815c9ba8ad 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -1267,6 +1267,12 @@ export async function handleAction({ } } +/** + * Limit on the number of arguments passed to a server action. This prevents + * stack overflow during `action.apply()` from malicious requests. + */ +const SERVER_ACTION_ARGS_LIMIT = 1000 + async function executeActionAndPrepareForRender< TFn extends (...args: any[]) => Promise, >( @@ -1282,6 +1288,12 @@ async function executeActionAndPrepareForRender< requestStore.phase = 'action' let skipPageRendering = actionWasForwarded + if (args.length > SERVER_ACTION_ARGS_LIMIT) { + throw new Error( + `Server Action arguments list is too long (${args.length}). Maximum allowed is ${SERVER_ACTION_ARGS_LIMIT}.` + ) + } + try { const actionResult = await workUnitAsyncStorage.run(requestStore, () => action.apply(null, args) diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index f8733c1401f2b6..2ab7b30053cd98 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -647,8 +647,19 @@ export class AppRouteRouteModule extends RouteModule< // Validate that the response is a valid response object. if (!(res instanceof Response)) { + const invalidType = + res === null + ? 'null' + : res === undefined + ? 'undefined' + : typeof res === 'object' + ? res.constructor?.name || 'object' + : typeof res + throw new Error( - `No response is returned from route handler '${this.resolvedPagePath}'. Ensure you return a \`Response\` or a \`NextResponse\` in all branches of your handler.` + `No response is returned from route handler '${this.resolvedPagePath}'. ` + + `Expected a Response object but received '${invalidType}' (method: ${request.method}, url: ${requestStore.url.pathname}). ` + + `Ensure you return a \`Response\` or a \`NextResponse\` in all branches of your handler.` ) } diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index ea7c0ac1089375..6cd91400d551d1 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 957f8f1c80f8e2..26ba3549a05311 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.2.0-canary.11", + "version": "16.2.0-canary.13", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.2.0-canary.11", + "next": "16.2.0-canary.13", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d71b2f409946c2..8e4b1c32c58307 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1008,7 +1008,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.2.0-canary.11 + specifier: 16.2.0-canary.13 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1085,7 +1085,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.2.0-canary.11 + specifier: 16.2.0-canary.13 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1213,19 +1213,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.2.0-canary.11 + specifier: 16.2.0-canary.13 version: link:../font '@next/polyfill-module': - specifier: 16.2.0-canary.11 + specifier: 16.2.0-canary.13 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.2.0-canary.11 + specifier: 16.2.0-canary.13 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.2.0-canary.11 + specifier: 16.2.0-canary.13 version: link:../react-refresh-utils '@next/swc': - specifier: 16.2.0-canary.11 + specifier: 16.2.0-canary.13 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1943,7 +1943,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.2.0-canary.11 + specifier: 16.2.0-canary.13 version: link:../next outdent: specifier: 0.8.0 diff --git a/scripts/.gitignore b/scripts/.gitignore index ab0fb902017aa2..0be1da4c5ea048 100644 --- a/scripts/.gitignore +++ b/scripts/.gitignore @@ -1 +1 @@ -ci-failures/* \ No newline at end of file +pr-status/* \ No newline at end of file diff --git a/scripts/ci-failures.js b/scripts/ci-failures.js deleted file mode 100644 index 6d2cec5ce9b7bb..00000000000000 --- a/scripts/ci-failures.js +++ /dev/null @@ -1,590 +0,0 @@ -const { execSync } = require('child_process') -const fs = require('fs/promises') -const path = require('path') - -const OUTPUT_DIR = path.join(__dirname, 'ci-failures') - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function exec(cmd) { - try { - return execSync(cmd, { - encoding: 'utf8', - maxBuffer: 50 * 1024 * 1024, // 50MB for large logs - }).trim() - } catch (error) { - console.error(`Command failed: ${cmd}`) - console.error(error.stderr || error.message) - throw error - } -} - -function execJson(cmd) { - const output = exec(cmd) - return JSON.parse(output) -} - -function formatDuration(startedAt, completedAt) { - if (!startedAt || !completedAt) return 'N/A' - const start = new Date(startedAt) - const end = new Date(completedAt) - - // Validate that both dates are valid (not Invalid Date objects) - if (isNaN(start.getTime()) || isNaN(end.getTime())) return 'N/A' - - const seconds = Math.floor((end - start) / 1000) - - if (seconds < 60) return `${seconds}s` - const minutes = Math.floor(seconds / 60) - const remainingSeconds = seconds % 60 - return `${minutes}m ${remainingSeconds}s` -} - -function sanitizeFilename(name) { - return name - .replace(/[^a-zA-Z0-9._-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .substring(0, 100) -} - -function escapeMarkdownTableCell(text) { - if (!text) return '' - // Escape pipe characters and newlines for markdown table cells - return String(text) - .replace(/\|/g, '\\|') - .replace(/\n/g, ' ') - .replace(/\r/g, '') -} - -function stripTimestamps(logContent) { - // Remove GitHub Actions timestamp prefixes like "2026-01-23T10:11:12.8077557Z " - return logContent.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s/gm, '') -} - -// ============================================================================ -// Data Fetching Functions -// ============================================================================ - -function getBranchInfo() { - try { - const output = exec(`gh pr view --json number,headRefName`) - const data = JSON.parse(output) - if (data.number && data.headRefName) { - return { prNumber: String(data.number), branchName: data.headRefName } - } - } catch { - // Fallback to git if not in PR context - } - const branchName = exec('git rev-parse --abbrev-ref HEAD') - return { prNumber: null, branchName } -} - -function getWorkflowRuns(branch) { - const encodedBranch = encodeURIComponent(branch) - const jqQuery = - '.workflow_runs[] | select(.name == "build-and-test") | {id, run_attempt, status, conclusion}' - const output = exec( - `gh api "repos/vercel/next.js/actions/runs?branch=${encodedBranch}&per_page=10" --jq '${jqQuery}'` - ) - - if (!output.trim()) return [] - - return output - .split('\n') - .filter((line) => line.trim()) - .map((line) => JSON.parse(line)) -} - -function getRunMetadata(runId) { - return execJson( - `gh api "repos/vercel/next.js/actions/runs/${runId}" --jq '{id, name, status, conclusion, run_attempt, html_url, head_sha, created_at, updated_at}'` - ) -} - -function getFailedJobs(runId) { - const failedJobs = [] - let page = 1 - - while (true) { - const jqQuery = '.jobs[] | select(.conclusion == "failure") | {id, name}' - let output - try { - output = exec( - `gh api "repos/vercel/next.js/actions/runs/${runId}/jobs?per_page=100&page=${page}" --jq '${jqQuery}'` - ) - } catch { - break - } - - if (!output.trim()) break - - const jobs = output - .split('\n') - .filter((line) => line.trim()) - .map((line) => JSON.parse(line)) - - failedJobs.push(...jobs) - - if (jobs.length < 100) break - page++ - } - - return failedJobs -} - -function getJobMetadata(jobId) { - return execJson( - `gh api "repos/vercel/next.js/actions/jobs/${jobId}" --jq '{id, name, status, conclusion, started_at, completed_at, html_url}'` - ) -} - -function getJobLogs(jobId) { - try { - return exec(`gh api "repos/vercel/next.js/actions/jobs/${jobId}/logs"`) - } catch { - return 'Logs not available' - } -} - -// ============================================================================ -// Log Parsing Functions -// ============================================================================ - -function extractTestOutputJson(logContent) { - // Extract all --test output start-- {JSON} --test output end-- blocks - const results = [] - const regex = /--test output start--\s*(\{[\s\S]*?\})\s*--test output end--/g - let match = regex.exec(logContent) - - while (match !== null) { - try { - const json = JSON.parse(match[1]) - results.push(json) - } catch { - // Skip invalid JSON - } - match = regex.exec(logContent) - } - - return results -} - -function extractTestCaseGroups(logContent) { - // Extract ##[group]❌ test/... ##[endgroup] blocks - // Combine multiple retries of the same test into one entry - const groupsByPath = new Map() - const regex = - /##\[group\]❌\s*(test\/[^\s]+)\s+output([\s\S]*?)##\[endgroup\]/g - let match = regex.exec(logContent) - - while (match !== null) { - const testPath = match[1] - const content = stripTimestamps(match[2].trim()) - - if (groupsByPath.has(testPath)) { - // Append retry content with a separator - const existing = groupsByPath.get(testPath) - groupsByPath.set(testPath, `${existing}\n\n--- RETRY ---\n\n${content}`) - } else { - groupsByPath.set(testPath, content) - } - match = regex.exec(logContent) - } - - const groups = [] - for (const [testPath, content] of groupsByPath) { - groups.push({ testPath, content }) - } - return groups -} - -function extractSections(logContent) { - // Split the log into sections at ##[group] and ##[endgroup] boundaries - const sections = [] - const lines = logContent.split('\n') - - let currentSection = { name: null, startLine: 0 } - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - - // Check for group start - const groupMatch = line.match(/##\[group\](.*)/) - if (groupMatch) { - // End current section - const lineCount = i - currentSection.startLine - if (lineCount > 0 || sections.length === 0) { - const rawContent = lines.slice(currentSection.startLine, i).join('\n') - const hasError = rawContent.includes('##[error]') - const content = stripTimestamps(rawContent.trim()) - sections.push({ - name: currentSection.name, - lineCount: lineCount, - content: content, - hasError: hasError, - }) - } - // Start new section with group name - currentSection = { name: groupMatch[1].trim() || null, startLine: i + 1 } - continue - } - - // Check for group end - if (line.includes('##[endgroup]')) { - // End current section - const lineCount = i - currentSection.startLine - const rawContent = lines.slice(currentSection.startLine, i).join('\n') - const hasError = rawContent.includes('##[error]') - const content = stripTimestamps(rawContent.trim()) - sections.push({ - name: currentSection.name, - lineCount: lineCount, - content: content, - hasError: hasError, - }) - // Start new section with no name - currentSection = { name: null, startLine: i + 1 } - continue - } - } - - // Add final section if there are remaining lines - const finalLineCount = lines.length - currentSection.startLine - if (finalLineCount > 0) { - const rawContent = lines.slice(currentSection.startLine).join('\n') - const hasError = rawContent.includes('##[error]') - const content = stripTimestamps(rawContent.trim()) - sections.push({ - name: currentSection.name, - lineCount: finalLineCount, - content: content, - hasError: hasError, - }) - } - - return sections -} - -// ============================================================================ -// Markdown Generation Functions -// ============================================================================ - -function generateIndexMd(branchInfo, runMetadata, failedJobs, jobTestCounts) { - const lines = ['# CI Failures Report', '', `Branch: ${branchInfo.branchName}`] - - if (branchInfo.prNumber) { - lines.push(`PR: #${branchInfo.prNumber}`) - } - - lines.push( - `Run: ${runMetadata.id} (attempt ${runMetadata.run_attempt})`, - `Status: ${runMetadata.conclusion}`, - `Time: ${runMetadata.created_at} - ${runMetadata.updated_at}`, - `URL: ${runMetadata.html_url}`, - '', - `## Failed Jobs (${failedJobs.length})`, - '', - '| Job | Name | Duration | Tests | File |', - '|-----|------|----------|-------|------|' - ) - - for (const job of failedJobs) { - const duration = formatDuration(job.started_at, job.completed_at) - const testCount = jobTestCounts[job.id] - const testsStr = testCount - ? `${testCount.failed}/${testCount.total}` - : 'N/A' - lines.push( - `| ${job.id} | ${escapeMarkdownTableCell(job.name)} | ${duration} | ${testsStr} | [Details](job-${job.id}.md) |` - ) - } - - return lines.join('\n') -} - -function generateJobMd(jobMetadata, testResults, testGroups, sections) { - const duration = formatDuration( - jobMetadata.started_at, - jobMetadata.completed_at - ) - - const lines = [ - `# Job: ${jobMetadata.name}`, - '', - `ID: ${jobMetadata.id}`, - `Status: ${jobMetadata.conclusion}`, - `Started: ${jobMetadata.started_at}`, - `Completed: ${jobMetadata.completed_at}`, - `Duration: ${duration}`, - `URL: ${jobMetadata.html_url}`, - `Full Log: [job-${jobMetadata.id}-full-log.txt](job-${jobMetadata.id}-full-log.txt)`, - '', - ] - - // Add sections list with line counts and links to section files - if (sections.length > 0) { - lines.push('## Sections', '') - - for (let i = 0; i < sections.length; i++) { - const section = sections[i] - const sectionNum = i + 1 - const filename = `job-${jobMetadata.id}-section-${sectionNum}.txt` - const errorPrefix = section.hasError ? '[error] ' : '' - - if (section.name) { - lines.push( - `- ${errorPrefix}[${section.name} (${section.lineCount} lines)](${filename})` - ) - } else { - lines.push(`- ${errorPrefix}[${section.lineCount} lines](${filename})`) - } - } - lines.push('') - } - - // Aggregate test results from all test output JSONs - let totalFailed = 0 - let totalPassed = 0 - let totalTests = 0 - const allFailedTests = [] - - for (const result of testResults) { - totalFailed += result.numFailedTests || 0 - totalPassed += result.numPassedTests || 0 - totalTests += result.numTotalTests || 0 - - if (result.testResults) { - for (const testResult of result.testResults) { - if (testResult.assertionResults) { - for (const assertion of testResult.assertionResults) { - if (assertion.status === 'failed') { - allFailedTests.push({ - testFile: testResult.name, - testName: assertion.fullName || assertion.title, - error: - assertion.failureMessages?.[0]?.substring(0, 100) || - 'Unknown', - }) - } - } - } - } - } - } - - if (totalTests > 0) { - lines.push( - '## Test Results', - '', - `Failed: ${totalFailed}`, - `Passed: ${totalPassed}`, - `Total: ${totalTests}`, - '' - ) - - if (allFailedTests.length > 0) { - lines.push( - '## Failed Tests', - '', - '| Test File | Test Name | Error |', - '|-----------|-----------|-------|' - ) - - 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, '\\|') - lines.push( - `| ${escapeMarkdownTableCell(shortFile)} | ${escapeMarkdownTableCell(test.testName)} | ${shortError}... |` - ) - } - lines.push('') - } - } - - if (testGroups.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) - lines.push( - `- [${group.testPath}](job-${jobMetadata.id}-test-${sanitizedName}.md)` - ) - } - } - - return lines.join('\n') -} - -function generateTestMd(jobMetadata, testPath, content, testResultJson) { - const lines = [ - `# Test: ${testPath}`, - '', - `Job: [${jobMetadata.name}](job-${jobMetadata.id}.md)`, - '', - '## Output', - '', - '```', - content, - '```', - ] - - if (testResultJson) { - lines.push( - '', - '## Test Results JSON', - '', - '```json', - JSON.stringify(testResultJson, null, 2), - '```' - ) - } - - return lines.join('\n') -} - -// ============================================================================ -// Main Function -// ============================================================================ - -async function main() { - // 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 }) - - // Step 2: Get branch info - console.log('Getting branch info...') - const branchInfo = getBranchInfo() - console.log( - `Branch: ${branchInfo.branchName}, PR: ${branchInfo.prNumber || 'N/A'}` - ) - - // Step 3: Get workflow runs - console.log('Fetching workflow runs...') - const runs = getWorkflowRuns(branchInfo.branchName) - - if (runs.length === 0) { - console.log('No workflow runs found for this branch.') - process.exit(0) - } - - // Find the most recent run (first in list) - const latestRun = runs[0] - console.log( - `Latest run: ${latestRun.id} (${latestRun.status}/${latestRun.conclusion})` - ) - - // Step 4: Get run metadata - console.log('Fetching run metadata...') - const runMetadata = getRunMetadata(latestRun.id) - - // Step 5: Get failed jobs - console.log('Fetching failed jobs...') - const failedJobIds = getFailedJobs(latestRun.id) - console.log(`Found ${failedJobIds.length} failed jobs`) - - if (failedJobIds.length === 0) { - console.log('No failed jobs found.') - await fs.writeFile( - path.join(OUTPUT_DIR, 'index.md'), - generateIndexMd(branchInfo, runMetadata, [], {}) - ) - process.exit(0) - } - - // Step 6: Fetch details for each failed job - const failedJobs = [] - const jobTestCounts = {} - - for (const { id, name } of failedJobIds) { - console.log(`Processing job ${id}: ${name}...`) - - // Get job metadata - const jobMetadata = getJobMetadata(id) - failedJobs.push(jobMetadata) - - // Get job logs - const logs = getJobLogs(id) - - // Write full log - await fs.writeFile(path.join(OUTPUT_DIR, `job-${id}-full-log.txt`), logs) - - // Extract test output JSON - const testResults = extractTestOutputJson(logs) - - // Calculate test counts for index - let failed = 0 - let total = 0 - for (const result of testResults) { - failed += result.numFailedTests || 0 - total += result.numTotalTests || 0 - } - if (total > 0) { - jobTestCounts[id] = { failed, total } - } - - // Extract sections from the log - const sections = extractSections(logs) - - // Write individual section files - for (let i = 0; i < sections.length; i++) { - const section = sections[i] - const sectionNum = i + 1 - await fs.writeFile( - path.join(OUTPUT_DIR, `job-${id}-section-${sectionNum}.txt`), - section.content - ) - } - - // Extract test case groups - const testGroups = 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 - ) - await fs.writeFile( - path.join(OUTPUT_DIR, `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) - } - - // Step 7: Generate index.md - console.log('Generating index.md...') - const indexMd = generateIndexMd( - branchInfo, - runMetadata, - failedJobs, - jobTestCounts - ) - await fs.writeFile(path.join(OUTPUT_DIR, 'index.md'), indexMd) - - console.log(`\nDone! Output written to ${OUTPUT_DIR}/index.md`) -} - -main().catch((err) => { - console.error(err) - process.exit(1) -}) diff --git a/scripts/pr-status.js b/scripts/pr-status.js new file mode 100644 index 00000000000000..c84c13a5a410ca --- /dev/null +++ b/scripts/pr-status.js @@ -0,0 +1,1107 @@ +const { execSync } = require('child_process') +const fs = require('fs/promises') +const path = require('path') + +const OUTPUT_DIR = path.join(__dirname, 'pr-status') + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function exec(cmd) { + try { + return execSync(cmd, { + encoding: 'utf8', + maxBuffer: 50 * 1024 * 1024, // 50MB for large logs + }).trim() + } catch (error) { + console.error(`Command failed: ${cmd}`) + console.error(error.stderr || error.message) + throw error + } +} + +function execJson(cmd) { + const output = exec(cmd) + return JSON.parse(output) +} + +function formatDuration(startedAt, completedAt) { + if (!startedAt || !completedAt) return 'N/A' + const start = new Date(startedAt) + const end = new Date(completedAt) + + // Validate that both dates are valid (not Invalid Date objects) + if (isNaN(start.getTime()) || isNaN(end.getTime())) return 'N/A' + + const seconds = Math.floor((end - start) / 1000) + + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes}m ${remainingSeconds}s` +} + +function formatElapsedTime(startedAt) { + if (!startedAt) return 'N/A' + const start = new Date(startedAt) + if (isNaN(start.getTime())) return 'N/A' + + const now = new Date() + const seconds = Math.floor((now - start) / 1000) + + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes}m ${remainingSeconds}s` +} + +function sanitizeFilename(name) { + return name + .replace(/[^a-zA-Z0-9._-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 100) +} + +function escapeMarkdownTableCell(text) { + if (!text) return '' + // Escape pipe characters and newlines for markdown table cells + return String(text) + .replace(/\|/g, '\\|') + .replace(/\n/g, ' ') + .replace(/\r/g, '') +} + +function stripTimestamps(logContent) { + // Remove GitHub Actions timestamp prefixes like "2026-01-23T10:11:12.8077557Z " + return logContent.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s/gm, '') +} + +function isBot(username) { + if (!username) return false + return username.endsWith('-bot') || username.endsWith('[bot]') +} + +// ============================================================================ +// Data Fetching Functions +// ============================================================================ + +function getBranchInfo(prNumberArg) { + // If PR number provided as argument, fetch branch from that PR + if (prNumberArg) { + try { + const output = exec(`gh pr view ${prNumberArg} --json number,headRefName`) + const data = JSON.parse(output) + if (data.number && data.headRefName) { + return { prNumber: String(data.number), branchName: data.headRefName } + } + } catch { + console.error(`Failed to fetch PR #${prNumberArg}`) + process.exit(1) + } + } + + // Auto-detect from current branch/PR context + try { + const output = exec(`gh pr view --json number,headRefName`) + const data = JSON.parse(output) + if (data.number && data.headRefName) { + return { prNumber: String(data.number), branchName: data.headRefName } + } + } catch { + // Fallback to git if not in PR context + } + const branchName = exec('git rev-parse --abbrev-ref HEAD') + return { prNumber: null, branchName } +} + +function getWorkflowRuns(branch) { + const encodedBranch = encodeURIComponent(branch) + const jqQuery = + '.workflow_runs[] | select(.name == "build-and-test") | {id, run_attempt, status, conclusion}' + const output = exec( + `gh api "repos/vercel/next.js/actions/runs?branch=${encodedBranch}&per_page=10" --jq '${jqQuery}'` + ) + + if (!output.trim()) return [] + + return output + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) +} + +function getRunMetadata(runId) { + return execJson( + `gh api "repos/vercel/next.js/actions/runs/${runId}" --jq '{id, name, status, conclusion, run_attempt, html_url, head_sha, created_at, updated_at}'` + ) +} + +function getFailedJobs(runId) { + const failedJobs = [] + let page = 1 + + while (true) { + const jqQuery = '.jobs[] | select(.conclusion == "failure") | {id, name}' + let output + try { + output = exec( + `gh api "repos/vercel/next.js/actions/runs/${runId}/jobs?per_page=100&page=${page}" --jq '${jqQuery}'` + ) + } catch { + break + } + + if (!output.trim()) break + + const jobs = output + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + + failedJobs.push(...jobs) + + if (jobs.length < 100) break + page++ + } + + return failedJobs +} + +function getAllJobs(runId) { + const allJobs = [] + let page = 1 + + while (true) { + const jqQuery = + '.jobs[] | {id, name, status, conclusion, started_at, completed_at}' + let output + try { + output = exec( + `gh api "repos/vercel/next.js/actions/runs/${runId}/jobs?per_page=100&page=${page}" --jq '${jqQuery}'` + ) + } catch { + break + } + + if (!output.trim()) break + + const jobs = output + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + + allJobs.push(...jobs) + + if (jobs.length < 100) break + page++ + } + + return allJobs +} + +function categorizeJobs(jobs) { + return { + failed: jobs.filter((j) => j.conclusion === 'failure'), + inProgress: jobs.filter((j) => j.status === 'in_progress'), + queued: jobs.filter((j) => j.status === 'queued'), + succeeded: jobs.filter((j) => j.conclusion === 'success'), + cancelled: jobs.filter((j) => j.conclusion === 'cancelled'), + skipped: jobs.filter((j) => j.conclusion === 'skipped'), + } +} + +function getJobMetadata(jobId) { + return execJson( + `gh api "repos/vercel/next.js/actions/jobs/${jobId}" --jq '{id, name, status, conclusion, started_at, completed_at, html_url}'` + ) +} + +function getJobLogs(jobId) { + try { + return exec(`gh api "repos/vercel/next.js/actions/jobs/${jobId}/logs"`) + } catch { + return 'Logs not available' + } +} + +function getPRReviews(prNumber) { + try { + const reviews = execJson( + `gh api "repos/vercel/next.js/pulls/${prNumber}/reviews" --jq '[.[] | {id, user: .user.login, state: .state, body: .body, submitted_at: .submitted_at, html_url: .html_url}]'` + ) + return reviews.filter((r) => !isBot(r.user)) + } catch { + return [] + } +} + +function getPRReviewThreads(prNumber) { + const query = ` + query { + repository(owner:"vercel", name:"next.js") { + pullRequest(number:${prNumber}) { + reviewThreads(first:100) { + nodes { + isResolved + path + line + startLine + diffSide + comments(first:50) { + nodes { + id + author { login } + body + createdAt + url + diffHunk + } + } + } + } + } + } + } + ` + try { + const output = exec(`gh api graphql -f query='${query}'`) + const data = JSON.parse(output) + return data.data.repository.pullRequest.reviewThreads.nodes + } catch { + return [] + } +} + +function getPRComments(prNumber) { + try { + const comments = execJson( + `gh api "repos/vercel/next.js/issues/${prNumber}/comments" --jq '[.[] | {id, user: .user.login, body: .body, created_at: .created_at, html_url: .html_url}]'` + ) + return comments.filter((c) => !isBot(c.user)) + } catch { + return [] + } +} + +// ============================================================================ +// Log Parsing Functions +// ============================================================================ + +function extractTestOutputJson(logContent) { + // Extract all --test output start-- {JSON} --test output end-- blocks + const results = [] + const regex = /--test output start--\s*(\{[\s\S]*?\})\s*--test output end--/g + let match = regex.exec(logContent) + + while (match !== null) { + try { + const json = JSON.parse(match[1]) + results.push(json) + } catch { + // Skip invalid JSON + } + match = regex.exec(logContent) + } + + return results +} + +function extractTestCaseGroups(logContent) { + // Extract ##[group]❌ test/... ##[endgroup] blocks + // Combine multiple retries of the same test into one entry + const groupsByPath = new Map() + const regex = + /##\[group\]❌\s*(test\/[^\s]+)\s+output([\s\S]*?)##\[endgroup\]/g + let match = regex.exec(logContent) + + while (match !== null) { + const testPath = match[1] + const content = stripTimestamps(match[2].trim()) + + if (groupsByPath.has(testPath)) { + // Append retry content with a separator + const existing = groupsByPath.get(testPath) + groupsByPath.set(testPath, `${existing}\n\n--- RETRY ---\n\n${content}`) + } else { + groupsByPath.set(testPath, content) + } + match = regex.exec(logContent) + } + + const groups = [] + for (const [testPath, content] of groupsByPath) { + groups.push({ testPath, content }) + } + return groups +} + +function extractSections(logContent) { + // Split the log into sections at ##[group] and ##[endgroup] boundaries + const sections = [] + const lines = logContent.split('\n') + + let currentSection = { name: null, startLine: 0 } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Check for group start + const groupMatch = line.match(/##\[group\](.*)/) + if (groupMatch) { + // End current section + const lineCount = i - currentSection.startLine + if (lineCount > 0 || sections.length === 0) { + const rawContent = lines.slice(currentSection.startLine, i).join('\n') + const hasError = rawContent.includes('##[error]') + const content = stripTimestamps(rawContent.trim()) + sections.push({ + name: currentSection.name, + lineCount: lineCount, + content: content, + hasError: hasError, + }) + } + // Start new section with group name + currentSection = { name: groupMatch[1].trim() || null, startLine: i + 1 } + continue + } + + // Check for group end + if (line.includes('##[endgroup]')) { + // End current section + const lineCount = i - currentSection.startLine + const rawContent = lines.slice(currentSection.startLine, i).join('\n') + const hasError = rawContent.includes('##[error]') + const content = stripTimestamps(rawContent.trim()) + sections.push({ + name: currentSection.name, + lineCount: lineCount, + content: content, + hasError: hasError, + }) + // Start new section with no name + currentSection = { name: null, startLine: i + 1 } + continue + } + } + + // Add final section if there are remaining lines + const finalLineCount = lines.length - currentSection.startLine + if (finalLineCount > 0) { + const rawContent = lines.slice(currentSection.startLine).join('\n') + const hasError = rawContent.includes('##[error]') + const content = stripTimestamps(rawContent.trim()) + sections.push({ + name: currentSection.name, + lineCount: finalLineCount, + content: content, + hasError: hasError, + }) + } + + return sections +} + +// ============================================================================ +// Markdown Generation Functions +// ============================================================================ + +function generateIndexMd( + branchInfo, + runMetadata, + categorizedJobs, + jobTestCounts, + reviewData +) { + const { failed, inProgress, queued, succeeded, cancelled, skipped } = + categorizedJobs + const totalJobs = + failed.length + + inProgress.length + + queued.length + + succeeded.length + + cancelled.length + + skipped.length + const completedJobs = + failed.length + succeeded.length + cancelled.length + skipped.length + + const isRunComplete = runMetadata.status === 'completed' + const reportTitle = isRunComplete + ? '# CI Failures Report' + : '# CI Status Report' + + const lines = [reportTitle, '', `Branch: ${branchInfo.branchName}`] + + if (branchInfo.prNumber) { + lines.push(`PR: #${branchInfo.prNumber}`) + } + + const statusStr = runMetadata.conclusion + ? `${runMetadata.status}/${runMetadata.conclusion}` + : runMetadata.status + + lines.push( + `Run: ${runMetadata.id} (attempt ${runMetadata.run_attempt})`, + `Status: ${statusStr}`, + `Time: ${runMetadata.created_at} - ${runMetadata.updated_at || 'ongoing'}`, + `URL: ${runMetadata.html_url}`, + '' + ) + + // Progress summary for in-progress runs + if (!isRunComplete) { + lines.push( + '## CI Progress', + '', + `**${completedJobs}/${totalJobs}** jobs completed`, + '', + '| Status | Count |', + '|--------|-------|', + `| Failed | ${failed.length} |`, + `| In Progress | ${inProgress.length} |`, + `| Queued | ${queued.length} |`, + `| Succeeded | ${succeeded.length} |` + ) + if (cancelled.length > 0) lines.push(`| Cancelled | ${cancelled.length} |`) + if (skipped.length > 0) lines.push(`| Skipped | ${skipped.length} |`) + lines.push( + '', + '> **Note:** CI is still running. Re-run this script later for updated results.', + '' + ) + } + + // Failed jobs section + if (failed.length > 0) { + lines.push( + `## Failed Jobs (${failed.length})`, + '', + '| Job | Name | Duration | Tests | File |', + '|-----|------|----------|-------|------|' + ) + + for (const job of failed) { + const duration = formatDuration(job.started_at, job.completed_at) + const testCount = jobTestCounts[job.id] + const testsStr = testCount + ? `${testCount.failed}/${testCount.total}` + : 'N/A' + lines.push( + `| ${job.id} | ${escapeMarkdownTableCell(job.name)} | ${duration} | ${testsStr} | [Details](job-${job.id}.md) |` + ) + } + lines.push('') + } + + // In-progress jobs section (only when CI is running) + if (inProgress.length > 0) { + lines.push( + `## In Progress Jobs (${inProgress.length})`, + '', + '| Job | Name | Running For |', + '|-----|------|-------------|' + ) + + for (const job of inProgress) { + const elapsed = formatElapsedTime(job.started_at) + lines.push( + `| ${job.id} | ${escapeMarkdownTableCell(job.name)} | ${elapsed} |` + ) + } + lines.push('') + } + + // Queued jobs section (only when CI is running) + if (queued.length > 0) { + lines.push( + `## Queued Jobs (${queued.length})`, + '', + '| Job | Name |', + '|-----|------|' + ) + + for (const job of queued) { + lines.push(`| ${job.id} | ${escapeMarkdownTableCell(job.name)} |`) + } + lines.push('') + } + + // Add PR reviews section if we have review data + if (reviewData) { + const { reviews, reviewThreads, prComments } = reviewData + + // Filter reviews to only include meaningful ones + const meaningfulReviews = reviews.filter( + (r) => + r.state === 'APPROVED' || + r.state === 'CHANGES_REQUESTED' || + r.body?.trim() + ) + + if (meaningfulReviews.length > 0 || prComments.length > 0) { + lines.push('', `## PR Reviews (${meaningfulReviews.length})`, '') + + if (meaningfulReviews.length > 0) { + lines.push( + '| Reviewer | State | Date/Time | Comment |', + '|----------|-------|-----------|---------|' + ) + + // Sort reviews by date, oldest first + const sortedReviews = [...meaningfulReviews].sort( + (a, b) => new Date(a.submitted_at) - new Date(b.submitted_at) + ) + + for (const review of sortedReviews) { + const time = review.submitted_at + ? new Date(review.submitted_at) + .toISOString() + .replace('T', ' ') + .substring(0, 19) + : 'N/A' + const hasComment = review.body?.trim() + const commentLink = hasComment ? `[View](review-${review.id}.md)` : '' + lines.push( + `| ${escapeMarkdownTableCell(review.user)} | ${review.state} | ${time} | ${commentLink} |` + ) + } + } + } + + if (reviewThreads.length > 0) { + lines.push( + '', + `## Inline Review Comments (${reviewThreads.length} threads)`, + '', + '| File | Line | Author | Replies | Status | Details |', + '|------|------|--------|---------|--------|---------|' + ) + + for (let i = 0; i < reviewThreads.length; i++) { + const thread = reviewThreads[i] + const line = thread.line || thread.startLine || 'N/A' + const author = thread.comments.nodes[0]?.author?.login || 'Unknown' + const replyCount = Math.max(0, thread.comments.nodes.length - 1) + const status = thread.isResolved ? 'Resolved' : 'Open' + lines.push( + `| ${escapeMarkdownTableCell(thread.path)} | ${line} | ${author} | ${replyCount} | ${status} | [View](thread-${i + 1}.md) |` + ) + } + } + + // General comments section + if (prComments.length > 0) { + lines.push( + '', + `## General Comments (${prComments.length})`, + '', + '| Author | Date/Time | Details |', + '|--------|-----------|---------|' + ) + + const sortedComments = [...prComments].sort( + (a, b) => new Date(a.created_at) - new Date(b.created_at) + ) + + for (const comment of sortedComments) { + const time = comment.created_at + ? new Date(comment.created_at) + .toISOString() + .replace('T', ' ') + .substring(0, 19) + : 'N/A' + lines.push( + `| ${escapeMarkdownTableCell(comment.user)} | ${time} | [View](comment-${comment.id}.md) |` + ) + } + } + } + + return lines.join('\n') +} + +function generateJobMd(jobMetadata, testResults, testGroups, sections) { + const duration = formatDuration( + jobMetadata.started_at, + jobMetadata.completed_at + ) + + const lines = [ + `# Job: ${jobMetadata.name}`, + '', + `ID: ${jobMetadata.id}`, + `Status: ${jobMetadata.conclusion}`, + `Started: ${jobMetadata.started_at}`, + `Completed: ${jobMetadata.completed_at}`, + `Duration: ${duration}`, + `URL: ${jobMetadata.html_url}`, + '', + ] + + // Add sections list with line counts and links to section files + if (sections.length > 0) { + lines.push('## Sections', '') + + for (let i = 0; i < sections.length; i++) { + const section = sections[i] + const sectionNum = i + 1 + const filename = `job-${jobMetadata.id}-section-${sectionNum}.txt` + const errorPrefix = section.hasError ? '[error] ' : '' + + if (section.name) { + lines.push( + `- ${errorPrefix}[${section.name} (${section.lineCount} lines)](${filename})` + ) + } else { + lines.push(`- ${errorPrefix}[${section.lineCount} lines](${filename})`) + } + } + lines.push('') + } + + // Aggregate test results from all test output JSONs + let totalFailed = 0 + let totalPassed = 0 + let totalTests = 0 + const allFailedTests = [] + + for (const result of testResults) { + totalFailed += result.numFailedTests || 0 + totalPassed += result.numPassedTests || 0 + totalTests += result.numTotalTests || 0 + + if (result.testResults) { + for (const testResult of result.testResults) { + if (testResult.assertionResults) { + for (const assertion of testResult.assertionResults) { + if (assertion.status === 'failed') { + allFailedTests.push({ + testFile: testResult.name, + testName: assertion.fullName || assertion.title, + error: + assertion.failureMessages?.[0]?.substring(0, 100) || + 'Unknown', + }) + } + } + } + } + } + } + + if (totalTests > 0) { + lines.push( + '## Test Results', + '', + `Failed: ${totalFailed}`, + `Passed: ${totalPassed}`, + `Total: ${totalTests}`, + '' + ) + + if (allFailedTests.length > 0) { + lines.push( + '## Failed Tests', + '', + '| Test File | Test Name | Error |', + '|-----------|-----------|-------|' + ) + + 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, '\\|') + lines.push( + `| ${escapeMarkdownTableCell(shortFile)} | ${escapeMarkdownTableCell(test.testName)} | ${shortError}... |` + ) + } + lines.push('') + } + } + + if (testGroups.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) + lines.push( + `- [${group.testPath}](job-${jobMetadata.id}-test-${sanitizedName}.md)` + ) + } + } + + return lines.join('\n') +} + +function generateTestMd(jobMetadata, testPath, content, testResultJson) { + const lines = [ + `# Test: ${testPath}`, + '', + `Job: [${jobMetadata.name}](job-${jobMetadata.id}.md)`, + '', + '## Output', + '', + '```', + content, + '```', + ] + + if (testResultJson) { + lines.push( + '', + '## Test Results JSON', + '', + '```json', + JSON.stringify(testResultJson, null, 2), + '```' + ) + } + + return lines.join('\n') +} + +function generateReviewMd(review) { + const time = review.submitted_at + ? new Date(review.submitted_at) + .toISOString() + .replace('T', ' ') + .substring(0, 19) + : 'N/A' + + const lines = [ + `# Review by ${review.user}`, + '', + `State: ${review.state}`, + `Time: ${time}`, + '', + '## Comment', + '', + review.body.trim(), + ] + + return lines.join('\n') +} + +function generateCommentMd(comment) { + const time = comment.created_at + ? new Date(comment.created_at) + .toISOString() + .replace('T', ' ') + .substring(0, 19) + : 'N/A' + + const lines = [ + `# Comment by ${comment.user}`, + '', + `Time: ${time}`, + `URL: ${comment.html_url}`, + '', + '## Comment', + '', + comment.body?.trim() || '_No content_', + ] + + return lines.join('\n') +} + +function generateThreadMd(thread, index) { + const lines = [ + `# Thread ${index + 1}: ${thread.path}`, + '', + `Line: ${thread.line || thread.startLine || 'N/A'}`, + `Status: ${thread.isResolved ? 'Resolved' : 'Open'}`, + '', + ] + + // Add diff hunk from first comment + if (thread.comments.nodes[0]?.diffHunk) { + lines.push('```diff', thread.comments.nodes[0].diffHunk, '```', '') + } + + // Add all comments + lines.push('## Comments', '') + for (const comment of thread.comments.nodes) { + const date = comment.createdAt + ? new Date(comment.createdAt).toISOString().split('T')[0] + : 'N/A' + lines.push(`### ${comment.author?.login || 'Unknown'} - ${date}`, '') + lines.push(comment.body || '', '') + lines.push(`[View on GitHub](${comment.url})`, '', '---', '') + } + + return lines.join('\n') +} + +// ============================================================================ +// Main Function +// ============================================================================ + +async function main() { + // Parse CLI argument for PR number + const prNumberArg = process.argv[2] + + // 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 }) + + // Step 2: Get branch info + console.log('Getting branch info...') + const branchInfo = getBranchInfo(prNumberArg) + console.log( + `Branch: ${branchInfo.branchName}, PR: ${branchInfo.prNumber || 'N/A'}` + ) + + // Step 3: Get workflow runs + console.log('Fetching workflow runs...') + const runs = getWorkflowRuns(branchInfo.branchName) + + if (runs.length === 0) { + console.log('No workflow runs found for this branch.') + process.exit(0) + } + + // Find the most recent run (first in list) + const latestRun = runs[0] + console.log( + `Latest run: ${latestRun.id} (${latestRun.status}/${latestRun.conclusion})` + ) + + // Step 4: Get run metadata + console.log('Fetching run metadata...') + const runMetadata = getRunMetadata(latestRun.id) + + // Step 5: Determine fetch strategy based on run status + const isRunInProgress = + runMetadata.status === 'in_progress' || runMetadata.status === 'queued' + + let categorizedJobs + + if (isRunInProgress) { + // Fetch ALL jobs when CI is still running + console.log('CI is in progress. Fetching all jobs...') + const allJobs = getAllJobs(latestRun.id) + categorizedJobs = categorizeJobs(allJobs) + console.log( + `Found: ${categorizedJobs.failed.length} failed, ${categorizedJobs.inProgress.length} in progress, ${categorizedJobs.queued.length} queued, ${categorizedJobs.succeeded.length} succeeded` + ) + } else { + // For completed runs, only fetch failed jobs (efficiency) + console.log('Fetching failed jobs...') + const failedJobIds = getFailedJobs(latestRun.id) + console.log(`Found ${failedJobIds.length} failed jobs`) + + categorizedJobs = { + failed: failedJobIds, + inProgress: [], + queued: [], + succeeded: [], + cancelled: [], + skipped: [], + } + } + + // Fetch PR reviews if we have a PR number + let reviewData = null + if (branchInfo.prNumber) { + console.log('Fetching PR reviews and comments...') + const reviews = getPRReviews(branchInfo.prNumber) + const reviewThreads = getPRReviewThreads(branchInfo.prNumber) + const prComments = getPRComments(branchInfo.prNumber) + reviewData = { reviews, reviewThreads, prComments } + console.log( + `Found ${reviews.length} reviews, ${reviewThreads.length} review threads, ${prComments.length} general comments` + ) + } + + // Check if we should write an early report (no failed jobs yet) + const hasNoFailedJobs = categorizedJobs.failed.length === 0 + const hasInProgressOrQueued = + categorizedJobs.inProgress.length > 0 || categorizedJobs.queued.length > 0 + + if (hasNoFailedJobs && !hasInProgressOrQueued) { + // Completed run with no failures + console.log('No failed jobs found.') + + // Write review files if we have PR data + if (reviewData) { + // Write individual thread files + 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`), + generateThreadMd(thread, i) + ) + } + // Write individual review files for reviews with comments + for (const review of reviewData.reviews) { + if (review.body && review.body.trim()) { + await fs.writeFile( + path.join(OUTPUT_DIR, `review-${review.id}.md`), + generateReviewMd(review) + ) + } + } + // Write individual comment files + for (const comment of reviewData.prComments) { + await fs.writeFile( + path.join(OUTPUT_DIR, `comment-${comment.id}.md`), + generateCommentMd(comment) + ) + } + } + + const emptyCategorizedJobs = { + failed: [], + inProgress: [], + queued: [], + succeeded: [], + cancelled: [], + skipped: [], + } + await fs.writeFile( + path.join(OUTPUT_DIR, 'index.md'), + generateIndexMd( + branchInfo, + runMetadata, + emptyCategorizedJobs, + {}, + reviewData + ) + ) + process.exit(0) + } + + if (hasNoFailedJobs && hasInProgressOrQueued) { + // In-progress run with no failures yet - still write the progress report + console.log('No failed jobs yet, but CI is still running.') + } + + // Step 6: Fetch details for each failed job + const processedFailedJobs = [] + const jobTestCounts = {} + + for (const job of categorizedJobs.failed) { + const id = job.id + const name = job.name + console.log(`Processing failed job ${id}: ${name}...`) + + // Get full job metadata (getAllJobs already has basic metadata, but getFailedJobs doesn't) + const jobMetadata = job.started_at ? job : getJobMetadata(id) + processedFailedJobs.push(jobMetadata) + + // Get job logs + const logs = getJobLogs(id) + + // Extract test output JSON + const testResults = extractTestOutputJson(logs) + + // Calculate test counts for index + let failed = 0 + let total = 0 + for (const result of testResults) { + failed += result.numFailedTests || 0 + total += result.numTotalTests || 0 + } + if (total > 0) { + jobTestCounts[id] = { failed, total } + } + + // Extract sections from the log + const sections = extractSections(logs) + + // Write individual section files + for (let i = 0; i < sections.length; i++) { + const section = sections[i] + const sectionNum = i + 1 + await fs.writeFile( + path.join(OUTPUT_DIR, `job-${id}-section-${sectionNum}.txt`), + section.content + ) + } + + // Extract test case groups + const testGroups = 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 + ) + await fs.writeFile( + path.join(OUTPUT_DIR, `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) + } + + // Step 7: Write PR review files if we have PR data + if (reviewData) { + console.log('Generating review files...') + // Write individual thread files + 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`), + generateThreadMd(thread, i) + ) + } + // Write individual review files for reviews with comments + for (const review of reviewData.reviews) { + if (review.body?.trim()) { + await fs.writeFile( + path.join(OUTPUT_DIR, `review-${review.id}.md`), + generateReviewMd(review) + ) + } + } + // Write individual comment files + for (const comment of reviewData.prComments) { + await fs.writeFile( + path.join(OUTPUT_DIR, `comment-${comment.id}.md`), + generateCommentMd(comment) + ) + } + } + + // Step 8: Generate index.md + console.log('Generating index.md...') + // Update categorizedJobs.failed with full processed metadata + const finalCategorizedJobs = { + ...categorizedJobs, + failed: processedFailedJobs, + } + const indexMd = generateIndexMd( + branchInfo, + runMetadata, + finalCategorizedJobs, + jobTestCounts, + reviewData + ) + await fs.writeFile(path.join(OUTPUT_DIR, 'index.md'), indexMd) + + console.log(`\nDone! Output written to ${OUTPUT_DIR}/index.md`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index 8a29367f1b3b7e..84d145cbf68743 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -192,6 +192,27 @@ describe('app-dir action handling', () => { ) }) + it('should error if server action arguments list is too long', async () => { + const browser = await next.browser('/too-many-args') + const cliOutputIndex = next.cliOutput.length + await browser.elementById('submit').click() + const expectedError = + 'Error: Server Action arguments list is too long (1001). Maximum allowed is 1000.' + + const error = await browser.waitForElementByCss('#error-text') + if (isNextDev) { + expect(await error.text()).toBe(expectedError) + } else { + expect(await error.text()).toBe(GENERIC_RSC_ERROR) + } + + if (!isNextDeploy) { + const cliOutput = next.cliOutput.slice(cliOutputIndex) + expect(cliOutput).toInclude(expectedError) + expect(cliOutput).not.toInclude('Action was called') + } + }) + it('should support headers and cookies', async () => { const browser = await next.browser('/header') diff --git a/test/e2e/app-dir/actions/app/too-many-args/actions.js b/test/e2e/app-dir/actions/app/too-many-args/actions.js new file mode 100644 index 00000000000000..9500f05c940ec6 --- /dev/null +++ b/test/e2e/app-dir/actions/app/too-many-args/actions.js @@ -0,0 +1,5 @@ +'use server' + +export async function action(...args) { + console.log(`Action was called with ${args.length} arguments.`) +} diff --git a/test/e2e/app-dir/actions/app/too-many-args/error.js b/test/e2e/app-dir/actions/app/too-many-args/error.js new file mode 100644 index 00000000000000..f9012d7c68398d --- /dev/null +++ b/test/e2e/app-dir/actions/app/too-many-args/error.js @@ -0,0 +1,11 @@ +'use client' + +export default function Error({ error }) { + return ( +
+

+ {error.name}: {error.message} +

+
+ ) +} diff --git a/test/e2e/app-dir/actions/app/too-many-args/page.js b/test/e2e/app-dir/actions/app/too-many-args/page.js new file mode 100644 index 00000000000000..5f2086acc32ba5 --- /dev/null +++ b/test/e2e/app-dir/actions/app/too-many-args/page.js @@ -0,0 +1,15 @@ +'use client' + +import { action } from './actions' + +// Bind the action with 1000 arguments. React will add the form data as the last +// argument when invoked, exceeding the limit. +const boundAction = action.bind(null, ...Array(1000).fill(0)) + +export default function Page() { + return ( +
+ +
+ ) +} diff --git a/test/e2e/app-dir/app-routes/app-custom-routes.test.ts b/test/e2e/app-dir/app-routes/app-custom-routes.test.ts index d2c79863797262..c4bb0bb2d90a1a 100644 --- a/test/e2e/app-dir/app-routes/app-custom-routes.test.ts +++ b/test/e2e/app-dir/app-routes/app-custom-routes.test.ts @@ -523,14 +523,16 @@ describe('app-custom-routes', () => { }) describe('error conditions', () => { - it('responds with 400 (Bad Request) when the requested method is not a valid HTTP method', async () => { - const res = await next.fetch(basePath + '/status/405', { - method: 'HEADER', - }) + if (!isNextDeploy) { + it('responds with 400 (Bad Request) when the requested method is not a valid HTTP method', async () => { + const res = await next.fetch(basePath + '/status/405', { + method: 'HEADER', + }) - expect(res.status).toEqual(400) - expect(await res.text()).toBeEmpty() - }) + expect(res.status).toEqual(400) + expect(await res.text()).toBeEmpty() + }) + } it('responds with 405 (Method Not Allowed) when method is not implemented', async () => { const res = await next.fetch(basePath + '/status/405', { @@ -690,7 +692,7 @@ describe('app-custom-routes', () => { await next.fetch(basePath + '/no-response', { method: 'POST' }) await retry(() => { expect(next.cliOutput).toMatch( - /No response is returned from route handler '.+\/route\.ts'\. Ensure you return a `Response` or a `NextResponse` in all branches of your handler\./ + /No response is returned from route handler '.+\/route\.ts'\. Expected a Response object but received '\w+' \(method: POST, url: .+\)\. Ensure you return a `Response` or a `NextResponse` in all branches of your handler\./ ) }) }) diff --git a/test/e2e/postcss-config-ts/index.test.ts b/test/e2e/postcss-config-ts/index.test.ts new file mode 100644 index 00000000000000..72faf359f3f85b --- /dev/null +++ b/test/e2e/postcss-config-ts/index.test.ts @@ -0,0 +1,96 @@ +import { FileRef, nextTestSetup } from 'e2e-utils' +import { join } from 'path' + +// Not supported when using webpack. +;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)( + 'postcss-config-ts', + () => { + describe('postcss.config.ts', () => { + const { next } = nextTestSetup({ + files: new FileRef(join(__dirname, 'postcss-config')), + }) + + it('works with postcss.config.ts files', async () => { + const browser = await next.browser('/') + try { + const text = await browser.elementByCss('#test').text() + expect(text).toBe('Hello World') + // The CSS has `color: red` but the PostCSS plugin transforms it to green. + // If this is green, it proves the TypeScript PostCSS config was loaded and applied. + const color = await browser + .elementByCss('#test') + .getComputedCss('color') + expect(color).toBe('rgb(0, 128, 0)') + } finally { + await browser.close() + } + }) + }) + + describe('.postcssrc.ts', () => { + const { next } = nextTestSetup({ + files: new FileRef(join(__dirname, 'postcssrc')), + }) + + it('works with .postcssrc.ts files', async () => { + const browser = await next.browser('/') + try { + const text = await browser.elementByCss('#test').text() + expect(text).toBe('Hello World') + // The CSS has `color: red` but the PostCSS plugin transforms it to green. + // If this is green, it proves the TypeScript PostCSS config was loaded and applied. + const color = await browser + .elementByCss('#test') + .getComputedCss('color') + expect(color).toBe('rgb(0, 128, 0)') + } finally { + await browser.close() + } + }) + }) + + describe('postcss.config.mts', () => { + const { next } = nextTestSetup({ + files: new FileRef(join(__dirname, 'postcss-config-mts')), + }) + + it('works with postcss.config.mts files', async () => { + const browser = await next.browser('/') + try { + const text = await browser.elementByCss('#test').text() + expect(text).toBe('Hello World') + // The CSS has `color: red` but the PostCSS plugin transforms it to green. + // If this is green, it proves the TypeScript PostCSS config was loaded and applied. + const color = await browser + .elementByCss('#test') + .getComputedCss('color') + expect(color).toBe('rgb(0, 128, 0)') + } finally { + await browser.close() + } + }) + }) + + describe('postcss.config.cts', () => { + const { next } = nextTestSetup({ + files: new FileRef(join(__dirname, 'postcss-config-cts')), + }) + + it('works with postcss.config.cts files', async () => { + const browser = await next.browser('/') + try { + const text = await browser.elementByCss('#test').text() + expect(text).toBe('Hello World') + // The CSS has `color: red` but the PostCSS plugin transforms it to green. + // If this is green, it proves the TypeScript PostCSS config was loaded and applied. + const color = await browser + .elementByCss('#test') + .getComputedCss('color') + expect(color).toBe('rgb(0, 128, 0)') + } finally { + await browser.close() + } + }) + }) + } +) diff --git a/test/e2e/postcss-config-ts/postcss-config-cts/app/globals.css b/test/e2e/postcss-config-ts/postcss-config-cts/app/globals.css new file mode 100644 index 00000000000000..1361f4519db094 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-cts/app/globals.css @@ -0,0 +1,5 @@ +.test-class { + /* This red color should be transformed to green by the PostCSS plugin */ + color: red; + display: flex; +} diff --git a/test/e2e/postcss-config-ts/postcss-config-cts/app/layout.tsx b/test/e2e/postcss-config-ts/postcss-config-cts/app/layout.tsx new file mode 100644 index 00000000000000..a51c8f115568c9 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-cts/app/layout.tsx @@ -0,0 +1,13 @@ +import './globals.css' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/postcss-config-ts/postcss-config-cts/app/page.tsx b/test/e2e/postcss-config-ts/postcss-config-cts/app/page.tsx new file mode 100644 index 00000000000000..ef7b85d308e03e --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-cts/app/page.tsx @@ -0,0 +1,9 @@ +export default function Home() { + return ( +
+

+ Hello World +

+
+ ) +} diff --git a/test/e2e/postcss-config-ts/postcss-config-cts/plugin.cjs b/test/e2e/postcss-config-ts/postcss-config-cts/plugin.cjs new file mode 100644 index 00000000000000..c7aa6d6ecc4b7a --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-cts/plugin.cjs @@ -0,0 +1,19 @@ +// Custom PostCSS plugin that transforms color: red to color: green +// This allows us to verify that the PostCSS config is actually being applied + +const plugin = () => { + return { + postcssPlugin: 'color-transform', + Declaration: { + color(decl) { + if (decl.value === 'red') { + decl.value = 'green' + } + }, + }, + } +} + +plugin.postcss = true + +module.exports = plugin diff --git a/test/e2e/postcss-config-ts/postcss-config-cts/postcss.config.cts b/test/e2e/postcss-config-ts/postcss-config-cts/postcss.config.cts new file mode 100644 index 00000000000000..01cf442c06b405 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-cts/postcss.config.cts @@ -0,0 +1,14 @@ +// TypeScript PostCSS config file (.cts) +const plugin = require('./plugin.cjs') + +type PluginConfig = Record | boolean | (() => unknown) + +interface PostCSSConfig { + plugins: PluginConfig[] +} + +const config: PostCSSConfig = { + plugins: [plugin], +} + +module.exports = config diff --git a/test/e2e/postcss-config-ts/postcss-config-mts/app/globals.css b/test/e2e/postcss-config-ts/postcss-config-mts/app/globals.css new file mode 100644 index 00000000000000..1361f4519db094 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-mts/app/globals.css @@ -0,0 +1,5 @@ +.test-class { + /* This red color should be transformed to green by the PostCSS plugin */ + color: red; + display: flex; +} diff --git a/test/e2e/postcss-config-ts/postcss-config-mts/app/layout.tsx b/test/e2e/postcss-config-ts/postcss-config-mts/app/layout.tsx new file mode 100644 index 00000000000000..a51c8f115568c9 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-mts/app/layout.tsx @@ -0,0 +1,13 @@ +import './globals.css' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/postcss-config-ts/postcss-config-mts/app/page.tsx b/test/e2e/postcss-config-ts/postcss-config-mts/app/page.tsx new file mode 100644 index 00000000000000..ef7b85d308e03e --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-mts/app/page.tsx @@ -0,0 +1,9 @@ +export default function Home() { + return ( +
+

+ Hello World +

+
+ ) +} diff --git a/test/e2e/postcss-config-ts/postcss-config-mts/plugin.mjs b/test/e2e/postcss-config-ts/postcss-config-mts/plugin.mjs new file mode 100644 index 00000000000000..33b768854645b8 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-mts/plugin.mjs @@ -0,0 +1,19 @@ +// Custom PostCSS plugin that transforms color: red to color: green +// This allows us to verify that the PostCSS config is actually being applied + +const plugin = () => { + return { + postcssPlugin: 'color-transform', + Declaration: { + color(decl) { + if (decl.value === 'red') { + decl.value = 'green' + } + }, + }, + } +} + +plugin.postcss = true + +export default plugin diff --git a/test/e2e/postcss-config-ts/postcss-config-mts/postcss.config.mts b/test/e2e/postcss-config-ts/postcss-config-mts/postcss.config.mts new file mode 100644 index 00000000000000..7b58e0e4f9092a --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config-mts/postcss.config.mts @@ -0,0 +1,14 @@ +// TypeScript PostCSS config file (.mts) +import plugin from './plugin.mjs' + +type PluginConfig = Record | boolean | (() => unknown) + +interface PostCSSConfig { + plugins: PluginConfig[] +} + +const config: PostCSSConfig = { + plugins: [plugin], +} + +export default config diff --git a/test/e2e/postcss-config-ts/postcss-config/app/globals.css b/test/e2e/postcss-config-ts/postcss-config/app/globals.css new file mode 100644 index 00000000000000..1361f4519db094 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config/app/globals.css @@ -0,0 +1,5 @@ +.test-class { + /* This red color should be transformed to green by the PostCSS plugin */ + color: red; + display: flex; +} diff --git a/test/e2e/postcss-config-ts/postcss-config/app/layout.tsx b/test/e2e/postcss-config-ts/postcss-config/app/layout.tsx new file mode 100644 index 00000000000000..a51c8f115568c9 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config/app/layout.tsx @@ -0,0 +1,13 @@ +import './globals.css' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/postcss-config-ts/postcss-config/app/page.tsx b/test/e2e/postcss-config-ts/postcss-config/app/page.tsx new file mode 100644 index 00000000000000..ef7b85d308e03e --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config/app/page.tsx @@ -0,0 +1,9 @@ +export default function Home() { + return ( +
+

+ Hello World +

+
+ ) +} diff --git a/test/e2e/postcss-config-ts/postcss-config/plugin.ts b/test/e2e/postcss-config-ts/postcss-config/plugin.ts new file mode 100644 index 00000000000000..812f14cad05a41 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config/plugin.ts @@ -0,0 +1,19 @@ +// Custom PostCSS plugin that transforms color: red to color: green +// This allows us to verify that the PostCSS config is actually being applied + +const plugin = () => { + return { + postcssPlugin: 'color-transform', + Declaration: { + color(decl: { value: string }) { + if (decl.value === 'red') { + decl.value = 'green' + } + }, + }, + } +} + +plugin.postcss = true + +export default plugin diff --git a/test/e2e/postcss-config-ts/postcss-config/postcss.config.ts b/test/e2e/postcss-config-ts/postcss-config/postcss.config.ts new file mode 100644 index 00000000000000..f0dd2a134dec99 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcss-config/postcss.config.ts @@ -0,0 +1,14 @@ +// TypeScript PostCSS config file +import plugin from './plugin' + +type PluginConfig = Record | boolean | (() => unknown) + +interface PostCSSConfig { + plugins: PluginConfig[] +} + +const config: PostCSSConfig = { + plugins: [plugin], +} + +export default config diff --git a/test/e2e/postcss-config-ts/postcssrc/.postcssrc.ts b/test/e2e/postcss-config-ts/postcssrc/.postcssrc.ts new file mode 100644 index 00000000000000..f0dd2a134dec99 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcssrc/.postcssrc.ts @@ -0,0 +1,14 @@ +// TypeScript PostCSS config file +import plugin from './plugin' + +type PluginConfig = Record | boolean | (() => unknown) + +interface PostCSSConfig { + plugins: PluginConfig[] +} + +const config: PostCSSConfig = { + plugins: [plugin], +} + +export default config diff --git a/test/e2e/postcss-config-ts/postcssrc/app/globals.css b/test/e2e/postcss-config-ts/postcssrc/app/globals.css new file mode 100644 index 00000000000000..1361f4519db094 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcssrc/app/globals.css @@ -0,0 +1,5 @@ +.test-class { + /* This red color should be transformed to green by the PostCSS plugin */ + color: red; + display: flex; +} diff --git a/test/e2e/postcss-config-ts/postcssrc/app/layout.tsx b/test/e2e/postcss-config-ts/postcssrc/app/layout.tsx new file mode 100644 index 00000000000000..a51c8f115568c9 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcssrc/app/layout.tsx @@ -0,0 +1,13 @@ +import './globals.css' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/postcss-config-ts/postcssrc/app/page.tsx b/test/e2e/postcss-config-ts/postcssrc/app/page.tsx new file mode 100644 index 00000000000000..ef7b85d308e03e --- /dev/null +++ b/test/e2e/postcss-config-ts/postcssrc/app/page.tsx @@ -0,0 +1,9 @@ +export default function Home() { + return ( +
+

+ Hello World +

+
+ ) +} diff --git a/test/e2e/postcss-config-ts/postcssrc/plugin.ts b/test/e2e/postcss-config-ts/postcssrc/plugin.ts new file mode 100644 index 00000000000000..812f14cad05a41 --- /dev/null +++ b/test/e2e/postcss-config-ts/postcssrc/plugin.ts @@ -0,0 +1,19 @@ +// Custom PostCSS plugin that transforms color: red to color: green +// This allows us to verify that the PostCSS config is actually being applied + +const plugin = () => { + return { + postcssPlugin: 'color-transform', + Declaration: { + color(decl: { value: string }) { + if (decl.value === 'red') { + decl.value = 'green' + } + }, + }, + } +} + +plugin.postcss = true + +export default plugin diff --git a/test/get-test-filter.js b/test/get-test-filter.js index f6e83ba76be19e..6b88047ffecd6b 100644 --- a/test/get-test-filter.js +++ b/test/get-test-filter.js @@ -80,7 +80,13 @@ function getTestFilter() { tests .filter((test) => { // Check to see if this was included as-is in the manifest. - if (test.file in manifest.suites) return true + if (test.file in manifest.suites) { + // When merging multiple manifests, a test file may be included in + // the suites by one manifest, but excluded in the rules by another. + // If it's excluded by filename (and not by pattern), the exclusion + // takes precedence over the inclusion. + return !manifest.rules.exclude?.includes(test.file) + } // If this file doesn't match any of the include patterns, then it // should be excluded. diff --git a/turbopack/crates/turbopack-core/src/reference_type.rs b/turbopack/crates/turbopack-core/src/reference_type.rs index 045789cc5f10d9..5d71abe41809b8 100644 --- a/turbopack/crates/turbopack-core/src/reference_type.rs +++ b/turbopack/crates/turbopack-core/src/reference_type.rs @@ -74,7 +74,7 @@ pub enum ImportWithType { pub enum EcmaScriptModulesReferenceSubType { ImportPart(ModulePart), Import, - ImportWithType(ImportWithType), + ImportWithType(RcStr), DynamicImport, Custom(u8), #[default] diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs index 30b789599b917e..e2125959497a7d 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs @@ -24,7 +24,7 @@ use turbopack_core::{ module::{Module, ModuleSideEffects}, module_graph::binding_usage_info::ModuleExportUsageInfo, reference::ModuleReference, - reference_type::{EcmaScriptModulesReferenceSubType, ImportWithType}, + reference_type::EcmaScriptModulesReferenceSubType, resolve::{ BindingUsage, ExportUsage, ExternalType, ImportUsage, ModulePart, ModuleResolveResult, ModuleResolveResultItem, RequestKey, @@ -410,10 +410,10 @@ impl EsmAssetReference { impl ModuleReference for EsmAssetReference { #[turbo_tasks::function] async fn resolve_reference(&self) -> Result> { - let ty = if self.annotations.module_type().is_some_and(|v| v == "json") { - EcmaScriptModulesReferenceSubType::ImportWithType(ImportWithType::Json) - } else if self.annotations.module_type().is_some_and(|v| v == "bytes") { - EcmaScriptModulesReferenceSubType::ImportWithType(ImportWithType::Bytes) + let ty = if let Some(module_type) = self.annotations.module_type() { + EcmaScriptModulesReferenceSubType::ImportWithType(RcStr::from( + &*module_type.to_string_lossy(), + )) } else if let Some(part) = &self.export_name { EcmaScriptModulesReferenceSubType::ImportPart(part.clone()) } else { diff --git a/turbopack/crates/turbopack-node/src/transforms/postcss.rs b/turbopack/crates/turbopack-node/src/transforms/postcss.rs index 220ede5d591c57..4d1a09193c5a85 100644 --- a/turbopack/crates/turbopack-node/src/transforms/postcss.rs +++ b/turbopack/crates/turbopack-node/src/transforms/postcss.rs @@ -84,6 +84,9 @@ fn postcss_configs() -> Vc> { ".postcssrc.js", ".postcssrc.mjs", ".postcssrc.cjs", + ".postcssrc.ts", + ".postcssrc.mts", + ".postcssrc.cts", ".config/postcssrc", ".config/postcssrc.json", ".config/postcssrc.yaml", @@ -91,9 +94,15 @@ fn postcss_configs() -> Vc> { ".config/postcssrc.js", ".config/postcssrc.mjs", ".config/postcssrc.cjs", + ".config/postcssrc.ts", + ".config/postcssrc.mts", + ".config/postcssrc.cts", "postcss.config.js", "postcss.config.mjs", "postcss.config.cjs", + "postcss.config.ts", + "postcss.config.mts", + "postcss.config.cts", "postcss.config.json", ] .into_iter() diff --git a/turbopack/crates/turbopack/src/lib.rs b/turbopack/crates/turbopack/src/lib.rs index 0f022891954aec..1286e09277bde9 100644 --- a/turbopack/crates/turbopack/src/lib.rs +++ b/turbopack/crates/turbopack/src/lib.rs @@ -31,8 +31,8 @@ use turbopack_core::{ output::{ExpandedOutputAssets, OutputAsset}, raw_module::RawModule, reference_type::{ - CssReferenceSubType, EcmaScriptModulesReferenceSubType, ImportContext, ImportWithType, - InnerAssets, ReferenceType, + CssReferenceSubType, EcmaScriptModulesReferenceSubType, ImportContext, InnerAssets, + ReferenceType, }, resolve::{ ExternalTraced, ExternalType, ModulePart, ModuleResolveResult, ModuleResolveResultItem, @@ -530,28 +530,11 @@ async fn process_default_internal( ReferenceType::Internal(inner_assets) => Some(*inner_assets), _ => None, }; - - let mut has_type_attribute = false; - let mut current_source = source; - let mut current_module_type = match &reference_type { - ReferenceType::EcmaScriptModules(EcmaScriptModulesReferenceSubType::ImportWithType(ty)) => { - has_type_attribute = true; - - match ty { - ImportWithType::Json => Some(ModuleType::Json), - // Reenable this once `import {type: "bytes"}` is stabilized - ImportWithType::Bytes => None, - } - } - _ => None, - }; + let mut current_module_type = None; let options_value = options.await?; for (i, rule) in options_value.rules.iter().enumerate() { - if has_type_attribute && current_module_type.is_some() { - continue; - } if processed_rules.contains(&i) { continue; }