- 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)
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
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 });
|
|
});
|
|
});
|