diff --git a/tests/e2e/accessibility-shell.spec.ts b/tests/e2e/accessibility-shell.spec.ts index 44a053c..94a132c 100644 --- a/tests/e2e/accessibility-shell.spec.ts +++ b/tests/e2e/accessibility-shell.spec.ts @@ -1,7 +1,7 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; -const apiBaseUrl = "https://test-character-admin.sodalive.net"; +import { apiBaseUrl } from "./api-base-url"; const sessionStorageKey = "ai-character-admin-auth-session"; async function openShell(page: import("@playwright/test").Page): Promise { diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts new file mode 100644 index 0000000..f3e5e80 --- /dev/null +++ b/tests/e2e/accessibility.spec.ts @@ -0,0 +1,84 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +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 expectNoBlockingAxeViolations(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 storageDump(page: Page): Promise { + return page.evaluate(() => JSON.stringify({ cookies: document.cookie, local: { ...localStorage }, session: { ...sessionStorage } })); +} + +test("active mock routes have no critical or serious axe violations", async ({ page }) => { + // Given + await loginThroughMockMode(page); + + for (const route of ["/ai-characters", "/ai-characters/101", "/ai-characters/101/audio-contents/9001", "/ai-characters/101/community-posts", "/ai-characters/101/fan-talks"] as const) { + // When + await page.goto(route); + + // Then + await expectNoBlockingAxeViolations(page); + } +}); + +test("keyboard skip link and representative sheet focus remain usable with reduced motion", async ({ browserName, isMobile, page }) => { + test.skip(browserName !== "chromium" || isMobile, "Keyboard focus traversal is covered with desktop Chromium evidence."); + + // Given + await page.emulateMedia({ reducedMotion: "reduce" }); + await loginThroughMockMode(page); + + // When / Then + await page.getByRole("link", { name: "본문으로 건너뛰기" }).focus(); + await expect(page.getByRole("link", { name: "본문으로 건너뛰기" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toBeFocused(); + + await page.goto("/ai-characters/101/community-posts"); + const trigger = page.getByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }); + await trigger.click(); + await expect(page.getByRole("dialog", { name: "커뮤니티 게시글" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "커뮤니티 게시글" })).toBeHidden(); + await expect(trigger).toBeFocused(); +}); + +test("mock preview keeps bright theme and avoids sensitive persistence", async ({ page }) => { + // Given + const consoleMessages: string[] = []; + page.on("console", (message) => consoleMessages.push(message.text())); + await page.emulateMedia({ colorScheme: "dark" }); + + // When + await loginThroughMockMode(page); + await page.goto("/ai-characters/101/fan-talks"); + await page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }).click(); + const dialog = page.getByRole("dialog", { name: "FanTalk 답변" }); + await dialog.getByLabel("답변 내용").fill("보안 smoke 답변입니다."); + await dialog.getByRole("button", { name: "답변 등록" }).click(); + await expect(dialog.getByRole("status", { name: "답변 저장 성공" })).toBeVisible(); + + // Then + const dump = await storageDump(page); + const visibleText = await page.locator("body").innerText(); + expect(await page.evaluate(() => document.documentElement.classList.contains("dark"))).toBe(false); + await expect(page.getByRole("button", { name: /dark|theme|테마|다크/i })).toHaveCount(0); + expect(dump).not.toContain("password"); + expect(dump).not.toContain("data:audio"); + expect(dump).not.toContain("data:image"); + expect(visibleText).not.toContain("password"); + expect(consoleMessages.join("\n")).not.toContain("password"); +}); diff --git a/tests/e2e/api-base-url.ts b/tests/e2e/api-base-url.ts new file mode 100644 index 0000000..1d8d241 --- /dev/null +++ b/tests/e2e/api-base-url.ts @@ -0,0 +1,17 @@ +/// + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function readDevelopmentApiBaseUrl(): string { + const envFile = readFileSync(resolve(process.cwd(), ".env.development"), "utf8"); + const line = envFile.split("\n").find((entry) => entry.startsWith("VITE_API_BASE_URL=")); + + if (line === undefined) { + throw new Error("VITE_API_BASE_URL is missing from .env.development"); + } + + return line.slice("VITE_API_BASE_URL=".length).trim(); +} + +export const apiBaseUrl = process.env.VITE_API_BASE_URL ?? readDevelopmentApiBaseUrl(); diff --git a/tests/e2e/audio-content.spec.ts b/tests/e2e/audio-content.spec.ts new file mode 100644 index 0000000..96579e4 --- /dev/null +++ b/tests/e2e/audio-content.spec.ts @@ -0,0 +1,220 @@ +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); + } +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts index 2aeb3c1..806b70f 100644 --- a/tests/e2e/auth.spec.ts +++ b/tests/e2e/auth.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -const apiBaseUrl = "https://test-character-admin.sodalive.net"; +import { apiBaseUrl } from "./api-base-url"; test("completes login, navigation, and logout using only the keyboard", async ({ page }) => { await page.route(`${apiBaseUrl}/admin/member/login`, async (route) => { diff --git a/tests/e2e/character-workspace.spec.ts b/tests/e2e/character-workspace.spec.ts new file mode 100644 index 0000000..1f575bc --- /dev/null +++ b/tests/e2e/character-workspace.spec.ts @@ -0,0 +1,280 @@ +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$/); + await expect(page.getByRole("heading", { name: "AI 캐릭터", 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 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 zoomTo200Percent(page: Page): Promise { + await page.evaluate(() => { + document.documentElement.style.zoom = "2"; + }); +} + +async function pressTabUntilFocused(page: Page, target: Locator): Promise { + 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); + } +}); diff --git a/tests/e2e/comments-test-support.ts b/tests/e2e/comments-test-support.ts new file mode 100644 index 0000000..1d00556 --- /dev/null +++ b/tests/e2e/comments-test-support.ts @@ -0,0 +1,164 @@ +import type { Page, Route } from "@playwright/test"; + +import { apiBaseUrl } from "./api-base-url"; +const profileUrl = "https://cdn.example.com/luna.png"; + +type E2eComment = { + comment: string; + date: string; + donationCan?: number; + id: number; + isSecret: boolean; + languageCode?: string | null; + nickname: string; + parentId: number | null; + profileUrl: string; + replyCount: number; + writerId: number; +}; + +type CommentCreateBody = { + readonly comment: string; + readonly isSecret: boolean; + readonly languageCode?: string | null; + readonly parentId?: number | null; +}; + +type CommentUpdateBody = { readonly comment: string }; + +export async function installServerModeCommentRoutes(page: Page): Promise { + const audioRoots = comments([ + [1101, null, 301, "팬", "오디오 팬 루트 댓글", 2], + [1102, null, 101, "루나", "오디오 AI 루트 댓글", 0], + ], true); + const audioReplies = comments([ + [1201, 1101, 301, "팬", "오디오 팬 답글", 0], + [1202, 1101, 101, "루나", "오디오 AI 답글", 0], + ], true); + const communityRoots = comments([ + [2101, null, 301, "팬", "커뮤니티 팬 루트 댓글", 2], + [2102, null, 101, "루나", "커뮤니티 AI 루트 댓글", 0], + ], false); + const communityReplies = comments([ + [2201, 2101, 301, "팬", "커뮤니티 팬 답글", 0], + [2202, 2101, 101, "루나", "커뮤니티 AI 답글", 0], + ], false); + let nextCommentId = 3000; + + await page.route(`${apiBaseUrl}/admin/member/login`, async (route) => fulfillJson(route, { token: "admin-token", role: "ADMIN" })); + await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, async (route) => fulfillJson(route, characterList())); + await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters/101`, async (route) => fulfillJson(route, characterDetail())); + await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters/101/audio-contents/9001`, async (route) => fulfillJson(route, audioDetail())); + await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts?page=0&size=20`, async (route) => fulfillJson(route, communityList())); + await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters/101/audio-contents/9001/comments**`, async (route) => { + nextCommentId = await handleComments(route, audioRoots, audioReplies, nextCommentId, true, 1101); + }); + await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts/7001/comments**`, async (route) => { + nextCommentId = await handleComments(route, communityRoots, communityReplies, nextCommentId, false, 2101); + }); +} + +async function handleComments(route: Route, roots: E2eComment[], replies: E2eComment[], nextId: number, isAudio: boolean, replyRootId: number): Promise { + const url = new URL(route.request().url()); + const method = route.request().method(); + if (url.pathname.endsWith(`/${replyRootId}/replies`)) { + await fulfillJson(route, pageComments(replies)); + return nextId; + } + if (method === "GET") { + await fulfillJson(route, pageComments(roots)); + return nextId; + } + if (method === "POST") { + const body = parseCreateBody(route.request().postData()); + const collection = body.parentId === replyRootId ? replies : roots; + const id = nextId + 1; + collection.unshift({ id, parentId: body.parentId ?? null, writerId: 101, nickname: "루나", profileUrl, comment: body.comment, isSecret: body.isSecret, date: "2026-07-29T03:00:00Z", replyCount: 0, languageCode: isAudio ? body.languageCode ?? null : undefined, donationCan: isAudio ? 0 : undefined }); + await fulfillJson(route, null); + return id; + } + if (method === "PUT") { + updateComment(roots, replies, url.pathname, parseUpdateBody(route.request().postData())); + } else { + deleteComment(roots, replies, url.pathname); + } + await fulfillJson(route, null); + return nextId; +} + +function parseCreateBody(value: string | null): CommentCreateBody { + const data: unknown = JSON.parse(value ?? "{}"); + if (isRecord(data) && typeof data.comment === "string" && typeof data.isSecret === "boolean") { + return { comment: data.comment, isSecret: data.isSecret, parentId: typeof data.parentId === "number" ? data.parentId : null, languageCode: typeof data.languageCode === "string" ? data.languageCode : null }; + } + throw new Error("invalid comment create body"); +} + +function parseUpdateBody(value: string | null): CommentUpdateBody { + const data: unknown = JSON.parse(value ?? "{}"); + if (isRecord(data) && typeof data.comment === "string") { + return { comment: data.comment }; + } + throw new Error("invalid comment update body"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function fulfillJson(route: Route, data: unknown): Promise { + return route.fulfill({ contentType: "application/json", json: { success: true, message: null, data, errorProperty: null } }); +} + +function pageComments(items: readonly E2eComment[]) { + return { totalCount: items.length, items }; +} + +function updateComment(roots: readonly E2eComment[], replies: readonly E2eComment[], path: string, body: CommentUpdateBody): void { + const id = Number(lastPathSegment(path)); + for (const comment of [...roots, ...replies]) { + if (comment.id === id) { + comment.comment = body.comment; + return; + } + } +} + +function deleteComment(roots: E2eComment[], replies: E2eComment[], path: string): void { + const id = Number(lastPathSegment(path)); + removeById(roots, id); + removeById(replies, id); +} + +function removeById(comments: E2eComment[], id: number): void { + const index = comments.findIndex((comment) => comment.id === id); + if (index !== -1) { + comments.splice(index, 1); + } +} + +function lastPathSegment(path: string): string { + const parts = path.split("/"); + + return parts[parts.length - 1] ?? ""; +} + +function comments(rows: readonly (readonly [number, number | null, number, string, string, number])[], includeAudioFields: boolean): E2eComment[] { + return rows.map(([id, parentId, writerId, nickname, comment, replyCount], index) => ({ id, parentId, writerId, nickname, profileUrl, comment, isSecret: false, date: `2026-07-29T0${index + 1}:00:00Z`, replyCount, languageCode: includeAudioFields ? "ko" : undefined, donationCan: includeAudioFields ? 0 : undefined })); +} + +function characterList() { + return { totalCount: 1, content: [{ id: 101, name: "루나", imageUrl: null, description: "차분한 상담형 AI 캐릭터", gender: "여성", age: 24, mbti: "INFJ", speechStyle: "다정함", speechPattern: "존댓말", region: "KR", tags: ["상담", "힐링"], createdAt: "2026-07-28 10:00:00", updatedAt: "2026-07-28 11:00:00" }] }; +} + +function characterDetail() { + return { ...characterList().content[0], characterUUID: "character-uuid-101", systemPrompt: "친절하고 안전하게 답한다.", characterType: "Character", appearance: null, isActive: true, hobbies: [], values: [], goals: [], relationships: [], personalities: [], backgrounds: [], memories: [], originalWork: null }; +} + +function audioDetail() { + return { contentId: 9001, title: "달빛 상담 오디오", detail: "잠들기 전 듣는 상담 오디오", languageCode: "ko", coverImageUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3C/svg%3E", contentUrl: "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=", themeStr: "힐링", tag: "상담,힐링", price: 1000, duration: "00:01", releaseDate: "2026-07-28T01:00:00Z", totalContentCount: 5, remainingContentCount: 4, orderSequence: 1, isActivePreview: true, isAdult: false, isMosaic: false, isOnlyRental: false, existOrdered: false, purchaseOption: "RENT_ONLY", orderType: null, remainingTime: "7일", creatorOtherContentList: [], sameThemeOtherContentList: [], isCommentAvailable: true, isLike: false, likeCount: 0, commentList: [], commentCount: 0, isPin: false, isAvailablePin: false, creator: { creatorId: 101, nickname: "루나", profileImageUrl: profileUrl, isFollowing: false, isFollow: false, isNotify: false }, previousContent: null, nextContent: null, buyerList: [], isAvailableUsePoint: true, translated: null }; +} + +function communityList() { + return { totalCount: 1, page: 0, size: 20, hasNext: false, items: [{ postId: 7001, creatorId: 101, creatorNickname: "루나", creatorProfileUrl: profileUrl, imageUrl: null, audioUrl: null, content: "오늘의 상담 기록입니다.", price: 0, date: "2026-07-28 10:00:00", dateUtc: "2026-07-28T01:00:00Z", isCommentAvailable: true, isAdult: false, isFixed: true, isLike: false, existOrdered: false, likeCount: 3, commentCount: 1, firstComment: null }] }; +} diff --git a/tests/e2e/comments.spec.ts b/tests/e2e/comments.spec.ts new file mode 100644 index 0000000..bf0da96 --- /dev/null +++ b/tests/e2e/comments.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; + +import { installServerModeCommentRoutes } from "./comments-test-support"; + +async function loginThroughMockMode(page: Page): Promise { + await installServerModeCommentRoutes(page); + 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 expectNoHorizontalOverflow(page: Page): Promise { + const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth); + + expect(hasHorizontalOverflow).toBe(false); +} + +async function pressTabUntilFocused(page: Page, target: Locator): Promise { + for (let attempt = 0; attempt < 60; 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("Audio comments create reply edit AI rows and delete fan or AI rows through two-level endpoints", async ({ page }) => { + // Given + const commentRequests: { readonly body: string | null; readonly method: string; readonly path: string; readonly search: string }[] = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname.includes("/audio-contents/9001/comments")) { + commentRequests.push({ body: request.postData(), method: request.method(), path: url.pathname, search: url.search }); + } + }); + await loginThroughMockMode(page); + + // When + await page.goto("/ai-characters/101/audio-contents/9001"); + + // Then + await expect(page.getByRole("heading", { name: "댓글 관리" })).toBeVisible(); + await expect(page.getByText("오디오 팬 루트 댓글", { exact: true })).toBeVisible(); + expect(commentRequests).toContainEqual({ body: null, method: "GET", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", search: "?page=0&size=20" }); + + // When + await page.getByLabel("새 댓글").fill("오디오 루트 생성"); + await page.getByRole("button", { name: "댓글 등록" }).click(); + await page.getByRole("button", { name: "오디오 팬 루트 댓글 답글 보기" }).click(); + const replies = page.getByRole("region", { name: "오디오 팬 루트 댓글 답글" }); + await replies.getByLabel("오디오 팬 루트 댓글에 답글").fill("오디오 답글 생성"); + await replies.getByRole("button", { name: "답글 등록" }).click(); + await expect(replies.getByText("오디오 답글 생성", { exact: true })).toBeVisible(); + await replies.getByRole("button", { name: "오디오 AI 답글 수정" }).click(); + await replies.getByLabel("댓글 수정 내용").fill("오디오 AI 답글 수정"); + await replies.getByRole("button", { name: "수정 저장" }).click(); + await expect(replies.getByText("오디오 답글 생성", { exact: true })).toBeVisible(); + await expect.poll(() => commentRequests.some((request) => request.method === "PUT" && request.path.endsWith("/1202"))).toBe(true); + await replies.getByRole("button", { name: "오디오 팬 답글 삭제" }).click(); + await expect.poll(() => commentRequests.some((request) => request.method === "DELETE" && request.path.endsWith("/1201"))).toBe(true); + await page.getByRole("button", { name: "오디오 AI 루트 댓글 삭제" }).click(); + + // Then + await expect(page.getByText("오디오 AI 루트 댓글", { exact: true })).toBeHidden(); + await expect(replies.getByText("오디오 답글 생성", { exact: true })).toBeVisible(); + expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 루트 생성", parentId: null, isSecret: false, languageCode: null }), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", search: "" }); + expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 답글 생성", parentId: 1101, isSecret: false, languageCode: null }), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", search: "" }); + expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 AI 답글 수정" }), method: "PUT", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1202", search: "" }); + expect(commentRequests).toContainEqual({ body: null, method: "DELETE", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1201", search: "" }); + expect(commentRequests).toContainEqual({ body: null, method: "DELETE", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1102", search: "" }); + expect(commentRequests.some((request) => request.method === "PUT" && request.path.endsWith("/1101"))).toBe(false); +}); + +test("Community sheet comments keep two-level controls usable at 320px", async ({ page }) => { + // Given + await page.setViewportSize({ width: 320, height: 640 }); + await loginThroughMockMode(page); + + // When + await page.goto("/ai-characters/101/community-posts"); + await page.getByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }).click(); + const dialog = page.getByRole("dialog", { name: "커뮤니티 게시글" }); + + // Then + await expect(dialog.getByRole("heading", { name: "댓글 관리" })).toBeVisible(); + await expect(dialog.getByLabel("새 댓글")).toBeInViewport(); + await expect(dialog.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toBeVisible(); + await expectNoHorizontalOverflow(page); + + // When + await dialog.getByLabel("새 댓글").fill("커뮤니티 루트 생성"); + await dialog.getByRole("button", { name: "댓글 등록" }).click(); + await expect(dialog.getByText("커뮤니티 루트 생성", { exact: true })).toBeVisible(); + const showReplies = dialog.getByRole("button", { name: "커뮤니티 팬 루트 댓글 답글 보기" }); + await expect(showReplies).toBeEnabled(); + await showReplies.click(); + const replies = dialog.getByRole("region", { name: "커뮤니티 팬 루트 댓글 답글" }); + await expect(replies).toBeVisible(); + await replies.getByLabel("커뮤니티 팬 루트 댓글에 답글").fill("커뮤니티 답글 생성"); + await replies.getByRole("button", { name: "답글 등록" }).click(); + await replies.getByRole("button", { name: "커뮤니티 AI 답글 수정" }).click(); + await replies.getByLabel("댓글 수정 내용").fill("커뮤니티 AI 답글 수정"); + await replies.getByRole("button", { name: "수정 저장" }).click(); + await expect.poll(() => replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" }).isEnabled()).toBe(true); + await replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" }).click(); + + // Then + await expect(replies.getByText("커뮤니티 팬 답글", { exact: true })).toBeHidden(); + await expectNoHorizontalOverflow(page); +}); + +test("keyboard-only Audio comment flow reaches form and reply controls", async ({ browserName, page }) => { + test.skip(browserName === "webkit", "WebKit does not consistently tab-focus controls in this keyboard path."); + + // Given + await page.setViewportSize({ width: 1280, height: 900 }); + await loginThroughMockMode(page); + await page.goto("/ai-characters/101/audio-contents/9001"); + const rootInput = page.getByLabel("새 댓글"); + + // When / Then + await pressTabUntilFocused(page, rootInput); + await page.keyboard.type("키보드 루트 댓글"); + await pressTabUntilFocused(page, page.getByRole("button", { name: "댓글 등록" })); + await page.keyboard.press("Enter"); + await expect(page.getByText("키보드 루트 댓글", { exact: true })).toBeVisible(); +}); diff --git a/tests/e2e/community.spec.ts b/tests/e2e/community.spec.ts new file mode 100644 index 0000000..43b314d --- /dev/null +++ b/tests/e2e/community.spec.ts @@ -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 { + 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 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 }) => { + // 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); + } + } +}); diff --git a/tests/e2e/error-mapping.spec.ts b/tests/e2e/error-mapping.spec.ts new file mode 100644 index 0000000..c0c29c3 --- /dev/null +++ b/tests/e2e/error-mapping.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { apiBaseUrl } from "./api-base-url"; + +type ApiProbe = { + readonly message: string | null; + readonly status: number; +}; + +async function fetchMockApi(page: Page, path: string, init?: RequestInit): Promise { + return page.evaluate( + async ({ apiBaseUrl, init, path }) => { + const response = await fetch(`${apiBaseUrl}${path}`, init); + const body = (await response.json()) as { readonly message?: string | null }; + + return { message: body.message ?? null, status: response.status }; + }, + { apiBaseUrl, init, path }, + ); +} + +async function waitForMockWorker(page: Page): Promise { + await page.goto("/"); + await expect + .poll(() => page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready; + + return registration.active?.scriptURL.endsWith("/mockServiceWorker.js") ?? false; + })) + .toBe(true); + if (!(await page.evaluate(() => navigator.serviceWorker.controller !== null))) { + await page.reload(); + } + await expect.poll(() => page.evaluate(() => navigator.serviceWorker.controller !== null)).toBe(true); +} + +test("mock API exposes Korean auth and media error messages without backend fallback", async ({ page }) => { + // Given + await waitForMockWorker(page); + + // When + const missingBearer = await fetchMockApi(page, "/api/v2/admin/ai-characters?page=0&size=20"); + const forbiddenLogout = await fetchMockApi(page, "/member/logout", { + headers: { Authorization: "Bearer mock-member-jwt" }, + method: "POST", + }); + const unsupportedLoginMedia = await fetchMockApi(page, "/admin/member/login", { + body: "not-json", + headers: { "Content-Type": "text/plain" }, + method: "POST", + }); + + // Then + expect(missingBearer).toEqual({ message: "인증 정보가 없습니다.", status: 401 }); + expect(forbiddenLogout).toEqual({ message: "접근 권한이 없습니다.", status: 403 }); + expect(unsupportedLoginMedia).toEqual({ message: "지원하지 않는 미디어 타입입니다.", status: 415 }); +}); + +test("mock preview does not persist secrets or file bodies in browser storage", async ({ page }) => { + // Given + 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$/); + + // When + const storageDump = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); + + // Then + expect(storageDump).not.toContain("password"); + expect(storageDump).not.toContain("data:audio"); + expect(storageDump).not.toContain("data:image"); +}); diff --git a/tests/e2e/fan-talk.spec.ts b/tests/e2e/fan-talk.spec.ts new file mode 100644 index 0000000..938ff34 --- /dev/null +++ b/tests/e2e/fan-talk.spec.ts @@ -0,0 +1,246 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; + +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 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$/); + await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible(); +} + +async function openFirstUnansweredFanTalk(page: Page): Promise { + await page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }).click(); + + return page.getByRole("dialog", { name: "FanTalk 답변" }); +} + +async function expectFanTalkListReady(page: Page): Promise { + await expect(page.getByRole("heading", { name: "FanTalk", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" })).toBeVisible(); + await expect(page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "검색어" })).toBeHidden(); +} + +test("FanTalk mock journey creates edits and deletes through list-backed endpoints", async ({ page }) => { + // Given + const fanTalkRequests: { readonly body: string | null; readonly method: string; readonly path: string; readonly search: string }[] = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname.startsWith("/api/v2/admin/") && url.pathname.includes("/fan-talks")) { + fanTalkRequests.push({ body: request.postData(), method: request.method(), path: url.pathname, search: url.search }); + } + }); + await loginThroughMockMode(page); + + // When + await page.goto("/ai-characters/101/fan-talks?page=0&size=20&status=pending&sort=createdAt"); + + // Then + await expect(page.getByRole("heading", { name: "FanTalk", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" })).toBeVisible(); + await expect(page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "검색어" })).toBeHidden(); + await expect(page.locator("body")).toContainText("2026. 07. 28. 10:00"); + await expect(page.locator("body")).toContainText("2026. 07. 28. 11:00"); + await expect(page.locator("body")).not.toContainText("2026-07-28T01:00:00Z"); + await expect(page.locator("body")).not.toContainText("2026-07-28T02:00:00Z"); + expect(fanTalkRequests).toContainEqual({ body: null, method: "GET", path: "/api/v2/admin/ai-characters/101/fan-talks", search: "?page=0&size=20" }); + + // When + await page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }).click(); + const dialog = page.getByRole("dialog", { name: "FanTalk 답변" }); + await dialog.getByLabel("답변 내용").fill("응원 고마워요."); + await dialog.getByRole("button", { name: "답변 등록" }).click(); + + // Then + const createStatus = dialog.getByRole("status", { name: "답변 저장 성공" }); + await expect(createStatus).toContainText("답변이 등록되었습니다."); + await expect(createStatus).not.toContainText("fanTalk"); + await expect(createStatus).not.toContainText("reply"); + await expect(createStatus).not.toContainText("creator"); + await expect(createStatus).not.toContainText("2026-07-28T04:00:00Z"); + await expect(dialog).toContainText("응원 고마워요."); + await expect(dialog).toContainText("2026. 07. 28. 13:00"); + await expect(dialog).not.toContainText("2026-07-28T04:00:00Z"); + await expect(dialog.getByLabel("답변 내용")).toBeHidden(); + await dialog.getByRole("button", { name: "닫기" }).click(); + expect(fanTalkRequests.filter((request) => request.method === "POST" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7001/replies" && request.body === JSON.stringify({ content: "응원 고마워요." }))).toHaveLength(1); + + // When + await page.getByRole("button", { name: "첫 번째 응원입니다. 답변 보기" }).click(); + const createdReplyDialog = page.getByRole("dialog", { name: "FanTalk 답변" }); + await createdReplyDialog.getByLabel("답변 내용").fill("방금 등록한 답변을 수정합니다."); + await createdReplyDialog.getByRole("button", { name: "답변 수정" }).click(); + + // Then + await expect(createdReplyDialog.getByRole("status", { name: "답변 저장 성공" })).toContainText("답변이 수정되었습니다."); + await createdReplyDialog.getByRole("button", { name: "닫기" }).click(); + await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변 보기" })).toBeVisible(); + expect(fanTalkRequests.filter((request) => request.method === "PUT" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7001/replies/9001" && request.body === JSON.stringify({ content: "방금 등록한 답변을 수정합니다." }))).toHaveLength(1); + + // When + await page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" }).click(); + const editDialog = page.getByRole("dialog", { name: "FanTalk 답변" }); + await expect(editDialog.getByLabel("답변 내용")).toHaveValue("이미 답변했습니다."); + await editDialog.getByLabel("답변 내용").fill("수정한 답변입니다."); + await editDialog.getByRole("button", { name: "답변 수정" }).click(); + + // Then + const updateStatus = editDialog.getByRole("status", { name: "답변 저장 성공" }); + await expect(updateStatus).toContainText("답변이 수정되었습니다."); + await expect(updateStatus).not.toContainText("reply"); + await expect(updateStatus).not.toContainText("2026-07-28T05:00:00Z"); + expect(fanTalkRequests.filter((request) => request.method === "PUT" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7002/replies/7102" && request.body === JSON.stringify({ content: "수정한 답변입니다." }))).toHaveLength(1); + expect(fanTalkRequests.every((request) => request.body === null || !request.body.includes("isActive"))).toBe(true); + + // When + await editDialog.getByRole("button", { name: "FanTalk 원글 삭제" }).click(); + await page.getByRole("button", { name: "삭제 확인" }).click(); + + // Then + await expect(editDialog).toBeHidden(); + await expect(page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })).toBeHidden(); + expect(fanTalkRequests.filter((request) => request.method === "DELETE" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7002" && request.body === null)).toHaveLength(1); + expect(fanTalkRequests.some((request) => request.method === "GET" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7001")).toBe(false); + expect(fanTalkRequests.every((request) => !request.search.includes("status=") && !request.search.includes("sort="))).toBe(true); +}); + +for (const width of [320, 768, 1280] as const) { + test(`FanTalk create edit and delete stay available at ${width}px`, async ({ page }) => { + // Given + await page.setViewportSize({ width, height: 640 }); + await loginThroughMockMode(page); + + // When / Then + await page.goto("/ai-characters/101/fan-talks"); + await expectFanTalkListReady(page); + await expectNoHorizontalOverflow(page); + + const dialog = await openFirstUnansweredFanTalk(page); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("답변 내용")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "답변 등록" })).toBeVisible(); + + await dialog.getByLabel("답변 내용").fill(`${width}px 응답입니다.`); + await dialog.getByRole("button", { name: "답변 등록" }).click(); + await expect(dialog).toContainText(`${width}px 응답입니다.`); + await expect(dialog.getByLabel("답변 내용")).toBeHidden(); + await dialog.getByRole("button", { name: "닫기" }).click(); + + await page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" }).click(); + const editDialog = page.getByRole("dialog", { name: "FanTalk 답변" }); + await expect(editDialog.getByRole("button", { name: "답변 수정" })).toBeVisible(); + await expect(editDialog.getByRole("button", { name: "FanTalk 원글 삭제" })).toBeVisible(); + await editDialog.getByLabel("답변 내용").fill(`${width}px 수정입니다.`); + await editDialog.getByRole("button", { name: "답변 수정" }).click(); + await expect(editDialog.getByRole("status", { name: "답변 저장 성공" })).toContainText("답변이 수정되었습니다."); + await editDialog.getByRole("button", { name: "FanTalk 원글 삭제" }).click(); + await page.getByRole("button", { name: "삭제 확인" }).click(); + await expect(editDialog).toBeHidden(); + }); +} + +test("320px FanTalk reply sheet keeps input and submit usable in a keyboard viewport", async ({ page }) => { + // Given + await page.setViewportSize({ width: 320, height: 480 }); + await loginThroughMockMode(page); + await page.goto("/ai-characters/101/fan-talks"); + + // When / Then + const dialog = await openFirstUnansweredFanTalk(page); + const replyInput = dialog.getByLabel("답변 내용"); + const submitButton = dialog.getByRole("button", { name: "답변 등록" }); + await expect(replyInput).toBeInViewport(); + await replyInput.focus(); + await expect(submitButton).toBeInViewport(); + await replyInput.fill("좁은 화면에서도 등록합니다."); + await submitButton.click(); + await expect(dialog).toContainText("좁은 화면에서도 등록합니다."); + await expectNoHorizontalOverflow(page); +}); + +test("keyboard-only FanTalk flow opens sheet creates reply and restores focus", async ({ browserName, page }) => { + test.skip(browserName === "webkit", "WebKit does not consistently tab-focus buttons in this keyboard path."); + + // Given + await page.setViewportSize({ width: 1280, height: 900 }); + await loginThroughMockMode(page); + await page.goto("/ai-characters/101/fan-talks"); + const opener = page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }); + + // When / Then + await pressTabUntilFocused(page, opener); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog", { name: "FanTalk 답변" }); + const closeButton = dialog.getByRole("button", { name: "닫기" }); + await expect(closeButton).toBeFocused(); + + const replyInput = dialog.getByLabel("답변 내용"); + await pressTabUntilFocused(page, replyInput); + await page.keyboard.type("키보드로 작성한 답변입니다."); + await pressTabUntilFocused(page, dialog.getByRole("button", { name: "답변 등록" })); + await page.keyboard.press("Enter"); + + await expect(dialog.getByRole("status", { name: "답변 저장 성공" })).toBeVisible(); + await expect(dialog).toContainText("키보드로 작성한 답변입니다."); + await expect(replyInput).toBeHidden(); + await pressTabUntilFocused(page, closeButton); + await page.keyboard.press("Enter"); + await expect(dialog).toBeHidden(); + await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변 보기" })).toBeFocused(); +}); + +test("FanTalk route keeps 200 percent zoom and axe coverage across viewport widths", async ({ page }) => { + // Given + await loginThroughMockMode(page); + + for (const width of [320, 768, 1280] as const) { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/ai-characters/101/fan-talks"); + + // When + await zoomTo200Percent(page); + + // Then + await expectNoHorizontalOverflow(page); + await expectNoCriticalOrSeriousAxeViolations(page); + } +}); diff --git a/tests/e2e/mock-preview-shell.spec.ts b/tests/e2e/mock-preview-shell.spec.ts index 55020ad..1c4de8e 100644 --- a/tests/e2e/mock-preview-shell.spec.ts +++ b/tests/e2e/mock-preview-shell.spec.ts @@ -16,6 +16,12 @@ async function zoomTo200Percent(page: Page): Promise { }); } +async function resetZoom(page: Page): Promise { + await page.evaluate(() => { + document.documentElement.style.zoom = ""; + }); +} + async function expectNoHorizontalOverflow(page: Page): Promise { const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, @@ -38,7 +44,7 @@ async function expectBannerDoesNotOverlap(banner: Locator, control: Locator): Pr expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(controlBox.y); } -test("logs in through mock mode and opens the protected shell without backend fallback", async ({ page }) => { +test("opens the mock shell without backend fallback and keeps the 320px session usable", async ({ page }) => { // Given const interceptedApiContractRequests: string[] = []; page.on("request", (request) => { @@ -54,20 +60,33 @@ test("logs in through mock mode and opens the protected shell without backend fa // Then await expect(page.getByRole("status", { name: /Mock Preview/ })).toBeVisible(); await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible(); - expect(interceptedApiContractRequests).toEqual([ + const results = await new AxeBuilder({ page }).analyze(); + const blockingViolations = results.violations.filter( + (violation) => violation.impact === "critical" || violation.impact === "serious", + ); + expect(blockingViolations).toEqual([]); + expect(new Set(interceptedApiContractRequests)).toEqual(new Set([ "POST /admin/member/login", "GET /api/v2/admin/ai-characters?page=0&size=20", - ]); -}); - -test("logs out and logs in again with a fresh mock preview session", async ({ page }) => { - // Given - await loginThroughMockMode(page); - await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible(); + ])); // When await page.getByRole("button", { name: "로그아웃" }).click(); await expect(page).toHaveURL(/\/login$/); + + await page.setViewportSize({ width: 320, height: 640 }); + await zoomTo200Percent(page); + + const loginBanner = page.getByRole("status", { name: /Mock Preview/ }); + const email = page.getByLabel("이메일"); + await expect(loginBanner).toBeVisible(); + await expect(email).toBeVisible(); + await expect(page.getByLabel("비밀번호")).toBeVisible(); + await expect(page.getByRole("button", { name: "로그인" })).toBeVisible(); + await expectBannerDoesNotOverlap(loginBanner, email); + await expectNoHorizontalOverflow(page); + await resetZoom(page); + await page.getByLabel("이메일").fill("admin@test.com"); await page.getByLabel("비밀번호").fill("password"); await page.getByRole("button", { name: "로그인" }).click(); @@ -75,36 +94,8 @@ test("logs out and logs in again with a fresh mock preview session", async ({ pa // Then await expect(page).toHaveURL(/\/ai-characters$/); await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible(); -}); - -test("keeps the mock login banner and core controls usable at 320px and 200 percent zoom", async ({ page }) => { - // Given - await page.setViewportSize({ width: 320, height: 640 }); - await page.goto("/login"); - - // When await zoomTo200Percent(page); - // Then - const banner = page.getByRole("status", { name: /Mock Preview/ }); - const email = page.getByLabel("이메일"); - await expect(banner).toBeVisible(); - await expect(email).toBeVisible(); - await expect(page.getByLabel("비밀번호")).toBeVisible(); - await expect(page.getByRole("button", { name: "로그인" })).toBeVisible(); - await expectBannerDoesNotOverlap(banner, email); - await expectNoHorizontalOverflow(page); -}); - -test("keeps the mock protected banner and shell controls usable at 320px and 200 percent zoom", async ({ page }) => { - // Given - await page.setViewportSize({ width: 320, height: 640 }); - await loginThroughMockMode(page); - - // When - await zoomTo200Percent(page); - - // Then const banner = page.getByRole("status", { name: /Mock Preview/ }); const menuButton = page.getByRole("button", { name: "모바일 메뉴 열기" }); await expect(banner).toBeVisible(); @@ -112,11 +103,7 @@ test("keeps the mock protected banner and shell controls usable at 320px and 200 await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible(); await expectBannerDoesNotOverlap(banner, menuButton); await expectNoHorizontalOverflow(page); -}); - -test("keeps the mock banner in the mobile menu background and clears inert state at desktop widths", async ({ page }) => { - // Given - await loginThroughMockMode(page); + await resetZoom(page); for (const desktopWidth of [1024, 1200]) { await page.setViewportSize({ width: 320, height: 640 }); @@ -154,17 +141,3 @@ test("keeps the mock banner in the mobile menu background and clears inert state expect(desktopState).toEqual({ bannerHidden: false, bannerInert: false, mainInert: false }); } }); - -test("has no critical or serious axe violations in mock preview mode", async ({ page }) => { - // Given - await loginThroughMockMode(page); - - // When - const results = await new AxeBuilder({ page }).analyze(); - const blockingViolations = results.violations.filter( - (violation) => violation.impact === "critical" || violation.impact === "serious", - ); - - // Then - expect(blockingViolations).toEqual([]); -}); diff --git a/tests/e2e/resource-workflows.spec.ts b/tests/e2e/resource-workflows.spec.ts new file mode 100644 index 0000000..24ca484 --- /dev/null +++ b/tests/e2e/resource-workflows.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +type ApiRequest = { + readonly method: string; + readonly path: string; + readonly search: string; +}; + +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$/); +} + +function collectAdminApiRequests(page: Page): ApiRequest[] { + const requests: ApiRequest[] = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname.startsWith("/api/v2/admin/")) { + requests.push({ method: request.method(), path: url.pathname, search: url.search }); + } + }); + + return requests; +} + +test("active mock resources complete the cross-domain journey without uncontracted requests", async ({ page }, testInfo) => { + test.skip(testInfo.project.name.startsWith("mobile-"), "Desktop-only mutation journey."); + + // Given + const requests = collectAdminApiRequests(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await loginThroughMockMode(page); + + // When / Then + await page.getByRole("link", { name: "루나 선택" }).click(); + await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible(); + + await page.goto("/ai-characters/101/audio-contents"); + await expect(page.getByRole("group", { name: "달빛 상담 오디오 오디오 플레이어" })).toBeVisible(); + await page.getByRole("link", { name: "달빛 상담 오디오 상세 보기" }).click(); + await expect(page.getByRole("heading", { name: "달빛 상담 오디오", exact: true })).toBeVisible(); + + await page.goto("/ai-characters/101/series/5001"); + await expect(page.getByRole("heading", { name: "연결된 오디오", exact: true })).toBeVisible(); + await page.getByRole("searchbox", { name: "검색어" }).fill("아침"); + await page.getByRole("checkbox", { name: /아침 안내 오디오/ }).click(); + await page.getByRole("button", { name: "선택한 오디오 연결" }).click(); + await expect(page.getByRole("button", { name: "아침 안내 오디오 연결 해제" })).toBeVisible(); + await page.getByRole("link", { name: "전체 시리즈 순서 관리" }).click(); + await page.getByRole("button", { name: "아침 루틴 시리즈 위로" }).click(); + await page.getByRole("button", { name: "순서 저장" }).click(); + await expect(page.getByText("시리즈 순서를 저장했습니다")).toBeVisible(); + + await page.goto("/ai-characters/101/community-posts"); + await page.getByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }).click(); + const communityDialog = page.getByRole("dialog", { name: "커뮤니티 게시글" }); + await communityDialog.getByRole("textbox", { name: "내용" }).fill("교차 회귀 수정 내용입니다."); + await communityDialog.getByRole("button", { name: "수정 저장" }).click(); + await expect.poll(() => requests.some((request) => request.method === "PUT" && request.path.includes("/community-posts/"))).toBe(true); + + await page.goto("/ai-characters/101/fan-talks"); + await page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }).click(); + const fanTalkDialog = page.getByRole("dialog", { name: "FanTalk 답변" }); + await fanTalkDialog.getByLabel("답변 내용").fill("교차 회귀 답변입니다."); + await fanTalkDialog.getByRole("button", { name: "답변 등록" }).click(); + await expect(fanTalkDialog.getByRole("status", { name: "답변 저장 성공" })).toBeVisible(); + + expect(requests.some((request) => /\/community-posts\/\d+$/.test(request.path) && request.method === "GET")).toBe(false); + expect(requests.some((request) => request.path.includes("/fan-talks/7001") && request.method === "GET")).toBe(false); + expect(requests.every((request) => !request.search.includes("status=") && !request.search.includes("sort="))).toBe(true); +}); + +test("inactive character workspace blocks child mutation requests", async ({ page }) => { + // Given + const requests = collectAdminApiRequests(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await loginThroughMockMode(page); + + // When / Then + await page.goto("/ai-characters/202/community-posts/new"); + await expect(page.getByRole("heading", { name: "커뮤니티 게시글 생성 차단" })).toBeVisible(); + await expect(page.getByRole("button", { name: "생성" })).toBeHidden(); + + await page.goto("/ai-characters/202/fan-talks"); + await expect(page.getByRole("heading", { name: "FanTalk", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /답변하기/ })).toBeHidden(); + + expect(requests.some((request) => request.method !== "GET" && request.path.includes("/ai-characters/202/"))).toBe(false); +}); diff --git a/tests/e2e/responsive-capabilities.spec.ts b/tests/e2e/responsive-capabilities.spec.ts new file mode 100644 index 0000000..4aef601 --- /dev/null +++ b/tests/e2e/responsive-capabilities.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; + +type ViewportCase = { + readonly height: number; + readonly name: string; + readonly width: number; +}; + +const viewports = [ + { height: 640, name: "mobile 320", width: 320 }, + { height: 640, name: "mobile 640", width: 640 }, + { height: 900, name: "tablet 768", width: 768 }, + { height: 900, name: "desktop 1024", width: 1024 }, + { height: 900, name: "desktop 1280", width: 1280 }, + { height: 390, name: "mobile landscape", width: 844 }, +] as const satisfies readonly ViewportCase[]; + +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 expectNoHorizontalOverflow(page: Page): Promise { + expect(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)).toBe(false); +} + +async function expectTouchTarget(locator: Locator): Promise { + const box = await locator.boundingBox(); + + expect(box).not.toBeNull(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); +} + +test("mock shell stays usable across the P9 viewport matrix", async ({ page }) => { + // Given + await loginThroughMockMode(page); + + for (const viewport of viewports) { + // When + await page.setViewportSize(viewport); + await page.goto("/ai-characters"); + + // Then + await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toBeVisible(); + await expect(page.getByRole("status", { name: /Mock Preview/ })).toBeVisible(); + await expectNoHorizontalOverflow(page); + if (viewport.width < 768) { + await expectTouchTarget(page.getByRole("button", { name: "모바일 메뉴 열기" })); + await expectTouchTarget(page.getByRole("button", { name: "로그아웃" })); + } + } +}); + +test("route capability matches mobile read-only and tablet desktop management rules", async ({ page }) => { + // Given + await loginThroughMockMode(page); + + // When / Then + await page.setViewportSize({ width: 320, height: 640 }); + await page.goto("/ai-characters/101/audio-contents/new"); + await expect(page.getByText(/데스크톱.*(생성|업로드|이용)/)).toBeVisible(); + await expect(page.getByRole("button", { name: "생성" })).toBeHidden(); + + await page.goto("/ai-characters/101/community-posts/new"); + await expect(page.getByRole("heading", { name: "모바일에서는 커뮤니티 게시글 생성을 제한합니다" })).toBeVisible(); + await expect(page.getByRole("button", { name: "생성" })).toBeHidden(); + + await page.goto("/ai-characters/101/fan-talks"); + await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" })).toBeVisible(); + + await page.setViewportSize({ width: 768, height: 900 }); + await page.goto("/ai-characters/101/community-posts"); + await expect(page.getByRole("link", { name: "커뮤니티 게시글 생성" })).toBeVisible(); + + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/ai-characters/101/series/5001"); + await expect(page.getByRole("searchbox", { name: "검색어" })).toBeVisible(); + await expectNoHorizontalOverflow(page); +}); diff --git a/tests/e2e/series.spec.ts b/tests/e2e/series.spec.ts new file mode 100644 index 0000000..1c5acef --- /dev/null +++ b/tests/e2e/series.spec.ts @@ -0,0 +1,321 @@ +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; +const validPngBytes = [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 255, 255, 63, 0, 5, 254, 2, 254, 167, 53, 129, 132, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] as const; + +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 expectNoRawSeriesDisplayText(page: Page): Promise { + await expect(page.getByText(/SUN|PROCEEDING|isAdult true|isActive true|genreId/)).toBeHidden(); +} + +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 < 40; 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$/); +} + +async function setSeriesImageFile(page: Page): Promise { + await page.locator('input[type="file"][accept="image/jpeg,image/png"]').evaluate((element, bytes) => { + if (!(element instanceof HTMLInputElement)) { + throw new Error("Series image input not found"); + } + const transfer = new DataTransfer(); + transfer.items.add(new File([new Uint8Array(bytes)], "series.png", { type: "image/png" })); + element.files = transfer.files; + element.dispatchEvent(new Event("change", { bubbles: true })); + }, validPngBytes); +} + +async function openSeriesDetail(page: Page): Promise { + await page.goto("/ai-characters/101/series/5001"); + await expect(page.getByRole("heading", { name: "달빛 상담 시리즈", exact: true })).toBeVisible(); +} + +async function linkMorningAudio(page: Page): Promise { + await page.getByRole("searchbox", { name: "검색어" }).fill("아침"); + await page.getByRole("checkbox", { name: /아침 안내 오디오/ }).click(); + await page.getByRole("button", { name: "선택한 오디오 연결" }).click(); + await expect(page.getByRole("button", { name: "아침 안내 오디오 연결 해제" })).toBeVisible(); +} + +test("mobile route capability keeps Series list detail and linked content read-only", async ({ page }) => { + // Given + await page.setViewportSize({ width: 320, height: 640 }); + await loginThroughMockMode(page); + + // When / Then + await page.goto("/ai-characters/101/series"); + await expect(page.getByRole("heading", { name: "시리즈", exact: true })).toBeVisible(); + const seriesCard = page.getByRole("link", { name: "달빛 상담 시리즈 상세 보기" }); + await expect(seriesCard).toBeVisible(); + await expect(seriesCard).toContainText("일, 수 · 로맨스"); + await expect(seriesCard).toContainText("연재중"); + await expect(seriesCard).toContainText("전체 이용 · 활성"); + await expectNoRawSeriesDisplayText(page); + await expectActionAbsentAcrossInteractiveRoles(page, "시리즈 생성"); + await expect(page.getByText("시리즈 생성과 수정은 태블릿 이상 화면에서 이용할 수 있습니다.")).toBeVisible(); + await expectNoHorizontalOverflow(page); + + await openSeriesDetail(page); + const seriesSummary = page.getByLabel("시리즈 요약"); + await expect(seriesSummary).toContainText("로맨스"); + await expect(seriesSummary).toContainText("일, 수"); + await expect(seriesSummary).toContainText("연재중"); + await expect(seriesSummary).toContainText("전체 이용"); + await expect(seriesSummary).toContainText("활성"); + await expectNoRawSeriesDisplayText(page); + await expect(page.getByRole("heading", { name: "연결된 오디오", exact: true })).toBeVisible(); + await expect(page.getByText("달빛 상담 오디오", { exact: true })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "검색어" })).toBeHidden(); + await expect(page.getByRole("button", { name: "달빛 상담 오디오 연결 해제" })).toBeHidden(); + await expect(page.getByRole("button", { name: "선택한 오디오 연결" })).toBeHidden(); + await expect(page.getByRole("link", { name: "전체 시리즈 순서 관리" })).toBeHidden(); + await expectActionAbsentAcrossInteractiveRoles(page, "수정"); + await expectActionAbsentAcrossInteractiveRoles(page, "비활성화"); + await expectNoHorizontalOverflow(page); + + await page.goto("/ai-characters/101/series/order"); + await expect(page.getByRole("heading", { name: "태블릿에서 시리즈 순서를 관리해 주세요" })).toBeVisible(); + await expect(page.getByRole("list", { name: "시리즈 순서 목록" })).toBeHidden(); + await expect(page.getByRole("button", { name: "순서 저장" })).toBeHidden(); + await expectNoHorizontalOverflow(page); +}); + +test("desktop Series CRUD uses genre lookup, crop, edit initialization, and soft delete", async ({ page }) => { + // Given + await page.setViewportSize({ width: 1280, height: 900 }); + await loginThroughMockMode(page); + + // When + await page.goto("/ai-characters/101/series"); + await expect(page.getByRole("region", { name: "시리즈 목록" })).toContainText("로맨스"); + await expectNoRawSeriesDisplayText(page); + await page.getByRole("link", { name: "시리즈 생성" }).click(); + + // Then + await expect(page.getByRole("heading", { name: "시리즈 생성" })).toBeVisible(); + await expect(page.getByLabel("장르")).toContainText("로맨스"); + + // When + await page.getByLabel("제목").fill("새 시리즈"); + await page.getByLabel("소개").fill("새로운 시리즈 소개"); + await page.getByLabel("키워드").fill("달빛"); + await page.getByRole("checkbox", { name: "월요일" }).check(); + await page.getByLabel("장르").selectOption("77"); + await setSeriesImageFile(page); + await expect(page.getByRole("dialog", { name: "이미지 crop" })).toBeVisible(); + await page.getByRole("button", { name: "적용" }).click(); + await expect(page.getByRole("dialog", { name: "이미지 crop" })).toBeHidden(); + await page.getByRole("button", { name: "생성" }).click(); + + // Then + await expect(page).toHaveURL(/\/ai-characters\/101\/series$/); + await expect(page.getByText("시리즈를 생성했습니다.")).toBeVisible(); + await expect(page.getByRole("link", { name: "새 시리즈 상세 보기" })).toBeVisible(); + + // When + await page.getByRole("link", { name: "새 시리즈 상세 보기" }).click(); + await page.getByRole("link", { name: "수정" }).click(); + + // Then + await expect(page.getByRole("heading", { name: "시리즈 수정" })).toBeVisible(); + await expect(page.getByLabel("제목")).toHaveValue("새 시리즈"); + await expect(page.getByLabel("상태", { exact: true })).toBeVisible(); + await expect(page.getByLabel("키워드")).toBeHidden(); + + // When + await page.getByLabel("상태", { exact: true }).selectOption("COMPLETE"); + await page.getByRole("button", { name: "저장" }).click(); + + // Then + await expect(page).toHaveURL(/\/ai-characters\/101\/series\/\d+$/); + await expect(page.getByText("시리즈를 저장했습니다.")).toBeVisible(); + + // When + await page.getByRole("link", { name: "수정" }).click(); + await page.getByRole("button", { name: "비활성화" }).click(); + await page.getByRole("button", { name: "비활성화 확인" }).click(); + + // Then + await expect(page).toHaveURL(/\/ai-characters\/101\/series$/); + await expect(page.getByText("시리즈를 비활성화했습니다.")).toBeVisible(); +}); + +for (const width of [768, 1280] as const) { + test(`desktop and tablet Series management flow remains available at ${width}px`, async ({ page }) => { + // Given + await page.setViewportSize({ width, height: 900 }); + await loginThroughMockMode(page); + await openSeriesDetail(page); + + // When / Then + await expect(page.getByRole("link", { name: "전체 시리즈 순서 관리" })).toBeVisible(); + await linkMorningAudio(page); + await page.getByRole("button", { name: "아침 안내 오디오 연결 해제" }).click(); + await expect(page.getByRole("alertdialog", { name: "오디오 연결을 해제하시겠습니까?" })).toContainText("아침 안내 오디오"); + await page.getByRole("button", { name: "연결 해제", exact: true }).click(); + await expect(page.getByRole("button", { name: "아침 안내 오디오 연결 해제" })).toBeHidden(); + + await page.getByRole("link", { name: "전체 시리즈 순서 관리" }).click(); + await expect(page.getByRole("heading", { name: "시리즈 순서 관리" })).toBeVisible(); + await page.getByRole("button", { name: "아침 루틴 시리즈 위로" }).click(); + await page.getByRole("button", { name: "순서 저장" }).click(); + await expect(page.getByText("시리즈 순서를 저장했습니다")).toBeVisible(); + }); +} + +test("keyboard-only Series interactions cover link unlink dialog and order save", async ({ browserName, isMobile, page }) => { + test.skip(isMobile, "Mobile view intentionally blocks Series mutation actions."); + 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 openSeriesDetail(page); + + // When / Then + const searchInput = page.getByRole("searchbox", { name: "검색어" }); + await pressTabUntilFocused(page, searchInput); + await page.keyboard.type("아침"); + const candidate = page.getByRole("checkbox", { name: /아침 안내 오디오/ }); + await pressTabUntilFocused(page, candidate); + await page.keyboard.press("Space"); + await pressTabUntilFocused(page, page.getByRole("button", { name: "선택한 오디오 연결" })); + await page.keyboard.press("Enter"); + await expect(page.getByRole("button", { name: "아침 안내 오디오 연결 해제" })).toBeVisible(); + + const unlinkButton = page.getByRole("button", { name: "아침 안내 오디오 연결 해제" }); + await pressTabUntilFocused(page, unlinkButton); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("alertdialog", { name: "오디오 연결을 해제하시겠습니까?" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("button", { name: "취소" })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(unlinkButton).toBeFocused(); + + await pressTabUntilFocused(page, page.getByRole("link", { name: "전체 시리즈 순서 관리" })); + await page.keyboard.press("Enter"); + await expect(page.getByRole("heading", { name: "시리즈 순서 관리" })).toBeVisible(); + await pressTabUntilFocused(page, page.getByRole("button", { name: "아침 루틴 시리즈 위로" })); + await page.keyboard.press("Enter"); + await pressTabUntilFocused(page, page.getByRole("button", { name: "순서 저장" })); + await page.keyboard.press("Enter"); + await expect(page.getByText("시리즈 순서를 저장했습니다")).toBeVisible(); +}); + +test("Series list detail and order keep 200 percent zoom and axe coverage across route capability widths", async ({ page }) => { + // Given + await loginThroughMockMode(page); + + for (const width of [320, 768, 1280] as const) { + for (const route of ["/ai-characters/101/series", "/ai-characters/101/series/5001", "/ai-characters/101/series/order"] as const) { + await page.setViewportSize({ width, height: 900 }); + await page.goto(route); + + // When + await zoomTo200Percent(page); + + // Then + await expectNoHorizontalOverflow(page); + await expectNoCriticalOrSeriousAxeViolations(page); + } + } +}); + +test("series detail links unlinked audio, unlinks with confirmation, and persists keyboard order", async ({ page }) => { + // Given + await page.setViewportSize({ width: 1280, height: 900 }); + await loginThroughMockMode(page); + + // When + await page.goto("/ai-characters/101/series/5001"); + + // Then + await expect(page.getByRole("heading", { name: "달빛 상담 시리즈", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "연결된 오디오", exact: true })).toBeVisible(); + await expect(page.getByText("총 1개 · 1페이지")).toBeVisible(); + await expect(page.getByRole("button", { name: "달빛 상담 오디오 연결 해제" })).toBeVisible(); + + // When + await page.getByRole("searchbox", { name: "검색어" }).fill("아침"); + await page.getByRole("checkbox", { name: /아침 안내 오디오/ }).click(); + await page.getByRole("button", { name: "선택한 오디오 연결" }).click(); + + // Then + await expect(page.getByRole("button", { name: "아침 안내 오디오 연결 해제" })).toBeVisible(); + await expect(page.getByRole("checkbox", { name: /아침 안내 오디오/ })).toBeHidden(); + + // When + await page.getByRole("button", { name: "달빛 상담 오디오 연결 해제" }).click(); + + // Then + await expect(page.getByRole("alertdialog", { name: "오디오 연결을 해제하시겠습니까?" })).toContainText("달빛 상담 오디오"); + + // When + await page.getByRole("button", { name: "연결 해제", exact: true }).click(); + + // Then + await expect(page.getByRole("button", { name: "달빛 상담 오디오 연결 해제" })).toBeHidden(); + + // When + await page.getByRole("link", { name: "전체 시리즈 순서 관리" }).click(); + + // Then + await expect(page.getByRole("heading", { name: "시리즈 순서 관리" })).toBeVisible(); + + // When + await page.getByRole("button", { name: "아침 루틴 시리즈 위로" }).click(); + await page.getByRole("button", { name: "순서 저장" }).click(); + await page.goBack(); + await page.getByRole("link", { name: "전체 시리즈 순서 관리" }).click(); + + // Then + await expect(page.getByRole("heading", { name: "시리즈 순서 관리" })).toBeVisible(); + const firstRow = page.getByRole("list", { name: "시리즈 순서 목록" }).getByRole("listitem").first(); + await expect(firstRow).toContainText("아침 루틴 시리즈"); +}); diff --git a/tests/e2e/server-mode-boundary.spec.ts b/tests/e2e/server-mode-boundary.spec.ts index 7880588..ab166fe 100644 --- a/tests/e2e/server-mode-boundary.spec.ts +++ b/tests/e2e/server-mode-boundary.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; -const apiBaseUrl = "https://test-character-admin.sodalive.net"; +import { apiBaseUrl } from "./api-base-url"; const authSession = JSON.stringify({ token: "admin-token", role: "ADMIN" }); async function hasMockWorker(page: Page): Promise { @@ -87,7 +87,7 @@ test("keeps a network error from falling back to browser MSW", async ({ page }) await page.goto("/ai-characters"); // Then - await expect(page.getByRole("alert")).toContainText("보호 route 확인에 실패했습니다."); + await expect(page.getByRole("alert")).toContainText("알 수 없는 오류가 발생했습니다."); await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0); await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0); expect(await hasMockWorker(page)).toBe(false); @@ -110,7 +110,7 @@ test("keeps retry available after a server network retry fails", async ({ page } await page.goto("/ai-characters"); const retryButton = page.getByRole("button", { name: "보호 route 다시 시도" }); await expect(retryButton).toBeVisible(); - await expect(page.getByRole("alert")).toContainText("보호 route 확인에 실패했습니다."); + await expect(page.getByRole("alert")).toContainText("알 수 없는 오류가 발생했습니다."); await page.unroute(protectedRouteUrl); await page.route(protectedRouteUrl, async (route) => { retryRequestCount += 1; @@ -128,7 +128,7 @@ test("keeps retry available after a server network retry fails", async ({ page } await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0); releaseRetryFailure(); await expect(page.getByRole("button", { name: "보호 route 다시 시도" })).toBeVisible(); - await expect(page.getByRole("alert")).toContainText("보호 route 확인에 실패했습니다."); + await expect(page.getByRole("alert")).toContainText("알 수 없는 오류가 발생했습니다."); await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0); await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0); expect(await hasMockWorker(page)).toBe(false);