281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import AxeBuilder from "@axe-core/playwright";
|
|
import { expect, test } from "@playwright/test";
|
|
import type { Locator, Page } from "@playwright/test";
|
|
|
|
const interactiveActionRoles = ["button", "link", "menuitem"] as const;
|
|
|
|
async function loginThroughMockMode(page: Page): Promise<void> {
|
|
await page.goto("/login");
|
|
await page.getByLabel("이메일").fill("admin@test.com");
|
|
await page.getByLabel("비밀번호").fill("password");
|
|
await page.getByRole("button", { name: "로그인" }).click();
|
|
await expect(page).toHaveURL(/\/ai-characters$/);
|
|
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
|
|
}
|
|
|
|
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
|
|
const hasHorizontalOverflow = await page.evaluate(
|
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
|
);
|
|
|
|
expect(hasHorizontalOverflow).toBe(false);
|
|
}
|
|
|
|
async function expectNoCriticalOrSeriousAxeViolations(page: Page): Promise<void> {
|
|
const results = await new AxeBuilder({ page }).analyze();
|
|
const blockingViolations = results.violations.filter(
|
|
(violation) => violation.impact === "critical" || violation.impact === "serious",
|
|
);
|
|
|
|
expect(blockingViolations).toEqual([]);
|
|
}
|
|
|
|
async function expectActionAbsentAcrossInteractiveRoles(page: Page, name: string): Promise<void> {
|
|
for (const role of interactiveActionRoles) {
|
|
await expect(page.getByRole(role, { name })).toBeHidden();
|
|
}
|
|
}
|
|
|
|
async function zoomTo200Percent(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
document.documentElement.style.zoom = "2";
|
|
});
|
|
}
|
|
|
|
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) {
|
|
return;
|
|
}
|
|
|
|
await page.keyboard.press("Tab");
|
|
}
|
|
|
|
await expect(target).toBeFocused();
|
|
}
|
|
|
|
test("desktop and tablet expose the create management action from the list", async ({ page }) => {
|
|
// Given
|
|
await loginThroughMockMode(page);
|
|
|
|
for (const width of [1280, 768]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.goto("/ai-characters");
|
|
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
|
|
|
|
// When / Then
|
|
await expect(page.getByRole("link", { name: "AI 캐릭터 생성" })).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("desktop and tablet expose edit and deactivate management actions on detail", async ({ page }) => {
|
|
// Given
|
|
await loginThroughMockMode(page);
|
|
|
|
for (const width of [1280, 768]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
|
|
// When
|
|
await page.goto("/ai-characters/101");
|
|
|
|
// Then
|
|
await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "수정" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "비활성화" })).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("mobile supports list, search, and detail while hiding detail mutation actions", async ({ page }) => {
|
|
// Given
|
|
await page.setViewportSize({ width: 320, height: 640 });
|
|
await loginThroughMockMode(page);
|
|
|
|
// When
|
|
await expectActionAbsentAcrossInteractiveRoles(page, "AI 캐릭터 생성");
|
|
await page.getByLabel("검색어").fill("루나");
|
|
await expect(page.getByRole("link", { name: "루나 선택" })).toBeVisible();
|
|
await page.getByRole("link", { name: "루나 선택" }).click();
|
|
|
|
// Then
|
|
await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible();
|
|
await expectActionAbsentAcrossInteractiveRoles(page, "수정");
|
|
await expectActionAbsentAcrossInteractiveRoles(page, "비활성화");
|
|
});
|
|
|
|
test("mobile direct create route shows desktop guidance instead of the create form", async ({ page }) => {
|
|
// Given
|
|
await page.setViewportSize({ width: 320, height: 640 });
|
|
await loginThroughMockMode(page);
|
|
|
|
// When
|
|
await page.goto("/ai-characters/new");
|
|
|
|
// Then
|
|
await expect(page.getByText(/데스크톱.*(생성|관리|이용)/)).toBeVisible();
|
|
await expect(page.getByRole("form", { name: "AI 캐릭터 생성 form" })).toBeHidden();
|
|
});
|
|
|
|
test("mobile direct edit route shows desktop guidance instead of the edit form", async ({ page }) => {
|
|
// Given
|
|
await page.setViewportSize({ width: 320, height: 640 });
|
|
await loginThroughMockMode(page);
|
|
|
|
// When
|
|
await page.goto("/ai-characters/101/edit");
|
|
|
|
// Then
|
|
await expect(page.getByText(/데스크톱.*(수정|관리|이용)/)).toBeVisible();
|
|
await expect(page.getByRole("form", { name: "AI 캐릭터 수정 form" })).toBeHidden();
|
|
});
|
|
|
|
test("direct create and edit routes keep accessibility coverage at 320, 768, and 1280px with 200 percent zoom", async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
|
|
// Given
|
|
await loginThroughMockMode(page);
|
|
|
|
for (const [width, route, formName, guidancePattern] of [
|
|
[320, "/ai-characters/new", "AI 캐릭터 생성 form", /데스크톱.*(생성|관리|이용)/],
|
|
[768, "/ai-characters/new", "AI 캐릭터 생성 form", null],
|
|
[1280, "/ai-characters/new", "AI 캐릭터 생성 form", null],
|
|
[320, "/ai-characters/101/edit", "AI 캐릭터 수정 form", /데스크톱.*(수정|관리|이용)/],
|
|
[768, "/ai-characters/101/edit", "AI 캐릭터 수정 form", null],
|
|
[1280, "/ai-characters/101/edit", "AI 캐릭터 수정 form", null],
|
|
] as const) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
|
|
// When
|
|
await page.goto(route);
|
|
|
|
if (guidancePattern) {
|
|
// Then
|
|
await expect(page.getByText(guidancePattern)).toBeVisible();
|
|
await expect(page.getByRole("form", { name: formName })).toBeHidden();
|
|
|
|
if (width === 320) {
|
|
// When
|
|
await zoomTo200Percent(page);
|
|
|
|
// Then
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalOrSeriousAxeViolations(page);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
await expect(page.getByRole("heading", { name: route === "/ai-characters/new" ? "AI 캐릭터 생성" : "AI 캐릭터 수정" })).toBeVisible();
|
|
await expect(page.getByRole("form", { name: formName })).toBeVisible();
|
|
|
|
// When
|
|
await zoomTo200Percent(page);
|
|
|
|
// Then
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalOrSeriousAxeViolations(page);
|
|
}
|
|
});
|
|
|
|
test("mobile cards preserve the selection name and table-visible core metadata", async ({ page }) => {
|
|
// Given
|
|
await page.setViewportSize({ width: 320, height: 640 });
|
|
await loginThroughMockMode(page);
|
|
|
|
// When
|
|
const lunaCard = page.getByRole("link", { name: "루나 선택" });
|
|
|
|
// Then
|
|
await expect(lunaCard).toBeVisible();
|
|
await expect(lunaCard).toContainText("루나");
|
|
await expect(lunaCard).toContainText("차분한 상담형 AI 캐릭터");
|
|
await expect(lunaCard).toContainText("ID 101");
|
|
await expect(lunaCard).toContainText("KR");
|
|
await expect(lunaCard).toContainText("상담, 힐링");
|
|
});
|
|
|
|
test("keyboard-only path reaches the edit form and dirty-leave dialog", async ({ browserName, isMobile, page }) => {
|
|
test.skip(isMobile, "Mobile view intentionally hides Character mutation actions.");
|
|
test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path.");
|
|
|
|
// Given
|
|
await loginThroughMockMode(page);
|
|
const searchInput = page.getByLabel("검색어");
|
|
|
|
// When
|
|
await searchInput.focus();
|
|
await page.keyboard.type("루나");
|
|
await expect(page.getByRole("link", { name: "루나 선택" })).toBeVisible();
|
|
await pressTabUntilFocused(page, page.getByRole("link", { name: "루나 선택" }));
|
|
await page.keyboard.press("Enter");
|
|
await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible();
|
|
await pressTabUntilFocused(page, page.getByRole("link", { name: "프로필" }));
|
|
await pressTabUntilFocused(page, page.getByRole("button", { name: "수정" }));
|
|
await page.keyboard.press("Enter");
|
|
await expect(page.getByRole("heading", { name: "AI 캐릭터 수정" })).toBeVisible();
|
|
await page.getByLabel("이름").focus();
|
|
await page.keyboard.type(" 키보드");
|
|
await pressTabUntilFocused(page, page.getByRole("button", { name: "상세로 돌아가기" }));
|
|
await page.keyboard.press("Enter");
|
|
|
|
// Then
|
|
await expect(page.getByRole("alertdialog", { name: "수정을 취소하시겠습니까?" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "계속 편집" })).toBeFocused();
|
|
});
|
|
|
|
test("has no critical or serious axe violations on the list at 320, 768, and 1280px with 200 percent zoom", async ({ page }) => {
|
|
// Given
|
|
await loginThroughMockMode(page);
|
|
|
|
for (const width of [320, 768, 1280]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.goto("/ai-characters");
|
|
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
|
|
|
|
// When
|
|
await zoomTo200Percent(page);
|
|
|
|
// Then
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalOrSeriousAxeViolations(page);
|
|
}
|
|
});
|
|
|
|
test("has no critical or serious axe violations on detail at 320, 768, and 1280px with 200 percent zoom", async ({ page }) => {
|
|
// Given
|
|
await loginThroughMockMode(page);
|
|
|
|
for (const width of [320, 768, 1280]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.goto("/ai-characters/101");
|
|
await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible();
|
|
|
|
// When
|
|
await zoomTo200Percent(page);
|
|
|
|
// Then
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalOrSeriousAxeViolations(page);
|
|
}
|
|
});
|
|
|
|
test("has no critical or serious axe violations on the edit dirty-leave dialog at 768 and 1280px with 200 percent zoom", async ({ page }) => {
|
|
// Given
|
|
await loginThroughMockMode(page);
|
|
|
|
for (const width of [768, 1280]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.goto("/ai-characters/101/edit");
|
|
await expect(page.getByRole("heading", { name: "AI 캐릭터 수정" })).toBeVisible();
|
|
await page.getByLabel("이름").fill("루나 dirty");
|
|
await page.getByRole("button", { name: "상세로 돌아가기" }).click();
|
|
await expect(page.getByRole("alertdialog", { name: "수정을 취소하시겠습니까?" })).toBeVisible();
|
|
|
|
// When
|
|
await zoomTo200Percent(page);
|
|
|
|
// Then
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalOrSeriousAxeViolations(page);
|
|
}
|
|
});
|