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);
+ });
+});