Loading panel/e2e/a11y.spec.ts 0 → 100644 +107 −0 Changes for panel/e2e/a11y.spec.ts: 107 added lines, 0 removed lines. Original line number Diff line number Diff line import type { Page } from "@playwright/test"; import { test, expect, t } from "./fixtures"; // Every control a screen reader can land on must say what it does. The sweep // reads each page's accessibility tree (what assistive tech gets) and fails on // a button, link, field or toggle whose accessible name is empty, which is // what an icon-only button without aria-label turns into. const CONTROL_ROLES = ["button", "link", "combobox", "checkbox", "switch", "textbox", "menuitem", "tab", "slider"]; // A named control reads `- button "Save"`; an unnamed one has nothing quoted // after the role, only [state] flags and maybe its value (`- combobox: Presets`). const UNNAMED = new RegExp(`^\\s*- (${CONTROL_ROLES.join("|")})(?: \\[[^\\]]*\\])*(?::.*)?$`); async function unnamedControls(page: Page): Promise<string[]> { const tree = await page.locator("body").ariaSnapshot(); const lines = tree.split("\n"); // Each offender is reported with the lines under it, so the failure shows // which icon or field it was. return lines.flatMap((line, i) => (UNNAMED.test(line) ? [lines.slice(i, i + 3).join("\n")] : [])); } for (const [account, path] of [ ["linked", "/"], ["linked", "/servers"], ["linked", "/servers/lobby"], ["linked", "/servers/lobby/players"], ["linked", "/servers/lobby/backups"], ["linked", "/servers/lobby/files"], ["linked", "/servers/lobby/luckperms"], ["linked", "/submissions"], ["linked", "/account"], ["owner", "/"], ["owner", "/servers"], ["owner", "/admin/images"], ["owner", "/admin/builds"], ["owner", "/admin/submissions"], ["owner", "/admin/updates"], ["owner", "/admin/users"], ["owner", "/account"], ] as const) { test(`every control on ${path} has a name for ${account}`, async ({ page, signIn }) => { await signIn(account); await page.goto(path); await page.waitForLoadState("networkidle"); expect(await unnamedControls(page)).toEqual([]); }); } // Dialogs only exist once opened, so each is opened and swept on its own. for (const [account, path, opener] of [ ["owner", "/servers", "servers:create_server_btn"], ["owner", "/servers/lobby", "servers:edit_server_title"], ["owner", "/admin/users", "admin:users_create_btn"], ["owner", "/admin/images", "admin:add_image_btn"], ["owner", "/admin/builds", "admin:trigger_build_title"], ["linked", "/submissions", "submissions:submit_card_title"], ] as const) { test(`every control in the ${opener} dialog has a name`, async ({ page, signIn }) => { await signIn(account); await page.goto(path); await page.getByRole("button", { name: t(opener) }).first().click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.waitForLoadState("networkidle"); expect(await unnamedControls(page)).toEqual([]); }); } test("every control on a user's detail page has a name", async ({ page, signIn }) => { await signIn("owner"); await page.goto("/admin/users"); await page.getByRole("link", { name: "linked", exact: true }).click(); await expect(page.getByRole("heading", { name: "linked", exact: true })).toBeVisible(); await page.waitForLoadState("networkidle"); expect(await unnamedControls(page)).toEqual([]); }); test("every control on a player's LuckPerms groups and nodes has a name", async ({ page, signIn }) => { await signIn("owner"); await page.goto("/servers/survival/luckperms"); await page.getByRole("textbox", { name: t("servers:luckperms_player_lookup") }).fill("Steve"); await page.keyboard.press("Enter"); await expect(page.getByRole("cell", { name: "essentials.fly", exact: true })).toBeVisible(); expect(await unnamedControls(page)).toEqual([]); }); test("every control on the sign-in page has a name", async ({ page }) => { await page.goto("/login"); await page.waitForLoadState("networkidle"); expect(await unnamedControls(page)).toEqual([]); }); test("the submission's file picker is reachable from the keyboard", async ({ page, signIn }) => { await signIn("linked"); await page.goto("/submissions"); await page.getByRole("button", { name: t("submissions:submit_card_title") }).first().click(); const picker = page.getByLabel(t("submissions:file_label")); await page.getByLabel(t("submissions:display_name_label")).focus(); for (let i = 0; i < 10 && !(await picker.evaluate((el) => el === document.activeElement)); i++) { await page.keyboard.press("Tab"); } await expect(picker).toBeFocused(); }); panel/playwright.config.ts +1 −1 Changes for panel/playwright.config.ts: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -22,7 +22,7 @@ export default defineConfig({ trace: "retain-on-failure", }, projects: [ { name: "desktop", testMatch: /smoke\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } }, { name: "desktop", testMatch: /(smoke|a11y)\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } }, { name: "mobile", testMatch: /mobile\.spec\.ts/, Loading panel/src/components/AppShell.tsx +6 −2 Changes for panel/src/components/AppShell.tsx: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -117,11 +117,15 @@ function UserStrip() { } function LangToggle() { const { i18n } = useTranslation(); const { t, i18n } = useTranslation("common"); return ( <Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}> <SelectPrimitive.Trigger className={FOOT_ICON_BTN}> <SelectPrimitive.Trigger className={FOOT_ICON_BTN} aria-label={t("change_language")} title={t("change_language")} > <Globe className="h-4 w-4" /> </SelectPrimitive.Trigger> <SelectContent align="start" className="min-w-[6rem]"> Loading panel/src/components/CreateServerDialog.tsx +10 −9 Changes for panel/src/components/CreateServerDialog.tsx: 10 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -24,6 +24,7 @@ import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { hostFor, type RuntimeConfig } from "@/lib/config"; import type { AutostartPolicy, CreateServerRequest } from "@/lib/types"; import { InlineError } from "@/components/MessageLine"; // CreateServerDialog IS the §15 收口 ("structured form, not free YAML"): every // dangerous knob (hostNetwork, hostPath, arbitrary image, raw resource quantities) Loading Loading @@ -154,9 +155,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { </div> <div className="grid gap-2"> <Label>{t("create_server_image")}</Label> <Label htmlFor="create-server-image">{t("create_server_image")}</Label> <Select value={form.image} onValueChange={(v) => set("image", v)}> <SelectTrigger> <SelectTrigger id="create-server-image"> <SelectValue placeholder={ images.loading Loading @@ -179,9 +180,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { <div className="grid grid-cols-2 gap-4"> <div className="grid gap-2"> <Label>{t("create_server_memory")}</Label> <Label htmlFor="create-server-memory">{t("create_server_memory")}</Label> <Select value={form.memory} onValueChange={(v) => set("memory", v)}> <SelectTrigger> <SelectTrigger id="create-server-memory"> <SelectValue /> </SelectTrigger> <SelectContent> Loading @@ -194,9 +195,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { </Select> </div> <div className="grid gap-2"> <Label>{t("create_server_storage")}</Label> <Label htmlFor="create-server-storage">{t("create_server_storage")}</Label> <Select value={form.storage} onValueChange={(v) => set("storage", v)}> <SelectTrigger> <SelectTrigger id="create-server-storage"> <SelectValue /> </SelectTrigger> <SelectContent> Loading @@ -211,12 +212,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { </div> <div className="grid gap-2"> <Label>{t("create_server_policy")}</Label> <Label htmlFor="create-server-policy">{t("create_server_policy")}</Label> <Select value={form.autostartPolicy} onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)} > <SelectTrigger> <SelectTrigger id="create-server-policy"> <SelectValue /> </SelectTrigger> <SelectContent> Loading @@ -229,7 +230,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { </Select> </div> {error && <p className="text-sm text-destructive">{error}</p>} <InlineError message={error} /> </div> <ConfirmFooter Loading panel/src/components/CreateUserDialog.tsx +6 −4 Changes for panel/src/components/CreateUserDialog.tsx: 6 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -95,10 +95,11 @@ export function CreateUserDialog({ onCreated }: Props) { <form onSubmit={handleSubmit} className="space-y-4"> {/* Username */} <div className="space-y-1.5"> <Label className="text-xs font-semibold text-muted-foreground"> <Label htmlFor="create-user-username" className="text-xs font-semibold text-muted-foreground"> {t("users_field_username")} * </Label> <Input id="create-user-username" value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t("users_create_username_placeholder")} Loading @@ -109,10 +110,11 @@ export function CreateUserDialog({ onCreated }: Props) { {/* Email */} <div className="space-y-1.5"> <Label className="text-xs font-semibold text-muted-foreground"> <Label htmlFor="create-user-email" className="text-xs font-semibold text-muted-foreground"> {t("users_field_email")} </Label> <Input id="create-user-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} Loading @@ -123,11 +125,11 @@ export function CreateUserDialog({ onCreated }: Props) { {/* Role */} <div className="space-y-1.5"> <Label className="text-xs font-semibold text-muted-foreground"> <Label htmlFor="create-user-role" className="text-xs font-semibold text-muted-foreground"> {t("users_field_role")} </Label> <Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}> <SelectTrigger className="h-9 text-sm"> <SelectTrigger id="create-user-role" className="h-9 text-sm"> <SelectValue /> </SelectTrigger> <SelectContent> Loading Loading
panel/e2e/a11y.spec.ts 0 → 100644 +107 −0 Changes for panel/e2e/a11y.spec.ts: 107 added lines, 0 removed lines. Original line number Diff line number Diff line import type { Page } from "@playwright/test"; import { test, expect, t } from "./fixtures"; // Every control a screen reader can land on must say what it does. The sweep // reads each page's accessibility tree (what assistive tech gets) and fails on // a button, link, field or toggle whose accessible name is empty, which is // what an icon-only button without aria-label turns into. const CONTROL_ROLES = ["button", "link", "combobox", "checkbox", "switch", "textbox", "menuitem", "tab", "slider"]; // A named control reads `- button "Save"`; an unnamed one has nothing quoted // after the role, only [state] flags and maybe its value (`- combobox: Presets`). const UNNAMED = new RegExp(`^\\s*- (${CONTROL_ROLES.join("|")})(?: \\[[^\\]]*\\])*(?::.*)?$`); async function unnamedControls(page: Page): Promise<string[]> { const tree = await page.locator("body").ariaSnapshot(); const lines = tree.split("\n"); // Each offender is reported with the lines under it, so the failure shows // which icon or field it was. return lines.flatMap((line, i) => (UNNAMED.test(line) ? [lines.slice(i, i + 3).join("\n")] : [])); } for (const [account, path] of [ ["linked", "/"], ["linked", "/servers"], ["linked", "/servers/lobby"], ["linked", "/servers/lobby/players"], ["linked", "/servers/lobby/backups"], ["linked", "/servers/lobby/files"], ["linked", "/servers/lobby/luckperms"], ["linked", "/submissions"], ["linked", "/account"], ["owner", "/"], ["owner", "/servers"], ["owner", "/admin/images"], ["owner", "/admin/builds"], ["owner", "/admin/submissions"], ["owner", "/admin/updates"], ["owner", "/admin/users"], ["owner", "/account"], ] as const) { test(`every control on ${path} has a name for ${account}`, async ({ page, signIn }) => { await signIn(account); await page.goto(path); await page.waitForLoadState("networkidle"); expect(await unnamedControls(page)).toEqual([]); }); } // Dialogs only exist once opened, so each is opened and swept on its own. for (const [account, path, opener] of [ ["owner", "/servers", "servers:create_server_btn"], ["owner", "/servers/lobby", "servers:edit_server_title"], ["owner", "/admin/users", "admin:users_create_btn"], ["owner", "/admin/images", "admin:add_image_btn"], ["owner", "/admin/builds", "admin:trigger_build_title"], ["linked", "/submissions", "submissions:submit_card_title"], ] as const) { test(`every control in the ${opener} dialog has a name`, async ({ page, signIn }) => { await signIn(account); await page.goto(path); await page.getByRole("button", { name: t(opener) }).first().click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.waitForLoadState("networkidle"); expect(await unnamedControls(page)).toEqual([]); }); } test("every control on a user's detail page has a name", async ({ page, signIn }) => { await signIn("owner"); await page.goto("/admin/users"); await page.getByRole("link", { name: "linked", exact: true }).click(); await expect(page.getByRole("heading", { name: "linked", exact: true })).toBeVisible(); await page.waitForLoadState("networkidle"); expect(await unnamedControls(page)).toEqual([]); }); test("every control on a player's LuckPerms groups and nodes has a name", async ({ page, signIn }) => { await signIn("owner"); await page.goto("/servers/survival/luckperms"); await page.getByRole("textbox", { name: t("servers:luckperms_player_lookup") }).fill("Steve"); await page.keyboard.press("Enter"); await expect(page.getByRole("cell", { name: "essentials.fly", exact: true })).toBeVisible(); expect(await unnamedControls(page)).toEqual([]); }); test("every control on the sign-in page has a name", async ({ page }) => { await page.goto("/login"); await page.waitForLoadState("networkidle"); expect(await unnamedControls(page)).toEqual([]); }); test("the submission's file picker is reachable from the keyboard", async ({ page, signIn }) => { await signIn("linked"); await page.goto("/submissions"); await page.getByRole("button", { name: t("submissions:submit_card_title") }).first().click(); const picker = page.getByLabel(t("submissions:file_label")); await page.getByLabel(t("submissions:display_name_label")).focus(); for (let i = 0; i < 10 && !(await picker.evaluate((el) => el === document.activeElement)); i++) { await page.keyboard.press("Tab"); } await expect(picker).toBeFocused(); });
panel/playwright.config.ts +1 −1 Changes for panel/playwright.config.ts: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -22,7 +22,7 @@ export default defineConfig({ trace: "retain-on-failure", }, projects: [ { name: "desktop", testMatch: /smoke\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } }, { name: "desktop", testMatch: /(smoke|a11y)\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } }, { name: "mobile", testMatch: /mobile\.spec\.ts/, Loading
panel/src/components/AppShell.tsx +6 −2 Changes for panel/src/components/AppShell.tsx: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -117,11 +117,15 @@ function UserStrip() { } function LangToggle() { const { i18n } = useTranslation(); const { t, i18n } = useTranslation("common"); return ( <Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}> <SelectPrimitive.Trigger className={FOOT_ICON_BTN}> <SelectPrimitive.Trigger className={FOOT_ICON_BTN} aria-label={t("change_language")} title={t("change_language")} > <Globe className="h-4 w-4" /> </SelectPrimitive.Trigger> <SelectContent align="start" className="min-w-[6rem]"> Loading
panel/src/components/CreateServerDialog.tsx +10 −9 Changes for panel/src/components/CreateServerDialog.tsx: 10 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -24,6 +24,7 @@ import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { hostFor, type RuntimeConfig } from "@/lib/config"; import type { AutostartPolicy, CreateServerRequest } from "@/lib/types"; import { InlineError } from "@/components/MessageLine"; // CreateServerDialog IS the §15 收口 ("structured form, not free YAML"): every // dangerous knob (hostNetwork, hostPath, arbitrary image, raw resource quantities) Loading Loading @@ -154,9 +155,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { </div> <div className="grid gap-2"> <Label>{t("create_server_image")}</Label> <Label htmlFor="create-server-image">{t("create_server_image")}</Label> <Select value={form.image} onValueChange={(v) => set("image", v)}> <SelectTrigger> <SelectTrigger id="create-server-image"> <SelectValue placeholder={ images.loading Loading @@ -179,9 +180,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { <div className="grid grid-cols-2 gap-4"> <div className="grid gap-2"> <Label>{t("create_server_memory")}</Label> <Label htmlFor="create-server-memory">{t("create_server_memory")}</Label> <Select value={form.memory} onValueChange={(v) => set("memory", v)}> <SelectTrigger> <SelectTrigger id="create-server-memory"> <SelectValue /> </SelectTrigger> <SelectContent> Loading @@ -194,9 +195,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { </Select> </div> <div className="grid gap-2"> <Label>{t("create_server_storage")}</Label> <Label htmlFor="create-server-storage">{t("create_server_storage")}</Label> <Select value={form.storage} onValueChange={(v) => set("storage", v)}> <SelectTrigger> <SelectTrigger id="create-server-storage"> <SelectValue /> </SelectTrigger> <SelectContent> Loading @@ -211,12 +212,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { </div> <div className="grid gap-2"> <Label>{t("create_server_policy")}</Label> <Label htmlFor="create-server-policy">{t("create_server_policy")}</Label> <Select value={form.autostartPolicy} onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)} > <SelectTrigger> <SelectTrigger id="create-server-policy"> <SelectValue /> </SelectTrigger> <SelectContent> Loading @@ -229,7 +230,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { </Select> </div> {error && <p className="text-sm text-destructive">{error}</p>} <InlineError message={error} /> </div> <ConfirmFooter Loading
panel/src/components/CreateUserDialog.tsx +6 −4 Changes for panel/src/components/CreateUserDialog.tsx: 6 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -95,10 +95,11 @@ export function CreateUserDialog({ onCreated }: Props) { <form onSubmit={handleSubmit} className="space-y-4"> {/* Username */} <div className="space-y-1.5"> <Label className="text-xs font-semibold text-muted-foreground"> <Label htmlFor="create-user-username" className="text-xs font-semibold text-muted-foreground"> {t("users_field_username")} * </Label> <Input id="create-user-username" value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t("users_create_username_placeholder")} Loading @@ -109,10 +110,11 @@ export function CreateUserDialog({ onCreated }: Props) { {/* Email */} <div className="space-y-1.5"> <Label className="text-xs font-semibold text-muted-foreground"> <Label htmlFor="create-user-email" className="text-xs font-semibold text-muted-foreground"> {t("users_field_email")} </Label> <Input id="create-user-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} Loading @@ -123,11 +125,11 @@ export function CreateUserDialog({ onCreated }: Props) { {/* Role */} <div className="space-y-1.5"> <Label className="text-xs font-semibold text-muted-foreground"> <Label htmlFor="create-user-role" className="text-xs font-semibold text-muted-foreground"> {t("users_field_role")} </Label> <Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}> <SelectTrigger className="h-9 text-sm"> <SelectTrigger id="create-user-role" className="h-9 text-sm"> <SelectValue /> </SelectTrigger> <SelectContent> Loading