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 { 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$/); } async function openAudioList(page: Page): Promise { await page.goto("/ai-characters/101/audio-contents"); await expect(page.getByRole("heading", { name: "오디오 콘텐츠", exact: true })).toBeVisible(); } async function expectNoHorizontalOverflow(page: Page): Promise { const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth); expect(hasHorizontalOverflow).toBe(false); } async function zoomTo200Percent(page: Page): Promise { await page.evaluate(() => { document.documentElement.style.zoom = "2"; }); } async function expectNoCriticalOrSeriousAxeViolations(page: Page): Promise { 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 { for (const role of interactiveActionRoles) { await expect(page.getByRole(role, { name })).toBeHidden(); } } async function expectResourceActionHidden(page: Page, name: string): Promise { await expect(page.locator("#audio-content-title").locator("..").locator("..").getByRole("button", { name })).toBeHidden(); } async function pressTabUntilFocused(page: Page, target: Locator): Promise { for (let attempt = 0; attempt < 36; 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("mobile route capability keeps audio list detail and player available while blocking mutations", async ({ page }) => { // Given await page.setViewportSize({ width: 320, height: 640 }); await loginThroughMockMode(page); // When / Then await openAudioList(page); await expectActionAbsentAcrossInteractiveRoles(page, "오디오 생성"); await expect(page.getByRole("group", { name: "달빛 상담 오디오 오디오 플레이어" })).toBeVisible(); await page.getByRole("link", { name: "달빛 상담 오디오 상세 보기" }).click(); await expect(page.getByRole("heading", { name: "달빛 상담 오디오", exact: true })).toBeVisible(); await expect(page.getByRole("group", { name: "달빛 상담 오디오 오디오 플레이어" })).toBeVisible(); await expectResourceActionHidden(page, "수정"); await page.goto("/ai-characters/101/audio-contents/new"); await expect(page.getByText(/데스크톱.*(생성|업로드|이용)/)).toBeVisible(); await expect(page.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" })).toBeHidden(); await expect(page.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeHidden(); await page.goto("/ai-characters/101/audio-contents/9001/edit"); await expect(page.getByText(/데스크톱.*(수정|업로드|비활성화|이용)/)).toBeVisible(); await expect(page.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" })).toBeHidden(); await expectActionAbsentAcrossInteractiveRoles(page, "비활성화"); await expect(page.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeHidden(); }); test("desktop and tablet expose audio mutation and upload actions", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [768, 1280]) { await page.setViewportSize({ width, height: 900 }); // When / Then await openAudioList(page); await expect(page.getByRole("button", { name: "오디오 생성" })).toBeVisible(); await page.goto("/ai-characters/101/audio-contents/9001"); await expect(page.getByRole("button", { name: "수정", exact: true })).toBeVisible(); await page.goto("/ai-characters/101/audio-contents/new"); await expect(page.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" })).toBeVisible(); await expect(page.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeVisible(); await page.goto("/ai-characters/101/audio-contents/9001/edit"); await expect(page.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" })).toBeVisible(); await expect(page.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeVisible(); await expect(page.getByRole("button", { name: "비활성화" })).toBeVisible(); } }); test("desktop create form exposes create settings and rejects raw invalid price", async ({ page }) => { // Given let createRequests = 0; page.on("request", (request) => { const requestUrl = new URL(request.url()); if (request.method() === "POST" && requestUrl.pathname === "/api/v2/admin/ai-characters/101/audio-contents") { createRequests += 1; } }); await page.setViewportSize({ width: 1280, height: 900 }); await loginThroughMockMode(page); // When await page.goto("/ai-characters/101/audio-contents/new"); // Then await expect(page.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" })).toBeVisible(); await expect(page.getByLabel("구매 옵션")).toBeVisible(); await expect(page.getByLabel("기간제")).toBeVisible(); await expect(page.getByLabel("성인 콘텐츠")).toBeVisible(); await expect(page.getByLabel("미리듣기 생성")).toBeVisible(); await expect(page.getByLabel("대여 전용")).toBeVisible(); await expect(page.getByLabel("포인트 사용")).toBeVisible(); await expect(page.getByLabel("댓글 허용")).toBeVisible(); await expect(page.getByLabel("상세 정보 전체 공개")).toBeVisible(); await expect(page.getByLabel("미리듣기 시작")).toBeVisible(); await expect(page.getByLabel("미리듣기 종료")).toBeVisible(); await expect(page.getByLabel("언어 코드")).toBeVisible(); // When await page.getByLabel("가격").fill("-1"); await page.getByRole("button", { name: "생성" }).click(); // Then await expect(page.getByLabel("가격")).toHaveValue("-1"); await expect(page.getByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeVisible(); expect(createRequests).toBe(0); }); test("audio list detail player error and long title avoid 320px overflow at 200 percent zoom", async ({ page }) => { // Given await page.setViewportSize({ width: 320, height: 640 }); await loginThroughMockMode(page); for (const route of ["/ai-characters/101/audio-contents", "/ai-characters/101/audio-contents/9001"] as const) { await page.goto(route); await expect(page.getByRole("group", { name: "달빛 상담 오디오 오디오 플레이어" })).toBeVisible(); // When await page.evaluate(() => { const title = document.querySelector("#audio-content-title") ?? document.querySelector("[aria-label='달빛 상담 오디오 상세 보기'] span span"); if (title !== null) { title.textContent = "공백없이매우긴오디오콘텐츠제목이운영화면에서잘리지않고읽히는지확인합니다"; } document.querySelector("audio")?.dispatchEvent(new Event("error")); document.documentElement.style.zoom = "2"; }); // Then await expect(page.getByRole("alert")).toContainText("오디오를 재생할 수 없습니다."); await expectNoHorizontalOverflow(page); } }); test("keyboard only reaches audio player and desktop form actions without double toggling descendants", async ({ browserName, isMobile, page }) => { test.skip(isMobile, "Mobile view intentionally blocks Audio mutation routes."); test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path."); // Given await page.setViewportSize({ width: 1280, height: 900 }); await loginThroughMockMode(page); await openAudioList(page); const player = page.getByRole("group", { name: "달빛 상담 오디오 오디오 플레이어" }).first(); // When / Then await pressTabUntilFocused(page, player); await page.keyboard.press("Space"); await expect(player.getByRole("button", { name: "일시정지" })).toBeVisible(); await pressTabUntilFocused(page, player.getByRole("button", { name: "일시정지" })); await page.keyboard.press("Enter"); await expect(player.getByRole("button", { name: "재생" })).toBeVisible(); await page.goto("/ai-characters/101/audio-contents/new"); await pressTabUntilFocused(page, page.getByLabel("제목")); await page.keyboard.type("키보드 오디오"); await pressTabUntilFocused(page, page.getByLabel("오디오 테마")); await page.keyboard.press("ArrowDown"); await page.keyboard.press("Enter"); await pressTabUntilFocused(page, page.getByRole("button", { name: "오디오 파일 파일 선택" })); await pressTabUntilFocused(page, page.getByRole("button", { name: "생성" })); }); test("audio states have no critical or serious axe violations at 320 768 and 1280px with 200 percent zoom", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [320, 768, 1280]) { for (const route of ["/ai-characters/101/audio-contents", "/ai-characters/101/audio-contents/9001"] as const) { await page.setViewportSize({ width, height: 900 }); await page.goto(route); await zoomTo200Percent(page); await expectNoHorizontalOverflow(page); await expectNoCriticalOrSeriousAxeViolations(page); } await page.setViewportSize({ width, height: 900 }); await page.goto("/ai-characters/101/audio-contents/new"); await zoomTo200Percent(page); await expectNoHorizontalOverflow(page); await expectNoCriticalOrSeriousAxeViolations(page); } });