import { existsSync } from "node:fs"; import { spawn, spawnSync, type ChildProcessWithoutNullStreams, } from "node:child_process"; import { createServer, type IncomingMessage, type Server } from "node:http"; import net from "node:net"; import { expect, test, type APIResponse, type Page } from "@playwright/test"; import { acquireGlobalEngineConfigLock, completeAlliancePreCheckpoint, } from "./support"; const API_PORT = 8018; const WEB_PORT = 5178; const API_BASE = `http://127.0.0.1:${API_PORT}`; const WEB_BASE = `http://127.0.0.1:${WEB_PORT}`; const DB_CONTAINER = process.env.E2E_DB_CONTAINER ?? "vignette-dev-db"; interface ManagedProcess { process: ChildProcessWithoutNullStreams; logs: () => string; } interface ManagedServer { server: Server; url: string; requests: () => string[]; } interface EngineConfigSnapshot { engine_mode: string; engine_url: string; model: string; reasoning_effort: string | null; updated_by: string | null; updated_at: string | null; } interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; reasoning_effort: string | null; durable: boolean; source: string; } interface AuthMe { user_id: string; } interface PersonaReviewSummary { persona_id: string; code: string; version: number; status: "draft" | "review" | "approved" | "archived"; display_name: string; } interface PersonaSummary { persona_id: string | null; code: string; version: number | null; status: "approved"; display_name: string; presenting_summary: string; source: string; degraded: boolean; } interface SessionStartResponse { session_id: string; persona_id: string; persona_version: number; degraded: boolean; } interface SessionDetailResponse { session_id: string; persona_id: string; persona_version: number; persona_code: string; persona_name: string; turns: Array<{ speaker: "learner" | "client"; text: string }>; } interface ProtocolResponse { protocol_id: string; source_id: string; title: string; version: number; license: "A" | "B" | "C" | "D"; external_llm_ok: boolean; status: "draft" | "active" | "retired"; } interface ProtocolActivationResponse { protocol: ProtocolResponse; chunks_indexed: number; skipped_unchanged: boolean; embedded: boolean; degraded: boolean; } let engine: ManagedServer | null = null; let api: ManagedProcess | null = null; let web: ManagedProcess | null = null; let engineConfigSnapshot: EngineConfigSnapshot | null = null; let engineConfigSnapshotCaptured = false; let releaseEngineConfigLock: (() => Promise) | null = null; const cleanup = { sessionIds: new Set(), personaIds: new Set(), userIds: new Set(), emails: new Set(), protocolIds: new Set(), sourceIds: new Set(), }; function testSuffix(testInfo: { workerIndex: number; retry: number }): string { return `${Date.now().toString(36)}-${testInfo.workerIndex}-${testInfo.retry}`; } async function expectOk(response: APIResponse): Promise { if (response.ok()) return; expect(response.ok(), await response.text()).toBeTruthy(); } async function readRequestBody(request: IncomingMessage): Promise> { const chunks: Buffer[] = []; for await (const chunk of request) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } const raw = Buffer.concat(chunks).toString("utf8"); return raw ? (JSON.parse(raw) as Record) : {}; } function sendJson(response: import("node:http").ServerResponse, status: number, body: unknown) { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify(body)); } async function startControlledEngine(): Promise { const requests: string[] = []; const server = createServer(async (request, response) => { requests.push(`${request.method ?? "UNKNOWN"} ${request.url ?? "/"}`); try { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (request.method === "GET" && ["/health", "/ready"].includes(url.pathname)) { sendJson(response, 200, { ok: true, status: "ok", detail: "REQ-005 controlled engine ready", cached: false, }); return; } if (request.method === "GET" && url.pathname === "/v1/capabilities") { const provider = url.searchParams.get("provider") ?? "claude_api"; sendJson(response, 200, { provider, available: true, source: "live_api", models: [ { id: "req005-controlled", label: "REQ-005 controlled engine", description: "Deterministic browser and PostgreSQL contract proof", reasoning_efforts: ["low", "medium", "high"], default_reasoning_effort: "medium", is_default: true, }, ], default_model: "req005-controlled", default_reasoning_effort: "medium", detail: "controlled engine ready", fetched_at: Date.now() / 1000, }); return; } if (request.method === "DELETE" && url.pathname.startsWith("/session/")) { sendJson(response, 200, { closed: true }); return; } if (request.method === "POST" && url.pathname === "/v1/generate") { const payload = await readRequestBody(request); sendJson(response, 200, { text: payload.ai_role === "evaluator" ? JSON.stringify({ techniques: [], client_state_read: [], appropriateness: "pos", appropriateness_note: "실 DB 첫 턴 저장 확인", rapport_signal: 0.2, }) : "그렇게 천천히 물어봐 주니까 조금 더 말해볼 수 있을 것 같아요.", model: "req005-controlled", provider: String(payload.provider ?? "claude_api"), tokens_in: 8, tokens_out: 12, cost_usd: 0, structured: payload.ai_role === "evaluator" ? { techniques: [], client_state_read: [], appropriateness: "pos", appropriateness_note: "실 DB 첫 턴 저장 확인", rapport_signal: 0.2, } : null, }); return; } if (request.method === "POST" && url.pathname === "/v1/stream") { await readRequestBody(request); response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", }); response.write( `event: token\ndata: ${JSON.stringify({ text: "그렇게 천천히 물어봐 주니까 " })}\n\n`, ); response.write( `event: token\ndata: ${JSON.stringify({ text: "조금 더 말해볼 수 있을 것 같아요." })}\n\n`, ); response.end( `event: done\ndata: ${JSON.stringify({ provider: "claude_api", model: "req005-controlled", tokens_in: 8, tokens_out: 12, cost_usd: 0, turns: 1, })}\n\n`, ); return; } sendJson(response, 404, { detail: `unexpected engine path: ${url.pathname}` }); } catch (error) { sendJson(response, 500, { detail: error instanceof Error ? error.message : String(error), }); } }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => resolve()); }); const address = server.address(); if (!address || typeof address === "string") { await new Promise((resolve) => server.close(() => resolve())); throw new Error("controlled engine did not expose a TCP port"); } return { server, url: `http://127.0.0.1:${address.port}`, requests: () => [...requests], }; } function python311(): string { const local = process.env.USERPROFILE ? `${process.env.USERPROFILE}\\AppData\\Local\\Programs\\Python\\Python311\\python.exe` : ""; return ( process.env.E2E_PYTHON ?? process.env.PYTHON311 ?? (local && existsSync(local) ? local : (process.env.PYTHON ?? "python")) ); } function startProcess( executable: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }, ): ManagedProcess { const child = spawn(executable, args, { cwd: options.cwd, env: options.env, windowsHide: true, }); let output = ""; const append = (chunk: Buffer | string) => { output = `${output}${String(chunk)}`.slice(-12_000); }; child.stdout.on("data", append); child.stderr.on("data", append); return { process: child, logs: () => output }; } async function waitForHttp(url: string, managed: ManagedProcess, timeoutMs: number) { const started = Date.now(); let lastError = ""; while (Date.now() - started < timeoutMs) { if (managed.process.exitCode !== null) { throw new Error( `process exited before ${url}: ${managed.process.exitCode}\n${managed.logs()}`, ); } try { const response = await fetch(url); if (response.ok) return; lastError = `HTTP ${response.status}: ${await response.text()}`; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error(`timed out waiting for ${url}: ${lastError}\n${managed.logs()}`); } async function startApi(engineUrl: string): Promise { const managed = startProcess( python311(), [ "-X", "utf8", "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", String(API_PORT), "--log-level", "warning", ], { cwd: "../api", env: { ...process.env, PYTHONUNBUFFERED: "1", ENVIRONMENT: "dev", AUTH_DEV_LOGIN_ENABLED: "true", AUTH_ALLOWED_EMAIL_DOMAINS: '["hs.ac.kr","twentyoz.kr"]', AUTH_DEV_LOGIN_EXTRA_ORIGINS: JSON.stringify([WEB_BASE]), AUTO_SEED_PERSONAS: "false", ALLOW_SEED_PERSONA_FALLBACK: "false", DB_POOL_MIN_SIZE: "1", DB_POOL_MAX_SIZE: "4", DB_COMMAND_TIMEOUT: "20", ENGINE_URL: engineUrl, ENGINE_MODE: "claude_api", VIGNETTE_LIVE_CLIENT_PROVIDER: "claude_api", ENGINE_TIMEOUT: "20", ENGINE_CONNECT_TIMEOUT: "2", EVALUATOR_SEMANTIC_CACHE_ENABLED: "false", FRONTEND_BASE_URL: WEB_BASE, CORS_ORIGINS: JSON.stringify([WEB_BASE]), }, }, ); await waitForHttp(`${API_BASE}/health`, managed, 45_000); return managed; } async function startWeb(): Promise { const managed = startProcess( process.execPath, ["node_modules/vite/bin/vite.js", "--host", "127.0.0.1", "--port", String(WEB_PORT)], { cwd: ".", env: { ...process.env, VITE_API_BASE: API_BASE, }, }, ); await waitForHttp(WEB_BASE, managed, 45_000); return managed; } async function stopProcess(managed: ManagedProcess | null): Promise { if (!managed || managed.process.exitCode !== null) return; managed.process.kill(); await new Promise((resolve) => { const timer = setTimeout(resolve, 5_000); managed.process.once("exit", () => { clearTimeout(timer); resolve(); }); }); } function processExited(managed: ManagedProcess | null): boolean { return ( managed === null || managed.process.exitCode !== null || managed.process.signalCode !== null ); } async function portIsListening(port: number): Promise { return new Promise((resolve) => { const socket = net.createConnection({ host: "127.0.0.1", port }); const done = (value: boolean) => { socket.destroy(); resolve(value); }; socket.setTimeout(500); socket.once("connect", () => done(true)); socket.once("timeout", () => done(false)); socket.once("error", () => done(false)); }); } async function expectPortClosed(port: number): Promise { await expect .poll(() => portIsListening(port), { timeout: 10_000, intervals: [100, 250, 500], }) .toBe(false); } function runPsql(sql: string): string { const result = spawnSync( "docker", [ "exec", "-i", DB_CONTAINER, "psql", "-X", "-U", "vignette_owner", "-d", "vignette", "-v", "ON_ERROR_STOP=1", "-A", "-t", "-F", "|", ], { input: sql, encoding: "utf8", windowsHide: true }, ); if (result.status !== 0) { throw new Error(`psql failed (${result.status}): ${String(result.stderr).trim()}`); } return String(result.stdout).trim(); } function sqlTextValue(value: string | null): string { return value === null ? "NULL" : `'${value.replaceAll("'", "''")}'`; } function readEngineConfigSnapshot(): EngineConfigSnapshot | null { const raw = runPsql(` SELECT COALESCE( ( SELECT json_build_object( 'engine_mode', engine_mode, 'engine_url', engine_url, 'model', model, 'reasoning_effort', reasoning_effort, 'updated_by', updated_by, 'updated_at', updated_at )::text FROM app.admin_engine_config WHERE id = TRUE ), 'null' ); `); return raw === "null" ? null : (JSON.parse(raw) as EngineConfigSnapshot); } function installControlledEngineConfig(engineUrl: string): void { runPsql(` INSERT INTO app.admin_engine_config ( id, engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at ) VALUES ( TRUE, 'claude_api', ${sqlTextValue(engineUrl)}, 'req005-controlled', 'medium', 'req005-e2e', now() ) ON CONFLICT (id) DO UPDATE SET engine_mode = EXCLUDED.engine_mode, engine_url = EXCLUDED.engine_url, model = EXCLUDED.model, reasoning_effort = EXCLUDED.reasoning_effort, updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at; `); expect(readEngineConfigSnapshot()).toMatchObject({ engine_mode: "claude_api", engine_url: engineUrl, model: "req005-controlled", reasoning_effort: "medium", updated_by: "req005-e2e", }); } function restoreEngineConfig(snapshot: EngineConfigSnapshot | null): void { if (snapshot === null) { runPsql("DELETE FROM app.admin_engine_config WHERE id = TRUE;"); } else { runPsql(` INSERT INTO app.admin_engine_config ( id, engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at ) VALUES ( TRUE, ${sqlTextValue(snapshot.engine_mode)}, ${sqlTextValue(snapshot.engine_url)}, ${sqlTextValue(snapshot.model)}, ${sqlTextValue(snapshot.reasoning_effort)}, ${sqlTextValue(snapshot.updated_by)}, ${sqlTextValue(snapshot.updated_at)}::timestamptz ) ON CONFLICT (id) DO UPDATE SET engine_mode = EXCLUDED.engine_mode, engine_url = EXCLUDED.engine_url, model = EXCLUDED.model, reasoning_effort = EXCLUDED.reasoning_effort, updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at; `); } expect(readEngineConfigSnapshot()).toEqual(snapshot); } function sqlUuidArray(values: Iterable): string { const rows = [...values]; return rows.length ? `ARRAY[${rows.map((value) => `'${value}'::uuid`).join(",")}]` : "ARRAY[]::uuid[]"; } function sqlTextArray(values: Iterable): string { const rows = [...values]; return rows.length ? `ARRAY[${rows.map((value) => `'${value.replaceAll("'", "''")}'::text`).join(",")}]` : "ARRAY[]::text[]"; } function cleanupTrackedRows(): void { const sessionIds = sqlUuidArray(cleanup.sessionIds); const personaIds = sqlUuidArray(cleanup.personaIds); const userIds = sqlUuidArray(cleanup.userIds); const emails = sqlTextArray(cleanup.emails); const protocolIds = sqlUuidArray(cleanup.protocolIds); const sourceIds = sqlTextArray(cleanup.sourceIds); runPsql(` BEGIN; SET LOCAL session_replication_role = replica; CREATE TEMP TABLE e2e_turn_ids AS SELECT id FROM app.turns WHERE session_id = ANY(${sessionIds}); CREATE TEMP TABLE e2e_pulse_ids AS SELECT pulse_id FROM app.alliance_pulse WHERE session_id = ANY(${sessionIds}); DO $cleanup$ DECLARE item record; BEGIN FOR item IN SELECT table_schema, table_name, column_name FROM information_schema.columns WHERE table_schema IN ('app','audit','kb') AND column_name = 'turn_id' LOOP EXECUTE format( 'DELETE FROM %I.%I WHERE %I IN (SELECT id FROM e2e_turn_ids)', item.table_schema, item.table_name, item.column_name ); END LOOP; FOR item IN SELECT table_schema, table_name, column_name FROM information_schema.columns WHERE table_schema IN ('app','audit','kb') AND column_name = 'pulse_id' LOOP EXECUTE format( 'DELETE FROM %I.%I WHERE %I IN (SELECT pulse_id FROM e2e_pulse_ids)', item.table_schema, item.table_name, item.column_name ); END LOOP; FOR item IN SELECT table_schema, table_name, column_name FROM information_schema.columns WHERE table_schema IN ('app','audit','kb') AND column_name = 'session_id' LOOP EXECUTE format('DELETE FROM %I.%I WHERE %I = ANY($1)', item.table_schema, item.table_name, item.column_name) USING ${sessionIds}; END LOOP; FOR item IN SELECT table_schema, table_name, column_name FROM information_schema.columns WHERE table_schema IN ('app','audit','kb') AND column_name = 'persona_id' LOOP EXECUTE format('DELETE FROM %I.%I WHERE %I = ANY($1)', item.table_schema, item.table_name, item.column_name) USING ${personaIds}; END LOOP; FOR item IN SELECT table_schema, table_name, column_name FROM information_schema.columns WHERE table_schema IN ('app','audit','kb') AND column_name IN ( 'user_id','learner_id','actor_uid','created_by_uid','registered_by', 'approved_by','created_by','reviewer_id','recipient_user_id', 'subject_user_id','actor_user_id','owner_user_id','requested_by_uid' ) AND data_type = 'uuid' LOOP EXECUTE format('DELETE FROM %I.%I WHERE %I = ANY($1)', item.table_schema, item.table_name, item.column_name) USING ${userIds}; END LOOP; END $cleanup$; DELETE FROM app.sessions WHERE id = ANY(${sessionIds}); DELETE FROM audit.audit_log WHERE target_id = ANY(${sqlTextArray(cleanup.personaIds)}) OR actor_uid = ANY(${userIds}); DELETE FROM kb.chunk WHERE source_id = ANY(${sourceIds}); DELETE FROM kb.document WHERE source_id = ANY(${sourceIds}); DELETE FROM kb.source WHERE source_id = ANY(${sourceIds}); DELETE FROM kb.protocol_registration WHERE protocol_id = ANY(${protocolIds}); DELETE FROM app.persona_card WHERE persona_id = ANY(${personaIds}); DELETE FROM app.auth_session WHERE user_id = ANY(${userIds}); DELETE FROM app.app_user WHERE user_id = ANY(${userIds}) OR email = ANY(${emails}); COMMIT; SELECT (SELECT count(*) FROM app.sessions WHERE id = ANY(${sessionIds})), (SELECT count(*) FROM app.persona_card WHERE persona_id = ANY(${personaIds})), (SELECT count(*) FROM app.app_user WHERE user_id = ANY(${userIds}) OR email = ANY(${emails})), (SELECT count(*) FROM kb.protocol_registration WHERE protocol_id = ANY(${protocolIds})), (SELECT count(*) FROM kb.source WHERE source_id = ANY(${sourceIds})); `); const remaining = runPsql(` SELECT (SELECT count(*) FROM app.sessions WHERE id = ANY(${sessionIds})), (SELECT count(*) FROM app.persona_card WHERE persona_id = ANY(${personaIds})), (SELECT count(*) FROM app.app_user WHERE user_id = ANY(${userIds}) OR email = ANY(${emails})), (SELECT count(*) FROM kb.protocol_registration WHERE protocol_id = ANY(${protocolIds})), (SELECT count(*) FROM kb.source WHERE source_id = ANY(${sourceIds})); `); expect(remaining).toBe("0|0|0|0|0"); } async function signIn( page: Page, role: "admin" | "teacher" | "learner", email: string, displayName: string, ): Promise { cleanup.emails.add(email); const login = await page.request.post(`${API_BASE}/auth/dev-login`, { data: { email, role, display_name: displayName, cohort_ids: ["req005-e2e"], }, }); await expectOk(login); const onboarding = await page.request.post(`${API_BASE}/users/me/onboarding`, { data: { legal_name: displayName, affiliation: "한신대학교", department: role === "admin" ? "운영" : "상담심리학과", grade_level: role === "admin" ? "관리자" : role === "teacher" ? "교수" : "3학년", phone: "010-0000-0000", contact_address: "경기도 오산시 한신대학교", nickname: displayName, self_introduction: "REQ-005 실제 DB 브라우저 계약 검증 계정입니다.", avatar_url: "", terms_accepted: true, privacy_accepted: true, }, }); await expectOk(onboarding); const me = await page.request.get(`${API_BASE}/auth/me`); await expectOk(me); const userId = ((await me.json()) as AuthMe).user_id; cleanup.userIds.add(userId); return userId; } test.describe("real PostgreSQL persona and protocol lifecycle", () => { test.describe.configure({ mode: "default" }); test.beforeAll(async ({ request }) => { expect(await portIsListening(API_PORT), `API port ${API_PORT} must be isolated`).toBe(false); expect(await portIsListening(WEB_PORT), `web port ${WEB_PORT} must be isolated`).toBe(false); const portMap = spawnSync("docker", ["port", DB_CONTAINER, "5432/tcp"], { encoding: "utf8", windowsHide: true, }); expect(portMap.status, String(portMap.stderr)).toBe(0); expect(String(portMap.stdout)).toContain("55432"); expect(await portIsListening(55432), "PostgreSQL port 55432 must be reachable").toBe(true); const release = await acquireGlobalEngineConfigLock( "req005-persona-db-lifecycle", ); releaseEngineConfigLock = release; try { engineConfigSnapshot = readEngineConfigSnapshot(); engineConfigSnapshotCaptured = true; engine = await startControlledEngine(); installControlledEngineConfig(engine.url); api = await startApi(engine.url); const bootstrapEmail = `req005-engine-bootstrap-${Date.now()}@twentyoz.kr`; cleanup.emails.add(bootstrapEmail); const bootstrapLogin = await request.post(`${API_BASE}/auth/dev-login`, { data: { email: bootstrapEmail, role: "admin", display_name: "REQ005 엔진 격리 확인", cohort_ids: ["req005-e2e"], }, }); await expectOk(bootstrapLogin); const bootstrapMe = await request.get(`${API_BASE}/auth/me`); await expectOk(bootstrapMe); cleanup.userIds.add(((await bootstrapMe.json()) as AuthMe).user_id); const runtimeConfigResponse = await request.get(`${API_BASE}/admin/engine-config`); await expectOk(runtimeConfigResponse); expect((await runtimeConfigResponse.json()) as AdminEngineConfigResponse).toMatchObject({ engine_mode: "claude_api", engine_url: engine.url, model: "req005-controlled", reasoning_effort: "medium", durable: true, source: "database", }); // The isolated API has already configured its process-local EngineClient. // Restore the shared DB row immediately so unrelated/public API startups // cannot observe this one-shot localhost gateway during the browser run. restoreEngineConfig(engineConfigSnapshot); web = await startWeb(); } catch (error) { let setupError: unknown = error; try { await stopProcess(web); } catch (stopError) { setupError ??= stopError; } try { await stopProcess(api); } catch (stopError) { setupError ??= stopError; } try { if (engine) { await new Promise((resolve) => engine?.server.close(() => resolve())); } } catch (closeError) { setupError ??= closeError; } try { cleanupTrackedRows(); } catch (cleanupError) { setupError ??= cleanupError; } try { if (engineConfigSnapshotCaptured) { restoreEngineConfig(engineConfigSnapshot); } } catch (restoreError) { setupError ??= restoreError; } try { await release(); } catch (releaseError) { setupError ??= releaseError; } releaseEngineConfigLock = null; throw setupError; } }); test.afterAll(async () => { let teardownError: unknown = null; const remember = (error: unknown) => { teardownError ??= error; }; try { await stopProcess(web); expect(processExited(web), "the Vite process started by this spec must exit").toBe(true); } catch (error) { remember(error); } try { await stopProcess(api); expect(processExited(api), "the uvicorn process started by this spec must exit").toBe(true); } catch (error) { remember(error); } try { if (engine) { await new Promise((resolve) => engine?.server.close(() => resolve())); expect(engine.server.listening, "the controlled engine server must close").toBe(false); } } catch (error) { remember(error); } try { cleanupTrackedRows(); } catch (error) { remember(error); } try { if (engineConfigSnapshotCaptured) { restoreEngineConfig(engineConfigSnapshot); } } catch (error) { remember(error); } try { if (releaseEngineConfigLock) { await releaseEngineConfigLock(); releaseEngineConfigLock = null; } } catch (error) { remember(error); } try { await expectPortClosed(WEB_PORT); await expectPortClosed(API_PORT); expect(await portIsListening(55432), "PostgreSQL port 55432 must remain reachable").toBe( true, ); } catch (error) { remember(error); } if (teardownError) throw teardownError; }); test("teacher browser creates and approves a persona that stays pinned through the learner's first stored turn @single-run", async ({ page, }, testInfo) => { test.setTimeout(4 * 60_000); page.setDefaultTimeout(30_000); const suffix = testSuffix(testInfo); const code = `P${Date.now().toString().slice(-8)}`; const displayName = `은하(가명) · REQ005 ${suffix}`; const teacherEmail = `req005-teacher-${suffix}@hs.ac.kr`; const learnerEmail = `req005-learner-${suffix}@hs.ac.kr`; const learnerText = "그 이야기를 꺼내는 동안 어떤 마음이 가장 크게 느껴졌나요?"; const complaint = "사람들 앞에서 말한 뒤 계속 실수를 떠올려요."; const surfaceCondition = "상담자가 침묵을 기다리고 감정을 반영할 때 발표 장면을 구체적으로 말한다."; await signIn(page, "teacher", teacherEmail, `REQ005 교수 ${suffix}`); await page.goto(`${WEB_BASE}/teach/personas?view=personas`); await expect(page.getByRole("heading", { name: "페르소나 관리" })).toBeVisible({ timeout: 30_000, }); await page.getByRole("button", { name: "새로운 페르소나 만들기" }).click(); await expect(page.getByRole("heading", { name: "새 페르소나" })).toBeVisible(); await page .getByRole("navigation", { name: "페르소나 작성 단계" }) .getByRole("button", { name: /설정/ }) .click(); await expect(page.getByLabel("코드", { exact: true })).toBeVisible(); await page.getByLabel("코드", { exact: true }).fill(code); await page.getByLabel("표시 이름", { exact: true }).fill(displayName); await page.getByLabel("연령대", { exact: true }).fill("20대"); await page.getByLabel("역할/학년", { exact: true }).fill("대학생"); await page.getByLabel("맥락", { exact: true }).fill("발표 이후 대인관계 불안이 높아짐"); await page.getByLabel("주호소", { exact: true }).fill(complaint); await page .getByLabel("첫 회기 입장 발화", { exact: true }) .fill("무슨 말부터 해야 할지 잘 모르겠어요."); await page.getByRole("tab", { name: "임상", exact: true }).click(); await page.getByLabel("촉발 사건", { exact: true }).fill("최근 발표에서 말을 더듬은 경험"); await page.getByLabel("핵심신념", { exact: true }).fill("실수하면 사람들에게 받아들여지지 못한다."); const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" }); await automaticThoughts .getByRole("textbox", { name: "자동사고 1", exact: true }) .fill("또 실수할 거야"); await page.getByRole("tab", { name: "회기", exact: true }).click(); await page.getByLabel("표면화 조건", { exact: true }).fill(surfaceCondition); await page.getByRole("tab", { name: "안전", exact: true }).click(); const soreSpots = page.locator(".ps-list-field").filter({ hasText: "역린 민감 영역" }); await soreSpots .getByRole("textbox", { name: "역린 민감 영역 1", exact: true }) .fill("공개적인 비교와 성급한 조언"); const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" }); await forbidden .getByRole("textbox", { name: "상담자 금기 1", exact: true }) .fill("노력하면 된다는 단정"); await page.getByLabel("역린 반응", { exact: true }).fill("대답이 짧아지고 시선을 피한다."); await page .getByRole("navigation", { name: "페르소나 작성 단계" }) .getByRole("button", { name: /검토/ }) .click(); const createResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/personas/drafts"; }); await page.getByRole("button", { name: "검수 요청", exact: true }).click(); const createResponse = await createResponsePromise; expect(createResponse.status()).toBe(201); const created = (await createResponse.json()) as PersonaReviewSummary; cleanup.personaIds.add(created.persona_id); expect(created).toMatchObject({ code, version: 1, status: "review", display_name: displayName, }); await expect(page.getByText(`${code} v1 검수 요청을 올렸습니다.`)).toBeVisible(); await page.goto(`${WEB_BASE}/teach/personas?view=personas&queue=review`); const reviewCard = page.locator(".ps-review-card").filter({ hasText: code }); await expect(reviewCard).toContainText(displayName); const approveResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname === `/personas/review/${created.persona_id}` ); }); await reviewCard.getByRole("button", { name: "승인", exact: true }).click(); const approveResponse = await approveResponsePromise; await expectOk(approveResponse); expect((await approveResponse.json()) as PersonaReviewSummary).toMatchObject({ persona_id: created.persona_id, code, version: created.version, status: "approved", }); await page.goto(`${WEB_BASE}/teach/personas?view=catalog`); const catalogCard = page.locator(".ps-catalog-list > button").filter({ hasText: code }); await expect(catalogCard).toContainText(displayName); await expect(catalogCard.locator("strong")).toHaveText(`v${created.version}`); const catalogResponse = await page.request.get(`${API_BASE}/personas`); await expectOk(catalogResponse); const catalog = (await catalogResponse.json()) as PersonaSummary[]; const approvedPersona = catalog.find((item) => item.code === code); expect(approvedPersona).toMatchObject({ persona_id: created.persona_id, code, version: created.version, status: "approved", display_name: displayName, presenting_summary: complaint, source: "database", degraded: false, }); expect(approvedPersona?.presenting_summary).toContain(complaint); expect(approvedPersona?.presenting_summary).not.toContain(surfaceCondition); await signIn(page, "learner", learnerEmail, `REQ005 학습자 ${suffix}`); await page.goto(`${WEB_BASE}/learn/practice`); const personaOption = page.getByRole("option").filter({ hasText: code }); await expect(personaOption).toContainText(displayName, { timeout: 30_000 }); await personaOption.click(); await expect(personaOption).toHaveAttribute("aria-selected", "true"); await page.getByRole("button", { name: "새 회기 시작", exact: true }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${code}$`)); const prestartFacts = page.locator(".sx-prestart__facts"); await expect(prestartFacts.getByText("호소", { exact: true })).toBeVisible(); await expect(prestartFacts.getByText(complaint, { exact: true })).toBeVisible(); await expect(prestartFacts).not.toContainText(surfaceCondition); const startResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/sessions"; }); await page.getByRole("button", { name: "회기 시작", exact: true }).click(); const startResponse = await startResponsePromise; expect(startResponse.status()).toBe(201); const started = (await startResponse.json()) as SessionStartResponse; cleanup.sessionIds.add(started.session_id); expect(started).toMatchObject({ persona_id: created.persona_id, persona_version: created.version, degraded: false, }); await expect(page).toHaveURL(new RegExp(`/learn/session/${started.session_id}$`)); await completeAlliancePreCheckpoint(page); const streamResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname === `/sessions/${started.session_id}/stream` ); }); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기", exact: true }).click(); const streamResponse = await streamResponsePromise; await expectOk(streamResponse); expect(streamResponse.headers()["content-type"] ?? "").toContain("text/event-stream"); const engineRequests = engine?.requests() ?? []; const streamDiagnostics = [ `engine=${engineRequests.join(" | ") || ""}`, `api-tail=${api?.logs() || ""}`, ].join("\n"); await testInfo.attach("req005-stream-wire.txt", { body: streamDiagnostics, contentType: "text/plain; charset=utf-8", }); expect( engineRequests.some((request) => request.startsWith("POST /v1/stream")), streamDiagnostics, ).toBe(true); await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible(); await expect(page.locator(".sx-utt.is-client").last()).toContainText( "조금 더 말해볼 수 있을 것 같아요.", { timeout: 30_000 }, ); const detailResponse = await page.request.get(`${API_BASE}/sessions/${started.session_id}`); await expectOk(detailResponse); const detail = (await detailResponse.json()) as SessionDetailResponse; expect(detail).toMatchObject({ session_id: started.session_id, persona_id: created.persona_id, persona_version: created.version, persona_code: code, persona_name: displayName, }); expect(detail.turns).toHaveLength(2); expect(detail.turns.map((turn) => turn.speaker)).toEqual(["learner", "client"]); expect(detail.turns[0].text).toContain("어떤 마음이 가장 크게 느껴졌나요"); const dbEvidence = runPsql(` SELECT s.persona_id::text, s.persona_version, pc.code, pc.version, count(t.id), string_agg(t.speaker, ',' ORDER BY t.seq) FROM app.sessions s JOIN app.persona_card pc ON pc.persona_id = s.persona_id AND pc.version = s.persona_version LEFT JOIN app.turns t ON t.session_id = s.id WHERE s.id = '${started.session_id}'::uuid GROUP BY s.persona_id, s.persona_version, pc.code, pc.version; `); expect(dbEvidence).toBe( `${created.persona_id}|${created.version}|${code}|${created.version}|2|counselor,client`, ); }); test("admin API activates and retires a protocol with the matching real KB document lifecycle @single-run", async ({ page, }, testInfo) => { test.setTimeout(2 * 60_000); const suffix = testSuffix(testInfo); const adminEmail = `req002-admin-${suffix}@twentyoz.kr`; const title = `REQ002 실제 수명주기 ${suffix}`; await signIn(page, "admin", adminEmail, `REQ002 관리자 ${suffix}`); const create = await page.request.post(`${API_BASE}/admin/protocols`, { data: { title, source: `repo://e2e/protocol/${suffix}`, version: 1, license: "A", external_llm_ok: true, content: "첫 문단은 상담자가 내담자의 안전 신호를 관찰하는 절차를 설명합니다.\n\n두 번째 문단은 평가자 전용 근거와 기록 순서를 설명합니다.", }, }); expect(create.status(), await create.text()).toBe(201); const draft = (await create.json()) as ProtocolResponse; cleanup.protocolIds.add(draft.protocol_id); cleanup.sourceIds.add(draft.source_id); expect(draft).toMatchObject({ title, version: 1, license: "A", external_llm_ok: true, status: "draft", }); const activate = await page.request.post( `${API_BASE}/admin/protocols/${draft.protocol_id}/activate`, ); await expectOk(activate); const activated = (await activate.json()) as ProtocolActivationResponse; expect(activated.protocol).toMatchObject({ protocol_id: draft.protocol_id, source_id: draft.source_id, status: "active", }); expect(activated.chunks_indexed).toBeGreaterThanOrEqual(1); const activeList = await page.request.get( `${API_BASE}/admin/protocols?status=active&search=${encodeURIComponent(title)}`, ); await expectOk(activeList); expect((await activeList.json()) as { total: number; protocols: ProtocolResponse[] }).toMatchObject({ total: 1, protocols: [ { protocol_id: draft.protocol_id, source_id: draft.source_id, status: "active", }, ], }); const activeDb = runPsql(` SELECT pr.status, bool_and(d.is_active), count(c.chunk_id), min(c.sensitivity), bool_and(c.visible_to = ARRAY['evaluator']::text[]), s.license_class, s.external_llm_ok FROM kb.protocol_registration pr JOIN kb.source s ON s.source_id = pr.source_id JOIN kb.document d ON d.source_id = pr.source_id JOIN kb.chunk c ON c.doc_id = d.doc_id WHERE pr.protocol_id = '${draft.protocol_id}'::uuid GROUP BY pr.status, s.license_class, s.external_llm_ok; `); expect(activeDb).toMatch(/^active\|t\|[1-9][0-9]*\|2\|t\|A\|t$/); const retire = await page.request.post( `${API_BASE}/admin/protocols/${draft.protocol_id}/retire`, ); await expectOk(retire); expect((await retire.json()) as ProtocolResponse).toMatchObject({ protocol_id: draft.protocol_id, source_id: draft.source_id, status: "retired", }); const retiredDb = runPsql(` SELECT pr.status, bool_or(d.is_active) FROM kb.protocol_registration pr JOIN kb.document d ON d.source_id = pr.source_id WHERE pr.protocol_id = '${draft.protocol_id}'::uuid GROUP BY pr.status; `); expect(retiredDb).toBe("retired|f"); }); });