import { performance } from "node:perf_hooks"; import { expect, test } from "@playwright/test"; const tenantCount = 3500; const userCount = 3500; type TenantFixture = { id: string; name: string; slug: string; status: string; type: string; memberCount: number; recursiveMemberCount: number; createdAt: string; updatedAt: string; }; type UserFixture = { id: string; name: string; email: string; phone: string; loginId: string; role: string; status: string; tenantId: string; tenantSlug: string; tenantName: string; department: string; createdAt: string; }; function buildTenants(): TenantFixture[] { const baseTime = Date.UTC(2026, 0, 1, 0, 0, 0); return Array.from({ length: tenantCount }, (_, index) => { const sequence = index + 1; const padded = String(sequence).padStart(4, "0"); const timestamp = new Date(baseTime + sequence * 1000).toISOString(); return { id: `tenant-${padded}`, name: `Tenant ${padded}`, slug: sequence === 100 ? "full-dataset-needle-0100" : `tenant-${padded}`, status: sequence % 17 === 0 ? "inactive" : "active", type: sequence % 5 === 0 ? "ORGANIZATION" : "COMPANY", memberCount: sequence % 13, recursiveMemberCount: sequence % 29, createdAt: timestamp, updatedAt: timestamp, }; }); } function buildUsers(): UserFixture[] { const baseTime = Date.UTC(2026, 0, 1, 0, 0, 0); return Array.from({ length: userCount }, (_, index) => { const sequence = index + 1; const padded = String(sequence).padStart(4, "0"); const timestamp = new Date(baseTime + sequence * 1000).toISOString(); const email = sequence === 100 ? "full-dataset-user-needle-0100@example.com" : `user-${padded}@example.com`; return { id: `user-${padded}`, name: `User ${padded}`, email, phone: "010-1111-2222", loginId: `user-${padded}`, role: "user", status: sequence % 19 === 0 ? "inactive" : "active", tenantId: "tenant-main", tenantSlug: "tenant-main", tenantName: "Main Tenant", department: "Platform", createdAt: timestamp, }; }); } function compareTenantValues( left: TenantFixture, right: TenantFixture, sortKey: string, ) { const key = sortKey as keyof TenantFixture; const leftValue = left[key] ?? ""; const rightValue = right[key] ?? ""; return String(leftValue).localeCompare(String(rightValue)); } test.describe("Tenant list performance", () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { window.localStorage.setItem("locale", "ko"); window.localStorage.setItem("admin_session", "fake-token"); ( window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } )._IS_TEST_MODE = true; const authority = "http://localhost:5000/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; window.localStorage.setItem( key, JSON.stringify({ access_token: "fake-token", token_type: "Bearer", profile: { sub: "admin-user", name: "Admin", role: "super_admin" }, expires_at: Math.floor(Date.now() / 1000) + 36000, }), ); }); await page.route("**/oidc/**", async (route) => { await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); }); }); test("loads and searches the tenant list within the performance budget", async ({ page, }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); const tenants = buildTenants(); await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); const headers = { "Access-Control-Allow-Origin": "*" }; if (url.pathname.endsWith("/user/me")) { return route.fulfill({ json: { id: "admin-user", name: "Admin", role: "super_admin", manageableTenants: [], }, headers, }); } if ( url.pathname.endsWith("/admin/tenants") && route.request().method() === "GET" ) { const limit = Number(url.searchParams.get("limit") ?? "500"); const cursor = Number(url.searchParams.get("cursor") ?? "0"); const search = url.searchParams.get("search")?.trim().toLowerCase(); const sort = url.searchParams.get("sort") ?? "createdAt"; const direction = url.searchParams.get("direction") ?? "desc"; let filtered = tenants; if (search) { filtered = tenants.filter((tenant) => [tenant.id, tenant.name, tenant.slug, tenant.type].some((value) => value.toLowerCase().includes(search), ), ); } const sorted = [...filtered].sort((left, right) => { const result = compareTenantValues(left, right, sort); return direction === "asc" ? result : -result; }); const pageItems = sorted.slice(cursor, cursor + limit); const nextOffset = cursor + limit; return route.fulfill({ json: { items: pageItems, total: sorted.length, limit, offset: 0, nextCursor: nextOffset < sorted.length ? String(nextOffset) : undefined, }, headers, }); } return route.fulfill({ json: { items: [], total: 0 }, headers }); }); const loadStarted = performance.now(); await page.goto("/tenants"); await expect( page.getByTestId("tenant-internal-id-tenant-3500"), ).toBeVisible({ timeout: 15000 }); const loadMs = performance.now() - loadStarted; const loadSnapshot = testInfo.outputPath("tenant-list-load.png"); await page.screenshot({ path: loadSnapshot, fullPage: true }); await expect(page.locator("tbody tr").first()).toContainText("Tenant 3500"); const searchInput = page.getByPlaceholder("이름 또는 슬러그, ID 검색"); const searchStarted = performance.now(); await searchInput.fill("full-dataset-needle-0100"); await expect( page.getByTestId("tenant-internal-id-tenant-0100"), ).toBeVisible({ timeout: 15000 }); const searchMs = performance.now() - searchStarted; const searchSnapshot = testInfo.outputPath("tenant-list-search.png"); await page.screenshot({ path: searchSnapshot, fullPage: true }); await expect(page.locator("tbody")).toContainText( "full-dataset-needle-0100", ); await expect( page.getByTestId("tenant-internal-id-tenant-3500"), ).toHaveCount(0); console.log( JSON.stringify({ metric: "tenant-list-performance", loadMs: Math.round(loadMs), searchMs: Math.round(searchMs), loadSnapshot, searchSnapshot, }), ); const searchBudgetMs = testInfo.project.name === "firefox" ? 1000 : 500; expect(loadMs).toBeLessThanOrEqual(1500); expect(searchMs).toBeLessThanOrEqual(searchBudgetMs); }); }); test.describe("User list performance", () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { window.localStorage.setItem("locale", "ko"); window.localStorage.setItem("admin_session", "fake-token"); ( window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } )._IS_TEST_MODE = true; const authority = "http://localhost:5000/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; window.localStorage.setItem( key, JSON.stringify({ id_token: "fake-id-token", access_token: "fake-token", token_type: "Bearer", scope: "openid profile email", profile: { sub: "admin-user", name: "Admin", email: "admin@test.com", role: "super_admin", }, expires_at: Math.floor(Date.now() / 1000) + 36000, }), ); }); await page.route("**/oidc/**", async (route) => { if (route.request().url().includes("/.well-known/openid-configuration")) { return route.fulfill({ json: { issuer: "http://localhost:5000/oidc", authorization_endpoint: "http://localhost:5000/oidc/auth", token_endpoint: "http://localhost:5000/oidc/token", userinfo_endpoint: "http://localhost:5000/oidc/userinfo", jwks_uri: "http://localhost:5000/oidc/jwks", }, }); } await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); }); }); test("loads and searches the user list within the performance budget", async ({ page, }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); const users = buildUsers(); await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); const headers = { "Access-Control-Allow-Origin": "*" }; if (url.pathname.endsWith("/user/me")) { return route.fulfill({ json: { id: "admin-user", name: "Admin", email: "admin@test.com", role: "super_admin", manageableTenants: [], }, headers, }); } if ( url.pathname.endsWith("/admin/tenants") && route.request().method() === "GET" ) { return route.fulfill({ json: { items: [ { id: "tenant-main", slug: "tenant-main", name: "Main Tenant", type: "COMPANY", config: { userSchema: [] }, }, ], total: 1, limit: 500, offset: 0, }, headers, }); } if ( url.pathname.endsWith("/admin/users") && route.request().method() === "GET" ) { const limit = Number(url.searchParams.get("limit") ?? "50"); const cursor = Number(url.searchParams.get("cursor") ?? "0"); const search = url.searchParams.get("search")?.trim().toLowerCase(); const filtered = search ? users.filter((user) => [user.id, user.name, user.email, user.loginId].some((value) => value.toLowerCase().includes(search), ), ) : users; const sorted = [...filtered].sort((left, right) => right.createdAt.localeCompare(left.createdAt), ); const pageItems = sorted.slice(cursor, cursor + limit); const nextOffset = cursor + limit; return route.fulfill({ json: { items: pageItems, total: sorted.length, limit, offset: 0, nextCursor: nextOffset < sorted.length ? String(nextOffset) : undefined, }, headers, }); } return route.fulfill({ json: { items: [], total: 0 }, headers }); }); const loadStarted = performance.now(); await page.goto("/users"); await expect(page.getByTestId("user-internal-id-user-3500")).toBeVisible({ timeout: 15000, }); const loadMs = performance.now() - loadStarted; const loadSnapshot = testInfo.outputPath("user-list-load.png"); await page.screenshot({ path: loadSnapshot, fullPage: true }); await expect(page.getByText("User 3500")).toBeVisible(); const searchInput = page.getByPlaceholder("이름 또는 이메일 검색"); const searchStarted = performance.now(); await searchInput.fill("full-dataset-user-needle-0100"); await expect(page.getByTestId("user-internal-id-user-0100")).toBeVisible({ timeout: 15000, }); const searchMs = performance.now() - searchStarted; const searchSnapshot = testInfo.outputPath("user-list-search.png"); await page.screenshot({ path: searchSnapshot, fullPage: true }); await expect( page.getByText("full-dataset-user-needle-0100@example.com"), ).toBeVisible(); await expect(page.getByTestId("user-internal-id-user-3500")).toHaveCount(0); console.log( JSON.stringify({ metric: "user-list-performance", loadMs: Math.round(loadMs), searchMs: Math.round(searchMs), loadSnapshot, searchSnapshot, }), ); expect(loadMs).toBeLessThanOrEqual(1500); expect(searchMs).toBeLessThanOrEqual(500); }); });