import { promises as fs } from "node:fs"; import path from "node:path"; import { expect, test } from "@playwright/test"; const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net"; const SHOT_DIR = path.join(process.cwd(), "node_modules", ".tmp", "public-admin-visual"); interface AuthMeResponse { email?: string; role?: string; admin_access?: boolean; super_admin?: boolean; } async function browserFetchJson( page: import("@playwright/test").Page, pathName: string, ) { return page.evaluate( async ({ apiBase, apiPath }) => { const response = await fetch(`${apiBase}${apiPath}`, { credentials: "include", headers: { accept: "application/json" }, }); const text = await response.text(); let json: unknown = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { status: response.status, ok: response.ok, text, json }; }, { apiBase: publicApiBase, apiPath: pathName }, ) as Promise<{ status: number; ok: boolean; text: string; json: T | null }>; } test.describe("public admin visual @public-auth", () => { test("renders the public admin home with visible operational content", async ({ page, }, testInfo) => { if (!process.env.E2E_PUBLIC_STORAGE_STATE) { throw new Error( "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.", ); } await fs.mkdir(SHOT_DIR, { recursive: true }); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/admin", { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); const me = await browserFetchJson(page, "/auth/me"); expect(me.status, me.text).toBe(200); expect( me.json?.role === "admin" || me.json?.admin_access === true || me.json?.super_admin === true, JSON.stringify(me.json), ).toBeTruthy(); await expect(page).toHaveURL(/\/admin(?:$|[?#])/); await expect(page.locator("[data-vignette-admin-root]")).toBeVisible({ timeout: 15_000 }); await expect(page.locator(".vg-shell")).toBeVisible(); await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible(); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible(); await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible(); await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible(); await expect(page.locator(".vgops-kpi b")).toHaveCount(4); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); const visualReport = await page.evaluate(() => { const adminRoot = document.querySelector("[data-vignette-admin-root]"); const main = document.querySelector("main"); const heading = Array.from(document.querySelectorAll("h1,h2,h3")).find( (node) => node.textContent?.includes("현재 서비스 상태"), ); const visibleNodes = Array.from( document.querySelectorAll( ".vgops-root,.vgops-head,.vgops-kpi,.vgops-panel,.vgops-service,.vg-shell,.vg-nav,h1,h2,h3,p,a,button", ), ).filter((node) => { const rect = node.getBoundingClientRect(); const style = getComputedStyle(node); return ( rect.width > 4 && rect.height > 4 && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" ); }); const rootRect = adminRoot?.getBoundingClientRect(); const mainRect = main?.getBoundingClientRect(); const headingRect = heading?.getBoundingClientRect(); return { url: location.href, bodyTextLength: document.body.innerText.trim().length, visibleNodeCount: visibleNodes.length, adminRootRect: rootRect ? { x: rootRect.x, y: rootRect.y, width: rootRect.width, height: rootRect.height } : null, mainRect: mainRect ? { x: mainRect.x, y: mainRect.y, width: mainRect.width, height: mainRect.height } : null, headingRect: headingRect ? { x: headingRect.x, y: headingRect.y, width: headingRect.width, height: headingRect.height } : null, horizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, }; }); expect(visualReport.bodyTextLength, JSON.stringify(visualReport)).toBeGreaterThan(400); expect(visualReport.visibleNodeCount, JSON.stringify(visualReport)).toBeGreaterThan(20); expect(visualReport.adminRootRect?.width, JSON.stringify(visualReport)).toBeGreaterThan(900); expect(visualReport.adminRootRect?.height, JSON.stringify(visualReport)).toBeGreaterThan(500); expect(visualReport.mainRect?.width, JSON.stringify(visualReport)).toBeGreaterThan(800); expect(visualReport.headingRect?.y, JSON.stringify(visualReport)).toBeGreaterThanOrEqual(0); expect(visualReport.headingRect?.y, JSON.stringify(visualReport)).toBeLessThan(900); expect(visualReport.horizontalOverflow, JSON.stringify(visualReport)).toBeLessThanOrEqual(1); const shotPath = path.join(SHOT_DIR, `admin-home-${testInfo.project.name}.png`); await page.screenshot({ path: shotPath, fullPage: true }); const shot = await fs.stat(shotPath); expect(shot.size, `admin visual screenshot was too small: ${shotPath}`).toBeGreaterThan( 80_000, ); }); test("shows the live Codex and Agy model catalogs with safe defaults", async ({ page }) => { if (!process.env.E2E_PUBLIC_STORAGE_STATE) { throw new Error( "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.", ); } const pageErrors: string[] = []; const consoleErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); await page.goto("/admin/ai", { waitUntil: "domcontentloaded" }); await expect(page.locator('[data-testid="admin-ai-page"]')).toBeVisible({ timeout: 15_000 }); const provider = page.getByLabel("AI 엔진 공급자"); const model = page.getByLabel("AI 기본 모델"); const effort = page.getByLabel("AI 추론 강도"); await expect(provider.locator("option")).toHaveCount(6); await provider.selectOption("codex_cli"); await expect(model).toBeEnabled({ timeout: 30_000 }); await expect(model).toHaveValue("gpt-5.6-terra"); await expect(effort).toHaveValue("medium"); await expect(page.getByText("7개 모델 확인됨")).toBeVisible(); await provider.selectOption("agy_cli"); await expect(model).toBeEnabled({ timeout: 30_000 }); await expect(model).toHaveValue("gemini-3.6-flash-high"); await expect(effort).toHaveValue("high"); await expect(page.getByText("11개 모델 확인됨")).toBeVisible(); expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]); expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]); }); test("keeps the public admin visible with EasyList cosmetic filters active", async ({ page, }) => { if (!process.env.E2E_PUBLIC_STORAGE_STATE) { throw new Error( "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.", ); } const pageErrors: string[] = []; const consoleErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); await page.addInitScript(() => { // 모든 공개 관리자 navigation의 첫 paint부터 실제 EasyList 충돌 규칙을 적용한다. const style = document.createElement("style"); style.dataset.testEasylist = "true"; style.textContent = ".ad-root,.ad-section{display:none!important}"; const install = () => { const target = document.head ?? document.documentElement; if (!target) return false; target.append(style); return true; }; if (!install()) { const observer = new MutationObserver(() => { if (install()) observer.disconnect(); }); observer.observe(document, { childList: true, subtree: true }); } }); const sections = [ { path: "/admin", heading: "현재 서비스 상태", root: "[data-vignette-admin-root]" }, { path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" }, { path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" }, { path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" }, { path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" }, ] as const; for (const section of sections) { await page.goto(section.path, { waitUntil: "domcontentloaded" }); await expect(page.locator(section.root)).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole("heading", { name: section.heading })).toBeVisible(); await expect(page.locator('[class^="ad-"],[class*=" ad-"]')).toHaveCount(0); } await page.waitForTimeout(4_000); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); const adminRoot = page.locator("[data-vignette-admin-root]"); await expect(adminRoot).toBeVisible(); const visibility = await adminRoot.evaluate((root) => { const rect = root.getBoundingClientRect(); const style = getComputedStyle(root); return { width: rect.width, height: rect.height, display: style.display, visibility: style.visibility, opacity: style.opacity, }; }); expect(visibility.width, JSON.stringify(visibility)).toBeGreaterThan(900); expect(visibility.height, JSON.stringify(visibility)).toBeGreaterThan(500); expect(visibility.display, JSON.stringify(visibility)).not.toBe("none"); expect(visibility.visibility, JSON.stringify(visibility)).not.toBe("hidden"); expect(visibility.opacity, JSON.stringify(visibility)).not.toBe("0"); expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]); expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]); }); test("renders every public admin section without a silent blank pane", async ({ page }) => { if (!process.env.E2E_PUBLIC_STORAGE_STATE) { throw new Error( "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.", ); } const pageErrors: string[] = []; const consoleErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); const sections = [ { path: "/admin", heading: "현재 서비스 상태", root: "[data-vignette-admin-root]" }, { path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" }, { path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" }, { path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" }, { path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" }, ] as const; for (const section of sections) { await page.goto(section.path, { waitUntil: "domcontentloaded" }); await expect(page.locator(section.root)).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole("heading", { name: section.heading })).toBeVisible(); await expect(page.locator(".vgops-diagnostic")).toHaveCount(0); const report = await page.evaluate((rootSelector) => { const root = document.querySelector(rootSelector); const main = document.querySelector(".vg-main"); const rect = root?.getBoundingClientRect(); const mainRect = main?.getBoundingClientRect(); const visibleNodes = root ? Array.from(root.querySelectorAll("h1,h2,p,button,input,select,article,section")) .filter((node) => { const nodeRect = node.getBoundingClientRect(); const style = getComputedStyle(node); return ( nodeRect.width > 1 && nodeRect.height > 1 && nodeRect.bottom > (mainRect?.top ?? 0) && nodeRect.top < (mainRect?.bottom ?? window.innerHeight) && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" ); }).length : 0; return { textLength: root?.innerText.trim().length ?? 0, visibleNodes, width: rect?.width ?? 0, height: rect?.height ?? 0, scrollY: window.scrollY, mainScrollTop: main?.scrollTop ?? -1, }; }, section.root); expect(report.textLength, JSON.stringify({ section, report })).toBeGreaterThan(120); expect(report.visibleNodes, JSON.stringify({ section, report })).toBeGreaterThan(5); expect(report.width, JSON.stringify({ section, report })).toBeGreaterThan(300); expect(report.height, JSON.stringify({ section, report })).toBeGreaterThan(120); expect(report.scrollY, JSON.stringify({ section, report })).toBe(0); expect(report.mainScrollTop, JSON.stringify({ section, report })).toBe(0); } expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]); expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]); }); test("keeps the operating console visible from the primary learner workspace", async ({ page }) => { if (!process.env.E2E_PUBLIC_STORAGE_STATE) { throw new Error( "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.", ); } await page.goto("/learn", { waitUntil: "domcontentloaded" }); const adminEntry = page.locator(".vg-nav").getByRole("link", { name: "운영 콘솔" }); await expect(adminEntry).toBeVisible({ timeout: 15_000 }); await adminEntry.click(); await expect(page).toHaveURL(/\/admin(?:$|[?#])/); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); }); test("returns a restored admin tab to visible content after pageshow", async ({ page }) => { if (!process.env.E2E_PUBLIC_STORAGE_STATE) { throw new Error( "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.", ); } await page.goto("/admin", { waitUntil: "domcontentloaded" }); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); await expect(page.locator("[data-vignette-admin-root]")).toBeVisible(); const beforeRestore = await page.evaluate(() => { const main = document.querySelector(".vg-main"); const root = document.querySelector("[data-vignette-admin-root]"); if (!main || !root) throw new Error("admin scroll container missing"); root.style.minHeight = "2200px"; main.scrollTop = 900; return main.scrollTop; }); expect(beforeRestore).toBeGreaterThan(0); await page.evaluate(() => { window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true })); }); await expect .poll( () => page.evaluate( () => document.querySelector(".vg-main")?.scrollTop ?? -1, ), { timeout: 5_000 }, ) .toBe(0); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeInViewport(); }); });