test(ai-character): 리소스 운영 E2E 보강

This commit is contained in:
Yu Sung
2026-08-01 01:30:52 +09:00
parent 3faca90135
commit 3759abf8b7
16 changed files with 1962 additions and 62 deletions

211
tests/e2e/community.spec.ts Normal file
View File

@@ -0,0 +1,211 @@
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 expectNoHorizontalOverflow(page: Page): Promise<void> {
const hasHorizontalOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
);
expect(hasHorizontalOverflow).toBe(false);
}
async function zoomTo200Percent(page: Page): Promise<void> {
await page.evaluate(() => {
document.documentElement.style.zoom = "2";
});
}
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 expectNoRawAdultBoolean(page: Page): Promise<void> {
await expect(page.locator("body")).not.toContainText(/성인 (?:true|false)/);
}
async function expectActionAbsentAcrossInteractiveRoles(page: Page, name: string): Promise<void> {
for (const role of interactiveActionRoles) {
await expect(page.getByRole(role, { name })).toBeHidden();
}
}
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
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<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$/);
}
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<HTMLAudioElement>("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 }) => {
// Given
await page.setViewportSize({ width: 320, height: 640 });
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);
});
for (const width of [768, 1280] as const) {
test(`tablet and desktop Community management remains available at ${width}px`, async ({ page }) => {
// 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();
await expect(page.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" })).toBeVisible();
await expect(page.getByRole("button", { name: "생성" })).toBeVisible();
});
}
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 zoom overflow and axe coverage across capability widths", async ({ page }) => {
// Given
await loginThroughMockMode(page);
for (const width of [320, 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);
// When
await zoomTo200Percent(page);
// Then
await expectNoHorizontalOverflow(page);
await expectNoCriticalOrSeriousAxeViolations(page);
}
}
});