Spaces:
Running
Add an opt-in frontend preflight check suite
Browse filesPre-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 +4 -0
- package.json +4 -0
- playwright.config.ts +30 -0
- pnpm-lock.yaml +47 -5
- tests/PREFLIGHT.md +76 -0
- tests/e2e/frontend-preflight.spec.ts +169 -0
- tests/redirect-integrity.test.ts +93 -0
- vitest.config.ts +3 -1
|
@@ -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/
|
|
@@ -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",
|
|
@@ -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 |
+
})
|
|
@@ -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:
|
|
@@ -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.
|
|
@@ -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 |
+
})
|
|
@@ -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 |
+
})
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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 |
})
|