G7 증명과 G8 clean-head 승격 준비

This commit is contained in:
Yun Chan 2026-08-09 18:22:03 +09:00
parent 94c681d450
commit 5221f79e3f
52 changed files with 6876 additions and 506 deletions

View file

@ -590,6 +590,12 @@ function isOnline(seconds: number) {
}
async function openAdminAndReadHealth(page: Page) {
const userSnapshots: AdminUsersResponse[] = [];
const collectUsersResponse = async (response: Response) => {
if (!isAdminUsersResponse(response) || !response.ok()) return;
userSnapshots.push((await response.json()) as AdminUsersResponse);
};
page.on("response", collectUsersResponse);
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
const usageResponsePromise = page.waitForResponse(isAdminUsageResponse);
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
@ -613,10 +619,36 @@ async function openAdminAndReadHealth(page: Page) {
const health = (await healthResponse.json()) as AdminHealthResponse;
const usage = (await usageResponse.json()) as AdminUsageResponse;
const users = (await usersResponse.json()) as AdminUsersResponse;
const initialUsers = (await usersResponse.json()) as AdminUsersResponse;
const uptime = (await uptimeResponse.json()) as AdminUptimeResponse;
const tickets = (await ticketsResponse.json()) as AdminTicketsResponse;
expect(health.services.length).toBeGreaterThan(0);
if (userSnapshots.length === 0) userSnapshots.push(initialUsers);
let users = initialUsers;
await expect
.poll(
async () => {
const rendered = await page.locator(".vgops-kpi b").allTextContents();
const matchingSnapshot = userSnapshots.find((snapshot) => {
const activeSessions = snapshot.users.reduce(
(sum, user) => sum + Math.max(0, user.active_sessions),
0,
);
const onlineUsers = snapshot.users.filter((user) => isOnline(user.last_seen_at)).length;
return (
rendered[0] === countLabel(activeSessions) &&
rendered[1] === countLabel(onlineUsers) &&
rendered[2] === countLabel(snapshot.users.length)
);
});
if (matchingSnapshot) users = matchingSnapshot;
return Boolean(matchingSnapshot);
},
{ message: "관리자 KPI가 실제 /admin/users 응답 중 하나를 반영해야 한다" },
)
.toBe(true);
page.off("response", collectUsersResponse);
return { health, usage, users, uptime, tickets };
}

View file

@ -2,11 +2,12 @@ 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 { fileURLToPath, pathToFileURL } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
const repoRoot = path.resolve(process.cwd(), "..", "..");
const specDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(specDir, "..", "..", "..");
const dashboardPath = path.join(repoRoot, "docs", "dev_dashboard.html");
interface StaticServer {
@ -144,8 +145,8 @@ async function readMetrics(page: Page): Promise<DashboardMetrics> {
function expectDerivedMetrics(metrics: DashboardMetrics) {
expect(metrics.total).toBeGreaterThan(0);
expect(metrics.done).toBe(33);
expect(metrics.doing).toBe(2);
expect(metrics.done).toBe(32);
expect(metrics.doing).toBe(3);
expect(metrics.planned).toBe(0);
expect(metrics.total).toBe(metrics.done + metrics.doing + metrics.planned);
expect(metrics.ownerBoard).toBe(
@ -336,6 +337,30 @@ test.describe("dev dashboard static command center", () => {
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("detached clean release와 exact Git commit/tree");
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);
}
}
});
});

View file

@ -862,6 +862,60 @@ async function capture(page: Page, projectName: string, stage: string) {
}
test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fixture", () => {
test("3.5초 경계의 자기주도 경로 전환이 복구되면 부트 진단을 즉시 닫는다", async ({
page,
}) => {
await routeSelfDirectedUiFixture(page);
await page.goto("/learn");
await expect(
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
).toBeVisible();
// index.html의 첫 watchdog(3.5초)과 실제 자기주도 경로 전환을 겹친다.
// route가 잠깐 비어 보이는 동안에는 진단이 뜨되, React 콘텐츠 복구 후에는
// 두 번째 watchdog(8초)을 기다리지 않고 바로 사라져야 한다.
await page.evaluate(async () => {
const remaining = Math.max(0, 3_300 - performance.now());
await new Promise((resolve) => window.setTimeout(resolve, remaining));
const root = document.getElementById("root");
const action = document.querySelector<HTMLElement>(
"[data-learner-primary-action]",
);
if (!root || !action) {
throw new Error("self-directed route transition fixture unavailable");
}
root.style.visibility = "hidden";
action.click();
});
await expect(page).toHaveURL(/\/learn\/practice$/);
await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({
timeout: 1_200,
});
await page.evaluate(() => {
document.getElementById("root")?.style.removeProperty("visibility");
});
await expect(
page.getByRole("heading", { name: "연습할 내담자를 선택합니다." }),
).toBeVisible();
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0, {
timeout: 1_000,
});
});
test("React 부트가 실제로 비어 있으면 진단을 유지한다", async ({ page }) => {
await page.route("**/src/main.tsx", (route) => route.abort("failed"));
await page.goto("/learn", { waitUntil: "domcontentloaded" });
const diagnostic = page.locator("#vignette-boot-diagnostic");
await expect(diagnostic).toBeVisible({ timeout: 4_500 });
await expect(diagnostic).toContainText("React 화면이 정상 콘텐츠를 표시하지 못해서");
await page.waitForTimeout(250);
await expect(diagnostic).toBeVisible();
await expect(page.locator("#root")).toBeEmpty();
});
test("홈 추천과 목표 선택부터 회기·복기·처방 재연습 시작까지 한 흐름으로 보존한다", async ({
page,
}, testInfo) => {

View file

@ -432,6 +432,24 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
});
});
await page.route(
`**/api/sessions/${sessionId}/multimodal-alliance/consent`,
async (route) => {
const consentBody = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
submission_id: consentBody.submission_id,
consent_snapshot_id: "00000000-0000-4000-8000-000000000777",
consent_status: "granted",
deletion_request_id: null,
idempotent_replay: false,
}),
});
},
);
return {
sessionStartRequests,
liveCoachRequests,
@ -444,6 +462,15 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
};
}
async function acceptVoiceInputConsent(page: Page) {
const consentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
await expect(consentDialog).toBeVisible();
await expect(consentDialog).toContainText("원음은 보존하지 않습니다");
await expect(consentDialog).toContainText("동의하지 않아도 텍스트로 계속할 수 있습니다");
await consentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
await expect(consentDialog).toHaveCount(0);
}
async function installVoicePersistenceFailureFixture(page: Page, transcriptText: string) {
await page.addInitScript((text) => {
const fixture = {
@ -1370,6 +1397,7 @@ test.describe("P1 MVP core loop", () => {
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
await acceptVoiceInputConsent(page);
await page.getByRole("button", { name: "발화 보내기" }).click();
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: failedVoiceText });
@ -1410,6 +1438,7 @@ test.describe("P1 MVP core loop", () => {
await page.getByRole("button", { name: "이어가기" }).click();
await page.getByRole("button", { name: "마이크 켜기" }).click();
await acceptVoiceInputConsent(page);
await page.getByRole("button", { name: "발화 보내기" }).click();
const transcriptLog = page.getByRole("log", { name: "실시간 상담 축어록" });
@ -1494,6 +1523,7 @@ test.describe("P1 MVP core loop", () => {
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
await acceptVoiceInputConsent(page);
await page.getByRole("button", { name: "발화 보내기" }).click();
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: crisisText });

View file

@ -85,6 +85,8 @@ interface SessionDetailResponse {
session_id: string;
theory_mode: string;
status: "active" | "ended";
started_at: string;
ended_at?: string | null;
turns?: Array<{
speaker: "learner" | "client";
text: string;
@ -548,6 +550,8 @@ async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워
}
test.describe("session persistence", () => {
test.use({ timezoneId: "Asia/Seoul" });
test("persists selected CBT theory mode from session UI into DB-backed detail @single-run", async ({
page,
}) => {
@ -922,6 +926,21 @@ test.describe("session persistence", () => {
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible({
timeout: 15_000,
});
const reloadedDetailResponse = await page.request.get(`/api/sessions/${sessionId}`);
await expectResponseOk(reloadedDetailResponse);
const reloadedDetail = (await reloadedDetailResponse.json()) as SessionDetailResponse;
expect(reloadedDetail.status).toBe("active");
expect(reloadedDetail.ended_at).toBeNull();
expect(reloadedDetail.started_at).toMatch(/(?:Z|[+-]\d{2}:\d{2})$/);
const sessionAgeMs = Date.now() - Date.parse(reloadedDetail.started_at);
expect(Number.isFinite(sessionAgeMs)).toBe(true);
expect(sessionAgeMs).toBeGreaterThanOrEqual(-5_000);
expect(sessionAgeMs).toBeLessThan(10 * 60 * 1_000);
await expect(
page.locator(".sx-session-progress__grid > span").first().locator("b"),
).toHaveText(/^0\d:[0-5]\d$/);
await expect(page.locator(".sx-timebar.is-over")).toHaveCount(0);
await expect(page.getByRole("dialog", { name: "회기 시간이 끝났어요" })).toHaveCount(0);
const coachMark = page.locator(".sx-utt.is-learner .sx-utt__coach-mark");
await expect(coachMark).toBeVisible({
timeout: 15_000,

View file

@ -246,6 +246,13 @@ export async function routeFilledSessionReview(
body: JSON.stringify(filledReviewResponse(sessionId)),
});
});
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
});
}
export function prepostMeasuresResponse(

View file

@ -156,6 +156,7 @@ async function expectReviewRegionsReachable(page: Page) {
.evaluate((el) => window.getComputedStyle(el).gridTemplateAreas);
expect(transcriptAreas).toContain("transcript");
await page.locator(".sr-overview").scrollIntoViewIfNeeded();
await expect(page.locator(".sr-overview")).toBeInViewport();
await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded();
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
@ -546,9 +547,12 @@ test.describe("session review", () => {
};
}),
);
expect(filterMetrics.every((item) => item.height <= 34 && item.whiteSpace === "nowrap")).toBe(
true,
);
expect(filterMetrics.every((item) => item.whiteSpace === "nowrap")).toBe(true);
if ((page.viewportSize()?.width ?? 0) <= 720) {
expect(filterMetrics.every((item) => item.height >= 44)).toBe(true);
} else {
expect(filterMetrics.every((item) => item.height <= 34)).toBe(true);
}
await expectNoHorizontalOverflow(page);
});
@ -566,6 +570,7 @@ test.describe("session review", () => {
expect(review.session_id).toBe(FILLED_REVIEW_SESSION_ID);
expect(review.reviewReady).toBe(true);
expect(review.turns).toHaveLength(6);
await expect(page.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review$`));
await expect(page.getByText("관계 신호가 안정적으로 회복된 회기")).toBeVisible();