vignette/apps/web/e2e/dev-dashboard.spec.ts
Yun Chan 93dd8f82d7 G8 실제 rollback 증명 종료와 비-secure origin 회기 리뷰 크래시 수정
G8 마지막 게이트인 receipt-bound 실제 image rollback을 격리 NAS
vignette-preview-20260807 에서 실행해 종료했다.

Gate6 계약 정정:
감사 대상 current API 이미지가 com.docker.compose.project/service/version
image label 을 갖고 있어 "helper 의 compose label 0개" 계약은 감사되지 않은
다른 이미지를 쓰지 않는 한 성립하지 않는다. 계약을 key 부재가 아니라
소속(membership) 으로 바꿔 launch-nas-preview-g8-helpers.py 에 구현했다.
image 상속 label 을 baseline 으로 읽고 container 의 모든 compose label 이
baseline 과 같거나 선언된 격리 override 인지 검사하며, 최종 project 는
target 이 아니고 service 는 api/web/db/proxy 가 아니어야 한다. docker run
argv 에 target label 을 주입하면 fake-runner 테스트가 먼저 깨진다 (37/37).

실행 결과:
- rollback-old  receipt nas-g8-723eeef22eab05e63e3fafb0 -> 79ec../c530..
- restore-current receipt nas-g8-2738846cf2cf4fbe8ce0fc26 -> 52e0../6fdb..
- release gate/approval 각 2회 멱등, audit.ci_lifecycle_event rollback/executed 2,
  audit.ci_human_approval_event authorize_rollback 2, silent auto-promotion 0
- HMAC journal 6-record 체인 검증, health 3/3, OpenAPI 126, auth 401, Web 200
- helper 0, listener 0, 비밀 env 파기. down/volume rm/prune 미실행, 공개 런타임 미접촉
- 계획했던 Windows SSH 터널은 NAS sshd 가 direct-tcpip 를 거부해 사용할 수 없어
  sshd 설정 변경 대신 같은 격리 계약의 NAS-side probe 컨테이너로 실행했다

비-secure origin 크래시 수정:
배포된 NAS 프리뷰(평문 HTTP, 비-localhost)에 회기 스펙을 돌려 24건 실패를 확인했고
원인은 하나였다. crypto.randomUUID 는 secure context 전용인데 제품 코드 18곳이
fallback 없이 호출했고 RuptureRepairCard 는 렌더 시점 호출이라 회기 리뷰 라우트
전체가 error boundary 로 떨어졌다. 릴리스 게이트 108/108 은 localhost 후보 스택에서만
돌아 이 경로를 밟은 적이 없다. src/lib/uuid.ts 의 randomUuid() 로 통일하고 fallback 도
crypto.getRandomValues 를 우선 사용해 idempotency key 의 예측 불가능성을 유지했다.
회귀는 insecure-context-uuid.spec.ts 6/6 으로 고정했다(직접 호출 0건 검사 포함).
이 수정은 아직 NAS 에 배포하지 않았다.

검증:
API 898, gateway 58, executor 28, probe 11, helper launcher 37, release agent 21,
ruff clean, web api-types/typecheck/build, SSOT FAIL 0, SSOT unit 5/5,
dashboard E2E 10/10, 학생 폐루프 실 DB 브라우저 4/4(일회용 클론),
crypto 수정 후 기존 스펙 회귀 70/70, 복원된 NAS 실제 브라우저 SSE->DB 리뷰 PASS.

부수 발견(열린 항목):
공개 API 가 engine=false 로 degraded 인데 워치독이 이를 감지하지 못한다. engine 판정이
게이트웨이 /health 의 ok 만 보고 claude readiness probe 를 돌리지 않기 때문이다.
같은 .env 와 같은 CLI 로 새 게이트웨이를 다른 포트에 띄우면 즉시 ready 이므로 상주
프로세스의 세션만 죽은 형태다. TODO A절과 대시보드에 기록했다.

이 커밋은 파일 단위로 담겼다. 위 파일들에는 이전 세션의 미커밋 G0~G8 작업이 함께
들어 있으며, hunk 를 쪼개면 대시보드/체커/TODO 정합성이 깨져 SSOT 체커가 실패한다.
2026-08-07 22:17:20 +09:00

341 lines
14 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 { pathToFileURL } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
const repoRoot = path.resolve(process.cwd(), "..", "..");
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);
}
});
});