test: add E2E server-detail tests and fill coverage gaps to 83.9%

- Add Playwright E2E for all 5 UX phases (Config/Missions/Mods/Players/Logs)
  with ServerDetailPage POM and fully mocked API routes
- Add logger.test.ts: dynamic module re-import pattern for level-gating tests
- Add useUpdateServer + useKillServer tests to useServers.test.tsx
- Add CreateServerPage edge cases: non-admin gate, API error handling, step 2 render
- Add auth.store rehydration and null-branch coverage tests
- Update FRONTEND.md, MODULES.md, API.md, README.md to reflect current state
  (167 unit tests, 38 E2E tests, 9 useServers hooks, all UX phases implemented)
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-18 10:24:03 +07:00
parent 8bac29fb68
commit b7d670a91c
10 changed files with 606 additions and 11 deletions

View File

@@ -0,0 +1,31 @@
import { Page, Locator } from "@playwright/test";
export class ServerDetailPage {
readonly page: Page;
readonly content: Locator;
readonly loading: Locator;
readonly errorMessage: Locator;
readonly tabBar: Locator;
constructor(page: Page) {
this.page = page;
this.content = page.locator('[data-testid="server-detail-page"]');
this.loading = page.locator('[data-testid="server-detail-loading"]');
this.errorMessage = page.locator('[data-testid="server-detail-error"]');
// Tab bar: div wrapping the tab buttons (no ARIA role, plain flex div)
this.tabBar = page.locator('[data-testid="server-detail-page"] .flex.gap-1');
}
async goto(serverId: number) {
await this.page.goto(`/servers/${serverId}`);
await this.page.waitForLoadState("networkidle");
}
async clickTab(name: string) {
await this.tabBar.locator(`button:has-text("${name}")`).click();
}
getTab(name: string): Locator {
return this.tabBar.locator(`button:has-text("${name}")`);
}
}

View File

@@ -0,0 +1,276 @@
import { test, expect } from "@playwright/test";
import { ServerDetailPage } from "../pages/ServerDetailPage";
const MOCK_TOKEN = "mock-jwt-token";
const MOCK_USER = { id: 1, username: "admin", role: "admin" };
const SERVER_ID = 1;
const MOCK_SERVER = {
id: SERVER_ID,
name: "A3Master",
game_type: "arma3",
status: "running",
port: 2302,
max_players: 64,
current_players: 3,
restart_count: 0,
auto_restart: true,
created_at: "2026-01-01T00:00:00Z",
exe_path: "/servers/A3Master/arma3server",
config_path: "/servers/A3Master/server.cfg",
};
const MOCK_CONFIG = {
hostname: "A3Master Tactical",
password: "",
password_admin: "secret",
max_players: 64,
battleye: true,
motd: ["Welcome to A3Master"],
disable_von: false,
voteMissionPlayers: 1,
};
const MOCK_CONFIG_SCHEMA = {
hostname: { widget: "text", label: "Hostname" },
password: { widget: "text", label: "Password" },
max_players: { widget: "number", label: "Max Players" },
battleye: { widget: "toggle", label: "BattlEye" },
motd: { widget: "tag-list", label: "MOTD Lines" },
};
const MOCK_MISSIONS = {
server_id: SERVER_ID,
total: 2,
missions: [
{ name: "co10_example", filename: "co10_example.Altis.pbo", size_bytes: 102400, terrain: "Altis" },
{ name: "tvt06_test", filename: "tvt06_test.Stratis.pbo", size_bytes: 51200, terrain: "Stratis" },
],
};
const MOCK_ROTATION = { missions: [{ name: "co10_example.Altis", difficulty: "Regular" }], config_version: 1 };
const MOCK_MODS = {
server_id: SERVER_ID,
enabled_count: 2,
mods: [
{ name: "@ace", path: "/mods/@ace", size_bytes: 5000000, enabled: true, display_name: "ACE3", workshop_id: "463939057" },
{ name: "@cba_a3", path: "/mods/@cba_a3", size_bytes: 1000000, enabled: true, display_name: "CBA_A3", workshop_id: "450814997" },
{ name: "@task_force_radio", path: "/mods/@task_force_radio", size_bytes: 2000000, enabled: false, display_name: "Task Force Radio", workshop_id: null },
],
};
const MOCK_PLAYERS = {
server_id: SERVER_ID,
player_count: 2,
players: [
{ id: 1, slot_id: "0", name: "PlayerOne", guid: "abc123", ip: "192.168.1.1", ping: 45 },
{ id: 2, slot_id: "1", name: "PlayerTwo", guid: "def456", ip: "192.168.1.2", ping: 88 },
],
};
const MOCK_LOGFILES = [
{ filename: "arma3server_2026-04-17_12-00-00.rpt", size_bytes: 20480, modified_at: 1745092800 },
{ filename: "arma3server_2026-04-16_08-00-00.rpt", size_bytes: 40960, modified_at: 1745006400 },
];
async function setupMocks(page: import("@playwright/test").Page) {
await page.addInitScript(({ token, user }) => {
localStorage.setItem("languard_token", token);
localStorage.setItem("languard-auth", JSON.stringify({ state: { token, user }, version: 0 }));
}, { token: MOCK_TOKEN, user: MOCK_USER });
// Catch-all (lowest priority — register first)
await page.route("**/api/**", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: null, error: null }) }),
);
await page.route("**/api/auth/me", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: MOCK_USER, error: null }) }),
);
await page.route(`**/api/servers/${SERVER_ID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: MOCK_SERVER, error: null }) }),
);
await page.route(`**/api/servers/${SERVER_ID}/config/schema`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: MOCK_CONFIG_SCHEMA, error: null }) }),
);
await page.route(`**/api/servers/${SERVER_ID}/config`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: MOCK_CONFIG, error: null }) }),
);
await page.route(`**/api/servers/${SERVER_ID}/missions`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: MOCK_MISSIONS, error: null }) }),
);
await page.route(`**/api/servers/${SERVER_ID}/missions/rotation`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: MOCK_ROTATION, error: null }) }),
);
await page.route(`**/api/servers/${SERVER_ID}/mods`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: MOCK_MODS, error: null }) }),
);
await page.route(`**/api/servers/${SERVER_ID}/players`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: MOCK_PLAYERS, error: null }) }),
);
await page.route(`**/api/servers/${SERVER_ID}/logfiles`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: MOCK_LOGFILES, error: null }) }),
);
}
test.describe("Server Detail — Overview Tab", () => {
let detailPage: ServerDetailPage;
test.beforeEach(async ({ page }) => {
await setupMocks(page);
detailPage = new ServerDetailPage(page);
await detailPage.goto(SERVER_ID);
});
test("should display server name and status", async ({ page }) => {
await expect(page.locator("text=A3Master").first()).toBeVisible({ timeout: 10_000 });
await expect(page.locator("text=running").first()).toBeVisible();
});
test("should show tab list with all tabs", async () => {
await expect(detailPage.tabBar).toBeVisible({ timeout: 10_000 });
for (const tab of ["Overview", "Config", "Players", "Missions", "Mods", "Logs"]) {
await expect(detailPage.getTab(tab)).toBeVisible();
}
});
});
test.describe("Server Detail — Config Tab (Phase 1)", () => {
let detailPage: ServerDetailPage;
test.beforeEach(async ({ page }) => {
await setupMocks(page);
detailPage = new ServerDetailPage(page);
await detailPage.goto(SERVER_ID);
await detailPage.clickTab("Config");
});
test("should show config fields", async ({ page }) => {
await expect(page.locator("text=Hostname").first()).toBeVisible({ timeout: 10_000 });
});
test("should render BattlEye field label", async ({ page }) => {
// Config fields render their labels regardless of edit mode
// formatLabel("battleye") → "Battleye" or via schema label "BattlEye"
await expect(page.locator("text=Battleye").or(page.locator("text=BattlEye")).first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe("Server Detail — Missions Tab (Phase 2)", () => {
let detailPage: ServerDetailPage;
test.beforeEach(async ({ page }) => {
await setupMocks(page);
detailPage = new ServerDetailPage(page);
await detailPage.goto(SERVER_ID);
await detailPage.clickTab("Missions");
});
test("should list mission files", async ({ page }) => {
// MissionList shows mission.name (not filename) in the table
await expect(page.getByText("co10_example", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("tvt06_test", { exact: true })).toBeVisible();
});
test("should show terrain names", async ({ page }) => {
await expect(page.locator("text=Altis").first()).toBeVisible({ timeout: 10_000 });
await expect(page.locator("text=Stratis").first()).toBeVisible();
});
test("should show upload button", async ({ page }) => {
// Upload is a <label> element acting as a button
await expect(page.locator("label", { hasText: "Upload .pbo" })).toBeVisible({ timeout: 10_000 });
});
});
test.describe("Server Detail — Mods Tab (Phase 3)", () => {
let detailPage: ServerDetailPage;
test.beforeEach(async ({ page }) => {
await setupMocks(page);
detailPage = new ServerDetailPage(page);
await detailPage.goto(SERVER_ID);
await detailPage.clickTab("Mods");
});
test("should list mods with display names", async ({ page }) => {
// Use exact match to avoid strict-mode violations from substrings (e.g. @cba_a3 contains cba_a3)
await expect(page.getByText("ACE3", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("CBA_A3", { exact: true })).toBeVisible();
await expect(page.getByText("Task Force Radio", { exact: true })).toBeVisible();
});
test("should show enabled/disabled state for mods", async ({ page }) => {
await expect(page.locator("text=ACE3")).toBeVisible({ timeout: 10_000 });
// Disabled mod should have a visual indicator
const disabledMod = page.locator('[data-testid*="mod"]').filter({ hasText: "Task Force Radio" });
await expect(disabledMod).toBeVisible({ timeout: 10_000 });
});
});
test.describe("Server Detail — Players Tab (Phase 4)", () => {
let detailPage: ServerDetailPage;
test.beforeEach(async ({ page }) => {
await setupMocks(page);
detailPage = new ServerDetailPage(page);
await detailPage.goto(SERVER_ID);
await detailPage.clickTab("Players");
});
test("should list online players", async ({ page }) => {
await expect(page.locator("text=PlayerOne")).toBeVisible({ timeout: 10_000 });
await expect(page.locator("text=PlayerTwo")).toBeVisible();
});
test("should show ping values", async ({ page }) => {
await expect(page.locator("text=45ms").first()).toBeVisible({ timeout: 10_000 });
});
test("should show kick action for each player", async ({ page }) => {
// Kick buttons are rendered per-row for admins
await expect(page.locator("button", { hasText: "Kick" }).first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe("Server Detail — Logs Tab (Phase 5)", () => {
let detailPage: ServerDetailPage;
test.beforeEach(async ({ page }) => {
await setupMocks(page);
detailPage = new ServerDetailPage(page);
await detailPage.goto(SERVER_ID);
await detailPage.clickTab("Logs");
});
test("should list historical log files", async ({ page }) => {
// "Log Files" section is collapsed by default — click to expand
await page.locator("button", { hasText: "Log Files" }).click();
await expect(
page.locator("text=arma3server_2026-04-17_12-00-00.rpt"),
).toBeVisible({ timeout: 10_000 });
await expect(
page.locator("text=arma3server_2026-04-16_08-00-00.rpt"),
).toBeVisible();
});
test("should show download buttons for log files", async ({ page }) => {
await page.locator("button", { hasText: "Log Files" }).click();
await expect(page.locator("button", { hasText: "Download" }).first()).toBeVisible({ timeout: 10_000 });
});
test("should show live log viewer area", async ({ page }) => {
// The live log output container (pre or div with font-mono) renders even with empty logs
const logArea = page.locator('[data-testid="log-viewer"], pre, [class*="font-mono"]').first();
await expect(logArea).toBeVisible({ timeout: 10_000 });
});
});