From c48f4e3d7a9eebbb0053009ff8d20e58cf3c442d Mon Sep 17 00:00:00 2001 From: Matt Palmerlee Date: Sun, 3 May 2026 13:17:25 -0700 Subject: [PATCH 01/21] Playwright tests --- apps/astriarch-backend/src/app.ts | 7 ++ .../src/routes/testRoutes.ts | 67 +++++++++++++++ .../astriarch-frontend/e2e/fixtures/player.ts | 40 +++++++++ .../astriarch-frontend/e2e/helpers/cleanup.ts | 27 ++++++ .../e2e/helpers/gameOptions.ts | 45 ++++++++++ apps/astriarch-frontend/e2e/helpers/inGame.ts | 69 +++++++++++++++ apps/astriarch-frontend/e2e/helpers/lobby.ts | 85 +++++++++++++++++++ .../scenarios/scenario-a-human-vs-ai.spec.ts | 54 ++++++++++++ .../scenarios/scenario-b-two-humans.spec.ts | 68 +++++++++++++++ .../scenarios/scenario-c-resume-game.spec.ts | 58 +++++++++++++ .../scenarios/scenario-d-command-flow.spec.ts | 59 +++++++++++++ apps/astriarch-frontend/package.json | 4 + apps/astriarch-frontend/playwright.config.ts | 76 +++++++++++++++++ .../navigation-tab/NavigationTab.svelte | 8 +- .../NavigationTabSelectedSvg.svelte | 3 +- .../NavigationTabUnselectedSvg.svelte | 4 +- .../TopOverviewButton.svelte | 2 +- .../top-overview-item/TopOverviewItem.svelte | 4 +- .../astriarch/top-overview/TopOverview.svelte | 59 ++++++------- .../game-views/FleetCommandView.svelte | 1 + .../lib/components/lobby/GameDetails.svelte | 4 +- .../src/lib/components/lobby/GameList.svelte | 1 + .../components/lobby/GameOptionsView.svelte | 7 +- .../src/lib/components/lobby/LobbyView.svelte | 5 +- .../src/routes/+page.svelte | 9 +- .../src/engine/gameController.spec.ts | 82 +++++++++++++++++- pnpm-lock.yaml | 37 +++++--- 27 files changed, 824 insertions(+), 61 deletions(-) create mode 100644 apps/astriarch-backend/src/routes/testRoutes.ts create mode 100644 apps/astriarch-frontend/e2e/fixtures/player.ts create mode 100644 apps/astriarch-frontend/e2e/helpers/cleanup.ts create mode 100644 apps/astriarch-frontend/e2e/helpers/gameOptions.ts create mode 100644 apps/astriarch-frontend/e2e/helpers/inGame.ts create mode 100644 apps/astriarch-frontend/e2e/helpers/lobby.ts create mode 100644 apps/astriarch-frontend/e2e/scenarios/scenario-a-human-vs-ai.spec.ts create mode 100644 apps/astriarch-frontend/e2e/scenarios/scenario-b-two-humans.spec.ts create mode 100644 apps/astriarch-frontend/e2e/scenarios/scenario-c-resume-game.spec.ts create mode 100644 apps/astriarch-frontend/e2e/scenarios/scenario-d-command-flow.spec.ts create mode 100644 apps/astriarch-frontend/playwright.config.ts diff --git a/apps/astriarch-backend/src/app.ts b/apps/astriarch-backend/src/app.ts index a0e2c5f3..4ea9d9e1 100644 --- a/apps/astriarch-backend/src/app.ts +++ b/apps/astriarch-backend/src/app.ts @@ -13,6 +13,7 @@ import { connectDatabase } from "./database/connection"; import { WebSocketServer } from "./websocket"; import { healthRoutes } from "./routes/healthRoutes"; import { highScoreRoutes } from "./routes/highScoreRoutes"; +import { testRoutes } from "./routes/testRoutes"; import { GameController } from "./controllers/GameControllerWebSocket"; import { logger } from "./utils/logger"; @@ -82,6 +83,12 @@ app.use( app.use("/api/health", healthRoutes); app.use("/api/highscores", highScoreRoutes); +// Test-only routes — only registered in test environment. +if (process.env.NODE_ENV === "test") { + app.use("/api/test", testRoutes); + logger.info("Test routes registered (NODE_ENV=test)"); +} + // 404 handler app.use("*", (req, res) => { res.status(404).json({ error: "Not found" }); diff --git a/apps/astriarch-backend/src/routes/testRoutes.ts b/apps/astriarch-backend/src/routes/testRoutes.ts new file mode 100644 index 00000000..65a8dcba --- /dev/null +++ b/apps/astriarch-backend/src/routes/testRoutes.ts @@ -0,0 +1,67 @@ +/** + * Test-only cleanup routes. + * + * These routes are ONLY registered when NODE_ENV=test and should NEVER + * be reachable in production or staging environments. + * + * POST /api/test/cleanup + * Removes games and sessions whose name starts with the given prefix + * (default "__e2e__") so that E2E test runs start from a clean state. + */ + +import { Router, Request, Response } from "express"; +import mongoose from "mongoose"; +import { ServerGameModel } from "../models/Game"; +import { logger } from "../utils/logger"; + +const router = Router(); + +router.post("/cleanup", async (req: Request, res: Response) => { + const prefix: string = (req.body?.prefix as string) || "__e2e__"; + + // Extra safety guard — only allow the e2e prefix pattern. + if (!prefix.startsWith("__e2e__")) { + res.status(400).json({ error: "Invalid prefix: must start with '__e2e__'" }); + return; + } + + try { + // Remove games whose name starts with the prefix. + const gameResult = await ServerGameModel.deleteMany({ + name: { $regex: `^${escapeRegex(prefix)}` }, + }); + + // Remove connect-mongo sessions (stored in the 'sessions' collection). + const db = mongoose.connection.db; + let sessionCount = 0; + if (db) { + try { + const sessionResult = await db + .collection("sessions") + .deleteMany({ "session.e2eTestMarker": prefix }); + sessionCount = sessionResult.deletedCount ?? 0; + } catch { + // Sessions collection may not exist yet — not a fatal error. + } + } + + logger.info( + `[test/cleanup] Deleted ${gameResult.deletedCount} game(s) and ${sessionCount} session(s) with prefix "${prefix}"` + ); + + res.json({ + ok: true, + gamesDeleted: gameResult.deletedCount, + sessionsDeleted: sessionCount, + }); + } catch (error) { + logger.error("[test/cleanup] Cleanup failed:", error); + res.status(500).json({ error: "Cleanup failed" }); + } +}); + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export { router as testRoutes }; diff --git a/apps/astriarch-frontend/e2e/fixtures/player.ts b/apps/astriarch-frontend/e2e/fixtures/player.ts new file mode 100644 index 00000000..4ccd4bf8 --- /dev/null +++ b/apps/astriarch-frontend/e2e/fixtures/player.ts @@ -0,0 +1,40 @@ +/** + * Player fixture — wraps an isolated browser context modelling one human player. + * + * Usage: + * test('...', async ({ playerOne, playerTwo }) => { ... }); + * + * Each player gets its own cookie jar and localStorage, so multiple players can + * run in the same Playwright worker without their sessions colliding. + */ + +import { test as base, type BrowserContext, type Page } from '@playwright/test'; + +export interface Player { + context: BrowserContext; + page: Page; + name: string; +} + +interface PlayerFixtures { + playerOne: Player; + playerTwo: Player; +} + +export const test = base.extend({ + playerOne: async ({ browser }, use) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await use({ context, page, name: 'TestPlayer1' }); + await context.close(); + }, + + playerTwo: async ({ browser }, use) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await use({ context, page, name: 'TestPlayer2' }); + await context.close(); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/apps/astriarch-frontend/e2e/helpers/cleanup.ts b/apps/astriarch-frontend/e2e/helpers/cleanup.ts new file mode 100644 index 00000000..d05c065f --- /dev/null +++ b/apps/astriarch-frontend/e2e/helpers/cleanup.ts @@ -0,0 +1,27 @@ +/** + * Test cleanup helper — calls the backend test-cleanup endpoint to remove + * games and sessions created by E2E tests, keeping runs isolated. + */ + +const BACKEND_PORT = process.env.TEST_BACKEND_PORT ?? '8002'; +const CLEANUP_URL = `http://localhost:${BACKEND_PORT}/api/test/cleanup`; + +export async function cleanupTestData(prefix = '__e2e__'): Promise { + try { + const res = await fetch(CLEANUP_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prefix }), + }); + if (!res.ok) { + console.warn(`[e2e cleanup] HTTP ${res.status} — test data may not have been cleaned up.`); + } + } catch (err) { + console.warn('[e2e cleanup] Could not reach cleanup endpoint:', err); + } +} + +/** Unique game name scoped to the current test run, with the e2e prefix. */ +export function testGameName(suffix: string): string { + return `__e2e__${suffix}_${Date.now()}`; +} diff --git a/apps/astriarch-frontend/e2e/helpers/gameOptions.ts b/apps/astriarch-frontend/e2e/helpers/gameOptions.ts new file mode 100644 index 00000000..c9416d8f --- /dev/null +++ b/apps/astriarch-frontend/e2e/helpers/gameOptions.ts @@ -0,0 +1,45 @@ +/** + * Game-options screen helpers. + */ + +import type { Page } from '@playwright/test'; + +const DEFAULT_TIMEOUT = 10_000; + +/** + * Opponent type values matching OpponentOptionType in the engine. + * -2 = Closed, -1 = Open, 0 = Human, 1 = Easy, 2 = Normal, 3 = Hard, 4 = Expert + */ +export const OpponentType = { + CLOSED: '-2', + OPEN: '-1', + HUMAN: '0', + EASY_COMPUTER: '1', + NORMAL_COMPUTER: '2', + HARD_COMPUTER: '3', + EXPERT_COMPUTER: '4', +} as const; + +/** Set a player slot (1-indexed, slot 1 = second player position) to the given opponent type. */ +export async function setOpponentSlot( + page: Page, + slotIndex: number, + type: string +): Promise { + const select = page.locator(`[data-testid="opponent-slot-${slotIndex}"]`); + await select.waitFor({ timeout: DEFAULT_TIMEOUT }); + await select.selectOption(type); + // Allow the options-change to propagate to the server. + await page.waitForTimeout(300); +} + +/** Click Start Game and wait for the in-game view. */ +export async function startGame(page: Page): Promise { + await page.click('[data-testid="start-game-btn"]'); + await page.waitForSelector('[data-testid="view-game"]', { timeout: 20_000 }); +} + +/** Wait for the game-options view to be visible. */ +export async function waitForGameOptions(page: Page): Promise { + await page.waitForSelector('[data-testid="view-game-options"]', { timeout: DEFAULT_TIMEOUT }); +} diff --git a/apps/astriarch-frontend/e2e/helpers/inGame.ts b/apps/astriarch-frontend/e2e/helpers/inGame.ts new file mode 100644 index 00000000..1cc02972 --- /dev/null +++ b/apps/astriarch-frontend/e2e/helpers/inGame.ts @@ -0,0 +1,69 @@ +/** + * In-game helpers — waiting for server acknowledgments and triggering game actions. + */ + +import type { Page } from '@playwright/test'; + +const DEFAULT_TIMEOUT = 15_000; + +/** + * Wait for the in-game view to be fully loaded (planet list or galaxy canvas visible). + */ +export async function waitForGameView(page: Page): Promise { + await page.waitForSelector('[data-testid="view-game"]', { timeout: DEFAULT_TIMEOUT }); +} + +/** + * Wait until no error/desync notification is shown. + * Returns immediately if the notification is already absent. + */ +export async function assertNoDesyncError(page: Page): Promise { + // Give the UI a moment to surface any error that may already be in-flight. + await page.waitForTimeout(500); + const errorNotification = page.locator('[data-testid="notification-error"]'); + const count = await errorNotification.count(); + if (count > 0) { + const text = await errorNotification.first().innerText(); + throw new Error(`Unexpected error notification visible: "${text}"`); + } +} + +/** + * Wait for a COMMAND_ACK to arrive (tracked via DOM attribute set by the store). + * Falls back to a short wait if the attribute mechanism isn't wired yet. + */ +export async function waitForCommandAck(page: Page, commandId?: string): Promise { + if (commandId) { + await page.waitForFunction( + (id) => { + const el = document.querySelector(`[data-command-acked="${id}"]`); + return !!el; + }, + commandId, + { timeout: DEFAULT_TIMEOUT } + ); + } else { + // Generic wait — enough time for a round trip at normal game speed. + await page.waitForTimeout(2_000); + } +} + +/** + * Send ships via the Fleet Command view UI. + * Requires the source planet to already be selected in the galaxy canvas. + */ +export async function sendShipsViaUI(page: Page): Promise { + const sendBtn = page.locator('[data-testid="send-ships-btn"]'); + await sendBtn.waitFor({ timeout: DEFAULT_TIMEOUT }); + await sendBtn.click(); +} + +/** + * Navigate to the Fleet Command view tab. + */ +export async function openFleetCommandView(page: Page): Promise { + // The nav tabs are SVG paths with role="button" and aria-label matching the tab label. + const fleetTab = page.getByRole('button', { name: 'Fleets' }); + await fleetTab.waitFor({ timeout: DEFAULT_TIMEOUT }); + await fleetTab.click(); +} diff --git a/apps/astriarch-frontend/e2e/helpers/lobby.ts b/apps/astriarch-frontend/e2e/helpers/lobby.ts new file mode 100644 index 00000000..ce46d7f6 --- /dev/null +++ b/apps/astriarch-frontend/e2e/helpers/lobby.ts @@ -0,0 +1,85 @@ +/** + * Lobby helpers — reusable actions for the lobby and game-options screens. + */ + +import type { Page } from '@playwright/test'; + +const DEFAULT_TIMEOUT = 15_000; + +/** Navigate to the app root and wait for the lobby to be visible. */ +export async function openLobby(page: Page): Promise { + await page.goto('/'); + // Wait for Vite module graph to finish loading so SvelteKit has hydrated + // and onclick handlers are attached before we click. + await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); + // The main page shows a "Play Now" button to enter the multiplayer lobby. + // Try testid first, fall back to role+name if the testid isn't in the DOM. + const lobbyBtn = page + .locator('[data-testid="show-lobby-btn"]') + .or(page.getByRole('button', { name: 'Play Now' })); + await lobbyBtn.waitFor({ timeout: 10_000 }); + await lobbyBtn.click(); + await page.waitForSelector('[data-testid="view-lobby"]', { timeout: DEFAULT_TIMEOUT }); +} + +/** Wait for the WebSocket connection indicator to show connected. */ +export async function waitForConnected(page: Page): Promise { + // The lobby hides the "Connecting…" banner once connected. + // We wait until it is gone rather than asserting a positive state, + // since the connected state is implied by the lobby being interactive. + await page.waitForFunction( + () => !document.querySelector('[data-testid="connection-status"]'), + { timeout: DEFAULT_TIMEOUT } + ); +} + +/** Create a new game and wait for the game-options screen. Returns the page. */ +export async function createGame(page: Page, playerName: string): Promise { + // Set player name via the name input in the lobby header area if visible. + const nameInput = page.locator('[data-testid="lobby-player-name-input"]'); + if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await nameInput.fill(playerName); + } + + await page.click('[data-testid="create-game-btn"]'); + await page.waitForSelector('[data-testid="view-game-options"]', { timeout: DEFAULT_TIMEOUT }); + + // Fill the player name inside game options if it's there. + const optionsNameInput = page.locator('[data-testid="player-name-input"]'); + if (await optionsNameInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await optionsNameInput.fill(playerName); + await optionsNameInput.blur(); + } +} + +/** Select a game from the lobby list by partial name and wait for the details panel. */ +export async function selectGameByName(page: Page, partialName: string): Promise { + const item = page.locator('[data-testid^="game-item-"]', { hasText: partialName }); + await item.waitFor({ timeout: DEFAULT_TIMEOUT }); + await item.click(); +} + +/** Click Join Game in the details panel. */ +export async function joinGame(page: Page, playerName: string): Promise { + const optionsNameInput = page.locator('[data-testid="player-name-input"]'); + + // Fill name if the name input is visible in the details panel + const detailsNameInput = page.locator('[data-testid="lobby-player-name-input"]'); + if (await detailsNameInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await detailsNameInput.fill(playerName); + } + + await page.click('[data-testid="join-game-btn"]'); + await page.waitForSelector('[data-testid="view-game-options"]', { timeout: DEFAULT_TIMEOUT }); + + if (await optionsNameInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await optionsNameInput.fill(playerName); + await optionsNameInput.blur(); + } +} + +/** Click Resume Game in the details panel and wait for the in-game view. */ +export async function resumeGame(page: Page): Promise { + await page.click('[data-testid="resume-game-btn"]'); + await page.waitForSelector('[data-testid="view-game"]', { timeout: DEFAULT_TIMEOUT }); +} diff --git a/apps/astriarch-frontend/e2e/scenarios/scenario-a-human-vs-ai.spec.ts b/apps/astriarch-frontend/e2e/scenarios/scenario-a-human-vs-ai.spec.ts new file mode 100644 index 00000000..11b5862b --- /dev/null +++ b/apps/astriarch-frontend/e2e/scenarios/scenario-a-human-vs-ai.spec.ts @@ -0,0 +1,54 @@ +/** + * Scenario A — 1 human player vs AI opponents + * + * Flow: + * 1. Open lobby and wait for WebSocket connection. + * 2. Create a game with the E2E naming prefix. + * 3. Set at least one opponent slot to Normal Computer. + * 4. Start the game. + * 5. Assert the in-game view is shown and no desync error surfaces. + */ + +import { test, expect } from '@playwright/test'; +import { openLobby, waitForConnected, createGame } from '../helpers/lobby'; +import { setOpponentSlot, startGame, OpponentType } from '../helpers/gameOptions'; +import { waitForGameView, assertNoDesyncError } from '../helpers/inGame'; +import { cleanupTestData, testGameName } from '../helpers/cleanup'; + +test.beforeEach(async () => { + await cleanupTestData(); +}); + +test.afterEach(async () => { + await cleanupTestData(); +}); + +test('create game, set AI opponent, start game, reach in-game view', async ({ page }) => { + const gameName = testGameName('scenarioA'); + + await openLobby(page); + await waitForConnected(page); + + // Create game — sets player name and transitions to game-options view. + await createGame(page, 'TestPlayer1'); + + // Set the game name via the input on the game-options screen. + const gameNameInput = page.locator('[data-testid="game-name-input"]'); + if (await gameNameInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await gameNameInput.fill(gameName); + await gameNameInput.blur(); + } + + // Configure at least one opponent as a computer player. + await setOpponentSlot(page, 1, OpponentType.NORMAL_COMPUTER); + + // Start the game and wait for the in-game view. + await startGame(page); + + // Verify we are in the game. + await waitForGameView(page); + await expect(page.locator('[data-testid="view-game"]')).toBeVisible(); + + // Assert no desync/error notifications. + await assertNoDesyncError(page); +}); diff --git a/apps/astriarch-frontend/e2e/scenarios/scenario-b-two-humans.spec.ts b/apps/astriarch-frontend/e2e/scenarios/scenario-b-two-humans.spec.ts new file mode 100644 index 00000000..67fbac5f --- /dev/null +++ b/apps/astriarch-frontend/e2e/scenarios/scenario-b-two-humans.spec.ts @@ -0,0 +1,68 @@ +/** + * Scenario B — 2 human players in the same game + * + * Flow: + * 1. Player 1 creates a game with an open second slot. + * 2. Player 2 (separate browser context) joins that game. + * 3. Player 1 (host) starts the game. + * 4. Both players reach the in-game view with distinct identities. + */ + +import { expect } from '@playwright/test'; +import { test } from '../fixtures/player'; +import { openLobby, waitForConnected, createGame, selectGameByName, joinGame } from '../helpers/lobby'; +import { setOpponentSlot, startGame, waitForGameOptions, OpponentType } from '../helpers/gameOptions'; +import { waitForGameView, assertNoDesyncError } from '../helpers/inGame'; +import { cleanupTestData, testGameName } from '../helpers/cleanup'; + +test.beforeEach(async () => { + await cleanupTestData(); +}); + +test.afterEach(async () => { + await cleanupTestData(); +}); + +test('two human players create, join, and both reach in-game view', async ({ + playerOne, + playerTwo, +}) => { + const gameName = testGameName('scenarioB'); + + // --- Player 1: create game --- + await openLobby(playerOne.page); + await waitForConnected(playerOne.page); + await createGame(playerOne.page, playerOne.name); + + // Set the game name. + const gameNameInput = playerOne.page.locator('[data-testid="game-name-input"]'); + if (await gameNameInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await gameNameInput.fill(gameName); + await gameNameInput.blur(); + } + + // Keep slot 1 as Open so player 2 can join. + await setOpponentSlot(playerOne.page, 1, OpponentType.OPEN); + + // --- Player 2: join the game --- + await openLobby(playerTwo.page); + await waitForConnected(playerTwo.page); + await selectGameByName(playerTwo.page, gameName); + await joinGame(playerTwo.page, playerTwo.name); + await waitForGameOptions(playerTwo.page); + + // --- Player 1 (host): start the game --- + await startGame(playerOne.page); + + // --- Both players should reach the in-game view --- + await Promise.all([ + waitForGameView(playerOne.page), + waitForGameView(playerTwo.page), + ]); + + await expect(playerOne.page.locator('[data-testid="view-game"]')).toBeVisible(); + await expect(playerTwo.page.locator('[data-testid="view-game"]')).toBeVisible(); + + await assertNoDesyncError(playerOne.page); + await assertNoDesyncError(playerTwo.page); +}); diff --git a/apps/astriarch-frontend/e2e/scenarios/scenario-c-resume-game.spec.ts b/apps/astriarch-frontend/e2e/scenarios/scenario-c-resume-game.spec.ts new file mode 100644 index 00000000..bc5af635 --- /dev/null +++ b/apps/astriarch-frontend/e2e/scenarios/scenario-c-resume-game.spec.ts @@ -0,0 +1,58 @@ +/** + * Scenario C — Resume an in-progress game + * + * Flow: + * 1. Create and start a 1-human-vs-AI game. + * 2. Confirm the in-game view is shown. + * 3. Navigate away (simulate a disconnect/reload by going to '/'). + * 4. Return to the lobby — the in-progress game should appear. + * 5. Resume the game and confirm the in-game view is restored. + */ + +import { test, expect } from '@playwright/test'; +import { openLobby, waitForConnected, createGame, selectGameByName, resumeGame } from '../helpers/lobby'; +import { setOpponentSlot, startGame, OpponentType } from '../helpers/gameOptions'; +import { waitForGameView, assertNoDesyncError } from '../helpers/inGame'; +import { cleanupTestData, testGameName } from '../helpers/cleanup'; + +test.beforeEach(async () => { + await cleanupTestData(); +}); + +test.afterEach(async () => { + await cleanupTestData(); +}); + +test('start a game, return to lobby, resume and reach in-game view', async ({ page }) => { + const gameName = testGameName('scenarioC'); + + // --- Start a game --- + await openLobby(page); + await waitForConnected(page); + await createGame(page, 'TestPlayer1'); + + const gameNameInput = page.locator('[data-testid="game-name-input"]'); + if (await gameNameInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await gameNameInput.fill(gameName); + await gameNameInput.blur(); + } + + await setOpponentSlot(page, 1, OpponentType.NORMAL_COMPUTER); + await startGame(page); + await waitForGameView(page); + + // --- Simulate disconnect by reloading --- + await page.reload(); + + // After reload the app returns to the main/welcome screen. + // Re-open the lobby so the in-progress game list is visible. + await openLobby(page); + + // The in-progress game should appear in the games list. + await selectGameByName(page, gameName); + + // Resume game and confirm view restoration. + await resumeGame(page); + await expect(page.locator('[data-testid="view-game"]')).toBeVisible(); + await assertNoDesyncError(page); +}); diff --git a/apps/astriarch-frontend/e2e/scenarios/scenario-d-command-flow.spec.ts b/apps/astriarch-frontend/e2e/scenarios/scenario-d-command-flow.spec.ts new file mode 100644 index 00000000..c974396a --- /dev/null +++ b/apps/astriarch-frontend/e2e/scenarios/scenario-d-command-flow.spec.ts @@ -0,0 +1,59 @@ +/** + * Scenario D — Basic in-game command flow + * + * Flow: + * 1. Start a 1-human-vs-AI game. + * 2. Wait for the in-game view to stabilise. + * 3. Verify that the GAME_COMMAND pipeline is functional: + * - No desync/error notifications are shown after the initial state sync. + * 4. Navigate to the Fleet Command view. + * 5. Assert the Send Ships button is present (verifying the fleet UI rendered). + * + * Note: Triggering an actual send-ships command requires selecting a planet and + * destination in the canvas, which needs additional test setup around the Konva + * canvas. This test focuses on verifying the command pipeline is wired correctly + * at the UI level without full canvas interaction. + */ + +import { test, expect } from '@playwright/test'; +import { openLobby, waitForConnected, createGame } from '../helpers/lobby'; +import { setOpponentSlot, startGame, OpponentType } from '../helpers/gameOptions'; +import { waitForGameView, assertNoDesyncError, openFleetCommandView } from '../helpers/inGame'; +import { cleanupTestData, testGameName } from '../helpers/cleanup'; + +test.beforeEach(async () => { + await cleanupTestData(); +}); + +test.afterEach(async () => { + await cleanupTestData(); +}); + +test('start game, verify no desync, fleet command UI is reachable', async ({ page }) => { + const gameName = testGameName('scenarioD'); + + await openLobby(page); + await waitForConnected(page); + await createGame(page, 'TestPlayer1'); + + const gameNameInput = page.locator('[data-testid="game-name-input"]'); + if (await gameNameInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await gameNameInput.fill(gameName); + await gameNameInput.blur(); + } + + await setOpponentSlot(page, 1, OpponentType.NORMAL_COMPUTER); + await startGame(page); + await waitForGameView(page); + + // Allow initial CLIENT_EVENT messages to arrive and settle. + await page.waitForTimeout(2_000); + + // No error/desync notification should appear after initial sync. + await assertNoDesyncError(page); + + // Navigate to the Fleet Command view and assert the Send Ships button exists. + await openFleetCommandView(page); + const sendShipsBtn = page.locator('[data-testid="send-ships-btn"]'); + await expect(sendShipsBtn).toBeVisible({ timeout: 10_000 }); +}); diff --git a/apps/astriarch-frontend/package.json b/apps/astriarch-frontend/package.json index 8aa8eb7f..dd456aff 100644 --- a/apps/astriarch-frontend/package.json +++ b/apps/astriarch-frontend/package.json @@ -14,6 +14,9 @@ "lint": "prettier --check . && eslint .", "test:unit": "vitest", "test": "npm run test:unit -- --run", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:ui": "playwright test --ui", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, @@ -26,6 +29,7 @@ "@eslint/js": "^9.18.0", "@internationalized/date": "^3.8.2", "@lucide/svelte": "^0.515.0", + "@playwright/test": "^1.59.1", "@storybook/addon-svelte-csf": "^5.0.6", "@storybook/sveltekit": "^9.0.16", "@sveltejs/adapter-auto": "^6.1.1", diff --git a/apps/astriarch-frontend/playwright.config.ts b/apps/astriarch-frontend/playwright.config.ts new file mode 100644 index 00000000..948049ac --- /dev/null +++ b/apps/astriarch-frontend/playwright.config.ts @@ -0,0 +1,76 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for Astriarch E2E tests. + * + * Prerequisites (run before `test:e2e`): + * docker-compose up -d mongodb (or have Mongo already running on port 27017) + * + * The config starts both the backend and the frontend dev servers automatically + * when running Playwright, so you do NOT need to start them manually. + * + * Port assignments (deliberately non-overlapping with normal dev ports): + * Frontend : http://localhost:4173 (Vite preview) + * Backend : http://localhost:8002 (test-only instance) + * + * Environment variables used: + * PUBLIC_BACKEND_HTTP_URL – backend HTTP base URL for the frontend + * PUBLIC_BACKEND_WS_URL – backend WS URL for the frontend + * TEST_BACKEND_PORT – port the backend listens on (default 8002) + */ + +const BACKEND_PORT = process.env.TEST_BACKEND_PORT ?? '8002'; +const BACKEND_HTTP = `http://localhost:${BACKEND_PORT}`; +const BACKEND_WS = `ws://localhost:${BACKEND_PORT}`; +const FRONTEND_PORT = '4173'; +const FRONTEND_URL = `http://localhost:${FRONTEND_PORT}`; + +export default defineConfig({ + testDir: './e2e', + // Run scenario files in parallel; tests within a file run serially by default. + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['html', { open: 'never' }], ['list']], + + use: { + baseURL: FRONTEND_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: [ + // 1. Backend — started first so it is ready when the frontend connects. + { + command: `PORT=${BACKEND_PORT} NODE_ENV=test pnpm --filter astriarch-backend dev`, + url: `${BACKEND_HTTP}/api/health`, + timeout: 30_000, + reuseExistingServer: !process.env.CI, + env: { + PORT: BACKEND_PORT, + WS_PORT: BACKEND_PORT, + NODE_ENV: 'test', + // Inherit Mongo connection from environment or fall back to local dev DB. + MONGODB_CONNECTION_STRING: + process.env.MONGODB_CONNECTION_STRING ?? + 'mongodb://localhost:27017/astriarch_v2_test', + }, + }, + // 2. Frontend — uses Vite dev server on a dedicated port, pointed at the test backend. + { + command: `PUBLIC_BACKEND_HTTP_URL=${BACKEND_HTTP} PUBLIC_BACKEND_WS_URL=${BACKEND_WS} pnpm --filter astriarch-frontend dev --port ${FRONTEND_PORT}`, + url: FRONTEND_URL, + timeout: 30_000, + reuseExistingServer: !process.env.CI, + }, + ], +}); diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTab.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTab.svelte index 849b7f40..d580c1c5 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTab.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTab.svelte @@ -25,9 +25,9 @@ {#if selected} - + {:else} - + {/if} {:else} @@ -41,9 +41,9 @@ {#if selected} - + {:else} - + {/if} {/if} diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTabSelectedSvg.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTabSelectedSvg.svelte index 9bd1ad7e..7f16aead 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTabSelectedSvg.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTabSelectedSvg.svelte @@ -2,9 +2,10 @@ interface Props { onclick?: () => void; scale?: number; + ariaLabel?: string; } - let { onclick, scale = 1 }: Props = $props(); + let { onclick, scale = 1, ariaLabel }: Props = $props(); const width = 241 * scale; const height = 48 * scale; diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTabUnselectedSvg.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTabUnselectedSvg.svelte index 9fcc5623..feef3a4a 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTabUnselectedSvg.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/navigation-tab/NavigationTabUnselectedSvg.svelte @@ -2,9 +2,10 @@ interface Props { onclick?: () => void; scale?: number; + ariaLabel?: string; } - let { onclick, scale = 1 }: Props = $props(); + let { onclick, scale = 1, ariaLabel }: Props = $props(); const width = 239 * scale; const height = 48 * scale; @@ -23,6 +24,7 @@ onkeydown={(e) => e.key === 'Enter' && onclick?.()} role="button" tabindex="0" + aria-label={ariaLabel} opacity="0.9" d="M61.0641 44.7726L4.92509 2H174.3C175.613 2 176.891 2.43121 177.936 3.22741L234.075 46H64.7004C63.3866 46 62.1091 45.5688 61.0641 44.7726ZM236.431 47.7954C236.43 47.7947 236.429 47.7939 236.428 47.7932L236.431 47.7954Z" fill="#1B1F25" diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/top-overview-button/TopOverviewButton.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/top-overview-button/TopOverviewButton.svelte index 58e07959..b1e5b2c3 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/top-overview-button/TopOverviewButton.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/top-overview-button/TopOverviewButton.svelte @@ -20,7 +20,7 @@ } -
+
+ {/if} {#if canResumeGame(game)} - + {/if} {#if game.status === 'completed'} diff --git a/apps/astriarch-frontend/src/lib/components/lobby/GameList.svelte b/apps/astriarch-frontend/src/lib/components/lobby/GameList.svelte index 01f73d33..11acda49 100644 --- a/apps/astriarch-frontend/src/lib/components/lobby/GameList.svelte +++ b/apps/astriarch-frontend/src/lib/components/lobby/GameList.svelte @@ -50,6 +50,7 @@ {#each games as game (game._id)}
handleGameClick(game)} on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/apps/astriarch-frontend/src/lib/components/lobby/GameOptionsView.svelte b/apps/astriarch-frontend/src/lib/components/lobby/GameOptionsView.svelte index f8d48059..9fd37fed 100644 --- a/apps/astriarch-frontend/src/lib/components/lobby/GameOptionsView.svelte +++ b/apps/astriarch-frontend/src/lib/components/lobby/GameOptionsView.svelte @@ -192,6 +192,7 @@ {#if isHost} {#if isHost} @@ -372,7 +368,7 @@ disabled={!isHost} class="form-select" > - {#each galaxySizeOptions as option} + {#each galaxySizeOptions as option (option.value)} {/each} @@ -391,7 +387,7 @@ disabled={!isHost} class="form-select" > - {#each systemsOptions as option} + {#each systemsOptions as option (option.value)} {/each} @@ -410,7 +406,7 @@ disabled={!isHost} class="form-select" > - {#each planetsPerSystemOptions as option} + {#each planetsPerSystemOptions as option (option.value)} {/each} diff --git a/apps/astriarch-frontend/src/lib/components/lobby/LobbyView.svelte b/apps/astriarch-frontend/src/lib/components/lobby/LobbyView.svelte index b8e92c9c..7e91912e 100644 --- a/apps/astriarch-frontend/src/lib/components/lobby/LobbyView.svelte +++ b/apps/astriarch-frontend/src/lib/components/lobby/LobbyView.svelte @@ -12,13 +12,11 @@ let selectedGame: IGame | null = null; let isConnected = false; let currentView = 'lobby'; // 'lobby' | 'game_options' | 'game' - let gameJoined = false; let gameId = ''; let currentGame: IGame | null = null; let currentPlayerName = ''; let currentPlayerPosition = 0; let isCurrentPlayerHost = false; - let didCreateCurrentGame = false; // Track if we created the current game let unsubscribeWebSocket: (() => void) | null = null; @@ -38,7 +36,6 @@ // Handle view transitions currentView = state.currentView; - gameJoined = state.gameJoined; gameId = state.gameId || ''; // Find current game if we have a gameId @@ -97,8 +94,6 @@ function handleJoinGame(event: CustomEvent) { const game = event.detail; - // Track that we are joining (not creating) this game - didCreateCurrentGame = false; webSocketService.joinGame(game._id); } @@ -118,9 +113,6 @@ return; } - // Track that we are creating this game (so we know we're the host) - didCreateCurrentGame = true; - // Create game with default options - this will trigger transition to game_options view const defaultGameOptions = getDefaultServerGameOptions({}); @@ -138,7 +130,6 @@ multiplayerGameStore.setGameId(null); multiplayerGameStore.setPlayerPosition(null); // Reset player position currentGame = null; - didCreateCurrentGame = false; // Reset the flag requestGamesList(); } diff --git a/apps/astriarch-frontend/src/lib/services/websocket.ts b/apps/astriarch-frontend/src/lib/services/websocket.ts index 6a0311c6..5cfbee1c 100644 --- a/apps/astriarch-frontend/src/lib/services/websocket.ts +++ b/apps/astriarch-frontend/src/lib/services/websocket.ts @@ -1281,6 +1281,7 @@ class WebSocketService { event.type === ClientEventType.PLANET_CAPTURED || event.type === ClientEventType.FLEET_ATTACK_FAILED ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const attackingFleetId = (event.data as any)?.attackingFleetId; if (attackingFleetId !== undefined) { gameActions.clearPendingArrivalFleet(attackingFleetId); @@ -1291,6 +1292,7 @@ class WebSocketService { ) { // For defender events, also clear any pending fleet with matching attackingFleetId // (unlikely to have one, but be thorough) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const attackingFleetId = (event.data as any)?.attackingFleetId; if (attackingFleetId !== undefined) { gameActions.clearPendingArrivalFleet(attackingFleetId); diff --git a/apps/astriarch-frontend/src/lib/utils/notificationUtils.ts b/apps/astriarch-frontend/src/lib/utils/notificationUtils.ts index 7573034a..cb1c9f50 100644 --- a/apps/astriarch-frontend/src/lib/utils/notificationUtils.ts +++ b/apps/astriarch-frontend/src/lib/utils/notificationUtils.ts @@ -119,6 +119,7 @@ export function convertClientNotificationToUINotification( break; } default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any console.warn('Unknown ClientNotificationType:', (notification as any).type); return null; } diff --git a/apps/astriarch-frontend/src/routes/+page.svelte b/apps/astriarch-frontend/src/routes/+page.svelte index 81c9699d..541ec15b 100644 --- a/apps/astriarch-frontend/src/routes/+page.svelte +++ b/apps/astriarch-frontend/src/routes/+page.svelte @@ -6,8 +6,7 @@ resourceData, population, gameTime, - isGameRunning, - gameActions + isGameRunning } from '$lib/stores/gameStore'; import { multiplayerGameStore } from '$lib/stores/multiplayerGameStore'; import type { MultiplayerGameState } from '$lib/stores/multiplayerGameStore'; @@ -26,8 +25,7 @@ TopOverview, NavigationController, Button, - Text, - Notification + Text } from '$lib/components/astriarch'; import NotificationItem from '$lib/components/astriarch/notification/NotificationItem.svelte'; import TaskNotificationPanel from '$lib/components/astriarch/notification/TaskNotificationPanel.svelte'; @@ -52,6 +50,7 @@ import { webSocketService } from '$lib/services/websocket'; // Dynamically import GalaxyCanvas to avoid SSR issues with Konva + // eslint-disable-next-line @typescript-eslint/no-explicit-any let GalaxyCanvas: any = $state(null); // UI state @@ -278,68 +277,6 @@ console.log('Astriarch game component destroyed'); }); - // Helper functions for notifications - function getNotificationColor(type: string): string { - switch (type) { - case 'research': - return '#3B82F6'; // Blue - case 'construction': - return '#10B981'; // Green - case 'battle': - return '#EF4444'; // Red - case 'planet': - return '#8B5CF6'; // Purple - case 'fleet': - return '#06B6D4'; // Cyan - case 'warning': - return '#F59E0B'; // Yellow - case 'error': - return '#EF4444'; // Red - case 'success': - return '#10B981'; // Green - case 'chat': - return '#EC4899'; // Pink - default: - return '#00FFFF'; // Cyan - } - } - - function getNotificationTypeLabel(type: string): string { - switch (type) { - case 'research': - return 'RESEARCH'; - case 'construction': - return 'CONSTRUCTION'; - case 'battle': - return 'BATTLE'; - case 'planet': - return 'PLANET'; - case 'fleet': - return 'FLEET'; - case 'warning': - return 'WARNING'; - case 'error': - return 'ERROR'; - case 'success': - return 'SUCCESS'; - case 'chat': - return 'CHAT'; - default: - return 'INFO'; - } - } - - function formatTimestamp(timestamp: number): string { - const now = Date.now(); - const diff = now - timestamp; - const seconds = Math.floor(diff / 1000); - - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - return `${hours}h`; - } @@ -596,7 +533,7 @@ ? `right: calc(600px + 1rem); bottom: 1rem;` : undefined} > - {#each $notifications.slice(-5) as notification, i (notification.id)} + {#each $notifications.slice(-5) as notification (notification.id)}

Size Variations

- {#each [16, 24, 32, 48] as size} + {#each [16, 24, 32, 48] as size (size)}
{size}px Icons
- {#each allIconTypes.slice(0, 8) as iconType} + {#each allIconTypes.slice(0, 8) as iconType (iconType)}
- {#each allIconTypes as iconType} + {#each allIconTypes as iconType (iconType)}