vignette/apps/web/e2e/persona-db-lifecycle.spec.ts

1145 lines
39 KiB
TypeScript

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<void>) | null = null;
const cleanup = {
sessionIds: new Set<string>(),
personaIds: new Set<string>(),
userIds: new Set<string>(),
emails: new Set<string>(),
protocolIds: new Set<string>(),
sourceIds: new Set<string>(),
};
function testSuffix(testInfo: { workerIndex: number; retry: number }): string {
return `${Date.now().toString(36)}-${testInfo.workerIndex}-${testInfo.retry}`;
}
async function expectOk(response: APIResponse): Promise<void> {
if (response.ok()) return;
expect(response.ok(), await response.text()).toBeTruthy();
}
async function readRequestBody(request: IncomingMessage): Promise<Record<string, unknown>> {
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<string, unknown>) : {};
}
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<ManagedServer> {
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<void>((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<void>((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<ManagedProcess> {
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<ManagedProcess> {
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<void> {
if (!managed || managed.process.exitCode !== null) return;
managed.process.kill();
await new Promise<void>((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<boolean> {
return new Promise<boolean>((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<void> {
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>): string {
const rows = [...values];
return rows.length ? `ARRAY[${rows.map((value) => `'${value}'::uuid`).join(",")}]` : "ARRAY[]::uuid[]";
}
function sqlTextArray(values: Iterable<string>): 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<string> {
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<void>((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<void>((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(" | ") || "<none>"}`,
`api-tail=${api?.logs() || "<none>"}`,
].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");
});
});