feat(ai-character): Mock Preview 모드 구현
This commit is contained in:
20
tests/e2e/mock-mode-boundary.spec.ts
Normal file
20
tests/e2e/mock-mode-boundary.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("registers a browser MSW worker in explicit mock mode", async ({ page }) => {
|
||||
// Given
|
||||
const expectedWorkerRegistration = true;
|
||||
|
||||
// When
|
||||
await page.goto("/");
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(async () => {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
return registration.active?.scriptURL.endsWith("/mockServiceWorker.js") ?? false;
|
||||
}),
|
||||
)
|
||||
.toBe(expectedWorkerRegistration);
|
||||
});
|
||||
170
tests/e2e/mock-preview-shell.spec.ts
Normal file
170
tests/e2e/mock-preview-shell.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
async function loginThroughMockMode(page: Page): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("이메일").fill("admin@test.com");
|
||||
await page.getByLabel("비밀번호").fill("password");
|
||||
await page.getByRole("button", { name: "로그인" }).click();
|
||||
await expect(page).toHaveURL(/\/ai-characters$/);
|
||||
}
|
||||
|
||||
async function zoomTo200Percent(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.zoom = "2";
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
|
||||
const hasHorizontalOverflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
);
|
||||
|
||||
expect(hasHorizontalOverflow).toBe(false);
|
||||
}
|
||||
|
||||
async function expectBannerDoesNotOverlap(banner: Locator, control: Locator): Promise<void> {
|
||||
const bannerBox = await banner.boundingBox();
|
||||
const controlBox = await control.boundingBox();
|
||||
|
||||
expect(bannerBox).not.toBeNull();
|
||||
expect(controlBox).not.toBeNull();
|
||||
|
||||
if (bannerBox === null || controlBox === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(controlBox.y);
|
||||
}
|
||||
|
||||
test("logs in through mock mode and opens the protected shell without backend fallback", async ({ page }) => {
|
||||
// Given
|
||||
const interceptedApiContractRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.hostname !== "127.0.0.1" && url.pathname !== "/mockServiceWorker.js") {
|
||||
interceptedApiContractRequests.push(`${request.method()} ${url.pathname}${url.search}`);
|
||||
}
|
||||
});
|
||||
|
||||
// When
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
// Then
|
||||
await expect(page.getByRole("status", { name: /Mock Preview/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
|
||||
expect(interceptedApiContractRequests).toEqual([
|
||||
"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.getByLabel("이메일").fill("admin@test.com");
|
||||
await page.getByLabel("비밀번호").fill("password");
|
||||
await page.getByRole("button", { name: "로그인" }).click();
|
||||
|
||||
// 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();
|
||||
await expect(menuButton).toBeVisible();
|
||||
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);
|
||||
|
||||
for (const desktopWidth of [1024, 1200]) {
|
||||
await page.setViewportSize({ width: 320, height: 640 });
|
||||
await page.getByRole("button", { name: "모바일 메뉴 열기" }).click();
|
||||
|
||||
const openState = await page.evaluate(() => {
|
||||
const banner = document.querySelector("[aria-label='Mock Preview']");
|
||||
const main = document.querySelector("main[aria-label='AI 캐릭터 관리']");
|
||||
|
||||
return {
|
||||
bannerInert: banner?.closest("[inert]") !== null,
|
||||
bannerHidden: banner?.closest("[aria-hidden='true']") !== null,
|
||||
mainInert: main?.closest("[inert]") !== null,
|
||||
};
|
||||
});
|
||||
expect(openState).toEqual({ bannerHidden: true, bannerInert: true, mainInert: true });
|
||||
|
||||
// When
|
||||
await page.setViewportSize({ width: desktopWidth, height: 800 });
|
||||
|
||||
// Then
|
||||
await expect(page.getByRole("navigation", { name: "모바일 주 메뉴" })).toBeHidden();
|
||||
await expect(page.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
|
||||
const desktopState = await page.evaluate(() => {
|
||||
const banner = document.querySelector("[aria-label='Mock Preview']");
|
||||
const main = document.querySelector("main[aria-label='AI 캐릭터 관리']");
|
||||
|
||||
return {
|
||||
bannerInert: banner?.closest("[inert]") !== null,
|
||||
bannerHidden: banner?.closest("[aria-hidden='true']") !== null,
|
||||
mainInert: main?.closest("[inert]") !== null,
|
||||
};
|
||||
});
|
||||
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([]);
|
||||
});
|
||||
135
tests/e2e/server-mode-boundary.spec.ts
Normal file
135
tests/e2e/server-mode-boundary.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const apiBaseUrl = "https://test-character-admin.sodalive.net";
|
||||
const authSession = JSON.stringify({ token: "admin-token", role: "ADMIN" });
|
||||
|
||||
async function hasMockWorker(page: Page): Promise<boolean> {
|
||||
return page.evaluate(async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
|
||||
return registrations.some((registration) =>
|
||||
registration.active?.scriptURL.endsWith("/mockServiceWorker.js"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps server mode requests outside browser MSW", async ({ page }) => {
|
||||
// Given
|
||||
await page.goto("/");
|
||||
|
||||
// When
|
||||
const registeredMockWorker = await hasMockWorker(page);
|
||||
|
||||
// Then
|
||||
expect(registeredMockWorker).toBe(false);
|
||||
});
|
||||
|
||||
test("retries a server 404 without falling back to browser MSW", async ({ page }) => {
|
||||
// Given
|
||||
await page.addInitScript((session) => {
|
||||
sessionStorage.setItem("ai-character-admin-auth-session", session);
|
||||
}, authSession);
|
||||
const protectedRouteUrl = `${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`;
|
||||
let retryRequestCount = 0;
|
||||
let releaseRetryResponse: () => void = () => undefined;
|
||||
const retryResponseGate = new Promise<void>((resolve) => {
|
||||
releaseRetryResponse = resolve;
|
||||
});
|
||||
await page.route(protectedRouteUrl, async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
json: { success: false, message: "없습니다.", data: null, errorProperty: null },
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
await page.goto("/ai-characters");
|
||||
const retryButton = page.getByRole("button", { name: "보호 route 다시 시도" });
|
||||
await expect(retryButton).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toContainText("없습니다.");
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0);
|
||||
await page.unroute(protectedRouteUrl);
|
||||
await page.route(protectedRouteUrl, async (route) => {
|
||||
retryRequestCount += 1;
|
||||
await retryResponseGate;
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
json: { success: true, message: null, data: null, errorProperty: null },
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
// When
|
||||
await retryButton.click();
|
||||
|
||||
// Then
|
||||
await expect.poll(() => retryRequestCount).toBe(1);
|
||||
await expect(page.getByRole("status")).toContainText("보호 route 확인 중");
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0);
|
||||
releaseRetryResponse();
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
|
||||
expect(await hasMockWorker(page)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps a network error from falling back to browser MSW", async ({ page }) => {
|
||||
// Given
|
||||
await page.addInitScript((session) => {
|
||||
sessionStorage.setItem("ai-character-admin-auth-session", session);
|
||||
}, authSession);
|
||||
await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, async (route) => {
|
||||
await route.abort("failed");
|
||||
});
|
||||
|
||||
// When
|
||||
await page.goto("/ai-characters");
|
||||
|
||||
// Then
|
||||
await expect(page.getByRole("alert")).toContainText("보호 route 확인에 실패했습니다.");
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0);
|
||||
expect(await hasMockWorker(page)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps retry available after a server network retry fails", async ({ page }) => {
|
||||
// Given
|
||||
await page.addInitScript((session) => {
|
||||
sessionStorage.setItem("ai-character-admin-auth-session", session);
|
||||
}, authSession);
|
||||
const protectedRouteUrl = `${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`;
|
||||
let retryRequestCount = 0;
|
||||
let releaseRetryFailure: () => void = () => undefined;
|
||||
const retryFailureGate = new Promise<void>((resolve) => {
|
||||
releaseRetryFailure = resolve;
|
||||
});
|
||||
await page.route(protectedRouteUrl, async (route) => {
|
||||
await route.abort("failed");
|
||||
});
|
||||
await page.goto("/ai-characters");
|
||||
const retryButton = page.getByRole("button", { name: "보호 route 다시 시도" });
|
||||
await expect(retryButton).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toContainText("보호 route 확인에 실패했습니다.");
|
||||
await page.unroute(protectedRouteUrl);
|
||||
await page.route(protectedRouteUrl, async (route) => {
|
||||
retryRequestCount += 1;
|
||||
await retryFailureGate;
|
||||
await route.abort("failed");
|
||||
});
|
||||
|
||||
// When
|
||||
await retryButton.click();
|
||||
|
||||
// Then
|
||||
await expect.poll(() => retryRequestCount).toBe(1);
|
||||
await expect(page.getByRole("status")).toContainText("보호 route 확인 중");
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
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("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0);
|
||||
expect(await hasMockWorker(page)).toBe(false);
|
||||
});
|
||||
Reference in New Issue
Block a user