forked from baron/baron-sso
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|