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 체커가 실패한다.
341 lines
14 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|