367 lines
15 KiB
TypeScript
367 lines
15 KiB
TypeScript
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<void>;
|
|
}
|
|
|
|
interface DashboardMetrics {
|
|
total: number;
|
|
done: number;
|
|
doing: number;
|
|
planned: number;
|
|
ownerCards: number;
|
|
ownerBoard: number;
|
|
crit: number;
|
|
ui: Record<string, string>;
|
|
ownerColumns: Record<string, number>;
|
|
visibleCards: number;
|
|
visibleGroups: number;
|
|
phaseSegments: number;
|
|
trackRows: number;
|
|
trackNames: string[];
|
|
stackSegments: string[];
|
|
}
|
|
|
|
async function startStaticServer(root: string): Promise<StaticServer> {
|
|
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<void>((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<void>((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<DashboardMetrics> {
|
|
return await page.evaluate(() => {
|
|
const cards = Array.from(document.querySelectorAll<HTMLElement>(".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<HTMLElement>("#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<HTMLElement>(`[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<HTMLAnchorElement | HTMLImageElement | HTMLLinkElement>("[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 },
|
|
]) {
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
});
|