j-chim Claude Opus 4.8 (1M context) commited on
Commit
ffd4efa
·
1 Parent(s): d1e9d01

Add an opt-in frontend preflight check suite

Browse files

Pre-deploy correctness checks that go beyond "the page loads": they verify
folded ids resolve, redirects point the right way, encoded developer pages load,
and comparison charts render real peer data — all against a warehouse snapshot.

- tests/redirect-integrity.test.ts (vitest, server-free): redirect-map and
resolver fallback invariants. Self-skips unless SNAPSHOT_URL is set, so it
never runs in the default `pnpm test`. Run with `pnpm test:integrity`.
- tests/e2e/ (@playwright /test): page rendering + chart content against the live
app, which it boots itself. Run with `pnpm test:e2e`.
- tests/PREFLIGHT.md: how to run, what each layer checks, the bug taxonomy, and
known limitations.

Nothing here gates the default test run or deploy — it's opt-in, matching the
existing scripts/verify-*.mjs convention. There is no CI today (this repo is an
HF Space; deploy is a git push that builds the Dockerfile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

.gitignore CHANGED
@@ -53,3 +53,7 @@ public/peer-ranks.json
53
  # private working notes / design specs / migration plans — not for publishing
54
  notes/
55
  docs/INTERPRETIVE_SIGNALS.md
 
 
 
 
 
53
  # private working notes / design specs / migration plans — not for publishing
54
  notes/
55
  docs/INTERPRETIVE_SIGNALS.md
56
+
57
+ # Playwright e2e output (tests/e2e via @playwright/test)
58
+ test-results/
59
+ playwright-report/
package.json CHANGED
@@ -12,6 +12,8 @@
12
  "start": "next start",
13
  "test": "vitest",
14
  "test:drift": "RUN_DRIFT=1 vitest --run tests/upstream-drift.test.ts",
 
 
15
  "compare-data-backends": "node scripts/compare-data-backends.mjs",
16
  "refresh-fixtures": "node scripts/refresh-fixtures.mjs",
17
  "audit-adapters": "tsx scripts/audit-adapters.mjs"
@@ -72,10 +74,12 @@
72
  "zod": "3.25.67"
73
  },
74
  "devDependencies": {
 
75
  "@tailwindcss/postcss": "^4.1.9",
76
  "@types/node": "^22",
77
  "@types/react": "^19",
78
  "@types/react-dom": "^19",
 
79
  "postcss": "^8.5",
80
  "puppeteer-core": "^24.42.0",
81
  "tailwindcss": "^4.1.9",
 
12
  "start": "next start",
13
  "test": "vitest",
14
  "test:drift": "RUN_DRIFT=1 vitest --run tests/upstream-drift.test.ts",
15
+ "test:integrity": "vitest --run tests/redirect-integrity.test.ts",
16
+ "test:e2e": "playwright test",
17
  "compare-data-backends": "node scripts/compare-data-backends.mjs",
18
  "refresh-fixtures": "node scripts/refresh-fixtures.mjs",
19
  "audit-adapters": "tsx scripts/audit-adapters.mjs"
 
74
  "zod": "3.25.67"
75
  },
76
  "devDependencies": {
77
+ "@playwright/test": "^1.60.0",
78
  "@tailwindcss/postcss": "^4.1.9",
79
  "@types/node": "^22",
80
  "@types/react": "^19",
81
  "@types/react-dom": "^19",
82
+ "playwright": "^1.60.0",
83
  "postcss": "^8.5",
84
  "puppeteer-core": "^24.42.0",
85
  "tailwindcss": "^4.1.9",
playwright.config.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "@playwright/test"
2
+
3
+ // Frontend preflight e2e (opt-in): `SNAPSHOT_URL=<warehouse> pnpm test:e2e`.
4
+ // NOT part of `pnpm test` and nothing gates deploy — it's a correctness check you
5
+ // run before shipping a meaningful frontend change. See tests/PREFLIGHT.md.
6
+ //
7
+ // The webServer block boots `pnpm dev` against the snapshot itself (and reuses an
8
+ // already-running one), so there's no manual server dance. When SNAPSHOT_URL is
9
+ // unset the spec self-skips.
10
+ const SNAPSHOT_URL = process.env.SNAPSHOT_URL
11
+ const PORT = Number(process.env.PORT || 3211)
12
+
13
+ export default defineConfig({
14
+ testDir: "tests/e2e",
15
+ // Each test sweeps many pages; give them room (they parallelize internally).
16
+ timeout: 300_000,
17
+ fullyParallel: false,
18
+ workers: 1,
19
+ reporter: "list",
20
+ use: { baseURL: `http://localhost:${PORT}` },
21
+ webServer: SNAPSHOT_URL
22
+ ? {
23
+ command: `pnpm dev -p ${PORT}`,
24
+ url: `http://localhost:${PORT}`,
25
+ timeout: 120_000,
26
+ reuseExistingServer: true,
27
+ env: { DATA_BACKEND: "v2", SNAPSHOT_URL },
28
+ }
29
+ : undefined,
30
+ })
pnpm-lock.yaml CHANGED
@@ -115,7 +115,7 @@ importers:
115
  version: 8.5.1(react@19.1.1)
116
  geist:
117
  specifier: ^1.3.1
118
- version: 1.4.2(next@15.2.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1))
119
  input-otp:
120
  specifier: 1.4.1
121
  version: 1.4.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -124,7 +124,7 @@ importers:
124
  version: 0.454.0(react@19.1.1)
125
  next:
126
  specifier: 15.2.8
127
- version: 15.2.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
128
  next-themes:
129
  specifier: latest
130
  version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -168,6 +168,9 @@ importers:
168
  specifier: 3.25.67
169
  version: 3.25.67
170
  devDependencies:
 
 
 
171
  '@tailwindcss/postcss':
172
  specifier: ^4.1.9
173
  version: 4.1.12
@@ -180,6 +183,9 @@ importers:
180
  '@types/react-dom':
181
  specifier: ^19
182
  version: 19.1.7(@types/react@19.1.10)
 
 
 
183
  postcss:
184
  specifier: ^8.5
185
  version: 8.5.6
@@ -754,6 +760,11 @@ packages:
754
  cpu: [x64]
755
  os: [win32]
756
 
 
 
 
 
 
757
  '@puppeteer/browsers@2.13.0':
758
  resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==}
759
  engines: {node: '>=18'}
@@ -2058,6 +2069,11 @@ packages:
2058
  fraction.js@4.3.7:
2059
  resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
2060
 
 
 
 
 
 
2061
  fsevents@2.3.3:
2062
  resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
2063
  engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2351,6 +2367,16 @@ packages:
2351
  pkg-types@1.3.1:
2352
  resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
2353
 
 
 
 
 
 
 
 
 
 
 
2354
  postcss-value-parser@4.2.0:
2355
  resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
2356
 
@@ -3177,6 +3203,10 @@ snapshots:
3177
  '@next/swc-win32-x64-msvc@15.2.5':
3178
  optional: true
3179
 
 
 
 
 
3180
  '@puppeteer/browsers@2.13.0':
3181
  dependencies:
3182
  debug: 4.4.3
@@ -4495,12 +4525,15 @@ snapshots:
4495
 
4496
  fraction.js@4.3.7: {}
4497
 
 
 
 
4498
  fsevents@2.3.3:
4499
  optional: true
4500
 
4501
- geist@1.4.2(next@15.2.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)):
4502
  dependencies:
4503
- next: 15.2.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
4504
 
4505
  get-caller-file@2.0.5: {}
4506
 
@@ -4664,7 +4697,7 @@ snapshots:
4664
  react: 19.1.1
4665
  react-dom: 19.1.1(react@19.1.1)
4666
 
4667
- next@15.2.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
4668
  dependencies:
4669
  '@next/env': 15.2.8
4670
  '@swc/counter': 0.1.3
@@ -4684,6 +4717,7 @@ snapshots:
4684
  '@next/swc-linux-x64-musl': 15.2.5
4685
  '@next/swc-win32-arm64-msvc': 15.2.5
4686
  '@next/swc-win32-x64-msvc': 15.2.5
 
4687
  sharp: 0.33.5
4688
  transitivePeerDependencies:
4689
  - '@babel/core'
@@ -4747,6 +4781,14 @@ snapshots:
4747
  mlly: 1.7.4
4748
  pathe: 2.0.3
4749
 
 
 
 
 
 
 
 
 
4750
  postcss-value-parser@4.2.0: {}
4751
 
4752
  postcss@8.4.31:
 
115
  version: 8.5.1(react@19.1.1)
116
  geist:
117
  specifier: ^1.3.1
118
+ version: 1.4.2(next@15.2.8(@playwright/test@1.60.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))
119
  input-otp:
120
  specifier: 1.4.1
121
  version: 1.4.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
 
124
  version: 0.454.0(react@19.1.1)
125
  next:
126
  specifier: 15.2.8
127
+ version: 15.2.8(@playwright/test@1.60.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
128
  next-themes:
129
  specifier: latest
130
  version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
 
168
  specifier: 3.25.67
169
  version: 3.25.67
170
  devDependencies:
171
+ '@playwright/test':
172
+ specifier: ^1.60.0
173
+ version: 1.60.0
174
  '@tailwindcss/postcss':
175
  specifier: ^4.1.9
176
  version: 4.1.12
 
183
  '@types/react-dom':
184
  specifier: ^19
185
  version: 19.1.7(@types/react@19.1.10)
186
+ playwright:
187
+ specifier: ^1.60.0
188
+ version: 1.60.0
189
  postcss:
190
  specifier: ^8.5
191
  version: 8.5.6
 
760
  cpu: [x64]
761
  os: [win32]
762
 
763
+ '@playwright/test@1.60.0':
764
+ resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
765
+ engines: {node: '>=18'}
766
+ hasBin: true
767
+
768
  '@puppeteer/browsers@2.13.0':
769
  resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==}
770
  engines: {node: '>=18'}
 
2069
  fraction.js@4.3.7:
2070
  resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
2071
 
2072
+ fsevents@2.3.2:
2073
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
2074
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
2075
+ os: [darwin]
2076
+
2077
  fsevents@2.3.3:
2078
  resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
2079
  engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
 
2367
  pkg-types@1.3.1:
2368
  resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
2369
 
2370
+ playwright-core@1.60.0:
2371
+ resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
2372
+ engines: {node: '>=18'}
2373
+ hasBin: true
2374
+
2375
+ playwright@1.60.0:
2376
+ resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
2377
+ engines: {node: '>=18'}
2378
+ hasBin: true
2379
+
2380
  postcss-value-parser@4.2.0:
2381
  resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
2382
 
 
3203
  '@next/swc-win32-x64-msvc@15.2.5':
3204
  optional: true
3205
 
3206
+ '@playwright/test@1.60.0':
3207
+ dependencies:
3208
+ playwright: 1.60.0
3209
+
3210
  '@puppeteer/browsers@2.13.0':
3211
  dependencies:
3212
  debug: 4.4.3
 
4525
 
4526
  fraction.js@4.3.7: {}
4527
 
4528
+ fsevents@2.3.2:
4529
+ optional: true
4530
+
4531
  fsevents@2.3.3:
4532
  optional: true
4533
 
4534
+ geist@1.4.2(next@15.2.8(@playwright/test@1.60.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)):
4535
  dependencies:
4536
+ next: 15.2.8(@playwright/test@1.60.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
4537
 
4538
  get-caller-file@2.0.5: {}
4539
 
 
4697
  react: 19.1.1
4698
  react-dom: 19.1.1(react@19.1.1)
4699
 
4700
+ next@15.2.8(@playwright/test@1.60.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
4701
  dependencies:
4702
  '@next/env': 15.2.8
4703
  '@swc/counter': 0.1.3
 
4717
  '@next/swc-linux-x64-musl': 15.2.5
4718
  '@next/swc-win32-arm64-msvc': 15.2.5
4719
  '@next/swc-win32-x64-msvc': 15.2.5
4720
+ '@playwright/test': 1.60.0
4721
  sharp: 0.33.5
4722
  transitivePeerDependencies:
4723
  - '@babel/core'
 
4781
  mlly: 1.7.4
4782
  pathe: 2.0.3
4783
 
4784
+ playwright-core@1.60.0: {}
4785
+
4786
+ playwright@1.60.0:
4787
+ dependencies:
4788
+ playwright-core: 1.60.0
4789
+ optionalDependencies:
4790
+ fsevents: 2.3.2
4791
+
4792
  postcss-value-parser@4.2.0: {}
4793
 
4794
  postcss@8.4.31:
tests/PREFLIGHT.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Frontend preflight — correctness checks before a deploy
2
+
3
+ Run these before pushing a meaningful frontend change to the HF Space. They are
4
+ **opt-in and non-blocking** — nothing here gates `git push` or the default
5
+ `pnpm test`; they're a correctness gate you choose to run.
6
+
7
+ Guiding principle, learned the hard way: **"the page returns 200 / an SVG exists /
8
+ no console error" ≠ correct.** A model page can render cleanly and still be wrong —
9
+ a folded id 404s its data, a redirect points the wrong way, or a chart silently
10
+ shows only the current model. These checks assert content, not just liveness.
11
+
12
+ ## How to run
13
+
14
+ ```bash
15
+ export SNAPSHOT_URL="https://huggingface.co/datasets/evaleval/card_backend/resolve/main/warehouse/<id>"
16
+ # (or file:///abs/path/to/warehouse/<id>)
17
+
18
+ # 1. Server-free data-contract checks (fast). Self-skips if SNAPSHOT_URL is unset.
19
+ pnpm test:integrity # tests/redirect-integrity.test.ts (vitest)
20
+
21
+ # 2. Live-server page + chart-content checks (Playwright boots its own dev server).
22
+ pnpm test:e2e # tests/e2e/frontend-preflight.spec.ts
23
+ ```
24
+
25
+ Both need `SNAPSHOT_URL`. `test:e2e` starts `pnpm dev` against it automatically
26
+ (and reuses an already-running server on the port). Set `PORT` to override 3211.
27
+
28
+ ## What each layer asserts
29
+
30
+ **`test:integrity` (vitest, server-free)** — against the warehouse snapshot:
31
+ - redirect map has 0 self-redirects, 0 chains/loops, every target is an addressable route;
32
+ - direction is folded→group (no addressable id is a redirect KEY — catches an inverted map);
33
+ - every redirect key is a known folded `raw_model_id`;
34
+ - no `raw_model_id` belongs to >1 group (the resolver's `LIMIT 1` fallback is unambiguous);
35
+ - no `models_view` row has NULL `raw_model_ids`.
36
+
37
+ **`test:e2e` (Playwright, live server)** — against the running app + `/api/comparison-index` ground truth:
38
+ - model / eval / developer pages render with HTTP <400, no client error text, no console errors —
39
+ **including the regression sets**: folded model ids (from `raw_model_ids`) and percent-encoded
40
+ developer names (e.g. `Mistral AI`);
41
+ - **100% of folded model ids resolve** (cheap status sweep — not a sample);
42
+ - comparison charts render real **peer bars** — counted in the DOM via `data-model-bar` /
43
+ `data-bar-current`, NOT via `#n/m` badges (those come from the peer-*ranks* sidecar) or the
44
+ "No peer scores" string (which never fires on the silent single-bar failure). Catches the
45
+ "chart shows only the current model" bug. Also: 0 "Unknown Model" labels; the current model is
46
+ not rendered as its own peer.
47
+
48
+ ## The bug taxonomy these were built to catch
49
+
50
+ 1. Folded model ids 404 — lookup didn't match `raw_model_ids`.
51
+ 2. Inverted redirect map 301'd working group URLs to dead leaves.
52
+ 3. Percent-encoded developer names (`Mistral%20AI`) 404'd (decoded-vs-encoded mismatch).
53
+ 4. Comparison charts showed only the current model (peer lookup keyed on a non-matching id).
54
+ 5. Peer bars showed "Unknown Model" (label read a field that isn't on the score rows).
55
+
56
+ The chart checks depend on the inert `data-model-bar` / `data-bar-current` attributes in
57
+ `components/benchmark-detail.tsx` — keep them.
58
+
59
+ ## Known limitations (spot-check manually when touching the relevant code)
60
+
61
+ - The chart check only inspects the DEFAULT-rendered metric tab; a break isolated to a
62
+ non-default tab isn't exercised.
63
+ - Eval/benchmark detail pages get liveness-only checking — their leaderboard content isn't
64
+ asserted against the index the way model charts are.
65
+ - Error-marker strings are hardcoded UI copy; update them here if the copy changes.
66
+ - **Redirect preservation** (old/pre-rework URLs that used to redirect): not gated here. The
67
+ warehouse-derived map only covers ids the producer folds into `raw_model_ids`; old base→variant
68
+ spellings that aren't folded will 404. That's a stale-bookmark concern with an upstream
69
+ (registry alias) fix, not a frontend gate.
70
+
71
+ ## Enforcement
72
+
73
+ There is no CI today (this repo is an HF Space — deploy is `git push`; HF builds the Dockerfile
74
+ and does not run tests). So these are run by hand, by convention, like the `scripts/verify-*.mjs`
75
+ family. If we add hosted CI later (e.g. mirror to GitHub), `test:integrity` is the cheap,
76
+ server-free job to wire in first; `test:e2e` belongs in a separate, explicitly-owned job.
tests/e2e/frontend-preflight.spec.ts ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DuckDBConnection } from "@duckdb/node-api"
2
+ import { expect, test, type Browser } from "@playwright/test"
3
+
4
+ // Frontend correctness e2e — the layer naive "page returns 200" checks miss.
5
+ // Opt-in via `SNAPSHOT_URL=<warehouse> pnpm test:e2e`; self-skips otherwise.
6
+ // Verifies, against the live server + comparison-index ground truth:
7
+ // - model/eval/developer pages render (incl. folded-id + encoded-name regressions),
8
+ // - 100% of folded model ids resolve (the raw_model_ids fallback),
9
+ // - comparison charts render real PEER bars (not just the current model), 0 "Unknown Model".
10
+ // Full bug taxonomy + known limitations: tests/PREFLIGHT.md.
11
+
12
+ const BASE = `http://localhost:${process.env.PORT || 3211}`
13
+ const SNAPSHOT = (process.env.SNAPSHOT_URL || "").replace(/\/+$/, "")
14
+ const SAMPLE = 30
15
+ const CHART_MODELS = 16
16
+
17
+ test.describe.configure({ mode: "serial" })
18
+ test.skip(!SNAPSHOT, "set SNAPSHOT_URL to run the frontend preflight e2e")
19
+
20
+ const ERROR_MARKERS = [
21
+ "Model not found", "Failed to load model data", "Eval not found",
22
+ "Benchmark not found", "Failed to load", "Application error",
23
+ "Something went wrong", "This page could not be found",
24
+ ]
25
+
26
+ const enc = (s: unknown) => encodeURIComponent(String(s))
27
+ const norm = (s: unknown) => String(s || "").toLowerCase().replace(/[^a-z0-9]/g, "")
28
+ const asArray = (j: any): any[] => Array.isArray(j) ? j
29
+ : (j && typeof j === "object" && ["models", "evals", "developers", "items", "rows", "cards", "data"].map((k) => j[k]).find(Array.isArray)) || []
30
+ const sample = <T>(a: T[], n: number): T[] => a.length <= n ? a.slice() : Array.from({ length: n }, (_, i) => a[Math.floor(i * (a.length / n))])
31
+ const listItems = (v: any): string[] => Array.isArray(v) ? v.map(String) : Array.isArray(v?.items) ? v.items.map(String) : []
32
+ const getJson = async (p: string) => { const r = await fetch(`${BASE}${p}`); return r.ok ? r.json() : null }
33
+ const getJsonT = async (p: string, ms = 90_000, tries = 2) => {
34
+ for (let i = 0; i < tries; i++) {
35
+ try { const r = await fetch(`${BASE}${p}`, { signal: AbortSignal.timeout(ms) }); if (r.ok) return r.json() } catch { /* retry */ }
36
+ }
37
+ return null
38
+ }
39
+ async function mapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>): Promise<R[]> {
40
+ const out = new Array<R>(items.length)
41
+ let i = 0
42
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
43
+ while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx]) }
44
+ }))
45
+ return out
46
+ }
47
+
48
+ let models: any[] = []
49
+ let evals: any[] = []
50
+ let devs: any[] = []
51
+ let folded: string[] = []
52
+ let expectedPeersFor: (routeId: string) => string[]
53
+
54
+ test.beforeAll(async () => {
55
+ models = asArray(await getJson("/api/model-cards-lite"))
56
+ evals = asArray(await getJson("/api/eval-list-lite"))
57
+ devs = asArray(await getJson("/api/developers"))
58
+ expect(models.length, "model-cards-lite empty").toBeGreaterThan(0)
59
+
60
+ // Comparison-index is the chart ground truth — unavailable must be a HARD fail,
61
+ // not a silent skip (else the whole chart layer disables itself).
62
+ const ci = await getJsonT("/api/comparison-index")
63
+ expect(ci?.by_model && ci?.evals, "comparison-index unavailable — chart check cannot run").toBeTruthy()
64
+ expectedPeersFor = (routeId: string) => {
65
+ const byModel = ci.by_model[routeId]
66
+ if (!byModel) return []
67
+ const peers = new Set<string>()
68
+ for (const evalId of Object.keys(byModel)) {
69
+ for (const metric of (ci.evals[evalId]?.metrics ?? [])) {
70
+ for (const s of metric.scores) {
71
+ if (s.model_route_id === routeId) continue
72
+ const n = norm(s.model_family_name || s.model_family_id)
73
+ if (n) peers.add(n)
74
+ }
75
+ }
76
+ }
77
+ return [...peers]
78
+ }
79
+
80
+ // Discover folded ids exhaustively from the warehouse (not a tiny stride).
81
+ const con = await DuckDBConnection.create()
82
+ await con.run("INSTALL httpfs; LOAD httpfs;")
83
+ const rows = (await con.runAndReadAll(
84
+ `SELECT model_id, raw_model_ids FROM read_parquet('${SNAPSHOT}/models_view.parquet') WHERE len(raw_model_ids) > 0`,
85
+ )).getRowObjects()
86
+ const set = new Set<string>()
87
+ for (const r of rows) for (const raw of listItems(r.raw_model_ids)) {
88
+ if (String(raw).toLowerCase() !== String(r.model_id).toLowerCase()) set.add(String(raw))
89
+ }
90
+ folded = [...set]
91
+ expect(folded.length, "folded-id regression set empty — discovery/data path changed").toBeGreaterThan(0)
92
+ })
93
+
94
+ test("100% of folded model ids resolve (raw_model_ids fallback)", async () => {
95
+ const statuses = await mapLimit(folded, 12, async (raw) => {
96
+ const r = await fetch(`${BASE}/api/model-summary?id=${enc(raw)}`, { signal: AbortSignal.timeout(30_000) }).catch(() => null)
97
+ return { raw, status: r ? r.status : 0 }
98
+ })
99
+ const bad = statuses.filter((s) => s.status !== 200).map((s) => s.raw)
100
+ expect(bad, `${bad.length}/${folded.length} folded ids do not resolve: ${bad.slice(0, 8)}`).toEqual([])
101
+ })
102
+
103
+ test("model / eval / developer pages render (incl. regression sets)", async ({ browser }) => {
104
+ const seen = new Set<string>()
105
+ const targets: { cls: string; url: string }[] = []
106
+ const add = (cls: string, url: string) => { if (url && !seen.has(url)) { seen.add(url); targets.push({ cls, url }) } }
107
+
108
+ for (const m of sample(models, SAMPLE)) add("model", `/models/${enc(m.model_id || m.id || m.model_key)}`)
109
+ for (const raw of sample(folded, 25)) add("model(folded)", `/models/${enc(raw)}`)
110
+ for (const e of sample(evals, SAMPLE)) { const id = e.evaluation_id || e.id || e.benchmark_id; if (id) add("eval", `/evals/${String(id).replace(/%2F/g, "/")}`) }
111
+ const devUrl = (rid: string) => `/developers/${String(rid).replace(/%2F/g, "/")}`
112
+ const encodedDevs = devs.filter((d) => /[^A-Za-z0-9._/-]/.test(String(d.developer || "")) || /%(?!2F)/i.test(String(d.route_id || "")))
113
+ expect(encodedDevs.length, "encoded-developer regression set empty — data path changed").toBeGreaterThan(0)
114
+ for (const d of encodedDevs) if (d.route_id) add("developer(encoded)", devUrl(d.route_id))
115
+ for (const d of sample(devs, 20)) if (d.route_id) add("developer", devUrl(d.route_id))
116
+
117
+ const results = await mapLimit(targets, 6, async (t) => {
118
+ const page = await browser.newPage()
119
+ const errs: string[] = []
120
+ page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 120)) })
121
+ page.on("pageerror", (e) => errs.push(String(e).slice(0, 120)))
122
+ let status = 0
123
+ try { const r = await page.goto(`${BASE}${t.url}`, { waitUntil: "networkidle", timeout: 45_000 }); status = r ? r.status() : 0; await page.waitForTimeout(700) }
124
+ catch (e) { errs.push(`goto: ${String(e).slice(0, 120)}`) }
125
+ const text = await page.evaluate(() => document.body?.innerText || "").catch(() => "")
126
+ const marker = ERROR_MARKERS.find((m) => text.includes(m))
127
+ await page.close()
128
+ return (!(status > 0 && status < 400) || marker || errs.length)
129
+ ? `${t.url} [${status}]${marker ? ` "${marker}"` : ""}${errs.length ? ` ${JSON.stringify(errs.slice(0, 2))}` : ""}`
130
+ : null
131
+ })
132
+ const broken = results.filter(Boolean) as string[]
133
+ expect(broken, `${broken.length} broken pages:\n ${broken.slice(0, 15).join("\n ")}`).toEqual([])
134
+ })
135
+
136
+ test("comparison charts render real peer bars (not only the current model)", async ({ browser }) => {
137
+ const candidates = sample(models, CHART_MODELS)
138
+ .map((m) => enc(m.model_id || m.id || m.model_key))
139
+ .filter((routeId) => expectedPeersFor(routeId).length > 0)
140
+ const per = await mapLimit(candidates, 6, async (routeId) => {
141
+ const expected = expectedPeersFor(routeId)
142
+ const page = await browser.newPage()
143
+ const problems: string[] = []
144
+ let bars: { id: string | null; cur: boolean }[] = []
145
+ try {
146
+ await page.goto(`${BASE}/models/${routeId}`, { waitUntil: "networkidle", timeout: 45_000 })
147
+ await page.waitForTimeout(1000)
148
+ const unknown = ((await page.evaluate(() => document.body?.innerText || "")).match(/Unknown Model/g) || []).length
149
+ bars = await page.$$eval("[data-model-bar]", (els) =>
150
+ els.map((e) => ({ id: e.getAttribute("data-model-bar"), cur: e.getAttribute("data-bar-current") === "1" })))
151
+ const peerBars = bars.filter((b) => !b.cur)
152
+ const currentBars = bars.length - peerBars.length
153
+ // MULTIPLE charts rendered but NONE show a peer bar => the only-current-model bug.
154
+ if (currentBars >= 2 && peerBars.length === 0) problems.push(`${routeId}: ${expected.length} peers in index but 0 peer bars`)
155
+ if (peerBars.some((b) => b.id === routeId)) problems.push(`${routeId}: current model rendered as its own peer (double-count)`)
156
+ if (unknown > 0) problems.push(`${routeId}: ${unknown} "Unknown Model" labels`)
157
+ } catch (e) { problems.push(`${routeId}: ${String(e).slice(0, 80)}`) }
158
+ await page.close()
159
+ return { bars: bars.length, problems }
160
+ })
161
+ const charted = candidates.length
162
+ const totalBars = per.reduce((n, r) => n + r.bars, 0)
163
+ const broken = per.flatMap((r) => r.problems)
164
+ expect(charted, "no sampled model had expected peers — chart check was vacuous").toBeGreaterThan(0)
165
+ // Suite-wide: peers expected but NO bars anywhere => the data-model-bar hook was
166
+ // dropped or charts don't render — the chart check silently disabled itself.
167
+ expect(totalBars, "0 chart bars across charted pages — data-model-bar hook missing or charts broken").toBeGreaterThan(0)
168
+ expect(broken, `chart problems:\n ${broken.slice(0, 15).join("\n ")}`).toEqual([])
169
+ })
tests/redirect-integrity.test.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync } from "fs"
2
+ import { fileURLToPath } from "url"
3
+
4
+ import { DuckDBConnection } from "@duckdb/node-api"
5
+ import { beforeAll, describe, expect, it } from "vitest"
6
+
7
+ // Server-FREE data-contract checks for the model URL redirect map + resolver
8
+ // fallback invariants, asserted against a Stage-J warehouse snapshot. Opt-in via
9
+ // `SNAPSHOT_URL=<warehouse> pnpm test:integrity` — self-skips in the default
10
+ // `pnpm test` run (mirrors the RUN_DRIFT pattern in upstream-drift.test.ts) so it
11
+ // never blocks a dev or a deploy. The server-BOUND checks (page rendering, chart
12
+ // content, redirect-preservation) live in the Playwright e2e suite + PREFLIGHT.md.
13
+ //
14
+ // Why this exists: "the page returns 200" != "correct". The redirect map and the
15
+ // raw_model_ids fallback have invariants that, if violated, silently break model
16
+ // pages (folded ids 404, an inverted map 301s working URLs to dead leaves). See
17
+ // tests/PREFLIGHT.md for the full bug taxonomy.
18
+
19
+ const SNAPSHOT = (process.env.SNAPSHOT_URL || "").replace(/\/+$/, "")
20
+ const shouldRun = !!SNAPSHOT
21
+ const MAP_PATH = fileURLToPath(new URL("../lib/model-url-redirects.ts", import.meta.url))
22
+
23
+ const dec = (s: string) => { try { return decodeURIComponent(s) } catch { return s } }
24
+ const listItems = (v: unknown): string[] =>
25
+ Array.isArray(v) ? v.map(String)
26
+ : v && typeof v === "object" && Array.isArray((v as { items?: unknown[] }).items)
27
+ ? (v as { items: unknown[] }).items.map(String)
28
+ : []
29
+
30
+ describe.skipIf(!shouldRun)("redirect-map + fallback integrity (vs SNAPSHOT_URL)", () => {
31
+ let map: [string, string][] = []
32
+ let keys: Set<string>
33
+ const addressable = new Set<string>() // decoded route forms that resolve to a page
34
+ const rawOwners = new Map<string, Set<string>>() // raw spelling -> owning group(s)
35
+ let nullRaw = 0
36
+ let rowCount = 0
37
+
38
+ beforeAll(async () => {
39
+ map = [...readFileSync(MAP_PATH, "utf8").matchAll(/\["([^"]+)",\s*"([^"]+)"\]/g)].map((m) => [m[1], m[2]])
40
+ keys = new Set(map.map(([k]) => k))
41
+ const con = await DuckDBConnection.create()
42
+ await con.run("INSTALL httpfs; LOAD httpfs;")
43
+ const rows = (await con.runAndReadAll(
44
+ `SELECT model_id, route_id, model_route_id, model_group_id, model_key, raw_model_ids
45
+ FROM read_parquet('${SNAPSHOT}/models_view.parquet')`,
46
+ )).getRowObjects()
47
+ rowCount = rows.length
48
+ for (const r of rows) {
49
+ for (const v of [r.model_id, r.model_route_id, r.route_id, r.model_group_id, r.model_key]) {
50
+ if (v) addressable.add(dec(String(v)))
51
+ }
52
+ if (r.raw_model_ids == null) nullRaw++
53
+ for (const raw of listItems(r.raw_model_ids)) {
54
+ const set = rawOwners.get(String(raw)) ?? rawOwners.set(String(raw), new Set()).get(String(raw))!
55
+ set.add(String(r.model_id))
56
+ }
57
+ }
58
+ }, 120_000)
59
+
60
+ it("redirect map has no self-redirects", () => {
61
+ expect(map.filter(([k, v]) => k === v)).toEqual([])
62
+ })
63
+
64
+ it("redirect map has no chains/loops (no target is also a key)", () => {
65
+ expect(map.filter(([, v]) => keys.has(v)).map(([, v]) => v)).toEqual([])
66
+ })
67
+
68
+ it("every redirect target is an addressable route", () => {
69
+ const dead = [...new Set(map.map(([, v]) => v))].filter((t) => !addressable.has(dec(t)))
70
+ expect(dead, `dead targets: ${dead.slice(0, 8)}`).toEqual([])
71
+ })
72
+
73
+ it("direction is folded->group (no addressable id is a redirect KEY)", () => {
74
+ // An addressable key means the map redirects a WORKING page away — the
75
+ // inverted-map bug.
76
+ const inverted = map.filter(([k]) => addressable.has(dec(k))).map(([k]) => k)
77
+ expect(inverted, `addressable ids used as redirect keys: ${inverted.slice(0, 8)}`).toEqual([])
78
+ })
79
+
80
+ it("every redirect key is a known folded raw_model_id", () => {
81
+ const orphan = map.filter(([k]) => !addressable.has(dec(k)) && !rawOwners.has(dec(k))).map(([k]) => k)
82
+ expect(orphan, `orphan keys (neither addressable nor a raw id): ${orphan.slice(0, 8)}`).toEqual([])
83
+ })
84
+
85
+ it("no raw_model_id belongs to >1 group (LIMIT 1 fallback is unambiguous)", () => {
86
+ const multi = [...rawOwners].filter(([, s]) => s.size > 1).map(([k]) => k)
87
+ expect(multi, `raw ids in >1 group: ${multi.slice(0, 8)}`).toEqual([])
88
+ })
89
+
90
+ it("no models_view row has NULL raw_model_ids (the fallback scans this column)", () => {
91
+ expect(nullRaw, `${nullRaw}/${rowCount} rows have NULL raw_model_ids`).toBe(0)
92
+ })
93
+ })
vitest.config.ts CHANGED
@@ -15,6 +15,8 @@ export default defineConfig({
15
  // self-skips otherwise. We DON'T exclude the path here because vitest's
16
  // exclude wins over an explicit path arg, which would silently make
17
  // `pnpm test:drift` find zero tests.
18
- exclude: ["**/node_modules/**", "**/dist/**"],
 
 
19
  },
20
  })
 
15
  // self-skips otherwise. We DON'T exclude the path here because vitest's
16
  // exclude wins over an explicit path arg, which would silently make
17
  // `pnpm test:drift` find zero tests.
18
+ // tests/e2e/ is the Playwright (@playwright/test) suite — a different runner.
19
+ // Excluded so `pnpm test` (vitest) doesn't try to collect its *.spec.ts.
20
+ exclude: ["**/node_modules/**", "**/dist/**", "**/tests/e2e/**"],
21
  },
22
  })