vignette/apps/web/e2e/returned-practice-db-closed-loop.spec.ts
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

387 lines
13 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import {
expect,
test,
type APIResponse,
type Page,
type TestInfo,
} from "@playwright/test";
import { expectNoHorizontalOverflow, useRealApi } from "./support";
interface ReturnedPracticeFixture {
schema_version: "vignette.returned-practice-browser-fixture.v1";
login: {
email: string;
role: "learner";
display_name: string;
cohort_ids: string[];
};
source_session_id: string;
practice_session_id: string;
deliberate: {
prescription_id: string;
criterion_id: string;
novelty: "familiar" | "unseen_transfer";
mode:
| "replay"
| "branch"
| "constrained_response"
| "voice_retry"
| "difficulty_ladder";
};
transfer: {
prescription_id: string;
suite_id: string;
trial_id: string;
criterion_id: string;
novelty: "unseen_transfer";
mode:
| "counterevidence_forecast"
| "evidence_recall"
| "uncertainty_range"
| "collect_more_evidence";
};
}
interface DeliberateSubmission {
idempotent_replay: boolean;
progress: string;
}
interface DeliberateReadModel {
episodes: Array<{
session_id?: string | null;
attempts?: unknown[];
}>;
}
interface TransferSubmission {
idempotent_replay: boolean;
assessment: {
execution_count: number;
independent_execution_count: number;
observed_execution_count: number;
};
}
interface TransferReadModel {
actual_executions: Array<{
original_transfer_trial_record_id: string;
practice_session_id: string;
}>;
}
const LIVE_GATE = process.env.E2E_RETURNED_PRACTICE_DB_CLOSED_LOOP === "1";
const FIXTURE_PATH = process.env.E2E_RETURNED_PRACTICE_FIXTURE ?? "";
function loadFixture(): ReturnedPracticeFixture {
if (!FIXTURE_PATH) {
throw new Error("E2E_RETURNED_PRACTICE_FIXTURE is required for the live gate");
}
const resolved = path.resolve(FIXTURE_PATH);
return JSON.parse(readFileSync(resolved, "utf8")) as ReturnedPracticeFixture;
}
function launchSearch(
fixture: ReturnedPracticeFixture,
kind: "deliberate" | "transfer",
): string {
const source = kind === "deliberate" ? fixture.deliberate : fixture.transfer;
const search = new URLSearchParams({
launch: kind,
prescription: source.prescription_id,
source_session: fixture.source_session_id,
criterion: source.criterion_id,
novelty: source.novelty,
mode: source.mode,
});
if (kind === "transfer") {
search.set("suite", fixture.transfer.suite_id);
search.set("trial", fixture.transfer.trial_id);
}
return search.toString();
}
async function expectOk(response: APIResponse) {
expect(response.ok(), await response.text()).toBeTruthy();
}
async function signInExistingFixture(page: Page, fixture: ReturnedPracticeFixture) {
const login = await page.request.post("/api/auth/dev-login", {
data: fixture.login,
});
await expectOk(login);
const me = await page.request.get("/api/auth/me");
await expectOk(me);
}
async function installMediaProbe(page: Page) {
await page.addInitScript(() => {
const state = { getUserMedia: 0 };
Object.defineProperty(window, "__returnedPracticeMediaProbe", {
value: state,
configurable: false,
});
const devices = navigator.mediaDevices;
if (!devices?.getUserMedia) return;
Object.defineProperty(devices, "getUserMedia", {
configurable: true,
value: (..._args: unknown[]) => {
state.getUserMedia += 1;
return Promise.reject(new Error("unexpected getUserMedia in review gate"));
},
});
});
}
async function expectNoOpaqueIdsOnScreen(
page: Page,
fixture: ReturnedPracticeFixture,
) {
const text = await page.locator("body").innerText();
const opaqueValues = [
fixture.source_session_id,
fixture.practice_session_id,
fixture.deliberate.prescription_id,
fixture.transfer.prescription_id,
fixture.transfer.suite_id,
fixture.transfer.trial_id,
];
for (const value of opaqueValues) expect(text).not.toContain(value);
expect(text).not.toMatch(
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i,
);
}
async function expectMediaProbeUntouched(page: Page) {
const calls = await page.evaluate(() => {
const target = window as typeof window & {
__returnedPracticeMediaProbe?: { getUserMedia: number };
};
return target.__returnedPracticeMediaProbe?.getUserMedia ?? -1;
});
expect(calls).toBe(0);
}
function isDesktop(testInfo: TestInfo): boolean {
return testInfo.project.name === "chromium-desktop";
}
function runtimeAttemptCount(
readModel: DeliberateReadModel,
practiceSessionId: string,
): number {
return readModel.episodes
.filter((episode) => episode.session_id === practiceSessionId)
.reduce((count, episode) => count + (episode.attempts?.length ?? 0), 0);
}
function actualExecutionCount(
readModel: TransferReadModel,
fixture: ReturnedPracticeFixture,
): number {
return readModel.actual_executions.filter(
(execution) =>
execution.original_transfer_trial_record_id === fixture.transfer.trial_id &&
execution.practice_session_id === fixture.practice_session_id,
).length;
}
async function openReturnedReview(
page: Page,
fixture: ReturnedPracticeFixture,
kind: "deliberate" | "transfer",
) {
await page.goto(
`/learn/session/${fixture.practice_session_id}/review?${launchSearch(fixture, kind)}`,
);
await expect(page.locator("#sr-tab-insights")).toHaveAttribute(
"aria-selected",
"true",
);
const panel = page.locator("#sr-panel-insights");
const selector = kind === "deliberate" ? ".dp-runtime-observation" : ".ct-actual-transfer";
await expect(panel.locator(selector)).toBeVisible({ timeout: 30_000 });
expect(
await panel.evaluate((element, target) => {
const first = element.firstElementChild;
return Boolean(first?.matches(target) || first?.querySelector(target));
}, selector),
).toBe(true);
await expectNoOpaqueIdsOnScreen(page, fixture);
await expectNoHorizontalOverflow(page);
return panel.locator(selector);
}
test.describe("returned-practice browser-only DB closed loop", () => {
test.describe.configure({ mode: "serial" });
test.skip(!LIVE_GATE, "Explicit disposable DB gate only");
let fixture: ReturnedPracticeFixture;
test.beforeEach(async ({ page }) => {
fixture = loadFixture();
expect(fixture.schema_version).toBe(
"vignette.returned-practice-browser-fixture.v1",
);
await useRealApi(page);
await installMediaProbe(page);
await signInExistingFixture(page, fixture);
});
test("returned G4 card performs the real POST, reload, progress comparison, and idempotent replay", async ({
page,
}, testInfo) => {
test.setTimeout(6 * 60_000);
const card = await openReturnedReview(page, fixture, "deliberate");
const firstButton = card.getByRole("button", {
name: isDesktop(testInfo)
? "이번 회기를 독립 관찰로 반영"
: "이번 회기를 독립 관찰로 반영",
});
const postUrl = `/api/practice/${encodeURIComponent(
fixture.deliberate.prescription_id,
)}/attempts/from-session/${fixture.practice_session_id}`;
const firstPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(postUrl),
{ timeout: 5 * 60_000 },
);
const firstReload = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().endsWith("/api/practice/learners/me"),
{ timeout: 5 * 60_000 },
);
await firstButton.click();
const firstResponse = await firstPost;
await expectOk(firstResponse);
const firstBody = (await firstResponse.json()) as DeliberateSubmission;
expect(firstBody.idempotent_replay).toBe(!isDesktop(testInfo));
expect(firstBody.progress).toBeTruthy();
const firstReadResponse = await firstReload;
await expectOk(firstReadResponse);
const firstRead = (await firstReadResponse.json()) as DeliberateReadModel;
const firstCount = runtimeAttemptCount(firstRead, fixture.practice_session_id);
expect(firstCount).toBeGreaterThan(0);
await expect(card.getByText("반영 전", { exact: true })).toBeVisible();
await expect(card.getByText("반영 후", { exact: true })).toBeVisible();
await expect(
card.getByText(
isDesktop(testInfo)
? "새 관찰 근거를 원장에 반영하고 최신 진행 상태를 다시 불러왔습니다."
: "같은 회기 근거를 중복 없이 확인했습니다.",
{ exact: true },
),
).toBeVisible();
const replayPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(postUrl),
{ timeout: 5 * 60_000 },
);
const replayReload = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().endsWith("/api/practice/learners/me"),
{ timeout: 5 * 60_000 },
);
await card.getByRole("button", { name: "반영 상태 다시 확인" }).click();
const replayResponse = await replayPost;
await expectOk(replayResponse);
const replayBody = (await replayResponse.json()) as DeliberateSubmission;
expect(replayBody.idempotent_replay).toBe(true);
const replayReadResponse = await replayReload;
await expectOk(replayReadResponse);
const replayRead = (await replayReadResponse.json()) as DeliberateReadModel;
expect(runtimeAttemptCount(replayRead, fixture.practice_session_id)).toBe(
firstCount,
);
await expect(
card.getByText("같은 회기 근거를 중복 없이 확인했습니다.", {
exact: true,
}),
).toBeVisible();
await expectNoOpaqueIdsOnScreen(page, fixture);
await expectMediaProbeUntouched(page);
});
test("returned G5 card performs the real POST, reload, before-after ledger, and idempotent replay", async ({
page,
}, testInfo) => {
test.setTimeout(6 * 60_000);
const card = await openReturnedReview(page, fixture, "transfer");
const initialAction = isDesktop(testInfo)
? "이 회기를 전이 근거로 확인"
: "같은 회기 기록 다시 확인";
const postUrl = "/api/calibration/transfer-executions";
const firstPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(postUrl),
{ timeout: 5 * 60_000 },
);
const firstReload = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().endsWith("/api/calibration/learners/me"),
{ timeout: 5 * 60_000 },
);
await card.getByRole("button", { name: initialAction }).click();
const firstResponse = await firstPost;
await expectOk(firstResponse);
const firstBody = (await firstResponse.json()) as TransferSubmission;
expect(firstBody.idempotent_replay).toBe(!isDesktop(testInfo));
expect(firstBody.assessment.execution_count).toBe(1);
expect(firstBody.assessment.independent_execution_count).toBe(1);
const firstReadResponse = await firstReload;
await expectOk(firstReadResponse);
const firstRead = (await firstReadResponse.json()) as TransferReadModel;
const firstCount = actualExecutionCount(firstRead, fixture);
expect(firstCount).toBe(1);
await expect(card.getByLabel("실제 전이 근거 변화")).toContainText(
isDesktop(testInfo) ? "0 → 1회" : "1 → 1회",
);
await expect(
card.getByText(
isDesktop(testInfo)
? "이번 완료 회기를 실제 전이 근거로 기록했어."
: "이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어.",
{ exact: true },
),
).toBeVisible();
await expect(card.getByText("최신 원장과 다시 맞춰 봤어", { exact: true })).toBeVisible();
const replayPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(postUrl),
{ timeout: 5 * 60_000 },
);
const replayReload = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().endsWith("/api/calibration/learners/me"),
{ timeout: 5 * 60_000 },
);
await card.getByRole("button", { name: "같은 회기 기록 다시 확인" }).click();
const replayResponse = await replayPost;
await expectOk(replayResponse);
const replayBody = (await replayResponse.json()) as TransferSubmission;
expect(replayBody.idempotent_replay).toBe(true);
const replayReadResponse = await replayReload;
await expectOk(replayReadResponse);
const replayRead = (await replayReadResponse.json()) as TransferReadModel;
expect(actualExecutionCount(replayRead, fixture)).toBe(firstCount);
await expect(
card.getByText(
"이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어.",
{ exact: true },
),
).toBeVisible();
await expectNoOpaqueIdsOnScreen(page, fixture);
await expectMediaProbeUntouched(page);
});
});