- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나 웹 진입점이 없던 2결함 - a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩 그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정 - uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정 - breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정 — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로 - 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN), 직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소, 교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기 - 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json, SSOT 체커 PASS(59)
141 lines
6.2 KiB
TypeScript
141 lines
6.2 KiB
TypeScript
import { expect, test, type APIResponse, type Page } from "@playwright/test";
|
|
import {
|
|
completeOnboarding,
|
|
expectNoHorizontalOverflow,
|
|
withGlobalEngineConfigLock,
|
|
} 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);
|
|
await completeOnboarding(page, {
|
|
legal_name: displayName,
|
|
affiliation: "한신대학교",
|
|
department: role === "admin" ? "운영팀" : "상담심리학과",
|
|
grade_level: role === "admin" ? "관리자" : "3학년",
|
|
phone: "010-7777-7777",
|
|
contact_address: "경기도 오산시 한신대학교",
|
|
nickname: displayName,
|
|
self_introduction: `${displayName} readiness E2E 사용자입니다.`,
|
|
});
|
|
}
|
|
|
|
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.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
|
|
const statusRegion = page.getByRole("region", { name: "학습 현황" });
|
|
await expect(statusRegion.getByRole("button", { name: /진행 회기 0회/ })).toBeVisible();
|
|
await expect(statusRegion.getByRole("button", { name: /리뷰 대기 0건/ })).toBeVisible();
|
|
// 중간 폭 이하에서는 기록 패널이 '기록 · 리뷰' 탭 안에 있다.
|
|
const dashTabs = page.locator(".lh-tabs");
|
|
if (await dashTabs.isVisible().catch(() => false)) {
|
|
await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click();
|
|
}
|
|
await expect(page.getByText("최근 기록이 없습니다.")).toBeVisible();
|
|
if (await dashTabs.isVisible().catch(() => false)) {
|
|
await dashTabs.locator("button", { hasText: "오늘의 회기" }).click();
|
|
}
|
|
await expect(page.getByRole("button", { name: "학습 대상 선택" })).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|
|
|
|
test("admin-owned runtime controls are durable database state", async ({ page }) => {
|
|
test.setTimeout(60_000); // 엔진 설정 전역 락 대기 여유 포함
|
|
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 });
|
|
|
|
// 엔진 설정의 durable 계약은 "관리자가 저장한 설정이 DB 행으로 남는다"이다.
|
|
// 시드 없는 격리 DB에서는 저장 전 GET이 runtime_default(durable=false)를
|
|
// 돌려주는 것이 정상 계약이므로, 실제 게이트웨이 검증을 거친 저장 round-trip
|
|
// 후의 durable/database 상태를 확인한다. (support.ts withGlobalEngineConfigLock 계약)
|
|
await withGlobalEngineConfigLock("readiness-admin-engine", async () => {
|
|
type EngineConfig = {
|
|
durable: boolean;
|
|
source: string;
|
|
engine_mode: string;
|
|
engine_url: string;
|
|
model: string;
|
|
reasoning_effort: string | null;
|
|
};
|
|
const currentResponse = await page.request.get("/api/admin/engine-config");
|
|
await expectResponseOk(currentResponse);
|
|
const current = (await currentResponse.json()) as EngineConfig;
|
|
|
|
const capabilityResponse = await page.request.get(
|
|
`/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(current.engine_mode)}`,
|
|
);
|
|
await expectResponseOk(capabilityResponse);
|
|
const capability = (await capabilityResponse.json()) as {
|
|
models: Array<{ id: string; default_reasoning_effort?: string | null }>;
|
|
};
|
|
const selected =
|
|
capability.models.find((model) => model.id === current.model) ?? capability.models[0];
|
|
if (!selected) throw new Error("engine capability returned no selectable model");
|
|
|
|
const patchResponse = await page.request.patch("/api/admin/engine-config", {
|
|
data: {
|
|
engine_mode: current.engine_mode,
|
|
engine_url: current.engine_url,
|
|
model: selected.id,
|
|
reasoning_effort:
|
|
selected.id === current.model
|
|
? current.reasoning_effort
|
|
: (selected.default_reasoning_effort ?? null),
|
|
},
|
|
});
|
|
await expectResponseOk(patchResponse);
|
|
|
|
const engineResponse = await page.request.get("/api/admin/engine-config");
|
|
await expectResponseOk(engineResponse);
|
|
const engine = (await engineResponse.json()) as EngineConfig;
|
|
expect(engine).toMatchObject({ durable: true, source: "database" });
|
|
expect(["claude_cli", "claude_api", "codex_cli", "agy_cli", "openai", "solar"]).toContain(
|
|
engine.engine_mode,
|
|
);
|
|
expect(engine.engine_url).toMatch(/^https?:\/\//);
|
|
expect(engine.model.trim().length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|