190 lines
11 KiB
TypeScript
190 lines
11 KiB
TypeScript
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
import type { CanvasState } from "@dada/shared-contracts";
|
|
import axe from "axe-core";
|
|
import { createServer, type ViteDevServer } from "vite";
|
|
|
|
let vite: ViteDevServer;
|
|
let webUrl: string;
|
|
|
|
test.beforeAll(async () => {
|
|
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
|
await vite.listen();
|
|
const address = vite.httpServer?.address();
|
|
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
|
webUrl = `http://127.0.0.1:${address.port}`;
|
|
});
|
|
|
|
test.afterAll(async () => vite.close());
|
|
|
|
const projectId = "00000000-0000-4000-8000-000000000940";
|
|
const firstImageId = "00000000-0000-4000-8000-000000000941";
|
|
const secondImageId = "00000000-0000-4000-8000-000000000942";
|
|
|
|
const session = {
|
|
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
|
csrf_token: "csrf-editor-a11y-000000000000000000000000000000000",
|
|
expires_at: "2026-09-03T08:00:00.000Z",
|
|
user: { creator_name: "最长可访问性测试创作署名不会遮挡主动作", role: "user", social_id: "@a11y_user", status: "active", user_id: "00000000-0000-4000-8000-000000000943" },
|
|
};
|
|
|
|
function initialCanvas(): CanvasState {
|
|
return {
|
|
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: firstImageId },
|
|
elements: [{
|
|
created_at: "2026-08-03T07:00:00.000Z", element_id: "00000000-0000-4000-8000-000000000944", opacity: 1,
|
|
position: { x: 0.5, y: 0.5 }, resource_version: "fixture-v1", rotation: 0, scale: { x: 1, y: 1 },
|
|
style_parameters: { flip_horizontal: false }, template_or_asset_id: "STK001", type: "static_sticker", z_index: 0,
|
|
}],
|
|
pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
|
};
|
|
}
|
|
|
|
interface Backend { canvas: CanvasState; saves: number; version: number }
|
|
|
|
function evidence(file: string, value: unknown) {
|
|
const root = process.env.DADA_EVIDENCE_DIR_A11Y;
|
|
if (!root) return;
|
|
const path = resolve(root, "pages", "editor", file);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
async function routeEditor(page: Page, backend: Backend) {
|
|
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json" }));
|
|
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
|
body: JSON.stringify({
|
|
canvas_state: backend.canvas, created_at: "2026-08-03T07:00:00.000Z", current_image_id: firstImageId,
|
|
images: [firstImageId, secondImageId].map((imageId, index) => ({ created_at: `2026-08-03T0${index + 7}:00:00.000Z`, generation_id: `00000000-0000-4000-8000-00000000095${index}`, image_id: imageId })),
|
|
name: "最长项目名称用于检查百分之二百缩放时主动作仍然完整可达", project_id: projectId, ratio: "3:4", state_version: backend.version,
|
|
}), contentType: "application/json",
|
|
}));
|
|
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
|
backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state;
|
|
backend.saves += 1;
|
|
backend.version += 1;
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 80));
|
|
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json" });
|
|
});
|
|
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => route.fulfill({
|
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1440"><rect width="1080" height="1440" fill="#39d98a"/><circle cx="540" cy="720" r="180" fill="#111111"/></svg>',
|
|
contentType: "image/svg+xml",
|
|
}));
|
|
await page.route("**/api/v1/assets/public/fixture-v1/STK001", (route) => route.fulfill({
|
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><rect width="120" height="120" fill="#f2f400"/></svg>', contentType: "image/svg+xml",
|
|
}));
|
|
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" }));
|
|
}
|
|
|
|
async function activeName(page: Page) {
|
|
return page.evaluate(() => {
|
|
const active = document.activeElement as HTMLElement | null;
|
|
return active?.getAttribute("aria-label") || active?.textContent?.trim() || active?.id || active?.tagName || "";
|
|
});
|
|
}
|
|
|
|
async function resetTabStart(page: Page) {
|
|
await expect(page.getByRole("link", { name: "跳到主要内容" })).toBeAttached();
|
|
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
|
}
|
|
|
|
test("TDD-WP4-A11Y-001 keyboard focus keeps editor actions, dialogs, status, and 200 percent layout reachable", async ({ page }) => {
|
|
const backend: Backend = { canvas: initialCanvas(), saves: 0, version: 6 };
|
|
const focusTrace: Array<{ active: string; step: string }> = [];
|
|
await routeEditor(page, backend);
|
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
await resetTabStart(page);
|
|
|
|
await page.keyboard.press("Tab");
|
|
focusTrace.push({ active: await activeName(page), step: "first-tab" });
|
|
await expect(page.getByRole("link", { name: "跳到主要内容" })).toBeFocused();
|
|
await page.keyboard.press("Enter");
|
|
await expect(page.locator("#editor-main")).toBeFocused();
|
|
focusTrace.push({ active: await activeName(page), step: "skip-activated" });
|
|
|
|
await page.reload();
|
|
await resetTabStart(page);
|
|
for (const step of ["skip", "back", "export", "first-left-tab"]) {
|
|
await page.keyboard.press("Tab");
|
|
focusTrace.push({ active: await activeName(page), step });
|
|
}
|
|
expect(focusTrace.slice(-4).map((item) => item.active)).toEqual(["跳到主要内容", "返回项目", "导出", "底图"]);
|
|
|
|
const historyTab = page.getByRole("button", { name: "历史", exact: true });
|
|
await historyTab.focus(); await page.keyboard.press("Enter");
|
|
const backgroundTrigger = page.locator(".editor-history-list button").last();
|
|
await backgroundTrigger.focus(); await page.keyboard.press("Enter");
|
|
await expect(page.getByRole("dialog", { name: "更换底图" }).getByRole("button", { name: "确认更换" })).toBeFocused();
|
|
focusTrace.push({ active: await activeName(page), step: "background-dialog-initial" });
|
|
await page.keyboard.press("Escape");
|
|
await expect(backgroundTrigger).toBeFocused();
|
|
|
|
const dynamicTab = page.getByRole("button", { name: "动态贴纸", exact: true });
|
|
await dynamicTab.focus(); await page.keyboard.press("Enter");
|
|
const locationTrigger = page.getByRole("button", { name: /添加动态贴纸 DYN004/ });
|
|
await locationTrigger.focus(); await page.keyboard.press("Enter");
|
|
await expect(page.getByRole("dialog", { name: "使用自动定位" }).getByRole("button", { name: "同意并自动定位" })).toBeFocused();
|
|
focusTrace.push({ active: await activeName(page), step: "location-dialog-initial" });
|
|
await page.keyboard.press("Escape");
|
|
await expect(locationTrigger).toBeFocused();
|
|
|
|
const exportTrigger = page.getByRole("button", { name: "导出", exact: true });
|
|
await exportTrigger.focus(); await page.keyboard.press("Enter");
|
|
const exportDialog = page.getByRole("dialog", { name: "导出成品" });
|
|
const jpg = exportDialog.getByRole("button", { name: "JPG", exact: true });
|
|
await expect(jpg).toBeFocused();
|
|
const exportOrder = [await activeName(page)];
|
|
for (let index = 0; index < 5; index += 1) { await page.keyboard.press("Tab"); exportOrder.push(await activeName(page)); }
|
|
expect(exportOrder).toEqual(["JPG", "PNG", "JPG 质量", "JPG 质量数值", "导出并下载", "取消"]);
|
|
await page.keyboard.press("Tab");
|
|
await expect(jpg).toBeFocused();
|
|
await page.keyboard.press("Shift+Tab");
|
|
await expect(exportDialog.getByRole("button", { name: "取消" })).toBeFocused();
|
|
await page.keyboard.press("Escape");
|
|
await expect(exportTrigger).toBeFocused();
|
|
focusTrace.push({ active: await activeName(page), step: "export-dialog-return" });
|
|
|
|
const cycle = page.getByRole("button", { name: "循环选择" });
|
|
await cycle.focus(); await page.keyboard.press("Enter");
|
|
const candidate = page.getByRole("menuitem", { name: /static_sticker.*STK001/ });
|
|
await expect(candidate).toBeFocused();
|
|
await page.keyboard.press("Enter");
|
|
await expect(cycle).toBeFocused();
|
|
const moveRight = page.getByRole("button", { name: "向右移动" });
|
|
await moveRight.focus(); await page.keyboard.press("Enter");
|
|
await expect(moveRight).toBeFocused();
|
|
await expect.poll(() => backend.canvas.elements[0]?.position.x).toBeCloseTo(0.51);
|
|
|
|
await page.getByLabel("编辑画布").press("Escape");
|
|
const apply = page.getByRole("button", { name: "应用调整" });
|
|
await apply.focus(); await page.keyboard.press("Enter");
|
|
await expect(page.locator(".editor-save-status")).toHaveText(/有未保存修改|正在保存|已保存/);
|
|
await expect.poll(() => backend.saves).toBeGreaterThan(1);
|
|
await expect(apply).toBeFocused();
|
|
expect(await page.locator('[tabindex]:not([tabindex="0"]):not([tabindex="-1"])').count()).toBe(0);
|
|
|
|
await page.addScriptTag({ content: axe.source });
|
|
const axeResult = await page.evaluate(async () => {
|
|
const report = await (window as Window & { axe: typeof axe }).axe.run(document, { resultTypes: ["violations"] });
|
|
return { violations: report.violations.map((violation) => ({ id: violation.id, impact: violation.impact, nodes: violation.nodes.map((node) => ({ html: node.html, target: node.target })) })) };
|
|
});
|
|
const blocking = axeResult.violations.filter((violation) => violation.impact === "critical" || violation.impact === "serious");
|
|
expect(blocking).toEqual([]);
|
|
evidence("axe.json", { blocking, ...axeResult });
|
|
|
|
await page.setViewportSize({ width: 640, height: 360 });
|
|
await expect(exportTrigger).toBeVisible();
|
|
const overflow = await page.evaluate(() => ({ client_width: document.documentElement.clientWidth, scroll_width: document.documentElement.scrollWidth }));
|
|
expect(overflow.scroll_width).toBeLessThanOrEqual(overflow.client_width);
|
|
const screenshotRoot = process.env.DADA_EVIDENCE_DIR_A11Y;
|
|
if (screenshotRoot) {
|
|
const screenshot = resolve(screenshotRoot, "pages", "editor", "screenshots", "200pct.png");
|
|
mkdirSync(dirname(screenshot), { recursive: true });
|
|
await page.screenshot({ fullPage: true, path: screenshot });
|
|
}
|
|
focusTrace.push({ active: await activeName(page), step: "200-percent-equivalent-viewport" });
|
|
evidence("focus-trace.json", { focus_trace: focusTrace, modal_export_order: exportOrder, no_positive_tabindex: true, overflow });
|
|
});
|