Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/features/admin/components/AdminOAuthClients.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export let scopeLabel: (scope: string) => string;
</div>

<div class="xl:hidden">
<Item.Group>
<Item.Group role="list">
{#each clients as client (client.clientId)}
<Item.Root role="listitem" variant="outline" class="items-start">
<Item.Content class="min-w-0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export let stats: ProfileStat[];
<span
aria-label={item.label}
class={cn("size-3 rounded-[2px]", item.className)}
role="img"
title={item.label}
></span>
{/each}
Expand Down
2 changes: 1 addition & 1 deletion src/features/settings/components/SettingsAccountRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export let unlinkAccountId: string | null;
export let user: SettingsUser;
</script>

<Item.Root variant="outline">
<Item.Root role="listitem" variant="outline">
<Item.Content class="min-w-0">
<Item.Title>{account.name}</Item.Title>
<Item.Description class="truncate">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export let user: SettingsUser;
class="grid gap-4"
role="region"
>
<Item.Group>
<Item.Group role="list">
{#each accounts as account}
<SettingsAccountRow
{account}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function revokeAction(consentId: string): SubmitFunction {
</Empty.Header>
</Empty.Root>
{:else}
<Item.Group>
<Item.Group role="list">
{#each authorizations as authorization}
<Item.Root role="listitem" variant="outline">
<Item.Content class="min-w-0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ $: identities = account.ustcIdentities?.records ?? [];
</script>

{#if account.id === "oidc" && account.linked}
<Item.Root class="col-span-full" variant="muted">
<Item.Root class="col-span-full" role="listitem" variant="muted">
<Item.Content class="gap-3">
<div>
<Item.Title>{identityCopy.ustcIdentityTitle}</Item.Title>
Expand Down
2 changes: 1 addition & 1 deletion src/features/welcome/components/WelcomeProfileForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ $: avatarFallback = (user.name ?? user.username ?? "U")
<Card.Root>
<Card.Header class="items-center text-center">
<Badge class="w-fit" variant="secondary">{welcomeCopy.firstSignIn}</Badge>
<Card.Title>{welcomeCopy.title}</Card.Title>
<Card.Title aria-level={1} role="heading">{welcomeCopy.title}</Card.Title>
<Card.Description>{welcomeCopy.description}</Card.Description>
</Card.Header>

Expand Down
6 changes: 5 additions & 1 deletion src/lib/components/shell/AppShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ afterNavigate(({ from, to }) => {
<Sidebar.Inset
aria-label={mainContentLabel}
id="main-content"
tabindex={-1}
tabindex={0}
class="relative flex w-full min-w-0 flex-1 flex-col lg:min-h-0 lg:overflow-hidden"
>
<AppTopbar
Expand All @@ -742,9 +742,13 @@ afterNavigate(({ from, to }) => {
{viewerLoading}
/>

<!-- svelte-ignore a11y_no_noninteractive_tabindex -- the desktop content region is the keyboard-scrollable viewport -->
<div
bind:this={contentScrollContainer}
aria-label={mainContentLabel}
data-shell-scroll-container
role="region"
tabindex="0"
class={cn(
"flex min-w-0 flex-1 flex-col",
detailWorkspace
Expand Down
1 change: 0 additions & 1 deletion src/lib/components/ui/item/item-group.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

<div
bind:this={ref}
role="list"
data-slot="item-group"
class={cn("gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2 group/item-group flex w-full flex-col", className)}
{...restProps}
Expand Down
1 change: 1 addition & 0 deletions src/lib/components/ui/scroll-area/scroll-area.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<ScrollAreaPrimitive.Viewport
bind:ref={viewportRef}
data-slot="scroll-area-viewport"
tabindex={0}
class="cn-scroll-area-viewport focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{@render children?.()}
Expand Down
4 changes: 2 additions & 2 deletions src/routes/e2e/oauth/callback/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ $: callbackJson = JSON.stringify(callbackPayload, null, 2);

<svelte:head><title>OAuth E2E Callback - Life@USTC</title></svelte:head>

<main class="grid gap-4">
<div class="grid gap-4">
<Card.Root>
<Card.Header>
<Card.Title>
Expand All @@ -24,4 +24,4 @@ $: callbackJson = JSON.stringify(callbackPayload, null, 2);
<pre class="overflow-auto rounded-md border bg-muted/30 p-4 text-sm">{callbackJson}</pre>
</Card.Content>
</Card.Root>
</main>
</div>
4 changes: 2 additions & 2 deletions src/routes/error/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ $: message =

<svelte:head><title>{copy.errorPageTitle} - Life@USTC</title></svelte:head>

<main class="grid min-h-[calc(100vh-8rem)] place-items-center px-4">
<div class="grid min-h-[calc(100vh-8rem)] place-items-center px-4">
<Card.Root class="w-full max-w-md">
<Card.Header class="text-center">
<Card.Title>
Expand All @@ -28,4 +28,4 @@ $: message =
<Button href="/" variant="outline">{copy.returnHome}</Button>
</Card.Content>
</Card.Root>
</main>
</div>
49 changes: 36 additions & 13 deletions tests/e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<spec>`.
- 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.
3 changes: 3 additions & 0 deletions tests/e2e/mobile-screenshots/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function healthyMobileRoute(name: string, path: string) {
browserHealth: {},
expectMeaningfulContent: true,
expectNoHorizontalOverflow: true,
uiQuality: {},
});

expect(
Expand Down Expand Up @@ -62,6 +63,7 @@ test.describe("移动端页面健全性", () => {
browserHealth: {},
expectMeaningfulContent: true,
expectNoHorizontalOverflow: true,
uiQuality: {},
});
});

Expand All @@ -81,6 +83,7 @@ test.describe("移动端页面健全性", () => {
browserHealth: {},
expectMeaningfulContent: true,
expectNoHorizontalOverflow: true,
uiQuality: {},
});
await expect(page).toHaveURL(/\/account\/welcome(?:\?.*)?$/);
await expect(
Expand Down
38 changes: 37 additions & 1 deletion tests/e2e/src/app/_shared/page-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" ||
Expand Down Expand Up @@ -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;
Expand Down
Loading