Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

51
apps/web/e2e/README.md Normal file
View file

@ -0,0 +1,51 @@
# Playwright E2E
These tests exercise the app through the Vite `/api` proxy and a running local
FastAPI server. They do not replace `/auth/me`, `/personas`, sessions, admin, or
review endpoints with Playwright route fixtures.
Required local services:
```sh
# apps/api
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
# apps/web, started automatically by Playwright unless already running
npm run dev -- --host 127.0.0.1 --port 5173
```
Useful overrides:
```sh
PLAYWRIGHT_PORT=5174 npm run e2e
PLAYWRIGHT_BASE_URL=http://localhost:5173 npm run e2e
VITE_API_BASE=http://127.0.0.1:8000 npm run e2e
```
Public Google OAuth `/turn` smoke:
```sh
# 1) Verify the public API is not accidentally serving the dev runtime.
$env:E2E_PUBLIC_AUTH="1"
npx playwright test e2e/public-auth-turn.spec.ts --project=chromium-public-auth --grep "production-safe"
# 2) Open a browser, sign in with an allowed Google account, then close codegen.
npx playwright codegen https://vignette.chanpaca.net/login --save-storage=./node_modules/.tmp/public-auth.json
# 3) Reuse that authenticated storage state for the public API turn smoke.
$env:E2E_PUBLIC_AUTH="1"
$env:E2E_PUBLIC_STORAGE_STATE="./node_modules/.tmp/public-auth.json"
npx playwright test e2e/public-auth-turn.spec.ts --project=chromium-public-auth
```
Notes:
- `E2E_PUBLIC_AUTH=1` targets the public site and does not start the local Vite web server.
- `public-auth.json` contains the HttpOnly API session cookie exported by Playwright. Treat it as sensitive and keep it under `node_modules/.tmp`.
- The API session TTL is currently 8 hours, so recapture storage state when the smoke begins returning `401`.
Install browser binaries once with:
```sh
npx playwright install chromium
```

304
apps/web/e2e/admin.spec.ts Normal file
View file

@ -0,0 +1,304 @@
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import {
expectNoHorizontalOverflow,
signInAsLearner,
useRealApi,
withGlobalEngineConfigLock,
} from "./support";
type AdminHealthStatus = "ok" | "degraded" | "down";
interface AdminServiceHealth {
key: string;
name: string;
status: AdminHealthStatus;
detail: string;
metric: string;
load: number;
}
interface AdminHealthResponse {
status: AdminHealthStatus;
environment: string;
engine_mode: string;
services: AdminServiceHealth[];
}
interface AdminManagedUser {
user_id: string;
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
affiliation: string;
cohort_ids: string[];
}
interface AdminUsersResponse {
source: "database" | "server_session_registry";
durable: boolean;
users: AdminManagedUser[];
}
async function expectResponseOk(response: APIResponse | Response) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function signInAsAdmin(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "admin@twentyoz.kr",
role: "admin",
display_name: "E2E Admin",
},
});
await expectResponseOk(res);
}
function isAdminHealthResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/health");
}
function isAdminUsersResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/users");
}
function isAdminUserCreate(response: Response) {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname.endsWith("/admin/users");
}
function isAdminUserPatch(userId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "PATCH" &&
url.pathname.endsWith(`/admin/users/${userId}`)
);
};
}
function isAdminUserDelete(userId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "DELETE" &&
url.pathname.endsWith(`/admin/users/${userId}`)
);
};
}
function environmentLabel(value: string) {
if (value === "prod") return "운영";
if (value === "staging") return "스테이징";
if (value === "dev") return "개발";
return value;
}
function engineModeLabel(value: string) {
if (value === "claude_cli") return "Claude CLI 게이트웨이";
if (value === "claude_api" || value === "messages_api") return "Anthropic API";
if (value === "openai") return "OpenAI 호환";
if (value === "solar") return "Solar";
return value;
}
async function openAdminAndReadHealth(page: Page) {
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
await page.goto("/admin");
const healthResponse = await healthResponsePromise;
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as AdminHealthResponse;
expect(health.services.length).toBeGreaterThan(0);
return health;
}
async function openAdminAndReadUsers(page: Page) {
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
await page.goto("/admin");
const usersResponse = await usersResponsePromise;
await expectResponseOk(usersResponse);
return (await usersResponse.json()) as AdminUsersResponse;
}
test.describe("admin route", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("allows an admin to open the live health dashboard", async ({ page }) => {
await signInAsAdmin(page);
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
const health = await openAdminAndReadHealth(page);
const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)");
const counts = {
ok: health.services.filter((service) => service.status === "ok").length,
degraded: health.services.filter((service) => service.status === "degraded").length,
down: health.services.filter((service) => service.status === "down").length,
};
await expect(page).toHaveURL(/\/admin$/);
await expect(page.locator(".ad-status")).toContainText(environmentLabel(health.environment));
await expect(page.locator(".ad-status")).toContainText(engineModeLabel(health.engine_mode));
await expect(page.locator(".ad-kpi b")).toHaveText([
String(health.services.length),
String(counts.ok),
String(counts.degraded),
String(counts.down),
]);
await expect(serviceCards).toHaveCount(health.services.length);
for (const service of health.services) {
const card = serviceCards.filter({ hasText: service.name });
await expect(card).toBeVisible();
await expect(card).toContainText(service.detail);
expect(service.load, `${service.key} load must be normalized`).toBeGreaterThanOrEqual(0);
expect(service.load, `${service.key} load must be normalized`).toBeLessThanOrEqual(1);
if (service.key === "engine" && service.status === "ok") {
await expect(card).toContainText(/\d+ms/);
} else if (service.key === "db" && service.status === "ok") {
await expect(card).toContainText(/풀 \d+\/\d+/);
} else if (service.key === "evaluation") {
await expect(card).toContainText(/대기 \d+건/);
} else if (service.key === "kb" && service.status === "ok") {
await expect(card).toContainText(/활성 세션 \d+건/);
} else {
await expect(card).toContainText(service.metric);
}
}
const byKey = Object.fromEntries(health.services.map((service) => [service.key, service]));
if (byKey.engine?.status === "ok") {
expect(byKey.engine.metric).toMatch(/^\d+ms$/);
}
if (byKey.db?.status === "ok") {
expect(byKey.db.metric).toMatch(/^풀 \d+\/\d+$/);
}
expect(byKey.evaluation?.metric).toMatch(/^대기 \d+건$/);
if (byKey.kb?.status === "ok") {
expect(byKey.kb.metric).toMatch(/^활성 세션 \d+건$/);
}
});
});
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const email = `admin-users.${slug}@hs.ac.kr`.toLowerCase();
const displayName = `관리 대상 ${testInfo.project.name}`;
const users = await openAdminAndReadUsers(page);
expect(users.source).toBe("database");
expect(users.durable).toBe(true);
await page.getByLabel("사용자 검색").fill(`no-match-${slug}`);
await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible();
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
await page.getByLabel("사용자 검색").fill("");
await page.getByLabel("새 사용자 이메일").fill(email);
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
await page.getByLabel("새 사용자 역할").selectOption("learner");
await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`);
const createPromise = page.waitForResponse(isAdminUserCreate);
const reloadAfterCreatePromise = page.waitForResponse(isAdminUsersResponse);
await page.getByRole("button", { name: "사용자 등록" }).click();
const createResponse = await createPromise;
await expectResponseOk(createResponse);
await expectResponseOk(await reloadAfterCreatePromise);
const created = (await createResponse.json()) as AdminManagedUser;
expect(created).toMatchObject({
email,
display_name: displayName,
role: "learner",
cohort_ids: [`created-${testInfo.project.name}`],
});
await page.getByLabel("사용자 검색").fill(email);
const card = page.locator(".ad-user").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card).toContainText(displayName);
const nextName = `교수자 ${testInfo.project.name}`;
const nameInput = card.getByLabel(`${email} 표시 이름`);
const roleSelect = card.getByLabel(`${email} 역할`);
const cohortInput = card.getByLabel(`${email} 코호트`);
const saveButton = card.getByRole("button", { name: "저장" });
const nextCohort = `cohort-${testInfo.project.name}`;
await nameInput.fill(nextName);
await expect(nameInput).toHaveValue(nextName);
await page.evaluate(() => new Promise(requestAnimationFrame));
await roleSelect.selectOption("teacher");
await expect(roleSelect).toHaveValue("teacher");
await cohortInput.fill(nextCohort);
await expect(cohortInput).toHaveValue(nextCohort);
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(saveButton).toBeEnabled();
const patchPromise = page.waitForResponse(isAdminUserPatch(created.user_id));
await saveButton.click();
const patchResponse = await patchPromise;
await expectResponseOk(patchResponse);
const updated = (await patchResponse.json()) as AdminManagedUser;
expect(updated).toMatchObject({
user_id: created.user_id,
email,
display_name: nextName,
role: "teacher",
cohort_ids: [nextCohort],
});
await expect(card).toContainText(nextName);
await expect(card).toContainText("교수자");
await expect(card).toContainText(nextCohort);
const deletePromise = page.waitForResponse(isAdminUserDelete(created.user_id));
await card.getByRole("button", { name: "비활성화" }).click();
const deleteResponse = await deletePromise;
await expectResponseOk(deleteResponse);
await expect(card).toHaveCount(0);
const blockedLogin = await page.request.post("/api/auth/dev-login", {
data: {
email,
role: "teacher",
display_name: nextName,
},
});
expect(blockedLogin.status(), await blockedLogin.text()).toBe(403);
});
test("denies learner access to the admin API and UI", async ({ page }) => {
await signInAsLearner(page);
const denied = await page.request.get("/api/admin/health");
expect(denied.status(), await denied.text()).toBe(403);
const deniedUsers = await page.request.get("/api/admin/users");
expect(deniedUsers.status(), await deniedUsers.text()).toBe(403);
await page.goto("/admin");
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".ad-root")).toHaveCount(0);
});
test("does not horizontally overflow at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAsAdmin(page);
await openAdminAndReadHealth(page);
await expectNoHorizontalOverflow(page);
});
});

183
apps/web/e2e/auth.spec.ts Normal file
View file

@ -0,0 +1,183 @@
import { expect, test } from "@playwright/test";
import { useRealApi } from "./support";
const apiBase = process.env.E2E_API_BASE ?? "http://127.0.0.1:8000";
const publicBase = process.env.E2E_PUBLIC_BASE_URL ?? "https://vignette.chanpaca.net";
const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net";
function isLocalHostname(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
test.describe("auth domain policy", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("requires authentication for the persona catalog", async ({ page }) => {
const response = await page.request.get("/api/personas");
expect(response.status(), await response.text()).toBe(401);
});
test("allows only the configured school and operator email domains", async ({
page,
}, testInfo) => {
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const allowed = [
`domain-learner.${slug}@hs.ac.kr`,
`domain-admin.${slug}@twentyoz.kr`,
];
for (const email of allowed) {
const response = await page.request.post("/api/auth/dev-login", {
data: {
email: email.toLowerCase(),
role: email.endsWith("@twentyoz.kr") ? "admin" : "learner",
display_name: "Domain Policy E2E",
},
});
expect(response.ok(), await response.text()).toBeTruthy();
}
const denied = await page.request.post("/api/auth/dev-login", {
data: {
email: `domain-denied.${slug}@example.com`.toLowerCase(),
role: "learner",
display_name: "Denied Domain",
},
});
const deniedText = await denied.text();
expect(denied.status(), deniedText).toBe(403);
expect(deniedText).toContain("email domain is not allowed");
});
test("shows Google OAuth readiness without exposing a raw JSON error page", async ({
page,
}) => {
const configResponse = await page.request.get("/api/auth/config");
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
const config = (await configResponse.json()) as {
google_oauth_configured: boolean;
allowed_email_domains: string[];
redirect_uri: string;
dev_login_enabled: boolean;
};
await page.goto("/login");
const googleButtons = page.locator(".lg-obtn");
await expect(googleButtons).toHaveCount(2);
await expect(page.locator(".lg-policy b")).toContainText(config.allowed_email_domains);
const currentHost = new URL(page.url()).hostname;
const redirectHost = new URL(config.redirect_uri).hostname;
const localOAuthUnavailable =
isLocalHostname(currentHost) &&
config.dev_login_enabled &&
!isLocalHostname(redirectHost);
if (config.dev_login_enabled) {
await expect(page.locator(".lg-dev")).toBeVisible();
} else {
await expect(page.locator(".lg-dev")).toHaveCount(0);
}
if (config.google_oauth_configured && !localOAuthUnavailable) {
await expect(googleButtons.first()).toBeEnabled();
await expect(page.locator(".lg-config")).toHaveCount(0);
} else {
await expect(googleButtons.first()).toBeDisabled();
await expect(googleButtons.nth(1)).toBeDisabled();
await expect(page.locator(".lg-config")).toBeVisible();
await expect(page).toHaveURL(/\/login$/);
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
if (localOAuthUnavailable) {
await expect(page.locator(".lg-config")).toContainText("로컬 테스트 계정으로 로그인");
return;
}
await page.goto("/api/auth/login?provider=google&next=%2Flearn");
await expect(page).toHaveURL(/\/login\?oauth=not_configured$/);
await expect(page.locator(".lg-error")).toContainText("Google 로그인이 아직 연결되지 않았습니다");
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
}
});
test("logs in locally with the server dev session and redirects to learner home", async ({
page,
}) => {
await page.goto("/login");
await expect(page.locator(".lg-dev")).toBeVisible();
await Promise.all([
page.waitForURL(/\/learn(?:$|[/?#])/),
page.getByRole("button", { name: "로컬 테스트 계정으로 계속" }).click(),
]);
const me = await page.request.get("/api/auth/me");
expect(me.status(), await me.text()).toBe(200);
await expect(page.getByRole("heading", { name: "오늘의 회기를 준비합니다." })).toBeVisible();
});
test("keeps dev login closed for the public API origin", async ({ request }) => {
const publicHeaders = {
origin: "https://vignette.chanpaca.net",
"x-forwarded-host": "api-vignette.chanpaca.net",
};
const configResponse = await request.get(`${apiBase}/auth/config`, {
headers: publicHeaders,
});
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
const config = (await configResponse.json()) as { dev_login_enabled: boolean };
expect(config.dev_login_enabled).toBe(false);
const response = await request.post(`${apiBase}/auth/dev-login`, {
headers: publicHeaders,
data: {
email: "public-dev-login-probe@hs.ac.kr",
role: "learner",
display_name: "Public Probe",
},
});
expect(response.status(), await response.text()).toBe(404);
});
test("keeps the public login screen on real Google OAuth only", async ({ page, request }) => {
const configResponse = await request.get(`${publicApiBase}/auth/config`, {
headers: {
origin: publicBase,
"x-forwarded-host": "api-vignette.chanpaca.net",
},
});
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
const config = (await configResponse.json()) as {
google_oauth_configured: boolean;
allowed_email_domains: string[];
dev_login_enabled: boolean;
};
expect(config).toMatchObject({
google_oauth_configured: true,
dev_login_enabled: false,
});
expect(config.allowed_email_domains).toEqual(expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]));
await page.goto(`${publicBase}/login`, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
await expect(page.locator(".lg-obtn")).toHaveCount(2);
await expect(page.locator(".lg-dev")).toHaveCount(0);
await expect(page.getByText("로컬 테스트")).toHaveCount(0);
await expect(page.getByText("로컬 테스트 계정으로 계속")).toHaveCount(0);
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
await expect(page.locator(".lg-obtn").first()).toBeEnabled();
await Promise.all([
page.waitForURL(/accounts\.google\.com/, { timeout: 20_000 }),
page.locator(".lg-obtn").first().click(),
]);
expect(page.url()).toContain("client_id=");
expect(page.url()).toContain("redirect_uri=https%3A%2F%2Fapi-vignette.chanpaca.net%2Fauth%2Fcallback");
});
});

View file

@ -0,0 +1,177 @@
import { expect, test, type APIResponse } from "@playwright/test";
import { useRealApi, withGlobalEngineConfigLock } from "./support";
interface HealthResponse {
db: boolean;
}
interface UserProfileResponse {
user_id: string;
email: string;
display_name: string;
role: string;
cohort_ids: string[];
affiliation: string;
}
interface UserPreferencesResponse {
theme: string;
voice_preset_id: string;
voice_rate: number;
notifications: {
session_done: boolean;
safety_signal: boolean;
learner_progress: boolean;
product_news: boolean;
};
}
interface AdminUsersResponse {
source: "database" | "server_session_registry";
durable: boolean;
users: UserProfileResponse[];
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
updated_by: string | null;
updated_at: number | null;
}
async function expectResponseOk(response: APIResponse) {
expect(response.ok(), await response.text()).toBeTruthy();
}
function slugFor(testInfo: { project: { name: string }; workerIndex: number; retry: number }) {
const project = testInfo.project.name.includes("mobile") ? "mob" : "desk";
return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
}
test.describe("database-backed runtime state", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("persists user settings and admin AI configuration through DB-backed APIs @single-run", async ({
page,
}, testInfo) => {
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db, "PostgreSQL is not connected for this run");
const slug = slugFor(testInfo);
const learnerEmail = `db-persist.${slug}@hs.ac.kr`.toLowerCase();
const learnerName = `DB Persist Learner ${testInfo.project.name}`;
const nextName = `DB Persist Updated ${testInfo.project.name}`;
const affiliation = "Hanshin University";
const learnerLogin = await page.request.post("/api/auth/dev-login", {
data: {
email: learnerEmail,
role: "learner",
display_name: learnerName,
},
});
await expectResponseOk(learnerLogin);
const profilePatch = await page.request.patch("/api/users/me", {
data: {
display_name: nextName,
affiliation,
},
});
await expectResponseOk(profilePatch);
const profile = (await profilePatch.json()) as UserProfileResponse;
expect(profile).toMatchObject({
email: learnerEmail,
display_name: nextName,
affiliation,
});
const preferencesPatch = await page.request.patch("/api/users/me/preferences", {
data: {
theme: "dark",
voice_rate: 1.1,
notifications: {
session_done: true,
safety_signal: true,
learner_progress: true,
product_news: true,
},
},
});
await expectResponseOk(preferencesPatch);
const preferences = (await preferencesPatch.json()) as UserPreferencesResponse;
expect(preferences.theme).toBe("dark");
expect(preferences.voice_rate).toBeCloseTo(1.1);
expect(preferences.notifications.product_news).toBe(true);
const adminEmail = `db-persist-admin.${slug}@twentyoz.kr`.toLowerCase();
const adminLogin = await page.request.post("/api/auth/dev-login", {
data: {
email: adminEmail,
role: "admin",
display_name: `DB Persist Admin ${testInfo.project.name}`,
},
});
await expectResponseOk(adminLogin);
const usersResponse = await page.request.get("/api/admin/users");
await expectResponseOk(usersResponse);
const users = (await usersResponse.json()) as AdminUsersResponse;
expect(users.source).toBe("database");
expect(users.durable).toBe(true);
const learner = users.users.find((user) => user.email === learnerEmail);
expect(learner, `Expected ${learnerEmail} in DB-backed admin list`).toBeTruthy();
expect(learner).toMatchObject({
display_name: nextName,
affiliation,
});
await withGlobalEngineConfigLock(`db-persistence-${slug}`, async () => {
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse;
const nextModel = `db-persist-${slug}`;
try {
const enginePatch = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: nextModel,
},
});
await expectResponseOk(enginePatch);
const updatedEngine = (await enginePatch.json()) as AdminEngineConfigResponse;
expect(updatedEngine).toMatchObject({
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: nextModel,
updated_by: adminEmail,
});
expect(updatedEngine.updated_at).toBeGreaterThan(0);
const persistedEngineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(persistedEngineResponse);
const persistedEngine = (await persistedEngineResponse.json()) as AdminEngineConfigResponse;
expect(persistedEngine).toMatchObject({
model: nextModel,
updated_by: adminEmail,
});
} finally {
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: currentEngine.model,
},
});
await expectResponseOk(restoreResponse);
}
});
});
});

View file

@ -0,0 +1,212 @@
import { expect, test } from "@playwright/test";
import {
expectNoDocumentOverflow,
expectNoHorizontalOverflow,
fetchAvailablePersona,
fetchAvailablePersonas,
signInAsLearner,
useRealApi,
} from "./support";
async function signInAsLearnerEmail(
page: import("@playwright/test").Page,
email: string,
displayName = "History Learner",
) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email,
role: "learner",
display_name: displayName,
},
});
expect(res.ok(), await res.text()).toBeTruthy();
}
async function createPracticeSession(page: import("@playwright/test").Page, personaCode?: string) {
const selectedPersonaCode = personaCode ?? (await fetchAvailablePersona(page)).code;
const res = await page.request.post("/api/sessions", {
data: {
persona_code: selectedPersonaCode,
theory_mode: "humanistic",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
return (await res.json()) as { session_id: string };
}
async function expectNoLearnerInternalCopy(page: import("@playwright/test").Page) {
await expect(page.getByText(/API|GET \/|OPENAI_API_KEY|teacher\/dashboard/)).toHaveCount(0);
}
async function expectVisibleResumeLoadedSignal(page: import("@playwright/test").Page) {
const result = await page.evaluate(() => {
const candidates = Array.from(
document.querySelectorAll<HTMLElement>(
".sx-page--active .sx-mobile-context__resume, .sx-page--active .sx-mic-block__h",
),
);
return candidates.map((el) => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return {
text: el.textContent ?? "",
visible:
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0,
};
});
});
expect(
result.some(
(entry) => entry.visible && entry.text.includes("이전 회기 기록을 불러왔습니다."),
),
`Expected visible resume-loaded signal: ${JSON.stringify(result)}`,
).toBeTruthy();
}
test.describe("learner app shell and session launcher", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("redirects an unauthenticated learner route to login", async ({ page }) => {
await page.goto("/learn");
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
expect(await page.evaluate(() => window.localStorage.getItem("vignette.dev-auth"))).toBeNull();
});
test("renders API personas without legacy session rows", async ({ page }) => {
await signInAsLearnerEmail(page, `learner.catalog.${Date.now()}@hs.ac.kr`, "Catalog Learner");
await page.goto("/learn");
const personas = await fetchAvailablePersonas(page);
const launcher = page.getByRole("listbox", { name: "연습 페르소나" });
await expect(launcher).toBeVisible();
await expect(launcher.getByRole("option")).toHaveCount(personas.length);
for (const persona of personas) {
await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible();
await expect(launcher.getByRole("option", { name: new RegExp(persona.display_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })).toBeVisible();
}
await expect(page.getByText(/12회/)).toHaveCount(0);
await expect(page.getByText(/최근 연습/)).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.getByText("음성")).toBeInViewport();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expectNoLearnerInternalCopy(page);
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("routes the launcher CTA to the selected database persona", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/learn");
const persona = await fetchAvailablePersona(page, 1);
const option = page.getByRole("option", { name: new RegExp(persona.code) });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
await page.getByRole("button", { name: "새 회기 시작" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${persona.code}$`));
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
await expectNoDocumentOverflow(page);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-grid")).toBeVisible();
await expectNoLearnerInternalCopy(page);
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("shows real session history with resume, record, and retry actions", async ({
page,
}, testInfo) => {
const suffix = `${testInfo.project.name.replace(/\W+/g, "-")}-${testInfo.workerIndex}-${Date.now()}`.toLowerCase();
await signInAsLearnerEmail(page, `history.${suffix}@hs.ac.kr`);
const persona = await fetchAvailablePersona(page);
const active = await createPracticeSession(page, persona.code);
const ended = await createPracticeSession(page, persona.code);
const endResponse = await page.request.post(`/api/sessions/${ended.session_id}/end`);
expect(endResponse.ok(), await endResponse.text()).toBeTruthy();
await page.goto("/learn");
await expect(page.getByText("기존 회기")).toBeVisible();
await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible();
await expect(page.getByRole("button", { name: "기록" })).toBeVisible();
await expect(page.getByRole("button", { name: "다시 연습" })).toHaveCount(2);
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expect(page.getByRole("button", { name: "이어하기" })).toBeInViewport();
await expect(page.getByRole("button", { name: "기록" })).toBeInViewport();
await expect(page.getByRole("button", { name: "다시 연습" }).first()).toBeInViewport();
await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기");
await expect(page.locator(".lh-activity__stats")).toContainText("2");
await page.getByRole("button", { name: "이어하기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${active.session_id}$`));
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expectVisibleResumeLoadedSignal(page);
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("does not fall back to P1 for an unavailable persona code", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/learn/session/P9");
await expect(page.getByText(/P9 페르소나는 현재 연습 목록에 없습니다/)).toBeVisible();
await expect(page.getByText("페르소나 P9")).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "연습 대상 정보를 확인하고 있습니다." }),
).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("blocks direct session starts for degraded or non-database personas", async ({ page }) => {
await signInAsLearner(page);
await page.route("**/api/personas", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
code: "SEEDX",
display_name: "검증 불가 내담자",
difficulty: "easy",
theory_target: ["humanistic"],
demographics: { age_band: "20대" },
presenting_summary: "서버 카탈로그 원본이 확인되지 않은 항목",
voice_preset: null,
source: "seed_fallback",
degraded: true,
},
]),
});
});
await page.goto("/learn/session/SEEDX");
await expect(
page.getByRole("heading", { name: "이 내담자는 현재 연습에 사용할 수 없습니다." }),
).toBeVisible();
await expect(page.getByText("카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
});

View file

@ -0,0 +1,160 @@
import { expect, test } from "@playwright/test";
const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net";
interface PublicHealthResponse {
status: string;
environment: string;
db: boolean;
engine: boolean;
}
async function browserFetchJson<T>(
page: import("@playwright/test").Page,
path: string,
init: RequestInit = {},
) {
return page.evaluate(
async ({ apiBase, apiPath, requestInit }) => {
const response = await fetch(`${apiBase}${apiPath}`, {
credentials: "include",
...requestInit,
headers: {
"content-type": "application/json",
...(requestInit.headers ?? {}),
},
});
const text = await response.text();
let json: unknown = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return {
ok: response.ok,
status: response.status,
text,
json,
};
},
{
apiBase: publicApiBase,
apiPath: path,
requestInit: {
...init,
body: typeof init.body === "string" ? init.body : undefined,
},
},
) as Promise<{ ok: boolean; status: number; text: string; json: T | null }>;
}
test.describe("public Google OAuth turn smoke", () => {
test("uses production-safe public API configuration @public-auth", async ({
request,
playwright,
}) => {
const healthResponse = await request.get(`${publicApiBase}/health`);
expect(healthResponse.ok(), await healthResponse.text()).toBeTruthy();
const health = (await healthResponse.json()) as PublicHealthResponse;
expect(health.environment, JSON.stringify(health)).not.toBe("dev");
expect(health.db, JSON.stringify(health)).toBe(true);
expect(health.engine, JSON.stringify(health)).toBe(true);
const configResponse = await request.get(`${publicApiBase}/auth/config`, {
headers: {
origin: "https://vignette.chanpaca.net",
"x-forwarded-host": "api-vignette.chanpaca.net",
},
});
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
await expect(await configResponse.json()).toMatchObject({
google_oauth_configured: true,
allowed_email_domains: expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]),
dev_login_enabled: false,
});
const unauthRequest = await playwright.request.newContext();
try {
const personasResponse = await unauthRequest.get(`${publicApiBase}/personas`);
expect(personasResponse.status(), await personasResponse.text()).toBe(401);
} finally {
await unauthRequest.dispose();
}
});
test("starts a real public session and completes one /turn @public-auth", async ({ page }) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
"Set E2E_PUBLIC_STORAGE_STATE to a Playwright storageState file captured after Google login.",
);
}
await page.goto("/learn", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
await expect(page).toHaveURL(/\/learn(?:$|[/?#])/);
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeVisible({
timeout: 15_000,
});
const me = await browserFetchJson<{ email?: string; role?: string }>(page, "/auth/me");
expect(me.status, me.text).toBe(200);
expect(me.json).toMatchObject({ role: "learner" });
const personas = await browserFetchJson<
Array<{ code: string; source: string; degraded: boolean }>
>(page, "/personas");
expect(personas.status, personas.text).toBe(200);
const persona = personas.json?.find(
(item) => item.source === "database" && !item.degraded,
);
expect(persona, personas.text).toBeTruthy();
let startedSessionId: string | null = null;
const started = await browserFetchJson<{
session_id: string;
degraded: boolean;
}>(page, "/sessions", {
method: "POST",
body: JSON.stringify({
persona_code: persona!.code,
theory_mode: "humanistic",
}),
});
expect(started.status, started.text).toBe(201);
expect(started.json).toMatchObject({ degraded: false });
expect(started.json?.session_id).toEqual(expect.any(String));
startedSessionId = started.json!.session_id;
try {
const turn = await browserFetchJson<{
client_reply?: string;
stage?: string;
turn_seq?: number;
}>(page, `/sessions/${startedSessionId}/turn`, {
method: "POST",
body: JSON.stringify({
text: "처음 오신 자리라 긴장될 수 있어요. 지금 가장 이야기하고 싶은 것부터 천천히 말해도 괜찮습니다.",
}),
});
expect(turn.status, turn.text).toBe(200);
expect(turn.json?.client_reply).toEqual(expect.any(String));
expect(turn.json!.client_reply!.length).toBeGreaterThan(0);
const detail = await browserFetchJson<{
session_id: string;
turns: Array<{ speaker: string; text: string }>;
}>(page, `/sessions/${startedSessionId}`);
expect(detail.status, detail.text).toBe(200);
expect(detail.json?.turns.length).toBeGreaterThanOrEqual(2);
} finally {
if (startedSessionId) {
await browserFetchJson(page, `/sessions/${startedSessionId}/end`, {
method: "POST",
body: JSON.stringify({}),
}).catch(() => undefined);
}
}
});
});

View file

@ -0,0 +1,79 @@
import { expect, test, type APIResponse, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
async function expectResponseOk(response: APIResponse) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function signIn(
page: Page,
role: "learner" | "admin",
email: string,
displayName: string,
) {
const res = await page.request.post("/api/auth/dev-login", {
data: { email, role, display_name: displayName },
});
await expectResponseOk(res);
}
test.describe("production readiness gates", () => {
test("learner-facing data comes from server persistence, not browser or seed mocks", async ({
page,
}, testInfo) => {
const email = `readiness.${testInfo.project.name}.${testInfo.workerIndex}.${Date.now()}@hs.ac.kr`;
await signIn(page, "learner", email.toLowerCase(), "Readiness Learner");
const personaResponse = await page.request.get("/api/personas");
await expectResponseOk(personaResponse);
expect(personaResponse.headers()["x-vignette-catalog-source"]).toBe("database");
const personas = (await personaResponse.json()) as Array<{
source: string;
degraded: boolean;
}>;
expect(personas.length).toBeGreaterThan(0);
expect(personas.every((persona) => persona.source === "database" && !persona.degraded)).toBe(
true,
);
const sessionsResponse = await page.request.get("/api/sessions");
await expectResponseOk(sessionsResponse);
const sessions = (await sessionsResponse.json()) as { source: string; sessions: unknown[] };
expect(sessions).toMatchObject({ source: "database", sessions: [] });
await page.goto("/learn");
await expect(page.getByText("지금까지 12회 연습했어요")).toHaveCount(0);
await expect(page.getByText("최근 8회")).toHaveCount(0);
await expect(page.getByText("기존 회기")).toBeVisible();
await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기");
await expect(page.locator(".lh-activity__stats")).toContainText("0");
await expect(page.getByText("저장된 기존 회기가 없습니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expectNoHorizontalOverflow(page);
});
test("admin-owned runtime controls are durable database state", async ({ page }) => {
await signIn(page, "admin", "readiness-admin@twentyoz.kr", "Readiness Admin");
const usersResponse = await page.request.get("/api/admin/users");
await expectResponseOk(usersResponse);
const users = (await usersResponse.json()) as { source: string; durable: boolean };
expect(users).toMatchObject({ source: "database", durable: true });
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const engine = (await engineResponse.json()) as {
durable: boolean;
source: string;
engine_mode: string;
engine_url: string;
model: string;
};
expect(engine).toMatchObject({ durable: true, source: "database" });
expect(["claude_cli", "claude_api", "openai", "solar"]).toContain(engine.engine_mode);
expect(engine.engine_url).toMatch(/^https?:\/\//);
expect(engine.model.trim().length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,313 @@
import { expect, test, type Page } from "@playwright/test";
import {
expectNoDocumentOverflow,
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
} from "./support";
async function expectSessionPageHeightToMatchViewport(page: Page) {
const metrics = await page.evaluate(() => {
const sessionPage = document.querySelector<HTMLElement>(".sx-page--active");
const topbar = document.querySelector<HTMLElement>(".vg-topbar");
if (!sessionPage) {
return null;
}
const pageHeight = sessionPage.getBoundingClientRect().height;
const expectedHeight = window.innerHeight;
return {
pageHeight: Math.round(pageHeight),
expectedHeight: Math.round(expectedHeight),
delta: Math.abs(pageHeight - expectedHeight),
hasTopbar: Boolean(topbar),
};
});
expect(metrics, "Expected active session page to be present").not.toBeNull();
expect(metrics!.hasTopbar, "Active session should hide the global topbar").toBe(false);
expect(
metrics!.delta,
`Expected .sx-page height ${metrics!.pageHeight}px to match viewport ${metrics!.expectedHeight}px`,
).toBeLessThanOrEqual(1);
}
async function expectNoSessionInternalCopy(page: Page) {
await expect(page.getByText(/API|GET \/|OPENAI_API_KEY|API와 엔진/)).toHaveCount(0);
}
async function expectNoLocalStageDemoControl(page: Page) {
await expect(page.getByRole("button", { name: /다음 단계로/ })).toHaveCount(0);
await expect(page.locator(".sx-track__advance")).toHaveCount(0);
}
async function expectMobileContextIfNarrow(page: Page) {
const isNarrow = await page.evaluate(() =>
window.matchMedia("(max-width: 1180px)").matches,
);
if (!isNarrow) {
return;
}
const isPhoneLayout = await page.evaluate(() =>
window.matchMedia("(max-width: 880px)").matches,
);
if (isPhoneLayout) {
await expect(page.locator(".sx-page--active .sx-col-left")).toBeHidden();
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
} else {
const feedbackSurface = page.locator(".sx-page--active .sx-col-right");
await expect(feedbackSurface).toBeVisible();
await expect(feedbackSurface).toBeInViewport();
}
const mobileContext = page.getByLabel("현재 회기 요약");
await expect(mobileContext).toBeVisible();
await expect(mobileContext).toContainText("조용히 표시");
await expect(mobileContext).toContainText("내담자");
await expect(mobileContext).toContainText("마이크");
}
async function expectSessionControlsInsideViewport(page: Page) {
const selectors = [
".sx-grid",
".sx-stage",
".sx-transcript",
".sx-transcript__scroll",
".sx-compose",
".sx-controlbar",
];
const result = await page.evaluate((items) => {
const viewport = { width: window.innerWidth, height: window.innerHeight };
const checks = items.map((selector) => {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return { selector, ok: false, reason: "missing" };
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const ok =
visible &&
rect.top >= -1 &&
rect.left >= -1 &&
rect.right <= viewport.width + 1 &&
rect.bottom <= viewport.height + 1;
return {
selector,
ok,
reason: visible ? "out-of-viewport" : "not-visible",
rect: {
top: Math.round(rect.top),
left: Math.round(rect.left),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
};
});
return { viewport, checks };
}, selectors);
const failures = result.checks.filter((check) => !check.ok);
expect(
failures,
`Viewport ${result.viewport.width}x${result.viewport.height} clipped session controls: ${JSON.stringify(failures)}`,
).toEqual([]);
}
async function expectActiveSessionUsableLayout(page: Page) {
const result = await page.evaluate(() => {
const grid = document.querySelector<HTMLElement>(".sx-page--active .sx-grid");
const center = document.querySelector<HTMLElement>(".sx-page--active .sx-col-center");
const stage = document.querySelector<HTMLElement>(".sx-page--active .sx-stage");
const transcript = document.querySelector<HTMLElement>(".sx-page--active .sx-transcript");
const scroll = document.querySelector<HTMLElement>(".sx-page--active .sx-transcript__scroll");
const compose = document.querySelector<HTMLElement>(".sx-page--active .sx-compose");
const status = document.querySelector<HTMLElement>(".sx-page--active .sx-stage__status");
const timer = document.querySelector<HTMLElement>(".sx-page--active .sx-stage__timer");
if (!grid || !center || !stage || !transcript || !scroll || !compose || !status || !timer) {
return { ok: false, reason: "missing" };
}
const gridRect = grid.getBoundingClientRect();
const centerRect = center.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect();
const transcriptRect = transcript.getBoundingClientRect();
const composeRect = compose.getBoundingClientRect();
const stageOverflow = stage.scrollHeight - stage.clientHeight;
const transcriptOverflow = transcript.scrollHeight - transcript.clientHeight;
const phone = window.matchMedia("(max-width: 880px)").matches;
return {
ok: true,
phone,
gridWidth: Math.round(gridRect.width),
centerWidth: Math.round(centerRect.width),
scrollHeight: Math.round(scroll.getBoundingClientRect().height),
stageOverflow,
transcriptOverflow,
stageBottom: Math.round(stageRect.bottom),
transcriptTop: Math.round(transcriptRect.top),
transcriptBottom: Math.round(transcriptRect.bottom),
composeTop: Math.round(composeRect.top),
statusText: status.textContent ?? "",
timerText: timer.textContent ?? "",
};
});
expect(result.ok, `Expected active session layout elements: ${JSON.stringify(result)}`).toBeTruthy();
if ("phone" in result && result.phone) {
expect(
Math.abs(result.gridWidth - result.centerWidth),
`Phone center column should use full grid width: ${JSON.stringify(result)}`,
).toBeLessThanOrEqual(2);
}
expect(result.scrollHeight, `Transcript viewport too small: ${JSON.stringify(result)}`).toBeGreaterThanOrEqual(110);
expect(result.stageOverflow, `Stage content clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4);
expect(result.transcriptOverflow, `Transcript chrome clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4);
expect(result.stageBottom, `Stage overlaps transcript: ${JSON.stringify(result)}`).toBeLessThanOrEqual(result.transcriptTop);
expect(result.composeTop, `Compose overlaps transcript bounds: ${JSON.stringify(result)}`).toBeLessThan(result.transcriptBottom);
expect(result.statusText, `Missing visible running status: ${JSON.stringify(result)}`).toContain("회기");
expect(result.timerText, `Missing visible timer: ${JSON.stringify(result)}`).toMatch(/\d/);
}
test.describe("learner session full-screen layout", () => {
test("keeps the prestart and active session routes inside the viewport", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-grid")).toBeVisible();
await expect(page.locator(".vg-topbar")).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.locator(".vg-shell__body")).toHaveClass(/(^|\s)vg-shell__body--bare(\s|$)/);
await expectNoSessionInternalCopy(page);
await expectNoLocalStageDemoControl(page);
await expectNoDocumentOverflow(page);
await expectSessionPageHeightToMatchViewport(page);
await expectMobileContextIfNarrow(page);
await expectSessionControlsInsideViewport(page);
});
test("keeps critical session controls visible across dense viewport sizes", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const viewports = [
{ width: 1366, height: 768 },
{ width: 1366, height: 720 },
{ width: 1024, height: 768 },
{ width: 1024, height: 640 },
{ width: 820, height: 1180 },
{ width: 390, height: 844 },
{ width: 375, height: 667 },
{ width: 320, height: 568 },
];
await page.setViewportSize(viewports[0]);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
await expectNoLocalStageDemoControl(page);
await expectSessionControlsInsideViewport(page);
await expectSessionPageHeightToMatchViewport(page);
await expectActiveSessionUsableLayout(page);
if (viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-mobile-context")).toBeVisible();
}
if (viewport.width > 880 && viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeVisible();
await expect(page.locator(".sx-page--active .sx-col-right")).toBeInViewport();
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
}
if (viewport.width <= 880) {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
}
}
});
test("does not leave an unsaved local transcript when a text turn is rejected", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "engine unavailable: e2e rejection" }),
});
});
const learnerText = "오늘은 너무 힘들었어요";
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.getByRole("alert")).toContainText("AI 엔진이 응답하지 않습니다");
await expect(input).toHaveValue(learnerText);
await expect(page.locator(".sx-utt")).toHaveCount(0);
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toHaveCount(0);
await expect(page.getByText("내담자 응답 없음")).toHaveCount(0);
});
test("removes pending transcript when an accepted stream later errors", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: token",
'data: {"text":"부분 응답"}',
"",
"event: error",
'data: {"detail":"engine unavailable: e2e stream error"}',
"",
].join("\n"),
});
});
const learnerText = "스트림 중간에 실패하면 남기지 말아 주세요";
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.getByRole("alert")).toContainText("AI 엔진이 응답하지 않습니다");
await expect(input).toHaveValue(learnerText);
await expect(page.locator(".sx-utt")).toHaveCount(0);
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toHaveCount(0);
await expect(page.getByText("부분 응답")).toHaveCount(0);
});
});

View file

@ -0,0 +1,95 @@
import { expect, test, type Page } from "@playwright/test";
import { fetchAvailablePersona, signInAsLearner, signInAsTeacher } from "./support";
interface SessionStartResponse {
session_id: string;
degraded: boolean;
}
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
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 };
}
test.describe("session persistence", () => {
test("persists learner turns into DB-backed review and teacher dashboard @single-run", async ({ page }) => {
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);
});
});

View file

@ -0,0 +1,78 @@
import { expect, test, type Page, type Response } from "@playwright/test";
import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support";
interface SessionStartResponse {
session_id: string;
}
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
function isReviewResponse(sessionId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "GET" &&
url.pathname.endsWith(`/sessions/${sessionId}/review`)
);
};
}
async function createEndedSession(page: Page) {
const persona = await fetchAvailablePersona(page, 1);
const start = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(start);
const session = (await start.json()) as SessionStartResponse;
const ended = await page.request.post(`/api/sessions/${session.session_id}/end`);
await expectResponseOk(ended);
return session.session_id;
}
test.describe("session review", () => {
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
await signInAsLearner(page);
const sessionId = await createEndedSession(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId));
await page.goto(`/learn/session/${sessionId}/review`);
const reviewResponse = await reviewResponsePromise;
await expectResponseOk(reviewResponse);
const review = await reviewResponse.json();
expect(review.session_id).toBe(sessionId);
expect(review.reviewReady).toBe(false);
expect(review.turns).toHaveLength(0);
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`));
await expect(page.getByText("축어록 없음")).toBeVisible();
await expect(page.getByText("감정 타임라인 대기")).toBeVisible();
await expect(page.getByText("개선점 대기")).toBeVisible();
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled();
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled();
await expect(page.getByText("32분 14초")).toHaveCount(0);
await expect(page.getByText("시연")).toHaveCount(0);
const filterMetrics = await page.locator(".sr-chip-toggle").evaluateAll((buttons) =>
buttons.map((button) => {
const rect = button.getBoundingClientRect();
const style = window.getComputedStyle(button);
return {
height: Math.ceil(rect.height),
whiteSpace: style.whiteSpace,
};
}),
);
expect(filterMetrics.every((item) => item.height <= 34 && item.whiteSpace === "nowrap")).toBe(
true,
);
await expectNoHorizontalOverflow(page);
});
});

View file

@ -0,0 +1,428 @@
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import { expectNoHorizontalOverflow, useRealApi, withGlobalEngineConfigLock } from "./support";
type Role = "learner" | "admin";
interface UserProfileResponse {
user_id: string;
email: string;
display_name: string;
role: string;
cohort_ids: string[];
affiliation: string;
}
interface UserPreferencesResponse {
theme: string;
voice_preset_id: string;
voice_rate: number;
notifications: {
session_done: boolean;
safety_signal: boolean;
learner_progress: boolean;
product_news: boolean;
};
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
updated_by: string | null;
updated_at: number | null;
}
async function expectResponseOk(response: APIResponse | Response) {
if (!response.ok()) {
const body = await response.text();
const method =
typeof (response as Response).request === "function"
? (response as Response).request().method()
: "API";
expect(
response.ok(),
`Expected ${method} ${response.url()} to be OK, got ${response.status()}: ${body}`,
).toBeTruthy();
}
}
function isApiResponse(method: string, pathnameSuffix: string) {
return (response: Response) => {
const url = new URL(response.url());
return response.request().method() === method && url.pathname.endsWith(pathnameSuffix);
};
}
function isApiRequest(method: string, pathnameSuffix: string) {
return (request: { method: () => string; url: () => string }) => {
const url = new URL(request.url());
return request.method() === method && url.pathname.endsWith(pathnameSuffix);
};
}
async function runAndWaitForApiResponse(
page: Page,
method: string,
pathnameSuffix: string,
action: () => Promise<void>,
responsePredicate: (response: Response) => boolean = () => true,
) {
const responsePromise = page.waitForResponse(
(response) => isApiResponse(method, pathnameSuffix)(response) && responsePredicate(response),
{
timeout: 10_000,
},
);
const failedRequestPromise = page
.waitForEvent("requestfailed", {
predicate: isApiRequest(method, pathnameSuffix),
timeout: 10_000,
})
.then((request) => {
throw new Error(
`${method} ${pathnameSuffix} failed before a response: ${
request.failure()?.errorText ?? "unknown network error"
}`,
);
});
await action();
return Promise.race([responsePromise, failedRequestPromise]);
}
async function waitForReactInputCommit(page: Page) {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
}),
);
}
function hasEngineConfigRequestBody(engineMode: string, model: string) {
return (response: Response) => {
try {
const body = response.request().postDataJSON() as { engine_mode?: string; model?: string };
return body.engine_mode === engineMode && body.model === model;
} catch {
return false;
}
};
}
function slugFor(testInfo: TestInfo) {
let hash = 0;
for (const char of testInfo.title) {
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
}
const project = testInfo.project.name.includes("mobile") ? "mob" : "desk";
return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${hash.toString(36)}`;
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((next) => {
resolve = next;
});
return { promise, resolve };
}
function testEmail(role: Role, testInfo: TestInfo) {
const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr";
return `settings.${role}.${slugFor(testInfo)}@${domain}`;
}
async function signInAs(
page: Page,
role: Role,
testInfo: TestInfo,
displayName: string,
email = testEmail(role, testInfo),
) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email,
role,
display_name: displayName,
},
});
await expectResponseOk(res);
return email;
}
async function openSettings(page: Page, options: { admin?: boolean } = {}) {
const profilePromise = page.waitForResponse(isApiResponse("GET", "/users/me"));
const preferencesPromise = page.waitForResponse(
isApiResponse("GET", "/users/me/preferences"),
);
const voicePresetsPromise = page.waitForResponse(
isApiResponse("GET", "/users/me/voice-presets"),
);
const engineConfigPromise = options.admin
? page.waitForResponse(isApiResponse("GET", "/admin/engine-config"))
: null;
await page.goto("/settings");
const [profileResponse, preferencesResponse, voicePresetsResponse, engineConfigResponse] =
await Promise.all([
profilePromise,
preferencesPromise,
voicePresetsPromise,
engineConfigPromise,
]);
await expectResponseOk(profileResponse);
await expectResponseOk(preferencesResponse);
await expectResponseOk(voicePresetsResponse);
if (engineConfigResponse) await expectResponseOk(engineConfigResponse);
const profile = (await profileResponse.json()) as UserProfileResponse;
const preferences = (await preferencesResponse.json()) as UserPreferencesResponse;
const engineConfig = engineConfigResponse
? ((await engineConfigResponse.json()) as AdminEngineConfigResponse)
: null;
await expect(page).toHaveURL(/\/settings$/);
await expect(page.locator(".vg-set")).toBeVisible();
return { profile, preferences, engineConfig };
}
test.describe("settings page", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("learner opens settings with server-backed account details", async ({ page }, testInfo) => {
await page.addInitScript(() => {
window.localStorage.setItem(
"vignette.dev-auth",
JSON.stringify({
email: "browser-local@hs.ac.kr",
name: "Browser Local Learner",
role: "learner",
}),
);
});
const displayName = `Settings Learner ${testInfo.project.name}`;
const email = await signInAs(page, "learner", testInfo, displayName);
const { profile } = await openSettings(page);
expect(profile).toMatchObject({
email,
display_name: displayName,
role: "learner",
});
const account = page.locator("#set-account");
await expect(account.locator(".vg-set__profile-meta .n")).toHaveText(displayName);
await expect(account.locator(".vg-set__profile-meta .e")).toHaveText(email);
await expect(account.locator("input").nth(0)).toHaveValue(displayName);
await expect(account.locator("input").nth(1)).toHaveValue(email);
await expect(page.getByText("Browser Local Learner")).toHaveCount(0);
await expect(page.getByText("browser-local@hs.ac.kr")).toHaveCount(0);
});
test("does not expose fallback settings before server state arrives", async ({
page,
}, testInfo) => {
const displayName = `Settings Loading Admin ${testInfo.project.name}`;
await signInAs(page, "admin", testInfo, displayName);
const preferencesGate = deferred();
const preferencesSeen = deferred();
const voicesGate = deferred();
const voicesSeen = deferred();
const engineGate = deferred();
const engineSeen = deferred();
await page.route("**/api/users/me/preferences", async (route) => {
preferencesSeen.resolve();
await preferencesGate.promise;
await route.continue();
});
await page.route("**/api/users/me/voice-presets", async (route) => {
voicesSeen.resolve();
await voicesGate.promise;
await route.continue();
});
await page.route("**/api/admin/engine-config", async (route) => {
engineSeen.resolve();
await engineGate.promise;
await route.continue();
});
await page.goto("/settings");
await Promise.all([preferencesSeen.promise, voicesSeen.promise, engineSeen.promise]);
await expect(page.getByTestId("settings-preferences-loading")).toBeVisible();
await expect(page.getByTestId("settings-voice-loading")).toBeVisible();
await expect(page.getByTestId("settings-notify-loading")).toBeVisible();
await expect(page.getByTestId("settings-engine-loading")).toBeVisible();
await expect(page.getByText("soft-young-fem")).toHaveCount(0);
await expect(page.getByRole("radio", { name: "Claude CLI 게이트웨이" })).toHaveCount(0);
await expect(page.getByLabel("말하기 속도")).toHaveCount(0);
await expect(page.getByRole("button", { name: "음성 저장" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "알림 저장" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "운영 설정 저장" })).toHaveCount(0);
preferencesGate.resolve();
voicesGate.resolve();
engineGate.resolve();
await expect(page.getByTestId("settings-preferences-loading")).toHaveCount(0);
await expect(page.getByRole("button", { name: "음성 저장" })).toBeVisible();
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible();
await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeVisible();
});
test("learner saves display name and preferences", async ({ page }, testInfo) => {
const displayName = `Settings Save Learner ${testInfo.project.name}`;
await signInAs(page, "learner", testInfo, displayName);
await openSettings(page);
const account = page.locator("#set-account");
const displayNameInput = account.locator("input").nth(0);
const nextName = `Updated Learner ${testInfo.project.name}`;
await expect(displayNameInput).toHaveValue(displayName);
const profilePatchResponse = await runAndWaitForApiResponse(
page,
"PATCH",
"/users/me",
async () => {
await displayNameInput.fill(nextName);
await expect(displayNameInput).toHaveValue(nextName);
await waitForReactInputCommit(page);
await account.locator(".vg-set__foot .vg-btn").click();
},
);
await expectResponseOk(profilePatchResponse);
const profile = (await profilePatchResponse.json()) as UserProfileResponse;
expect(profile.display_name).toBe(nextName);
const persistedProfileResponse = await page.request.get("/api/users/me");
await expectResponseOk(persistedProfileResponse);
const persistedProfile = (await persistedProfileResponse.json()) as UserProfileResponse;
expect(persistedProfile.display_name).toBe(nextName);
await expect(account.locator(".vg-set__profile-meta .n")).toHaveText(nextName);
await expect(displayNameInput).toHaveValue(nextName);
const notify = page.locator("#set-notify");
const productNews = notify.getByRole("switch").last();
const currentProductNews = (await productNews.getAttribute("aria-checked")) === "true";
const nextProductNews = !currentProductNews;
await productNews.click();
await expect(productNews).toHaveAttribute("aria-checked", String(nextProductNews));
const preferencesPatchResponse = await runAndWaitForApiResponse(
page,
"PATCH",
"/users/me/preferences",
async () => {
const saveButton = notify.locator(".vg-set__foot .vg-btn");
await saveButton.scrollIntoViewIfNeeded();
await saveButton.focus();
await page.keyboard.press("Enter");
},
);
await expectResponseOk(preferencesPatchResponse);
const preferences = (await preferencesPatchResponse.json()) as UserPreferencesResponse;
expect(preferences.notifications.product_news).toBe(nextProductNews);
});
test("admin sees and updates the AI engine settings panel @single-run", async ({ page }, testInfo) => {
const displayName = `Settings Admin ${testInfo.project.name}`;
const email = await signInAs(page, "admin", testInfo, displayName);
await withGlobalEngineConfigLock(`settings-${slugFor(testInfo)}`, async () => {
const { engineConfig } = await openSettings(page, { admin: true });
expect(engineConfig).not.toBeNull();
const originalEngineConfig = engineConfig!;
const engine = page.locator("#set-engine");
await expect(engine).toBeVisible();
await expect(engine.locator("input").nth(0)).toHaveValue(originalEngineConfig.engine_url);
await expect(engine.locator("input").nth(1)).toHaveValue(originalEngineConfig.model);
const nextMode =
originalEngineConfig.engine_mode === "claude_api" ? "claude_cli" : "claude_api";
const nextModel = `e2e-model-${slugFor(testInfo)}`;
try {
const nextModeButton = engine.locator(`[data-engine-mode="${nextMode}"]`);
await nextModeButton.click();
await expect(nextModeButton).toHaveAttribute("aria-checked", "true");
await engine.locator("input").nth(1).fill(nextModel);
await expect(engine.locator("input").nth(1)).toHaveValue(nextModel);
await waitForReactInputCommit(page);
const enginePatchResponse = await runAndWaitForApiResponse(
page,
"PATCH",
"/admin/engine-config",
async () => {
await engine.locator(".vg-set__foot .vg-btn").click();
},
hasEngineConfigRequestBody(nextMode, nextModel),
);
await expectResponseOk(enginePatchResponse);
const updatedEngine = (await enginePatchResponse.json()) as AdminEngineConfigResponse;
expect(updatedEngine).toMatchObject({
engine_mode: nextMode,
model: nextModel,
updated_by: email,
});
await expect(engine.locator("input").nth(1)).toHaveValue(nextModel);
const healthResponse = await page.request.get("/api/admin/health");
await expectResponseOk(healthResponse);
const health = await healthResponse.json();
expect(health.engine_mode).toBe(nextMode);
} finally {
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: originalEngineConfig.engine_mode,
engine_url: originalEngineConfig.engine_url,
model: originalEngineConfig.model,
},
});
await expectResponseOk(restoreResponse);
}
});
});
test("admin engine settings panel stays readable at a mobile viewport", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
const displayName = `Settings Mobile Admin ${testInfo.project.name}`;
await signInAs(page, "admin", testInfo, displayName);
const { engineConfig } = await openSettings(page, { admin: true });
expect(engineConfig).not.toBeNull();
const engine = page.locator("#set-engine");
await expect(engine).toBeVisible();
await expect(engine.locator("[data-engine-mode]")).toHaveCount(4);
await expect(engine.locator("input").nth(0)).toBeVisible();
await expect(engine.locator("input").nth(0)).toHaveValue(engineConfig!.engine_url);
await expect(engine.locator("input").nth(1)).toBeVisible();
await expect(engine.locator("input").nth(1)).toHaveValue(engineConfig!.model);
await expectNoHorizontalOverflow(page);
});
test("does not horizontally overflow at a mobile viewport", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAs(page, "admin", testInfo, "E2E Admin", "admin@twentyoz.kr");
await openSettings(page, { admin: true });
await expectNoHorizontalOverflow(page);
});
});

172
apps/web/e2e/support.ts Normal file
View file

@ -0,0 +1,172 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
export interface E2EPersona {
code: string;
display_name: string;
source: string;
degraded: boolean;
}
export async function useRealApi(_page: Page) {
// Intentionally empty. E2E should exercise the local API through the Vite
// proxy instead of replacing app data with browser-side route fixtures.
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
export async function withGlobalEngineConfigLock<T>(
label: string,
action: () => Promise<T>,
): Promise<T> {
const dir = path.join(process.cwd(), "node_modules", ".tmp");
const lockPath = path.join(dir, "engine-config.lock");
const startedAt = Date.now();
await fs.mkdir(dir, { recursive: true });
while (true) {
try {
const handle = await fs.open(lockPath, "wx");
try {
await handle.writeFile(`${process.pid} ${label} ${new Date().toISOString()}`);
return await action();
} finally {
await handle.close().catch(() => undefined);
await fs.unlink(lockPath).catch(() => undefined);
}
} catch (err) {
const code =
typeof err === "object" && err !== null && "code" in err
? String((err as { code?: unknown }).code)
: "";
if (code !== "EEXIST") throw err;
const stat = await fs.stat(lockPath).catch(() => null);
if (stat && Date.now() - stat.mtimeMs > 60_000) {
await fs.unlink(lockPath).catch(() => undefined);
continue;
}
if (Date.now() - startedAt > 30_000) {
throw new Error(`Timed out waiting for engine config lock: ${label}`);
}
await sleep(100);
}
}
}
export async function signInAsLearner(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "learner@hs.ac.kr",
role: "learner",
display_name: "E2E Learner",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
}
export async function signInAsTeacher(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "teacher@hs.ac.kr",
role: "teacher",
display_name: "E2E Teacher",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
}
export async function fetchAvailablePersonas(page: Page): Promise<E2EPersona[]> {
const res = await page.request.get("/api/personas");
expect(res.ok(), await res.text()).toBeTruthy();
const personas = (await res.json()) as E2EPersona[];
const usable = personas.filter((persona) => persona.source === "database" && !persona.degraded);
expect(usable.length, `Expected at least one database persona: ${JSON.stringify(personas)}`).toBeGreaterThan(0);
return usable;
}
export async function fetchAvailablePersona(page: Page, index = 0): Promise<E2EPersona> {
const personas = await fetchAvailablePersonas(page);
return personas[index] ?? personas[0];
}
export async function expectNoHorizontalOverflow(page: Page) {
await expect
.poll(async () => {
try {
return await page.evaluate(() => {
const doc = document.documentElement;
return Math.ceil(doc.scrollWidth - doc.clientWidth);
});
} catch (err) {
if (isNavigationRace(err)) return Number.MAX_SAFE_INTEGER;
throw err;
}
})
.toBeLessThanOrEqual(1);
const overflow = await page.evaluate(() => {
const doc = document.documentElement;
const viewportWidth = doc.clientWidth;
const delta = Math.ceil(doc.scrollWidth - viewportWidth);
const offenders = Array.from(document.querySelectorAll<HTMLElement>("body *"))
.map((el) => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return {
tag: el.tagName.toLowerCase(),
className: String(el.className || ""),
text: (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80),
left: Math.floor(rect.left),
right: Math.ceil(rect.right),
width: Math.ceil(rect.width),
visible:
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0,
};
})
.filter((item) => item.visible && (item.left < -1 || item.right > viewportWidth + 1))
.slice(0, 8);
return { delta, viewportWidth, offenders };
});
expect(
overflow.delta,
`Horizontal overflow ${overflow.delta}px at ${overflow.viewportWidth}px viewport. Offenders: ${JSON.stringify(
overflow.offenders,
)}`,
).toBeLessThanOrEqual(1);
}
export async function expectNoDocumentOverflow(page: Page) {
await expect
.poll(async () => {
try {
return await page.evaluate(() => {
const doc = document.documentElement;
return {
x: Math.ceil(doc.scrollWidth - doc.clientWidth),
y: Math.ceil(doc.scrollHeight - doc.clientHeight),
};
});
} catch (err) {
if (isNavigationRace(err)) return { x: Number.MAX_SAFE_INTEGER, y: Number.MAX_SAFE_INTEGER };
throw err;
}
})
.toEqual({ x: 0, y: 0 });
}
function isNavigationRace(err: unknown) {
return err instanceof Error && /Execution context was destroyed|most likely because of a navigation/i.test(err.message);
}

View file

@ -0,0 +1,111 @@
import { expect, test, type Page, type Response } from "@playwright/test";
import {
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
signInAsTeacher,
} from "./support";
interface SessionStartResponse {
session_id: string;
}
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
function isTeacherDashboardResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/teacher/dashboard");
}
async function createEndedLearnerSession(page: Page) {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page);
const start = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(start);
const session = (await start.json()) as SessionStartResponse;
const ended = await page.request.post(`/api/sessions/${session.session_id}/end`);
await expectResponseOk(ended);
return session.session_id;
}
test.describe("teacher console", () => {
test("renders real server sessions from server-owned rows", async ({ page }) => {
const sessionId = await createEndedLearnerSession(page);
await signInAsTeacher(page);
const dashboardResponsePromise = page.waitForResponse(isTeacherDashboardResponse);
await page.goto("/teach");
const dashboardResponse = await dashboardResponsePromise;
await expectResponseOk(dashboardResponse);
const dashboard = await dashboardResponse.json();
expect(dashboard.recent_sessions.some((session: { session_id: string }) => session.session_id === sessionId)).toBe(
true,
);
await expect(page.locator("code").filter({ hasText: sessionId }).first()).toBeVisible();
await expect(page.getByRole("heading", { name: /\d+건의 리뷰가 대기 중입니다\./ })).toBeVisible();
await expect(page.getByText("3명에게 개입")).toHaveCount(0);
await expect(page.getByText("김상담")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("keeps long teacher lists in bounded panels", async ({ page }) => {
await createEndedLearnerSession(page);
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator(".pf-list")).toBeVisible();
await expect(page.locator(".pf-tablewrap")).toBeVisible();
const metrics = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".pf-list");
const table = document.querySelector<HTMLElement>(".pf-tablewrap");
const header = document.querySelector<HTMLElement>(".pf-table th");
if (!list || !table || !header) {
throw new Error("teacher list panels were not rendered");
}
const listStyle = window.getComputedStyle(list);
const tableStyle = window.getComputedStyle(table);
const headerStyle = window.getComputedStyle(header);
const doc = document.documentElement;
return {
docHeight: doc.scrollHeight,
viewportHeight: doc.clientHeight,
listMaxHeight: listStyle.maxHeight,
listOverflowY: listStyle.overflowY,
tableMaxHeight: tableStyle.maxHeight,
tableOverflowY: tableStyle.overflowY,
headerPosition: headerStyle.position,
};
});
expect(metrics.listMaxHeight).not.toBe("none");
expect(metrics.tableMaxHeight).not.toBe("none");
expect(["auto", "scroll"]).toContain(metrics.listOverflowY);
expect(["auto", "scroll"]).toContain(metrics.tableOverflowY);
expect(metrics.headerPosition).toBe("sticky");
expect(metrics.docHeight - metrics.viewportHeight).toBeLessThanOrEqual(2200);
await expectNoHorizontalOverflow(page);
});
test("denies learner access to the teacher dashboard API and UI", async ({ page }) => {
await signInAsLearner(page);
const denied = await page.request.get("/api/teacher/dashboard");
expect(denied.status(), await denied.text()).toBe(403);
await page.goto("/teach");
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".pf-root")).toHaveCount(0);
});
});

View file

@ -0,0 +1,360 @@
import { expect, test, type Page } from "@playwright/test";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import http, { type IncomingMessage, type ServerResponse } from "node:http";
import net from "node:net";
interface TestServer {
url: string;
requests: () => string[];
close: () => Promise<void>;
}
interface SpawnedApi {
baseURL: string;
logs: () => string;
stop: () => Promise<void>;
}
interface VoiceProbe {
code: number;
messages: string[];
binaryChunks: number;
}
// This fixture intentionally starts a DB-offline API with ALLOW_SEED_PERSONA_FALLBACK=true
// so the voice provider cascade can be exercised without a Postgres dependency.
const SEEDED_VOICE_PERSONA_CODE = "P1";
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function startHttpServer(
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
): Promise<TestServer> {
const port = await freePort();
const requests: string[] = [];
const server = http.createServer((req, res) => {
requests.push(`${req.method ?? "?"} ${req.url ?? "?"}`);
void Promise.resolve(handler(req, res)).catch((err) => {
res.writeHead(500, { "content-type": "text/plain" });
res.end(String(err));
});
});
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
return {
url: `http://127.0.0.1:${port}`,
requests: () => requests,
close: () => new Promise((resolve) => server.close(() => resolve())),
};
}
async function startFakeOpenAI(): Promise<TestServer> {
return startHttpServer(async (req, res) => {
await readBody(req);
if (req.method === "POST" && req.url === "/v1/audio/transcriptions") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ text: "요즘 잠을 잘 못 자요.", language: "ko", duration: 1.2 }));
return;
}
if (req.method === "POST" && req.url === "/v1/audio/speech") {
res.writeHead(200, { "content-type": "audio/mpeg" });
res.end(Buffer.alloc(8192, 128));
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
}
async function startFakeEngine(): Promise<TestServer> {
return startHttpServer(async (req, res) => {
await readBody(req);
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, engine: "fake" }));
return;
}
if (req.method === "POST" && req.url === "/v1/generate") {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
text: "괜찮아요. 천천히 말해볼게요.",
model: "fake-client",
provider: "e2e",
tokens_in: 1,
tokens_out: 1,
cost_usd: 0,
inference_geo: "us",
structured: null,
}),
);
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
}
async function waitForApi(baseURL: string, proc: ChildProcessWithoutNullStreams): Promise<void> {
const started = Date.now();
let lastError = "";
while (Date.now() - started < 20_000) {
if (proc.exitCode !== null) {
throw new Error(`API exited early with code ${proc.exitCode}: ${lastError}`);
}
try {
const response = await fetch(`${baseURL}/health`);
if (response.ok) return;
lastError = await response.text();
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for API ${baseURL}: ${lastError}`);
}
async function startApi({
engineURL,
openAIBaseURL,
}: {
engineURL: string;
openAIBaseURL: string;
}): Promise<SpawnedApi> {
const port = await freePort();
const baseURL = `http://127.0.0.1:${port}`;
const localPython311 = process.env.USERPROFILE
? `${process.env.USERPROFILE}\\AppData\\Local\\Programs\\Python\\Python311\\python.exe`
: "";
const python =
process.env.E2E_PYTHON ??
process.env.PYTHON311 ??
(localPython311 && existsSync(localPython311) ? localPython311 : (process.env.PYTHON ?? "python"));
const proc = spawn(
python,
[
"-m",
"uvicorn",
"app.main:app",
"--host",
"127.0.0.1",
"--port",
String(port),
"--log-level",
"debug",
],
{
cwd: "../api",
env: {
...process.env,
PYTHONUNBUFFERED: "1",
ENVIRONMENT: "dev",
AUTH_DEV_LOGIN_ENABLED: "true",
AUTH_ALLOWED_EMAIL_DOMAINS: '["hs.ac.kr","twentyoz.kr"]',
ALLOW_SEED_PERSONA_FALLBACK: "true",
DATABASE_URL: "postgresql://user:pass@127.0.0.1:1/vignette",
DB_POOL_MIN_SIZE: "0",
DB_COMMAND_TIMEOUT: "1",
ENGINE_URL: engineURL,
ENGINE_MODE: "claude_api",
ENGINE_TIMEOUT: "10",
ENGINE_CONNECT_TIMEOUT: "2",
OPENAI_API_KEY: "e2e-fake-key",
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
FRONTEND_BASE_URL: "http://localhost:5173",
CORS_ORIGINS: '["http://localhost:5173"]',
},
windowsHide: true,
},
);
let logs = "";
proc.stdout.on("data", (chunk) => {
logs += String(chunk).slice(-4000);
});
proc.stderr.on("data", (chunk) => {
logs += String(chunk).slice(-4000);
});
await waitForApi(baseURL, proc).catch((err) => {
proc.kill();
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${logs}`);
});
return {
baseURL,
logs: () => logs,
stop: async () => {
if (proc.exitCode === null) proc.kill();
await new Promise<void>((resolve) => {
if (proc.exitCode !== null) {
resolve();
return;
}
proc.once("exit", () => resolve());
setTimeout(resolve, 3000);
});
},
};
}
async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: string): Promise<VoiceProbe> {
return page.evaluate(
({ apiBase, sid }) =>
new Promise<VoiceProbe>((resolve) => {
const wsURL = new URL(`/voice/ws?session_id=${encodeURIComponent(sid)}`, apiBase);
wsURL.protocol = "ws:";
const ws = new WebSocket(wsURL.href);
ws.binaryType = "arraybuffer";
const messages: string[] = [];
let binaryChunks = 0;
let sawTtsEnd = false;
const timeout = window.setTimeout(() => {
ws.close();
resolve({ code: -1, messages, binaryChunks });
}, 20_000);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "audio_start", format: "webm" }));
ws.send(new Uint8Array([1, 2, 3, 4, 5, 6]).buffer);
ws.send(JSON.stringify({ type: "audio_end", format: "webm" }));
};
ws.onmessage = (event) => {
if (typeof event.data === "string") {
messages.push(event.data);
try {
const parsed = JSON.parse(event.data) as { type?: string; state?: string };
if (parsed.type === "tts_end") sawTtsEnd = true;
if (sawTtsEnd && parsed.type === "state" && parsed.state === "idle") {
ws.send(JSON.stringify({ type: "close" }));
}
} catch {
messages.push(JSON.stringify({ type: "error", detail: "invalid json from ws" }));
}
} else {
binaryChunks += 1;
}
};
ws.onerror = () => {
messages.push(JSON.stringify({ type: "error", detail: "browser websocket error" }));
};
ws.onclose = (event) => {
window.clearTimeout(timeout);
resolve({ code: event.code, messages, binaryChunks });
};
}),
{ apiBase: apiBaseURL, sid: sessionId },
);
}
test.describe("voice cascade success path", () => {
test("runs STT, client turn, TTS, and audio chunks against controlled providers @single-run", async ({
page,
}, testInfo) => {
test.setTimeout(60_000);
const openai = await startFakeOpenAI();
const engine = await startFakeEngine();
const api = await startApi({ engineURL: engine.url, openAIBaseURL: openai.url });
try {
const health = await page.request.get(`${api.baseURL}/voice/health`);
expect(health.ok(), await health.text()).toBeTruthy();
await expect(await health.json()).toMatchObject({ status: "ok", available: true });
await page.goto(`${api.baseURL}/health`);
const browserSetup = await page.evaluate(async ({ apiBase, seededPersonaCode, workerIndex }) => {
const login = await fetch(`${apiBase}/auth/dev-login`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: `voice-success.${workerIndex}@hs.ac.kr`,
role: "learner",
display_name: "Voice Success",
}),
});
const loginBody = await login.text();
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
return { ok: false, step: "me", status: me.status, body: meBody };
}
const start = await fetch(`${apiBase}/sessions`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ persona_code: seededPersonaCode, theory_mode: "humanistic" }),
});
const startBody = await start.text();
if (!start.ok) {
return { ok: false, step: "sessions", status: start.status, body: startBody };
}
return {
ok: true,
me: JSON.parse(meBody) as unknown,
started: JSON.parse(startBody) as { session_id: string },
};
}, {
apiBase: api.baseURL,
seededPersonaCode: SEEDED_VOICE_PERSONA_CODE,
workerIndex: testInfo.workerIndex,
});
expect(browserSetup, api.logs()).toMatchObject({ ok: true });
if (!browserSetup.ok) throw new Error(JSON.stringify(browserSetup));
const started = browserSetup.started;
const result = await probeVoiceCascade(page, api.baseURL, started.session_id);
const events = result.messages.map((message) => JSON.parse(message) as { type: string; [key: string]: unknown });
expect(
result.code,
[
JSON.stringify(result, null, 2),
`fakeOpenAI=${JSON.stringify(openai.requests())}`,
`fakeEngine=${JSON.stringify(engine.requests())}`,
api.logs(),
].join("\n\n"),
).toBe(1000);
expect(events.some((event) => event.type === "degraded")).toBe(false);
expect(events.some((event) => event.type === "error")).toBe(false);
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "ready", session_id: started.session_id }),
expect.objectContaining({ type: "state", state: "listening" }),
expect.objectContaining({ type: "state", state: "thinking" }),
expect.objectContaining({ type: "transcript", text: "요즘 잠을 잘 못 자요." }),
expect.objectContaining({ type: "reply", text: "괜찮아요. 천천히 말해볼게요." }),
expect.objectContaining({ type: "state", state: "speaking" }),
expect.objectContaining({ type: "tts_chunk", seq: 0 }),
expect.objectContaining({ type: "tts_end" }),
expect.objectContaining({ type: "state", state: "idle" }),
]),
);
expect(result.binaryChunks).toBeGreaterThan(0);
} finally {
await api.stop();
await engine.close();
await openai.close();
}
});
});

115
apps/web/e2e/voice.spec.ts Normal file
View file

@ -0,0 +1,115 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { fetchAvailablePersona } from "./support";
interface WsResult {
code: number;
messages: string[];
}
async function signInLearner(page: Page, testInfo: TestInfo, label: string) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: `voice.${label}.${testInfo.project.name}.${testInfo.workerIndex}@hs.ac.kr`,
role: "learner",
display_name: `Voice ${label}`,
},
});
expect(res.ok(), await res.text()).toBeTruthy();
}
async function openVoiceSocket(page: Page, path: string): Promise<WsResult> {
return page.evaluate(
({ wsPath }) =>
new Promise<WsResult>((resolve) => {
const url = new URL(wsPath, window.location.href);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(url.href);
const messages: string[] = [];
const timeout = window.setTimeout(() => {
ws.close();
resolve({ code: -1, messages });
}, 5000);
ws.onmessage = (event) => {
messages.push(String(event.data));
};
ws.onclose = (event) => {
window.clearTimeout(timeout);
resolve({ code: event.code, messages });
};
ws.onerror = () => {
messages.push(JSON.stringify({ type: "error", detail: "browser websocket error" }));
};
}),
{ wsPath: path },
);
}
function parsedMessages(result: WsResult) {
return result.messages.map((message) => JSON.parse(message) as { type: string; detail?: string });
}
test.describe("voice websocket auth boundary", () => {
test("advertises only voice presets accepted by user preferences", async ({ page }, testInfo) => {
await signInLearner(page, testInfo, "presets");
const presetsResponse = await page.request.get("/api/users/me/voice-presets");
expect(presetsResponse.ok(), await presetsResponse.text()).toBeTruthy();
const presets = (await presetsResponse.json()) as { id: string; voice_id: string }[];
const ids = presets.map((preset) => preset.id);
expect(ids).toEqual(["soft-young-fem", "calm-adult-male", "warm-adult-fem", "neutral"]);
expect(ids).not.toContain("calm-adult-fem");
expect(ids).not.toContain("steady-adult-male");
for (const id of ids) {
const saveResponse = await page.request.patch("/api/users/me/preferences", {
data: { voice_preset_id: id },
});
expect(saveResponse.ok(), await saveResponse.text()).toBeTruthy();
expect(await saveResponse.json()).toMatchObject({ voice_preset_id: id });
}
const unsupported = await page.request.patch("/api/users/me/preferences", {
data: { voice_preset_id: "calm-adult-fem" },
});
expect(unsupported.status(), await unsupported.text()).toBe(422);
});
test("rejects unauthenticated websocket clients before degraded voice handling", async ({ page }) => {
await page.goto("/login");
const result = await openVoiceSocket(page, "/api/voice/ws?persona_code=UNAUTHENTICATED");
const messages = parsedMessages(result);
expect(result.code).toBe(1008);
expect(messages).toContainEqual({ type: "error", detail: "not authenticated" });
expect(messages.some((message) => message.type === "degraded")).toBeFalsy();
});
test("rejects binding another learner's session id", async ({ page }, testInfo) => {
await signInLearner(page, testInfo, "owner");
const persona = await fetchAvailablePersona(page);
const start = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
expect(start.ok(), await start.text()).toBeTruthy();
const { session_id } = (await start.json()) as { session_id: string };
await signInLearner(page, testInfo, "other");
await page.goto("/learn");
const result = await openVoiceSocket(
page,
`/api/voice/ws?session_id=${encodeURIComponent(session_id)}`,
);
const messages = parsedMessages(result);
expect(result.code).toBe(1008);
expect(messages).toContainEqual({
type: "error",
detail: "session does not belong to user",
});
expect(messages.some((message) => message.type === "degraded")).toBeFalsy();
});
});