음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
|
||||
import {
|
||||
completeOnboarding,
|
||||
expectNoHorizontalOverflow,
|
||||
signInAsLearner,
|
||||
useRealApi,
|
||||
|
|
@ -29,8 +30,12 @@ interface AdminManagedUser {
|
|||
email: string;
|
||||
display_name: string;
|
||||
role: "learner" | "teacher" | "admin";
|
||||
account_status: "pending" | "approved" | "suspended";
|
||||
affiliation: string;
|
||||
cohort_ids: string[];
|
||||
active_sessions: number;
|
||||
last_seen_at: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface AdminUsersResponse {
|
||||
|
|
@ -68,6 +73,48 @@ interface AdminUsageResponse {
|
|||
by_provider: AdminUsageBreakdown[];
|
||||
}
|
||||
|
||||
interface AdminUptimeResponse {
|
||||
source: "database" | "unavailable";
|
||||
durable: boolean;
|
||||
window_hours: number;
|
||||
sample_count: number;
|
||||
ok_ratio: number;
|
||||
degraded_events: number;
|
||||
down_events: number;
|
||||
last_down_at: number | null;
|
||||
}
|
||||
|
||||
interface AdminTicketReporter {
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AdminSupportTicket {
|
||||
ticket_id: string;
|
||||
reporter: AdminTicketReporter;
|
||||
category: string;
|
||||
priority: "low" | "normal" | "high" | "urgent";
|
||||
status: "open" | "triaged" | "in_progress" | "resolved" | "closed";
|
||||
subject: string;
|
||||
body: string;
|
||||
source_path: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface AdminTicketsResponse {
|
||||
source: "database" | "unavailable";
|
||||
durable: boolean;
|
||||
tickets: AdminSupportTicket[];
|
||||
summary: {
|
||||
total: number;
|
||||
open_count: number;
|
||||
high_priority_count: number;
|
||||
stale_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function expectResponseOk(response: APIResponse | Response) {
|
||||
if (!response.ok()) {
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
|
|
@ -75,34 +122,97 @@ async function expectResponseOk(response: APIResponse | Response) {
|
|||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
let res: APIResponse | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
res = await page.request.post("/api/auth/dev-login", {
|
||||
data: {
|
||||
email: "admin@twentyoz.kr",
|
||||
role: "admin",
|
||||
display_name: "E2E Admin",
|
||||
},
|
||||
});
|
||||
if (res.ok()) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
if (!res) throw new Error("admin dev-login did not return a response");
|
||||
await expectResponseOk(res);
|
||||
await completeOnboarding(page, {
|
||||
legal_name: "E2E Admin",
|
||||
affiliation: "한신대학교",
|
||||
department: "운영",
|
||||
grade_level: "관리자",
|
||||
phone: "010-2222-2222",
|
||||
contact_address: "경기도 오산시 한신대학교",
|
||||
});
|
||||
}
|
||||
|
||||
function isJsonResponse(response: Response) {
|
||||
return response.headers()["content-type"]?.includes("application/json") ?? false;
|
||||
}
|
||||
|
||||
function isAdminHealthResponse(response: Response) {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "GET" && url.pathname.endsWith("/admin/health");
|
||||
return (
|
||||
response.request().method() === "GET" &&
|
||||
url.pathname.endsWith("/admin/health") &&
|
||||
isJsonResponse(response)
|
||||
);
|
||||
}
|
||||
|
||||
function isAdminUsersResponse(response: Response) {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "GET" && url.pathname.endsWith("/admin/users");
|
||||
return (
|
||||
response.request().method() === "GET" &&
|
||||
url.pathname.endsWith("/admin/users") &&
|
||||
isJsonResponse(response)
|
||||
);
|
||||
}
|
||||
|
||||
function isAdminUsageResponse(response: Response) {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "GET" && url.pathname.endsWith("/admin/usage");
|
||||
return (
|
||||
response.request().method() === "GET" &&
|
||||
url.pathname.endsWith("/admin/usage") &&
|
||||
isJsonResponse(response)
|
||||
);
|
||||
}
|
||||
|
||||
function isAdminUptimeResponse(response: Response) {
|
||||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "GET" &&
|
||||
url.pathname.endsWith("/admin/uptime") &&
|
||||
isJsonResponse(response)
|
||||
);
|
||||
}
|
||||
|
||||
function isAdminTicketsResponse(response: Response) {
|
||||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "GET" &&
|
||||
url.pathname.endsWith("/admin/tickets") &&
|
||||
isJsonResponse(response)
|
||||
);
|
||||
}
|
||||
|
||||
function isAdminTicketPatch(ticketId: string) {
|
||||
return (response: Response) => {
|
||||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "PATCH" &&
|
||||
url.pathname.endsWith(`/admin/tickets/${ticketId}`) &&
|
||||
isJsonResponse(response)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function isAdminUserCreate(response: Response) {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname.endsWith("/admin/users");
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
url.pathname.endsWith("/admin/users") &&
|
||||
isJsonResponse(response)
|
||||
);
|
||||
}
|
||||
|
||||
function isAdminUserPatch(userId: string) {
|
||||
|
|
@ -110,7 +220,8 @@ function isAdminUserPatch(userId: string) {
|
|||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "PATCH" &&
|
||||
url.pathname.endsWith(`/admin/users/${userId}`)
|
||||
url.pathname.endsWith(`/admin/users/${userId}`) &&
|
||||
isJsonResponse(response)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -120,7 +231,8 @@ function isAdminUserDelete(userId: string) {
|
|||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "DELETE" &&
|
||||
url.pathname.endsWith(`/admin/users/${userId}`)
|
||||
url.pathname.endsWith(`/admin/users/${userId}`) &&
|
||||
isJsonResponse(response)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -150,35 +262,59 @@ function costLabel(value: number) {
|
|||
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
function isOnline(seconds: number) {
|
||||
return Number.isFinite(seconds) && seconds > 0 && Date.now() / 1000 - seconds < 15 * 60;
|
||||
}
|
||||
|
||||
async function openAdminAndReadHealth(page: Page) {
|
||||
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
|
||||
const usageResponsePromise = page.waitForResponse(isAdminUsageResponse);
|
||||
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
|
||||
const uptimeResponsePromise = page.waitForResponse(isAdminUptimeResponse);
|
||||
const ticketsResponsePromise = page.waitForResponse(isAdminTicketsResponse);
|
||||
|
||||
await page.goto("/admin");
|
||||
|
||||
const [healthResponse, usageResponse] = await Promise.all([
|
||||
const [healthResponse, usageResponse, usersResponse, uptimeResponse, ticketsResponse] = await Promise.all([
|
||||
healthResponsePromise,
|
||||
usageResponsePromise,
|
||||
usersResponsePromise,
|
||||
uptimeResponsePromise,
|
||||
ticketsResponsePromise,
|
||||
]);
|
||||
await expectResponseOk(healthResponse);
|
||||
await expectResponseOk(usageResponse);
|
||||
await expectResponseOk(usersResponse);
|
||||
await expectResponseOk(uptimeResponse);
|
||||
await expectResponseOk(ticketsResponse);
|
||||
|
||||
const health = (await healthResponse.json()) as AdminHealthResponse;
|
||||
const usage = (await usageResponse.json()) as AdminUsageResponse;
|
||||
const users = (await usersResponse.json()) as AdminUsersResponse;
|
||||
const uptime = (await uptimeResponse.json()) as AdminUptimeResponse;
|
||||
const tickets = (await ticketsResponse.json()) as AdminTicketsResponse;
|
||||
expect(health.services.length).toBeGreaterThan(0);
|
||||
return { health, usage };
|
||||
return { health, usage, users, uptime, tickets };
|
||||
}
|
||||
|
||||
async function openAdminAndReadUsers(page: Page) {
|
||||
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
|
||||
|
||||
await page.goto("/admin");
|
||||
await page.goto("/admin/users");
|
||||
|
||||
const usersResponse = await usersResponsePromise;
|
||||
await expectResponseOk(usersResponse);
|
||||
return (await usersResponse.json()) as AdminUsersResponse;
|
||||
}
|
||||
|
||||
async function openAdminTickets(page: Page) {
|
||||
const ticketsResponsePromise = page.waitForResponse(isAdminTicketsResponse);
|
||||
await page.goto("/admin/tickets");
|
||||
const ticketsResponse = await ticketsResponsePromise;
|
||||
await expectResponseOk(ticketsResponse);
|
||||
return (await ticketsResponse.json()) as AdminTicketsResponse;
|
||||
}
|
||||
|
||||
async function expectCreateUserControlsFit(page: Page, viewportWidth: number) {
|
||||
const form = page.locator(".ad-user-create");
|
||||
await expect(form).toBeVisible();
|
||||
|
|
@ -272,36 +408,36 @@ test.describe("admin route", () => {
|
|||
});
|
||||
|
||||
test("allows an admin to open the live health dashboard", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await signInAsAdmin(page);
|
||||
|
||||
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
|
||||
const { health, usage } = await openAdminAndReadHealth(page);
|
||||
const { health, usage, users, uptime, tickets } = 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,
|
||||
total: health.services.length,
|
||||
};
|
||||
const activeSessions = users.users.reduce(
|
||||
(sum, user) => sum + Math.max(0, user.active_sessions),
|
||||
0,
|
||||
);
|
||||
const onlineUsers = users.users.filter((user) => isOnline(user.last_seen_at)).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),
|
||||
countLabel(activeSessions),
|
||||
countLabel(onlineUsers),
|
||||
countLabel(users.users.length),
|
||||
counts.total ? `${counts.ok}/${counts.total}` : "-",
|
||||
]);
|
||||
await expect(page.getByRole("heading", { name: "AI 비용 관측" })).toBeVisible();
|
||||
await expect(page.locator(".ad-usage-kpi b")).toHaveText([
|
||||
costLabel(usage.cost_usd),
|
||||
countLabel(usage.tokens_in),
|
||||
countLabel(usage.tokens_out),
|
||||
usage.total_turns > 0
|
||||
? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%`
|
||||
: "0%",
|
||||
]);
|
||||
await expect(page.locator(".ad-usage-budget")).toContainText(
|
||||
await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible();
|
||||
await expect(page.locator(".ad-cost")).toContainText(costLabel(usage.cost_usd));
|
||||
await expect(page.locator(".ad-cost")).toContainText(
|
||||
usage.budget.status === "disabled"
|
||||
? "예산 경고 비활성"
|
||||
: usage.budget.status === "exceeded"
|
||||
|
|
@ -310,16 +446,15 @@ test.describe("admin route", () => {
|
|||
? "예산 주의"
|
||||
: "예산 정상",
|
||||
);
|
||||
if (usage.by_provider.length > 0) {
|
||||
await expect(page.locator(".ad-usage-row")).toHaveCount(usage.by_provider.length);
|
||||
await expect(page.locator(".ad-usage-row").first()).toContainText(
|
||||
usage.by_provider[0].provider,
|
||||
);
|
||||
} else {
|
||||
await expect(page.locator(".ad-usage-breakdown")).toContainText(
|
||||
"최근 윈도우에 계량된 AI 턴이 없습니다.",
|
||||
);
|
||||
}
|
||||
await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible();
|
||||
await expect(page.getByText("샘플 정상률")).toBeVisible();
|
||||
expect(uptime.ok_ratio).toBeGreaterThanOrEqual(0);
|
||||
expect(uptime.ok_ratio).toBeLessThanOrEqual(1);
|
||||
await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible();
|
||||
expect(tickets.summary.open_count).toBeGreaterThanOrEqual(0);
|
||||
await expect(page.locator(".ad-panel").filter({ hasText: "운영 티켓" })).toContainText(
|
||||
/(\d+건 미해결|미해결 티켓이 없습니다)/,
|
||||
);
|
||||
await expect(serviceCards).toHaveCount(health.services.length);
|
||||
|
||||
for (const service of health.services) {
|
||||
|
|
@ -357,6 +492,7 @@ test.describe("admin route", () => {
|
|||
});
|
||||
|
||||
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await signInAsAdmin(page);
|
||||
|
||||
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
|
||||
|
|
@ -366,14 +502,17 @@ test.describe("admin route", () => {
|
|||
expect(users.source).toBe("database");
|
||||
expect(users.durable).toBe(true);
|
||||
|
||||
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
||||
await page.getByLabel("사용자 검색").fill(`no-match-${slug}`);
|
||||
await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible();
|
||||
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
|
||||
await page.getByLabel("사용자 검색").fill("");
|
||||
|
||||
await page.getByRole("tab", { name: "사용자 등록" }).click();
|
||||
await page.getByLabel("새 사용자 이메일").fill(email);
|
||||
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
|
||||
await page.getByLabel("새 사용자 역할").selectOption("learner");
|
||||
await page.getByLabel("새 사용자 승인 상태").selectOption("pending");
|
||||
await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`);
|
||||
|
||||
const createPromise = page.waitForResponse(isAdminUserCreate);
|
||||
|
|
@ -387,13 +526,40 @@ test.describe("admin route", () => {
|
|||
email,
|
||||
display_name: displayName,
|
||||
role: "learner",
|
||||
account_status: "pending",
|
||||
cohort_ids: [`created-${testInfo.project.name}`],
|
||||
});
|
||||
|
||||
await expect(page.getByRole("tab", { name: "사용자 목록" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const approvalTab = page.getByRole("tab", { name: /가입 승인/ });
|
||||
await approvalTab.click();
|
||||
await expect(approvalTab).toHaveAttribute("aria-selected", "true");
|
||||
const approvalCard = page.locator(".ad-approval").filter({ hasText: email });
|
||||
await expect(approvalCard).toBeVisible();
|
||||
await expect(approvalCard).toContainText("승인 대기");
|
||||
await expectVisibleButtonsFit(page, ".ad-approval__actions .vg-btn", "admin approval buttons");
|
||||
|
||||
const approvePromise = page.waitForResponse(isAdminUserPatch(created.user_id));
|
||||
await approvalCard.getByRole("button", { name: "승인" }).click();
|
||||
const approveResponse = await approvePromise;
|
||||
await expectResponseOk(approveResponse);
|
||||
const approved = (await approveResponse.json()) as AdminManagedUser;
|
||||
expect(approved).toMatchObject({
|
||||
user_id: created.user_id,
|
||||
email,
|
||||
account_status: "approved",
|
||||
});
|
||||
await expect(approvalCard).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
||||
await page.getByLabel("사용자 검색").fill(email);
|
||||
const card = page.locator(".ad-user").filter({ hasText: email });
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText(displayName);
|
||||
await expect(card).toContainText("승인됨");
|
||||
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
|
||||
|
||||
const nextName = `교수자 ${testInfo.project.name}`;
|
||||
|
|
@ -422,27 +588,73 @@ test.describe("admin route", () => {
|
|||
email,
|
||||
display_name: nextName,
|
||||
role: "teacher",
|
||||
account_status: "approved",
|
||||
cohort_ids: [nextCohort],
|
||||
});
|
||||
|
||||
await expect(card).toContainText(nextName);
|
||||
await expect(card).toContainText("교수자");
|
||||
await expect(card).toContainText(nextCohort);
|
||||
await expect(cohortInput).toHaveValue(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("shows and resolves user-submitted operation tickets", async ({ page }, testInfo) => {
|
||||
await signInAsAdmin(page);
|
||||
|
||||
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
|
||||
const subject = `[E2E] 운영 티켓 ${slug}`;
|
||||
let createdTicketId: string | null = null;
|
||||
|
||||
try {
|
||||
const create = await page.request.post("/api/users/support-tickets", {
|
||||
data: {
|
||||
category: "session_review",
|
||||
priority: "high",
|
||||
subject,
|
||||
body: "E2E 운영 티켓 처리 흐름 검증입니다. 실제 리뷰 생성 지연이 아닙니다.",
|
||||
source_path: "/__e2e__/learn/session/test/review",
|
||||
},
|
||||
});
|
||||
await expectResponseOk(create);
|
||||
const created = (await create.json()) as { ticket_id: string };
|
||||
createdTicketId = created.ticket_id;
|
||||
|
||||
const tickets = await openAdminTickets(page);
|
||||
expect(tickets.source).toBe("database");
|
||||
expect(tickets.durable).toBe(true);
|
||||
expect(tickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBeTruthy();
|
||||
|
||||
const card = page.locator(".ad-ticket").filter({ hasText: subject });
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText("높음");
|
||||
await expect(card).toContainText("미해결");
|
||||
|
||||
const patchPromise = page.waitForResponse(isAdminTicketPatch(created.ticket_id));
|
||||
await card.getByRole("button", { name: "해결" }).click();
|
||||
const patchResponse = await patchPromise;
|
||||
await expectResponseOk(patchResponse);
|
||||
const updated = (await patchResponse.json()) as AdminSupportTicket;
|
||||
expect(updated.status).toBe("resolved");
|
||||
await expect(card).toContainText("해결");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
} finally {
|
||||
if (createdTicketId) {
|
||||
const cleanup = await page.request.patch(`/api/admin/tickets/${createdTicketId}`, {
|
||||
data: {
|
||||
status: "resolved",
|
||||
resolution_note: "E2E cleanup",
|
||||
},
|
||||
});
|
||||
if (!cleanup.ok()) {
|
||||
console.warn(`E2E ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("denies learner access to the admin API and UI", async ({ page }) => {
|
||||
|
|
@ -452,6 +664,10 @@ test.describe("admin route", () => {
|
|||
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);
|
||||
const deniedUptime = await page.request.get("/api/admin/uptime");
|
||||
expect(deniedUptime.status(), await deniedUptime.text()).toBe(403);
|
||||
const deniedTickets = await page.request.get("/api/admin/tickets");
|
||||
expect(deniedTickets.status(), await deniedTickets.text()).toBe(403);
|
||||
|
||||
await page.goto("/admin");
|
||||
|
||||
|
|
@ -460,35 +676,37 @@ test.describe("admin route", () => {
|
|||
});
|
||||
|
||||
test("keeps admin controls usable at a mobile viewport", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await signInAsAdmin(page);
|
||||
|
||||
const users = await openAdminAndReadUsers(page);
|
||||
await page.getByRole("tab", { name: "사용자 등록" }).click();
|
||||
const layout = await page.evaluate(() => {
|
||||
const workspace = document.querySelector<HTMLElement>(".ad-user-workspace");
|
||||
const form = document.querySelector<HTMLElement>(".ad-user-create");
|
||||
if (!workspace || !form) throw new Error("admin user workspace was not rendered");
|
||||
if (!form) throw new Error("admin user create form was not rendered");
|
||||
return {
|
||||
workspaceColumns: window.getComputedStyle(workspace).gridTemplateColumns.split(" ").length,
|
||||
formColumns: window.getComputedStyle(form).gridTemplateColumns.split(" ").length,
|
||||
};
|
||||
});
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectCreateUserControlsFit(page, 390);
|
||||
expect(layout.workspaceColumns).toBe(1);
|
||||
expect(layout.formColumns).toBe(1);
|
||||
if (users.users.length > 0) {
|
||||
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
||||
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the create-user form contained at tablet widths", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await signInAsAdmin(page);
|
||||
|
||||
for (const width of [861, 900, 1024]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await openAdminAndReadUsers(page);
|
||||
await page.getByRole("tab", { name: "사용자 등록" }).click();
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectCreateUserControlsFit(page, width);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue