diff --git a/.github/workflows/code-checks.yml b/.github/workflows/code-checks.yml new file mode 100644 index 00000000..f4cb4a3b --- /dev/null +++ b/.github/workflows/code-checks.yml @@ -0,0 +1,92 @@ +name: Code Checks + +on: + pull_request: + push: + branches: + - master + +concurrency: + group: code-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: Lint, Format, Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint check + run: pnpm run lint:check + + - name: Format check + run: pnpm run format:check + + - name: Unit tests + run: pnpm run test + + e2e-frontend: + name: Frontend E2E Tests + runs-on: ubuntu-latest + needs: checks + + services: + mongodb: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --eval 'db.adminCommand({ ping: 1 })' || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + run: pnpm --filter astriarch-frontend exec playwright install --with-deps chromium + + - name: Run frontend E2E + env: + TEST_BACKEND_PORT: 8002 + MONGODB_CONNECTION_STRING: mongodb://127.0.0.1:27017/astriarch_v2_test + run: pnpm --filter astriarch-frontend run test:e2e + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + apps/astriarch-frontend/playwright-report + apps/astriarch-frontend/test-results + if-no-files-found: ignore diff --git a/README.md b/README.md index 88ee5f2c..a1ee6ad8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,29 @@ Run Frontend and Backend by navigating to the proper directory and run: then visit `http://localhost:5173` +## E2E Testing + +Playwright end-to-end tests live in the frontend workspace and run against a real frontend + backend + MongoDB stack. + +Prerequisites: + +- Node.js 18+ +- `pnpm install` +- `npx playwright install chromium` +- MongoDB running locally (`docker-compose up -d mongodb`) + +Run the suite: + +- `pnpm --filter astriarch-frontend test:e2e` +- `pnpm --filter astriarch-frontend test:e2e:headed` +- `pnpm --filter astriarch-frontend test:e2e:ui` + +Notes: + +- The Playwright config starts a dedicated test backend on port `8002` and frontend on port `4173`. +- Test cleanup is performed via a test-only backend endpoint (`/api/test/cleanup`) that is enabled only when `NODE_ENV=test`. +- Add new scenarios under `apps/astriarch-frontend/e2e/scenarios` and reuse fixtures/helpers from `apps/astriarch-frontend/e2e/fixtures` and `apps/astriarch-frontend/e2e/helpers`. + ## Overview diff --git a/apps/astriarch-backend/jest.config.js b/apps/astriarch-backend/jest.config.js new file mode 100644 index 00000000..72fee35c --- /dev/null +++ b/apps/astriarch-backend/jest.config.js @@ -0,0 +1,11 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.spec.ts'], + clearMocks: true, + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.jest.json' }], + }, +}; \ No newline at end of file diff --git a/apps/astriarch-backend/package.json b/apps/astriarch-backend/package.json index 6e4b4f56..992cb9d9 100644 --- a/apps/astriarch-backend/package.json +++ b/apps/astriarch-backend/package.json @@ -10,9 +10,11 @@ "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "lint": "eslint src/**/*.ts", + "lint:check": "eslint src/**/*.ts", "type-check": "tsc --noEmit", "test": "jest", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "cleanup:test-data": "node scripts/cleanup-test-data.js" }, "dependencies": { "astriarch-engine": "workspace:*", diff --git a/apps/astriarch-backend/scripts/cleanup-test-data.js b/apps/astriarch-backend/scripts/cleanup-test-data.js new file mode 100644 index 00000000..afcbd18a --- /dev/null +++ b/apps/astriarch-backend/scripts/cleanup-test-data.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +const backendPort = process.env.TEST_BACKEND_PORT || '8002'; +const prefix = process.env.E2E_PREFIX || '__e2e__'; +const cleanupUrl = `http://localhost:${backendPort}/api/test/cleanup`; + +async function run() { + try { + const res = await fetch(cleanupUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prefix }), + }); + + if (!res.ok) { + const body = await res.text(); + console.error(`[cleanup-test-data] failed: HTTP ${res.status} ${body}`); + process.exit(1); + } + + const body = await res.json(); + console.log('[cleanup-test-data] success', body); + } catch (err) { + console.error('[cleanup-test-data] request failed', err); + process.exit(1); + } +} + +run(); 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/healthRoutes.spec.ts b/apps/astriarch-backend/src/routes/healthRoutes.spec.ts new file mode 100644 index 00000000..b0d9c1c5 --- /dev/null +++ b/apps/astriarch-backend/src/routes/healthRoutes.spec.ts @@ -0,0 +1,52 @@ +import type { Request, Response } from "express"; + +import { healthRoutes } from "./healthRoutes"; + +function getHealthRouteHandler() { + const routeLayer = (healthRoutes as any).stack.find( + (layer: any) => layer.route?.path === "/" && layer.route?.methods?.get, + ); + + if (!routeLayer) { + throw new Error("Health route GET / handler not found"); + } + + return routeLayer.route.stack[0].handle as (req: Request, res: Response) => void; +} + +describe("healthRoutes", () => { + beforeEach(() => { + jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns the expected health payload and initializes session state", () => { + const handler = getHealthRouteHandler(); + const json = jest.fn(); + + const req = { + session: {}, + sessionID: "test-session-id", + } as unknown as Request; + const res = { + json, + } as unknown as Response; + + handler(req, res); + + expect((req.session as any).created).toEqual(expect.any(String)); + expect(json).toHaveBeenCalledTimes(1); + + const payload = json.mock.calls[0][0]; + expect(payload).toMatchObject({ + status: "OK", + version: "2.0.0", + }); + expect(typeof payload.timestamp).toBe("string"); + expect(Number.isNaN(Date.parse(payload.timestamp))).toBe(false); + expect(typeof payload.uptime).toBe("number"); + }); +}); diff --git a/apps/astriarch-backend/src/routes/testRoutes.ts b/apps/astriarch-backend/src/routes/testRoutes.ts new file mode 100644 index 00000000..f7000b78 --- /dev/null +++ b/apps/astriarch-backend/src/routes/testRoutes.ts @@ -0,0 +1,65 @@ +/** + * 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(); + +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 all sessions — safe because this route only exists in NODE_ENV=test. + const db = mongoose.connection.db; + let sessionCount = 0; + if (db) { + try { + const sessionResult = await db.collection("sessions").deleteMany({}); + 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-backend/tsconfig.jest.json b/apps/astriarch-backend/tsconfig.jest.json new file mode 100644 index 00000000..2b908804 --- /dev/null +++ b/apps/astriarch-backend/tsconfig.jest.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/apps/astriarch-backend/tsconfig.json b/apps/astriarch-backend/tsconfig.json index 6672bc96..78a87ef2 100644 --- a/apps/astriarch-backend/tsconfig.json +++ b/apps/astriarch-backend/tsconfig.json @@ -25,6 +25,7 @@ "exclude": [ "node_modules", "dist", - "src/tests/**/*" + "src/tests/**/*", + "src/**/*.spec.ts" ] } diff --git a/apps/astriarch-frontend/.gitignore b/apps/astriarch-frontend/.gitignore index 42250352..d13f8726 100644 --- a/apps/astriarch-frontend/.gitignore +++ b/apps/astriarch-frontend/.gitignore @@ -24,3 +24,7 @@ vite.config.ts.timestamp-* *storybook.log storybook-static + +# Playwright artifacts +playwright-report/ +test-results/ diff --git a/apps/astriarch-frontend/e2e/README.md b/apps/astriarch-frontend/e2e/README.md new file mode 100644 index 00000000..05b94c22 --- /dev/null +++ b/apps/astriarch-frontend/e2e/README.md @@ -0,0 +1,60 @@ +# Frontend E2E Tests (Playwright) + +This directory contains end-to-end tests that run the real frontend in a browser and exercise game flows against the backend WebSocket/API stack. + +## Structure + +- `fixtures/` shared Playwright fixtures (player contexts/pages) +- `helpers/` reusable domain helpers (lobby setup, game options, in-game assertions) +- `scenarios/` top-level test scenarios +- `global-setup.ts` suite-level cleanup before tests +- `global-teardown.ts` suite-level cleanup after tests + +## Commands + +From repo root: + +- `pnpm --filter astriarch-frontend test:e2e` +- `pnpm --filter astriarch-frontend test:e2e:headed` +- `pnpm --filter astriarch-frontend test:e2e:ui` + +## Fixture Model + +- `fixtures/player.ts` provides isolated browser contexts for named players. +- Use separate player fixtures when validating multiplayer synchronization. +- Keep tests focused on behavior; move repeated interactions into `helpers/`. + +## Helper Conventions + +- Prefer domain verbs (`openLobby`, `createGame`, `startGame`) over low-level selector calls in scenarios. +- Keep helper waits state-driven (`waitForSelector`, role/testid assertions) instead of fixed sleeps. +- Use test game names prefixed with `__e2e__` so cleanup remains deterministic. + +## Data Cleanup + +- Tests clean up through backend test route: `POST /api/test/cleanup`. +- Route is only enabled when backend runs with `NODE_ENV=test`. + +## data-testid Contract + +The scenarios rely on these stable selectors: + +- `show-lobby-btn` +- `view-lobby` +- `view-game-options` +- `view-game` +- `connection-status` +- `create-game-btn` +- `game-item-` +- `join-game-btn` +- `resume-game-btn` +- `game-name-input` +- `player-name-input` +- `opponent-slot-1` +- `opponent-slot-2` +- `opponent-slot-3` +- `start-game-btn` +- `send-ships-btn` +- `notification-error` + +When changing UI components, preserve these ids or update helpers/scenarios in the same change. diff --git a/apps/astriarch-frontend/e2e/fixtures/player.ts b/apps/astriarch-frontend/e2e/fixtures/player.ts new file mode 100644 index 00000000..4177b633 --- /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/global-setup.ts b/apps/astriarch-frontend/e2e/global-setup.ts new file mode 100644 index 00000000..7a26f795 --- /dev/null +++ b/apps/astriarch-frontend/e2e/global-setup.ts @@ -0,0 +1,7 @@ +import { cleanupTestData } from './helpers/cleanup'; + +async function globalSetup(): Promise { + await cleanupTestData(); +} + +export default globalSetup; diff --git a/apps/astriarch-frontend/e2e/global-teardown.ts b/apps/astriarch-frontend/e2e/global-teardown.ts new file mode 100644 index 00000000..b0fd6c66 --- /dev/null +++ b/apps/astriarch-frontend/e2e/global-teardown.ts @@ -0,0 +1,7 @@ +import { cleanupTestData } from './helpers/cleanup'; + +async function globalTeardown(): Promise { + await cleanupTestData(); +} + +export default globalTeardown; diff --git a/apps/astriarch-frontend/e2e/helpers/cleanup.ts b/apps/astriarch-frontend/e2e/helpers/cleanup.ts new file mode 100644 index 00000000..1fcc94a7 --- /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..38bac385 --- /dev/null +++ b/apps/astriarch-frontend/e2e/helpers/gameOptions.ts @@ -0,0 +1,41 @@ +/** + * Game-options screen helpers. + */ + +import type { Page } from '@playwright/test'; +import { expect } 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); + await expect(select).toHaveValue(type); +} + +/** 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..ed6c66cc --- /dev/null +++ b/apps/astriarch-frontend/e2e/helpers/inGame.ts @@ -0,0 +1,71 @@ +/** + * In-game helpers — waiting for server acknowledgments and triggering game actions. + */ + +import type { Page } from '@playwright/test'; +import { expect } 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 { + const errorNotification = page.locator('[data-testid="notification-error"]'); + await expect(errorNotification).toHaveCount(0, { timeout: 5_000 }); + 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 { + await page.waitForSelector('[data-testid="view-game"]', { timeout: DEFAULT_TIMEOUT }); + await expect(page.locator('[data-testid="notification-error"]')).toHaveCount(0, { + timeout: 5_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..32782f9c --- /dev/null +++ b/apps/astriarch-frontend/e2e/helpers/lobby.ts @@ -0,0 +1,84 @@ +/** + * 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..5ef98022 --- /dev/null +++ b/apps/astriarch-frontend/e2e/scenarios/scenario-b-two-humans.spec.ts @@ -0,0 +1,76 @@ +/** + * 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..441938c5 --- /dev/null +++ b/apps/astriarch-frontend/e2e/scenarios/scenario-c-resume-game.spec.ts @@ -0,0 +1,64 @@ +/** + * 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..f5c62974 --- /dev/null +++ b/apps/astriarch-frontend/e2e/scenarios/scenario-d-command-flow.spec.ts @@ -0,0 +1,56 @@ +/** + * 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); + + // 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..e57ce3b2 100644 --- a/apps/astriarch-frontend/package.json +++ b/apps/astriarch-frontend/package.json @@ -5,15 +5,20 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "vite build", + "build": "pnpm --filter astriarch-engine build && vite build", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", + "format:check": "prettier --check .", "lint": "prettier --check . && eslint .", + "lint: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 +31,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..c5ea6b18 --- /dev/null +++ b/apps/astriarch-frontend/playwright.config.ts @@ -0,0 +1,77 @@ +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 dev server) + * 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', + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + // 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: `pnpm --filter astriarch-engine build && PORT=${BACKEND_PORT} NODE_ENV=test pnpm --filter astriarch-backend dev`, + url: `${BACKEND_HTTP}/api/health`, + timeout: 90_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/assets/tab/TabSvgUnselected.svelte b/apps/astriarch-frontend/src/lib/assets/tab/TabSvgUnselected.svelte index 65febc78..cdd9525b 100644 --- a/apps/astriarch-frontend/src/lib/assets/tab/TabSvgUnselected.svelte +++ b/apps/astriarch-frontend/src/lib/assets/tab/TabSvgUnselected.svelte @@ -1,4 +1,5 @@ diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/PlanetSelector.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/PlanetSelector.svelte index 85c91371..7b618abf 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/PlanetSelector.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/PlanetSelector.svelte @@ -86,7 +86,7 @@ {selectedPlanet?.name || 'Select planet'} - {#each planets as planet} + {#each planets as planet (planet.id)} + // eslint-disable-next-line @typescript-eslint/no-unused-vars type $$Props = { class?: string; style?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; }; diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/button/Button.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/button/Button.svelte index cb118ee7..3c1171eb 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/button/Button.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/button/Button.svelte @@ -5,6 +5,7 @@ import ButtonSvg from './ButtonSvg.svelte'; interface Props { + // eslint-disable-next-line @typescript-eslint/no-explicit-any children?: any; label?: string; size?: Size; @@ -164,6 +165,7 @@ {#if children} {@render children()} {:else if hotkey && label} + {@html displayText} {:else} {label} diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/card/Card.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/card/Card.svelte index ea7975de..ec264282 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/card/Card.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/card/Card.svelte @@ -3,6 +3,7 @@ import CardSvg from './CardSvg.svelte'; interface Props { + // eslint-disable-next-line @typescript-eslint/no-explicit-any children?: any; label?: string; size?: Size; diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/dialog/Dialog.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/dialog/Dialog.svelte index 59fdc557..22ea03d0 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/dialog/Dialog.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/dialog/Dialog.svelte @@ -10,6 +10,7 @@ open?: boolean; onCancel?: () => void; onSave?: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any children?: any; size?: 'small' | 'large'; variant?: 'info' | 'warning' | 'success' | 'error'; @@ -29,6 +30,7 @@ variant = 'info', style = 'default', showCloseButton = true, + // eslint-disable-next-line @typescript-eslint/no-unused-vars ...restProps }: Props = $props(); diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/dropdown/Dropdown.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/dropdown/Dropdown.svelte index 2e0eef45..08a65cbc 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/dropdown/Dropdown.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/dropdown/Dropdown.svelte @@ -116,7 +116,7 @@ class="min-w-[241px] rounded border-cyan-500/30 bg-gray-900/95 shadow-lg backdrop-blur-sm" sideOffset={-4} > - {#each options as option} + {#each options as option (option.value)} void; } @@ -41,7 +42,7 @@
- {#each items as item, i} + {#each items as item, i (i)} - {#each items as item, i} + {#each items as item, i (i)} {#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..ece60960 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; @@ -22,6 +23,7 @@ style="pointer-events: all; cursor: pointer;" {onclick} onkeydown={(e) => e.key === 'Enter' && onclick?.()} + aria-label={ariaLabel} role="button" tabindex="0" d="M188.3 14H15.9625C15.0054 14 14.5951 15.2153 15.3565 15.7954L73.852 60.3635C75.2454 61.4251 76.9487 62 78.7004 62H251.037C251.995 62 252.405 60.7847 251.643 60.2046L193.148 15.6365C191.755 14.5749 190.051 14 188.3 14Z" 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/notification/Notification.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/notification/Notification.svelte index 9723e8cd..012c6298 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/notification/Notification.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/notification/Notification.svelte @@ -8,6 +8,7 @@ label?: string; // Make label optional since we can use children size?: Size; onclick?: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any children?: any; } diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/notification/NotificationItem.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/notification/NotificationItem.svelte index 5c3e7eb8..ab0a99ed 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/notification/NotificationItem.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/notification/NotificationItem.svelte @@ -6,9 +6,10 @@ interface Props { notification: GameNotification; onDismiss: () => void; + testid?: string; } - let { notification, onDismiss }: Props = $props(); + let { notification, onDismiss, testid }: Props = $props(); // Animation state let visible = $state(false); @@ -99,6 +100,7 @@ class="notification-item transition-all duration-700 ease-in-out" class:visible class:fade-out={fadeOut} + data-testid={testid} > diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/notification/TaskItem.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/notification/TaskItem.svelte index 3932360b..3522fac6 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/notification/TaskItem.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/notification/TaskItem.svelte @@ -11,6 +11,7 @@ let { notification }: Props = $props(); // Debug logging to track component lifecycle + // eslint-disable-next-line @typescript-eslint/no-unused-vars const componentId = Math.random().toString(36).substr(2, 9); // console.log( // `TaskItem ${componentId} created for ${notification.type}-${notification.planetId} (${notification.planetName})` diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/notification/TaskNotificationPanel.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/notification/TaskNotificationPanel.svelte index 62da5a7d..bb0a8cbb 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/notification/TaskNotificationPanel.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/notification/TaskNotificationPanel.svelte @@ -2,10 +2,7 @@ import { clientGameModel } from '$lib/stores/gameStore'; import { derived } from 'svelte/store'; import TaskItem from './TaskItem.svelte'; - import type { - TaskNotification, - TaskNotificationIndex - } from 'astriarch-engine/src/model/clientModel'; + import type { TaskNotification } from 'astriarch-engine/src/model/clientModel'; // Access task notifications directly from clientGameModel, just like FleetCommandView accesses ships const taskNotifications = derived(clientGameModel, ($clientGameModel) => { @@ -27,7 +24,7 @@ {#if $taskNotifications.length > 0}
- {#each $taskNotifications as notification} + {#each $taskNotifications as notification (notification.type + notification.planetId)} {/each}
diff --git a/apps/astriarch-frontend/src/lib/components/astriarch/tab-controller/TabController.svelte b/apps/astriarch-frontend/src/lib/components/astriarch/tab-controller/TabController.svelte index d7cf2e30..c46c32c9 100644 --- a/apps/astriarch-frontend/src/lib/components/astriarch/tab-controller/TabController.svelte +++ b/apps/astriarch-frontend/src/lib/components/astriarch/tab-controller/TabController.svelte @@ -8,11 +8,11 @@ interface Props { tabs: TabControllerTab[]; size?: Size; - onclick?: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any children?: any; } - let { tabs, size = 'sm', onclick, children, ...restProps }: Props = $props(); + let { tabs, size = 'sm', children, ...restProps }: Props = $props(); let tabIndex = $state(0); @@ -29,7 +29,7 @@
- {#each tabs as tab, i} + {#each tabs as tab, i (tab.label)} import TopOverviewButton from './TopOverviewButton.svelte'; - import type { GameSpeed } from 'astriarch-engine'; import { currentGameSpeedNumber, gameActions } from '$lib/stores/gameStore'; interface Props { disabled?: boolean; class?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; } 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..11cee5c9 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 @@ -1,15 +1,15 @@ -
+
+ {/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..5632f61e 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..6b92dca5 100644 --- a/apps/astriarch-frontend/src/lib/components/lobby/GameOptionsView.svelte +++ b/apps/astriarch-frontend/src/lib/components/lobby/GameOptionsView.svelte @@ -165,11 +165,6 @@ const player = connectedPlayers.find((p) => p.position === position); return player?.name || null; } - - function isPlayerSlotOccupied(playerIndex: number): boolean { - const position = playerIndex; - return connectedPlayers.some((p) => p.position === position && p.connected); - }
@@ -192,6 +187,7 @@ {#if isHost} - {#each playerTypeOptions as option} + {#each playerTypeOptions as option (option.value)} @@ -319,11 +318,12 @@ {#if isHost} @@ -367,7 +367,7 @@ disabled={!isHost} class="form-select" > - {#each galaxySizeOptions as option} + {#each galaxySizeOptions as option (option.value)} {/each} @@ -386,7 +386,7 @@ disabled={!isHost} class="form-select" > - {#each systemsOptions as option} + {#each systemsOptions as option (option.value)} {/each} @@ -405,7 +405,7 @@ disabled={!isHost} class="form-select" > - {#each planetsPerSystemOptions as option} + {#each planetsPerSystemOptions as option (option.value)} {/each} @@ -456,7 +456,13 @@
@@ -590,9 +527,10 @@ ? `right: calc(600px + 1rem); bottom: 1rem;` : undefined} > - {#each $notifications.slice(-5) as notification, i (notification.id)} + {#each $notifications.slice(-5) as notification (notification.id)} multiplayerGameStore.dismissNotification(notification.id)} /> {/each} diff --git a/apps/astriarch-frontend/src/routes/test/+page.svelte b/apps/astriarch-frontend/src/routes/test/+page.svelte index 6fdca2e9..11606167 100644 --- a/apps/astriarch-frontend/src/routes/test/+page.svelte +++ b/apps/astriarch-frontend/src/routes/test/+page.svelte @@ -2,8 +2,6 @@ import { Button } from '$lib/components/ui/button'; import * as Card from '$lib/components/ui/card'; import { Badge } from '$lib/components/ui/badge'; - import { Input } from '$lib/components/ui/input'; - import { Label } from '$lib/components/ui/label'; // Destructure Card components const { @@ -70,7 +68,6 @@ 'battleship_custom' ]; - let showDialog = $state(false); let showAstriarchDialog = $state(false); let showGameOverModal = $state(false); let gameOverScenario = $state('victory'); @@ -537,11 +534,11 @@

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)}