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 산출물은 커밋에서 제외했다.
387 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|