From 44e567e9a40339dced53c4631ea2f2fe4204306d Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Fri, 14 Aug 2026 21:50:47 +0800 Subject: [PATCH] test(e2e): enforce unified UI quality contracts --- bun.lock | 5 + package.json | 1 + .../admin/components/AdminOAuthClients.svelte | 2 +- .../ProfileContributionFooter.svelte | 1 + .../components/SettingsAccountRow.svelte | 2 +- .../components/SettingsAccountsTab.svelte | 2 +- .../SettingsAuthorizationsTab.svelte | 2 +- .../SettingsUstcIdentityPanel.svelte | 2 +- .../components/WelcomeProfileForm.svelte | 2 +- src/lib/components/shell/AppShell.svelte | 6 +- src/lib/components/ui/item/item-group.svelte | 1 - .../ui/scroll-area/scroll-area.svelte | 1 + src/routes/e2e/oauth/callback/+page.svelte | 4 +- src/routes/error/+page.svelte | 4 +- tests/e2e/AGENTS.md | 49 +++- .../mobile-screenshots/screenshots.spec.ts | 3 + tests/e2e/src/app/_shared/page-contract.ts | 38 ++- tests/e2e/src/app/_shared/page-inventory.ts | 134 ++++++--- tests/e2e/src/app/admin/bus/test.ts | 7 +- tests/e2e/src/app/admin/moderation/test.ts | 67 ++--- tests/e2e/src/app/admin/users/test.ts | 10 +- tests/e2e/src/app/courses/test.ts | 13 + tests/e2e/src/app/sections/test.ts | 20 +- tests/e2e/src/app/settings/accounts/test.ts | 68 +++-- tests/e2e/src/app/teachers/test.ts | 19 +- tests/e2e/utils/page-ready.ts | 8 + tests/e2e/utils/ui-quality.ts | 261 ++++++++++++++++++ tests/unit/page-inventory.test.ts | 96 ++++++- tests/unit/ui-quality.test.ts | 76 +++++ 29 files changed, 763 insertions(+), 141 deletions(-) create mode 100644 tests/e2e/utils/ui-quality.ts create mode 100644 tests/unit/ui-quality.test.ts diff --git a/bun.lock b/bun.lock index 89e783231..52c44f1b3 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,7 @@ "zod": "4.3.6", }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@biomejs/biome": "2.4.13", "@commitlint/cli": "^21.2.0", "@commitlint/config-conventional": "^21.2.0", @@ -101,6 +102,8 @@ "@ai-sdk/vue": ["@ai-sdk/vue@3.0.33", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.5", "ai": "6.0.33", "swrv": "^1.0.4" }, "peerDependencies": { "vue": "^3.3.4" } }, "sha512-czM9Js3a7f+Eo35gjEYEeJYUoPvMg5Dfi4bOLyDBghLqn0gaVg8yTmTaSuHCg+3K/+1xPjyXd4+2XcQIohWWiQ=="], + "@axe-core/playwright": ["@axe-core/playwright@4.13.0", "", { "dependencies": { "axe-core": "~4.13.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], @@ -949,6 +952,8 @@ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "axe-core": ["axe-core@4.13.0", "", {}, "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], diff --git a/package.json b/package.json index 327135bfe..eab2dbcd0 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "zod": "4.3.6" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@biomejs/biome": "2.4.13", "@commitlint/cli": "^21.2.0", "@commitlint/config-conventional": "^21.2.0", diff --git a/src/features/admin/components/AdminOAuthClients.svelte b/src/features/admin/components/AdminOAuthClients.svelte index 13b963884..629026c39 100644 --- a/src/features/admin/components/AdminOAuthClients.svelte +++ b/src/features/admin/components/AdminOAuthClients.svelte @@ -119,7 +119,7 @@ export let scopeLabel: (scope: string) => string;
- + {#each clients as client (client.clientId)} diff --git a/src/features/profile/components/ProfileContributionFooter.svelte b/src/features/profile/components/ProfileContributionFooter.svelte index ad7fdae57..caccf547b 100644 --- a/src/features/profile/components/ProfileContributionFooter.svelte +++ b/src/features/profile/components/ProfileContributionFooter.svelte @@ -30,6 +30,7 @@ export let stats: ProfileStat[]; {/each} diff --git a/src/features/settings/components/SettingsAccountRow.svelte b/src/features/settings/components/SettingsAccountRow.svelte index d27094bcf..83ac92396 100644 --- a/src/features/settings/components/SettingsAccountRow.svelte +++ b/src/features/settings/components/SettingsAccountRow.svelte @@ -22,7 +22,7 @@ export let unlinkAccountId: string | null; export let user: SettingsUser; - + {account.name} diff --git a/src/features/settings/components/SettingsAccountsTab.svelte b/src/features/settings/components/SettingsAccountsTab.svelte index b87634742..d029e557d 100644 --- a/src/features/settings/components/SettingsAccountsTab.svelte +++ b/src/features/settings/components/SettingsAccountsTab.svelte @@ -27,7 +27,7 @@ export let user: SettingsUser; class="grid gap-4" role="region" > - + {#each accounts as account} {:else} - + {#each authorizations as authorization} diff --git a/src/features/settings/components/SettingsUstcIdentityPanel.svelte b/src/features/settings/components/SettingsUstcIdentityPanel.svelte index 939c2ae39..59924c1a3 100644 --- a/src/features/settings/components/SettingsUstcIdentityPanel.svelte +++ b/src/features/settings/components/SettingsUstcIdentityPanel.svelte @@ -10,7 +10,7 @@ $: identities = account.ustcIdentities?.records ?? []; {#if account.id === "oidc" && account.linked} - +
{identityCopy.ustcIdentityTitle} diff --git a/src/features/welcome/components/WelcomeProfileForm.svelte b/src/features/welcome/components/WelcomeProfileForm.svelte index b4d65dff1..3ad103370 100644 --- a/src/features/welcome/components/WelcomeProfileForm.svelte +++ b/src/features/welcome/components/WelcomeProfileForm.svelte @@ -44,7 +44,7 @@ $: avatarFallback = (user.name ?? user.username ?? "U") {welcomeCopy.firstSignIn} - {welcomeCopy.title} + {welcomeCopy.title} {welcomeCopy.description} diff --git a/src/lib/components/shell/AppShell.svelte b/src/lib/components/shell/AppShell.svelte index 92194d27e..86318919a 100644 --- a/src/lib/components/shell/AppShell.svelte +++ b/src/lib/components/shell/AppShell.svelte @@ -721,7 +721,7 @@ afterNavigate(({ from, to }) => { { {viewerLoading} /> +
{@render children?.()} diff --git a/src/routes/e2e/oauth/callback/+page.svelte b/src/routes/e2e/oauth/callback/+page.svelte index 12b845197..797d19b93 100644 --- a/src/routes/e2e/oauth/callback/+page.svelte +++ b/src/routes/e2e/oauth/callback/+page.svelte @@ -12,7 +12,7 @@ $: callbackJson = JSON.stringify(callbackPayload, null, 2); OAuth E2E Callback - Life@USTC -
+
@@ -24,4 +24,4 @@ $: callbackJson = JSON.stringify(callbackPayload, null, 2);
{callbackJson}
-
+
diff --git a/src/routes/error/+page.svelte b/src/routes/error/+page.svelte index dd758cd3e..ac111903f 100644 --- a/src/routes/error/+page.svelte +++ b/src/routes/error/+page.svelte @@ -13,7 +13,7 @@ $: message = {copy.errorPageTitle} - Life@USTC -
+
@@ -28,4 +28,4 @@ $: message = -
+
diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md index eeb3e8eca..471788995 100644 --- a/tests/e2e/AGENTS.md +++ b/tests/e2e/AGENTS.md @@ -38,16 +38,39 @@ Helpers: `signInAsDebugUser`, `gotoAndWaitForReady`, `DEV_SEED` under `utils/`. (e.g. `tests/e2e/src/app/test.ts`, welcome/settings, `dashboard/**`, MCP UI). -## Page inventory (L0 / L1 / L2) - -- **L0 — inventory gate:** `tests/e2e/src/app/_shared/page-inventory.ts` lists every - `src/routes/**/+page.svelte`. `tests/unit/page-inventory.test.ts` fails if a - new page is orphaned or a `primaryActions` entry lacks a spec / exemption. -- **L1 — page identity:** call `assertPageContract` from the page’s - `tests/e2e/src/app/**/test.ts` (reuse `gotoAndWaitForReady` / role labels). -- **L2 — primary actions:** each actionable control that changes state or - navigates needs a role/label case in the page spec, **or** an inventory - exemption: `decorative` | `live-oauth` | `covered-by:`. -- Prefer `getByRole` / bilingual labels. Do not soft-skip expected product - controls with `test.skip` when `count() === 0` — use `expect(...).toBeVisible()`. -- Mobile screenshot paths come from `mobileScreenshotPaths()` in the inventory. +## Unified UI contract (L0-L4) + +- **L0 — inventory:** `tests/e2e/src/app/_shared/page-inventory.ts` lists every + `src/routes/**/+page.svelte`, redirect, and non-page browser alias. The unit + gate fails when a route is orphaned. Every rendered page also owns exactly + one mobile contract: inventory-driven public/authed/admin coverage, or a + dedicated scenario with a spec, test name, and non-empty reason. +- **L1 — rendered page baseline:** every page calls `assertPageContract` and is + exercised on desktop and mobile. Require a successful document response, + final URL/title/language, one visible main content target, a visible level-one + heading, meaningful settled content, no runtime/console error or error + overlay, and no document-level horizontal overflow. +- **L2 — UI quality and required elements:** reject duplicate IDs, broken + visible images, empty headings, unsafe/missing link destinations, and serious + or critical structural WCAG A/AA violations. Page specs assert their required + controls with role/label locators. Third-party exceptions must be scoped by + issue kind and exact match, and must include a reason; never add a wildcard + allowlist. Contrast, link-color, target-size, and pixel-diff checks are visual + policy and stay outside the no-visual-change structural gate. +- **L3 — capabilities and states:** cover the states a page actually owns. Lists + exercise results, no-results, filters/search, clear, and pagination when + present. Forms exercise validation, pending/disabled state, success, + persistence, and failure/rollback. Dialogs exercise open, focus, Escape, + cancel, and confirm. Mutating tests create deterministic fixtures, assert the + UI and persisted effect, and restore state in `finally`. Dynamic detail pages + include missing-record/404 cases; role-sensitive pages cover anonymous, user, + and admin behavior as applicable. +- **L4 — visual evidence:** keep pixel regression opt-in and representative + across the shell, a public catalog surface, and an authenticated workspace in + both locales and viewports. Do not require pixel snapshots for every page. + +Prefer `getByRole` / bilingual labels. Do not blindly click every button: +destructive, OAuth, download, upload, clipboard, and external-navigation flows +need capability-specific assertions or an explicit inventory exemption. Never +soft-pass an expected control with `if (count() === 0) return`; deterministic +fixtures and `expect(...).toBeVisible()` must make missing UI fail loudly. diff --git a/tests/e2e/mobile-screenshots/screenshots.spec.ts b/tests/e2e/mobile-screenshots/screenshots.spec.ts index a28157608..195570e81 100644 --- a/tests/e2e/mobile-screenshots/screenshots.spec.ts +++ b/tests/e2e/mobile-screenshots/screenshots.spec.ts @@ -19,6 +19,7 @@ function healthyMobileRoute(name: string, path: string) { browserHealth: {}, expectMeaningfulContent: true, expectNoHorizontalOverflow: true, + uiQuality: {}, }); expect( @@ -62,6 +63,7 @@ test.describe("移动端页面健全性", () => { browserHealth: {}, expectMeaningfulContent: true, expectNoHorizontalOverflow: true, + uiQuality: {}, }); }); @@ -81,6 +83,7 @@ test.describe("移动端页面健全性", () => { browserHealth: {}, expectMeaningfulContent: true, expectNoHorizontalOverflow: true, + uiQuality: {}, }); await expect(page).toHaveURL(/\/account\/welcome(?:\?.*)?$/); await expect( diff --git a/tests/e2e/src/app/_shared/page-contract.ts b/tests/e2e/src/app/_shared/page-contract.ts index 7dacc889c..c2392e7ec 100644 --- a/tests/e2e/src/app/_shared/page-contract.ts +++ b/tests/e2e/src/app/_shared/page-contract.ts @@ -19,12 +19,43 @@ import { resolveSeedSectionId, resolveSeedTeacherId, } from "../../../utils/seed-lookups"; +import type { UiQualityAllowlist } from "../../../utils/ui-quality"; type PageContractCase = { routePath: string; testInfo?: TestInfo; }; +const API_REFERENCE_UI_QUALITY_EXCEPTIONS = { + structure: [ + { + match: /^main: expected exactly one visible main landmark, found 2$/, + reason: + "Scalar renders its own main landmark inside the application's documented API reference shell.", + }, + ], + "duplicate-id": [ + { + match: /^#scalar-client-\d+-\d+: 2 elements use the same id$/, + reason: + "Scalar duplicates hidden client examples for responsive render modes; the ids are third-party generated.", + }, + ], + "invalid-link": [ + { + match: /^a: visible link has no href$/, + reason: + "Scalar renders operation toggles as anchors without hrefs inside its generated API reference DOM.", + }, + ], +} satisfies UiQualityAllowlist; + +function getContractUiQuality(routePath: string): UiQualityAllowlist { + return routePath.startsWith("/api/docs") + ? API_REFERENCE_UI_QUALITY_EXCEPTIONS + : {}; +} + function getContractWaitUntil(routePath: string) { if ( routePath === "/api/docs/tag/catalog-section" || @@ -53,14 +84,19 @@ async function gotoContractPage( ) { const response = await gotoAndWaitForReady(page, path, { browserHealth: {}, + expectMeaningfulContent: true, expectNoHorizontalOverflow: true, + uiQuality: getContractUiQuality(path), waitUntil: getContractWaitUntil(path), testInfo, screenshotLabel: "contract", }); if (response) { - expect(response.status()).toBeLessThan(500); + expect( + response.ok(), + `Expected ${path} to resolve to a successful document response, received ${response.status()}`, + ).toBe(true); } return response; diff --git a/tests/e2e/src/app/_shared/page-inventory.ts b/tests/e2e/src/app/_shared/page-inventory.ts index dd67c414d..c66bd49a1 100644 --- a/tests/e2e/src/app/_shared/page-inventory.ts +++ b/tests/e2e/src/app/_shared/page-inventory.ts @@ -12,6 +12,15 @@ export type PageAuth = "public" | "user" | "admin"; export type PageKind = "page" | "redirect"; export type MobileScreenshotGroup = "public" | "authed" | "admin"; +export type MobileCoverageReference = { + /** Spec path relative to tests/e2e/. */ + e2eSpec: string; + /** Exact test title fragment that owns the mobile contract. */ + testName: string; + /** Why the generic inventory-driven mobile route cannot own this case. */ + reason: string; +}; + export type PrimaryActionExemption = | "decorative" | "live-oauth" @@ -26,6 +35,8 @@ export type PrimaryAction = { testId?: string; /** Spec path relative to tests/e2e/ that exercises this action. */ e2eSpec?: string; + /** Exact source fragment tying aggregate capability coverage to the spec. */ + evidence?: string; /** Explicit exemption when no dedicated L2 assertion is required. */ exemption?: PrimaryActionExemption; }; @@ -44,8 +55,10 @@ export type PageInventoryEntry = { e2eSpec?: string; coveredBy?: string; primaryActions?: PrimaryAction[]; - /** Drive mobile-screenshots goto list from inventory. */ - mobileScreenshot?: MobileScreenshotGroup; + /** Drive one or more authenticated-state mobile checks from the inventory. */ + mobileScreenshots?: readonly MobileScreenshotGroup[]; + /** Dedicated mobile scenario for pages that need fixture setup or richer UI checks. */ + mobileCoveredBy?: MobileCoverageReference; }; const E2E = { @@ -102,6 +115,16 @@ const E2E = { export const INVENTORY_SETTINGS_TABS = SETTINGS_TABS; export const INVENTORY_WORKSPACE_TABS = workspaceTabIds; +/** Browser-facing aliases implemented without +page.svelte files. */ +export const BROWSER_ALIAS_INVENTORY = [ + { + path: "/api-docs", + target: "/api/docs/tag/catalog-section", + e2eSpec: E2E.apiDocs, + testName: "重定向到 /api/docs", + }, +] as const; + export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ { routeId: "/", @@ -110,7 +133,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/", e2eSpec: E2E.home, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "nav-courses", @@ -141,7 +164,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ contractPath: "/account/settings/profile", e2eSpec: E2E.settingsProfile, coveredBy: E2E.settings, - mobileScreenshot: "authed", + mobileScreenshots: ["authed"], primaryActions: [ { id: "save-profile", @@ -152,10 +175,12 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ { id: "preferences-appearance", e2eSpec: E2E.settingsPreferences, + evidence: "外观选择立即应用并写入既有 localStorage", }, { id: "accounts-providers", e2eSpec: E2E.settingsAccounts, + evidence: "显示所有提供商卡片", }, { id: "accounts-ustc-connect", @@ -166,14 +191,17 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ { id: "authorizations-list", e2eSpec: E2E.settingsAuthorizations, + evidence: "仅显示安全的客户端信息,并支持确认后立即撤销", }, { id: "danger-delete", e2eSpec: E2E.settingsDanger, + evidence: "删除账号确认流程", }, { id: "passkeys-ui", e2eSpec: E2E.settingsPasskeys, + evidence: "注册、退出、通行密钥登录、重命名和删除", }, ...SETTINGS_TABS.map( (tab): PrimaryAction => ({ @@ -191,7 +219,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/account/sign-in", e2eSpec: E2E.signin, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "provider-ustc", @@ -220,6 +248,12 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "user", contractPath: "/account/welcome", e2eSpec: E2E.welcome, + mobileCoveredBy: { + e2eSpec: "mobile-screenshots/screenshots.spec.ts", + testName: "/account/welcome 页面截图", + reason: + "The welcome page requires temporarily clearing and restoring the seeded user's profile.", + }, primaryActions: [ { id: "welcome-name", @@ -256,7 +290,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "admin", contractPath: "/admin/bus", e2eSpec: E2E.adminBus, - mobileScreenshot: "admin", + mobileScreenshots: ["admin"], primaryActions: [ { id: "import-bus", @@ -273,7 +307,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "admin", contractPath: "/admin/moderation", e2eSpec: E2E.adminModeration, - mobileScreenshot: "admin", + mobileScreenshots: ["admin"], primaryActions: [ { id: "status-filter", @@ -295,7 +329,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "admin", contractPath: "/admin/oauth", e2eSpec: E2E.adminOauth, - mobileScreenshot: "admin", + mobileScreenshots: ["admin"], primaryActions: [ { id: "create-client", @@ -312,7 +346,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "admin", contractPath: "/admin/users", e2eSpec: E2E.adminUsers, - mobileScreenshot: "admin", + mobileScreenshots: ["admin"], primaryActions: [ { id: "manage-user", @@ -337,10 +371,17 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/api/docs/tag/catalog-section", e2eSpec: E2E.apiDocs, + mobileCoveredBy: { + e2eSpec: E2E.apiDocs, + testName: "移动端优先展示参考内容并用抽屉浏览完整导航", + reason: + "The embedded API reference has a dedicated mobile navigation and focus contract.", + }, primaryActions: [ { id: "scalar-nav", e2eSpec: E2E.apiDocs, + evidence: "使用路径导航而非哈希导航", }, ], }, @@ -351,7 +392,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/catalog/bus", e2eSpec: E2E.bus, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "change-route", @@ -374,7 +415,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/catalog/bus/map", e2eSpec: E2E.busMap, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "refresh-map", @@ -391,11 +432,12 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/catalog/courses", e2eSpec: E2E.courses, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "search-courses", e2eSpec: E2E.courses, + evidence: "搜索和清除按钮", }, ], }, @@ -406,11 +448,12 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/catalog/courses/[jwId]", e2eSpec: E2E.coursesJwId, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "teaching-sections", e2eSpec: E2E.coursesJwId, + evidence: "班级行链接到班级详情", }, ], }, @@ -429,7 +472,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/catalog/links", e2eSpec: E2E.dashboardLinks, - mobileScreenshot: "authed", + mobileScreenshots: ["public", "authed"], primaryActions: [ { id: "search-links", @@ -452,11 +495,12 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/catalog/sections", e2eSpec: E2E.sections, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "search-sections", e2eSpec: E2E.sections, + evidence: "结构化筛选、高级语法与清除", }, ], }, @@ -467,7 +511,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/catalog/sections/[jwId]", e2eSpec: E2E.sectionsJwId, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "subscribe", @@ -504,11 +548,12 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/catalog/teachers", e2eSpec: E2E.teachers, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "search-teachers", e2eSpec: E2E.teachers, + evidence: "搜索和清除按钮可用", }, ], }, @@ -519,10 +564,17 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/catalog/teachers/[id]", e2eSpec: E2E.teachersId, + mobileCoveredBy: { + e2eSpec: E2E.teachersId, + testName: "移动端教师标题与流式区块保持紧凑", + reason: + "The stable teacher URL is discovered from seeded search results before checking the mobile layout.", + }, primaryActions: [ { id: "teaching-sections", e2eSpec: E2E.teachersId, + evidence: "班级链接导航到班级详情", }, ], }, @@ -549,7 +601,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/community/comments/guide", e2eSpec: E2E.commentsGuide, - mobileScreenshot: "public", + mobileScreenshots: ["public"], }, { routeId: "/community/users/[identifier]", @@ -558,7 +610,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/community/users/[identifier]", e2eSpec: E2E.communityUser, - mobileScreenshot: "authed", + mobileScreenshots: ["public", "authed"], primaryActions: [ { id: "profile-identity", @@ -573,6 +625,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/e2e/oauth/callback", e2eSpec: E2E.e2eOauthCallback, + mobileScreenshots: ["public"], primaryActions: [ { id: "callback-payload", @@ -587,6 +640,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/error", e2eSpec: E2E.error, + mobileScreenshots: ["public"], primaryActions: [ { id: "return-home", @@ -603,7 +657,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/guides/markdown-support", e2eSpec: E2E.guidesMarkdown, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "markdown-preview", @@ -618,7 +672,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/usage/mobile", e2eSpec: E2E.usage, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "app-store", @@ -635,7 +689,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/usage/bot", e2eSpec: E2E.usage, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "open-presto-qq", @@ -652,7 +706,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/usage/mcp", e2eSpec: E2E.usage, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "copy-mcp-endpoint", @@ -669,7 +723,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/usage/cli", e2eSpec: E2E.usage, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "download-cli", @@ -686,10 +740,12 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/oauth/authorize", e2eSpec: E2E.oauthAuthorize, + mobileScreenshots: ["public"], primaryActions: [ { id: "authorize-flow", e2eSpec: E2E.oauthAuthorize, + evidence: "允许授权时带 code 回跳", }, ], }, @@ -700,11 +756,12 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/oauth/device", e2eSpec: E2E.oauthDevice, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "device-code", e2eSpec: E2E.oauthDevice, + evidence: "已登录用户看到批准界面", }, ], }, @@ -715,7 +772,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/privacy", e2eSpec: E2E.privacy, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "policy-body", @@ -730,7 +787,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/search", e2eSpec: E2E.search, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "search-input", @@ -740,6 +797,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ { id: "keyboard-nav", e2eSpec: E2E.search, + evidence: "search page supports keyboard navigation into results", }, ], }, @@ -750,7 +808,7 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "public", contractPath: "/terms", e2eSpec: E2E.terms, - mobileScreenshot: "public", + mobileScreenshots: ["public"], primaryActions: [ { id: "terms-body", @@ -773,38 +831,45 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "user", contractPath: "/workspace/overview", e2eSpec: E2E.dashboardTab, - mobileScreenshot: "authed", + mobileScreenshots: ["authed"], primaryActions: [ ...workspaceTabIds.map( (tab): PrimaryAction => ({ id: `workspace-tab-${tab}`, role: "link", e2eSpec: E2E.dashboardTab, + evidence: "登录工作台各分支提供唯一页面身份", }), ), { id: "overview-now-next", e2eSpec: E2E.dashboard, + evidence: "移动端总览优先显示此刻与下一步", }, { id: "calendar-export", e2eSpec: E2E.dashboardCalendar, + evidence: "复制日历链接生成有效的 iCal URL", }, { id: "homework-crud", e2eSpec: E2E.dashboardHomeworks, + evidence: "可以创建新作业", }, { id: "todo-crud", e2eSpec: E2E.dashboardTodos, + evidence: "可以创建、编辑和删除待办", }, { id: "exams-view", e2eSpec: E2E.dashboardExams, + evidence: "考试列表显示必填字段", }, { id: "subscriptions-bulk-import", e2eSpec: E2E.dashboardSubscriptions, + evidence: "批量导入可确认并显示成功", }, ], }, @@ -815,19 +880,22 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ auth: "user", contractPath: "/workspace/subscriptions", e2eSpec: E2E.dashboardSubscriptions, - mobileScreenshot: "authed", + mobileScreenshots: ["authed"], primaryActions: [ { id: "bulk-import", e2eSpec: E2E.dashboardSubscriptions, + evidence: "批量导入打开确认对话框并可取消", }, { id: "unsubscribe-row", e2eSpec: E2E.dashboardSubscriptions, + evidence: "取消订阅操作确认后移除订阅", }, { id: "calendar-feed-copy", e2eSpec: E2E.dashboardSubscriptions, + evidence: "复制日历链接生成有效的 iCal URL", }, ], }, @@ -848,8 +916,8 @@ export function inventoryByRouteId( } export function mobileScreenshotPaths(group: MobileScreenshotGroup): string[] { - const paths = PAGE_INVENTORY.filter( - (entry) => entry.mobileScreenshot === group, + const paths = PAGE_INVENTORY.filter((entry) => + entry.mobileScreenshots?.includes(group), ).map((entry) => entry.samplePath); if (group === "authed") { @@ -859,7 +927,7 @@ export function mobileScreenshotPaths(group: MobileScreenshotGroup): string[] { paths.push(path); } } - for (const tab of ["profile", "accounts", "danger"] as const) { + for (const tab of SETTINGS_TABS) { const path = `/account/settings/${tab}`; if (!paths.includes(path)) { paths.push(path); diff --git a/tests/e2e/src/app/admin/bus/test.ts b/tests/e2e/src/app/admin/bus/test.ts index 0330e6a37..fa5928ee5 100644 --- a/tests/e2e/src/app/admin/bus/test.ts +++ b/tests/e2e/src/app/admin/bus/test.ts @@ -109,12 +109,9 @@ test("/admin/bus 激活版本受保护且导入弹窗可打开", async ({ .first(); await expect(versionRow).toBeVisible(); - // Active seed versions should not expose the delete action; if the seed is - // inactive (e.g. left over from an earlier failed run) we just verify the - // row is rendered and move on. + // The active seed version must never expose a destructive delete action. const deleteBtn = versionRow.getByRole("button", { name: /删除|Delete/i }); - const deleteCount = await deleteBtn.count(); - expect(deleteCount === 0 || deleteCount === 1).toBe(true); + await expect(deleteBtn).toHaveCount(0); const importBtn = page .getByRole("button", { name: /从 Static 导入|Import from Static/i }) diff --git a/tests/e2e/src/app/admin/moderation/test.ts b/tests/e2e/src/app/admin/moderation/test.ts index dc8d14c71..b430bf572 100644 --- a/tests/e2e/src/app/admin/moderation/test.ts +++ b/tests/e2e/src/app/admin/moderation/test.ts @@ -430,38 +430,23 @@ test("/admin/moderation 可从评论弹窗封禁并解除用户", async ({ // ── Description governance ────────────────────────────────────────────────── test("/admin/moderation 课程简介治理表格可见", async ({ page }, testInfo) => { - await signInAsDevAdmin(page, "/admin/moderation"); + await signInAsDevAdmin(page, "/admin/moderation?tab=descriptions"); - // admin.yml moderation.display.fields: Description moderation table - const descTab = page.getByRole("link", { name: /简介|Description/i }).first(); - if ((await descTab.count()) > 0) { - await descTab.click(); - // description.content / preview visible - await expect(page.locator("td, [data-slot='card']").first()).toBeVisible(); - await captureStepScreenshot( - page, - testInfo, - "admin-moderation/description-table", - ); - } else { - // descriptions may be on the same tab — look for the section header - const descSection = page.getByText(/简介管理|Descriptions/i).first(); - if ((await descSection.count()) > 0) { - await expect(descSection).toBeVisible(); - } - // Verify the API is accessible - const descResponse = await page.request.get("/api/admin/descriptions"); - expect(descResponse.status()).toBe(200); - const descBody = (await descResponse.json()) as { - data?: Array<{ id?: string }>; - }; - expect(Array.isArray(descBody.data)).toBe(true); - await captureStepScreenshot( - page, - testInfo, - "admin-moderation/descriptions-api", - ); - } + await expect( + page.getByRole("link", { name: /课程简介|Descriptions/i }), + ).toHaveAttribute("aria-current", "page"); + const firstRow = page.locator("tbody tr:visible").first(); + await expect(firstRow).toBeVisible(); + await expect( + firstRow.getByRole("button", { + name: /管理课程简介|Manage Description/i, + }), + ).toBeVisible(); + await captureStepScreenshot( + page, + testInfo, + "admin-moderation/description-table", + ); }); test("/admin/moderation 简介桌面行操作可用键盘打开管理弹窗", async ({ @@ -544,18 +529,16 @@ test("/admin/moderation 可更新课程简介内容", async ({ page }, testInfo) // ── Homework governance ───────────────────────────────────────────────────── test("/admin/moderation 作业治理可访问", async ({ page }, testInfo) => { - await signInAsDevAdmin(page, "/admin/moderation"); + await signInAsDevAdmin(page, "/admin/moderation?tab=homeworks"); - // admin.yml moderation.display.fields (via homework.yml → homework-governance) - const hwTab = page.getByRole("link", { name: /作业|Homework/i }).first(); - if ((await hwTab.count()) > 0) { - await hwTab.click(); - await captureStepScreenshot( - page, - testInfo, - "admin-moderation/homework-tab", - ); - } + await expect( + page.locator('a[aria-current="page"][href*="tab=homeworks"]'), + ).toHaveAttribute("aria-current", "page"); + const firstRow = page.locator("tbody tr:visible").first(); + await expect(firstRow).toBeVisible(); + await expect( + page.getByRole("button", { name: /^(删除|Delete)$/i }).first(), + ).toBeVisible(); // Verify the homework governance API is accessible const hwResponse = await page.request.get("/api/admin/homeworks"); diff --git a/tests/e2e/src/app/admin/users/test.ts b/tests/e2e/src/app/admin/users/test.ts index 8c2614628..ac3a48cd9 100644 --- a/tests/e2e/src/app/admin/users/test.ts +++ b/tests/e2e/src/app/admin/users/test.ts @@ -248,12 +248,9 @@ test("/admin/users 自定义封禁时长会展示到期时间输入框", async ( await expect(reasonInput).toBeVisible(); await reasonInput.fill(reason); - let suspendButton = dialog + const suspendButton = dialog .getByRole("button", { name: /封禁|Suspend|Ban/i }) .first(); - if ((await suspendButton.count()) === 0) { - suspendButton = dialog.locator('button[class*="bg-destructive"]').first(); - } await expect(suspendButton).toBeVisible(); await captureStepScreenshot(page, testInfo, "admin-users-suspended-custom"); } finally { @@ -283,12 +280,9 @@ test("/admin/users 可创建默认时长封禁并通过 API 解除", async ({ await expect(reasonInput).toBeVisible(); await reasonInput.fill(reason); - let suspendButton = dialog + const suspendButton = dialog .getByRole("button", { name: /封禁|Suspend|Ban/i }) .first(); - if ((await suspendButton.count()) === 0) { - suspendButton = dialog.locator('button[class*="bg-destructive"]').first(); - } await expect(suspendButton).toBeVisible(); const responsePromise = page.waitForResponse( diff --git a/tests/e2e/src/app/courses/test.ts b/tests/e2e/src/app/courses/test.ts index c22f3a427..275cb6b1b 100644 --- a/tests/e2e/src/app/courses/test.ts +++ b/tests/e2e/src/app/courses/test.ts @@ -59,6 +59,19 @@ test.describe("/catalog/courses 课程目录", () => { expect(html).toContain(DEV_SEED.course.code); }); + test("无匹配课程时显示明确空状态且不渲染结果链接", async ({ page }) => { + await gotoAndWaitForReady( + page, + "/catalog/courses?search=e2e-no-matching-course-7f3c9a", + ); + + await expect(page.getByText(/未找到课程|No courses found/i)).toBeVisible(); + await expect( + page.locator("#main-content a[href^='/catalog/courses/']"), + ).toHaveCount(0); + await expect(page.getByRole("link", { name: /清除|Clear/i })).toBeVisible(); + }); + test("目录链接悬停时不预取 __data.json", async ({ page }) => { await gotoAndWaitForReady( page, diff --git a/tests/e2e/src/app/sections/test.ts b/tests/e2e/src/app/sections/test.ts index 5d602fdfb..e0b27e856 100644 --- a/tests/e2e/src/app/sections/test.ts +++ b/tests/e2e/src/app/sections/test.ts @@ -48,6 +48,19 @@ test.describe("/catalog/sections 班级搜索页", () => { }); }); + test("无匹配班级时显示明确空状态且不渲染结果链接", async ({ page }) => { + await gotoAndWaitForReady( + page, + "/catalog/sections?search=e2e-no-matching-section-7f3c9a", + ); + + await expect(page.getByText(/未找到班级|No sections found/i)).toBeVisible(); + await expect( + page.locator("#main-content a[href^='/catalog/sections/']"), + ).toHaveCount(0); + await expect(page.getByRole("link", { name: /清除|Clear/i })).toBeVisible(); + }); + test("SSR 输出包含搜索查询", async ({ baseURL }) => { const response = await fetch( absoluteTestUrl( @@ -559,12 +572,7 @@ test.describe("/catalog/sections 班级搜索页", () => { test("学期筛选保留种子数据结果", async ({ page }, testInfo) => { const filter = await getSeedSectionSemesterFixture(DEV_SEED.section.jwId); if (!filter.semesterName) { - await gotoAndWaitForReady(page, "/catalog/sections", { - testInfo, - screenshotLabel: "sections", - }); - await expect(page.locator("#main-content")).toBeVisible(); - return; + throw new Error("Expected the seeded section to have a semester fixture"); } await gotoAndWaitForReady( diff --git a/tests/e2e/src/app/settings/accounts/test.ts b/tests/e2e/src/app/settings/accounts/test.ts index 2bafcd88c..31f2b637c 100644 --- a/tests/e2e/src/app/settings/accounts/test.ts +++ b/tests/e2e/src/app/settings/accounts/test.ts @@ -33,7 +33,11 @@ import { ensureLinkedAccountFixture, getCurrentSessionUser, } from "../../../../utils/e2e-db"; -import { waitForUiSettled } from "../../../../utils/page-ready"; +import { withE2ePrisma } from "../../../../utils/e2e-db/prisma"; +import { + gotoAndWaitForReady, + waitForUiSettled, +} from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; import { assertPageContract } from "../../_shared/page-contract"; @@ -101,24 +105,54 @@ test.describe("/account/settings/accounts 关联账号设置", () => { test("仅关联一个账号时断开连接被禁用", async ({ page }, testInfo) => { await signInAsDebugUser(page, "/account/settings/accounts"); + const user = await getCurrentSessionUser(page); + const original = await withE2ePrisma(async (prisma) => ({ + accounts: await prisma.account.findMany({ where: { userId: user.id } }), + verifiedEmails: await prisma.verifiedEmail.findMany({ + where: { userId: user.id }, + }), + })); + + await withE2ePrisma(async (prisma) => { + await prisma.account.deleteMany({ where: { userId: user.id } }); + await prisma.verifiedEmail.deleteMany({ where: { userId: user.id } }); + }); + await ensureLinkedAccountFixture({ userId: user.id, provider: "oidc" }); - const disconnectButton = page - .getByRole("button", { name: /断开连接|Disconnect/i }) - .first(); - if ((await disconnectButton.count()) === 0) { - await expect(page.locator("#main-content")).toBeVisible(); - return; - } + try { + await gotoAndWaitForReady(page, "/account/settings/accounts"); + const providerCard = page + .locator("#main-content .rounded-lg.border") + .filter({ has: page.getByText("USTC", { exact: true }) }) + .first(); + const disconnectButton = providerCard.getByRole("button", { + name: /断开连接|Disconnect/i, + }); - await expect(disconnectButton).toBeDisabled(); - await expect( - page.getByText(/不能断开唯一关联的账户|cannot disconnect/i).first(), - ).toBeVisible(); - await captureStepScreenshot( - page, - testInfo, - "settings-accounts-disconnect-disabled", - ); + await expect(disconnectButton).toBeVisible(); + await expect(disconnectButton).toBeDisabled(); + await expect( + providerCard.getByText(/不能断开唯一关联的账户|cannot disconnect/i), + ).toBeVisible(); + await captureStepScreenshot( + page, + testInfo, + "settings-accounts-disconnect-disabled", + ); + } finally { + await withE2ePrisma(async (prisma) => { + await prisma.account.deleteMany({ where: { userId: user.id } }); + await prisma.verifiedEmail.deleteMany({ where: { userId: user.id } }); + if (original.accounts.length > 0) { + await prisma.account.createMany({ data: original.accounts }); + } + if (original.verifiedEmails.length > 0) { + await prisma.verifiedEmail.createMany({ + data: original.verifiedEmails, + }); + } + }); + } }); test("多账号:取消与确认解绑流程", async ({ page }, testInfo) => { diff --git a/tests/e2e/src/app/teachers/test.ts b/tests/e2e/src/app/teachers/test.ts index 5912130e8..ddbb1b639 100644 --- a/tests/e2e/src/app/teachers/test.ts +++ b/tests/e2e/src/app/teachers/test.ts @@ -55,6 +55,19 @@ test.describe("/catalog/teachers", () => { expect(html).toContain(DEV_SEED.teacher.nameCn); }); + test("无匹配教师时显示明确空状态且不渲染结果链接", async ({ page }) => { + await gotoAndWaitForReady( + page, + "/catalog/teachers?search=e2e-no-matching-teacher-7f3c9a", + ); + + await expect(page.getByText(/未找到教师|No teachers found/i)).toBeVisible(); + await expect( + page.locator("#main-content a[href^='/catalog/teachers/']"), + ).toHaveCount(0); + await expect(page.getByRole("link", { name: /清除|Clear/i })).toBeVisible(); + }); + test("移动端卡片可点击并导航到详情", async ({ page }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await gotoAndWaitForReady( @@ -131,9 +144,9 @@ test.describe("/catalog/teachers", () => { test("院系筛选保留教师结果", async ({ page }, testInfo) => { const filter = await getSeedTeacherDepartmentFixture(DEV_SEED.teacher.jwId); if (!filter.departmentName) { - await gotoAndWaitForReady(page, "/catalog/teachers"); - await expect(page.locator("#main-content")).toBeVisible(); - return; + throw new Error( + "Expected the seeded teacher to have a department fixture", + ); } await gotoAndWaitForReady(page, "/catalog/teachers", { diff --git a/tests/e2e/utils/page-ready.ts b/tests/e2e/utils/page-ready.ts index b0ded8eb0..82036e0f2 100644 --- a/tests/e2e/utils/page-ready.ts +++ b/tests/e2e/utils/page-ready.ts @@ -6,6 +6,7 @@ import { observeBrowserHealth, unexpectedBrowserIssues, } from "./browser-health"; +import { expectRenderedUiQuality, type UiQualityAllowlist } from "./ui-quality"; type GotoOptions = { expectMainContent?: boolean; @@ -18,6 +19,8 @@ type GotoOptions = { expectMeaningfulContent?: boolean; /** Assert the document itself does not scroll horizontally. */ expectNoHorizontalOverflow?: boolean; + /** Assert shared document, asset, link, heading, and control integrity. */ + uiQuality?: false | UiQualityAllowlist; }; const GOTO_RETRY_ATTEMPTS = 3; @@ -103,6 +106,7 @@ export async function gotoAndWaitForReady( expectMainContent = true, expectMeaningfulContent = false, expectNoHorizontalOverflow = false, + uiQuality = false, waitUntil, } = options; const healthObserver = @@ -149,6 +153,10 @@ export async function gotoAndWaitForReady( await expectNoPageHorizontalOverflow(page); } + if (uiQuality !== false) { + await expectRenderedUiQuality(page, uiQuality); + } + return response; } finally { healthObserver?.stop(); diff --git a/tests/e2e/utils/ui-quality.ts b/tests/e2e/utils/ui-quality.ts new file mode 100644 index 000000000..475ce5e42 --- /dev/null +++ b/tests/e2e/utils/ui-quality.ts @@ -0,0 +1,261 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, type Page } from "@playwright/test"; + +export type UiQualityIssueKind = + | "accessibility" + | "broken-image" + | "duplicate-id" + | "empty-heading" + | "invalid-link" + | "structure"; + +export type UiQualityIssue = { + kind: UiQualityIssueKind; + element: string; + detail: string; +}; + +export type UiQualityException = { + match: RegExp; + reason: string; +}; + +export type UiQualityAllowlist = Partial< + Record +>; + +export type UiQualityReport = { + documentLang: string; + title: string; + issues: UiQualityIssue[]; +}; + +export function uiQualityIssueIsAllowed( + issue: UiQualityIssue, + allowlist: UiQualityAllowlist = {}, +) { + const description = `${issue.element}: ${issue.detail}`; + return (allowlist[issue.kind] ?? []).some((exception) => { + exception.match.lastIndex = 0; + return ( + exception.reason.trim().length > 0 && exception.match.test(description) + ); + }); +} + +export function unexpectedUiQualityIssues( + issues: readonly UiQualityIssue[], + allowlist: UiQualityAllowlist = {}, +) { + return issues.filter((issue) => !uiQualityIssueIsAllowed(issue, allowlist)); +} + +export async function auditRenderedUi(page: Page): Promise { + return page.evaluate(() => { + type BrowserIssue = { + kind: + | "broken-image" + | "duplicate-id" + | "empty-heading" + | "invalid-link" + | "structure"; + element: string; + detail: string; + }; + + const issues: BrowserIssue[] = []; + + const describeElement = (element: Element) => { + const id = element.getAttribute("id"); + if (id) return `${element.tagName.toLowerCase()}#${id}`; + const testId = element.getAttribute("data-testid"); + if (testId) { + return `${element.tagName.toLowerCase()}[data-testid="${testId}"]`; + } + const name = element.getAttribute("name"); + if (name) return `${element.tagName.toLowerCase()}[name="${name}"]`; + return element.tagName.toLowerCase(); + }; + + const isVisible = (element: Element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" && + rect.width > 0 && + rect.height > 0 + ); + }; + + const elementName = (element: Element) => { + const ariaLabel = element.getAttribute("aria-label")?.trim(); + if (ariaLabel) return ariaLabel; + + const text = element.textContent?.trim(); + if (text) return text; + + const title = element.getAttribute("title")?.trim(); + return title ?? ""; + }; + + const visibleMainLandmarks = Array.from( + document.querySelectorAll("main"), + ).filter(isVisible); + if (visibleMainLandmarks.length !== 1) { + issues.push({ + kind: "structure", + element: "main", + detail: `expected exactly one visible main landmark, found ${visibleMainLandmarks.length}`, + }); + } + + const mainContent = document.querySelectorAll("#main-content"); + if (mainContent.length !== 1 || !isVisible(mainContent[0])) { + issues.push({ + kind: "structure", + element: "#main-content", + detail: `expected exactly one visible main content target, found ${mainContent.length}`, + }); + } + + const visibleLevelOneHeadings = Array.from( + document.querySelectorAll( + 'h1:not([aria-level]), [role="heading"][aria-level="1"], h1[aria-level="1"]', + ), + ).filter(isVisible); + if (visibleLevelOneHeadings.length < 1) { + issues.push({ + kind: "structure", + element: "h1", + detail: "expected at least one visible level-one heading", + }); + } + + const ids = new Map(); + for (const element of document.querySelectorAll("[id]")) { + const id = element.id.trim(); + if (!id) continue; + const matches = ids.get(id) ?? []; + matches.push(element); + ids.set(id, matches); + } + for (const [id, matches] of ids) { + if (matches.length <= 1) continue; + issues.push({ + kind: "duplicate-id", + element: `#${id}`, + detail: `${matches.length} elements use the same id`, + }); + } + + for (const image of document.querySelectorAll("img")) { + if (!isVisible(image)) continue; + if (!image.complete) { + issues.push({ + kind: "broken-image", + element: describeElement(image), + detail: `visible image did not finish loading: ${image.currentSrc || image.src}`, + }); + continue; + } + if (image.naturalWidth === 0) { + issues.push({ + kind: "broken-image", + element: describeElement(image), + detail: `visible image has no decoded pixels: ${image.currentSrc || image.src}`, + }); + } + } + + for (const link of document.querySelectorAll("a")) { + if (!isVisible(link)) continue; + const href = link.getAttribute("href")?.trim() ?? ""; + if ( + (!href && link.getAttribute("role") !== "button") || + /^javascript:/i.test(href) + ) { + issues.push({ + kind: "invalid-link", + element: describeElement(link), + detail: href ? `unsafe href ${href}` : "visible link has no href", + }); + } + } + + for (const heading of document.querySelectorAll("h1, h2, h3, h4, h5, h6")) { + if (!isVisible(heading) || elementName(heading)) continue; + issues.push({ + kind: "empty-heading", + element: describeElement(heading), + detail: "visible heading has no accessible text", + }); + } + + return { + documentLang: document.documentElement.lang, + title: document.title, + issues, + }; + }); +} + +export async function auditAccessibility( + page: Page, +): Promise { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + // Contrast, link-color, and target-size fixes intentionally change the + // visual system. Keep this PR's no-visual-change contract structural and + // track those visual accessibility checks separately. + .disableRules(["color-contrast", "link-in-text-block", "target-size"]) + .analyze(); + + return results.violations + .filter( + (violation) => + violation.impact === "critical" || violation.impact === "serious", + ) + .flatMap((violation) => + violation.nodes.map((node) => ({ + kind: "accessibility" as const, + element: node.target.join(" "), + detail: `${violation.id} (${violation.impact}): ${violation.help}; ${node.html.replace(/\s+/g, " ").slice(0, 240)}`, + })), + ); +} + +export async function expectRenderedUiQuality( + page: Page, + allowlist: UiQualityAllowlist = {}, +) { + const report = await auditRenderedUi(page); + expect( + report.title.trim(), + "Expected the page to have a non-empty title", + ).not.toBe(""); + expect( + report.documentLang, + "Expected the document language to be zh-cn or en-us", + ).toMatch(/^(?:zh-cn|en-us)$/i); + + await expect + .poll( + async () => { + const current = await auditRenderedUi(page); + return unexpectedUiQualityIssues(current.issues, allowlist); + }, + { + message: + "Expected visible UI elements to have valid ids, assets, links, headings, and accessible names", + timeout: 5_000, + }, + ) + .toEqual([]); + + expect( + unexpectedUiQualityIssues(await auditAccessibility(page), allowlist), + "Expected no serious or critical WCAG A/AA violations", + ).toEqual([]); +} diff --git a/tests/unit/page-inventory.test.ts b/tests/unit/page-inventory.test.ts index 9e952cb4d..dedb768cf 100644 --- a/tests/unit/page-inventory.test.ts +++ b/tests/unit/page-inventory.test.ts @@ -6,8 +6,10 @@ import { describe, expect, it } from "vitest"; import { workspaceTabIds } from "@/features/dashboard/lib/dashboard-nav"; import { SETTINGS_TABS } from "@/features/settings/lib/settings-tabs"; import { + BROWSER_ALIAS_INVENTORY, INVENTORY_SETTINGS_TABS, INVENTORY_WORKSPACE_TABS, + mobileScreenshotPaths, PAGE_INVENTORY, type PageInventoryEntry, routeIdFromPageFile, @@ -118,6 +120,84 @@ describe("page inventory gate", () => { expect(duplicates).toEqual([]); }); + it("keeps non-page browser aliases attached to executable redirect tests", async () => { + for (const alias of BROWSER_ALIAS_INVENTORY) { + const absolute = resolveSpecPath(alias.e2eSpec); + expect(existsSync(absolute), `missing alias spec: ${alias.e2eSpec}`).toBe( + true, + ); + const source = await readFile(absolute, "utf8"); + expect(source).toContain(alias.path); + expect(source).toContain(alias.target.split("/tag/")[0]); + expect(source).toContain(alias.testName); + } + }); + + it("requires one explicit mobile contract for every rendered page", async () => { + const pageEntries = PAGE_INVENTORY.filter((entry) => entry.kind === "page"); + + for (const entry of pageEntries) { + const inventoryGroups = entry.mobileScreenshots ?? []; + const hasInventoryCoverage = inventoryGroups.length > 0; + const hasDedicatedCoverage = entry.mobileCoveredBy !== undefined; + + expect( + Number(hasInventoryCoverage) + Number(hasDedicatedCoverage), + `${entry.routeId} needs exactly one inventory-driven or dedicated mobile contract`, + ).toBe(1); + expect( + new Set(inventoryGroups).size, + `${entry.routeId} repeats a mobile screenshot group`, + ).toBe(inventoryGroups.length); + + const dedicated = entry.mobileCoveredBy; + if (!dedicated) continue; + + expect( + dedicated.reason.trim(), + `${entry.routeId} mobile coverage needs a reason`, + ).not.toBe(""); + expect( + dedicated.testName.trim(), + `${entry.routeId} mobile coverage needs a test name`, + ).not.toBe(""); + const absolute = resolveSpecPath(dedicated.e2eSpec); + expect( + existsSync(absolute), + `${entry.routeId} missing mobile spec: ${dedicated.e2eSpec}`, + ).toBe(true); + const source = await readFile(absolute, "utf8"); + expect( + source.includes(dedicated.testName), + `${entry.routeId} mobile test ${dedicated.testName} is absent from ${dedicated.e2eSpec}`, + ).toBe(true); + expect( + dedicated.e2eSpec.startsWith("mobile-screenshots/") || + source.includes("setViewportSize"), + `${entry.routeId} dedicated mobile test does not configure a mobile viewport`, + ).toBe(true); + } + + for (const group of ["public", "authed", "admin"] as const) { + const inventoryPaths = PAGE_INVENTORY.filter((entry) => + entry.mobileScreenshots?.includes(group), + ).map((entry) => entry.samplePath); + const paths = mobileScreenshotPaths(group); + expect(new Set(paths).size).toBe(paths.length); + for (const inventoryPath of inventoryPaths) { + expect(paths).toContain(inventoryPath); + } + } + + const authedPaths = mobileScreenshotPaths("authed"); + for (const tab of workspaceTabIds) { + expect(authedPaths).toContain(`/workspace/${tab}`); + } + for (const tab of SETTINGS_TABS) { + expect(authedPaths).toContain(`/account/settings/${tab}`); + } + }); + it("requires L1 contract wiring and L2 primary-action coverage", async () => { const contractSource = await readFile(pageContractPath, "utf8"); const cases = new Set( @@ -182,7 +262,21 @@ describe("page inventory gate", () => { if (!action.e2eSpec) { continue; } - await loadSpec(action.e2eSpec); + const actionSource = await loadSpec(action.e2eSpec); + const roleIsExercised = action.role + ? actionSource.includes(`getByRole("${action.role}"`) || + actionSource.includes(`getByRole('${action.role}'`) + : false; + const testIdIsExercised = action.testId + ? actionSource.includes(action.testId) + : false; + const evidenceIsPresent = action.evidence + ? actionSource.includes(action.evidence) + : false; + expect( + roleIsExercised || testIdIsExercised || evidenceIsPresent, + `${entry.routeId} primaryAction ${action.id} is not tied to a locator or evidence in ${action.e2eSpec}`, + ).toBe(true); } } }); diff --git a/tests/unit/ui-quality.test.ts b/tests/unit/ui-quality.test.ts new file mode 100644 index 000000000..76e6e0d4b --- /dev/null +++ b/tests/unit/ui-quality.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + type UiQualityIssue, + uiQualityIssueIsAllowed, + unexpectedUiQualityIssues, +} from "../e2e/utils/ui-quality"; + +const issues: UiQualityIssue[] = [ + { + kind: "broken-image", + element: "img#hero", + detail: "visible image has no decoded pixels: /hero.png", + }, + { + kind: "invalid-link", + element: "a[data-testid=menu]", + detail: "visible link has no href", + }, +]; + +describe("UI quality allowlists", () => { + it("matches only the issue kind and description explicitly allowed", () => { + expect( + uiQualityIssueIsAllowed(issues[0], { + "broken-image": [ + { + match: /img#hero: .*hero\.png/, + reason: "The fixture intentionally uses a missing image.", + }, + ], + }), + ).toBe(true); + expect( + uiQualityIssueIsAllowed(issues[1], { + "broken-image": [ + { match: /menu/, reason: "This does not match the issue kind." }, + ], + }), + ).toBe(false); + }); + + it("keeps unmatched issues visible to the page contract", () => { + expect( + unexpectedUiQualityIssues(issues, { + "broken-image": [ + { + match: /hero\.png/, + reason: "The fixture intentionally uses a missing image.", + }, + ], + }), + ).toEqual([issues[1]]); + }); + + it("resets stateful regular expressions before each match", () => { + const pattern = /hero/g; + expect( + uiQualityIssueIsAllowed(issues[0], { + "broken-image": [{ match: pattern, reason: "Stateful regex fixture." }], + }), + ).toBe(true); + expect( + uiQualityIssueIsAllowed(issues[0], { + "broken-image": [{ match: pattern, reason: "Stateful regex fixture." }], + }), + ).toBe(true); + }); + + it("rejects an exception without a reason", () => { + expect( + uiQualityIssueIsAllowed(issues[0], { + "broken-image": [{ match: /hero/, reason: "" }], + }), + ).toBe(false); + }); +});