vignette/apps/web/e2e/session-persistence.spec.ts

1803 lines
68 KiB
TypeScript

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { expect, test, type Page } from "@playwright/test";
import {
completeAlliancePreCheckpoint,
fetchAvailablePersona,
signInAsAdmin,
signInAsLearner,
signInAsTeacher,
withGlobalEngineConfigLock,
} from "./support";
interface SessionStartResponse {
session_id: string;
case_id: string;
session_no: number;
degraded: boolean;
}
interface HealthResponse {
db?: boolean;
engine?: boolean;
}
interface TeacherSafetyAlert {
session_id: string;
resource_title: string;
resource_number: string;
}
interface TeacherDashboardResponse {
source: string;
safety_alerts: TeacherSafetyAlert[];
pending_reviews?: TeacherSessionSummary[];
recent_sessions?: TeacherSessionSummary[];
}
interface TeacherSessionSummary {
session_id: string;
turn_count: number;
evaluation_status?: "pending" | "ready" | "error";
review_ready?: boolean;
supervisor_state?: string;
}
interface ReviewTurn {
speaker: string;
text: string;
techniques?: Array<{
kind?: string;
label: string;
}>;
nonverbal?: Array<{
kind: string;
label: string;
detail: string;
}>;
note?: {
tone?: string;
title: string;
body: string;
} | null;
}
interface SessionReviewResponse {
session_id: string;
turns: ReviewTurn[];
degraded?: boolean;
reviewReady?: boolean;
supervisorState?: string;
summary?: string;
caseWorksheet?: {
status?: string;
sections?: Array<{
key: string;
items?: Array<{
key: string;
value?: string | null;
}>;
}>;
};
teacherReview?: {
worksheetStatus?: string | null;
worksheetNote?: string | null;
} | null;
}
interface SessionDetailResponse {
session_id: string;
theory_mode: string;
status: "active" | "ended";
started_at: string;
ended_at?: string | null;
turns?: Array<{
speaker: "learner" | "client";
text: string;
}>;
}
interface LiveCoachEvent {
turn_seq: number;
learner_text_excerpt?: string | null;
suggestion: {
status?: "ready" | "degraded";
title: string;
message: string;
latency_ms?: number;
sources?: Array<{
source_id: string;
title: string;
locator?: string | null;
kb_kind?: string | null;
version?: string | null;
citation?: string | null;
}>;
};
}
interface LiveCoachHistoryResponse {
source: string;
events: LiveCoachEvent[];
}
interface VoiceHealthResponse {
available?: boolean;
}
interface VoiceWsProbe {
code: number;
events: Array<{ type?: string; state?: string; [key: string]: unknown }>;
binaryChunks: number;
}
interface VoiceUiProbeMessage {
direction: "sent" | "received";
kind: "text" | "binary";
data?: string;
byteLength?: number;
}
interface VoiceUiProbeState {
getUserMediaCalls: number;
workletModuleLoads: number;
workletNodes: number;
workletChunks: number;
trackStops: number;
messages: VoiceUiProbeMessage[];
}
interface EvaluationSummaryResponse {
session_id: string;
status?: string | null;
error?: string | null;
durable?: boolean;
deep?: Record<string, unknown> | null;
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
reasoning_effort?: string | null;
}
interface PrepostMeasureItem {
measure_name: string;
timepoint: string;
raw_score: number;
normalized_score: number;
}
interface PrepostMeasuresResponse {
source: string;
durable: boolean;
pilot_id: string;
complete_measure_pairs: number;
measures: PrepostMeasureItem[];
}
interface ControlledFailureGateway {
url: string;
clientRequests: () => number;
evaluatorRequests: () => number;
close: () => Promise<void>;
}
async function startControlledEvaluationFailureGateway(
model: string,
fallbackProvider: string,
): Promise<ControlledFailureGateway> {
let clientRequestCount = 0;
let evaluatorRequestCount = 0;
const sendJson = (response: ServerResponse, status: number, payload: unknown) => {
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify(payload));
};
const readJson = async (request: IncomingMessage) => {
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>) : {};
};
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const provider = url.searchParams.get("provider") || fallbackProvider;
if (request.method === "GET" && url.pathname === "/v1/capabilities") {
sendJson(response, 200, {
provider,
available: true,
source: "live_cli",
models: [
{
id: model,
label: "REQ-007 controlled gateway",
description: "client succeeds while evaluator returns HTTP 500",
reasoning_efforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
default_reasoning_effort: "high",
is_default: true,
},
],
default_model: model,
default_reasoning_effort: "high",
detail: "controlled gateway ready",
fetched_at: Date.now() / 1000,
});
return;
}
if (request.method === "GET" && ["/ready", "/health"].includes(url.pathname)) {
sendJson(response, 200, {
ok: true,
status: "ok",
detail: "controlled gateway ready",
cached: false,
});
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 readJson(request);
if (payload.ai_role === "evaluator") {
evaluatorRequestCount += 1;
sendJson(response, 500, { detail: "controlled evaluator failure" });
return;
}
clientRequestCount += 1;
sendJson(response, 200, {
text: "조금 더 이야기해볼게요.",
model,
provider: String(payload.provider || fallbackProvider),
tokens_in: 7,
tokens_out: 9,
cost_usd: 0,
});
return;
}
sendJson(response, 404, { detail: `unexpected controlled gateway path: ${url.pathname}` });
} catch (error) {
sendJson(response, 500, {
detail: error instanceof Error ? error.message : String(error),
});
}
});
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once("error", onError);
server.listen(0, "127.0.0.1", () => {
server.off("error", onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
await new Promise<void>((resolve) => server.close(() => resolve()));
throw new Error("controlled gateway did not expose a TCP port");
}
return {
url: `http://127.0.0.1:${address.port}`,
clientRequests: () => clientRequestCount,
evaluatorRequests: () => evaluatorRequestCount,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
}),
};
}
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function expectTextTurnSettled(page: Page) {
const input = page.getByLabel("학습자 발화 입력");
await input.fill("후속 발화 준비 확인");
await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled({
timeout: 90_000,
});
await input.fill("");
}
async function setupSyntheticVoiceUiProbe(page: Page, transcript: string) {
await page.addInitScript((text) => {
type ProbeMessage = {
direction: "sent" | "received";
kind: "text" | "binary";
data?: string;
byteLength?: number;
};
type ProbeState = {
getUserMediaCalls: number;
workletModuleLoads: number;
workletNodes: number;
workletChunks: number;
trackStops: number;
messages: ProbeMessage[];
};
const probe: ProbeState = {
getUserMediaCalls: 0,
workletModuleLoads: 0,
workletNodes: 0,
workletChunks: 0,
trackStops: 0,
messages: [],
};
(window as Window & { __voiceUiProbe?: ProbeState }).__voiceUiProbe = probe;
const fakeTrack = {
kind: "audio",
enabled: true,
readyState: "live",
stop: () => {
probe.trackStops += 1;
fakeTrack.readyState = "ended";
},
};
const fakeStream = {
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: async () => {
probe.getUserMediaCalls += 1;
return fakeStream;
},
},
});
const NativeWebSocket = window.WebSocket;
const sizeOf = (data: unknown) => {
if (typeof data === "string") return data.length;
if (data instanceof Blob) return data.size;
if (data instanceof ArrayBuffer) return data.byteLength;
if (ArrayBuffer.isView(data)) return data.byteLength;
return 0;
};
class ProbeWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) super(url);
else super(url, protocols);
this.addEventListener("message", (event) => {
if (typeof event.data === "string") {
probe.messages.push({ direction: "received", kind: "text", data: event.data });
} else {
probe.messages.push({
direction: "received",
kind: "binary",
byteLength: sizeOf(event.data),
});
}
});
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (typeof data === "string") {
probe.messages.push({ direction: "sent", kind: "text", data });
try {
const parsed = JSON.parse(data) as {
type?: string;
silence_ms?: number;
barge_in?: boolean;
provider_events?: unknown[];
};
if (parsed.type === "audio_end") {
super.send(
JSON.stringify({
type: "stt_result",
text,
final: true,
silence_ms: parsed.silence_ms,
barge_in: parsed.barge_in,
provider_events: parsed.provider_events,
}),
);
return;
}
} catch {
// Keep non-JSON WebSocket traffic unchanged.
}
} else {
probe.messages.push({ direction: "sent", kind: "binary", byteLength: sizeOf(data) });
}
super.send(data);
}
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: ProbeWebSocket,
});
class FakeWorkletPort {
onmessage: ((event: MessageEvent) => void) | null = null;
closed = false;
postMessage(message: { type?: string }) {
if (message?.type === "flush") this.emitChunk();
}
close() {
this.closed = true;
}
emitChunk() {
if (this.closed) return;
probe.workletChunks += 1;
const pcm = new Int16Array([0, 4096, -4096, 0]);
this.onmessage?.({
data: {
type: "chunk",
pcm: pcm.buffer,
metrics: {
durationMs: 1600,
voiceMs: 360,
silenceMs: 1240,
trailingSilenceMs: 1240,
rms: 0.1,
peak: 0.72,
},
},
} as MessageEvent);
}
}
class FakeAudioWorkletNode {
port = new FakeWorkletPort();
constructor(_ctx: unknown, _name: string, _options?: unknown) {
probe.workletNodes += 1;
}
connect() {
return this;
}
disconnect() {
return undefined;
}
__start() {
window.setTimeout(() => this.port.emitChunk(), 25);
}
}
class FakeMediaStreamSource {
connect(node: { __start?: () => void }) {
node.__start?.();
return node;
}
disconnect() {
return undefined;
}
}
class FakeAudioContext {
state = "running";
destination = {};
sampleRate = 16000;
audioWorklet = {
addModule: async (_url: string) => {
probe.workletModuleLoads += 1;
},
};
async resume() {
this.state = "running";
}
async close() {
this.state = "closed";
}
async decodeAudioData(_data: ArrayBuffer) {
return { duration: 0.12 };
}
createAnalyser() {
return {
fftSize: 0,
frequencyBinCount: 4,
getByteTimeDomainData: (array: Uint8Array) => array.fill(128),
connect: () => undefined,
disconnect: () => undefined,
};
}
createBufferSource() {
return {
buffer: null,
connect: () => undefined,
disconnect: () => undefined,
start: () => undefined,
stop: () => undefined,
onended: null as (() => void) | null,
};
}
createMediaStreamSource(_stream: unknown) {
return new FakeMediaStreamSource();
}
}
Object.defineProperty(window, "AudioWorkletNode", {
configurable: true,
value: FakeAudioWorkletNode,
});
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: FakeAudioContext,
});
Object.defineProperty(window, "webkitAudioContext", {
configurable: true,
value: FakeAudioContext,
});
}, transcript);
}
async function readVoiceUiProbe(page: Page): Promise<VoiceUiProbeState> {
return page.evaluate(() => {
const probe = (window as Window & { __voiceUiProbe?: VoiceUiProbeState }).__voiceUiProbe;
if (!probe) throw new Error("voice UI probe was not installed");
return {
...probe,
messages: [...probe.messages],
};
});
}
async function createSessionWithTurn(page: Page) {
const persona = await fetchAvailablePersona(page);
const startedResponse = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(startedResponse);
const started = (await startedResponse.json()) as SessionStartResponse;
expect(started.degraded).toBe(false);
let turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, {
data: {
text: "오늘 많이 버거웠겠어요. 지금 가장 크게 남아 있는 마음은 어떤 건가요?",
},
});
for (let attempt = 0; attempt < 2 && !turnResponse.ok(); attempt += 1) {
await page.waitForTimeout(1000);
turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, {
data: {
text: "조금 천천히 이야기해도 괜찮습니다. 지금 마음에 남는 장면이 있나요?",
},
});
}
let expectedMinTurns = 2;
if (!turnResponse.ok()) {
expect(turnResponse.status(), await turnResponse.text()).toBe(503);
expectedMinTurns = 1;
}
const endedResponse = await page.request.post(`/api/sessions/${started.session_id}/end`);
await expectResponseOk(endedResponse);
return { sessionId: started.session_id, expectedMinTurns };
}
async function createActiveSessionWithTurn(page: Page) {
const persona = await fetchAvailablePersona(page);
const startedResponse = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(startedResponse);
const started = (await startedResponse.json()) as SessionStartResponse;
expect(started.degraded).toBe(false);
let turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, {
data: {
text: "제가 이해한 바로는 그 일이 꽤 오래 마음에 남아 있었던 것 같습니다.",
},
});
for (let attempt = 0; attempt < 2 && !turnResponse.ok(); attempt += 1) {
await page.waitForTimeout(1000);
turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, {
data: {
text: "그 장면을 떠올릴 때 몸이나 마음에서 먼저 느껴지는 반응이 있나요?",
},
});
}
await expectResponseOk(turnResponse);
return started.session_id;
}
async function createEndedSessionWithoutTurn(page: Page) {
const persona = await fetchAvailablePersona(page);
const startedResponse = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(startedResponse);
const started = (await startedResponse.json()) as SessionStartResponse;
expect(started.degraded).toBe(false);
const endedResponse = await page.request.post(`/api/sessions/${started.session_id}/end`);
await expectResponseOk(endedResponse);
return started.session_id;
}
async function evaluationState(page: Page, sessionId: string) {
const response = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`);
if (!response.ok()) {
return `http:${response.status()}:${(await response.text()).slice(0, 120)}`;
}
const body = (await response.json()) as EvaluationSummaryResponse;
const loop = typeof body.deep?.loop === "string" ? body.deep.loop : "none";
const turnsEvaluated =
typeof body.deep?.turns_evaluated === "number" ? body.deep.turns_evaluated : 0;
if (body.status === "ready" && body.durable === true && loop === "deep") {
return `ready:${turnsEvaluated}`;
}
if (body.status === "error") {
return `error:${body.error || "empty-error"}:${body.durable ? "durable" : "cache"}`;
}
return `pending:${body.status ?? "none"}:${body.durable ? "durable" : "cache"}:${loop}`;
}
/** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */
async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
const tabs = page.locator(".sr-tabs");
await tabs.waitFor({ state: "attached", timeout: 20_000 });
await tabs.locator("button", { hasText: label }).click();
}
test.describe("session persistence", () => {
test.use({ timezoneId: "Asia/Seoul" });
test("persists selected CBT theory mode from session UI into DB-backed detail @single-run", async ({
page,
}) => {
test.setTimeout(60_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db, "theory mode persistence requires DB");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
const cbtButton = page.locator(".sx-theory").getByRole("button", { name: /CBT/ });
await expect(cbtButton).toBeVisible();
await cbtButton.click();
await expect(cbtButton).toHaveAttribute("aria-pressed", "true");
const startResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname.endsWith("/api/sessions");
});
await page.getByRole("button", { name: "회기 시작" }).click();
const startResponse = await startResponsePromise;
await expectResponseOk(startResponse);
expect(startResponse.request().postDataJSON()).toMatchObject({
persona_code: persona.code,
theory_mode: "cbt",
});
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const detailResponse = await page.request.get(`/api/sessions/${sessionId}`);
await expectResponseOk(detailResponse);
const detail = (await detailResponse.json()) as SessionDetailResponse;
expect(detail.session_id).toBe(sessionId);
expect(detail.theory_mode).toBe("cbt");
expect(detail.status).toBe("active");
});
test("persists the browser SSE stream into DB-backed review @single-run", async ({ page }) => {
test.setTimeout(120_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "real stream persistence requires DB and engine");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const learnerText =
"지금 많이 버거웠겠어요. 오늘 제일 크게 남아 있는 감정은 무엇인가요?";
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const streamResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
);
});
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
const streamResponse = await streamResponsePromise;
await expectResponseOk(streamResponse);
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, {
timeout: 90_000,
});
const clientUtterance = page.locator(".sx-utt.is-client").first();
await expect(clientUtterance).toBeVisible();
await expect(clientUtterance).not.toContainText("답변을 준비 중입니다.");
await expectTextTurnSettled(page);
await expect
.poll(
async () => {
const response = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(response);
const candidate = (await response.json()) as SessionReviewResponse;
return {
learner: candidate.turns.some(
(turn) => turn.speaker === "learner" && turn.text === learnerText,
),
client: candidate.turns.some(
(turn) => turn.speaker === "client" && turn.text.trim().length > 0,
),
};
},
{
timeout: 15_000,
intervals: [100, 250, 500, 1_000],
message: "SSE done 뒤 양쪽 발화가 DB-backed review에 보여야 한다",
},
)
.toEqual({ learner: true, client: true });
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
expect(review.session_id).toBe(sessionId);
expect(review.turns.some((turn) => turn.speaker === "learner" && turn.text === learnerText)).toBe(
true,
);
expect(
review.turns.some((turn) => turn.speaker === "client" && turn.text.trim().length > 0),
).toBe(true);
});
test("masks Korean PII through the browser stream into DB-backed review @single-run", async ({
page,
}) => {
test.setTimeout(120_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "PII stream masking requires DB and engine");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const piiText =
"제 이름은 김서연이고 한신대학교 상담센터에서 010-1234-5678로 연락을 받았어요.";
const forbiddenRaw = ["김서연", "한신대학교", "상담센터", "010-1234-5678"];
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const streamResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
);
});
const input = page.getByLabel("학습자 발화 입력");
await input.fill(piiText);
await page.getByRole("button", { name: "보내기" }).click();
const streamResponse = await streamResponsePromise;
await expectResponseOk(streamResponse);
await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, {
timeout: 90_000,
});
const clientUtterance = page.locator(".sx-utt.is-client").first();
await expect(clientUtterance).toBeVisible();
await expect(clientUtterance).not.toContainText("답변을 준비 중입니다.");
await expectTextTurnSettled(page);
const detailResponse = await page.request.get(`/api/sessions/${sessionId}`);
await expectResponseOk(detailResponse);
const detail = (await detailResponse.json()) as SessionDetailResponse;
expect(detail.session_id).toBe(sessionId);
const detailBlob = JSON.stringify(detail);
for (const raw of forbiddenRaw) {
expect(detailBlob).not.toContain(raw);
}
const detailLearnerTurn = detail.turns?.find((turn) => turn.speaker === "learner");
expect(detailLearnerTurn?.text).toContain("[NAME]");
expect(detailLearnerTurn?.text).toContain("[ORG]");
expect(detailLearnerTurn?.text).toContain("[PHONE]");
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
const reviewBlob = JSON.stringify(review);
for (const raw of forbiddenRaw) {
expect(reviewBlob).not.toContain(raw);
}
const reviewLearnerTurn = review.turns.find((turn) => turn.speaker === "learner");
expect(reviewLearnerTurn?.text).toContain("[NAME]");
expect(reviewLearnerTurn?.text).toContain("[ORG]");
expect(reviewLearnerTurn?.text).toContain("[PHONE]");
});
test("persists crisis safety event into DB-backed teacher dashboard @single-run", async ({
page,
}) => {
test.setTimeout(120_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db, "crisis safety queue persistence requires DB");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const crisisText = "저 지금 자살하고 싶어요. 도와주세요.";
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const streamResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
);
});
const input = page.getByLabel("학습자 발화 입력");
await input.fill(crisisText);
await page.getByRole("button", { name: "보내기" }).click();
const streamResponse = await streamResponsePromise;
await expectResponseOk(streamResponse);
await expect(page.locator(".sx-crisis-resource")).toContainText("자살예방상담전화 109", {
timeout: 15_000,
});
await expect(page.getByRole("link", { name: "109" })).toBeVisible();
await expect(input).toBeDisabled();
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
expect(review.session_id).toBe(sessionId);
expect(
review.turns.some((turn) => turn.speaker === "learner" && turn.text === crisisText),
"the crisis utterance itself should be persisted for supervisor review",
).toBe(true);
expect(
review.turns.some((turn) => turn.speaker === "client" && turn.text.trim().length > 0),
"real crisis escalation must stop before a client AI reply is stored",
).toBe(false);
await signInAsTeacher(page);
await expect
.poll(
async () => {
const dashboardResponse = await page.request.get("/api/teacher/dashboard");
await expectResponseOk(dashboardResponse);
const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse;
expect(dashboard.source).toBe("database");
return dashboard.safety_alerts.some(
(alert) => alert.session_id === sessionId && alert.resource_number === "109",
);
},
{
timeout: 30_000,
intervals: [500, 1_000, 2_000],
message: "crisis safety event should reach the DB-backed teacher dashboard",
},
)
.toBe(true);
await page.goto("/teach");
await expect(page.getByText("109 안전 확인 큐")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".pf-alert").filter({ hasText: sessionId ?? "" })).toContainText("109", {
timeout: 30_000,
});
});
test("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
test.setTimeout(240_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "real live-coach persistence requires DB and engine");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const learnerText =
"그 말을 꺼내는 것도 쉽지 않았겠어요. 지금 가장 버거운 감정부터 천천히 말해볼까요?";
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await page.getByRole("button", { name: "코칭" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const streamResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
);
});
const coachResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/api/sessions/${sessionId}/live-coach`)
);
});
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
const streamResponse = await streamResponsePromise;
await expectResponseOk(streamResponse);
await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, {
timeout: 90_000,
});
await expectTextTurnSettled(page);
const coachResponse = await coachResponsePromise;
await expectResponseOk(coachResponse);
const coachSuggestion = (await coachResponse.json()) as LiveCoachEvent["suggestion"];
expect(coachSuggestion.status).toBe("ready");
expect(coachSuggestion.latency_ms ?? 0).toBeGreaterThan(0);
const workbookSource = coachSuggestion.sources?.find(
(source) => source.source_id === "workbook_0615_case_conceptualization",
);
expect(workbookSource, "live coach response should include the licensed workbook source pack").toBeTruthy();
expect(workbookSource?.version).toBe("2026-06-15");
expect(workbookSource?.citation ?? "").toContain("0615");
await expect(page.locator(".sx-coach-card").getByText(coachSuggestion.title)).toBeVisible({
timeout: 30_000,
});
await page.locator(".sx-coach-card").getByRole("button", { name: "근거 보기" }).click();
const evidenceDialog = page.locator(".sx-coach-modal [role='dialog']");
await expect(evidenceDialog).toBeVisible({ timeout: 15_000 });
await expect(evidenceDialog).toContainText(workbookSource!.title);
await expect(evidenceDialog).toContainText("2026-06-15");
await expect(evidenceDialog).toContainText("0615");
await evidenceDialog.getByRole("button", { name: "닫기" }).click();
await expect(evidenceDialog).toHaveCount(0);
const historyResponse = await page.request.get(`/api/sessions/${sessionId}/live-coach`);
await expectResponseOk(historyResponse);
const history = (await historyResponse.json()) as LiveCoachHistoryResponse;
expect(history.source).toBe("database");
const persistedCoachEvent = history.events.find(
(event) =>
event.turn_seq === 1 &&
event.learner_text_excerpt?.includes("가장 버거운 감정") &&
event.suggestion.title === coachSuggestion.title,
);
expect(persistedCoachEvent, "DB-backed live coach history should include the delivered coaching event").toBeTruthy();
expect(persistedCoachEvent?.suggestion.status).toBe("ready");
expect(persistedCoachEvent?.suggestion.latency_ms ?? 0).toBeGreaterThan(0);
expect(
persistedCoachEvent?.suggestion.sources?.some(
(source) =>
source.source_id === workbookSource!.source_id &&
source.version === workbookSource!.version &&
(source.citation ?? "").includes("0615"),
),
"DB-backed live coach history should preserve source pack metadata",
).toBe(true);
await page.goto(`/learn/session/${sessionId}`);
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible({
timeout: 15_000,
});
const reloadedDetailResponse = await page.request.get(`/api/sessions/${sessionId}`);
await expectResponseOk(reloadedDetailResponse);
const reloadedDetail = (await reloadedDetailResponse.json()) as SessionDetailResponse;
expect(reloadedDetail.status).toBe("active");
expect(reloadedDetail.ended_at).toBeNull();
expect(reloadedDetail.started_at).toMatch(/(?:Z|[+-]\d{2}:\d{2})$/);
const sessionAgeMs = Date.now() - Date.parse(reloadedDetail.started_at);
expect(Number.isFinite(sessionAgeMs)).toBe(true);
expect(sessionAgeMs).toBeGreaterThanOrEqual(-5_000);
expect(sessionAgeMs).toBeLessThan(10 * 60 * 1_000);
await expect(
page.locator(".sx-session-progress__grid > span").first().locator("b"),
).toHaveText(/^0\d:[0-5]\d$/);
await expect(page.locator(".sx-timebar.is-over")).toHaveCount(0);
await expect(page.getByRole("dialog", { name: "회기 시간이 끝났어요" })).toHaveCount(0);
const coachMark = page.locator(".sx-utt.is-learner .sx-utt__coach-mark");
await expect(coachMark).toBeVisible({
timeout: 15_000,
});
await coachMark.click();
const historyDialog = page.locator(".sx-coach-history [role='dialog']");
await expect(historyDialog).toBeVisible({ timeout: 15_000 });
await expect(historyDialog).toContainText(workbookSource!.title);
await expect(historyDialog).toContainText("2026-06-15");
await expect(historyDialog).toContainText("0615");
});
test("persists voice nonverbal metadata into DB-backed review @single-run", async ({ page }) => {
test.setTimeout(240_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
const voiceHealthResponse = await page.request.get("/api/voice/health");
await expectResponseOk(voiceHealthResponse);
const voiceHealth = (await voiceHealthResponse.json()) as VoiceHealthResponse;
test.skip(
!health.db || !health.engine || !voiceHealth.available,
"voice nonverbal persistence requires DB, engine, and voice service",
);
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const startedResponse = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(startedResponse);
const started = (await startedResponse.json()) as SessionStartResponse;
expect(started.degraded).toBe(false);
await page.goto(`/learn/session/${started.session_id}`);
const learnerText = "요즘 잠을 잘 못 자요.";
const probe = await page.evaluate(
({ sessionId, text }) =>
new Promise<VoiceWsProbe>((resolve) => {
const wsUrl = new URL(
`/api/voice/ws?session_id=${encodeURIComponent(sessionId)}`,
window.location.href,
);
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(wsUrl.href);
ws.binaryType = "arraybuffer";
const events: VoiceWsProbe["events"] = [];
let binaryChunks = 0;
let sawReply = false;
const finish = (code: number) => {
window.clearTimeout(timeout);
resolve({ code, events, binaryChunks });
};
const timeout = window.setTimeout(() => {
ws.close();
finish(-1);
// dev 엔진(Claude CLI 게이트웨이) 음성 턴이 90초를 넘기도 한다 — 게이트웨이
// GENERATE_TURN_TIMEOUT_SECONDS(300초)와 정합하게 150초까지 기다린다.
}, 150_000);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "audio_start", format: "webm" }));
window.setTimeout(() => {
ws.send(
JSON.stringify({
type: "stt_result",
text,
final: true,
silence_ms: 1500,
barge_in: true,
provider_events: [{ type: "sigh", confidence: 0.82, text: "drop raw" }],
}),
);
}, 150);
};
ws.onmessage = (event) => {
if (typeof event.data === "string") {
const parsed = JSON.parse(event.data) as { type?: string; state?: string };
events.push(parsed);
if (parsed.type === "reply") sawReply = true;
if (sawReply && parsed.type === "state" && parsed.state === "idle") {
ws.send(JSON.stringify({ type: "close" }));
}
return;
}
binaryChunks += 1;
};
ws.onerror = () => {
events.push({ type: "error", detail: "browser websocket error" });
};
ws.onclose = (event) => finish(event.code);
}),
{ sessionId: started.session_id, text: learnerText },
);
expect(probe.code, JSON.stringify(probe, null, 2)).toBe(1000);
expect(probe.events.some((event) => event.type === "error" || event.type === "degraded")).toBe(
false,
);
expect(probe.events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "eot", ready: true }),
expect.objectContaining({ type: "transcript", text: learnerText }),
expect.objectContaining({ type: "reply" }),
]),
);
const reviewResponse = await page.request.get(`/api/sessions/${started.session_id}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
const learnerTurn = review.turns.find(
(turn) => turn.speaker === "learner" && turn.text === learnerText,
);
expect(learnerTurn).toBeTruthy();
const nonverbalKinds = learnerTurn?.nonverbal?.map((event) => event.kind) ?? [];
expect(nonverbalKinds).toEqual(
expect.arrayContaining(["silence", "pace", "barge_in", "paralinguistic"]),
);
expect(learnerTurn?.nonverbal?.some((event) => event.label === "음성 단서")).toBe(true);
expect(learnerTurn?.nonverbal?.map((event) => event.detail).join(" ")).not.toContain(
"drop raw",
);
});
test("persists browser mic UI nonverbal metadata into DB-backed review @single-run", async ({ page }) => {
test.setTimeout(150_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
const voiceHealthResponse = await page.request.get("/api/voice/health");
await expectResponseOk(voiceHealthResponse);
const voiceHealth = (await voiceHealthResponse.json()) as VoiceHealthResponse;
test.skip(
!health.db || !health.engine || !voiceHealth.available,
"browser mic UI voice persistence requires DB, engine, and voice service",
);
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const learnerText = "요즘 잠을 잘 못 자요.";
await setupSyntheticVoiceUiProbe(page, learnerText);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const mic = page.locator(".sx-mic");
await expect(mic).toBeEnabled();
await mic.click();
const voiceConsentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
await expect(voiceConsentDialog).toBeVisible();
await voiceConsentDialog
.getByRole("button", { name: "동의하고 마이크 켜기" })
.click();
await expect
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => (await readVoiceUiProbe(page)).workletChunks, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_start"') &&
message.data?.includes('"format":"pcm"'),
);
}, { timeout: 10_000 })
.toBeTruthy();
await mic.click();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) => message.direction === "sent" && message.kind === "binary",
);
}, { timeout: 10_000 })
.toBeTruthy();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_end"') &&
message.data?.includes('"silence_ms":1240') &&
message.data?.includes('"browser_audio_worklet"'),
);
}, { timeout: 10_000 })
.toBeTruthy();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
const events = probe.messages
.filter((message) => message.direction === "received" && message.kind === "text")
.map((message) => JSON.parse(message.data ?? "{}") as { type?: string; text?: string });
return {
transcript: events.some((event) => event.type === "transcript" && event.text === learnerText),
reply: events.some((event) => event.type === "reply"),
errors: events.filter((event) => event.type === "error" || event.type === "degraded").length,
};
}, { timeout: 90_000 })
.toEqual({ transcript: true, reply: true, errors: 0 });
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
const learnerTurn = review.turns.find(
(turn) => turn.speaker === "learner" && turn.text === learnerText,
);
expect(learnerTurn).toBeTruthy();
const nonverbal = learnerTurn?.nonverbal ?? [];
expect(nonverbal.map((event) => event.kind)).toEqual(expect.arrayContaining(["silence", "pace"]));
expect(nonverbal.filter((event) => event.kind === "silence")).toHaveLength(1);
expect(nonverbal.some((event) => event.label === "침묵" && event.detail === "1.2초")).toBe(
true,
);
expect(nonverbal.map((event) => event.detail).join(" ")).not.toContain("browser_audio_worklet");
});
test("persists learner turns into DB-backed review and teacher dashboard @single-run", async ({ page }) => {
test.setTimeout(120_000);
await signInAsLearner(page);
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = await reviewResponse.json();
expect(review.session_id).toBe(sessionId);
expect(review.turns.length).toBeGreaterThanOrEqual(expectedMinTurns);
expect(review.turns[0].speaker).toBe("learner");
if (review.reviewReady) {
expect(review.degraded).toBe(false);
expect(review.summary).toContain("평가 AI");
} else {
expect(review.degraded).toBe(true);
expect(review.rubric).toHaveLength(0);
expect(review.goodMoments).toHaveLength(0);
expect(review.growthPoints).toHaveLength(0);
expect(review.summary).not.toContain("잘한 구체적");
}
const hasClientTurn = review.turns.some(
(turn: { speaker: string }) => turn.speaker === "client",
);
if (hasClientTurn) {
expect(review.clientFeedback).toEqual(expect.any(String));
expect(review.clientFeedback.length).toBeGreaterThan(0);
}
await signInAsTeacher(page);
const dashboardResponse = await page.request.get("/api/teacher/dashboard");
await expectResponseOk(dashboardResponse);
const dashboard = await dashboardResponse.json();
expect(dashboard.source).toBe("database");
expect(
dashboard.recent_sessions.some(
(session: { session_id: string; turn_count: number }) =>
session.session_id === sessionId && session.turn_count >= expectedMinTurns,
),
).toBe(true);
});
test("persists learner case worksheet and teacher worksheet review through DB @single-run", async ({
page,
}) => {
test.setTimeout(150_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "case worksheet E2E requires DB and engine-backed turn");
await signInAsLearner(page);
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
expect(
expectedMinTurns,
"case worksheet E2E requires a persisted learner/client transcript",
).toBeGreaterThanOrEqual(2);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "워크시트");
const worksheetCard = page.locator(".sr-card--worksheet");
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
await expect(worksheetCard).toContainText("축어록 기반 자동 초안");
const worksheetInputs = worksheetCard.locator(".sr-ws-input");
await expect(worksheetInputs.first()).toBeVisible();
const savedWorksheetValue = `E2E 저장 사례개념화 ${Date.now()}`;
await worksheetInputs.first().fill(savedWorksheetValue);
await worksheetCard.getByRole("button", { name: "저장" }).click();
await expect(worksheetCard).toContainText("저장된 학습자 제출본", { timeout: 15_000 });
const learnerReviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(learnerReviewResponse);
const learnerReview = (await learnerReviewResponse.json()) as SessionReviewResponse;
expect(learnerReview.caseWorksheet?.status).toBe("saved_by_learner");
const learnerWorksheetValues = (learnerReview.caseWorksheet?.sections ?? []).flatMap((section) =>
(section.items ?? []).map((item) => item.value ?? ""),
);
expect(learnerWorksheetValues).toContain(savedWorksheetValue);
await signInAsTeacher(page);
await page.goto(`/teach/session/${sessionId}/review`);
await openReviewTab(page, "워크시트");
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
await expect(worksheetCard).toContainText("학습자 저장본 읽기 전용");
await expect(worksheetCard.locator(".sr-ws-input").first()).toHaveValue(savedWorksheetValue);
// 워크시트 검수 메모·수정요청은 교수자 검토 카드(피드백 탭)에 있다.
await openReviewTab(page, "피드백");
const teacherWorksheetNote = `보호요인 보강 요청 ${Date.now()}`;
await page.getByLabel("워크시트 검수 메모").fill(teacherWorksheetNote);
await page.getByRole("button", { name: "수정요청" }).click();
await expect(page.locator(".sr-teacher-review__worksheet")).toContainText("수정요청", {
timeout: 15_000,
});
await expect(page.locator(".sr-teacher-review__worksheet")).toContainText("워크시트 검수 시각");
const teacherReviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(teacherReviewResponse);
const teacherReview = (await teacherReviewResponse.json()) as SessionReviewResponse;
expect(teacherReview.caseWorksheet?.status).toBe("saved_by_learner");
expect(teacherReview.teacherReview?.worksheetStatus).toBe("changes_requested");
expect(teacherReview.teacherReview?.worksheetNote).toBe(teacherWorksheetNote);
const teacherWorksheetValues = (teacherReview.caseWorksheet?.sections ?? []).flatMap((section) =>
(section.items ?? []).map((item) => item.value ?? ""),
);
expect(teacherWorksheetValues).toContain(savedWorksheetValue);
});
test("persists Phase 3 pre/post pilot scores through the DB-backed review UI @single-run", async ({
page,
}) => {
test.setTimeout(90_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db, "pre/post pilot score persistence requires DB");
await signInAsLearner(page);
const sessionId = await createEndedSessionWithoutTurn(page);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
const inputs = page.locator(".sr-card--prepost input");
await expect(inputs).toHaveCount(6);
const current = await inputs.evaluateAll((nodes) =>
nodes.map((node) => (node as HTMLInputElement).value),
);
const primaryValues = ["4.9", "4.7", "3.9", "4.1", "2.9", "3.8"];
const alternateValues = ["4.8", "4.6", "3.8", "4.2", "2.8", "3.7"];
const targetValues =
current.join("|") === primaryValues.join("|") ? alternateValues : primaryValues;
for (let index = 0; index < targetValues.length; index += 1) {
await inputs.nth(index).fill(targetValues[index]);
}
await expect(page.locator(".sr-prepost__actions")).toContainText("6개 변경");
await page.getByRole("button", { name: "점수 저장" }).click();
await expect(page.getByText("3/3 쌍")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
await expect(
page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다."),
).toBeVisible();
const prepostResponse = await page.request.get(
"/api/users/me/prepost-measures?pilot_id=phase3-pilot-draft",
);
await expectResponseOk(prepostResponse);
const prepost = (await prepostResponse.json()) as PrepostMeasuresResponse;
expect(prepost.source).toBe("database");
expect(prepost.durable).toBe(true);
expect(prepost.complete_measure_pairs).toBe(3);
const byKey = new Map(
prepost.measures.map((measure) => [
`${measure.measure_name}:${measure.timepoint}`,
measure.raw_score,
]),
);
expect(byKey.get("self_efficacy:pre")).toBe(Number(targetValues[0]));
expect(byKey.get("self_efficacy:post")).toBe(Number(targetValues[1]));
expect(byKey.get("skill_proficiency:pre")).toBe(Number(targetValues[2]));
expect(byKey.get("skill_proficiency:post")).toBe(Number(targetValues[3]));
expect(byKey.get("training_satisfaction:pre")).toBe(Number(targetValues[4]));
expect(byKey.get("training_satisfaction:post")).toBe(Number(targetValues[5]));
await page.reload();
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("3/3 쌍")).toBeVisible();
for (let index = 0; index < targetValues.length; index += 1) {
await expect(inputs.nth(index)).toHaveValue(targetValues[index]);
}
});
test("keeps the failed review durable while the next persona session accepts a stored turn @single-run", async ({
page,
}) => {
test.setTimeout(180_000);
await withGlobalEngineConfigLock("req-007-evaluation-failure-continuity", async () => {
await signInAsAdmin(page);
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse;
expect(currentEngine.source, "REQ-007 continuity proof requires the real DB lane").toBe(
"database",
);
expect(currentEngine.durable, "REQ-007 engine configuration must be durable").toBe(true);
const gateway = await startControlledEvaluationFailureGateway(
currentEngine.model,
currentEngine.engine_mode,
);
try {
const controlledPatch = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: gateway.url,
model: currentEngine.model,
reasoning_effort: currentEngine.reasoning_effort,
},
});
await expectResponseOk(controlledPatch);
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page);
const firstStartResponse = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
await expectResponseOk(firstStartResponse);
expect(firstStartResponse.status()).toBe(201);
const first = (await firstStartResponse.json()) as SessionStartResponse;
expect(first.degraded).toBe(false);
const firstTurnResponse = await page.request.post(
`/api/sessions/${first.session_id}/turn`,
{
data: { text: "오늘 가장 버거웠던 순간부터 이야기해도 괜찮을까요?" },
},
);
await expectResponseOk(firstTurnResponse);
const endResponse = await page.request.post(`/api/sessions/${first.session_id}/end`);
await expectResponseOk(endResponse);
expect(endResponse.status()).toBe(200);
const endedDetailResponse = await page.request.get(`/api/sessions/${first.session_id}`);
await expectResponseOk(endedDetailResponse);
const endedDetail = (await endedDetailResponse.json()) as SessionDetailResponse;
expect(endedDetail.status).toBe("ended");
expect(endedDetail.ended_at).toBeTruthy();
expect(endedDetail.turns?.length ?? 0).toBeGreaterThanOrEqual(2);
const nextStartResponse = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
await expectResponseOk(nextStartResponse);
expect(nextStartResponse.status()).toBe(201);
const next = (await nextStartResponse.json()) as SessionStartResponse;
expect(next.degraded).toBe(false);
expect(next.session_id).not.toBe(first.session_id);
expect(next.case_id).toBe(first.case_id);
expect(next.session_no).toBe(first.session_no + 1);
const nextTurnResponse = await page.request.post(
`/api/sessions/${next.session_id}/turn`,
{
data: { text: "지난 회기의 이야기를 이어서 천천히 살펴볼까요?" },
},
);
await expectResponseOk(nextTurnResponse);
const nextTurn = (await nextTurnResponse.json()) as {
client_reply?: string | null;
output_error?: string | null;
};
expect(nextTurn.client_reply).toBe("조금 더 이야기해볼게요.");
expect(nextTurn.output_error ?? null).toBeNull();
const nextDetailResponse = await page.request.get(`/api/sessions/${next.session_id}`);
await expectResponseOk(nextDetailResponse);
const nextDetail = (await nextDetailResponse.json()) as SessionDetailResponse;
expect(nextDetail.status).toBe("active");
expect(nextDetail.turns?.length ?? 0).toBeGreaterThanOrEqual(2);
expect(gateway.clientRequests()).toBeGreaterThanOrEqual(2);
await expect
.poll(() => gateway.evaluatorRequests(), {
timeout: 30_000,
message: "the ended session must reach the controlled evaluator failure",
})
.toBeGreaterThan(0);
await signInAsTeacher(page);
await expect
.poll(async () => evaluationState(page, first.session_id), {
timeout: 30_000,
intervals: [250, 500, 1_000],
message: "the evaluator HTTP 500 must become a durable error row",
})
.toMatch(/^error:.*:durable$/);
const reviewResponse = await page.request.get(`/api/sessions/${first.session_id}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
expect(review.reviewReady).toBe(false);
expect(review.degraded).toBe(true);
expect(review.supervisorState).toBe("평가 실패");
expect(review.summary ?? "").toContain("AI 평가 재시도가 필요합니다");
await page.goto(`/teach/session/${first.session_id}/review`);
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible({
timeout: 15_000,
});
const evaluatorRequestsBeforeRetry = gateway.evaluatorRequests();
const retryResponse = await page.request.post(
`/api/eval/sessions/${first.session_id}/reevaluate`,
{ data: { scope: "session_end" } },
);
expect(retryResponse.status()).toBe(503);
await expect
.poll(() => gateway.evaluatorRequests(), { timeout: 10_000 })
.toBeGreaterThan(evaluatorRequestsBeforeRetry);
await expect
.poll(async () => evaluationState(page, first.session_id), {
timeout: 10_000,
})
.toMatch(/^error:.*:durable$/);
} finally {
try {
await signInAsAdmin(page);
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: currentEngine.model,
reasoning_effort: currentEngine.reasoning_effort,
},
});
await expectResponseOk(restoreResponse);
} finally {
await gateway.close();
}
}
});
});
test("finishes session end evaluation into a durable DB-backed teacher review @single-run", async ({
page,
}) => {
test.setTimeout(240_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "session end evaluation requires DB and engine");
await signInAsLearner(page);
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
expect(
expectedMinTurns,
"session end evaluation smoke requires a completed learner/client turn",
).toBeGreaterThanOrEqual(2);
await signInAsTeacher(page);
await expect
.poll(async () => evaluationState(page, sessionId), {
timeout: 180_000,
intervals: [1_000, 2_000, 5_000, 10_000],
message: "session end background evaluation should create a durable ready row",
})
.toMatch(/^ready:[1-9]\d*$/);
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
expect(review.reviewReady).toBe(true);
expect(review.degraded).toBe(false);
expect(review.supervisorState).toBe("평가 완료");
expect(review.summary ?? "").toContain("평가 AI");
const dashboardResponse = await page.request.get("/api/teacher/dashboard");
await expectResponseOk(dashboardResponse);
const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse;
expect(dashboard.source).toBe("database");
const dashboardSession = (dashboard.recent_sessions ?? []).find(
(session) => session.session_id === sessionId,
);
expect(dashboardSession, "teacher dashboard should expose the same persisted session").toBeTruthy();
expect(dashboardSession?.turn_count ?? 0).toBeGreaterThanOrEqual(expectedMinTurns);
expect(dashboardSession?.evaluation_status).toBe("ready");
expect(dashboardSession?.review_ready).toBe(true);
expect(dashboardSession?.supervisor_state).toBe("평가 완료");
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.locator(".sr-head__stats")).toContainText("평가 완료", {
timeout: 15_000,
});
await expect(page.locator('[aria-label="리뷰 생성 상태"]')).toContainText("준비됨");
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
});
test("persists explicit teacher session reevaluation into durable DB state @single-run", async ({
page,
}) => {
test.setTimeout(180_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "teacher session reevaluation requires DB and engine");
await signInAsLearner(page);
const sessionId = await createActiveSessionWithTurn(page);
await signInAsTeacher(page);
const reevaluateResponse = await page.request.post(`/api/eval/sessions/${sessionId}/reevaluate`, {
data: { scope: "stage_transition" },
});
await expectResponseOk(reevaluateResponse);
const reevaluation = (await reevaluateResponse.json()) as {
scope?: string;
error?: string | null;
turns_evaluated?: number;
};
expect(reevaluation.scope).toBe("stage_transition");
expect(reevaluation.error ?? "").toBe("");
expect(reevaluation.turns_evaluated ?? 0).toBeGreaterThan(0);
const evaluationResponse = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`);
await expectResponseOk(evaluationResponse);
const evaluation = (await evaluationResponse.json()) as EvaluationSummaryResponse;
expect(evaluation.status).toBe("ready");
expect(evaluation.durable).toBe(true);
expect(evaluation.deep?.scope).toBe("stage_transition");
expect(evaluation.deep?.turns_evaluated).toBe(reevaluation.turns_evaluated);
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
expect(review.reviewReady).toBe(true);
expect(review.supervisorState).toBe("평가 완료");
});
test("persists explicit teacher turn reevaluation into DB-backed review state @single-run", async ({
page,
}) => {
test.setTimeout(120_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "teacher turn reevaluation requires DB and engine");
await signInAsLearner(page);
const sessionId = await createActiveSessionWithTurn(page);
await signInAsTeacher(page);
const turnReevaluationResponse = await page.request.post(`/api/eval/sessions/${sessionId}/turn`, {
data: { turn_seq: 1 },
});
await expectResponseOk(turnReevaluationResponse);
const turnReevaluation = (await turnReevaluationResponse.json()) as {
error?: string | null;
techniques?: Array<{ label_ko?: string; code?: string }>;
};
expect(turnReevaluation.error ?? "").toBe("");
const expectedLabels = (turnReevaluation.techniques ?? [])
.map((technique) => technique.label_ko || technique.code || "")
.filter((label) => label.length > 0);
test.skip(expectedLabels.length === 0, "evaluator returned no technique labels to verify in review");
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
const learnerTurn = review.turns.find((turn) => turn.speaker === "learner");
expect(learnerTurn, "review should still expose the original learner turn").toBeTruthy();
const reviewLabels = (learnerTurn?.techniques ?? []).map((technique) => technique.label);
for (const label of expectedLabels) {
expect(reviewLabels).toContain(label);
}
});
test("rejects an unreachable AI gateway before it can poison the evaluation runtime @single-run", async ({
page,
}) => {
test.setTimeout(120_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "engine configuration validation requires DB and engine");
await withGlobalEngineConfigLock("engine-config-fail-closed", async () => {
await signInAsAdmin(page);
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse;
const brokenEnginePatch = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: "http://127.0.0.1:9",
model: currentEngine.model,
reasoning_effort: currentEngine.reasoning_effort,
},
});
expect(brokenEnginePatch.status()).toBe(422);
const persistedResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(persistedResponse);
const persisted = (await persistedResponse.json()) as AdminEngineConfigResponse;
expect(persisted).toMatchObject(currentEngine);
});
});
});