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:
31
frontend/tests-e2e/pages/ServerDetailPage.ts
Normal file
31
frontend/tests-e2e/pages/ServerDetailPage.ts
Normal 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}")`);
|
||||
}
|
||||
}
|
||||
276
frontend/tests-e2e/server-detail/server-detail.spec.ts
Normal file
276
frontend/tests-e2e/server-detail/server-detail.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user