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 P4-R10 native controls and conditional states without overflow", 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: free state await expect(page.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" })).toBeVisible(); await expect(page.getByRole("group", { name: "구매 옵션" })).toHaveCount(0); await expect(page.getByLabel("미리듣기 생성")).toHaveCount(0); await expect(page.getByLabel("포인트 사용")).toHaveCount(0); await expect(page.getByLabel("미리듣기 시작")).toHaveCount(0); await expect(page.getByLabel("미리듣기 종료")).toHaveCount(0); await expect(page.getByLabel("예약 공개일")).toHaveCount(0); const priceInput = page.getByLabel("가격", { exact: true }); await expect(priceInput).toHaveAttribute("type", "number"); await expect(priceInput).toHaveAttribute("min", "0"); await expect(priceInput).toHaveAttribute("step", "1"); await expect(priceInput).toHaveValue("0"); await priceInput.focus(); await page.keyboard.press("ArrowDown"); await expect(priceInput).toHaveValue("0"); await page.keyboard.press("ArrowUp"); await expect(priceInput).toHaveValue("1"); await priceInput.fill("0"); await expectNoHorizontalOverflow(page); // When: tags commit through both keyboard separators const tagInput = page.getByLabel("태그", { exact: true }); await tagInput.fill("상담"); await tagInput.press("Enter"); await tagInput.fill("힐링"); await tagInput.press(","); // Then await expect(tagInput).toHaveValue(""); await expect(page.getByRole("button", { name: "태그 상담 삭제" })).toBeVisible(); await expect(page.getByRole("button", { name: "태그 힐링 삭제" })).toBeVisible(); expect(createRequests).toBe(0); // When await page.getByRole("button", { name: "태그 상담 삭제" }).click(); // Then await expect(page.getByRole("button", { name: "태그 상담 삭제" })).toHaveCount(0); await expect(page.getByRole("button", { name: "태그 힐링 삭제" })).toBeVisible(); // When: a positive normalized price enables paid controls await priceInput.fill("1000"); // Then: paid state await expect(priceInput).toHaveValue("1000"); const purchaseOptions = page.getByRole("group", { name: "구매 옵션" }); await expect(purchaseOptions).toBeVisible(); const bothRadio = page.getByRole("radio", { name: "구매/대여" }); const buyOnlyRadio = page.getByRole("radio", { name: "구매 전용" }); const rentOnlyRadio = page.getByRole("radio", { name: "대여 전용" }); await expect(bothRadio).toHaveAttribute("type", "radio"); await expect(buyOnlyRadio).toHaveAttribute("type", "radio"); await expect(rentOnlyRadio).toHaveAttribute("type", "radio"); await expect(bothRadio).toBeChecked(); await rentOnlyRadio.focus(); await page.keyboard.press("Space"); await expect(rentOnlyRadio).toBeChecked(); await expect(page.getByLabel("기간제")).toHaveCount(0); await expect(page.getByRole("checkbox", { name: "대여 전용" })).toHaveCount(0); await expect(page.getByLabel("언어 코드")).toHaveCount(0); await expect(page.getByLabel("성인 콘텐츠")).toBeVisible(); await expect(page.getByLabel("포인트 사용")).toBeVisible(); await expect(page.getByLabel("댓글 허용")).toBeVisible(); await expect(page.getByLabel("상세 정보 전체 공개")).toBeVisible(); await expectNoHorizontalOverflow(page); // When: preview is enabled const previewCheckbox = page.getByLabel("미리듣기 생성"); await previewCheckbox.check(); // Then: preview duration offsets const previewStart = page.getByLabel("미리듣기 시작"); const previewEnd = page.getByLabel("미리듣기 종료"); await expect(previewStart).toHaveAttribute("type", "text"); await expect(previewStart).toHaveAttribute("placeholder", "예: 00:00:30"); await expect(previewStart).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}"); await expect(previewStart).not.toHaveAttribute("step"); await expect(previewEnd).toHaveAttribute("type", "text"); await expect(previewEnd).toHaveAttribute("placeholder", "예: 01:00:05"); await expect(previewEnd).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}"); await expect(previewEnd).not.toHaveAttribute("step"); await expectNoHorizontalOverflow(page); // When / Then: unchecking preview clears the visible time controls await previewCheckbox.uncheck(); await expect(previewStart).toHaveCount(0); await expect(previewEnd).toHaveCount(0); // When: preview state is populated before returning to free await previewCheckbox.check(); await page.getByLabel("포인트 사용").check(); await previewStart.fill("00:00:30"); await previewEnd.fill("01:00:05"); // Then: immediate mode has no datetime await expect(page.getByLabel("즉시 공개")).toBeChecked(); await expect(page.getByLabel("예약 공개일")).toHaveCount(0); // When: scheduled mode is selected await page.getByLabel("예약 공개").check(); // Then: exactly one native scheduled datetime is rendered const releaseDateTime = page.getByLabel("예약 공개일"); await expect(releaseDateTime).toHaveCount(1); await expect(releaseDateTime).toHaveAttribute("type", "datetime-local"); await releaseDateTime.fill("2026-08-05T12:00"); await expectNoHorizontalOverflow(page); // When / Then: immediate mode removes and clears the scheduled datetime await page.getByLabel("즉시 공개").check(); await expect(releaseDateTime).toHaveCount(0); await page.getByLabel("예약 공개").check(); await expect(page.getByLabel("예약 공개일")).toHaveValue(""); await page.getByLabel("즉시 공개").check(); // When: returning to zero resets and hides paid state await priceInput.fill("0"); // Then await expect(purchaseOptions).toHaveCount(0); await expect(previewCheckbox).toHaveCount(0); await expect(page.getByLabel("포인트 사용")).toHaveCount(0); await expect(previewStart).toHaveCount(0); await expect(previewEnd).toHaveCount(0); await expectNoHorizontalOverflow(page); // When: paid controls return await priceInput.fill("1"); // Then: their reset defaults are visible await expect(page.getByRole("radio", { name: "구매/대여" })).toBeChecked(); await expect(page.getByRole("radio", { name: "대여 전용" })).not.toBeChecked(); await expect(page.getByLabel("미리듣기 생성")).not.toBeChecked(); await expect(page.getByLabel("포인트 사용")).not.toBeChecked(); await expect(page.getByLabel("미리듣기 시작")).toHaveCount(0); await expect(page.getByLabel("미리듣기 종료")).toHaveCount(0); // When: the preserved max boundary is exceeded await priceInput.fill("100000"); await page.getByRole("button", { name: "생성" }).click(); // Then await expect(priceInput).toHaveValue("100000"); 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); } });