import http, { type Server } from "node:http"; import { promises as fs } from "node:fs"; import { AddressInfo } from "node:net"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { expect, test, type Page } from "@playwright/test"; import { expectNoHorizontalOverflow } from "./support"; const specDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(specDir, "..", "..", ".."); const dashboardPath = path.join(repoRoot, "docs", "dev_dashboard.html"); interface StaticServer { port: number; close: () => Promise; } interface DashboardMetrics { total: number; done: number; doing: number; planned: number; ownerCards: number; ownerBoard: number; crit: number; ui: Record; ownerColumns: Record; visibleCards: number; visibleGroups: number; phaseSegments: number; trackRows: number; trackNames: string[]; stackSegments: string[]; } async function startStaticServer(root: string): Promise { const server: Server = http.createServer(async (req, res) => { try { const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1"); const requestPath = requestUrl.pathname === "/" ? "/docs/dev_dashboard.html" : decodeURIComponent(requestUrl.pathname); const normalized = path.normalize(requestPath).replace(/^([/\\])+/, ""); const filePath = path.join(root, normalized); const relative = path.relative(root, filePath); if (relative.startsWith("..") || path.isAbsolute(relative)) { res.writeHead(403).end("Forbidden"); return; } const body = await fs.readFile(filePath); res.writeHead(200, { "content-type": contentType(filePath) }); res.end(body); } catch { res.writeHead(404).end("Not found"); } }); await new Promise((resolve) => server.listen(0, resolve)); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Static dashboard server did not expose a TCP port"); } return { port: (address as AddressInfo).port, close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), }; } function contentType(filePath: string) { const ext = path.extname(filePath).toLowerCase(); if (ext === ".html") return "text/html; charset=utf-8"; if (ext === ".css") return "text/css; charset=utf-8"; if (ext === ".js" || ext === ".mjs") return "text/javascript; charset=utf-8"; if (ext === ".png") return "image/png"; if (ext === ".svg") return "image/svg+xml"; return "application/octet-stream"; } async function openDashboard(page: Page, url: string) { const errors: string[] = []; page.on("console", (msg) => { if (msg.type() === "error") errors.push(msg.text()); }); page.on("pageerror", (err) => errors.push(err.message)); const response = await page.goto(url, { waitUntil: "domcontentloaded" }); if (url.startsWith("http")) { expect(response?.ok(), `${url} should load over HTTP`).toBeTruthy(); } await expect(page.locator("#phase-rail .pr-seg")).toHaveCount(6); expect(errors).toEqual([]); } async function readMetrics(page: Page): Promise { return await page.evaluate(() => { const cards = Array.from(document.querySelectorAll(".scard")); const statusCount = (status: string) => cards.filter((card) => card.dataset.status === status).length; const text = (selector: string) => document.querySelector(selector)?.textContent?.trim() ?? ""; const countOwnerColumn = (column: string) => document.querySelector(`[data-owner-col="${column}"]`)?.querySelectorAll('.ocard[data-owner="1"]').length ?? 0; return { total: cards.length, done: statusCount("done"), doing: statusCount("doing"), planned: cards.length - statusCount("done") - statusCount("doing"), ownerCards: cards.filter((card) => card.dataset.owner === "1").length, ownerBoard: document.querySelectorAll('.ocard[data-owner="1"]').length, crit: cards.filter((card) => card.querySelector(".critbadge")).length, ui: { kpiDone: text("#kpi-done"), kpiDoing: text("#kpi-doing"), kpiPlan: text("#kpi-plan"), kpiOwner: text("#kpi-owner"), pillOwner: text("#pill-owner"), total: text("#g-total"), legendDone: text("#lg-done"), legendDoing: text("#lg-doing"), legendPlan: text("#lg-plan"), filterAll: text('[data-count="all"]'), filterDone: text('[data-count="done"]'), filterDoing: text('[data-count="doing"]'), filterPlanned: text('[data-count="planned"]'), filterOwner: text('[data-count="owner"]'), filterCrit: text('[data-count="crit"]'), }, ownerColumns: { block: countOwnerColumn("block"), decide: countOwnerColumn("decide"), ext: countOwnerColumn("ext"), }, visibleCards: cards.filter((card) => !card.classList.contains("hide")).length, visibleGroups: Array.from(document.querySelectorAll("#board .track-group")).filter( (group) => getComputedStyle(group).display !== "none", ).length, phaseSegments: document.querySelectorAll("#phase-rail .pr-seg").length, trackRows: document.querySelectorAll("#tracks .track").length, trackNames: Array.from(document.querySelectorAll("#tracks .track .tname")).map( (el) => el.textContent?.trim() ?? "", ), stackSegments: Array.from(document.querySelectorAll("#stackbar i")).map((el) => el.textContent?.trim() ?? ""), }; }); } function expectDerivedMetrics(metrics: DashboardMetrics) { expect(metrics.total).toBeGreaterThan(0); expect(metrics.done).toBe(33); expect(metrics.doing).toBe(2); expect(metrics.planned).toBe(0); expect(metrics.total).toBe(metrics.done + metrics.doing + metrics.planned); expect(metrics.ownerBoard).toBe( metrics.ownerColumns.block + metrics.ownerColumns.decide + metrics.ownerColumns.ext, ); expect(metrics.ownerBoard).toBeGreaterThan(0); expect(metrics.crit).toBe(3); expect(metrics.ui).toMatchObject({ kpiDone: String(metrics.done), kpiDoing: String(metrics.doing), kpiPlan: String(metrics.planned), kpiOwner: String(metrics.ownerBoard), pillOwner: String(metrics.ownerBoard), total: String(metrics.total), legendDone: String(metrics.done), legendDoing: String(metrics.doing), legendPlan: String(metrics.planned), filterAll: String(metrics.total), filterDone: String(metrics.done), filterDoing: String(metrics.doing), filterPlanned: String(metrics.planned), filterOwner: String(metrics.ownerCards), filterCrit: String(metrics.crit), }); expect(metrics.phaseSegments).toBe(6); expect(metrics.trackRows).toBeGreaterThan(1); expect(metrics.trackNames).not.toContain(""); expect(metrics.stackSegments).toEqual( [metrics.done, metrics.doing, metrics.planned] .filter((count) => count > 0) .map(String), ); } async function expectVisibleCards(page: Page, expected: number) { await expect .poll(async () => (await readMetrics(page)).visibleCards, { message: `expected ${expected} visible cards` }) .toBe(expected); } test.describe.configure({ mode: "serial" }); test.describe("dev dashboard static command center", () => { let staticServer: StaticServer; test.beforeAll(async () => { staticServer = await startStaticServer(repoRoot); }); test.afterAll(async () => { await staticServer.close(); }); test("derives counters and graphs on local file and HTTP origins", async ({ page }) => { const urls = [ pathToFileURL(dashboardPath).toString(), `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`, ]; for (const url of urls) { await openDashboard(page, url); expectDerivedMetrics(await readMetrics(page)); } }); test("keeps filters stable under empty states, wrong order, and rapid retries", async ({ page }) => { await openDashboard(page, `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`); const initial = await readMetrics(page); expectDerivedMetrics(initial); await page.locator('[data-filter="planned"]').click(); await expectVisibleCards(page, initial.planned); await expect(page.locator("#board .track-group").first()).not.toBeVisible(); await page.locator('[data-filter="done"]').focus(); await page.keyboard.press("Enter"); await expect(page.locator('[data-filter="done"]')).toHaveAttribute("aria-pressed", "true"); await expectVisibleCards(page, initial.done); await page.locator('[data-filter="planned"]').focus(); await page.keyboard.press("Space"); await expect(page.locator('[data-filter="planned"]')).toHaveAttribute("aria-pressed", "true"); await expectVisibleCards(page, initial.planned); const rapidSequence = ["all", "crit", "doing", "owner", "planned", "done", "all", "crit", "all"]; await page.evaluate((filters) => { for (const filter of filters) { document .querySelector(`[data-filter="${filter}"]`) ?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } }, rapidSequence); await expect(page.locator('[data-filter="all"]')).toHaveAttribute("aria-pressed", "true"); await expectVisibleCards(page, initial.total); expectDerivedMetrics(await readMetrics(page)); const firstCard = page.locator(".scard").first(); const firstHead = firstCard.locator(".scard-head"); await firstHead.click(); await expect(firstHead).toHaveAttribute("aria-expanded", "true"); await expect(firstCard).toHaveClass(/open/); await page.locator('[data-filter="planned"]').click(); await expectVisibleCards(page, initial.planned); await page.locator('[data-filter="all"]').click(); await expect(firstHead).toHaveAttribute("aria-expanded", "true"); await firstHead.click(); await expect(firstHead).toHaveAttribute("aria-expanded", "false"); }); test("keeps local links, images, and hash anchors intact", async ({ page }) => { const dashboardUrl = `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`; await openDashboard(page, dashboardUrl); await page.locator(".owner-pill").click(); await expect(page).toHaveURL(/#owner$/); await expect(page.locator("#owner")).toBeInViewport(); await page.locator(".kpi.k-done").click(); await expect(page).toHaveURL(/#board$/); await expect(page.locator("#board")).toBeInViewport(); const references = await page.evaluate(() => Array.from(document.querySelectorAll("[href],[src]")) .map((el) => ({ tag: el.tagName.toLowerCase(), value: el.getAttribute("href") ?? el.getAttribute("src") ?? "", })) .filter((item) => item.value.length > 0), ); for (const reference of references) { if (reference.value.startsWith("#")) { await expect(page.locator(reference.value)).toHaveCount(1); continue; } const resolved = new URL(reference.value, dashboardUrl); if (resolved.origin !== new URL(dashboardUrl).origin) { expect(["http:", "https:"], `${reference.tag} ${reference.value}`).toContain(resolved.protocol); continue; } const response = await page.request.get(resolved.toString()); expect(response.ok(), `${reference.tag} ${reference.value} should resolve`).toBeTruthy(); } }); test("supports tab click and keyboard navigation after repeated opening", async ({ page }) => { await openDashboard(page, `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`); for (const foldId of ["fold-roadmap", "fold-tabs", "fold-narrative"]) { const fold = page.locator(`#${foldId}`); const summary = fold.locator("summary"); await summary.click(); await expect(fold).toHaveJSProperty("open", true); await summary.click(); await expect(fold).toHaveJSProperty("open", false); await summary.click(); await expect(fold).toHaveJSProperty("open", true); } const tabs = page.locator('[role="tab"][data-tab]'); const tabCount = await tabs.count(); expect(tabCount).toBeGreaterThan(3); for (let index = tabCount - 1; index >= 0; index -= 1) { const tab = tabs.nth(index); const panelId = await tab.getAttribute("aria-controls"); expect(panelId).toBeTruthy(); await tab.click(); await expect(tab).toHaveAttribute("aria-selected", "true"); await expect(page.locator(`#${panelId}`)).toBeVisible(); await expect(page.locator('[role="tabpanel"]:not([hidden])')).toHaveCount(1); } await tabs.first().focus(); await page.keyboard.press("End"); await expect(tabs.nth(tabCount - 1)).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("Home"); await expect(tabs.first()).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("ArrowRight"); await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("ArrowLeft"); await expect(tabs.first()).toHaveAttribute("aria-selected", "true"); }); test("does not overflow at desktop and mobile widths in the simulated external origin", async ({ page }) => { for (const viewport of [ { width: 1440, height: 900 }, { width: 390, height: 844 }, { width: 320, height: 568 }, ]) { await page.setViewportSize(viewport); await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`); await expectNoHorizontalOverflow(page); const pulseDetails = page.locator(".pulse-details"); await expect(pulseDetails).toHaveJSProperty("open", false); await pulseDetails.locator("summary").click(); await expect(pulseDetails).toHaveJSProperty("open", true); await expect(pulseDetails).toContainText("b34623f3"); await expect(pulseDetails).toContainText("실제 mic·동시 high-water·human evidence"); await pulseDetails.locator("summary").click(); await expect(pulseDetails).toHaveJSProperty("open", false); if (viewport.width <= 760) { const controlHeights = await page .locator(".owner-pill, .fbtn, .tab-button, .dg-link, .pulse-details summary") .evaluateAll((controls) => controls.map((control) => Math.ceil(control.getBoundingClientRect().height)), ); expect(controlHeights.length).toBeGreaterThan(0); expect(controlHeights.every((height) => height >= 44)).toBe(true); const firstScreenPositions = await page.locator("#phase-rail, .kpis").evaluateAll((regions) => regions.map((region) => Math.floor(region.getBoundingClientRect().top)), ); expect(firstScreenPositions).toHaveLength(2); expect(firstScreenPositions.every((top) => top < viewport.height)).toBe(true); } } }); });