Files
cloudflare__vinext/scripts/classify-nextjs-suites.mjs
James Anderson f27c40a77f chore: split e2e compatibility by App Router vs Pages Router (#1321)
* feat(compat): split e2e compatibility by App Router vs Pages Router

The /compatibility page previously showed one undifferentiated grid of
~1000 Next.js test files. This change classifies each test by which
router(s) its fixture exercises (App, Pages, both, or unknown) and
surfaces the breakdown in the UI.

How:

- A new `router` column on `compat_file_results` (enum: app | pages |
  both | unknown). Defaults to 'unknown' so pre-classifier rows still
  render — they just show up under 'Other'.
- A new `scripts/classify-nextjs-suites.mjs` walks each test's fixture
  directory and looks for app/page.tsx, app/route.ts, app/layout.tsx,
  pages/*.tsx (excluding _app / _document / _error specials). A fixture
  with real routes in both folders is classified as 'both' — these are
  the genuine parity tests and counting them only once would hide a
  router-specific failure.
- Edge cases handled in the classifier:
    * pageExtensions naming (layout.page.tsx → layout)
    * Suites whose .test.ts lives in a test/ subdir alongside the fixture
    * Inline-fixture suites under test/e2e/app-dir/ (no on-disk routes,
      but path convention says App Router)
    * APP_ROUTER_NON_APP_DIR_SUITES curated override
    * Skips node_modules and .next when scanning fixtures
- The nightly workflow now runs the classifier in the build job once
  per run (the cheap part — happens with Next.js still on disk),
  uploads the suite → router map as an artifact, and the report job
  joins it into the ingest payload before POSTing.
- The compatibility page gains a 'By router' card row showing per-router
  pass rates and file counts (parity tests are counted toward both
  router buckets — adding them exceeds the total, see the explainer
  card). The contribution grid grows filter chips above it for
  interactive narrowing.

Verified:

- vp check (format + type + lint) clean
- 14 new classifier unit tests pass (vp test run tests/classify-nextjs-suites.test.ts)
- apps/web build:vinext succeeds end-to-end
- Local classifier run against .nextjs-ref reports
  app=576 / pages=325 / both=113 / unknown=22 out of 1036 suites, with
  spot-checked classifications matching expectations.

Backward compatibility: the ingest endpoint treats `router` as optional;
existing workflow runs and historical rows continue to function and
render as 'Other' until the next nightly classifies them.

* refactor(compat): store router classification in its own table

Replaces the inline `router` column on `compat_file_results` (introduced
in the previous commit) with a dedicated `compat_suite_meta` table,
classification keyed by `suite`. The /compatibility UI LEFT JOINs the
two tables at query time.

Rationale (per design discussion in PR #1321):

- Classifications conceptually describe test files, not test runs. Storing
  them per-row coupled their cadence to results ingestion, which is wrong
  when (a) the Next.js ref bumps and re-classifies everything, (b) an
  override fix lands without re-running tests, or (c) a partial test run
  still wants fresh classifications.
- One row per suite (PK on `suite`) means re-classifying is an upsert,
  not a backfill loop. Provenance (`next_ref`, `classified_at`) is
  stored on the row for debugging.
- Decoupling lets the workflow POST classifications from the build job
  immediately after running the classifier, without round-tripping through
  the report job. Results ingestion stays focused on results.

Endpoints:
- `POST /api/compatibility` no longer accepts `router` per file
  (reverted to its pre-PR shape).
- `POST /api/compatibility/classify` (new): accepts
  `{ nextRef, classifiedAt?, suites: [{ suite, router }] }`,
  upserts in chunks (25 rows per INSERT to fit SQLite's variable cap),
  shares auth with the results endpoint via a new `_auth.ts` helper.

Workflow:
- The build job now POSTs the classification map directly to the new
  endpoint after running the classifier. Same guardrails as the results
  POST (only full-suite, only against main).
- The report job no longer needs to download / merge the classification
  artifact — it's already in D1 by the time results land.
- The classification JSON is still uploaded as a workflow artifact for
  manual re-submission / debugging.

Schema:
- New table `compat_suite_meta(suite PK, router, next_ref, classified_at)`
  with an index on `router` for the per-router count queries.
- `compat_file_results` reverts to its pre-PR columns. No data migration
  needed because the column was only ever populated on this branch.

Trade-off: classification changes are now retroactive — re-classifying a
suite updates how it appears in every historical run. This is usually
what you want (corrections heal the whole history) but means the trend
chart isn't a strict point-in-time record. The provenance fields on the
meta row let you tie a reclassification back to a specific Next.js ref.

Verified:
- vp check clean
- 14 classifier unit tests still pass
- apps/web build:vinext succeeds; /api/compatibility/classify shows up
  in the route list

* refactor(compat): drop redundant next_ref from compat_suite_meta

The Next.js ref a classification was produced against is already
recoverable from the run history — compat_runs records next_ref per
run, and the most recent classification's ref is implicit (it's the
ref of the most recent classify POST, which the workflow always pairs
with a run).

The future-proofing case for per-ref classifications would want a
composite (suite, next_ref) PK rather than a single global row anyway,
so this column doesn't help with that scenario either. classified_at
is enough for the debug case ('when was this last computed?').

Changes:
- Drop next_ref column from compat_suite_meta (3 columns now:
  suite PK, router, classified_at)
- /api/compatibility/classify no longer requires nextRef in the body
- Workflow no longer passes NEXT_REF when building the classify payload
- Migration regenerated as 0001_romantic_skullbuster.sql (one fewer
  column on the CREATE TABLE)
- Chunk size bumped to 33 rows/INSERT (100-var cap / 3 columns)

Verified: vp check clean, classifier tests pass, apps/web build succeeds.

* feat(compat-ui): share router filter between grid and trend chart

Lifts the router-filter state out of ContributionGrid into a new
CompatibilityViews client wrapper that owns the Kumo segmented Tabs
control. The grid and the line chart both consume the active filter
as a prop, so changing the tab updates both visualisations in lockstep.

The line chart now plots per-router series. The trend query was
rewritten to aggregate via JOIN against compat_suite_meta — one row
per run with app/pages/both/unknown rollups in a single round-trip,
~90 runs × ~1000 file rows over an indexed join. The TrendPoint
carries all five series; the chart picks one based on the filter
without re-fetching.

Other UI tweaks in this commit:
- Replaced the hand-rolled pill row with Kumo Tabs (variant='segmented',
  size='sm'). Fixes a weird active state (bg-kumo-default + text-kumo-base
  was using the text color for backgrounds, producing a saturated
  inversion).
- 'Parity' → 'Mixed' in user-visible labels. Internal identifiers
  (DB enum 'both', bucket variable 'parity') unchanged.
- Removed the standalone 'Other' stat card; the segmented Tabs still
  expose the 'Other' filter so unclassified suites are reachable.
- Reorganised the page into one 'Test files and trend' section
  containing both visualisations, gated by the shared Tabs.
- .gitignore: ignore .dev.vars (Wrangler's local-secrets convention)
  so the COMPAT_INGEST_SECRET we use for local testing never gets
  committed by accident.

Verified:
- vp check clean (format + type + lint)
- 14 classifier unit tests pass
- apps/web build:vinext succeeds; both API endpoints + page render

* fix(compat): address PR review comments

Addresses actionable items from the two /bigbonk review passes:

1. scanFixture: skip recursion into already-checked app/ and pages/
   (with a wrinkle around fixture wrappers).

   The previous code unconditionally pushed every child onto the walk
   stack, which (a) wasted work re-walking subtrees the route checker
   already covered and (b) risked a false positive on App Router route
   groups literally named 'pages' (e.g. app/pages/index.tsx would have
   tripped the Pages Router detector).

   Naive fix (skip descent into any app/ or pages/) regressed ~10 real
   Next.js fixtures that use a wrapping directory literally named 'app'
   as the test app's project root, with the real app/ and pages/ nested
   inside (test/e2e/og-api, test/e2e/middleware-static-files, etc.).

   Final fix: a directory named app or pages is treated as a fixture
   wrapper (and recursed into) if it contains a top-level next.config.*
   OR an inner app/ alongside an inner pages/. Otherwise it's handed to
   the route checker, which either finds real routes or returns nothing
   and the walk skips it. The wrapper detector intentionally does NOT
   treat middleware.{js,ts} as a wrapper signal, because Next.js tests
   put noop middleware.js files inside real App Router app/ to assert
   the file is ignored at that level (test/e2e/app-dir/app-middleware).

   Verified against the full test/e2e/ tree of the local Next.js
   checkout: 1036 suites classify identically to the pre-fix output
   (576 app / 325 pages / 113 both / 22 unknown), with the
   app/pages/index.tsx false-positive now correctly handled.

2. list-nextjs-e2e-suites.mjs: add the same import.meta.url guard the
   classifier already has, so importing the module programmatically
   doesn't immediately parse process.argv and write a file.

3. _auth.ts: early-exit when the X-Compat-Secret header is missing
   or empty, before paying two SHA-256 digests. The constant-time
   guarantee we care about (don't leak the contents of the expected
   secret via length or prefix matching) is preserved — we only
   short-circuit on values we already know can't match a non-empty
   secret.

4. Migration 0001_romantic_skullbuster.sql: add trailing newline.

Tests:
- 3 new regression tests added (18 total, all pass):
  * fixture wrapper with next.config.js + inner app/ + pages/
  * fixture wrapper with next.config.js + inner pages/ only
  * App Router app/ containing a route group named 'pages'
  * App Router app/ containing a noop middleware.js
- vp check clean
- Classifier output diff against current Next.js HEAD: empty

* fix(compat-ui): unexport cellMatchesFilter (knip)

CI's knip step flagged `cellMatchesFilter` as an unused export. It's only
used inside contribution-grid.tsx itself — the shared wrapper has its
own bucketing logic in compatibility-views.tsx. Drop the `export`.

* fix(compat): address third-pass review comments

1. sqlExcluded: narrow parameter type to a closed union
   ("router" | "classified_at") so the no-user-input invariant for
   sql.raw is compiler-enforced rather than relying on a code comment.

2. ContributionGrid: gate the SVG render on visibleCells.length > 0,
   and also clamp svgWidth/svgHeight to >= 0. Previously, when a
   filter emptied the grid, the SVG rendered with width=-3/height=-3
   alongside the placeholder div (browsers clamped silently but it
   was invalid SVG).

3. compatibility/page.tsx 'How this works' card: add a short note
   that per-router trend lines use the latest classification, so
   reclassifying a suite updates how it appears in historical runs.
   The aggregate "All" line is unaffected. Avoids a confusing
   support question if users notice old per-router numbers shift
   after a classifier improvement.

4. scanFixture readability: simplify the early-return checks from
   'if (hasApp && hasPages)' to 'if (hasPages)' / 'if (hasApp)',
   since the other flag was just set on the line above. No
   behavioural change; classifier output against the full Next.js
   test/e2e tree is byte-identical (1036 suites, 576/325/113/22).

5. /api/compatibility/classify body validator: bounds-check
   classifiedAt. Reject NaN/Infinity and any timestamp earlier
   than 2023-01-01 UTC. Catches the common seconds-vs-milliseconds
   mistake and accidental 0/-1 values from buggy callers. The
   only legitimate caller (the GH workflow) sends Date.now() so
   this is purely defensive.

Verified:
- vp check clean (format + type + lint + knip)
- 18 classifier unit tests pass
- apps/web build:vinext succeeds
- Classifier output against current Next.js HEAD is unchanged

* refactor(compat-ui): extract shared router bucketing module

Addresses items 1, 3, and 4 from the fourth review pass.

1. Trend GROUP BY note (item 1): added an inline comment explaining
   that selecting r.created_at while grouping by r.id only is a SQLite
   functional-dependency affordance. Standard SQL (postgres etc.)
   would reject it; the comment heads off a future 'fix' that breaks
   the query if it's ever ported.

2. Unified router bucketing (item 3): the three places that sliced
   cells by router filter all used slightly different naming
   conventions ('parity' vs 'both', 'other' vs 'unknown'). Extracted
   the canonical logic to a new ./router-buckets module with one
   shared vocabulary (the same as RouterKind), and updated all three
   call sites:

     - contribution-grid.tsx: cellMatchesFilter from shared module
     - compatibility-views.tsx: countByFilter for tab labels
     - page.tsx: bucketByRouter + bucketPassRate for stat cards,
       byRouter.parity/other renamed to byRouter.both/unknown

   Doc comment at the top of router-buckets.ts pins the
   'Mixed counts in both app and pages' rule in one place. No
   behavioural change.

3. passRate function rename (item 4): renamed the line chart's local
   ratio helper from passRate -> computePassRateRatio to avoid
   shadowing the passRate variable in page.tsx (and to distinguish
   it from bucketPassRate in router-buckets which returns a
   percentage, not a ratio).

Not addressed (intentionally):
- 'Mixed (both)' vs 'Parity'/'Interop' label (item 2): the user
  explicitly chose 'Mixed' over 'Parity' in an earlier message.
- trendRowsDesc rename (item 5): reviewer self-tagged 'very minor';
  renaming would muddle the diff.
- deriveSuiteGroup inconsistent levels (item 5b): pre-existing
  tooltip-only behaviour, not introduced by this PR.

Verified:
- vp check clean (format + type + lint + knip)
- 18 classifier unit tests pass
- apps/web build:vinext succeeds
2026-05-20 13:52:11 +01:00

490 lines
17 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Classifies Next.js e2e test files by which router(s) their fixture exercises.
*
* Usage (CLI):
* node scripts/classify-nextjs-suites.mjs <nextjs-dir> <suites-input> <output-json>
*
* <suites-input> is either:
* - A JSON file containing an array of suite paths
* - A path to a compat-ingest payload (object with a `files` array of
* { suite, ... }); the script will read suite paths from there
*
* Usage (programmatic):
* import { classifySuites } from "./classify-nextjs-suites.mjs";
* const map = await classifySuites(nextjsDir, ["test/e2e/middleware-basic/middleware-basic.test.ts"]);
* // → Map { "test/e2e/middleware-basic/middleware-basic.test.ts" => "pages", ... }
*
* Classification rules (in priority order):
*
* 1. Override file (scripts/nextjs-suite-overrides.json) — explicit
* hand-curated routing for suites the heuristic gets wrong.
*
* 2. Walk the fixture directory (the directory containing the .test.ts file)
* up to a bounded depth, looking for `app/` and `pages/` subdirectories.
* A directory only "counts" if it contains a real route file:
* - app/ counts if it contains page.{js,jsx,ts,tsx}, route.{js,ts}, or
* layout.{js,jsx,ts,tsx} (anywhere under it)
* - pages/ counts if it contains any .{js,jsx,ts,tsx} file at depth ≤ 2
* that isn't a Pages-Router special file (_app, _document, _error)
* or an API stub
*
* → has-app + has-pages → "both"
* → has-app only → "app"
* → has-pages only → "pages"
* → neither → "unknown"
*
* 3. Cross-reference APP_ROUTER_NON_APP_DIR_SUITES (mirrored from
* nextjs-deploy-manifest.mjs) so curated App Router suites that don't
* live under test/e2e/app-dir/ get "app" even if the heuristic says
* "unknown" or "pages".
*
* Why we don't just use the path prefix:
* The path-prefix rule (test/e2e/app-dir/ = app router) is mostly right,
* but ~50 suites under app-dir/ have BOTH an app/ and a pages/ directory
* to exercise interop. Some of those are true parity tests; others have
* a stub pages/ that's incidental. The "has real routes" check on each
* side correctly distinguishes the two cases.
*
* Bounded-depth walk:
* Some fixtures nest their app under fixtures/<name>/{app,pages} or
* apps/<name>/{app,pages}. We walk up to depth 4 from the fixture root
* (which is the directory containing the .test.ts file), which is enough
* to find any standard layout. We skip node_modules and .next.
*/
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const OVERRIDES_PATH = path.join(__dirname, "nextjs-suite-overrides.json");
/**
* Suites that live outside test/e2e/app-dir/ but exercise App Router
* behaviour. Mirrored from nextjs-deploy-manifest.mjs so the two scripts
* stay in sync — keep them identical.
*/
const APP_ROUTER_NON_APP_DIR_SUITES = new Set([
"test/e2e/next-form/default/next-form-prefetch.test.ts",
]);
const MAX_WALK_DEPTH = 4;
const SKIP_DIRS = new Set(["node_modules", ".next", ".turbo", "dist", "build"]);
const ROUTE_EXTS = new Set([".js", ".jsx", ".ts", ".tsx"]);
/**
* Pages-Router files that don't count as "real routes" for classification:
* they exist as plumbing in many fixtures even when the fixture isn't
* primarily testing the Pages Router. Mostly the framework specials.
*/
const PAGES_NON_ROUTE_BASENAMES = new Set([
"_app",
"_document",
"_error",
"_app.page",
"_document.page",
]);
/**
* App Router special filenames that count as "this is a real route".
*/
const APP_ROUTE_BASENAMES = new Set([
"page",
"route",
"layout",
"default", // parallel-routes default
]);
function hasExt(name) {
const ext = path.extname(name);
return ROUTE_EXTS.has(ext);
}
/**
* Like path.basename(name, path.extname(name)) but strips a second optional
* extension segment for the Next.js `pageExtensions` config pattern, where
* files are named e.g. `page.page.tsx` to opt into a custom extension.
*
* layout.tsx → "layout"
* layout.page.tsx → "layout"
* page.page.js → "page"
* index.tsx → "index"
* blog.page.tsx → "blog"
*/
function basenameNoExt(name) {
let base = path.basename(name, path.extname(name));
// Strip a second `.page` / `.api` style segment if present. This is the
// pageExtensions convention. We only strip well-known suffixes so we
// don't accidentally collapse e.g. `app.config.tsx` → `app`.
const PAGE_EXT_SUFFIXES = [".page", ".route", ".api"];
for (const suffix of PAGE_EXT_SUFFIXES) {
if (base.endsWith(suffix) && base.length > suffix.length) {
base = base.slice(0, -suffix.length);
break;
}
}
return base;
}
/**
* Does the directory at `dir` contain any "real" App Router route file
* (page / route / layout / default) anywhere under it? Bounded by depth
* to avoid scanning enormous trees.
*/
function appDirHasRealRoute(dir, maxDepth = 5) {
const stack = [{ dir, depth: 0 }];
while (stack.length) {
const { dir: cur, depth } = stack.pop();
let entries;
try {
entries = fs.readdirSync(cur, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
if (depth + 1 <= maxDepth) {
stack.push({ dir: path.join(cur, e.name), depth: depth + 1 });
}
} else if (e.isFile() && hasExt(e.name)) {
if (APP_ROUTE_BASENAMES.has(basenameNoExt(e.name))) return true;
}
}
}
return false;
}
/**
* Does the directory at `dir` contain any "real" Pages Router route file?
* We accept any .js/.jsx/.ts/.tsx file directly under pages/ (excluding the
* framework specials), or one level deep (e.g. pages/blog/[slug].tsx), or
* two levels deep. Files under pages/api/ count too — they're real routes.
*/
function pagesDirHasRealRoute(dir, maxDepth = 3) {
const stack = [{ dir, depth: 0 }];
while (stack.length) {
const { dir: cur, depth } = stack.pop();
let entries;
try {
entries = fs.readdirSync(cur, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
if (depth + 1 <= maxDepth) {
stack.push({ dir: path.join(cur, e.name), depth: depth + 1 });
}
} else if (e.isFile() && hasExt(e.name)) {
const base = basenameNoExt(e.name);
// Hidden / underscore-prefixed Pages Router specials don't count
// (except api/_middleware which doesn't exist in modern Next anyway).
if (PAGES_NON_ROUTE_BASENAMES.has(base)) continue;
// A bare file at the root of pages/ is always a real route (e.g.
// pages/index.tsx). Nested files also count (e.g. pages/blog/post.tsx).
return true;
}
}
}
return false;
}
/**
* Some Next.js fixtures wrap their test app inside a directory that is
* literally named `app`, like:
*
* test/e2e/og-api/
* index.test.ts
* app/ ← test-app wrapper (NOT App Router)
* next.config.js
* middleware.js
* app/ ← actual App Router root
* og/route.js
* pages/ ← Pages Router fixture
* index.js
*
* The .test.ts uses `nextTestSetup({ files: __dirname + '/app' })`, so the
* outer `app/` is the Next.js project root, not an App Router directory.
* Distinguishing it from a real App Router app dir matters: if we treat
* the wrapper as App Router we never descend into it and miss both the
* inner `app/` and the sibling `pages/`.
*
* Heuristic: a directory named `app` is the Next.js project root (i.e. a
* wrapper) if it contains a top-level `next.config.{js,ts,mjs,cjs}`. Real
* App Router app directories don't ship a next.config alongside their
* route files.
*/
const NEXT_CONFIG_NAMES = new Set([
"next.config.js",
"next.config.ts",
"next.config.mjs",
"next.config.cjs",
]);
/**
* Detect whether a directory named `app/` (or `pages/`) is actually a
* Next.js project-root wrapper rather than a real router directory.
*
* Two wrapper signals, either of which is sufficient:
*
* 1. Contains a top-level `next.config.{js,ts,mjs,cjs}` file. App Router
* app dirs never ship their own next.config.
*
* 2. Contains BOTH an inner `app/` AND an inner `pages/` directory.
* App Router app dirs don't have recursive `app` children, and even
* though `pages` is a legal App Router route group name, it never
* coexists with a sibling route group named `app`.
*
* We deliberately do NOT treat a top-level `middleware.{js,ts}` as a
* wrapper signal: Next.js tests put noop `middleware.js` files inside
* real App Router app dirs to assert the file is ignored there.
*/
const FIXTURE_WRAPPER_MARKERS = new Set(NEXT_CONFIG_NAMES);
function looksLikeFixtureWrapper(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return false;
}
let hasInnerApp = false;
let hasInnerPages = false;
for (const e of entries) {
if (e.isFile() && FIXTURE_WRAPPER_MARKERS.has(e.name)) return true;
if (e.isDirectory()) {
if (e.name === "app") hasInnerApp = true;
else if (e.name === "pages") hasInnerPages = true;
}
}
if (hasInnerApp && hasInnerPages) return true;
return false;
}
/**
* Walk the fixture root and find every directory named `app` or `pages`
* within MAX_WALK_DEPTH. For each one, check whether it has real routes.
*
* Returns { hasApp: boolean, hasPages: boolean }.
*/
function scanFixture(fixtureRoot) {
let hasApp = false;
let hasPages = false;
const stack = [{ dir: fixtureRoot, depth: 0 }];
while (stack.length) {
const { dir, depth } = stack.pop();
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
if (!e.isDirectory()) continue;
if (SKIP_DIRS.has(e.name)) continue;
const childPath = path.join(dir, e.name);
// When we encounter a directory named `app` or `pages`:
// 1. If it looks like a fixture wrapper (contains next.config.*),
// it's NOT a Next.js router directory — it's the test app's
// project root. Recurse into it to find the real router
// directories inside.
// 2. Otherwise, hand it to the dedicated route-checker.
// - If the route checker finds real routes, mark the flag
// and skip descending. The route checker already covered
// the subtree, and re-walking would risk a false positive
// (an App Router route group named `pages` would trip the
// Pages detector).
// - If the route checker finds nothing, the directory is
// named `app`/`pages` but has neither routes nor a wrapper
// signature. Recurse anyway, on the off chance routes are
// somewhere deeper.
if (e.name === "app" || e.name === "pages") {
if (looksLikeFixtureWrapper(childPath)) {
// Wrapper: descend to find the real app/pages inside.
if (depth + 1 <= MAX_WALK_DEPTH) {
stack.push({ dir: childPath, depth: depth + 1 });
}
continue;
}
if (e.name === "app") {
if (!hasApp && appDirHasRealRoute(childPath)) {
hasApp = true;
// `hasApp` is now true; short-circuit if pages was already set
if (hasPages) return { hasApp, hasPages };
continue; // covered by the route checker
}
} else {
if (!hasPages && pagesDirHasRealRoute(childPath)) {
hasPages = true;
if (hasApp) return { hasApp, hasPages };
continue;
}
}
}
if (depth + 1 <= MAX_WALK_DEPTH) {
stack.push({ dir: childPath, depth: depth + 1 });
}
}
}
return { hasApp, hasPages };
}
/**
* Classify a single suite. `nextjsDir` is the absolute path to the Next.js
* checkout root (so that `suite = "test/e2e/foo/foo.test.ts"` resolves to
* `<nextjsDir>/test/e2e/foo/foo.test.ts`).
*
* Returns "app" | "pages" | "both" | "unknown".
*/
export function classifySuite(nextjsDir, suite, overrides = {}) {
if (overrides[suite]) return overrides[suite];
const testFilePath = path.join(nextjsDir, suite);
// Fixture root is the directory containing the .test.ts file. If the
// suite path doesn't resolve to an existing file, fall back to its
// parent directory anyway — we'll just get "unknown" if nothing's there.
let fixtureRoot = path.dirname(testFilePath);
// Many Next.js fixtures live one level above their .test.ts file, e.g.
// test/e2e/middleware-base-path/test/index.test.ts ← test file
// test/e2e/middleware-base-path/{app,pages}/ ← fixture
// When the immediate parent is literally named `test`, prefer the
// grandparent as the fixture root.
if (path.basename(fixtureRoot) === "test") {
const parent = path.dirname(fixtureRoot);
// Guard: never walk above the Next.js test/e2e/ root.
if (parent.startsWith(path.join(nextjsDir, "test", "e2e"))) {
fixtureRoot = parent;
}
}
let stat;
try {
stat = fs.statSync(fixtureRoot);
} catch {
// Directory doesn't exist (e.g. Next.js checkout doesn't have this test
// anymore). Fall back to the curated list, then "unknown".
if (APP_ROUTER_NON_APP_DIR_SUITES.has(suite)) return "app";
return "unknown";
}
if (!stat.isDirectory()) {
if (APP_ROUTER_NON_APP_DIR_SUITES.has(suite)) return "app";
return "unknown";
}
const { hasApp, hasPages } = scanFixture(fixtureRoot);
if (hasApp && hasPages) return "both";
if (hasApp) return "app";
if (hasPages) return "pages";
// Curated override for App Router suites whose fixture happens to look
// empty to the heuristic (e.g. the .test.ts loads pages programmatically).
if (APP_ROUTER_NON_APP_DIR_SUITES.has(suite)) return "app";
// Fallback: suites under test/e2e/app-dir/ that have no on-disk fixture
// (the test builds its files inline via nextTestSetup({ files: { ... } }))
// are still App Router tests by convention. Without this fallback we'd
// misclassify ~2-5 suites per release as "unknown". Note we DO NOT apply
// the inverse rule to Pages Router — there's no equivalent path
// convention and suites outside app-dir/ legitimately may not exercise
// any router (build-only, config-only, etc.).
if (suite.startsWith("test/e2e/app-dir/")) return "app";
return "unknown";
}
export async function loadOverrides() {
try {
const raw = await fsp.readFile(OVERRIDES_PATH, "utf8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed;
}
} catch {
// Missing or unreadable overrides file: ignore.
}
return {};
}
/**
* Classify many suites. Returns a Map from suite path → router kind.
*/
export async function classifySuites(nextjsDir, suites) {
const overrides = await loadOverrides();
const out = new Map();
for (const suite of suites) {
out.set(suite, classifySuite(nextjsDir, suite, overrides));
}
return out;
}
function printUsage() {
console.error(
"Usage: node scripts/classify-nextjs-suites.mjs <nextjs-dir> <suites-input> <output-json>",
);
console.error(
" <suites-input> is either a JSON array of suite paths or a compat-ingest payload",
);
}
async function main() {
const [, , nextjsDirArg, suitesInputArg, outputArg] = process.argv;
if (!nextjsDirArg || !suitesInputArg || !outputArg) {
printUsage();
process.exitCode = 1;
return;
}
const nextjsDir = path.resolve(nextjsDirArg);
const suitesInputPath = path.resolve(suitesInputArg);
const outputPath = path.resolve(outputArg);
const raw = await fsp.readFile(suitesInputPath, "utf8");
const parsed = JSON.parse(raw);
let suites;
if (Array.isArray(parsed)) {
suites = parsed;
} else if (parsed && Array.isArray(parsed.files)) {
suites = parsed.files.map((f) => f.suite).filter((s) => typeof s === "string");
} else {
console.error("Input must be a JSON array of suites or an object with a `files` array.");
process.exitCode = 1;
return;
}
const map = await classifySuites(nextjsDir, suites);
const result = Object.fromEntries(map);
const counts = { app: 0, pages: 0, both: 0, unknown: 0 };
for (const r of map.values()) counts[r]++;
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
await fsp.writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
console.log(`Wrote ${outputPath}`);
console.log(JSON.stringify({ totalSuites: suites.length, counts }, null, 2));
}
// Only run as CLI when invoked directly (not when imported).
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error(error?.stack || error);
process.exitCode = 1;
});
}