Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
51
apps/web/e2e/README.md
Normal file
51
apps/web/e2e/README.md
Normal 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
304
apps/web/e2e/admin.spec.ts
Normal 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
183
apps/web/e2e/auth.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
177
apps/web/e2e/db-persistence.spec.ts
Normal file
177
apps/web/e2e/db-persistence.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
212
apps/web/e2e/learner.spec.ts
Normal file
212
apps/web/e2e/learner.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
160
apps/web/e2e/public-auth-turn.spec.ts
Normal file
160
apps/web/e2e/public-auth-turn.spec.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
79
apps/web/e2e/readiness.spec.ts
Normal file
79
apps/web/e2e/readiness.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
313
apps/web/e2e/session-layout.spec.ts
Normal file
313
apps/web/e2e/session-layout.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
95
apps/web/e2e/session-persistence.spec.ts
Normal file
95
apps/web/e2e/session-persistence.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
78
apps/web/e2e/session-review.spec.ts
Normal file
78
apps/web/e2e/session-review.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
428
apps/web/e2e/settings.spec.ts
Normal file
428
apps/web/e2e/settings.spec.ts
Normal 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
172
apps/web/e2e/support.ts
Normal 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);
|
||||
}
|
||||
111
apps/web/e2e/teacher.spec.ts
Normal file
111
apps/web/e2e/teacher.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
360
apps/web/e2e/voice-success.spec.ts
Normal file
360
apps/web/e2e/voice-success.spec.ts
Normal 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
115
apps/web/e2e/voice.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue