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); }); });