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 산출물은 커밋에서 제외했다.
366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { expectNoHorizontalOverflow } from "./support";
|
|
|
|
const MODEL_GATE_ID = "10000000-0000-4000-8000-000000000001";
|
|
const RELEASE_GATE_ID = "10000000-0000-4000-8000-000000000002";
|
|
const INCIDENT_ID = "10000000-0000-4000-8000-000000000003";
|
|
const CREATED_AT = "2026-08-06T08:30:00Z";
|
|
|
|
function artifact(
|
|
recordId: string,
|
|
ownerKind: "model_change_gate" | "release_gate",
|
|
ownerId: string,
|
|
kind: "baseline" | "threshold" | "provenance" | "rollback",
|
|
) {
|
|
return {
|
|
artifact_record_id: recordId,
|
|
owner_kind: ownerKind,
|
|
owner_id: ownerId,
|
|
artifact_kind: kind,
|
|
artifact_id: `oas-g8-${kind}-${ownerId.slice(-4)}`,
|
|
content_sha256: kind.slice(0, 1).repeat(64),
|
|
provenance_uri: `audit://synthetic/g8/${ownerId}/${kind}`,
|
|
created_at: CREATED_AT,
|
|
};
|
|
}
|
|
|
|
function baseView() {
|
|
return {
|
|
content_qualifications: [
|
|
{
|
|
qualification_id: "20000000-0000-4000-8000-000000000001",
|
|
pipeline_id: "20000000-0000-4000-8000-000000000002",
|
|
catalog_entry_id: "oas-g8-catalog-synthetic-rupture",
|
|
payload_sha256: "a".repeat(64),
|
|
content_kind: "rupture" as const,
|
|
difficulty_level: 3,
|
|
synthetic_identity_id: "synthetic-identity-synthetic-rupture",
|
|
source_count: 1,
|
|
red_team_review_count: 2,
|
|
benchmark_variant_count: 3,
|
|
benchmark_pass_rate: 1,
|
|
source_provenance_uris: [
|
|
"repo://apps/api/app/data/continuous_improvement/synthetic_source_pack.v2.json",
|
|
],
|
|
draft_payload: {
|
|
title: "합성 관계 균열 수선 연습",
|
|
synthetic_profile: "실존 인물과 무관한 합성 내담자",
|
|
scenario: "상담자가 주제를 너무 빨리 바꿔 합성 내담자가 서두른다고 느낀 상황",
|
|
rupture_or_challenge: "상호작용을 명명하고 내담자의 정정을 초대한다.",
|
|
learner_task: "영향을 방어하지 않고 인정한 뒤 다음 초점을 공동 결정한다.",
|
|
success_criteria: ["상호작용 명명", "정정 초대"],
|
|
source_refs: ["oas-g8-source-repo-synthetic-rupture-v2"],
|
|
grounded_claims: [
|
|
{
|
|
claim: "합성 수련 시나리오",
|
|
source_ref: "oas-g8-source-repo-synthetic-rupture-v2",
|
|
},
|
|
],
|
|
},
|
|
gate_state: "pending_human_approval",
|
|
created_at: CREATED_AT,
|
|
},
|
|
],
|
|
model_change_gates: [
|
|
{
|
|
gate_id: MODEL_GATE_ID,
|
|
gate_decision: "promote",
|
|
reasons: ["synthetic benchmark threshold passed", "coverage drift stable"],
|
|
state: "pending_human_approval",
|
|
created_at: CREATED_AT,
|
|
},
|
|
],
|
|
release_gates: [
|
|
{
|
|
gate_id: RELEASE_GATE_ID,
|
|
release_id: "oas-g8-release-2026-08-06",
|
|
qualified: true,
|
|
state: "pending_human_approval",
|
|
created_at: CREATED_AT,
|
|
},
|
|
],
|
|
gate_artifacts: [
|
|
artifact("30000000-0000-4000-8000-000000000001", "model_change_gate", MODEL_GATE_ID, "baseline"),
|
|
artifact("30000000-0000-4000-8000-000000000002", "model_change_gate", MODEL_GATE_ID, "threshold"),
|
|
artifact("30000000-0000-4000-8000-000000000003", "model_change_gate", MODEL_GATE_ID, "provenance"),
|
|
artifact("30000000-0000-4000-8000-000000000004", "model_change_gate", MODEL_GATE_ID, "rollback"),
|
|
artifact("30000000-0000-4000-8000-000000000005", "release_gate", RELEASE_GATE_ID, "baseline"),
|
|
artifact("30000000-0000-4000-8000-000000000006", "release_gate", RELEASE_GATE_ID, "threshold"),
|
|
artifact("30000000-0000-4000-8000-000000000007", "release_gate", RELEASE_GATE_ID, "provenance"),
|
|
],
|
|
approvals: [],
|
|
catalog_entries: [],
|
|
lifecycle_events: [
|
|
{
|
|
lifecycle_event_id: "40000000-0000-4000-8000-000000000003",
|
|
target_kind: "model_change_gate",
|
|
target_id: "40000000-0000-4000-8000-000000000004",
|
|
event_type: "rollback",
|
|
event_status: "requested",
|
|
evidence_refs: ["audit://synthetic/g8/rollback-requested"],
|
|
created_at: "2026-08-06T08:20:00Z",
|
|
},
|
|
{
|
|
lifecycle_event_id: "40000000-0000-4000-8000-000000000005",
|
|
target_kind: "model_change_gate",
|
|
target_id: "40000000-0000-4000-8000-000000000006",
|
|
event_type: "rollback",
|
|
event_status: "failed",
|
|
evidence_refs: ["audit://synthetic/g8/rollback-failed"],
|
|
created_at: "2026-08-06T08:10:00Z",
|
|
},
|
|
{
|
|
lifecycle_event_id: "40000000-0000-4000-8000-000000000001",
|
|
target_kind: "model_change_gate",
|
|
target_id: "40000000-0000-4000-8000-000000000002",
|
|
event_type: "rollback",
|
|
event_status: "executed",
|
|
evidence_refs: ["audit://synthetic/g8/rollback"],
|
|
created_at: "2026-08-06T08:00:00Z",
|
|
},
|
|
],
|
|
incidents: [
|
|
{
|
|
incident_record_id: INCIDENT_ID,
|
|
incident_id: "oas-g8-incident-provider-timeout",
|
|
error_fingerprint: "e".repeat(64),
|
|
affected_contract: "synthetic.replay.provider-timeout",
|
|
evidence_refs: ["audit://synthetic/g8/incident"],
|
|
pii_included: false,
|
|
created_at: CREATED_AT,
|
|
},
|
|
],
|
|
regression_dag_nodes: [
|
|
["01", "reproduction_test", "passed"],
|
|
["02", "implementation", "passed"],
|
|
["03", "e2e", "pending"],
|
|
["04", "runtime_proof", "pending"],
|
|
].map(([suffix, nodeType, nodeStatus], index, all) => ({
|
|
node_record_id: `50000000-0000-4000-8000-0000000000${suffix}`,
|
|
incident_record_id: INCIDENT_ID,
|
|
node_id: `oas-g8-node-provider-timeout-${nodeType}`,
|
|
node_type: nodeType,
|
|
depends_on_record_ids:
|
|
index === 0 ? [] : [`50000000-0000-4000-8000-0000000000${all[index - 1][0]}`],
|
|
evidence_ref: nodeStatus === "passed" ? `audit://synthetic/g8/${nodeType}` : null,
|
|
node_status: nodeStatus,
|
|
created_at: CREATED_AT,
|
|
})),
|
|
data_classification: "synthetic_replay_red_team_coverage_drift",
|
|
silent_auto_promotion_allowed: false,
|
|
raw_transcript_included: false,
|
|
pii_included: false,
|
|
clinical_claim_allowed: false,
|
|
};
|
|
}
|
|
|
|
type MockOptions = {
|
|
role?: "admin" | "teacher" | "learner";
|
|
status?: number;
|
|
empty?: boolean;
|
|
};
|
|
|
|
async function installMock(page: Page, options: MockOptions = {}) {
|
|
const view = baseView();
|
|
if (options.empty) {
|
|
view.content_qualifications = [];
|
|
view.model_change_gates = [];
|
|
view.release_gates = [];
|
|
view.gate_artifacts = [];
|
|
view.incidents = [];
|
|
view.regression_dag_nodes = [];
|
|
view.lifecycle_events = [];
|
|
}
|
|
const approvalBodies: Record<string, unknown>[] = [];
|
|
|
|
await page.route("**/api/**", async (route) => {
|
|
const request = route.request();
|
|
const path = new URL(request.url()).pathname;
|
|
const fulfill = (body: unknown, status = 200) =>
|
|
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
|
|
|
if (request.method() === "GET" && path.endsWith("/auth/me")) {
|
|
const role = options.role ?? "admin";
|
|
await fulfill({
|
|
user_id: "60000000-0000-4000-8000-000000000001",
|
|
email: `${role}@twentyoz.kr`,
|
|
display_name: `CI ${role}`,
|
|
role,
|
|
admin_access: false,
|
|
super_admin: false,
|
|
account_status: "approved",
|
|
approval_required: false,
|
|
cohort_ids: [],
|
|
consent_at: 1_754_378_000,
|
|
onboarding_completed_at: 1_754_378_000,
|
|
nickname: "",
|
|
self_introduction: "",
|
|
avatar_url: "",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (request.method() === "GET" && path.endsWith("/continuous-improvement")) {
|
|
if (options.status && options.status !== 200) {
|
|
await fulfill({ detail: "continuous improvement read model unavailable" }, options.status);
|
|
return;
|
|
}
|
|
await fulfill(view);
|
|
return;
|
|
}
|
|
|
|
if (request.method() === "POST" && path.endsWith("/continuous-improvement/approvals")) {
|
|
const body = request.postDataJSON() as Record<string, string>;
|
|
approvalBodies.push(body);
|
|
view.approvals.push({
|
|
approval_event_id: body.approval_event_id,
|
|
target_kind: body.target_kind as "content_qualification" | "model_change_gate",
|
|
target_id: body.target_id,
|
|
decision: body.decision as "approve_content" | "approve_promotion",
|
|
reason_code: body.reason_code,
|
|
evidence_refs: body.evidence_refs as unknown as string[],
|
|
created_at: CREATED_AT,
|
|
});
|
|
if (body.target_kind === "content_qualification") {
|
|
view.catalog_entries.unshift({
|
|
catalog_record_id: body.effect_record_id,
|
|
qualification_id: body.target_id,
|
|
catalog_entry_id: "oas-g8-catalog-synthetic-rupture",
|
|
status: "approved",
|
|
clinical_claim_allowed: false,
|
|
created_at: CREATED_AT,
|
|
});
|
|
} else {
|
|
view.lifecycle_events.unshift({
|
|
lifecycle_event_id: body.effect_record_id,
|
|
target_kind: body.target_kind as "model_change_gate",
|
|
target_id: body.target_id,
|
|
event_type: "promotion",
|
|
event_status: "approved",
|
|
evidence_refs: body.evidence_refs as unknown as string[],
|
|
created_at: CREATED_AT,
|
|
});
|
|
}
|
|
await fulfill(
|
|
{
|
|
submission_id: body.submission_id,
|
|
approval_event_id: body.approval_event_id,
|
|
target_kind: body.target_kind,
|
|
target_id: body.target_id,
|
|
decision: body.decision,
|
|
effect_record_id: body.effect_record_id,
|
|
idempotent_replay: false,
|
|
},
|
|
201,
|
|
);
|
|
return;
|
|
}
|
|
|
|
await fulfill({ detail: `unmocked ${request.method()} ${path}` }, 404);
|
|
});
|
|
|
|
return { view, approvalBodies };
|
|
}
|
|
|
|
test.describe("continuous improvement admin cockpit", () => {
|
|
test("renders the generated-content, gate, incident and rollback ledgers fail-closed", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" });
|
|
await page.addInitScript(() => localStorage.setItem("vignette.theme", "dark"));
|
|
const mock = await installMock(page);
|
|
|
|
await page.goto("/admin/continuous-improvement");
|
|
|
|
await expect(page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" })).toBeVisible();
|
|
await expect(page.getByText("합성 콘텐츠 검증 파이프라인")).toBeVisible();
|
|
await expect(page.getByText("모델 변경 · 릴리스 게이트")).toBeVisible();
|
|
await expect(page.getByText("사건 회귀 DAG")).toBeVisible();
|
|
await expect(page.getByText("모니터 · 롤백 원장")).toBeVisible();
|
|
await expect(page.getByText("롤백 실행 대기 · 실행기 미구성", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("롤백 실행 실패", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("롤백은 실행됐지만 verification 이벤트가 아직 없어")).toBeVisible();
|
|
const contentCandidate = page.locator(
|
|
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
|
);
|
|
await expect(contentCandidate.getByText("합성 관계 균열 수선 연습")).toBeVisible();
|
|
await contentCandidate.getByText("검수 payload 펼쳐 보기").click();
|
|
await expect(contentCandidate.getByText("영향을 방어하지 않고 인정한 뒤")).toBeVisible();
|
|
await expect(contentCandidate).not.toContainText("hidden_answer");
|
|
const contentReason = contentCandidate.getByLabel("콘텐츠 승인 사유");
|
|
await expect(contentCandidate.getByRole("button", { name: "카탈로그 승인" })).toBeDisabled();
|
|
await contentReason.fill("합성 경계와 수련 목표를 직접 검수함");
|
|
await contentReason.press("Enter");
|
|
await expect(contentCandidate.getByText("카탈로그 승인 원장 기록됨")).toBeVisible();
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
|
await page.getByTestId("continuous-improvement-cockpit").screenshot({
|
|
path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"),
|
|
animations: "disabled",
|
|
});
|
|
|
|
const missingGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
|
|
await expect(missingGate.getByRole("button", { name: "증거 4종 미완료" })).toBeDisabled();
|
|
await expect(missingGate.getByText("필수 증거 4종이 모두 있어야 기록 가능")).toBeVisible();
|
|
|
|
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
|
|
const approvalButton = modelGate.getByRole("button", { name: "사람 승인 기록" });
|
|
const approvalReason = modelGate.getByLabel("사람 승인 사유");
|
|
await expect(approvalButton).toBeDisabled();
|
|
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible();
|
|
await approvalReason.fill("기준선과 rollback runbook을 독립 검토함");
|
|
await expect(approvalButton).toBeEnabled();
|
|
await expect(modelGate.getByText("증거 4종과 승인 사유가 준비됨")).toBeVisible();
|
|
await approvalButton.focus();
|
|
await expect(approvalButton).toBeFocused();
|
|
await approvalReason.focus();
|
|
await approvalReason.press("Enter");
|
|
|
|
await expect(modelGate.getByText("append-only 승인 원장 기록됨")).toBeVisible();
|
|
expect(mock.approvalBodies).toHaveLength(2);
|
|
expect(mock.approvalBodies[0]).toMatchObject({
|
|
target_kind: "content_qualification",
|
|
target_id: "20000000-0000-4000-8000-000000000001",
|
|
decision: "approve_content",
|
|
reason_code: "합성 경계와 수련 목표를 직접 검수함",
|
|
});
|
|
expect(mock.approvalBodies[1]).toMatchObject({
|
|
target_kind: "model_change_gate",
|
|
target_id: MODEL_GATE_ID,
|
|
decision: "approve_promotion",
|
|
reason_code: "기준선과 rollback runbook을 독립 검토함",
|
|
});
|
|
expect(mock.approvalBodies[1].evidence_refs).toHaveLength(4);
|
|
|
|
await expectNoHorizontalOverflow(page);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath("g8-continuous-improvement-cockpit.png"),
|
|
fullPage: true,
|
|
animations: "disabled",
|
|
});
|
|
});
|
|
|
|
test("shows empty and degraded states without opening an approval action", async ({ page }) => {
|
|
await installMock(page, { empty: true });
|
|
await page.goto("/admin/continuous-improvement");
|
|
await expect(page.getByText("검토할 콘텐츠 후보가 없어")).toBeVisible();
|
|
await expect(page.getByText("대기 중인 게이트가 없어")).toBeVisible();
|
|
await expect(page.getByText("등록된 운영 사건이 없어")).toBeVisible();
|
|
await expect(page.getByRole("button", { name: /승인 기록|결정 기록/ })).toHaveCount(0);
|
|
|
|
await page.unroute("**/api/**");
|
|
await installMock(page, { status: 503 });
|
|
await page.reload();
|
|
await expect(page.getByRole("heading", { name: "개선 원장을 확인할 수 없어" })).toBeVisible();
|
|
await expect(page.getByText(/승인 동작은 닫힌 상태/)).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|
|
|
|
for (const role of ["teacher", "learner"] as const) {
|
|
test(`does not expose the admin cockpit to ${role}`, async ({ page }) => {
|
|
await installMock(page, { role });
|
|
await page.goto("/admin/continuous-improvement");
|
|
await expect(page).toHaveURL(role === "teacher" ? /\/teach$/ : /\/learn$/);
|
|
await expect(page.getByTestId("continuous-improvement-cockpit")).toHaveCount(0);
|
|
});
|
|
}
|
|
});
|