import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; import type { Locator, Page, TestInfo } from "@playwright/test"; // allow: SIZE_OK - ordered Community browser states intentionally share one E2E narrative. const interactiveActionRoles = ["button", "link", "menuitem"] as const; const tinyGif = Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", "base64"); const tinyPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFElEQVQYV2NkYGD4z0AEYBxVSF+FAAfiAQELhYkNAAAAAElFTkSuQmCC", "base64"); async function captureScreenshot(page: Page, testInfo: TestInfo, name: string): Promise { const path = testInfo.outputPath(name); await page.screenshot({ fullPage: true, path }); await testInfo.attach(name, { contentType: "image/png", path }); } async function expectNoHorizontalOverflow(page: Page): Promise { const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, ); expect(hasHorizontalOverflow).toBe(false); } async function expectNoClippedKoreanText(root: Locator): Promise { const clippedText = await root.evaluate((element) => Array.from(element.querySelectorAll("*")) .filter((candidate) => candidate.childElementCount === 0 && /[가-힣]/.test(candidate.textContent ?? "")) .filter((candidate) => { const style = getComputedStyle(candidate); return style.display !== "none" && style.visibility !== "hidden" && candidate.clientWidth > 0 && candidate.clientHeight > 0; }) .filter((candidate) => candidate.scrollWidth > candidate.clientWidth + 1 || candidate.scrollHeight > candidate.clientHeight + 1) .map((candidate) => candidate.textContent?.trim())); expect(clippedText).toEqual([]); } 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 expectNoRawAdultBoolean(page: Page): Promise { await expect(page.locator("body")).not.toContainText(/성인 (?:true|false)/); } async function expectActionAbsentAcrossInteractiveRoles(page: Page, name: string): Promise { for (const role of interactiveActionRoles) { await expect(page.getByRole(role, { name })).toBeHidden(); } } async function pressTabUntilFocused(page: Page, target: Locator): Promise { for (let attempt = 0; attempt < 50; attempt += 1) { if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) { return; } await page.keyboard.press("Tab"); } await expect(target).toBeFocused(); } 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$/); } test("Community mock audio preview loads metadata in Chromium", async ({ browserName, page }) => { test.skip(browserName !== "chromium", "Chromium metadata decode is the required mock proof."); // Given await loginThroughMockMode(page); await page.goto("/ai-characters/101/community-posts"); const player = page.getByRole("group", { name: "커뮤니티 게시글 7003 오디오 플레이어" }); // When await expect(player).toBeVisible(); const audioStatus = await page.evaluate(async () => { const audio = document.querySelector("audio[src^='data:audio/wav']"); if (audio === null) { return { kind: "missing" } as const; } if (audio.readyState >= HTMLMediaElement.HAVE_METADATA) { return { duration: audio.duration, errorCode: audio.error?.code ?? null, kind: "loaded" } as const; } return new Promise<{ readonly duration: number; readonly errorCode: number | null; readonly kind: "loaded" } | { readonly errorCode: number | null; readonly kind: "error" }>((resolve) => { audio.addEventListener("loadedmetadata", () => resolve({ duration: audio.duration, errorCode: audio.error?.code ?? null, kind: "loaded" }), { once: true }); audio.addEventListener("error", () => resolve({ errorCode: audio.error?.code ?? null, kind: "error" }), { once: true }); audio.load(); }); }); // Then expect(audioStatus).toMatchObject({ errorCode: null, kind: "loaded" }); if (audioStatus.kind === "loaded") { expect(audioStatus.duration).toBeGreaterThan(0); } }); test("mobile Community route stays read-only while list sheet and audio remain available", async ({ page }) => { // Given await page.setViewportSize({ width: 320, height: 640 }); await loginThroughMockMode(page); // When / Then await page.goto("/ai-characters/101/community-posts"); await expect(page.getByRole("heading", { name: "커뮤니티 게시글", exact: true })).toBeVisible(); await expect(page.locator("article").filter({ hasText: "오디오가 포함된 커뮤니티 게시글입니다." }).getByText("일반 · 댓글 허용 · 성인 콘텐츠", { exact: true })).toBeVisible(); await expectNoRawAdultBoolean(page); await expect(page.getByRole("button", { name: "오디오가 포함된 커뮤니티 게시글입니다. 게시글 열기" })).toBeVisible(); await expect(page.getByRole("group", { name: "커뮤니티 게시글 7003 오디오 플레이어" })).toBeVisible(); await expectActionAbsentAcrossInteractiveRoles(page, "커뮤니티 게시글 생성"); await expectNoHorizontalOverflow(page); const opener = page.getByRole("button", { name: "오디오가 포함된 커뮤니티 게시글입니다. 게시글 열기" }); await opener.click(); const dialog = page.getByRole("dialog", { name: "커뮤니티 게시글" }); await expect(dialog).toBeVisible(); await expect(dialog.getByRole("group", { name: "커뮤니티 게시글 7003 오디오 플레이어" })).toBeVisible(); await expect(dialog.getByText("오디오가 포함된 커뮤니티 게시글입니다.", { exact: true })).toBeVisible(); await expect(dialog.getByRole("textbox", { name: "내용" })).toBeHidden(); await expectActionAbsentAcrossInteractiveRoles(page, "수정 저장"); await expectActionAbsentAcrossInteractiveRoles(page, "고정하기"); await expectActionAbsentAcrossInteractiveRoles(page, "고정 해제"); await expectActionAbsentAcrossInteractiveRoles(page, "비활성화"); await expectNoHorizontalOverflow(page); }); test("mobile Community create route shows guidance instead of the create form", async ({ page }, testInfo) => { // Given await page.setViewportSize({ width: 375, height: 812 }); await loginThroughMockMode(page); // When await page.goto("/ai-characters/101/community-posts/new"); // Then await expect(page.getByRole("heading", { name: "모바일에서는 커뮤니티 게시글 생성을 제한합니다" })).toBeVisible(); await expect(page.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" })).toBeHidden(); await expectActionAbsentAcrossInteractiveRoles(page, "생성"); await expectNoHorizontalOverflow(page); await expectNoClippedKoreanText(page.locator("main")); await expectNoCriticalOrSeriousAxeViolations(page); await captureScreenshot(page, testInfo, "community-create-375-guidance.png"); }); for (const width of [768, 1280] as const) { test(`tablet and desktop Community management remains available at ${width}px`, async ({ page }, testInfo) => { // Given await page.setViewportSize({ width, height: 900 }); await loginThroughMockMode(page); // When / Then await page.goto("/ai-characters/101/community-posts"); await expect(page.getByRole("link", { name: "커뮤니티 게시글 생성" })).toBeVisible(); await expect(page.getByRole("table").getByText("고정 · 댓글 허용 · 일반 콘텐츠", { exact: true })).toBeVisible(); await expect(page.getByRole("table").getByText("일반 · 댓글 허용 · 성인 콘텐츠", { exact: true })).toBeVisible(); await expectNoRawAdultBoolean(page); await page.getByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }).click(); const dialog = page.getByRole("dialog", { name: "커뮤니티 게시글" }); await expect(dialog.getByRole("textbox", { name: "내용" })).toBeVisible(); await expect(dialog.getByRole("button", { name: "수정 저장" })).toBeVisible(); await expect(dialog.getByRole("button", { name: "고정 해제" })).toBeVisible(); await expect(dialog.getByRole("button", { name: "비활성화" })).toBeVisible(); await dialog.getByRole("button", { name: "닫기" }).click(); await page.getByRole("link", { name: "커뮤니티 게시글 생성" }).click(); const form = page.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" }); const imageInput = form.locator("input[type='file']").first(); const audioInput = form.locator("input[type='file']").nth(1); const imageSelectButton = form.getByRole("button", { name: "게시글 이미지 파일 선택" }); const contentInput = form.getByRole("textbox", { name: "내용" }); const priceInput = form.getByRole("spinbutton", { name: "가격" }); await expect(form).toBeVisible(); await expect(page.getByRole("button", { name: "생성" })).toBeVisible(); await expect(audioInput).toBeHidden(); await expect(priceInput).toHaveValue("0"); expect(await form.evaluate((element) => { const image = element.querySelector("input[type='file']"); const content = element.querySelector("textarea"); return image !== null && content !== null && Boolean(image.compareDocumentPosition(content) & Node.DOCUMENT_POSITION_FOLLOWING); })).toBe(true); await expectNoHorizontalOverflow(page); await expectNoClippedKoreanText(form); await expectNoCriticalOrSeriousAxeViolations(page); await captureScreenshot(page, testInfo, `community-create-${width}-initial.png`); await pressTabUntilFocused(page, imageSelectButton); await expect(imageSelectButton).toBeFocused(); expect(await imageSelectButton.evaluate((element) => { const style = getComputedStyle(element); return style.outlineStyle !== "none" || style.boxShadow !== "none"; })).toBe(true); const imagePreview = form.getByRole("img", { name: "게시글 이미지 업로드 미리보기" }); const cropDialog = page.getByRole("dialog", { name: "이미지 crop" }); await imageInput.setInputFiles({ buffer: tinyPng, mimeType: "image/png", name: "community-crop.png" }); await expect(cropDialog).toBeVisible(); await expect(imagePreview).toBeHidden(); await expect(audioInput).toBeHidden(); await captureScreenshot(page, testInfo, `community-create-${width}-crop.png`); await cropDialog.getByRole("button", { name: "적용" }).click(); await expect(cropDialog).toBeHidden(); await expect(imagePreview).toBeVisible(); expect(await imagePreview.evaluate((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0)).toBe(true); await expect(audioInput).toBeVisible(); await captureScreenshot(page, testInfo, `community-create-${width}-applied.png`); await audioInput.setInputFiles({ buffer: Buffer.from("ID3"), mimeType: "audio/mpeg", name: "community-audio.mp3" }); await imageInput.setInputFiles({ buffer: tinyPng, mimeType: "image/png", name: "community-replacement.png" }); await expect(cropDialog).toBeVisible(); await expect(imagePreview).toBeHidden(); await expect(audioInput).toBeHidden(); await captureScreenshot(page, testInfo, `community-create-${width}-replacement.png`); await cropDialog.getByRole("button", { name: "취소" }).click(); await expect(cropDialog).toBeHidden(); await expect(imagePreview).toBeHidden(); await expect(audioInput).toBeHidden(); await imageInput.setInputFiles({ buffer: Buffer.from("not-an-image"), mimeType: "image/png", name: "community-invalid.png" }); const imageErrorAlert = form.getByRole("alert"); await expect(cropDialog).toBeHidden(); await expect(imagePreview).toBeHidden(); await expect(audioInput).toBeHidden(); await expect(imageErrorAlert).toContainText("이미지 미리보기를 불러오지 못했습니다."); await expect(imageErrorAlert).toBeVisible(); await imageErrorAlert.scrollIntoViewIfNeeded(); await captureScreenshot(page, testInfo, `community-create-${width}-error.png`); await imageInput.setInputFiles({ buffer: tinyGif, mimeType: "image/gif", name: "community-final.gif" }); await expect(imagePreview).toBeVisible(); expect(await imagePreview.evaluate((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0)).toBe(true); await expect(audioInput).toBeVisible(); expect(await form.evaluate((element) => { const [image, audio] = element.querySelectorAll("input[type='file']"); const content = element.querySelector("textarea"); return image !== undefined && audio !== undefined && content !== null && Boolean(image.compareDocumentPosition(audio) & Node.DOCUMENT_POSITION_FOLLOWING) && Boolean(audio.compareDocumentPosition(content) & Node.DOCUMENT_POSITION_FOLLOWING); })).toBe(true); await expectNoHorizontalOverflow(page); await expectNoClippedKoreanText(form); await expectNoCriticalOrSeriousAxeViolations(page); await captureScreenshot(page, testInfo, `community-create-${width}-gif-ready.png`); await audioInput.setInputFiles({ buffer: Buffer.from("ID3"), mimeType: "audio/mpeg", name: "community-audio.mp3" }); await expect(audioInput).not.toHaveValue(""); await form.getByRole("button", { name: "게시글 이미지 선택 취소" }).click(); await expect(form.locator("img")).toBeHidden(); await expect(audioInput).toBeHidden(); await expectNoHorizontalOverflow(page); await expectNoClippedKoreanText(form); await captureScreenshot(page, testInfo, `community-create-${width}-image-removed.png`); await imageInput.setInputFiles({ buffer: tinyGif, mimeType: "image/gif", name: "community-final.gif" }); await expect(audioInput).toBeVisible(); await expect(audioInput).toHaveValue(""); await contentInput.fill("가격 검증을 위한 커뮤니티 게시글입니다."); await priceInput.fill(""); const createRequests: string[] = []; page.on("request", (request) => { if (request.method() === "POST" && request.url().includes("/community-posts")) { createRequests.push(request.url()); } }); await page.getByRole("button", { name: "생성" }).click(); await expect(page.getByRole("alert")).toContainText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요."); await expect(priceInput).toBeFocused(); await expect(priceInput).toHaveValue(""); await expect(page).toHaveURL(/\/ai-characters\/101\/community-posts\/new$/); expect(createRequests).toEqual([]); await expectNoHorizontalOverflow(page); await expectNoClippedKoreanText(form); await expectNoCriticalOrSeriousAxeViolations(page); await page.evaluate(() => window.scrollTo(0, 0)); await captureScreenshot(page, testInfo, `community-create-${width}-blank-price-error.png`); }); } test("Community sheet traps focus closes on Escape and returns focus", async ({ browserName, page }) => { test.skip(browserName === "webkit", "WebKit does not consistently tab-focus range inputs in this keyboard path."); // Given await page.setViewportSize({ width: 1280, height: 900 }); await loginThroughMockMode(page); await page.goto("/ai-characters/101/community-posts"); const opener = page.getByRole("button", { name: "오디오가 포함된 커뮤니티 게시글입니다. 게시글 열기" }); // When / Then await opener.click(); const dialog = page.getByRole("dialog", { name: "커뮤니티 게시글" }); const closeButton = dialog.getByRole("button", { name: "닫기" }); await expect(closeButton).toBeFocused(); await page.keyboard.press("Shift+Tab"); await expect(dialog.getByRole("button", { name: "비활성화" })).toBeFocused(); await page.keyboard.press("Tab"); await expect(closeButton).toBeFocused(); await pressTabUntilFocused(page, dialog.getByRole("textbox", { name: "내용" })); await pressTabUntilFocused(page, dialog.getByRole("group", { name: "커뮤니티 게시글 7003 오디오 플레이어" })); await pressTabUntilFocused(page, dialog.getByRole("slider", { name: "재생 위치" })); await pressTabUntilFocused(page, dialog.getByRole("combobox", { name: "재생 속도" })); await page.keyboard.press("Escape"); await expect(dialog).toBeHidden(); await expect(opener).toBeFocused(); }); test("Community list and create routes keep overflow and axe coverage across capability widths", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [320, 375, 768, 1280] as const) { for (const route of ["/ai-characters/101/community-posts", "/ai-characters/101/community-posts/new"] as const) { await page.setViewportSize({ width, height: 900 }); await page.goto(route); await expectNoHorizontalOverflow(page); if (width !== 320) { await expectNoClippedKoreanText(page.locator("main")); } await expectNoCriticalOrSeriousAxeViolations(page); } } });