diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd85513..d4d6978 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,5 +41,8 @@ jobs: - name: Svelte check run: pnpm check + - name: Test + run: pnpm test + - name: Build site run: pnpm build diff --git a/README.md b/README.md index 5961b67..6a92957 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,15 @@ or price structured data: the site publishes neither, and invented ratings are spam. Submitting the sitemap in Google Search Console and 百度搜索资源平台 is a manual step after deploy. +## Agent results + +`/results` and `/zh-CN/results` show the Wright Agent Score. The page holds no +result data: the browser fetches `https://releases.wrightkit.dev/bench/latest.json` +(`wright-agent-results/v1`, published by `wrightkit/wright`), so new results need +no redeploy. The fields the page reads are typed in `src/lib/bench.ts`; a missing +file shows the empty state and another schema version shows the older-format +state. The release bucket must allow cross-origin `GET` from `wrightkit.dev`. + ## Stack - SvelteKit with `@sveltejs/adapter-static` @@ -87,6 +96,7 @@ Requires Node.js 20.19+ and `pnpm`. pnpm install pnpm dev pnpm check +pnpm test ``` ## Build and preview diff --git a/package.json b/package.json index ee97aee..88b0ae0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite dev", "build": "vite build && node scripts/verify-build.mjs", "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + "test": "vitest run", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", @@ -18,6 +19,7 @@ "svelte-check": "^4.7.6", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.1", + "vitest": "^5.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a53279..bd1299a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: vite: specifier: ^8.3.1 version: 8.3.1(jiti@2.7.0) + vitest: + specifier: ^5.0.3 + version: 5.0.3(vite@8.3.1(jiti@2.7.0)) packages: @@ -296,12 +299,32 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -311,10 +334,18 @@ packages: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -345,6 +376,9 @@ packages: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + esm-env@1.2.2: resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} @@ -356,6 +390,13 @@ packages: '@typescript-eslint/types': optional: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -541,6 +582,9 @@ packages: magic-string@1.1.0: resolution: {integrity: sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -593,6 +637,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + svelte-check@4.7.6: resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} engines: {node: '>= 18.0.0'} @@ -612,6 +659,14 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -676,6 +731,52 @@ packages: vite: optional: true + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + zimmerframe@1.1.5: resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} @@ -862,16 +963,38 @@ snapshots: tailwindcss: 4.3.3 vite: 8.3.1(jiti@2.7.0) + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} + '@vitest/mocker@5.0.3(vite@8.3.1(jiti@2.7.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.1(jiti@2.7.0) + + '@vitest/spy@5.0.3': {} + acorn@8.18.0: {} aria-query@5.3.1: {} + assertion-error@2.0.1: {} + axobject-query@4.1.0: {} + chai@6.3.0: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -893,12 +1016,20 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + es-module-lexer@2.3.2: {} + esm-env@1.2.2: {} esrap@2.3.7: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -1024,6 +1155,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + mri@1.2.0: {} mrmime@2.0.1: {} @@ -1079,6 +1214,8 @@ snapshots: source-map-js@1.2.1: {} + std-env@4.3.0: {} + svelte-check@4.7.6(picomatch@4.0.7)(svelte@5.57.0)(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -1116,6 +1253,10 @@ snapshots: tapable@2.3.3: {} + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -1140,4 +1281,25 @@ snapshots: optionalDependencies: vite: 8.3.1(jiti@2.7.0) + vitest@5.0.3(vite@8.3.1(jiti@2.7.0)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@8.3.1(jiti@2.7.0)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.1.4 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.1(jiti@2.7.0) + why-is-node-running: 3.2.1 + transitivePeerDependencies: + - msw + + why-is-node-running@3.2.1: {} + zimmerframe@1.1.5: {} diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index b66ce48..a615fa4 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -94,8 +94,33 @@ for (const image of images) { } } +const resultsPages = [ + { file: 'results.html', lang: 'en', canonical: `${SITE_URL}/results`, title: 'Wright Agent Score' }, + { file: 'zh-CN/results.html', lang: 'zh-CN', canonical: `${SITE_URL}/zh-CN/results`, title: 'Wright Agent Score' } +]; +for (const page of resultsPages) { + const path = resolve(ROOT_DIR, 'build', page.file); + if (!existsSync(path)) { + console.error(`[verify-build] Error: build/${page.file} does not exist.`); + process.exit(1); + } + const html = readFileSync(path, 'utf8'); + const expected = [ + `${page.canonical}`)) { console.error(`[verify-build] Error: sitemap.xml is missing ${page.canonical}`); process.exit(1); @@ -112,4 +137,4 @@ if (!robots.includes(`Sitemap: ${SITE_URL}/sitemap.xml`)) { process.exit(1); } -console.log(`[verify-build] OK: ${pages.length} localized pages, share images, and sitemap verified.`); +console.log(`[verify-build] OK: ${pages.length + resultsPages.length} localized pages, share images, and sitemap verified.`); diff --git a/src/lib/bench.ts b/src/lib/bench.ts new file mode 100644 index 0000000..fef23d4 --- /dev/null +++ b/src/lib/bench.ts @@ -0,0 +1,120 @@ +/** + * The hosted Wright Agent Score results (`wright-agent-results/v1`, published by + * wrightkit/wright). The page holds no result data; it fetches this document in + * the browser and shows only the fields declared here. + */ + +export const resultsUrl = 'https://releases.wrightkit.dev/bench/latest.json'; +export const resultsSchema = 'wright-agent-results/v1'; + +export const tracks = ['workshop', 'opy'] as const; +export type Track = (typeof tracks)[number]; + +export interface TrackResult { + /** 0-100. */ + score: number; + /** 95% interval, `[low, high]`. */ + ci95: readonly [number, number]; + provisional: readonly string[]; +} + +export interface AgentResult { + agent: { program: string; version: string }; + model: string; + effort: string | null; + tracks: Partial>; +} + +export interface Results { + schema: typeof resultsSchema; + environment: { wright: string; suite: string }; + agents: readonly AgentResult[]; +} + +export type Loaded = + | { state: 'ready'; results: Results } + | { state: 'empty' } + | { state: 'unsupported' }; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); +const isPercent = (value: unknown): value is number => + typeof value === 'number' && value >= 0 && value <= 100; + +function parseTrack(value: unknown): TrackResult | null { + if (!isRecord(value) || !isPercent(value.score)) return null; + const ci = value.ci95; + if (!Array.isArray(ci) || ci.length !== 2 || !isPercent(ci[0]) || !isPercent(ci[1])) return null; + const provisional = value.provisional ?? []; + if (!Array.isArray(provisional) || !provisional.every((reason) => typeof reason === 'string')) return null; + return { score: value.score, ci95: [ci[0], ci[1]], provisional }; +} + +function parseAgent(value: unknown): AgentResult | null { + if (!isRecord(value) || !isRecord(value.agent) || !isRecord(value.tracks)) return null; + const { program, version } = value.agent; + const effort = value.effort ?? null; + if ( + typeof program !== 'string' || + typeof version !== 'string' || + typeof value.model !== 'string' || + (effort !== null && typeof effort !== 'string') + ) + return null; + const parsed: Partial> = {}; + for (const track of tracks) { + if (value.tracks[track] === undefined) continue; + const result = parseTrack(value.tracks[track]); + if (!result) return null; + parsed[track] = result; + } + return { agent: { program, version }, model: value.model, effort, tracks: parsed }; +} + +/** Results of a known schema version, or `null` when the document is not one this page can show. */ +export function parseResults(value: unknown): Results | null { + if (!isRecord(value) || value.schema !== resultsSchema) return null; + const env = value.environment; + if (!isRecord(env) || typeof env.wright !== 'string' || typeof env.suite !== 'string') return null; + if (!Array.isArray(value.agents)) return null; + const agents = value.agents.map(parseAgent); + if (agents.some((agent) => agent === null)) return null; + return { + schema: resultsSchema, + environment: { wright: env.wright, suite: env.suite }, + agents: agents as AgentResult[] + }; +} + +/** Missing or unreachable data is empty; a document of another schema version is unsupported. */ +export async function loadResults(fetchImpl: typeof fetch = fetch): Promise { + let body: unknown; + try { + const response = await fetchImpl(resultsUrl, { cache: 'no-cache' }); + if (!response.ok) return { state: 'empty' }; + body = await response.json(); + } catch { + return { state: 'empty' }; + } + const results = parseResults(body); + if (!results) return { state: 'unsupported' }; + return results.agents.length ? { state: 'ready', results } : { state: 'empty' }; +} + +/** Rows ordered by the Workshop score, then the OverPy score; agents without a track sort last. Tracks are never averaged. */ +export function rankAgents(agents: readonly AgentResult[]): AgentResult[] { + return [...agents].sort((a, b) => { + for (const track of tracks) { + const diff = (b.tracks[track]?.score ?? -1) - (a.tracks[track]?.score ?? -1); + if (diff) return diff; + } + return 0; + }); +} + +/** Whether a row's interval overlaps the first row's in the same track, so the data cannot separate them. */ +export function tiedWithTop(ranked: readonly AgentResult[], index: number, track: Track): boolean { + const top = ranked[0]?.tracks[track]; + const own = ranked[index]?.tracks[track]; + return index > 0 && !!top && !!own && own.ci95[0] <= top.ci95[1] && top.ci95[0] <= own.ci95[1]; +} diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 0d78083..3d194ce 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -7,14 +7,20 @@ import ThemeSwitcher from './ThemeSwitcher.svelte'; import LanguageSwitcher from './LanguageSwitcher.svelte'; import { navSections, pageSections, site } from '$lib/site'; - import { currentMessages } from '$lib/content'; + import { localePath } from '$lib/locales'; + import { currentLocale, currentMessages } from '$lib/content'; + import { page } from '$app/state'; const t = $derived(currentMessages()); + const locale = $derived(currentLocale()); + const route = $derived(page.url.pathname.endsWith('/results') ? '/results' : ''); + const resultsHref = $derived(localePath(locale, '', '/results')); const nav = $derived([ - ...navSections.map((id) => ({ label: t.nav[id], href: `#${id}`, external: false, wideOnly: false })), + ...navSections.map((id) => ({ label: t.nav[id], href: `#${id}`, to: localePath(locale, `#${id}`), external: false, wideOnly: false })), + { label: t.nav.results, href: resultsHref, to: resultsHref, external: false, wideOnly: false }, // Docs stays in the mobile sheet but joins the desktop bar only when it fits. - { label: t.nav.docs, href: site.docs, external: true, wideOnly: true }, - { label: 'GitHub', href: site.org, external: true, wideOnly: false } + { label: t.nav.docs, href: site.docs, to: site.docs, external: true, wideOnly: true }, + { label: 'GitHub', href: site.org, to: site.org, external: true, wideOnly: false } ]); let isOpen = $state(false); @@ -22,6 +28,8 @@ let scrollY = $state(0); const desktop = new MediaQuery('min-width: 768px'); + const isCurrent = (href: string) => (route ? href === resultsHref : current === href); + function closeMenu() { isOpen = false; } @@ -87,10 +95,11 @@