diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f373d65..ba0c315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v6 @@ -71,6 +73,11 @@ jobs: - name: Check data health run: npm run data-health:check + - name: Check manifest changelog + run: npm run changelog:check + env: + CHANGELOG_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + spell-check: name: Spell Check runs-on: ubuntu-latest @@ -123,10 +130,33 @@ jobs: .open-next retention-days: 7 + e2e: + name: Browser Smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run browser smoke tests + run: npm run test:e2e:ci + ci-success: name: CI Success runs-on: ubuntu-latest - needs: [lint, type-check, test, spell-check, build] + needs: [lint, type-check, test, spell-check, build, e2e] if: always() steps: - name: Check all jobs diff --git a/.gitignore b/.gitignore index 04693f5..9aa81c7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ # testing /coverage +/playwright-report/ +/test-results/ # next.js /.next/ diff --git a/data/changelogs.json b/data/changelogs.json index 0967ef4..06c5f15 100644 --- a/data/changelogs.json +++ b/data/changelogs.json @@ -1 +1,63 @@ -{} +{ + "version": 1, + "entries": [ + { + "id": "2026-07-18-project-review", + "date": "2026-07-18", + "summary": "Backfilled official-source provenance for priority providers and models", + "changes": [ + { + "category": "models", + "id": "claude-haiku-4-5", + "change": "updated", + "fields": ["docsUrl", "confidence", "lastVerifiedAt", "sources", "verifiedBy"] + }, + { + "category": "models", + "id": "gemini-3-flash", + "change": "updated", + "fields": ["docsUrl", "confidence", "lastVerifiedAt", "sources", "verifiedBy"] + }, + { + "category": "models", + "id": "gpt-5-2", + "change": "updated", + "fields": [ + "docsUrl", + "lifecycle", + "confidence", + "lastVerifiedAt", + "sources", + "verifiedBy" + ] + }, + { + "category": "providers", + "id": "anthropic", + "change": "updated", + "fields": [ + "applyKeyUrl", + "docsUrl", + "verified", + "confidence", + "lastVerifiedAt", + "sources", + "verifiedBy" + ] + }, + { + "category": "providers", + "id": "google", + "change": "updated", + "fields": ["docsUrl", "verified", "confidence", "lastVerifiedAt", "sources", "verifiedBy"] + }, + { + "category": "providers", + "id": "openai", + "change": "updated", + "fields": ["docsUrl", "verified", "confidence", "lastVerifiedAt", "sources", "verifiedBy"] + } + ] + } + ] +} diff --git a/docs/PROJECT-REVIEW-2026-07-18.md b/docs/PROJECT-REVIEW-2026-07-18.md index 91abd23..abda7d9 100644 --- a/docs/PROJECT-REVIEW-2026-07-18.md +++ b/docs/PROJECT-REVIEW-2026-07-18.md @@ -74,7 +74,7 @@ Baseline captured on 2026-07-18: integrity, translation placeholders, and missing pricing/benchmark values. - [x] Add freshness policies (for example 30/60/90-day review thresholds by field/category). - [ ] Automate change discovery from official sources, but keep human review before merge. -- [ ] Populate a real changelog from manifest diffs instead of keeping an empty static file. +- [x] Populate a real changelog from manifest diffs instead of keeping an empty static file. ## P1 — Product Experience @@ -93,7 +93,7 @@ Baseline captured on 2026-07-18: ## P1 — Quality Gates - [x] Add route-contract tests for every manifest category. -- [ ] Add browser-level smoke tests for search, navigation, detail pages, locale switching, and +- [x] Add browser-level smoke tests for search, navigation, detail pages, locale switching, and comparison selection. - [ ] Add automated accessibility checks for dialogs, menus, comparison tables, and mobile nav. - [x] Run generation in CI and fail when tracked generated output differs. @@ -171,3 +171,9 @@ evidence; completed items are reflected in the checklists and implementation log a permanent redirect, and removed its duplicate comparison implementation. - Added a persistent two-model selection shared by model cards, detail-page comparison actions, canonical pair URLs, and the comparison selector; adding a third model replaces the oldest pick. +- Added Playwright browser smoke coverage for search-to-detail navigation, locale-preserving route + switches, and persistent model comparison selections, and made it a required CI job. +- Replaced the empty changelog placeholder with a deterministic manifest-diff record and added a CI + guard requiring future manifest changes to be represented in `data/changelogs.json`. +- Fixed the search result category badge to use the shared translated category names after browser + testing exposed a missing runtime message lookup. diff --git a/package-lock.json b/package-lock.json index 338dbe1..c440781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@mdx-js/loader": "^3.1.1", "@next/bundle-analyzer": "^16.1.2", "@next/mdx": "^16.1.2", + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.1.17", "@types/node": "^22.19.2", "@types/react": "^19.2.9", @@ -11514,6 +11515,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -20509,11 +20526,30 @@ "node": ">=0.10" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, "node_modules/playwright-core": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", - "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", - "dev": true, + "version": "1.61.1", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -20522,6 +20558,21 @@ "node": ">=18" } }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/po-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", diff --git a/package.json b/package.json index 42906bb..788cea0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "spell:fix": "cspell '**/*.{ts,tsx,js,jsx,json,md,mdx}' --show-suggestions", "test": "vitest", "test:ci": "vitest run --reporter=verbose", + "test:e2e": "playwright test", + "test:e2e:ci": "playwright test --reporter=line", "test:validate": "vitest run tests/validate --reporter=verbose", "test:urls": "RUN_URL_TESTS=1 vitest run tests/validate/urls.accessibility.test.ts --reporter=verbose", "validate:i18n": "npx tsx scripts/validate/validate-i18n.ts", @@ -31,6 +33,9 @@ "data-health": "npx tsx scripts/validate/data-health.ts", "data-health:check": "npx tsx scripts/validate/data-health.ts --check-snapshot --fail-on=error", "data-health:report": "npx tsx scripts/validate/data-health.ts --write", + "changelog:check": "npx tsx scripts/validate/changelog.ts --check", + "changelog:generate": "npx tsx scripts/validate/changelog.ts --write", + "dev:e2e": "next dev --turbopack -p 3100", "generate": "npx tsx scripts/generate/index.ts", "generate:manifests": "npx tsx scripts/generate/index.ts manifest-indexes", "generate:metadata": "npx tsx scripts/generate/index.ts metadata", @@ -69,6 +74,7 @@ "@mdx-js/loader": "^3.1.1", "@next/bundle-analyzer": "^16.1.2", "@next/mdx": "^16.1.2", + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.1.17", "@types/node": "^22.19.2", "@types/react": "^19.2.9", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..99b6980 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test' + +const port = 3100 + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? 'line' : 'list', + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev:e2e', + url: `http://127.0.0.1:${port}`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) diff --git a/scripts/README.md b/scripts/README.md index 4b511c0..81b23b8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,8 +12,11 @@ Repository automation is written in TypeScript and executed with `tsx`. Prefer t | Sort manifest fields | `npm run refactor:sort-fields` | Reorders manifest JSON using schema order | | Fetch GitHub stars | `npm run fetch:github-stars` | Updates `data/github-stars.json` | | Validate manifests and data | `npm run test:validate` | Runs the validation test suite | +| Run browser smoke tests | `npm run test:e2e` | Exercises core journeys in Chromium against a local Next.js server | | Check data health | `npm run data-health:check` | Fails on invalid health-report data | | Refresh data-health snapshot | `npm run data-health:report` | Writes `data/data-health.json` and `docs/DATA-HEALTH.md` | +| Check manifest changelog | `npm run changelog:check` | Requires manifest diffs to be represented in `data/changelogs.json` | +| Generate manifest changelog | `npm run changelog:generate -- --base= --id= --summary=""` | Adds or replaces one entry from a Git diff | | Validate i18n structure | `npm run validate:i18n` | Checks locale alignment and translation shape | | Validate i18n usage | `npm run validate:i18n-usage` | Checks translation keys referenced by source | | Validate duplicate i18n values | `npm run validate:i18n-duplicates` | Reports duplicated translation content | diff --git a/scripts/validate/changelog.ts b/scripts/validate/changelog.ts new file mode 100644 index 0000000..4ca6c09 --- /dev/null +++ b/scripts/validate/changelog.ts @@ -0,0 +1,252 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const MANIFEST_CATEGORIES = [ + 'ides', + 'clis', + 'extensions', + 'models', + 'providers', + 'vendors', +] as const + +type ManifestCategory = (typeof MANIFEST_CATEGORIES)[number] +type ChangeType = 'added' | 'updated' | 'removed' + +export interface ManifestChange { + category: ManifestCategory + id: string + change: ChangeType + fields: string[] +} + +interface ChangelogEntry { + id: string + date: string + summary: string + changes: ManifestChange[] +} + +interface ChangelogFile { + version: 1 + entries: ChangelogEntry[] +} + +function comparableFields(record: Record | null): string[] { + return record + ? Object.keys(record) + .filter(key => key !== '$schema') + .sort() + : [] +} + +export function createManifestChange( + category: ManifestCategory, + id: string, + before: Record | null, + after: Record | null +): ManifestChange | null { + if (!before && !after) return null + if (!before) return { category, id, change: 'added', fields: comparableFields(after) } + if (!after) return { category, id, change: 'removed', fields: comparableFields(before) } + + const fields = [...new Set([...comparableFields(before), ...comparableFields(after)])].filter( + key => JSON.stringify(before[key]) !== JSON.stringify(after[key]) + ) + return fields.length > 0 ? { category, id, change: 'updated', fields } : null +} + +function runGit(rootDir: string, args: string[]): string { + return execFileSync('git', args, { cwd: rootDir, encoding: 'utf8' }).trim() +} + +function readBaseManifest( + rootDir: string, + base: string, + filePath: string +): Record | null { + try { + return JSON.parse(runGit(rootDir, ['show', `${base}:${filePath}`])) as Record + } catch { + return null + } +} + +function readWorkingManifest(rootDir: string, filePath: string): Record | null { + const absolutePath = path.join(rootDir, filePath) + return fs.existsSync(absolutePath) + ? (JSON.parse(fs.readFileSync(absolutePath, 'utf8')) as Record) + : null +} + +function parseManifestPath(filePath: string): { category: ManifestCategory; id: string } | null { + const match = /^manifests\/([^/]+)\/([^/]+)\.json$/.exec(filePath) + if (!match || !MANIFEST_CATEGORIES.includes(match[1] as ManifestCategory)) return null + return { category: match[1] as ManifestCategory, id: match[2]! } +} + +export function collectManifestChanges(rootDir: string, base: string): ManifestChange[] { + const manifestPaths = MANIFEST_CATEGORIES.map(category => `manifests/${category}`) + const output = runGit(rootDir, [ + 'diff', + '--name-status', + '--find-renames', + base, + '--', + ...manifestPaths, + ]) + if (!output) return [] + + const changes: ManifestChange[] = [] + for (const line of output.split('\n')) { + const [status = '', firstPath, secondPath] = line.split('\t') + const paths = status.startsWith('R') ? [firstPath, secondPath] : [firstPath] + for (const filePath of paths.filter((value): value is string => Boolean(value))) { + const parsed = parseManifestPath(filePath) + if (!parsed) continue + const before = + status.startsWith('A') || (status.startsWith('R') && filePath === secondPath) + ? null + : readBaseManifest(rootDir, base, filePath) + const after = + status.startsWith('D') || (status.startsWith('R') && filePath === firstPath) + ? null + : readWorkingManifest(rootDir, filePath) + const change = createManifestChange(parsed.category, parsed.id, before, after) + if (change) changes.push(change) + } + } + + return changes.sort( + (a, b) => + a.category.localeCompare(b.category) || + a.id.localeCompare(b.id) || + a.change.localeCompare(b.change) + ) +} + +function parseChangelog(content: string): ChangelogFile { + const parsed = JSON.parse(content) as Partial + if (parsed.version === undefined && Object.keys(parsed).length === 0) { + return { version: 1, entries: [] } + } + if (parsed.version !== 1 || !Array.isArray(parsed.entries)) { + throw new Error('data/changelogs.json must contain version 1 and an entries array') + } + return parsed as ChangelogFile +} + +function readChangelog(filePath: string): ChangelogFile { + return parseChangelog(fs.readFileSync(filePath, 'utf8')) +} + +function readBaseChangelog(rootDir: string, base: string): ChangelogFile { + try { + return parseChangelog(runGit(rootDir, ['show', `${base}:data/changelogs.json`])) + } catch { + return { version: 1, entries: [] } + } +} + +function changesMatch(left: ManifestChange, right: ManifestChange): boolean { + return ( + left.category === right.category && + left.id === right.id && + left.change === right.change && + JSON.stringify(left.fields) === JSON.stringify(right.fields) + ) +} + +function validateChangelog(changelog: ChangelogFile): void { + const entryIds = new Set() + for (const entry of changelog.entries) { + if (!entry.id || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || !entry.summary) { + throw new Error(`Invalid changelog entry: ${entry.id || '(missing id)'}`) + } + if (entryIds.has(entry.id)) throw new Error(`Duplicate changelog entry id: ${entry.id}`) + entryIds.add(entry.id) + if (!Array.isArray(entry.changes) || entry.changes.length === 0) { + throw new Error(`Changelog entry has no manifest changes: ${entry.id}`) + } + } +} + +function getArgument(name: string): string | null { + const prefix = `--${name}=` + return process.argv.find(argument => argument.startsWith(prefix))?.slice(prefix.length) ?? null +} + +async function main(): Promise { + const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + const changelogPath = path.join(rootDir, 'data', 'changelogs.json') + const base = getArgument('base') ?? process.env.CHANGELOG_BASE ?? 'origin/main' + const date = getArgument('date') ?? new Date().toISOString().slice(0, 10) + const id = getArgument('id') ?? `${date}-manifest-update` + const changes = collectManifestChanges(rootDir, base) + const summary = + getArgument('summary') ?? + `${changes.length} manifest record${changes.length === 1 ? '' : 's'} changed` + const changelog = readChangelog(changelogPath) + + if (process.argv.includes('--write')) { + if (changes.length === 0) throw new Error(`No manifest changes found relative to ${base}`) + const entry: ChangelogEntry = { + id, + date, + summary, + changes, + } + changelog.entries = [...changelog.entries.filter(existing => existing.id !== id), entry].sort( + (a, b) => b.date.localeCompare(a.date) || b.id.localeCompare(a.id) + ) + fs.writeFileSync(changelogPath, `${JSON.stringify(changelog, null, 2)}\n`, 'utf8') + execFileSync('npx', ['biome', 'format', '--write', changelogPath], { + cwd: rootDir, + stdio: 'ignore', + }) + console.log(`Wrote ${changes.length} manifest changes to data/changelogs.json`) + } + + validateChangelog(changelog) + + if (process.argv.includes('--check') && changes.length > 0) { + try { + execFileSync('git', ['diff', '--quiet', base, '--', 'data/changelogs.json'], { cwd: rootDir }) + throw new Error('Manifest changes require an update to data/changelogs.json') + } catch (error) { + if (error instanceof Error && error.message.startsWith('Manifest changes require')) + throw error + } + + const baseChangelog = readBaseChangelog(rootDir, base) + const changedEntries = changelog.entries.filter(entry => { + const previous = baseChangelog.entries.find(candidate => candidate.id === entry.id) + return !previous || JSON.stringify(previous) !== JSON.stringify(entry) + }) + const missing = changes.filter( + change => + !changedEntries.some(entry => entry.changes.some(saved => changesMatch(change, saved))) + ) + if (missing.length > 0) { + throw new Error( + `Changelog is missing manifest changes: ${missing.map(change => `${change.category}/${change.id}`).join(', ')}` + ) + } + } + + console.log( + `Changelog valid: ${changelog.entries.length} entries; ${changes.length} manifest changes relative to ${base}` + ) +} + +const currentFile = fileURLToPath(import.meta.url) +if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) { + main().catch(error => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/src/app/[locale]/search/page.client.tsx b/src/app/[locale]/search/page.client.tsx index fd84618..6e1ec9d 100644 --- a/src/app/[locale]/search/page.client.tsx +++ b/src/app/[locale]/search/page.client.tsx @@ -18,6 +18,7 @@ type Props = { export default function SearchPageClient({ locale, initialQuery }: Props) { const tPage = useTranslations('pages.search') + const tShared = useTranslations('shared') const router = useRouter() const searchParams = useSearchParams() const [query, setQuery] = useState(initialQuery) @@ -101,8 +102,8 @@ export default function SearchPageClient({ locale, initialQuery }: Props) {
- {tPage( - `stacks.${result.category === 'providers' ? 'modelProviders' : result.category}` + {tShared( + `categories.plural.${result.category === 'providers' ? 'modelProviders' : result.category}` )}
diff --git a/tests/changelog.test.ts b/tests/changelog.test.ts new file mode 100644 index 0000000..3f61b7d --- /dev/null +++ b/tests/changelog.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { createManifestChange } from '../scripts/validate/changelog' + +describe('manifest changelog generation', () => { + it('captures added and removed records', () => { + expect( + createManifestChange('models', 'new-model', null, { id: 'new-model', name: 'New' }) + ).toEqual({ + category: 'models', + id: 'new-model', + change: 'added', + fields: ['id', 'name'], + }) + expect(createManifestChange('providers', 'old', { id: 'old' }, null)).toEqual({ + category: 'providers', + id: 'old', + change: 'removed', + fields: ['id'], + }) + }) + + it('reports only changed top-level fields', () => { + expect( + createManifestChange( + 'models', + 'example', + { $schema: 'old', id: 'example', name: 'Before', verified: false }, + { $schema: 'new', id: 'example', name: 'After', verified: true } + ) + ).toEqual({ + category: 'models', + id: 'example', + change: 'updated', + fields: ['name', 'verified'], + }) + }) + + it('ignores records without material changes', () => { + expect(createManifestChange('vendors', 'same', { id: 'same' }, { id: 'same' })).toBeNull() + }) +}) diff --git a/tests/e2e/core-journeys.spec.ts b/tests/e2e/core-journeys.spec.ts new file mode 100644 index 0000000..6abaedb --- /dev/null +++ b/tests/e2e/core-journeys.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test' + +test('search opens a model detail page', async ({ page }) => { + await page.goto('/search?q=GPT-5.2') + + const result = page.getByRole('link', { name: /GPT-5\.2/ }).first() + await expect(result).toBeVisible() + await result.click() + + await expect(page).toHaveURL(/\/models\/gpt-5-2$/) + await expect(page.getByRole('heading', { name: 'GPT-5.2', level: 1 })).toBeVisible() +}) + +test('model comparison selection persists across routes', async ({ page }) => { + await page.goto('/models') + + await page.getByTitle('Compare: Gemini 3 Flash').click() + await page.getByTitle('Compare: GPT-5.2').click() + + const compareLink = page.getByRole('link', { name: /Compare \(2\/2\)/ }) + await expect(compareLink).toBeVisible() + await compareLink.click() + + await expect(page).toHaveURL(/\/models\/compare\/gemini-3-flash-vs-gpt-5-2$/) + await expect(page.locator('select').nth(0)).toHaveValue('gemini-3-flash') + await expect(page.locator('select').nth(1)).toHaveValue('gpt-5-2') + + await page.goto('/models') + await expect(page.getByTitle('Compare: Gemini 3 Flash')).toHaveAttribute('aria-pressed', 'true') + await expect(page.getByTitle('Compare: GPT-5.2')).toHaveAttribute('aria-pressed', 'true') +}) + +test('language switching preserves the current detail route', async ({ page }) => { + await page.goto('/models/gpt-5-2') + await page.getByRole('button', { name: 'Select language' }).click() + await page.getByRole('button', { name: '简体中文' }).click() + + await expect(page).toHaveURL(/\/zh-Hans\/models\/gpt-5-2$/) + await expect(page.getByRole('heading', { name: 'GPT-5.2', level: 1 })).toBeVisible() +})