diff --git a/.github/instructions/webclient.instructions.md b/.github/instructions/webclient.instructions.md new file mode 100644 index 000000000..378359ad8 --- /dev/null +++ b/.github/instructions/webclient.instructions.md @@ -0,0 +1,168 @@ +--- +applyTo: "webclient/**" +--- + +# Webclient instructions + +Applies to the React/TypeScript SPA in `webclient/` (Webatrice) that connects to a Servatrice server over a WebSocket. The package is self-contained; the only thing it shares with the rest of the repo (C++ desktop/server stack) is the protobuf protocol in `../libcockatrice_protocol/`. Anything outside `webclient/` is out of scope unless a task explicitly touches the protocol. + +Canonical AI-tool instruction surface for this package — invariants, policy, and external facts. When a source comment ends with `See .github/instructions/webclient.instructions.md#`, the section with that anchor lives here. Source comments tagged `// @critical` guard cross-file invariants; do not remove them without updating the relevant section. For commands, stack, and getting-started, see [webclient/README.md](../../webclient/README.md). + +## Architecture + +### Protocol layer + +`src/generated/proto/` is buf-generated from `../libcockatrice_protocol/` (gitignored, never hand-edit). Runtime is `@bufbuild/protobuf`. `src/types/` re-exports the bindings namespaced as `Data` (raw proto), `Enriched` (UI/domain composition — proto extended with client-only sibling fields), and `App` (pure client types; no proto dependency). Consumer pattern: `import { Data, Enriched, App } from '@app/types'` then `Data.ServerInfo_User`, `Enriched.GameEntry`, `App.RouteEnum`. **UI, store, hooks, and api code must import proto types through `@app/types`, never `@app/generated` directly. `src/websocket/` is the exception and imports `@app/generated` by design.** + +Websocket protocol/transport types (`StatusEnum`, `WebSocketConnectReason`, the `*ConnectOptions` family, signal payload contexts `PendingActivationContext` / `LoginSuccessContext`, `GameEventMeta`, the `I*Request` / `I*Response` contracts, `WebClientConfig`) live separately under `@app/websocket/types` and are exposed as a single `WebsocketTypes` namespace: `import { WebsocketTypes } from '@app/websocket/types'` then `WebsocketTypes.StatusEnum`, `WebsocketTypes.LoginConnectOptions`, etc. This is the only public surface of the websocket layer's types — store, hooks, api, and UI code must access websocket types through this namespace. **Don't re-export websocket types through `Enriched`**; that namespace is strictly UI/domain composition. `@app/websocket` (the broader index) only exposes runtime values (`WebClient`, command groups, `setPendingOptions`, etc.) — not types. Inside `src/websocket/` use relative paths to specific files under `types/` (e.g. `from '../types/StatusEnum'`) rather than either alias. + +### WebSocket layer (`src/websocket/`) + +Outbound commands in `commands//`, inbound handlers in `events//`, transport in `services/`, type declarations in `types/` (request/response contracts, `StatusEnum`, `WebSocketConnectReason`, connect-options union, signal contexts — all exposed to outside consumers as the `WebsocketTypes` namespace via `@app/websocket/types`). `WebClient` is a singleton; `new WebClient(...)` is called only inside `WebClientProvider` ([webclient/src/hooks/useWebClient.tsx](../../webclient/src/hooks/useWebClient.tsx)), never at module load. + +**Layering invariant (enforced, zero violations today — keep it that way):** + +1. Containers and components call `useWebClient()` to get the `WebClient`, then `client.request..(…)`. Never import from `@app/websocket` in UI code (`@app/websocket/types` is fine — type-only); never call `new WebClient(...)` outside `WebClientProvider`. +2. `src/api/request/*RequestImpl` methods translate UI intent into `src/websocket/commands/*` calls. `src/api/response/*ResponseImpl` methods are invoked by command callbacks and event handlers and dispatch to the store. +3. Only `*.dispatch.ts` helpers inside `src/store/` and the `*ResponseImpl` classes may touch the Redux store. + +If you find yourself wanting to skip a layer (dispatching from an event handler, calling `@app/websocket` from a container, reaching into `@app/generated` from a component/store), stop. `eslint.boundaries.mjs` enforces this via the element types `api` / `components` / `containers` / `hooks` / `services` / `store` / `types` / `websocket` / `websocket-types`; `websocket-types` is deliberately a narrower surface than `websocket` so UI/store can reach protocol types without pulling in transport internals. + +### ProtobufService: request/response correlation + +- Every outbound `CommandContainer` gets a monotonically increasing `cmdId` (cast to `BigInt` for the proto field — the wire type is `int64`). A `Map` stores the response handler keyed by that ID; `processServerResponse` looks up and invokes the callback on `RESPONSE`, then deletes the entry. The `number` ↔ `BigInt` sides stay in sync because the counter never realistically exceeds `Number.MAX_SAFE_INTEGER`. +- **No timeout or retry** at the transport layer. `resetCommands()` (called on reconnect) zeros `cmdId` and clears the pending map, silently dropping any in-flight callbacks. Reconnection resilience is a caller concern. +- `sendCommand` is a no-op write if the transport isn't open — it still registers the callback, so a stale pending entry can accumulate until the next reset. +- Inbound event dispatch is extension-based: `processRoomEvent` / `processSessionEvent` / `processGameEvent` iterate the relevant registry array (entries built with `makeEntry(ext, handler)`) and invoke the first handler whose extension is set on the message. Adding a new handler means appending a `makeEntry(ExtSymbol, handler)` line to the relevant registry. + +### command-options contract (`src/websocket/services/command-options.ts`) + +Every `send*Command` call accepts an optional `CommandOptions`: + +- `responseExt?: GenExtension` — the response payload extension to unwrap on success. +- `onSuccess?: (response: R, raw: Response) => void` — called when `responseCode === RespOk`. If `responseExt` is absent, the overload becomes `() => void`. +- `onResponseCode?: { [code: number]: (raw: Response) => void }` — per-error-code handlers. +- `onError?: (code: number, raw: Response) => void` — fallback for codes not in `onResponseCode`. +- `onResponse?: (raw: Response) => void` — if set, handles the raw response and bypasses every other hook. Use when you need the full response object regardless of code. + +If none of the hooks fire for a non-OK response, `handleResponse` logs via `console.error` with the command's proto type name. Practical rule: `onSuccess` funnels into a `*ResponseImpl` method, `onError` funnels into a `*ResponseImpl` method (usually to flip connection state or show a toast), `onResponse` is rare. + +### Public API for UI (`src/api/`) + +One `*RequestImpl` / `*ResponseImpl` class per scope (session / rooms / game / admin / moderator; plus `AuthenticationRequestImpl` — auth has no inbound events). Request methods return `void` — fire-and-forget; response flows back via `command-options` callbacks → `*ResponseImpl` → store. `*ResponseImpl` classes are the only place outside `src/store/*.dispatch.ts` that calls `*Dispatch` helpers. **UI code never imports from `src/api/` directly — use `useWebClient()`.** Never call `client.response.*` from UI. + +### State (`src/store/`) + +Slices: `server/`, `rooms/`, `game/`. Consumers import through the `@app/store` barrel (`GameSelectors`, `GameDispatch`, `GameTypes`, same for `Server`/`Rooms`). **Don't deep-import from `src/store//*` — add the symbol to the barrel's `index.ts` instead.** This rule generalizes: deep paths through any `@app/*` barrel target are a smell. + +Shape notes worth knowing before you touch a reducer: + +- `game/` is deeply normalized: `games[gameId].players[playerId].zones[zoneName].cards`. Selectors are plain getters so lookups stay O(1); `createSelector` is reserved for the few that build derived lists (e.g. `getActiveGameIds`). +- Selectors return module-scope `EMPTY_ARRAY` / `EMPTY_OBJECT` constants for missing data to preserve referential equality and avoid spurious re-renders. +- `rooms/` is *partially* normalized: rooms are keyed by ID but each room also carries denormalized `gameList` / `userList` arrays. Server updates often omit those lists, so the reducer merges new metadata while preserving the existing arrays. Standing TODO to clean this up. +- `server/` is mostly flat maps keyed by username (`messages`, `userInfo`, buddy/ignore lists) plus connection state. + +### Local persistence + +Dexie (IndexedDB) holds cards, sets, tokens, known hosts, and settings; separate from Redux (persists across reloads). Stubbed globally in `setupTests.ts` so unit specs never hit a real IndexedDB. + +### UI + +Route-level containers in `containers/` (one subdir per route plus `AppShell` root and shared `Layout`); routing in `containers/App/AppShellRoutes.tsx`. Two hooks are load-bearing: **`useWebClient`** (context accessor — the only way UI code is allowed to reach the server; see the Layering invariant) and **`useAutoLogin`** (owns the once-per-session gate; see [#startup--session-invariants](#startup--session-invariants)). `WebClientProvider` ([webclient/src/hooks/useWebClient.tsx](../../webclient/src/hooks/useWebClient.tsx)) owns the singleton; `WebClientContext` is exported so integration tests can inject a pre-built `WebClient`. UI kit: MUI v9 + `@emotion`; i18n via `react-i18next` + ICU (Transifex). + +## Build pipeline and generated files + +`npm start` / `npm run build` run `prestart`/`prebuild` hooks: `proto:generate` followed by `node prebuild.js`. `prebuild.js` writes `src/server-props.json` (git SHA), merges `src/**/*.i18n.json` into `src/i18n-default.json` (**throws on duplicate keys** — namespace your i18n keys), and copies country flags from `../cockatrice/resources/countries`. + +| File | Tracked? | Regenerate with | +|---|---|---| +| `src/generated/proto/**` | Gitignored | `npm run proto:generate` | +| `src/server-props.json` | Gitignored | `npm start` / `npm run build` (prebuild writes it) | +| `src/i18n-default.json` | **Committed** | `npm run translate` (or the prebuild hook) | + +`.env.development`, `.env.production`, `.env.test` exist but are empty. No `import.meta.env` configuration surface; server URLs resolve through the login UI / `server-props.json`. + +## Testing + +Vitest + Testing Library + jsdom. [webclient/src/setupTests.ts](../../webclient/src/setupTests.ts) registers jest-dom matchers and installs a global Dexie mock. + +Unit specs run under [webclient/vite.config.ts](../../webclient/vite.config.ts) with `test.isolate: true`: every spec file gets a fresh module graph, but tests **within the same file share it**. `vi.clearAllMocks()` (clears call logs) runs in the global `afterEach` and is safe. **Never add `vi.resetAllMocks()` to `setupTests.ts`** — it resets `vi.fn()` instances created inside `vi.mock(...)` factories at file load, breaking any spec that mocks something once (e.g. `store.dispatch`) and expects it to persist across tests in the file. + +Integration specs run under [webclient/vitest.integration.config.ts](../../webclient/vitest.integration.config.ts) via `npm run test:integration` — slower; exercise the wired-up `WebClient` against fakes in `src/__test-utils__/`. + +**Globals that leak within a file.** `vi.restoreAllMocks()` only restores `vi.spyOn` targets. Bare `Object.defineProperty` writes (e.g. on `window.location`) and global reassignments (e.g. `globalThis.WebSocket = ...`) leak between tests in the same file — `setupTests.ts` does not auto-restore them. Use `withMockLocation` from [webclient/src/__test-utils__/globalGuards.ts](../../webclient/src/__test-utils__/globalGuards.ts) for scoped overrides that clean up after themselves. + +**Shared scaffolding.** [webclient/src/__test-utils__/](../../webclient/src/__test-utils__/) provides render helpers, a mock-client builder, and global guards. Prefer these over hand-rolling providers — the integration suite depends on injecting pre-built `WebClient` instances through them. Store slices have co-located `__mocks__/fixtures.ts` files exposing `make*` factories that build protobuf messages via `create(Schema, overrides)`; reuse them instead of hand-rolling proto objects. + +`npm run golden` (lint + unit + integration) is the CI gate — run it before declaring work done. + +## Protocol changes + +When a task edits `.proto` files in `../libcockatrice_protocol/`: + +1. Run `npm run proto:generate`. +2. Update any command / event / `*RequestImpl` / `*ResponseImpl` code that consumes the changed messages. +3. Commit consumer changes only — `src/generated/proto/**` is gitignored and must not be committed. + +--- + +## Domain Knowledge + +Facts that can't be read off the code — external systems (Servatrice protocol, Protobuf-ES runtime, browser WebSocket semantics) and invariants the code relies on but cannot itself express. + +### Initialization order + +Protobuf-ES maps proto `int64` / `uint64` fields to native `BigInt`. `BigInt.prototype` has no `toJSON`, so `JSON.stringify` throws on any state that contains one — which Redux DevTools, structured logging, and React error-boundary dumps all do. [webclient/src/polyfills.ts](../../webclient/src/polyfills.ts) installs a `BigInt.prototype.toJSON` that returns `this.toString()`, coercing to string on serialize. + +Coercion is one-way: `JSON.parse` does not round-trip back to `BigInt`. That is acceptable because in-memory state still holds real `BigInt`s; only serialized surfaces (devtools, logs) see the coerced form. + +The polyfill must execute before any module creates the store, or the first devtools dump throws. Enforced by making `./polyfills` the first import in [webclient/src/index.tsx](../../webclient/src/index.tsx) and [webclient/src/setupTests.ts](../../webclient/src/setupTests.ts). + +### Startup / session invariants + +Product requirement: **auto-login runs at most once per JS session, and logout within the same session does NOT re-trigger it.** Only a full page refresh does. This matches the Cockatrice desktop client. + +The gate lives at module scope in [webclient/src/hooks/useAutoLogin.ts](../../webclient/src/hooks/useAutoLogin.ts) as `autoLoginGate.hasChecked`. It flips to `true` after the startup check completes, regardless of whether the check actually fired a login — so a check that determined "don't auto-connect" (preference off, no saved password, etc.) still latches the gate. The gate is exported as a mutable object so integration tests can reset it without `vi.resetModules()`. + +`useAutoLogin` consults settings via `getSettings()` (one-shot), not by subscribing to `settingsStore`. Editing the persisted auto-connect preference is a preference write, not a login signal. + +### Data structure invariants + +`Enriched.Room` and `Enriched.GameEntry` compose a raw proto (`info`) with client-side sibling fields. The TypeScript types cannot distinguish which fields stay fresh and which go stale, so this is a convention: + +- **`info` is a wire snapshot at one point in time.** For `Room` it's the last `UPDATE_ROOMS` / `JOIN_ROOM` payload; for `GameEntry` it's the `Event_GameJoined` payload. +- **Fields on `info` that evolve via later events immediately go stale.** Read the sibling, never `info.*`: + +| Type | Stale on `info` | Read instead | +|---|---|---| +| `Room` | `info.gameList` | `room.games` | +| `Room` | `info.userList` | `room.users` | +| `Room` | `info.gametypeList` | `room.gametypeMap` | +| `GameEntry` | `info.started` | `game.started` | +| `GameEntry` | `info.activePlayerId` etc. | top-level twin fields | + +Adding a new field that updates via events means adding a top-level twin in [webclient/src/types/enriched.ts](../../webclient/src/types/enriched.ts) and never reading `info.` after the initial snapshot. + +### Reducer merge rules + +Servatrice's `UPDATE_ROOMS` event carries room metadata only: the repeated `gameList` / `userList` / `gametypeList` collections on each `ServerInfo_Room` may be absent or stale. The reducer at [webclient/src/store/rooms/rooms.reducer.tsx](../../webclient/src/store/rooms/rooms.reducer.tsx) replaces `info`, `gametypeMap`, and `order` on existing rooms but preserves the normalized `games` and `users` maps, which are maintained by their own events (`updateGames`, `userJoined`, `userLeft`). + +### Shared store pattern + +`createSharedStore` in [webclient/src/hooks/useSharedStore.ts](../../webclient/src/hooks/useSharedStore.ts) exposes two surfaces with different semantics. Pick the right one per caller: + +- **`subscribe` / `getSnapshot` (via `useSharedStore`)** — reactive. The component re-renders on every store update. Use from inside render. +- **`whenReady()`** — one-shot. Resolves with the first loaded value, then never fires again. Use from code that must read the loaded value exactly once and must NOT re-run on later updates (notably, startup orchestrators reading persisted preferences). + +Subscribing in a startup orchestrator turns a later user action (ticking a preference) into a re-evaluation of startup logic, which is almost always wrong. + +### Protocol quirks + +Servatrice-side behavior the client has to accommodate: + +- **`ServerOptions` is a bitmask.** [webclient/src/websocket/utils/passwordHasher.ts](../../webclient/src/websocket/utils/passwordHasher.ts) `passwordSaltSupported` uses `&`, not `===`. Don't "fix" it. +- **System-injected user messages can omit the username** (e.g. ban notifications where the target is the current user, or server announcements). [webclient/src/store/common/normalizers.ts](../../webclient/src/store/common/normalizers.ts) `normalizeUserMessage` handles this at the dispatch layer so the store always holds a clean user-facing string. + +### WebSocket lifecycle + +A failed `WebSocket` connect fires both `onerror` and `onclose`. `onerror` runs first with the richer status; [webclient/src/websocket/services/WebSocketService.ts](../../webclient/src/websocket/services/WebSocketService.ts) guards the `onclose` handler with `hasReportedError` so the generic "Connection Closed" doesn't overwrite the specific "Connection Failed". The flag clears on `onopen` and at the end of each `onclose` cycle. diff --git a/webclient/CLAUDE.md b/webclient/CLAUDE.md deleted file mode 100644 index 8c1b59078..000000000 --- a/webclient/CLAUDE.md +++ /dev/null @@ -1,170 +0,0 @@ -# CLAUDE.md - -Guidance for Claude Code when working inside `webclient/` — the React/TypeScript SPA (Webatrice) that connects to a Servatrice server over a WebSocket. It is a self-contained application; the only thing it shares with the rest of the repo (C++ desktop/server stack) is the protobuf protocol in `../libcockatrice_protocol/`. Anything outside `webclient/` is out-of-scope unless a task explicitly touches the protocol. - -All commands below are run from this directory. - -## Commands - -```bash -npm start # Vite dev server (runs proto:generate + prebuild.js first) -npm run build # production build (same prebuild hooks) -npm test # vitest run (one-shot) -npm run test:watch # vitest watch -npm run lint # eslint src/ -npm run lint:fix -npm run golden # lint + test — the CI-equivalent gate to run before declaring work done -npm run proto:generate # `npx buf generate` — regenerates TS bindings into src/generated/proto -``` - -Single test file: `npx vitest run path/to/file.spec.ts`. Filter by name: `npx vitest run -t "partial test name"`. - -The dev server has `server.open: true`, so `npm start` pops a browser tab automatically. - -## Architecture - -The webclient is a Redux Toolkit + RxJS app. Its defining abstraction is a layered WebSocket client that speaks the Cockatrice protobuf protocol to Servatrice. Understanding the layering is essential before editing anything under `src/websocket/`, `src/api/`, or `src/store/`. - -### Protocol layer - -- **`src/generated/proto/`** — auto-generated from `../libcockatrice_protocol/**/*.proto` by `buf` (see `buf.gen.yaml`). Never edit by hand. Runtime is `@bufbuild/protobuf` (Protobuf-ES); the codebase was recently migrated off the older `protobufjs`, so if you find any stray references to the old runtime, they're bugs. -- **`src/types/` is the only public surface for generated code.** `src/types/data.ts` hand-rolls an `export *` barrel over every proto file that consumers use, and `src/types/index.ts` re-exports it as `Data`, plus `Enriched` (protocol types extended with client-only fields) and `App` (pure client types). Import as `import { Data, Enriched } from '@app/types'` and use `Data.Command_Login`, `Data.ServerInfo_User`, etc. **Never import directly from `@app/generated/proto/*` outside `src/types/`.** When a new proto file starts being consumed, add an `export *` line to `src/types/data.ts` — there is a standing TODO to replace this rollup with a protobuf-es plugin. - -### WebSocket layer (`src/websocket/`) - -A strict inbound/outbound split sits on top of a transport core: - -- **`services/`** — transport: `WebSocketService` (socket lifecycle), `ProtobufService` (encode/decode + request/response correlation), `KeepAliveService` (ping/pong), `command-options` (per-command response config). This layer has no knowledge of Redux. -- **`commands/`** — *outbound*. Organised by scope (`session/`, `room/`, `game/`, `admin/`, `moderator/`). Each command builds a protobuf request and hands it to `ProtobufService.send{Session,Room,Game,Admin,Moderator}Command` along with a `CommandOptions` describing how to handle the response. -- **`events/`** — *inbound*. Handlers for server-pushed events, same scopes. They translate protobuf events into calls on the persistence layer. -- **`persistence/`** — the **only** bridge from the websocket layer into app state. `SessionPersistence`, `RoomPersistence`, `GamePersistence`, `AdminPersistence`, `ModeratorPersistence` dispatch Redux actions and/or write to Dexie. -- **`WebClient.ts`** — singleton facade that wires the services, commands, events, and persistence together. - -**Layering invariant (enforced on this branch, not aspirational):** - -1. Containers and components call `src/api/*` services — never `src/websocket/*` directly. -2. Commands and event handlers call `*Persistence` methods — never `store.dispatch` directly. -3. Only `*.dispatch.ts` helpers inside `src/store/` and persistence code may touch the Redux store. - -If you find yourself wanting to skip a layer (dispatching from an event handler, calling a command from a container, reaching into `src/generated/proto/` from a component), stop — the refactor on `webclient-websocket-layer` exists precisely to eliminate those shortcuts. There are currently zero violations; keep it that way. - -### ProtobufService: request/response correlation - -- Every outbound `CommandContainer` gets a monotonically increasing `cmdId` (cast to `BigInt` for the proto field). A `Map` stores the response handler keyed by that ID; when `ServerMessage.RESPONSE` arrives, `processServerResponse` looks up and invokes the callback, then deletes the entry. -- There is **no timeout or retry**. `resetCommands()` (called on reconnect) zeros `cmdId` and clears the pending map, silently dropping any in-flight callbacks. Code that needs reconnection resilience has to handle it at a higher layer. -- `sendCommand` is a no-op write if the transport isn't open — it still registers the callback, so a stale pending entry can accumulate until the next reset. -- Inbound event dispatch is extension-based: `processRoomEvent` / `processSessionEvent` / `processGameEvent` iterate `RoomEvents` / `SessionEvents` / `GameEvents` (tuples of `[extension, handler]`) and invoke the first handler whose extension is set on the message. Adding a new event handler means appending to those arrays. - -### command-options contract (`src/websocket/services/command-options.ts`) - -Every `send*Command` call accepts an optional `CommandOptions`: - -- `responseExt?: GenExtension` — the response payload extension to unwrap on success. -- `onSuccess?: (response: R, raw: Response) => void` — called when `responseCode === RespOk`. If `responseExt` is absent, the overload becomes `() => void`. -- `onResponseCode?: { [code: number]: (raw: Response) => void }` — per-error-code handlers. -- `onError?: (code: number, raw: Response) => void` — fallback for codes not in `onResponseCode`. -- `onResponse?: (raw: Response) => void` — if set, it handles the raw response and bypasses every other hook. Use this when you need the full response object regardless of code. - -If none of the hooks fire for a non-OK response, `handleResponse` logs the failure via `console.error` with the command's proto type name. The practical rule: `onSuccess` funnels into persistence, `onError` funnels into persistence (usually to flip connection state or show a toast), and `onResponse` is rare. - -### Public API for UI (`src/api/`) - -Thin service wrappers (`AuthenticationService`, `SessionService`, `RoomsService`, `GameService`, `ModeratorService`, `AdminService`) that expose websocket commands to UI code. A few things to know: - -- **All command methods are `static` and return `void`.** They're fire-and-forget — the response flows back through the `command-options` callbacks plumbed inside the command itself, into persistence, into the store. Don't try to await them. -- A handful of methods return `boolean` (e.g. `AuthenticationService.isConnected`, `isModerator`) — those are pure sync predicates, not command sends. -- Files use the `.tsx` extension even though they contain no JSX. That's a leftover convention; don't "fix" it. - -### State (`src/store/`) - -Redux Toolkit store (`store.ts`, `rootReducer.ts`) split by feature. Each slice follows the same file layout: - -- `*.actions.ts` — action creators -- `*.reducer.ts` — slice reducer -- `*.selectors.ts` — selectors (mostly plain getters; `createSelector` only for derived lists) -- `*.dispatch.ts` — dispatch helpers called by the persistence layer -- `*.interfaces.ts` / `*.types.ts` — state shape and enums - -Slices: `server/`, `rooms/`, `game/`, plus shared `actions/` and `common/` helpers (`SortUtil`, `normalizers`). Consumers import through the `@app/store` barrel — `GameSelectors`, `GameDispatch`, `GameTypes`, and the same prefixed set for `Server`/`Rooms`. **Don't deep-import from `src/store/game/game.selectors.ts` etc.** — go through `@app/store`. - -Shape notes worth knowing before you touch a reducer: - -- `game/` is deeply normalized: `games[gameId].players[playerId].zones[zoneName].cards`. Selectors are plain getters so lookups stay O(1); `createSelector` is reserved for the few that build derived lists (e.g. `getActiveGameIds`). -- Selectors return module-scope `EMPTY_ARRAY` / `EMPTY_OBJECT` constants for missing data to preserve referential equality and avoid spurious re-renders. -- `rooms/` is *partially* normalized: rooms are keyed by ID, but each room also carries denormalized `gameList` / `userList` arrays. Server updates often omit those lists, so the reducer merges new metadata while preserving the existing arrays. There is a standing TODO to clean this up. -- `server/` is mostly flat maps keyed by username (`messages`, `userInfo`, buddy/ignore lists) plus connection state. - -### Local persistence (`src/services/dexie/`) - -IndexedDB storage via Dexie for cards, sets, tokens, known hosts, and settings. DTOs live in `DexieDTOs/`. This is separate from the Redux store — used for data that should survive a reload (card database, user settings, host list). Dexie is not mocked in unit tests; code that writes to Dexie is typically exercised only in integration paths. - -### UI - -- **`containers/`** — route-level, Redux-connected. Top-level routes: `App`, `Initialize`, `Login`, `Server`, `Room`, `Game`, `Player`, `Decks`, `Account`, `Logs`, `Layout`, `Unsupported`. Routing lives in `containers/App/AppShellRoutes.tsx`. -- **`components/`** — presentational, mostly unconnected. -- **`forms/`** — `react-final-form` forms (e.g. `LoginForm`). -- **`dialogs/`** — MUI dialogs. -- **`hooks/`** — shared hooks (e.g. `useAutoConnect`). -- **`i18n.ts` / `i18n-backend.ts`** — `react-i18next` + ICU; translations managed via Transifex. -- UI kit: MUI v7 (`@mui/material`, `@emotion`). - -### Path aliases - -`tsconfig.json` defines the following (resolved at build time by `vite-tsconfig-paths`): - -``` -@app/api @app/components @app/containers @app/dialogs -@app/forms @app/hooks @app/images @app/services -@app/store @app/types @app/websocket @app/generated/* -``` - -Prefer these in new code over relative imports when crossing top-level directory boundaries. Deep paths into a barrel target (e.g. `@app/store/game/...`) are a smell — add the symbol to the relevant `index.ts` barrel instead. - -### End-to-end data flow - -User action in a container → `src/api/*Service` → `src/websocket/commands/*` → `ProtobufService.send*Command` → socket. -Server reply/event → `src/websocket/events/*` (or the `command-options` callback on the original command) → `src/websocket/persistence/*` → `*.dispatch.ts` helpers → Redux / Dexie → selectors → container re-render. - -## Build pipeline and generated files - -`npm start` and `npm run build` both run `prestart`/`prebuild` hooks that invoke `proto:generate` and then `node prebuild.js`. `prebuild.js` does three things: - -1. Copies shared country flag assets from `../cockatrice/resources/countries` into `src/images/countries`. -2. Writes `src/server-props.json` containing `REACT_APP_VERSION` = current `git rev-parse HEAD`. -3. Walks `src/**/*.i18n.json`, merges them into `src/i18n-default.json`, and **throws on duplicate keys** (`i18n key collision: ${key}`). Namespace your i18n keys — collisions fail the build. - -Files you should never edit by hand (all auto-generated, all committed): - -- `src/generated/proto/**` -- `src/i18n-default.json` -- `src/server-props.json` - -If `npm start` seems to be ignoring a new `.i18n.json` file or a fresh proto, run `npm run proto:generate && node prebuild.js` directly — the hooks only fire on `start`/`build`, not on `test` or `lint`. - -`.env.development`, `.env.production`, and `.env.test` exist but are empty. There is currently no env-var configuration surface; server URLs and the like are resolved through the login UI / `server-props.json`, not `import.meta.env`. - -## Testing - -Vitest + Testing Library + jsdom; `setupTests.ts` registers jest-dom matchers. - -**Vitest runs with `test.isolate: false`.** Every spec file in a worker shares the same module graph, so `vi.mock(...)` factories and any mocks they create persist across tests. Consequences: - -- The global `afterEach` in `setupTests.ts` calls `vi.clearAllMocks()` + `vi.restoreAllMocks()` + `vi.useRealTimers()`. It deliberately does **not** call `vi.resetAllMocks()`, because that would reset the implementations of `vi.fn()` instances created inside `vi.mock(...)` factories and break every spec that mocks `store.dispatch` once at file load. -- A test that installs a custom `mockReturnValue` / `mockImplementation` should not assume the next test resets it — either overwrite it or rely on `clearAllMocks` wiping only call histories. -- Always use real timers at the end of a test that switched to fake ones; the global teardown will catch leaks, but relying on it is fragile across files. - -Other conventions: - -- **Fixtures.** Store slices have co-located `__mocks__/fixtures.ts` files (notably `src/store/game/__mocks__/fixtures.ts`) exposing factories like `makeCard`, `makeGameEntry`, `makePlayerProperties`, `makeState`. They build protobuf messages via `create(Schema, overrides)`. Reuse them in new tests instead of hand-rolling proto objects. -- **Websocket mocks.** `src/websocket/__mocks__/` holds shared mock builders (e.g. `makeMockWebSocket`, `makeWebClientMock`, `makeSessionPersistenceMock`). Command and event specs install these with `vi.mock(...)` at the top of the file. -- **Slice tests are per-concern.** Each slice ships parallel `*.actions.spec.ts`, `*.reducer.spec.ts`, `*.selectors.spec.ts`, and `*.dispatch.spec.ts` files; tests don't cross concerns. - -`npm run golden` (lint + test) is the CI gate — run it before declaring work done. - -## Protocol changes - -When a task requires editing `.proto` files in `../libcockatrice_protocol/`, run `npm run proto:generate` afterwards, and: - -1. If the change introduces a new proto *file* that code outside `src/types/` needs to consume, add an `export *` line for it in `src/types/data.ts`. -2. Update any command/event/persistence code that consumes the changed messages. -3. Commit the regenerated files under `src/generated/proto/`. diff --git a/webclient/README.md b/webclient/README.md index 436ab4fad..774b84ffe 100644 --- a/webclient/README.md +++ b/webclient/README.md @@ -1,73 +1,67 @@ +# Webatrice + +The Cockatrice web client — a React/TypeScript SPA that connects to a Servatrice server over a WebSocket. + ## Application Architecture -![Application Architecture](architecture.png?raw=true "Application Architecture") -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). +![Application Architecture](architecture/simple.png?raw=true "Application Architecture") -## Available Scripts +For the full set of diagrams (detailed layer map + command/response/event sequence) and the `npm run diagram` scripts that regenerate them, see [architecture/](architecture/). For prose — WebSocket layering, Redux store shape, test conventions — see [.github/instructions/webclient.instructions.md](../.github/instructions/webclient.instructions.md). -In the project directory, you can run: +## Stack -### `npm start` +React 19 + TypeScript, built with [Vite](https://vite.dev/) 8. State via Redux Toolkit + RxJS, UI via MUI v9, tests via Vitest, protobuf bindings generated by [buf](https://buf.build/) into Protobuf-ES. -Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser. +## Prerequisites -The page will reload if you make edits.
-You will also see any lint errors in the console. +- Node.js and npm +- Run every command below from the `webclient/` directory -### `npm test` +## Getting started -Launches the test runner in the interactive watch mode.
-See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. +```bash +npm install +npm start +``` -### `npm run build` +`npm start` boots the Vite dev server and opens a browser tab at [http://localhost:5173](http://localhost:5173) automatically (configured via `server.open` in `vite.config.ts`). The first start runs `proto:generate` and `prebuild.js` via the `prestart` hook, so give it a moment. -Builds the app for production to the `build` folder.
-It correctly bundles React in production mode and optimizes the build for the best performance. +## Scripts -The build is minified and the filenames include the hashes.
-Your app is ready to be deployed! +### Dev & build -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. +- `npm start` — start the Vite dev server (runs `proto:generate` + `prebuild.js` first via `prestart`) +- `npm run build` — production build into `build/` (also runs the prebuild hooks) +- `npm run preview` — serve the built `build/` output locally to smoke-test a production build -### `npm run eject` +### Tests -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** +- `npm test` — one-shot Vitest run (unit specs) +- `npm run test:watch` — Vitest in watch mode +- `npm run test:integration` — integration specs via `vitest.integration.config.ts` +- `npm run test:coverage` / `npm run test:integration:coverage` — the above with v8 coverage -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. +### Quality -Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. +- `npm run lint` / `npm run lint:fix` — ESLint over `src/` +- `npm run golden` — `lint` + `test` + `test:integration`; the CI-equivalent gate to run before declaring work done -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. +### Codegen & i18n -## Learn More +- `npm run proto:generate` — regenerate Protobuf-ES bindings from `../libcockatrice_protocol` via `buf generate` +- `npm run translate` — re-run the i18n merge only (`prebuild.js -i18nOnly`) -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). +## Generated files -To learn React, check out the [React documentation](https://reactjs.org/). +Produced by `proto:generate` and `prebuild.js` on every `npm start` / `npm run build`. Don't edit them by hand: -## To-Do List +| File | Tracked? | Notes | +|---|---|---| +| `src/generated/proto/**` | Gitignored | Protobuf bindings. Regenerate with `npm run proto:generate`; only appears after a first local run. | +| `src/server-props.json` | Gitignored | Build metadata including the current git SHA. Written by `prebuild.js`; only appears after a first local run. | +| `src/i18n-default.json` | **Committed** | Merged i18n catalog. Regenerate with `npm run translate` and commit whenever it changes. | -1) RefreshGuard modal - - there is no browser support for displaying custom output to window.onbeforeunload - - we should also display a custom modal explaining why they shouldnt refresh or navigate from the site - - ideally, the custom popup can be synced with the alert, so when the alert is closed, the modal closes too +## Further reading -2) Disable AutoScrollToBottom when the user has scrolled up - - when the user scrolls back to bottom, it should renable - - renable after a period of inactivity (3 minutes?) - -3) Figure out how to type components w/ RouteComponentProps - - Component> - -4) clear input onSubmit - -5) figure out how to reflect server status changes in the ui - -6) Account page - -7) Register/Reset Password forms - -8) Message User - -9) Main Nav scheme +- [.github/instructions/webclient.instructions.md](../.github/instructions/webclient.instructions.md) — architecture deep dive, conventions, and domain-knowledge invariants for working in this directory (the canonical AI-tool instruction surface for this package) +- [Vite docs](https://vite.dev/guide/) · [React docs](https://react.dev/) · [Vitest docs](https://vitest.dev/) diff --git a/webclient/architecture.png b/webclient/architecture.png deleted file mode 100644 index 0226eb201..000000000 Binary files a/webclient/architecture.png and /dev/null differ diff --git a/webclient/architecture/README.md b/webclient/architecture/README.md new file mode 100644 index 000000000..55a9b9d64 --- /dev/null +++ b/webclient/architecture/README.md @@ -0,0 +1,70 @@ +# Webatrice architecture diagrams + +Three views of the same architecture at different zoom levels. The `.mmd` files are the source of truth — edit them and re-render when the architecture changes. The `.png` files are committed so the README and GitHub file view render everywhere, including offline. + +For the prose counterpart and conventions, see [../.github/instructions/webclient.instructions.md](../../.github/instructions/webclient.instructions.md) (the canonical AI-tool instruction surface for this package). + +## When to look at which + +| Diagram | Use it when | +|---|---| +| **[simple](simple.mmd)** | You need a mental model of the request/response loop in ten seconds. Good for onboarding. | +| **[detailed](detailed.mmd)** | You're making a structural change and want to see every module, which layer it belongs to, and how data moves between them. | +| **[flow](flow.mmd)** | You're debugging a specific round-trip and need to see the runtime order. Shows the `cmdId`-correlated response path vs. the extension-dispatched event path, plus the "no timeout, no retry" caveat. | + +## Simple — high-level flow + +![Simple architecture](simple.png) + +Application on the left, Servatrice on the right, two-lane racetrack in between. The top lane is outbound (`client.request.*` → `Commands`), the bottom lane is inbound (`Events · Responses` → `client.response.*`), and both lanes ride the same WebSocket. Redux hangs off Application as its in-memory store; IndexedDB sits under Servatrice as the browser-side persistent store reached from hooks via Dexie. Both are stores, both sit outside the racetrack. + +**Color = role:** + +- Blue — application code (UI, hooks, API seams, WebClient) +- Purple — transport (WebSocket layer, services) +- Amber — state / data stores (Redux, protocol types) +- Gray — external systems (Servatrice, IndexedDB) + +## Detailed — layers & dependencies + +![Detailed architecture](detailed.png) + +Every meaningful module in the webclient, arranged as a three-lane racetrack: outbound (`src/api/request/` → `commands/`) on top, transport (`WebClientProvider` → `WebClient` → `services/`) in the middle, inbound (`events/` → `src/api/response/`) on the bottom. Application bookend on the left holds UI, hooks, Redux store, and the Dexie persistence pair; Servatrice sits on the right. The protocol satellite (`src/types/` + `src/generated/proto/`) is drawn below with dashed edges up to the modules it types — it's cross-cutting, not on the flow path. Same four-role palette as the simple diagram. + +Load-bearing invariants (enforced on `webclient-websocket-layer`; keep it that way): + +- **UI never imports `@app/websocket` or `@app/api`** — always go through `useWebClient()`. +- **Only `src/types/` imports from `@app/generated`** — everywhere else uses `Data` / `Enriched` / `App`. +- **Only `*.dispatch.ts` helpers and `*ResponseImpl` classes call `store.dispatch`** — the API response layer is the single inbound seam into Redux. + +## Flow — command → response → event round-trip + +![Sequence: join room](flow.png) + +Scenario: user joins a room. The sequence shows the outbound command path (steps 1–6), the correlated response path matched by `cmdId` in `ProtobufService`'s pending map (steps 7–10), and an unsolicited server event dispatched by proto-extension match against the event registry in `processRoomEvent` / `processSessionEvent` / `processGameEvent` (steps 11–15). + +Read the footnote: `ProtobufService` has no timeout and no retry, and `resetCommands()` on reconnect silently drops in-flight callbacks. Code that needs reconnection resilience has to handle it at a higher layer. + +## Rendering + +npm scripts are defined in [../package.json](../package.json) — no separate build step, no added runtime dependency (everything runs via `npx`). + +```bash +# from the webclient/ directory: + +npm run diagram # render all three (simple + detailed + flow) +npm run diagram:simple # render just simple.png +npm run diagram:detailed # render just detailed.png +npm run diagram:flow # render just flow.png +``` + +Under the hood each command is: + +```bash +npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc \ + -i architecture/.mmd -o architecture/.png -b white -s 2 +``` + +`-s 2` renders at 2× scale so the PNG stays crisp on high-DPI displays; `-b white` gives the diagrams a light-mode background that looks right in both GitHub's light and dark themes. + +If `mmdc` fails locally (it spawns headless Chromium — some sandboxed environments block that), paste the `.mmd` contents into [mermaid.live](https://mermaid.live) and export to PNG. The `.mmd` sources remain canonical either way. diff --git a/webclient/architecture/detailed.mmd b/webclient/architecture/detailed.mmd new file mode 100644 index 000000000..112aca658 --- /dev/null +++ b/webclient/architecture/detailed.mmd @@ -0,0 +1,123 @@ +--- +config: + layout: elk + theme: base + themeVariables: + background: "#ffffff" + primaryColor: "#ffffff" + primaryBorderColor: "#1f2937" + primaryTextColor: "#0b1220" + lineColor: "#1f2937" + textColor: "#0b1220" + edgeLabelBackground: "#ffffff" + fontSize: "20px" + clusterBkg: "#fafafa" + clusterBorder: "#9ca3af" + flowchart: + htmlLabels: true + curve: basis + nodeSpacing: 60 + rankSpacing: 90 +--- +flowchart LR + %% ========================================================= + %% Left bookend — browser-side Application + %% ========================================================= + subgraph LBE["Application"] + direction TB + UI["UI
containers · components
forms · dialogs
"] + Hooks["hooks/
useWebClient · useAutoLogin
useSettings · useKnownHosts
"] + Store[("@app/store
server · rooms · game
actions · common
")] + DTOs["dexie DTOs
Card · Host · Set
Setting · Token
"] + IDB[("IndexedDB")] + end + + %% ========================================================= + %% Racetrack — three lanes: outbound / transport / inbound + %% ========================================================= + subgraph RACE[" "] + direction TB + + subgraph TOP["Outbound lane"] + direction LR + Req["src/api/request/
Authentication · Session · Rooms
Game · Admin · Moderator
"] + Cmds["commands/
session · room · game
admin · moderator
"] + end + + subgraph MID["Transport"] + direction LR + Provider["WebClientProvider"] + WC[["WebClient
singleton · request · response"]] + Svc["services/
ProtobufService · WebSocketService
KeepAliveService · command-options
"] + end + + subgraph BOT["Inbound lane"] + direction LR + Evts["events/
session · room · game"] + Res["src/api/response/
Session · Room · Game
Admin · Moderator
"] + end + end + + %% ========================================================= + %% Right bookend — Servatrice + %% ========================================================= + Srv[("Servatrice")] + + %% ========================================================= + %% Protocol satellite — cross-cutting types + %% ========================================================= + subgraph PROTO["Protocol (cross-cutting)"] + direction LR + Types["src/types/
Data · Enriched · App"] + Gen["src/generated/proto/
@bufbuild/protobuf"] + end + + %% ========================================================= + %% UI-side wiring + %% ========================================================= + UI --> Hooks + Hooks -- "useWebClient()" --> Provider + Provider --> WC + UI -- "selectors (read)" --> Store + Hooks --> DTOs + DTOs <--> IDB + + %% ========================================================= + %% Outbound — request goes up through the top lane to Srv + %% ========================================================= + WC --> Req + Req --> Cmds + Cmds --> Svc + Svc -- "frames" --> Srv + + %% ========================================================= + %% Inbound — Srv comes back through services, splits to + %% cmdId response (direct) and event-registry dispatch + %% ========================================================= + Srv -- "frames" --> Svc + Svc --> Evts + Svc -- "response by cmdId" --> Res + Evts --> Res + Res -- "dispatch" --> Store + + %% ========================================================= + %% Protocol edges — dashed, cross-cutting + %% ========================================================= + Req -.-> Types + Res -.-> Types + Cmds -.-> Types + Evts -.-> Types + Types --> Gen + + %% ========================================================= + %% Palette — four roles + %% ========================================================= + classDef app fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef transport fill:#ede9fe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef store fill:#fde68a,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef external fill:#e5e7eb,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + + class UI,Hooks,DTOs,Provider,WC app + class Req,Cmds,Svc,Evts,Res transport + class Store,Types,Gen store + class Srv,IDB external diff --git a/webclient/architecture/detailed.png b/webclient/architecture/detailed.png new file mode 100644 index 000000000..16d212392 Binary files /dev/null and b/webclient/architecture/detailed.png differ diff --git a/webclient/architecture/flow.mmd b/webclient/architecture/flow.mmd new file mode 100644 index 000000000..030800782 --- /dev/null +++ b/webclient/architecture/flow.mmd @@ -0,0 +1,41 @@ +%%{init: {"theme": "base", "themeVariables": {"background": "#ffffff", "primaryColor": "#ffffff", "primaryBorderColor": "#1f2937", "primaryTextColor": "#0b1220", "lineColor": "#1f2937", "textColor": "#0b1220", "signalColor": "#1f2937", "signalTextColor": "#0b1220", "actorBkg": "#ffedd5", "actorBorder": "#1f2937", "actorTextColor": "#0b1220", "actorLineColor": "#1f2937", "sequenceNumberColor": "#ffffff", "noteBkgColor": "#fef3c7", "noteBorderColor": "#1f2937", "noteTextColor": "#0b1220", "labelBoxBkgColor": "#ffffff", "labelTextColor": "#0b1220"}, "sequence": {"showSequenceNumbers": true, "mirrorActors": false, "messageFontSize": "13px", "actorFontSize": "13px", "noteFontSize": "12px", "actorMargin": 14, "boxMargin": 6, "boxTextMargin": 3, "noteMargin": 8, "messageMargin": 48, "diagramMarginX": 8, "diagramMarginY": 8, "width": 120}}}%% +sequenceDiagram + autonumber + + participant C as Application + participant RQ as API Request + participant CMD as Command + participant PB as Protobuf + participant WS as WebSocket + participant S as Servatrice + participant EV as Event + participant RS as API Response + + rect rgb(219, 234, 254) + Note over C,S: Request — user action → command + C->>RQ: joinRoom(roomId) + RQ->>CMD: build RoomCommand + CMD->>PB: sendRoomCommand(cmd, onSuccess/onError) + PB->>PB: assign cmdId,
register callback + PB->>WS: send(bytes) + WS->>S: Command + end + + rect rgb(254, 243, 199) + Note over S,RS: Response — correlated by cmdId + S-->>WS: Response (cmdId) + WS-->>PB: processServerResponse + PB->>RS: onSuccess(response) + RS->>RS: dispatch → Redux + end + + rect rgb(220, 252, 231) + Note over S,RS: Event — no cmdId — dispatched by extension + S-->>WS: Event + WS-->>PB: processRoomEvent + PB->>EV: pick handler by extension + EV->>RS: roomEvent(...) + RS->>RS: dispatch → Redux + end + + Note over PB,WS: No timeout, no retry.
resetCommands() on reconnect
silently drops pending callbacks. diff --git a/webclient/architecture/flow.png b/webclient/architecture/flow.png new file mode 100644 index 000000000..39a82cef7 Binary files /dev/null and b/webclient/architecture/flow.png differ diff --git a/webclient/architecture/simple.mmd b/webclient/architecture/simple.mmd new file mode 100644 index 000000000..19bba7d21 --- /dev/null +++ b/webclient/architecture/simple.mmd @@ -0,0 +1,58 @@ +--- +config: + theme: base + themeVariables: + background: "#ffffff" + primaryColor: "#ffffff" + primaryBorderColor: "#1f2937" + primaryTextColor: "#0b1220" + lineColor: "#1f2937" + textColor: "#0b1220" + edgeLabelBackground: "#ffffff" + fontSize: "18px" + clusterBkg: "#ffffff" + clusterBorder: "#9ca3af" + flowchart: + htmlLabels: true + curve: basis + nodeSpacing: 55 + rankSpacing: 90 +--- +flowchart LR + subgraph APP_COL[" "] + direction TB + App["Application
containers · components · hooks"] + Rdx[("Redux
in-memory state")] + end + + subgraph SRV_COL[" "] + direction TB + Srv[("Servatrice")] + IDB[("IndexedDB
local persistent store")] + end + + Req["client.request"] + Res["client.response"] + + %% Outbound lane (top) + App -- "useWebClient()" --> Req + Req -- "Commands" --> Srv + + %% Inbound lane (bottom) + Srv -- "Events · Responses" --> Res + Res -- "dispatch · rerender" --> App + + %% Local stores — Application owns both; edges only to IndexedDB + %% (Redux state is implicit — reducers sit under dispatch, selectors under rerender) + App -. "Dexie: settings · hosts · cards" .-> IDB + + %% Palette — four roles + classDef app fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef seam fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef store fill:#fde68a,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef external fill:#e5e7eb,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + + class App app + class Req,Res seam + class Rdx store + class Srv,IDB external diff --git a/webclient/architecture/simple.png b/webclient/architecture/simple.png new file mode 100644 index 000000000..0e040a2bb Binary files /dev/null and b/webclient/architecture/simple.png differ diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs index d2ca95aa1..d79272665 100644 --- a/webclient/eslint.boundaries.mjs +++ b/webclient/eslint.boundaries.mjs @@ -12,6 +12,7 @@ const elements = [ { type: 'services', pattern: ['src/services/**'] }, { type: 'store', pattern: ['src/store/**'] }, { type: 'types', pattern: ['src/types/**'] }, + { type: 'websocket-types', pattern: ['src/websocket/types/**'] }, { type: 'websocket', pattern: ['src/websocket/**'] }, ]; @@ -19,26 +20,27 @@ const types = (...types) => types.map((type) => ({ to: { type } })); const rules = [ { from: { type: 'generated' }, allow: [] }, - { from: { type: 'websocket' }, allow: types('generated') }, - { from: { type: 'types' }, allow: types('generated', 'websocket') }, + { from: { type: 'websocket-types' }, allow: types('generated') }, + { from: { type: 'websocket' }, allow: types('generated', 'websocket-types') }, + { from: { type: 'types' }, allow: types('generated') }, - { from: { type: 'store' }, allow: types('types') }, - { from: { type: 'api' }, allow: types('store', 'types', 'websocket') }, + { from: { type: 'store' }, allow: types('types', 'websocket-types') }, + { from: { type: 'api' }, allow: types('store', 'types', 'websocket', 'websocket-types') }, { from: { type: 'images' }, allow: types('types') }, { from: { type: 'services' }, allow: types('api', 'store', 'types') }, - { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket') }, + { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket', 'websocket-types') }, { from: { type: 'components' }, - allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types') }, { from: { type: 'containers' }, - allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types') }, - { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types') }, - { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types') }, + { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'websocket-types') }, + { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'websocket-types') }, ]; export const boundariesConfig = [ diff --git a/webclient/integration/src/app/login-autoconnect.spec.tsx b/webclient/integration/src/app/login-autoconnect.spec.tsx index 8f11c1843..f68d1ef6d 100644 --- a/webclient/integration/src/app/login-autoconnect.spec.tsx +++ b/webclient/integration/src/app/login-autoconnect.spec.tsx @@ -25,14 +25,14 @@ const flushStoresAndEffects = async (): Promise => { }); }; -import { autoLoginSession } from '../../../src/hooks/useAutoLogin'; +import { autoLoginGate } from '../../../src/hooks/useAutoLogin'; import { settingsStore } from '../../../src/hooks/useSettings'; import { knownHostsStore } from '../../../src/hooks/useKnownHosts'; import Login from '../../../src/containers/Login/Login'; import { HostDTO, SettingDTO } from '@app/services'; import { App } from '@app/types'; import { ServerSelectors, ServerDispatch } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { resetDexie } from '../services/dexie/resetDexie'; import { renderAppScreen, store } from './helpers'; @@ -41,7 +41,7 @@ import { renderAppScreen, store } from './helpers'; // dispatching updateStatus(DISCONNECTED) is what the real reducer uses to // clear connectionAttemptMade (clearStore intentionally preserves status). const simulateLogout = () => { - ServerDispatch.updateStatus(StatusEnum.DISCONNECTED, null); + ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null); }; const seedAutoConnect = async () => { @@ -86,7 +86,7 @@ beforeEach(async () => { // cached values). settingsStore.reset(); knownHostsStore.reset(); - autoLoginSession.startupCheckRan = false; + autoLoginGate.hasChecked = false; }); describe('autoconnect — cold start', () => { @@ -182,7 +182,7 @@ describe('autoconnect — refresh', () => { // Simulate a browser refresh: the session gate naturally resets on a // fresh JS context, and the real connection flag resets too. simulateLogout(); - autoLoginSession.startupCheckRan = false; + autoLoginGate.hasChecked = false; renderAppScreen(); await waitFor(() => { diff --git a/webclient/integration/src/helpers/setup.ts b/webclient/integration/src/helpers/setup.ts index 190f29d97..e9bfc56de 100644 --- a/webclient/integration/src/helpers/setup.ts +++ b/webclient/integration/src/helpers/setup.ts @@ -19,13 +19,8 @@ import { afterEach, beforeEach, vi } from 'vitest'; import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store'; import { Data } from '@app/types'; -import { - WebClient, - StatusEnum, - WebSocketConnectReason, - setPendingOptions, -} from '@app/websocket'; -import type { WebSocketConnectOptions } from '@app/websocket'; +import { WebClient, setPendingOptions } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { PROTOCOL_VERSION } from '../../../src/websocket/config'; import { createWebClientRequest, createWebClientResponse } from '@app/api'; @@ -109,7 +104,7 @@ function resetAll(): void { } client.protobuf.resetCommands(); - client.status = StatusEnum.DISCONNECTED; + client.status = WebsocketTypes.StatusEnum.DISCONNECTED; ServerDispatch.clearStore(); RoomsDispatch.clearStore(); @@ -128,8 +123,8 @@ function resetAll(): void { // ── Shared connect helpers ────────────────────────────────────────────────── -const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = { - reason: WebSocketConnectReason.LOGIN, +const DEFAULT_LOGIN_OPTIONS: WebsocketTypes.WebSocketConnectOptions = { + reason: WebsocketTypes.WebSocketConnectReason.LOGIN, host: 'localhost', port: '4748', userName: 'alice', @@ -137,16 +132,16 @@ const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = { }; export function connectRaw( - overrides: Partial = {} + overrides: Partial = {} ): void { const opts = { ...DEFAULT_LOGIN_OPTIONS, ...overrides }; - setPendingOptions(opts as WebSocketConnectOptions); + setPendingOptions(opts as WebsocketTypes.WebSocketConnectOptions); getWebClient().connect({ host: opts.host, port: opts.port }); openMockWebSocket(); } export function connectAndHandshake( - overrides: Partial = {} + overrides: Partial = {} ): void { connectRaw(overrides); deliverMessage(buildSessionEventMessage( @@ -160,7 +155,7 @@ export function connectAndHandshake( } export function connectAndHandshakeWithSalt( - overrides: Partial = {} + overrides: Partial = {} ): void { connectRaw(overrides); deliverMessage(buildSessionEventMessage( diff --git a/webclient/integration/src/websocket/authentication.spec.ts b/webclient/integration/src/websocket/authentication.spec.ts index 2da411aad..c2fc04428 100644 --- a/webclient/integration/src/websocket/authentication.spec.ts +++ b/webclient/integration/src/websocket/authentication.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { connectAndHandshake, connectAndHandshakeWithSalt } from '../helpers/setup'; import { @@ -44,7 +44,7 @@ describe('authentication', () => { }))); const state = store.getState().server; - expect(state.status.state).toBe(StatusEnum.LOGGED_IN); + expect(state.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); expect(state.status.description).toBe('Logged in.'); expect(state.user?.name).toBe('alice'); expect(Object.keys(state.buddyList)).toEqual(['bob']); @@ -64,7 +64,7 @@ describe('authentication', () => { }))); const state = store.getState().server; - expect(state.status.state).toBe(StatusEnum.DISCONNECTED); + expect(state.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(state.user).toBeNull(); expect(state.buddyList).toEqual({}); }); @@ -72,7 +72,7 @@ describe('authentication', () => { describe('register', () => { const registerOptions = { - reason: WebSocketConnectReason.REGISTER as const, + reason: WebsocketTypes.WebSocketConnectReason.REGISTER as const, host: 'localhost', port: '4748', userName: 'newbie', @@ -107,7 +107,7 @@ describe('authentication', () => { responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation, }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); }); }); @@ -115,7 +115,7 @@ describe('authentication', () => { describe('activate', () => { it('auto-logs-in on RespActivationAccepted', () => { connectAndHandshake({ - reason: WebSocketConnectReason.ACTIVATE_ACCOUNT as const, + reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT as const, host: 'localhost', port: '4748', userName: 'alice', @@ -171,7 +171,7 @@ describe('authentication', () => { }), }))); - expect(store.getState().server.status.state).toBe(StatusEnum.LOGGED_IN); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); }); }); }); diff --git a/webclient/integration/src/websocket/connection.spec.ts b/webclient/integration/src/websocket/connection.spec.ts index 2304df399..1903bb75d 100644 --- a/webclient/integration/src/websocket/connection.spec.ts +++ b/webclient/integration/src/websocket/connection.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { PROTOCOL_VERSION } from '../../../src/websocket/config'; @@ -18,17 +18,15 @@ import { setPendingOptions, connectAndHandshake, } from '../helpers/setup'; -import type { WebSocketConnectOptions } from '@app/websocket'; -import { WebSocketConnectReason } from '@app/websocket'; import { buildSessionEventMessage, deliverMessage, } from '../helpers/protobuf-builders'; import { findLastSessionCommand } from '../helpers/command-capture'; -function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions { +function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebsocketTypes.WebSocketConnectOptions { return { - reason: WebSocketConnectReason.LOGIN, + reason: WebsocketTypes.WebSocketConnectReason.LOGIN, host: 'localhost', port: '4748', userName: overrides.userName ?? 'alice', @@ -36,7 +34,7 @@ function loginOptions(overrides: Partial<{ userName: string; password: string }> }; } -function connectWithOptions(opts: WebSocketConnectOptions): void { +function connectWithOptions(opts: WebsocketTypes.WebSocketConnectOptions): void { setPendingOptions(opts); getWebClient().connect({ host: opts.host, port: opts.port }); } @@ -63,7 +61,7 @@ describe('connection lifecycle', () => { openMockWebSocket(); - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); expect(store.getState().server.status.description).toBe('Connected'); }); @@ -73,7 +71,7 @@ describe('connection lifecycle', () => { deliverMessage(serverIdentification()); - expect(store.getState().server.status.state).toBe(StatusEnum.LOGGING_IN); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.LOGGING_IN); expect(store.getState().server.info.name).toBe('TestServer'); expect(store.getState().server.info.version).toBe('2.8.0'); @@ -90,7 +88,7 @@ describe('connection lifecycle', () => { const mock = getMockWebSocket(); expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); }); @@ -103,7 +101,7 @@ describe('connection lifecycle', () => { vi.advanceTimersByTime(5000); expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('releases keep-alive ping loop on explicit disconnect', () => { @@ -115,7 +113,7 @@ describe('connection lifecycle', () => { getWebClient().disconnect(); expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('drops pending commands and clears state on unexpected socket close', () => { @@ -129,6 +127,6 @@ describe('connection lifecycle', () => { mock.readyState = 3; mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); \ No newline at end of file diff --git a/webclient/integration/src/websocket/keep-alive.spec.ts b/webclient/integration/src/websocket/keep-alive.spec.ts index 90ee634e0..0f6d6f4b5 100644 --- a/webclient/integration/src/websocket/keep-alive.spec.ts +++ b/webclient/integration/src/websocket/keep-alive.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { connectRaw, getMockWebSocket } from '../helpers/setup'; import { @@ -32,7 +32,7 @@ describe('keep-alive', () => { vi.advanceTimersByTime(5000); const second = findLastSessionCommand(Data.Command_Ping_ext); expect(second.cmdId).toBeGreaterThan(first.cmdId); - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); }); it('stays CONNECTED while pongs arrive before the next tick', () => { @@ -47,7 +47,7 @@ describe('keep-alive', () => { }))); } - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); expect(getMockWebSocket().close).not.toHaveBeenCalled(); }); @@ -56,11 +56,11 @@ describe('keep-alive', () => { vi.advanceTimersByTime(5000); expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow(); - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); vi.advanceTimersByTime(5000); expect(getMockWebSocket().close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); diff --git a/webclient/integration/src/websocket/password-reset.spec.ts b/webclient/integration/src/websocket/password-reset.spec.ts index 998390304..f4aa48901 100644 --- a/webclient/integration/src/websocket/password-reset.spec.ts +++ b/webclient/integration/src/websocket/password-reset.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { connectAndHandshake } from '../helpers/setup'; import { @@ -19,7 +19,7 @@ import { findLastSessionCommand } from '../helpers/command-capture'; describe('password reset', () => { it('forgotPasswordRequest sends command and disconnects on success', () => { connectAndHandshake({ - reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST as const, + reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST as const, host: 'localhost', port: '4748', userName: 'alice', @@ -37,12 +37,12 @@ describe('password reset', () => { }), }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('forgotPasswordChallenge sends command with userName and email', () => { connectAndHandshake({ - reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const, + reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const, host: 'localhost', port: '4748', userName: 'alice', @@ -58,12 +58,12 @@ describe('password reset', () => { responseCode: Data.Response_ResponseCode.RespOk, }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('forgotPasswordReset sends command with userName, token, and newPassword', () => { connectAndHandshake({ - reason: WebSocketConnectReason.PASSWORD_RESET as const, + reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET as const, host: 'localhost', port: '4748', userName: 'alice', @@ -81,6 +81,6 @@ describe('password reset', () => { responseCode: Data.Response_ResponseCode.RespOk, }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); \ No newline at end of file diff --git a/webclient/integration/src/websocket/server-events.spec.ts b/webclient/integration/src/websocket/server-events.spec.ts index a0eefebf1..0a27a9669 100644 --- a/webclient/integration/src/websocket/server-events.spec.ts +++ b/webclient/integration/src/websocket/server-events.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { connectAndHandshake } from '../helpers/setup'; import { @@ -73,7 +73,7 @@ describe('server events', () => { )); const status = store.getState().server.status; - expect(status.state).toBe(StatusEnum.DISCONNECTED); + expect(status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(status.description).toBe('kicked by admin'); }); diff --git a/webclient/package.json b/webclient/package.json index 422b2e517..386070a37 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -19,7 +19,11 @@ "golden:coverage": "npm run lint && npm run test:coverage && npm run test:integration:coverage", "prepare": "cd .. && husky", "translate": "node prebuild.js -i18nOnly", - "proto:generate": "npx buf generate" + "proto:generate": "npx buf generate", + "diagram": "npm run diagram:simple && npm run diagram:detailed && npm run diagram:flow", + "diagram:simple": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/simple.mmd -o architecture/simple.png -b white -s 2", + "diagram:detailed": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/detailed.mmd -o architecture/detailed.png -b white -s 2", + "diagram:flow": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/flow.mmd -o architecture/flow.png -b white -s 2" }, "dependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/webclient/src/__test-utils__/globalGuards.ts b/webclient/src/__test-utils__/globalGuards.ts index b350d440f..ee39ee223 100644 --- a/webclient/src/__test-utils__/globalGuards.ts +++ b/webclient/src/__test-utils__/globalGuards.ts @@ -1,15 +1,9 @@ -// Shared lifecycle helpers for test files that need to mutate global state. -// -// The root `setupTests.ts` guards catch leaks even when callers forget to -// clean up, but opt-in helpers make intent explicit at the call site and -// avoid piling cleanup logic onto the shared safety net. - /** * Temporarily override fields on `window.location` and return a restore fn. * - * `Object.defineProperty(window, 'location', ...)` is not a `vi.spyOn` target, - * so `vi.restoreAllMocks()` will NOT undo it. Always pair with the returned - * `restore` callback (ideally in `afterEach`). + * @critical `Object.defineProperty(window, 'location', ...)` isn't a vi.spyOn + * target, so `vi.restoreAllMocks()` will NOT undo it. Always invoke the + * returned restore callback. */ export function withMockLocation(overrides: Partial): () => void { const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); @@ -26,23 +20,3 @@ export function withMockLocation(overrides: Partial): () => void { } }; } - -/** - * Push an entry onto a shared event-handler registry array and return a - * teardown function that removes exactly that entry. - * - * Used by ProtobufService specs which install temporary handlers into the - * (mocked) `GameEvents` / `RoomEvents` / `SessionEvents` arrays. Manual - * `.push()`/`.pop()` inside a test body corrupts the array if an assertion - * throws between them — this helper makes the teardown safe to run in - * `afterEach`. - */ -export function withEventRegistry(registry: T[], entry: T): () => void { - registry.push(entry); - return () => { - const index = registry.lastIndexOf(entry); - if (index !== -1) { - registry.splice(index, 1); - } - }; -} diff --git a/webclient/src/__test-utils__/index.ts b/webclient/src/__test-utils__/index.ts index 4deac33d2..7ee47e601 100644 --- a/webclient/src/__test-utils__/index.ts +++ b/webclient/src/__test-utils__/index.ts @@ -1,4 +1,4 @@ -export { withMockLocation, withEventRegistry } from './globalGuards'; +export { withMockLocation } from './globalGuards'; export { renderWithProviders } from './renderWithProviders'; export { createMockWebClient } from './mockWebClient'; export { disconnectedState, connectedState, connectedWithRoomsState, makeUser } from './storeFixtures'; diff --git a/webclient/src/__test-utils__/mockWebClient.ts b/webclient/src/__test-utils__/mockWebClient.ts index 3faafe391..875a4a9d9 100644 --- a/webclient/src/__test-utils__/mockWebClient.ts +++ b/webclient/src/__test-utils__/mockWebClient.ts @@ -2,8 +2,9 @@ import type { WebClient } from '@app/websocket'; /** * Creates a mock WebClient whose `request` property has vi.fn() stubs - * for every service method that containers/forms call. Inject this into - * tests via `renderWithProviders({ webClient: createMockWebClient() })`. + * for every service method that containers/forms call. Inject via a + * vi.hoisted reference returned from a `vi.mock('@app/hooks', ...)` stub + * of `useWebClient`; see LoginForm.spec.tsx for the canonical pattern. */ export function createMockWebClient() { return { diff --git a/webclient/src/__test-utils__/renderWithProviders.tsx b/webclient/src/__test-utils__/renderWithProviders.tsx index 4d0546083..7d78f171b 100644 --- a/webclient/src/__test-utils__/renderWithProviders.tsx +++ b/webclient/src/__test-utils__/renderWithProviders.tsx @@ -14,12 +14,8 @@ import { actionReducer } from '../store/actions'; import { ToastProvider } from '../components/Toast/ToastContext'; import type { RootState } from '../store/store'; -// Minimal i18n instance for tests — returns keys as-is. A non-empty -// `resources` entry is required so i18next registers `en-US` as a known -// language; otherwise `i18n.resolvedLanguage` stays `undefined`, which -// LanguageDropdown seeds into a MUI Select and MUI warns "out-of-range -// value `undefined`". Value is an empty translation map, since tests -// already assert on i18n keys directly. +// Non-empty `resources` registers en-US so `resolvedLanguage` is defined; +// without it MUI warns about out-of-range Select values. const testI18n = i18n.createInstance(); testI18n.use(initReactI18next).init({ lng: 'en-US', diff --git a/webclient/src/__test-utils__/storeFixtures.ts b/webclient/src/__test-utils__/storeFixtures.ts index 9733fe6ef..2d7a57878 100644 --- a/webclient/src/__test-utils__/storeFixtures.ts +++ b/webclient/src/__test-utils__/storeFixtures.ts @@ -1,4 +1,5 @@ -import { App, Data, Enriched } from '@app/types'; +import { App, Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import type { RootState } from '../store/store'; /** @@ -30,7 +31,7 @@ export const disconnectedState: Partial = { ignoreList: {}, status: { connectionAttemptMade: false, - state: Enriched.StatusEnum.DISCONNECTED, + state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null, }, info: { message: null, name: null, version: null }, @@ -77,7 +78,7 @@ export const connectedState: Partial = { initialized: true, status: { connectionAttemptMade: true, - state: Enriched.StatusEnum.LOGGED_IN, + state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null, }, info: { diff --git a/webclient/src/api/request/AdminRequestImpl.ts b/webclient/src/api/request/AdminRequestImpl.ts index 5cc21695b..1c22d7be6 100644 --- a/webclient/src/api/request/AdminRequestImpl.ts +++ b/webclient/src/api/request/AdminRequestImpl.ts @@ -1,7 +1,7 @@ -import type { IAdminRequest } from '@app/websocket'; import { AdminCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class AdminRequestImpl implements IAdminRequest { +export class AdminRequestImpl implements WebsocketTypes.IAdminRequest { adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void { AdminCommands.adjustMod(userName, shouldBeMod, shouldBeJudge); } diff --git a/webclient/src/api/request/AuthenticationRequestImpl.ts b/webclient/src/api/request/AuthenticationRequestImpl.ts index 91788d862..ece8a1891 100644 --- a/webclient/src/api/request/AuthenticationRequestImpl.ts +++ b/webclient/src/api/request/AuthenticationRequestImpl.ts @@ -1,70 +1,58 @@ import { WebClient, - StatusEnum, SessionCommands, - WebSocketConnectReason, setPendingOptions, } from '@app/websocket'; -import type { - IAuthenticationRequest, - AuthRequestMap, - LoginConnectOptions, - TestConnectionOptions, - RegisterConnectOptions, - ActivateConnectOptions, - PasswordResetRequestConnectOptions, - PasswordResetChallengeConnectOptions, - PasswordResetConnectOptions, -} from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -interface AppAuthRequestOverrides extends AuthRequestMap { - LoginParams: Omit; - ConnectTarget: Omit; - RegisterParams: Omit; - ActivateParams: Omit; - ForgotPasswordRequestParams: Omit; - ForgotPasswordChallengeParams: Omit; - ForgotPasswordResetParams: Omit; +interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap { + LoginParams: Omit; + ConnectTarget: Omit; + RegisterParams: Omit; + ActivateParams: Omit; + ForgotPasswordRequestParams: Omit; + ForgotPasswordChallengeParams: Omit; + ForgotPasswordResetParams: Omit; } -export class AuthenticationRequestImpl implements IAuthenticationRequest { - login(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.LOGIN }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); +export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest { + login(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.LOGIN }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - testConnection(options: Omit): void { + testConnection(options: Omit): void { WebClient.instance.testConnect({ host: options.host, port: options.port }); } - register(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.REGISTER }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + register(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.REGISTER }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - activateAccount(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + activateAccount(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordRequest(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + resetPasswordRequest(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordChallenge(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + resetPasswordChallenge(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPassword(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + resetPassword(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } diff --git a/webclient/src/api/request/GameRequestImpl.ts b/webclient/src/api/request/GameRequestImpl.ts index e594118cc..78a9dc829 100644 --- a/webclient/src/api/request/GameRequestImpl.ts +++ b/webclient/src/api/request/GameRequestImpl.ts @@ -1,9 +1,9 @@ -import type { IGameRequest } from '@app/websocket'; import { GameCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { Data } from '@app/types'; -export class GameRequestImpl implements IGameRequest { +export class GameRequestImpl implements WebsocketTypes.IGameRequest { leaveGame(gameId: number): void { GameCommands.leaveGame(gameId); } diff --git a/webclient/src/api/request/ModeratorRequestImpl.ts b/webclient/src/api/request/ModeratorRequestImpl.ts index 9f5c063da..97984e397 100644 --- a/webclient/src/api/request/ModeratorRequestImpl.ts +++ b/webclient/src/api/request/ModeratorRequestImpl.ts @@ -1,8 +1,8 @@ import { Data } from '@app/types'; -import type { IModeratorRequest } from '@app/websocket'; import { ModeratorCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class ModeratorRequestImpl implements IModeratorRequest { +export class ModeratorRequestImpl implements WebsocketTypes.IModeratorRequest { banFromServer( minutes: number, userName?: string, diff --git a/webclient/src/api/request/RoomsRequestImpl.ts b/webclient/src/api/request/RoomsRequestImpl.ts index 62b1f4e9b..e7264ca3b 100644 --- a/webclient/src/api/request/RoomsRequestImpl.ts +++ b/webclient/src/api/request/RoomsRequestImpl.ts @@ -1,7 +1,7 @@ -import type { IRoomsRequest } from '@app/websocket'; import { RoomCommands, SessionCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class RoomsRequestImpl implements IRoomsRequest { +export class RoomsRequestImpl implements WebsocketTypes.IRoomsRequest { joinRoom(roomId: number): void { SessionCommands.joinRoom(roomId); } diff --git a/webclient/src/api/request/SessionRequestImpl.ts b/webclient/src/api/request/SessionRequestImpl.ts index c7b0e267a..d0ddf472f 100644 --- a/webclient/src/api/request/SessionRequestImpl.ts +++ b/webclient/src/api/request/SessionRequestImpl.ts @@ -1,7 +1,7 @@ -import type { ISessionRequest } from '@app/websocket'; import { SessionCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class SessionRequestImpl implements ISessionRequest { +export class SessionRequestImpl implements WebsocketTypes.ISessionRequest { addToBuddyList(userName: string): void { SessionCommands.addToBuddyList(userName); } diff --git a/webclient/src/api/request/index.ts b/webclient/src/api/request/index.ts index b5934d72a..77093ff38 100644 --- a/webclient/src/api/request/index.ts +++ b/webclient/src/api/request/index.ts @@ -1,4 +1,4 @@ -import type { IWebClientRequest } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { AuthenticationRequestImpl } from './AuthenticationRequestImpl'; import { SessionRequestImpl } from './SessionRequestImpl'; @@ -9,7 +9,7 @@ import { ModeratorRequestImpl } from './ModeratorRequestImpl'; export { AuthenticationRequestImpl, SessionRequestImpl, RoomsRequestImpl, GameRequestImpl, AdminRequestImpl, ModeratorRequestImpl }; -export function createWebClientRequest(): IWebClientRequest { +export function createWebClientRequest(): WebsocketTypes.IWebClientRequest { return { authentication: new AuthenticationRequestImpl(), session: new SessionRequestImpl(), diff --git a/webclient/src/api/response/AdminResponseImpl.ts b/webclient/src/api/response/AdminResponseImpl.ts index a47b0f40b..511357bee 100644 --- a/webclient/src/api/response/AdminResponseImpl.ts +++ b/webclient/src/api/response/AdminResponseImpl.ts @@ -1,7 +1,7 @@ -import type { IAdminResponse } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { ServerDispatch } from '@app/store'; -export class AdminResponseImpl implements IAdminResponse { +export class AdminResponseImpl implements WebsocketTypes.IAdminResponse { adjustMod(userName: string, shouldBeMod: boolean, shouldBeJudge: boolean): void { ServerDispatch.adjustMod(userName, shouldBeMod, shouldBeJudge); } diff --git a/webclient/src/api/response/GameResponseImpl.ts b/webclient/src/api/response/GameResponseImpl.ts index 01978e818..723b8dbcb 100644 --- a/webclient/src/api/response/GameResponseImpl.ts +++ b/webclient/src/api/response/GameResponseImpl.ts @@ -1,8 +1,8 @@ import { Data } from '@app/types'; -import type { IGameResponse } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { GameDispatch } from '@app/store'; -export class GameResponseImpl implements IGameResponse { +export class GameResponseImpl implements WebsocketTypes.IGameResponse { clearStore(): void { GameDispatch.clearStore(); } diff --git a/webclient/src/api/response/ModeratorResponseImpl.ts b/webclient/src/api/response/ModeratorResponseImpl.ts index c855152af..6bb9b2b8a 100644 --- a/webclient/src/api/response/ModeratorResponseImpl.ts +++ b/webclient/src/api/response/ModeratorResponseImpl.ts @@ -1,8 +1,8 @@ import { Data } from '@app/types'; -import type { IModeratorResponse } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { ServerDispatch } from '@app/store'; -export class ModeratorResponseImpl implements IModeratorResponse { +export class ModeratorResponseImpl implements WebsocketTypes.IModeratorResponse { banFromServer(userName: string): void { ServerDispatch.banFromServer(userName); } diff --git a/webclient/src/api/response/RoomResponseImpl.ts b/webclient/src/api/response/RoomResponseImpl.ts index d450158c0..38a8ee7d4 100644 --- a/webclient/src/api/response/RoomResponseImpl.ts +++ b/webclient/src/api/response/RoomResponseImpl.ts @@ -1,10 +1,10 @@ import { Data } from '@app/types'; -import type { IRoomResponse, WebSocketRoomResponseOverrides } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { RoomsDispatch } from '@app/store'; -type Message = WebSocketRoomResponseOverrides['Event_RoomSay']; +type Message = WebsocketTypes.WebSocketRoomResponseOverrides['Event_RoomSay']; -export class RoomResponseImpl implements IRoomResponse { +export class RoomResponseImpl implements WebsocketTypes.IRoomResponse { clearStore(): void { RoomsDispatch.clearStore(); } diff --git a/webclient/src/api/response/SessionResponseImpl.ts b/webclient/src/api/response/SessionResponseImpl.ts index 59892d08d..150a942f7 100644 --- a/webclient/src/api/response/SessionResponseImpl.ts +++ b/webclient/src/api/response/SessionResponseImpl.ts @@ -1,12 +1,11 @@ import { Data } from '@app/types'; -import type { ISessionResponse, WebSocketSessionResponseOverrides } from '@app/websocket'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store'; -type LoginSuccess = WebSocketSessionResponseOverrides['Response_Login']; -type PendingActivation = WebSocketSessionResponseOverrides['Response']; +type LoginSuccess = WebsocketTypes.WebSocketSessionResponseOverrides['Response_Login']; +type PendingActivation = WebsocketTypes.WebSocketSessionResponseOverrides['Response']; -export class SessionResponseImpl implements ISessionResponse { +export class SessionResponseImpl implements WebsocketTypes.ISessionResponse { initialized(): void { ServerDispatch.initialized(); } @@ -67,8 +66,8 @@ export class SessionResponseImpl implements ISessionResponse { return ( diff --git a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx index 5043ca36c..d98defb2b 100644 --- a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx +++ b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx @@ -11,11 +11,8 @@ import './LanguageDropdown.css'; const LanguageDropdown = () => { const { t, i18n } = useTranslation(); - // `resolvedLanguage` can be undefined when i18next hasn't matched the - // active lng against any registered resource yet — most often at the - // first render in tests with a minimal i18n instance. Fall back to - // `i18n.language` (always set to whatever was passed to init) and then - // to empty string so MUI's Select has a concrete, in-range value. + // i18next `resolvedLanguage` is undefined until a registered resource matches; + // MUI Select requires a concrete, in-range value. const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? ''); useEffect(() => { diff --git a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx index 939a7d753..95f10f62c 100644 --- a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx +++ b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useRef } from 'react'; const ScrollToBottomOnChanges = ({ content, changes }) => { const messagesEndRef = useRef(null); - // @TODO (2) const scrollToBottom = () => { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) } diff --git a/webclient/src/containers/App/AppShell.tsx b/webclient/src/containers/App/AppShell.tsx index 66618d865..4a0856bc5 100644 --- a/webclient/src/containers/App/AppShell.tsx +++ b/webclient/src/containers/App/AppShell.tsx @@ -12,7 +12,6 @@ import { ToastProvider } from '@app/components' function AppShell() { useEffect(() => { - // @TODO (1) window.onbeforeunload = () => true; }, []); diff --git a/webclient/src/containers/Login/Login.spec.tsx b/webclient/src/containers/Login/Login.spec.tsx index 4f59e37b1..e2a398066 100644 --- a/webclient/src/containers/Login/Login.spec.tsx +++ b/webclient/src/containers/Login/Login.spec.tsx @@ -1,28 +1,7 @@ -/** - * Login auto-connect integration tests. - * - * Exercises the full wire from `useAutoLogin` through the Login container - * into `webClient.request.authentication.login`. Scenarios mirror the user- - * visible cycles we care about: - * - cold start with / without auto-connect - * - logout within the same session must NOT re-auto-connect - * - page refresh (fresh JS context) resets the gate - * - * The startup-check gate lives on the `autoLoginSession` object exported by - * `useAutoLogin.ts`. Tests flip it back to false in `beforeEach` to stand in - * for a page refresh between scenarios. `vi.resetModules()` would be the - * natural equivalent but is prohibitively slow in the full suite because it - * forces every imported module to re-evaluate. - */ - import { act, waitFor } from '@testing-library/react'; import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__'; -// Lets pending microtasks resolve inside an act() scope so that the state -// updates they trigger (useFormState subscribers, useFireOnce state, etc.) -// are captured. Without this, useAutoLogin's Promise.all resolves *after* -// render returns, and React warns "update ... was not wrapped in act". const flushEffects = async (): Promise => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -30,7 +9,7 @@ const flushEffects = async (): Promise => { }; import { makeSettings, makeSettingsHook } from '../../hooks/__mocks__/useSettings'; import { makeHost, makeKnownHostsHook } from '../../hooks/__mocks__/useKnownHosts'; -import { autoLoginSession } from '../../hooks/useAutoLogin'; +import { autoLoginGate } from '../../hooks/useAutoLogin'; import { LoadingState } from '@app/hooks'; import Login from './Login'; @@ -62,22 +41,12 @@ beforeAll(() => { }); afterEach(async () => { - // Absorb any state updates that lingered past the test body (e.g. - // useAutoLogin's Promise.all resolving a moment too late) so they're - // wrapped in act and don't trip React's warning during teardown. await flushEffects(); }); beforeEach(() => { - // "Page refresh" between tests: reset the session gate that useAutoLogin - // uses to prevent re-firing within a JS session. Production code only - // writes this flag once, from inside the startup effect; tests flip it - // back to false here to simulate a fresh browser tab. - autoLoginSession.startupCheckRan = false; + autoLoginGate.hasChecked = false; - // clearAllMocks in the global afterEach only clears call history; mock - // implementations (mockResolvedValue, mockReturnValue) persist. Reset - // them explicitly so a previous test's arming doesn't leak into this one. hoisted.getSettings.mockReset(); hoisted.getKnownHosts.mockReset(); hoisted.useSettings.mockReset(); @@ -167,39 +136,26 @@ describe('Login — logout cycle (same JS session)', () => { test('does not re-auto-connect after first auto-login + logout', async () => { armAutoConnect(); - // First mount: Login appears, useAutoLogin fires login. const first = renderWithProviders(, { preloadedState: disconnectedState }); await waitFor(() => { expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); }); - // Simulate arriving at /server and then logging out: Login unmounts, - // then a fresh Login mounts again with disconnected state. first.unmount(); renderWithProviders(, { preloadedState: disconnectedState }); await flushEffects(); - // No second login call — the session gate is latched. expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); }); test('does not auto-connect when user enabled autoConnect mid-session and then logged out', async () => { - // Scenario: user manually logs in with autoConnect=false. They tick the - // auto-connect checkbox during that session (the setting flips true). - // They log out. Returning to /login must NOT auto-connect — the setting - // change was a preference for NEXT launch, not a signal to log in. - - // First mount: autoConnect=false, so the startup check runs and finds - // nothing to do. The gate latches anyway. const first = renderWithProviders(, { preloadedState: disconnectedState }); await flushEffects(); expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); first.unmount(); - // Mid-session, user ticked the checkbox. Future getSettings resolves - // return the new value, but the session gate prevents a re-check. hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); renderWithProviders(, { preloadedState: disconnectedState }); @@ -209,10 +165,6 @@ describe('Login — logout cycle (same JS session)', () => { }); describe('Login — refresh cycle', () => { - // `beforeEach` flips autoLoginSession.startupCheckRan back to false, which - // stands in for a page refresh. This test just re-asserts the positive - // case: a refresh re-enables auto-connect when the persisted preference - // still says yes. test('a fresh session gate re-fires auto-login when conditions still hold', async () => { armAutoConnect(); diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index be097a7b8..78bcdf242 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -12,7 +12,8 @@ import { LoginForm } from '@app/forms'; import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { Images } from '@app/images'; import { getHostPort, serverProps } from '@app/services'; -import { App, Enriched } from '@app/types'; +import { App } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { ServerSelectors, ServerTypes } from '@app/store'; import Layout from '../Layout/Layout'; import { useAppSelector } from '@app/store'; @@ -70,7 +71,7 @@ const Login = () => { const webClient = useWebClient(); const { t } = useTranslation(); - const [pendingActivationOptions, setPendingActivationOptions] = useState(null); + const [pendingActivationOptions, setPendingActivationOptions] = useState(null); const rememberLoginRef = useRef(null); const knownHosts = useKnownHosts(); @@ -128,7 +129,7 @@ const Login = () => { rememberLoginRef.current = loginForm; const { userName, password, selectedHost, remember } = loginForm; - const options: Omit = { + const options: Omit = { ...getHostPort(selectedHost), userName, password, diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index d2f8455bc..2da6b2871 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -17,7 +17,6 @@ import SayMessage from './SayMessage'; import './Room.css'; -// @TODO (3) const Room = () => { const joined = useAppSelector(state => RoomsSelectors.getJoinedRooms(state)); const rooms = useAppSelector(state => RoomsSelectors.getRooms(state)); diff --git a/webclient/src/forms/LoginForm/LoginForm.spec.tsx b/webclient/src/forms/LoginForm/LoginForm.spec.tsx index ed6178eec..0dcc8f071 100644 --- a/webclient/src/forms/LoginForm/LoginForm.spec.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.spec.tsx @@ -58,9 +58,6 @@ describe('LoginForm — regression: settings.autoConnect is not clobbered by hos { preloadedState: disconnectedState } ); - // After mount + all host-sync effects settle, the form has updated its - // local fields to reflect the selected host. What MUST NOT happen is a - // write to the persisted autoConnect setting. expect(update).not.toHaveBeenCalled(); }); diff --git a/webclient/src/forms/LoginForm/LoginForm.tsx b/webclient/src/forms/LoginForm/LoginForm.tsx index 8323989b9..b09470f55 100644 --- a/webclient/src/forms/LoginForm/LoginForm.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.tsx @@ -47,9 +47,7 @@ const LoginFormBody = ({ const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on); - // Host-sync: when the selected host changes, mirror its username + stored- - // password hint into the form. Deliberately does NOT touch autoConnect — the - // persisted setting is decoupled from which host is currently picked. + // @critical Host-sync must not touch autoConnect — app-level setting, not per-host. useEffect(() => { if (!selectedHost) { return; @@ -65,8 +63,6 @@ const LoginFormBody = ({ ); }, [selectedHost, form]); - // Mirror the persisted autoConnect setting into the form checkbox so the - // field reflects truth as soon as settings load. useEffect(() => { if (settings.status !== LoadingState.READY) { return; @@ -82,11 +78,7 @@ const LoginFormBody = ({ }; const onRememberChange = (checked: boolean) => { - // When the user unchecks "remember password", the auto-connect checkbox - // can't meaningfully stay on (there are no saved credentials to use), so - // reflect that in the form UI. Note: this writes only to the form field, - // NOT to the persisted setting — toggling host-level remember is not a - // user intent to change the app-level auto-connect preference. + // @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit. if (!checked && values.autoConnect) { form.change('autoConnect', false); } @@ -94,11 +86,8 @@ const LoginFormBody = ({ togglePasswordLabel(canUseStoredPassword(checked, values.password)); }; - // User-initiated toggle of the auto-connect checkbox. This is the ONLY path - // that writes to the persisted setting — wired directly to the Checkbox's - // native onChange (see JSX below), not to a listener, because - // OnChange fires on programmatic form.change calls too (host-sync effects - // etc.) and would leak those into Dexie. + // @critical Only persist-path for autoConnect; wired to native onChange, not , + // to avoid leaking form.change() writes into Dexie. const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => { fieldOnChange(checked); diff --git a/webclient/src/hooks/useAutoLogin.spec.tsx b/webclient/src/hooks/useAutoLogin.spec.tsx index 064f683d3..b825bb370 100644 --- a/webclient/src/hooks/useAutoLogin.spec.tsx +++ b/webclient/src/hooks/useAutoLogin.spec.tsx @@ -12,7 +12,7 @@ let makeSettings: (o?: AnyRecord) => AnyRecord; let makeHost: (o?: AnyRecord) => AnyRecord; beforeEach(async () => { - // Fresh module graph per test so the module-level hasFiredThisSession flag resets. + // Fresh module graph per test so autoLoginGate.hasChecked resets. vi.resetModules(); useAutoLoginModule = await import('./useAutoLogin'); const settingsMockModule = await import('./__mocks__/useSettings'); @@ -119,21 +119,14 @@ describe('useAutoLogin', () => { }); test('manual login then logout does NOT auto-connect on return to /login', async () => { - // Regression: the flag tracks whether the startup check RAN, not whether - // it FIRED. Without that distinction, a first-session manual login (where - // the hook saw conditions unmet) would leave the flag unset, and the - // next mount (after logout) would find conditions met and auto-connect. const onLogin = vi.fn(); - // First mount: autoConnect=false, so the check runs but doesn't fire. configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); await Promise.resolve(); await Promise.resolve(); expect(onLogin).not.toHaveBeenCalled(); - // User logs in manually and later hits logout; Login re-mounts with - // autoConnect now flipped on (they ticked the box during the session). unmount(); configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); @@ -144,10 +137,6 @@ describe('useAutoLogin', () => { }); test('ticking the auto-connect checkbox after mount does NOT trigger a login', async () => { - // This is the specific regression: editing the persisted preference is a - // settings write, not a "log in now" signal. Because useAutoLogin reads - // via whenReady (one-shot) instead of subscribing, a subsequent settings - // change cannot re-run the orchestrator. const onLogin = vi.fn(); configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); @@ -156,9 +145,6 @@ describe('useAutoLogin', () => { await Promise.resolve(); expect(onLogin).not.toHaveBeenCalled(); - // Swap to a "settings.autoConnect=true" world and rerender. Since - // getSettings is a one-shot that already resolved with the old value, - // changing its mockResolvedValue doesn't retroactively matter. configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); rerender(); diff --git a/webclient/src/hooks/useAutoLogin.ts b/webclient/src/hooks/useAutoLogin.ts index bf4944dca..4e8cf0770 100644 --- a/webclient/src/hooks/useAutoLogin.ts +++ b/webclient/src/hooks/useAutoLogin.ts @@ -13,32 +13,14 @@ export interface LoginFormValues { autoConnect?: boolean; } -// Auto-login is a *startup* concern — the persisted preference is consulted -// once per JS session, after both stores have loaded. A logout within the -// same session is an explicit user action; returning to /login should not -// re-auto-connect (matches Cockatrice desktop behaviour). The flag is -// module-scope so it persists across Login remounts and is naturally reset -// on page refresh, which is the one time we do want another try. -// -// The flag tracks whether the *check* has run, not whether it *fired* — a -// manual first login followed by a logout must not re-trigger auto-login -// either, so the outcome of the check is irrelevant; only that it happened. -// -// Exported as a mutable object (rather than a bare `let`) so integration -// tests can reset `startupCheckRan = false` between scenarios without -// resorting to `vi.resetModules`, which is prohibitively slow in the full -// suite. Production code only writes the flag from inside the effect. -export const autoLoginSession = { startupCheckRan: false }; +export const autoLoginGate = { hasChecked: false }; -// Deliberately does NOT subscribe to the settings / known-hosts stores — -// user actions that change those stores (ticking the auto-connect checkbox, -// picking a different host) are preference edits, not "log in now" signals. export function useAutoLogin( onLogin: (values: LoginFormValues) => void, connectionAttemptMade: boolean, ): void { useEffect(() => { - if (autoLoginSession.startupCheckRan) { + if (autoLoginGate.hasChecked) { return; } if (connectionAttemptMade) { @@ -48,10 +30,10 @@ export function useAutoLogin( let cancelled = false; Promise.all([getSettings(), getKnownHosts()]).then(([settings, hosts]) => { - if (cancelled || autoLoginSession.startupCheckRan) { + if (cancelled || autoLoginGate.hasChecked) { return; } - autoLoginSession.startupCheckRan = true; + autoLoginGate.hasChecked = true; if (!settings.autoConnect) { return; diff --git a/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx b/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx index 501bb9403..da80c876e 100644 --- a/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx +++ b/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx @@ -9,7 +9,6 @@ import { useFireOnce } from './useFireOnce'; describe('useFireOnce hook', () => { test('it only fires once when button is clicked twice', async () => { - // Mock a promise with a delay const onClickWithPromise = vi.fn((e) => { e.preventDefault() return new Promise((resolve) => { @@ -25,25 +24,20 @@ describe('useFireOnce hook', () => { return } - // render the button const { getByRole } = render( ); - //Grab the button from the DOM and confirm it initialized in an enabled state const button = getByRole('button', { name: 'Click Me!' }); expect(button).toBeEnabled(); - // Simulate two click events in a row fireEvent.click(button); fireEvent.click(button); - // Confirm that it's disabled await waitFor(() => { expect(button).toBeDisabled(); }); - // Confirm it became enabled after the timeout and that the click event was only fired once await waitFor( () => { expect(onClickWithPromise).toHaveBeenCalledTimes(1); @@ -53,7 +47,6 @@ describe('useFireOnce hook', () => { }); test('it only fires once when form is submitted twice', async () => { - // Mock a promise with a delay const onClickWithPromise = vi.fn((e) => { e.preventDefault() return new Promise((resolve) => { @@ -74,25 +67,20 @@ describe('useFireOnce hook', () => { ) } - // render the form const { getByRole } = render(
); - //Grab the button from the DOM and confirm it initialized in an enabled state const button = getByRole('button', { name: 'Click Me!' }); expect(button).toBeEnabled(); - // Simulate two click events in a row fireEvent.click(button); fireEvent.click(button); - // Confirm that it's disabled await waitFor(() => { expect(button).toBeDisabled(); }); - // Confirm it became enabled after the timeout and that the click event was only fired once await waitFor( () => { expect(onClickWithPromise).toHaveBeenCalledTimes(1); diff --git a/webclient/src/hooks/useKnownHosts.ts b/webclient/src/hooks/useKnownHosts.ts index c7e95c89c..d3ebf0c29 100644 --- a/webclient/src/hooks/useKnownHosts.ts +++ b/webclient/src/hooks/useKnownHosts.ts @@ -3,12 +3,6 @@ import { App } from '@app/types'; import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; -// Shared-store scope justification: multiple components on the login screen -// read the same host list and selected host simultaneously (KnownHosts -// dropdown, LoginForm's host-sync effect, useAutoLogin, and the Login -// container's post-login write). Collapsing to useState inside one component -// would duplicate Dexie reads and race on lastSelected updates — exactly the -// bug we set out to fix. export interface KnownHostsValue { hosts: HostDTO[]; selectedHost: HostDTO; @@ -29,15 +23,12 @@ const normalize = async (hosts: HostDTO[]): Promise => { return { hosts, selectedHost: existing }; } - // No row marked lastSelected yet (first-ever load after seeding, or legacy - // data). Pin hosts[0] and persist so subsequent boots are deterministic. const selected = hosts[0]; selected.lastSelected = true; await selected.save(); return { hosts, selectedHost: selected }; }; -// Exported for integration-test reset; see settingsStore for the rationale. export const knownHostsStore = createSharedStore(async () => { const hosts = await loadAll(); return normalize(hosts); @@ -51,8 +42,6 @@ export type KnownHostsHook = Loadable & { remove: (id: number) => Promise; }; -// Guard for mutators. Mutators run outside React render, so we can't gate -// them through the hook's status; peek + throw is the fail-fast alternative. const requireValue = (method: string): KnownHostsValue => { const current = store.peek(); if (!current) { @@ -127,6 +116,4 @@ export function useKnownHosts(): KnownHostsHook { return { ...state, select, add, update, remove }; } -// Non-reactive one-shot accessor, mirroring getSettings. See the comment on -// that export in useSettings.ts for the rationale. export const getKnownHosts = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useSettings.ts b/webclient/src/hooks/useSettings.ts index 9636634cf..e4792b833 100644 --- a/webclient/src/hooks/useSettings.ts +++ b/webclient/src/hooks/useSettings.ts @@ -3,15 +3,6 @@ import { App } from '@app/types'; import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; -// First-time bootstrap: SettingDTO.get returns undefined when no row exists -// for the app user yet (fresh install, or a user who has never hit the -// settings code path before). We materialize a default DTO and persist it so -// subsequent loads always see a non-null row. -// -// Exported as `settingsStore` so integration tests can call -// `settingsStore.reset()` between scenarios — the module cache would -// otherwise serve stale data across per-test Dexie resets. Production code -// goes through `useSettings()` / `getSettings()` and doesn't touch this. export const settingsStore = createSharedStore(async () => { let loaded = await SettingDTO.get(App.APP_USER); if (!loaded) { @@ -30,9 +21,6 @@ export function useSettings(): SettingsHook { const state = useSharedStore(store); const update = async (patch: Partial) => { - // Fail-fast if a caller tries to write before the initial load resolves. - // Shouldn't happen in normal flow (the checkbox is gated on the hook's - // ready status), so surface the bug loudly instead of silently no-oping. const current = store.peek(); if (!current) { throw new Error('useSettings.update called before settings loaded'); @@ -45,8 +33,4 @@ export function useSettings(): SettingsHook { return { ...state, update }; } -// Non-reactive one-shot accessor. Use this from code that wants the loaded -// value exactly once and does NOT want to re-run when the user subsequently -// edits their settings — e.g. the auto-login orchestrator, which consults -// the persisted preference at startup only. export const getSettings = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useSharedStore.ts b/webclient/src/hooks/useSharedStore.ts index 66505a168..668545f79 100644 --- a/webclient/src/hooks/useSharedStore.ts +++ b/webclient/src/hooks/useSharedStore.ts @@ -12,27 +12,14 @@ export interface Loadable { error?: Error; } +// @critical Two surfaces: subscribe (reactive) vs whenReady (one-shot). +// See .github/instructions/webclient.instructions.md#shared-store-pattern. export interface SharedStore { - // Reactive surface: subscribe + snapshot back useSyncExternalStore so - // consuming components re-render on every store update. subscribe: (cb: () => void) => () => void; getSnapshot: () => Loadable; - - // One-shot surface: whenReady resolves with the initial loaded value and - // never fires again. Callers that only need "read once after init" (e.g. - // the auto-login orchestrator) use this to avoid subscribing to updates - // they don't care about — which would otherwise turn a user preference - // toggle into a re-evaluation of startup logic. whenReady: () => Promise; - - // Mutator-side helpers, not for consumption inside render. setValue: (value: T) => void; peek: () => T | undefined; - - // Clear cached state and the resolved readyPromise; the next subscribe / - // whenReady call triggers a fresh load. In production nobody calls this; - // integration tests use it to discard per-test Dexie state without - // paying the cost of vi.resetModules across the whole dep graph. reset: () => void; } @@ -41,9 +28,7 @@ export function createSharedStore(load: () => Promise): SharedStore { const subscribers = new Set<() => void>(); let loadStarted = false; - // whenReady is lazy: we only attach a promise once someone asks for one. - // This avoids Node's unhandled-rejection bookkeeping for stores whose - // loader fails but never had a whenReady caller. + // Lazy to avoid unhandled-rejection bookkeeping when no caller awaits it. let readyPromise: Promise | null = null; const notify = () => { diff --git a/webclient/src/hooks/useWebClient.tsx b/webclient/src/hooks/useWebClient.tsx index 37570345c..3d9c02697 100644 --- a/webclient/src/hooks/useWebClient.tsx +++ b/webclient/src/hooks/useWebClient.tsx @@ -2,9 +2,6 @@ import { createContext, useContext, useState, ReactNode } from 'react'; import { WebClient } from '@app/websocket'; import { createWebClientRequest, createWebClientResponse } from '@app/api'; -// Exported so integration tests can inject the WebClient singleton built -// by their shared setup without going through the production provider -// (which would attempt to `new WebClient(...)` a second time and throw). export const WebClientContext = createContext(null); export function WebClientProvider({ children }: { children: ReactNode }) { diff --git a/webclient/src/index.tsx b/webclient/src/index.tsx index be51341a4..b0137f943 100644 --- a/webclient/src/index.tsx +++ b/webclient/src/index.tsx @@ -1,5 +1,4 @@ -// MUST be first: installs BigInt.prototype.toJSON before any module that -// creates the Redux store or connects to Redux DevTools. +// @critical Must be the first import. See .github/instructions/webclient.instructions.md#initialization-order. import './polyfills'; import { StrictMode } from 'react'; diff --git a/webclient/src/material-theme.ts b/webclient/src/material-theme.ts index beee1146e..8f646c85a 100644 --- a/webclient/src/material-theme.ts +++ b/webclient/src/material-theme.ts @@ -32,35 +32,9 @@ const palette = { A400: '#303030', A700: '#616161', }, - // secondary: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, - // error: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, - // warning: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, - // info: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, success: { main: '#6CDF39', light: '#6CDF39', - // dark: '', - // contrastText: '', }, }; @@ -201,10 +175,6 @@ export const materialTheme = createTheme({ fontSize: 24, fontWeight: 'bold', }, - // h3: {}, - // h4: {}, - // h5: {}, - // h6: {}, subtitle1: { fontSize: 14, fontWeight: 'bold', @@ -219,10 +189,6 @@ export const materialTheme = createTheme({ fontSize: '.75rem', lineHeight: 1.4, }, - // body2: {}, - // button: {}, - // caption: {}, - // overline: {}, }, spacing: 8, diff --git a/webclient/src/polyfills.ts b/webclient/src/polyfills.ts index d18a0c94c..e38af7101 100644 --- a/webclient/src/polyfills.ts +++ b/webclient/src/polyfills.ts @@ -1,17 +1,5 @@ -// Runtime polyfills that must execute before any other application module. -// Import this file first from `src/index.tsx`. - -// ── BigInt.prototype.toJSON ─────────────────────────────────────────────────── -// Protobuf-ES maps proto `int64`/`uint64` fields to native `BigInt`. Those -// land in Redux state (e.g. `ServerInfo_User.accountageSecs`, -// `Response_Register.deniedEndTime`, the outbound `cmdId`), and any consumer -// that JSON-stringifies state — notably the Redux DevTools browser -// extension, but also logging and error-boundary dumps — throws with -// "Do not know how to serialize a BigInt" because `BigInt.prototype` has no -// `toJSON`. Installing one globally makes `JSON.stringify` coerce -// `BigInt → string` instead of throwing. Coercion is lossy but only affects -// serialized representations; the in-memory Redux state still holds real -// `BigInt`s and every consumer reads them via the generated proto accessors. +// @critical Must be imported before any module that can JSON-stringify Redux state +// (BigInt proto fields throw without toJSON). See .github/instructions/webclient.instructions.md#initialization-order. (BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function bigIntToJSON() { return this.toString(); }; diff --git a/webclient/src/services/CardImporterService.ts b/webclient/src/services/CardImporterService.ts index 984de9005..20d155214 100644 --- a/webclient/src/services/CardImporterService.ts +++ b/webclient/src/services/CardImporterService.ts @@ -96,7 +96,7 @@ class CardImporterService { }; } - // @TODO: clean this up and normalize what i'm returning + // @TODO clean this up and normalize what i'm returning if (attributes[child.tagName]) { if (Array.isArray(attributes[child.tagName])) { attributes[child.tagName].push(parsedAttributes) diff --git a/webclient/src/setupTests.ts b/webclient/src/setupTests.ts index 219544389..645094434 100644 --- a/webclient/src/setupTests.ts +++ b/webclient/src/setupTests.ts @@ -1,8 +1,6 @@ -// Install runtime polyfills (BigInt.prototype.toJSON) before any module -// under test loads — matches the production boot order in src/index.tsx. +// @critical Must match the production boot order in src/index.tsx. See .github/instructions/webclient.instructions.md#initialization-order. import './polyfills'; -// Ensure jest-dom matchers are available in every test file. import '@testing-library/jest-dom/vitest'; // jsdom doesn't provide ResizeObserver; react-window needs it. @@ -14,9 +12,7 @@ if (typeof globalThis.ResizeObserver === 'undefined') { } as any; } -// Mock Dexie globally to prevent IndexedDB initialization in jsdom. -// Dexie eagerly opens IndexedDB on import, and jsdom's fake-indexeddb -// is memory-intensive. No UI test needs a real database. +// Dexie eagerly opens IndexedDB on import; jsdom's fake-indexeddb is memory-intensive. vi.mock('dexie', () => { const fakeTable = { mapToClass: () => {}, @@ -42,58 +38,11 @@ vi.mock('dexie', () => { return { default: FakeDexie, __esModule: true }; }); -// ── Global mock hygiene under `isolate: false` ──────────────────────────────── -// -// Vitest is configured with `test.isolate: false` for speed — every spec file -// in a worker shares the same module graph and the same `vi.mock` factories. -// Without aggressive per-test cleanup, state leaks trivially between tests: -// -// - A test accumulates `.mock.calls` on a shared `vi.fn()`. Later tests -// either see stale history or accidentally match on prior invocations. -// - A test installs `vi.spyOn` on a real method. Without restore, the spy -// persists into every following test and file. -// - A test swaps to fake timers. Real-time code in later tests hangs. -// -// `vi.clearAllMocks()` clears `.mock.calls` on every tracked mock without -// touching implementations — safe for module factories that produce `vi.fn()` -// instances at the top of a spec file and rely on those instances sticking -// around. `vi.restoreAllMocks()` restores original implementations on -// `vi.spyOn` targets. `vi.useRealTimers()` drops any fake-timer installation. -// -// NOTE: we intentionally do NOT call `vi.resetAllMocks()` — it resets the -// implementations of `vi.fn()` instances created inside `vi.mock(...)` -// factories, which breaks any spec that expects those mocks to persist -// across tests in the same file (e.g. `store.dispatch` mocked once at file -// load). -// -// If a specific test needs to install its own `mockReturnValue` / -// `mockImplementation`, it should set it in that test's body and rely on -// the next test overwriting or the global `clearAllMocks` clearing calls — -// it should NOT assume the mock is reset to its factory default automatically. -// -// Global snapshot/restore guards for non-`vi.spyOn` globals that tests mutate -// directly. `vi.restoreAllMocks()` only restores `vi.spyOn` targets, so bare -// `Object.defineProperty` writes on `window.location` and `globalThis.WebSocket` -// reassignments leak between tests unless we explicitly capture and restore them. -let _locationDescriptor: PropertyDescriptor | undefined; -let _originalWebSocket: typeof globalThis.WebSocket | undefined; - -beforeEach(() => { - _locationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); - _originalWebSocket = globalThis.WebSocket; -}); - +// Tests within a file share the module graph (vite.config.ts sets isolate: true +// between files, not within them). Never add vi.resetAllMocks() — it resets +// vi.fn() instances created inside vi.mock(...) factories at file load. afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); vi.useRealTimers(); - - const currentLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); - if (currentLocationDescriptor !== _locationDescriptor && _locationDescriptor) { - Object.defineProperty(window, 'location', _locationDescriptor); - } - - if (globalThis.WebSocket !== _originalWebSocket) { - globalThis.WebSocket = _originalWebSocket as typeof globalThis.WebSocket; - } }); diff --git a/webclient/src/store/common/SortUtil.spec.ts b/webclient/src/store/common/SortUtil.spec.ts index 5a79b0e29..78f9c6aef 100644 --- a/webclient/src/store/common/SortUtil.spec.ts +++ b/webclient/src/store/common/SortUtil.spec.ts @@ -2,7 +2,6 @@ import { create } from '@bufbuild/protobuf'; import { App, Data } from '@app/types'; import SortUtil from './SortUtil'; -// ── sortByField ─────────────────────────────────────────────────────────────── describe('sortByField', () => { it('sorts string field ASC alphabetically', () => { @@ -55,7 +54,6 @@ describe('sortByField', () => { }); }); -// ── sortByFields ────────────────────────────────────────────────────────────── describe('sortByFields', () => { it('sorts by the first key when all items have distinct first-key values', () => { @@ -114,7 +112,6 @@ describe('sortByFields', () => { }); }); -// ── sortUsersByField ────────────────────────────────────────────────────────── describe('sortUsersByField', () => { it('sorts by userLevel DESC first, then name ASC', () => { @@ -147,7 +144,6 @@ describe('sortUsersByField', () => { }); }); -// ── toggleSortBy ────────────────────────────────────────────────────────────── describe('toggleSortBy', () => { it('same field + ASC → returns DESC', () => { @@ -166,7 +162,6 @@ describe('toggleSortBy', () => { }); }); -// ── resolveFieldChain with numeric index ───────────────────────────────────── describe('resolveFieldChain via sortByField (numeric index)', () => { it('resolves numeric index in dot-notation chain', () => { diff --git a/webclient/src/store/common/normalizers.ts b/webclient/src/store/common/normalizers.ts index 70ec4881d..3648cc8fb 100644 --- a/webclient/src/store/common/normalizers.ts +++ b/webclient/src/store/common/normalizers.ts @@ -1,6 +1,5 @@ import { Data, Enriched } from '@app/types'; -/** Flatten a gametype list into a lookup map of { gameTypeId → description }. */ export function normalizeGametypeMap(gametypeList: Data.ServerInfo_GameType[]): Enriched.GametypeMap { return gametypeList.reduce((map, type) => { map[type.gameTypeId] = type.description; @@ -8,13 +7,6 @@ export function normalizeGametypeMap(gametypeList: Data.ServerInfo_GameType[]): }, {}); } -/** - * Build an Enriched.Room (composition shape) from a raw proto. The proto is - * stored verbatim on `info` and the repeated collections are normalized into - * keyed maps alongside it. `info.gameList`, `info.userList`, `info.gametypeList` - * are left as the wire snapshot — callers should always read the normalized - * fields, never those. - */ export function normalizeRoomInfo(roomInfo: Data.ServerInfo_Room): Enriched.Room { const gametypeMap = normalizeGametypeMap(roomInfo.gametypeList); @@ -38,7 +30,6 @@ export function normalizeRoomInfo(roomInfo: Data.ServerInfo_Room): Enriched.Room }; } -/** Wrap a raw ServerInfo_Game in the composition shape with cached gameType. */ export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enriched.GametypeMap): Enriched.Game { const { gameTypes } = game; const hasType = gameTypes && gameTypes.length; @@ -48,7 +39,6 @@ export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enr }; } -/** Group a flat LogItem[] into { room, game, chat } buckets for the server store. */ export function normalizeLogs(logs: Data.ServerInfo_ChatMessage[]): Enriched.LogGroups { return logs.reduce((obj, log) => { const type = log.targetType as keyof Enriched.LogGroups; @@ -59,12 +49,8 @@ export function normalizeLogs(logs: Data.ServerInfo_ChatMessage[]): Enriched.Log }, { room: [], game: [], chat: [] }); } -/** - * Prepend "name: " to the message text when a sender name is present. - * Messages from the current user are sent without a name by the server, - * so this is a no-op for those. - * Returns a new Message — does not mutate the original. - */ +// @critical Server omits `name` on messages from the current user; preserves that as a no-op. +// See .github/instructions/webclient.instructions.md#protocol-quirks. export function normalizeUserMessage(message: Enriched.Message): Enriched.Message { if (!message.name) { return message; @@ -72,12 +58,7 @@ export function normalizeUserMessage(message: Enriched.Message): Enriched.Messag return { ...message, message: `${message.name}: ${message.message}` }; } -/** - * Build the user-facing ban error string from raw server data. - * The server sends a reason string and an endTime epoch ms (0 = permanent). - * Messages from the current user do not carry the username — this quirk is - * handled at the dispatch layer so the redux store always stores a clean string. - */ +// endTime is epoch ms; 0 means permanent. export function normalizeBannedUserError(reason: string, endTime: number): string { let error: string; diff --git a/webclient/src/store/game/game.reducer.spec.ts b/webclient/src/store/game/game.reducer.spec.ts index 4eadf7238..6a467f145 100644 --- a/webclient/src/store/game/game.reducer.spec.ts +++ b/webclient/src/store/game/game.reducer.spec.ts @@ -20,7 +20,6 @@ function cardsIn(state: GamesState, gameId: number, playerId: number, zoneName: return zone ? zone.order.map(id => zone.byId[id]) : []; } -// ── 2A: Initialisation & lifecycle ─────────────────────────────────────────── describe('2A: Initialisation & lifecycle', () => { it('returns initialState ({ games: {} }) when called with undefined state', () => { @@ -77,7 +76,6 @@ describe('2A: Initialisation & lifecycle', () => { }); }); -// ── 2B: Game state & player management ─────────────────────────────────────── describe('2B: Game state & player management', () => { it('GAME_STATE_CHANGED with playerList → replaces players via normalizePlayers', () => { @@ -165,7 +163,6 @@ describe('2B: Game state & player management', () => { }); }); -// ── 2C: CARD_MOVED ──────────────────────────────────────────────────────────── describe('2C: CARD_MOVED', () => { function stateWithCard(cardOverrides: Parameters[0] = {}) { @@ -482,7 +479,6 @@ describe('2C: CARD_MOVED', () => { }); }); -// ── 2D: Card mutations ──────────────────────────────────────────────────────── describe('2D: Card mutations', () => { function stateWithCardInZone(zoneName: string) { @@ -587,7 +583,6 @@ describe('2D: Card mutations', () => { }); }); -// ── 2E: CARD_ATTR_CHANGED ───────────────────────────────────────────────────── describe('2E: CARD_ATTR_CHANGED', () => { function stateWithCard() { @@ -660,7 +655,6 @@ describe('2E: CARD_ATTR_CHANGED', () => { }); }); -// ── 2F: CARD_COUNTER_CHANGED ───────────────────────────────────────────────── describe('2F: CARD_COUNTER_CHANGED', () => { function stateWithCard(existingCounters: any[] = []) { @@ -711,7 +705,6 @@ describe('2F: CARD_COUNTER_CHANGED', () => { }); }); -// ── 2G: Arrows ──────────────────────────────────────────────────────────────── describe('2G: Arrows', () => { it('ARROW_CREATED → inserts arrowInfo into player.arrows keyed by id', () => { @@ -745,7 +738,6 @@ describe('2G: Arrows', () => { }); }); -// ── 2H: Player counters ─────────────────────────────────────────────────────── describe('2H: Player counters', () => { it('COUNTER_CREATED → inserts counterInfo into player.counters keyed by id', () => { @@ -809,7 +801,6 @@ describe('2H: Player counters', () => { }); }); -// ── 2I: Zone operations ─────────────────────────────────────────────────────── describe('2I: Zone operations', () => { it('CARDS_DRAWN → decrements deck.cardCount, appends cards to hand, increments hand.cardCount', () => { @@ -963,7 +954,6 @@ describe('2I: Zone operations', () => { }); }); -// ── 2J: Turn / phase / chat ─────────────────────────────────────────────────── describe('2J: Turn, phase, and chat', () => { it('ACTIVE_PLAYER_SET → sets game.activePlayerId', () => { @@ -998,7 +988,6 @@ describe('2J: Turn, phase, and chat', () => { }); }); -// ── 2K: No-op / passthrough actions ────────────────────────────────────────── describe('2K: No-op / passthrough actions', () => { it('ZONE_SHUFFLED → returns state unchanged (identity)', () => { @@ -1026,7 +1015,6 @@ describe('2K: No-op / passthrough actions', () => { }); }); -// ── 2L: Null-guard / missing entity early-returns ───────────────────────────── // Each test dispatches an action with a non-existent gameId (999) or playerId/zone // to exercise the `if (!game) return state` / `if (!player) return state` guards. diff --git a/webclient/src/store/game/game.reducer.ts b/webclient/src/store/game/game.reducer.ts index ec3c1f658..62e06da21 100644 --- a/webclient/src/store/game/game.reducer.ts +++ b/webclient/src/store/game/game.reducer.ts @@ -177,7 +177,6 @@ export const gamesSlice = createSlice({ } }, - // ── Card manipulation ──────────────────────────────────────────────────── cardMoved: ( state, @@ -343,7 +342,6 @@ export const gamesSlice = createSlice({ } }, - // ── Arrows ─────────────────────────────────────────────────────────────── arrowCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateArrow }>) => { const { gameId, playerId, data } = action.payload; @@ -361,7 +359,6 @@ export const gamesSlice = createSlice({ } }, - // ── Player counters ─────────────────────────────────────────────────────── counterCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateCounter }>) => { const { gameId, playerId, data } = action.payload; @@ -387,7 +384,6 @@ export const gamesSlice = createSlice({ } }, - // ── Zone operations ─────────────────────────────────────────────────────── cardsDrawn: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DrawCards }>) => { const { gameId, playerId, data } = action.payload; @@ -444,7 +440,6 @@ export const gamesSlice = createSlice({ } }, - // ── Turn / phase ────────────────────────────────────────────────────────── activePlayerSet: (state, action: PayloadAction<{ gameId: number; activePlayerId: number }>) => { const game = state.games[action.payload.gameId]; @@ -467,7 +462,6 @@ export const gamesSlice = createSlice({ } }, - // ── Chat ────────────────────────────────────────────────────────────────── gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string; timeReceived: number }>) => { const { gameId, playerId, message, timeReceived } = action.payload; @@ -481,7 +475,6 @@ export const gamesSlice = createSlice({ game.messages.push({ playerId, message, timeReceived }); }, - // ── Log-only events ───────────────────────────────────────────────────── zoneShuffled: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_Shuffle }>) => {}, zoneDumped: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DumpZone }>) => {}, dieRolled: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_RollDie }>) => {}, diff --git a/webclient/src/store/rooms/rooms.dispatch.spec.ts b/webclient/src/store/rooms/rooms.dispatch.spec.ts index bcbfa1977..83affda0a 100644 --- a/webclient/src/store/rooms/rooms.dispatch.spec.ts +++ b/webclient/src/store/rooms/rooms.dispatch.spec.ts @@ -1,10 +1,5 @@ -// Use `vi.hoisted` so the mocked `store.dispatch` reference stays stable across -// re-runs of the factory under `isolate: false`. Other dispatch specs mock the -// same `..` path with their own factories; under the shared module graph, the -// cache entry for `..` can flip between competing `vi.fn()` instances. Asserting -// against the hoisted `mockDispatch` directly (rather than reaching through -// `store.dispatch`) decouples the assertions from whatever the module cache -// currently resolves `store` to. +// Hoisted so the mockDispatch reference is available inside the vi.mock factory +// below and can be asserted against directly from each test. const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); vi.mock('..', () => ({ store: { dispatch: mockDispatch } })); diff --git a/webclient/src/store/rooms/rooms.reducer.spec.ts b/webclient/src/store/rooms/rooms.reducer.spec.ts index ce5b687dd..6e7f5fbfc 100644 --- a/webclient/src/store/rooms/rooms.reducer.spec.ts +++ b/webclient/src/store/rooms/rooms.reducer.spec.ts @@ -4,7 +4,6 @@ import { Actions } from './rooms.actions'; import { MAX_ROOM_MESSAGES } from './rooms.types'; import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures'; -// ── Initialisation ─────────────────────────────────────────────────────────── describe('Initialisation', () => { it('returns initialState when called with undefined state', () => { @@ -27,7 +26,6 @@ describe('Initialisation', () => { }); }); -// ── UPDATE_ROOMS ────────────────────────────────────────────────────────────── describe('UPDATE_ROOMS', () => { it('creates RoomEntry with empty normalized games/users for new room', () => { @@ -77,7 +75,6 @@ describe('UPDATE_ROOMS', () => { }); }); -// ── JOIN_ROOM ────────────────────────────────────────────────────────────────── describe('JOIN_ROOM', () => { it('normalizes raw room into keyed games/users maps and marks joined', () => { @@ -97,7 +94,6 @@ describe('JOIN_ROOM', () => { }); }); -// ── LEAVE_ROOM ──────────────────────────────────────────────────────────────── describe('LEAVE_ROOM', () => { it('removes joinedRoomIds entry and messages for roomId', () => { @@ -111,7 +107,6 @@ describe('LEAVE_ROOM', () => { }); }); -// ── ADD_MESSAGE ─────────────────────────────────────────────────────────────── describe('ADD_MESSAGE', () => { it('appends message preserving the timeReceived from the event handler', () => { @@ -157,7 +152,6 @@ describe('ADD_MESSAGE', () => { }); }); -// ── UPDATE_GAMES ────────────────────────────────────────────────────────────── describe('UPDATE_GAMES', () => { it('removes closed games from the keyed games map', () => { @@ -211,7 +205,6 @@ describe('UPDATE_GAMES', () => { }); }); -// ── USER_JOINED / USER_LEFT ─────────────────────────────────────────────────── describe('USER_JOINED', () => { it('inserts user into the keyed users map', () => { @@ -237,7 +230,6 @@ describe('USER_LEFT', () => { }); }); -// ── SORT_GAMES ──────────────────────────────────────────────────────────────── describe('SORT_GAMES', () => { it('updates sortGamesBy on state (sorting itself is now derived in selectors)', () => { @@ -251,7 +243,6 @@ describe('SORT_GAMES', () => { }); }); -// ── REMOVE_MESSAGES ─────────────────────────────────────────────────────────── describe('REMOVE_MESSAGES', () => { it('removes messages starting with "name:" up to amount, in reverse scan order', () => { @@ -294,7 +285,6 @@ describe('REMOVE_MESSAGES', () => { }); }); -// ── GAME_CREATED ────────────────────────────────────────────────────────────── describe('GAME_CREATED', () => { it('returns state unchanged', () => { @@ -304,7 +294,6 @@ describe('GAME_CREATED', () => { }); }); -// ── JOINED_GAME ─────────────────────────────────────────────────────────────── describe('JOINED_GAME', () => { it('sets joinedGameIds[roomId][gameId] = true', () => { diff --git a/webclient/src/store/rooms/rooms.reducer.tsx b/webclient/src/store/rooms/rooms.reducer.tsx index e6157952e..0482b4e3a 100644 --- a/webclient/src/store/rooms/rooms.reducer.tsx +++ b/webclient/src/store/rooms/rooms.reducer.tsx @@ -31,9 +31,8 @@ export const roomsSlice = createSlice({ updateRooms: (state, action: PayloadAction<{ rooms: Data.ServerInfo_Room[] }>) => { const { rooms } = action.payload; - // UPDATE_ROOMS carries metadata only. For existing rooms, replace - // `info`, `gametypeMap` and `order`; preserve the normalized `games` - // and `users` maps (those are maintained by their own events). + // @critical Partial merge — preserve normalized games/users maps. + // See .github/instructions/webclient.instructions.md#reducer-merge-rules. rooms.forEach((rawRoom, order) => { const { roomId } = rawRoom; const existing = state.rooms[roomId]; @@ -96,8 +95,6 @@ export const roomsSlice = createSlice({ const { roomId, games } = action.payload; const room = state.rooms[roomId]; - // An empty games array means no game updates — skip to avoid - // accidentally wiping the existing normalized games map. if (!room || !games?.length) { return; } @@ -111,7 +108,6 @@ export const roomsSlice = createSlice({ } const existing = room.games[rawGame.gameId]; if (existing) { - // Merge the incoming proto into the existing snapshot. const merged: Data.ServerInfo_Game = { ...existing.info, ...rawGame }; room.games[rawGame.gameId] = { info: merged, @@ -146,8 +142,6 @@ export const roomsSlice = createSlice({ }, sortGames: (state, action: PayloadAction<{ roomId: number; field: App.GameSortField; order: App.SortDirection }>) => { - // Sort is derived in selectors; the reducer stores the sort config. - // roomId is passed through for future per-room sorting support. const { field, order } = action.payload; state.sortGamesBy = { field, order }; }, @@ -160,8 +154,6 @@ export const roomsSlice = createSlice({ return; } - // Drop the `amount` most-recent messages whose text starts with `${name}:`. - // Walk newest → oldest so we remove the N latest matches. const prefix = `${name}:`; const keep = new Array(roomMessages.length).fill(true); let remaining = amount; @@ -184,7 +176,7 @@ export const roomsSlice = createSlice({ state.joinedGameIds[roomId][gameId] = true; }, - // Signal-only — no state mutation needed; explicit for discriminated-union exhaustiveness + // Signal-only; kept for discriminated-union exhaustiveness. gameCreated: (_state, _action: PayloadAction<{ roomId: number }>) => {}, }, }); diff --git a/webclient/src/store/rooms/rooms.selectors.spec.ts b/webclient/src/store/rooms/rooms.selectors.spec.ts index adbbcfe04..4308aba86 100644 --- a/webclient/src/store/rooms/rooms.selectors.spec.ts +++ b/webclient/src/store/rooms/rooms.selectors.spec.ts @@ -146,7 +146,6 @@ describe('Selectors', () => { expect(Selectors.getSortedRoomUsers(rootState(state), 999)).toHaveLength(0); }); - // ── createSelector reference stability ────────────────────────────── it('getSortedRoomGames → returns same array reference for identical state', () => { const game = makeGame({ gameId: 1 }); diff --git a/webclient/src/store/server/__mocks__/server-fixtures.ts b/webclient/src/store/server/__mocks__/server-fixtures.ts index fd805dbbb..9bb7c8cf4 100644 --- a/webclient/src/store/server/__mocks__/server-fixtures.ts +++ b/webclient/src/store/server/__mocks__/server-fixtures.ts @@ -1,4 +1,5 @@ import { App, Data, Enriched } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import type { MessageInitShape } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf'; @@ -117,8 +118,8 @@ export function makeGame(overrides: MakeGameOverrides = {}): Enriched.Game { } export function makeLoginSuccessContext( - overrides: Partial = {} -): Enriched.LoginSuccessContext { + overrides: Partial = {} +): WebsocketTypes.LoginSuccessContext { return { hashedPassword: 'hash', ...overrides, @@ -126,8 +127,8 @@ export function makeLoginSuccessContext( } export function makePendingActivationContext( - overrides: Partial = {} -): Enriched.PendingActivationContext { + overrides: Partial = {} +): WebsocketTypes.PendingActivationContext { return { host: 'localhost', port: '4747', @@ -143,7 +144,7 @@ export function makeServerState(overrides: Partial = {}): ServerSta ignoreList: {}, status: { connectionAttemptMade: false, - state: Enriched.StatusEnum.DISCONNECTED, + state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null, }, info: { diff --git a/webclient/src/store/server/server.actions.spec.ts b/webclient/src/store/server/server.actions.spec.ts index 5bc31dbd7..f741086c5 100644 --- a/webclient/src/store/server/server.actions.spec.ts +++ b/webclient/src/store/server/server.actions.spec.ts @@ -1,5 +1,6 @@ import { Actions } from './server.actions'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { Types } from './server.types'; import { create } from '@bufbuild/protobuf'; import { @@ -88,7 +89,7 @@ describe('Actions', () => { }); it('updateStatus', () => { - const status = { state: Enriched.StatusEnum.CONNECTED, description: 'connected' }; + const status = { state: WebsocketTypes.StatusEnum.CONNECTED, description: 'connected' }; expect(Actions.updateStatus({ status })).toEqual({ type: Types.UPDATE_STATUS, payload: { status } }); }); diff --git a/webclient/src/store/server/server.dispatch.spec.ts b/webclient/src/store/server/server.dispatch.spec.ts index bd7804850..bab47faa7 100644 --- a/webclient/src/store/server/server.dispatch.spec.ts +++ b/webclient/src/store/server/server.dispatch.spec.ts @@ -1,12 +1,11 @@ -// Use `vi.hoisted` so the mocked `store.dispatch` reference stays stable across -// re-runs of the factory under `isolate: false`. See rooms.dispatch.spec.ts for -// the same pattern and rationale. +// @critical See rooms.dispatch.spec.ts — same hoisted-mockDispatch pattern. const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); vi.mock('..', () => ({ store: { dispatch: mockDispatch } })); import { Actions } from './server.actions'; import { Dispatch } from './server.dispatch'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { create } from '@bufbuild/protobuf'; import { makeBanHistoryItem, @@ -106,9 +105,9 @@ describe('Dispatch', () => { }); it('updateStatus dispatches Actions.updateStatus({ status: { state, description } })', () => { - Dispatch.updateStatus(Enriched.StatusEnum.CONNECTED, 'ok'); + Dispatch.updateStatus(WebsocketTypes.StatusEnum.CONNECTED, 'ok'); expect(mockDispatch).toHaveBeenCalledWith( - Actions.updateStatus({ status: { state: Enriched.StatusEnum.CONNECTED, description: 'ok' } }) + Actions.updateStatus({ status: { state: WebsocketTypes.StatusEnum.CONNECTED, description: 'ok' } }) ); }); diff --git a/webclient/src/store/server/server.dispatch.ts b/webclient/src/store/server/server.dispatch.ts index fad027a57..823085e0b 100644 --- a/webclient/src/store/server/server.dispatch.ts +++ b/webclient/src/store/server/server.dispatch.ts @@ -1,6 +1,7 @@ import { Actions } from './server.actions'; import { store } from '..'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; export const Dispatch = { initialized: () => { @@ -12,7 +13,7 @@ export const Dispatch = { connectionAttempted: () => { store.dispatch(Actions.connectionAttempted()); }, - loginSuccessful: (options: Enriched.LoginSuccessContext) => { + loginSuccessful: (options: WebsocketTypes.LoginSuccessContext) => { store.dispatch(Actions.loginSuccessful({ options })); }, loginFailed: () => { @@ -48,7 +49,7 @@ export const Dispatch = { updateInfo: (name: string, version: string) => { store.dispatch(Actions.updateInfo({ info: { name, version } })); }, - updateStatus: (state: Enriched.StatusEnum, description: string) => { + updateStatus: (state: WebsocketTypes.StatusEnum, description: string) => { store.dispatch(Actions.updateStatus({ status: { state, description } })); }, updateUser: (user: Data.ServerInfo_User) => { @@ -93,7 +94,7 @@ export const Dispatch = { registrationUserNameError: (error: string) => { store.dispatch(Actions.registrationUserNameError({ error })); }, - accountAwaitingActivation: (options: Enriched.PendingActivationContext) => { + accountAwaitingActivation: (options: WebsocketTypes.PendingActivationContext) => { store.dispatch(Actions.accountAwaitingActivation({ options })); }, accountActivationSuccess: () => { diff --git a/webclient/src/store/server/server.interfaces.ts b/webclient/src/store/server/server.interfaces.ts index eb7cd4e69..ea7f61d15 100644 --- a/webclient/src/store/server/server.interfaces.ts +++ b/webclient/src/store/server/server.interfaces.ts @@ -1,4 +1,5 @@ import { App, Data, Enriched } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; export interface ServerState { initialized: boolean; @@ -43,7 +44,7 @@ export interface ServerState { export interface ServerStateStatus { connectionAttemptMade: boolean; description: string | null; - state: Enriched.StatusEnum; + state: WebsocketTypes.StatusEnum; } export interface ServerStateInfo { diff --git a/webclient/src/store/server/server.reducer.spec.ts b/webclient/src/store/server/server.reducer.spec.ts index c0edac090..328e10aa5 100644 --- a/webclient/src/store/server/server.reducer.spec.ts +++ b/webclient/src/store/server/server.reducer.spec.ts @@ -1,4 +1,5 @@ -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { create } from '@bufbuild/protobuf'; import { serverReducer, MAX_USER_MESSAGES } from './server.reducer'; import { Actions } from './server.actions'; @@ -18,14 +19,13 @@ import { const UserLevelFlag = Data.ServerInfo_User_UserLevelFlag; -// ── Initialisation ─────────────────────────────────────────────────────────── describe('Initialisation', () => { it('returns initialState when called with undefined state', () => { const result = serverReducer(undefined, { type: '@@INIT' }); expect(result.initialized).toBe(false); expect(result.buddyList).toEqual({}); - expect(result.status.state).toBe(Enriched.StatusEnum.DISCONNECTED); + expect(result.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('INITIALIZED → resets to initialState with initialized: true', () => { @@ -37,7 +37,7 @@ describe('Initialisation', () => { }); it('CLEAR_STORE → resets to initialState but preserves status', () => { - const status = { state: Enriched.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true }; + const status = { state: WebsocketTypes.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true }; const state = makeServerState({ status, banUser: 'someone' }); const result = serverReducer(state, Actions.clearStore()); expect(result.banUser).toBe(''); @@ -52,11 +52,12 @@ describe('Initialisation', () => { }); }); -// ── Account & Connection ───────────────────────────────────────────────────── describe('Account & Connection', () => { it('CONNECTION_ATTEMPTED → sets connectionAttemptMade to true', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, + }); const result = serverReducer(state, Actions.connectionAttempted()); expect(result.status.connectionAttemptMade).toBe(true); }); @@ -81,7 +82,6 @@ describe('Account & Connection', () => { }); }); -// ── Registration ────────────────────────────────────────────────────────────── describe('Registration', () => { it('REGISTRATION_FAILED → stores normalized error (plain reason)', () => { @@ -110,7 +110,6 @@ describe('Registration', () => { }); }); -// ── Server Info & Status ────────────────────────────────────────────────────── describe('Server Info & Status', () => { it('SERVER_MESSAGE → merges message into state.info', () => { @@ -131,15 +130,14 @@ describe('Server Info & Status', () => { it('UPDATE_STATUS → merges state and description into status', () => { const state = makeServerState(); - const update = { state: Enriched.StatusEnum.LOGGED_IN, description: 'ok' }; + const update = { state: WebsocketTypes.StatusEnum.LOGGED_IN, description: 'ok' }; const result = serverReducer(state, Actions.updateStatus({ status: update })); - expect(result.status.state).toBe(Enriched.StatusEnum.LOGGED_IN); + expect(result.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); expect(result.status.description).toBe('ok'); expect(result.status.connectionAttemptMade).toBe(false); }); }); -// ── User ────────────────────────────────────────────────────────────────────── describe('User', () => { it('UPDATE_USER → merges action.payload.user into state.user', () => { @@ -163,7 +161,6 @@ describe('User', () => { }); }); -// ── Users List ──────────────────────────────────────────────────────────────── describe('Users List', () => { it('UPDATE_USERS → replaces users map keyed by name', () => { @@ -192,7 +189,6 @@ describe('Users List', () => { }); }); -// ── Buddy & Ignore Lists ────────────────────────────────────────────────────── describe('Buddy List', () => { it('UPDATE_BUDDY_LIST → replaces map keyed by name', () => { @@ -246,7 +242,6 @@ describe('Ignore List', () => { }); }); -// ── Logs ───────────────────────────────────────────────────────────────────── describe('Logs', () => { it('VIEW_LOGS → groups LogItem[] into room/game/chat buckets', () => { @@ -273,7 +268,6 @@ describe('Logs', () => { }); }); -// ── Messaging ───────────────────────────────────────────────────────────────── describe('Messaging', () => { it('USER_MESSAGE → uses receiverName as key when current user is sender', () => { @@ -326,7 +320,6 @@ describe('Messaging', () => { }); }); -// ── User Info & Notifications ───────────────────────────────────────────────── describe('User Info & Notifications', () => { it('GET_USER_INFO → adds userInfo keyed by name', () => { @@ -352,7 +345,6 @@ describe('User Info & Notifications', () => { }); }); -// ── Moderation ──────────────────────────────────────────────────────────────── describe('Moderation', () => { it('BAN_FROM_SERVER → sets banUser', () => { @@ -401,7 +393,6 @@ describe('Moderation', () => { }); }); -// ── ADJUST_MOD ──────────────────────────────────────────────────────────────── describe('ADJUST_MOD', () => { const baseUserLevel = UserLevelFlag.IsUser | UserLevelFlag.IsRegistered | UserLevelFlag.IsModerator | UserLevelFlag.IsJudge; @@ -459,7 +450,6 @@ describe('ADJUST_MOD', () => { }); }); -// ── Replays ─────────────────────────────────────────────────────────────────── describe('Replays', () => { it('REPLAY_LIST → replaces replays map keyed by gameId', () => { @@ -512,7 +502,6 @@ describe('Replays', () => { }); }); -// ── Deck Storage ────────────────────────────────────────────────────────────── describe('Deck Storage', () => { it('BACKEND_DECKS → sets backendDecks', () => { @@ -675,7 +664,6 @@ describe('Deck Storage', () => { }); }); -// ── GAMES_OF_USER ───────────────────────────────────────────────────────────── describe('GAMES_OF_USER', () => { it('stores normalized games keyed by userName and gameId', () => { diff --git a/webclient/src/store/server/server.reducer.ts b/webclient/src/store/server/server.reducer.ts index 9e3033961..1aa7efc8b 100644 --- a/webclient/src/store/server/server.reducer.ts +++ b/webclient/src/store/server/server.reducer.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { App, Data, Enriched } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { create } from '@bufbuild/protobuf'; import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common'; @@ -73,7 +74,7 @@ const initialState: ServerState = { status: { connectionAttemptMade: false, - state: Enriched.StatusEnum.DISCONNECTED, + state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, info: { @@ -177,7 +178,7 @@ export const serverSlice = createSlice({ state.status.state = status.state; state.status.description = status.description; - if (status.state === Enriched.StatusEnum.DISCONNECTED) { + if (status.state === WebsocketTypes.StatusEnum.DISCONNECTED) { state.status.connectionAttemptMade = false; } }, @@ -403,10 +404,10 @@ export const serverSlice = createSlice({ }, // Signal-only action types — no state mutation, defined so type strings are generated - accountAwaitingActivation: (_state, _action: PayloadAction<{ options: Enriched.PendingActivationContext }>) => {}, + accountAwaitingActivation: (_state, _action: PayloadAction<{ options: WebsocketTypes.PendingActivationContext }>) => {}, accountActivationFailed: (_state) => {}, accountActivationSuccess: (_state) => {}, - loginSuccessful: (_state, _action: PayloadAction<{ options: Enriched.LoginSuccessContext }>) => {}, + loginSuccessful: (_state, _action: PayloadAction<{ options: WebsocketTypes.LoginSuccessContext }>) => {}, loginFailed: (_state) => {}, connectionFailed: (_state) => {}, testConnectionSuccessful: (_state) => {}, diff --git a/webclient/src/store/server/server.selectors.spec.ts b/webclient/src/store/server/server.selectors.spec.ts index 44286f950..df04e38bd 100644 --- a/webclient/src/store/server/server.selectors.spec.ts +++ b/webclient/src/store/server/server.selectors.spec.ts @@ -6,7 +6,8 @@ import { makeServerState, makeUser, } from './__mocks__/server-fixtures'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; function rootState(server: ServerState) { return { server }; @@ -34,17 +35,23 @@ describe('Selectors', () => { }); it('getDescription → returns status.description', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.CONNECTED, description: 'ok' } }); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.CONNECTED, description: 'ok' }, + }); expect(Selectors.getDescription(rootState(state))).toBe('ok'); }); it('getState → returns status.state', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.LOGGED_IN, description: null } }); - expect(Selectors.getState(rootState(state))).toBe(Enriched.StatusEnum.LOGGED_IN); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null }, + }); + expect(Selectors.getState(rootState(state))).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); }); it('getConnectionAttemptMade → returns status.connectionAttemptMade', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, + }); expect(Selectors.getConnectionAttemptMade(rootState(state))).toBe(true); }); @@ -150,20 +157,25 @@ describe('Selectors', () => { expect(Selectors.getRegistrationError(rootState(state))).toBe('bad input'); }); - // ── derived selectors (createSelector) ────────────────────────────── it('getIsConnected → true when state is LOGGED_IN', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.LOGGED_IN, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null }, + }); expect(Selectors.getIsConnected(rootState(state))).toBe(true); }); it('getIsConnected → false when state is CONNECTED', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.CONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.CONNECTED, description: null }, + }); expect(Selectors.getIsConnected(rootState(state))).toBe(false); }); it('getIsConnected → false when state is DISCONNECTED', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, + }); expect(Selectors.getIsConnected(rootState(state))).toBe(false); }); @@ -186,10 +198,11 @@ describe('Selectors', () => { expect(Selectors.getIsUserModerator(rootState(state))).toBe(false); }); - // ── createSelector reference stability ────────────────────────────── it('getIsConnected → returns same value reference for identical state', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.LOGGED_IN, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null }, + }); const root = rootState(state); const a = Selectors.getIsConnected(root); const b = Selectors.getIsConnected(root); diff --git a/webclient/src/store/server/server.selectors.ts b/webclient/src/store/server/server.selectors.ts index 32f5a2ac6..fb681bb90 100644 --- a/webclient/src/store/server/server.selectors.ts +++ b/webclient/src/store/server/server.selectors.ts @@ -1,5 +1,6 @@ import { createSelector } from '@reduxjs/toolkit'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { SortUtil } from '../common'; import { ServerState } from './server.interfaces'; @@ -23,7 +24,7 @@ export const Selectors = { /** True when the server status has reached LOGGED_IN. */ getIsConnected: createSelector( [({ server }: State) => server.status.state], - (state): boolean => state === Enriched.StatusEnum.LOGGED_IN + (state): boolean => state === WebsocketTypes.StatusEnum.LOGGED_IN ), /** True when the currently logged-in user has the IsModerator level flag. */ diff --git a/webclient/src/store/store.ts b/webclient/src/store/store.ts index 997d97661..b5261b782 100644 --- a/webclient/src/store/store.ts +++ b/webclient/src/store/store.ts @@ -3,11 +3,8 @@ import { isMessage } from '@bufbuild/protobuf'; import { useDispatch, useSelector } from 'react-redux'; import rootReducer from './rootReducer'; -// Protobuf-es v2 messages are already plain objects (no class prototype, unlike v1). -// They carry $typeName (string, identifies the message) and $unknown (binary unknown -// fields) — both are serializable and harmless in Redux state. No conversion needed. -// Fields may include Uint8Array (bytes) and BigInt (int64/uint64), which fail Redux -// Toolkit’s default serializable check, so we extend it to accept these types. +// Protobuf-es v2 messages are plain objects with $typeName/$unknown siblings; +// bytes fields are Uint8Array and int64/uint64 are BigInt. All four pass through. function isSerializable(value: unknown): boolean { return isPlain(value) || isMessage(value) || value instanceof Uint8Array || typeof value === 'bigint'; } diff --git a/webclient/src/types/enriched.ts b/webclient/src/types/enriched.ts index 6cf45316d..cc3de313c 100644 --- a/webclient/src/types/enriched.ts +++ b/webclient/src/types/enriched.ts @@ -10,30 +10,21 @@ import type { ServerInfo_User, } from '@app/generated'; -// ── Domain model types (composition: raw proto + client-side fields) ────────── -// -// `info` holds the proto snapshot verbatim. Normalized/client-only fields -// live as siblings. For `Room`, the repeated collections on `info` -// (gameList, userList, gametypeList) are the *wire snapshot* from the last -// full update — they become stale after subsequent events. Always read from -// the normalized `games`, `users`, and `gametypeMap` fields. +// @critical `info` is the wire snapshot; repeated collections on it go stale. Read normalized siblings. +// See .github/instructions/webclient.instructions.md#data-structure-invariants. export interface GametypeMap { [index: number]: string } -/** Room directory listing — composition of raw proto with normalized collections. */ export interface Room { info: ServerInfo_Room; gametypeMap: GametypeMap; - /** Server-determined display order from the UPDATE_ROOMS sequence. */ order: number; games: { [gameId: number]: Game }; users: { [userName: string]: ServerInfo_User }; } -/** Room directory game listing — composition of raw proto with cached gameType. */ export interface Game { info: ServerInfo_Game; - /** Cached display string resolved from the owning room's gametypeMap at ingest. */ gameType: string; } @@ -41,27 +32,17 @@ export type Message = Event_RoomSay & { timeReceived: number; }; -// ── Active game runtime state (game slice) ─────────────────────────────────── -// -// Composition pattern: the raw proto from Event_GameJoined is stored verbatim -// on `info`. Fields that evolve via in-game events live at the top level. -// -// Convention: `info` is the wire snapshot taken at join time. Fields with a -// proto twin (e.g. `started`) diverge after the first event update — always -// read the top-level field for "current value"; `info.*` is the initial -// server snapshot only. - +// @critical `info` = wire snapshot at join time; top-level twins hold live values updated by game events. +// See .github/instructions/webclient.instructions.md#data-structure-invariants. export interface GameEntry { info: ServerInfo_Game; - // From the Event_GameJoined wrapper (not on ServerInfo_Game itself). hostId: number; localPlayerId: number; spectator: boolean; judge: boolean; resuming: boolean; - // Client-tracked runtime state, updated by game events. started: boolean; activePlayerId: number; activePhase: number; @@ -72,32 +53,22 @@ export interface GameEntry { messages: GameMessage[]; } -/** Normalized from ServerInfo_Player — keyed collections for O(1) lookup. */ export interface PlayerEntry { properties: ServerInfo_PlayerProperties; deckList: string; - /** Zones keyed by zone name (e.g. "hand", "deck", "table"). */ zones: { [zoneName: string]: ZoneEntry }; - /** Player-level counters (e.g. life) keyed by counter id. */ counters: { [counterId: number]: ServerInfo_Counter }; - /** Arrows keyed by arrow id. */ arrows: { [arrowId: number]: ServerInfo_Arrow }; } -/** - * Normalized from ServerInfo_Zone — cards indexed by id for O(1) mutation, - * with `order` preserving display sequence. Iterate via `order.map(id => byId[id])`. - */ export interface ZoneEntry { name: string; /** ZoneType enum value (0=Private, 1=Public, 2=Hidden). */ type: number; withCoords: boolean; - /** Authoritative card count. For hidden zones this may exceed `order.length`. */ + /** Authoritative count; for hidden zones this may exceed `order.length`. */ cardCount: number; - /** Card ids in display order. */ order: number[]; - /** Card lookup by id. */ byId: { [cardId: number]: ServerInfo_Card }; alwaysRevealTopCard: boolean; alwaysLookAtTopCard: boolean; @@ -114,39 +85,3 @@ export interface LogGroups { game: ServerInfo_ChatMessage[]; chat: ServerInfo_ChatMessage[]; } - -// ── Websocket re-exports ───────────────────────────────────────────────────── -// Source of truth lives in @app/websocket. Re-exported here so app code can -// reach these via the Enriched.* namespace without importing @app/websocket. - -export { StatusEnum, WebSocketConnectReason } from '@app/websocket'; - -export type { - GameEventMeta, - LoginConnectOptions, - RegisterConnectOptions, - ActivateConnectOptions, - PasswordResetRequestConnectOptions, - PasswordResetChallengeConnectOptions, - PasswordResetConnectOptions, - TestConnectionOptions, - WebSocketConnectOptions, -} from '@app/websocket'; - -/** - * Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the - * activation dialog can resubmit against the same host/user without re-entering them. - */ -export interface PendingActivationContext { - host: string; - port: string; - userName: string; -} - -/** - * Payload for the LOGIN_SUCCESSFUL signal. Only carries what the UI needs to - * persist into the selected host record (hashedPassword for "remember me"). - */ -export interface LoginSuccessContext { - hashedPassword?: string; -} diff --git a/webclient/src/types/server.ts b/webclient/src/types/server.ts index 59eb399c0..4b23cc02d 100644 --- a/webclient/src/types/server.ts +++ b/webclient/src/types/server.ts @@ -1,10 +1,3 @@ -import type { StatusEnum } from './enriched'; - -export interface ServerStatus { - status: StatusEnum; - description: string; -} - export class Host { id?: number; name: string; diff --git a/webclient/src/websocket/WebClient.spec.ts b/webclient/src/websocket/WebClient.spec.ts index 2fab4d695..d2fdb9b5e 100644 --- a/webclient/src/websocket/WebClient.spec.ts +++ b/webclient/src/websocket/WebClient.spec.ts @@ -29,13 +29,14 @@ vi.mock('./services/ProtobufService', () => ({ import { WebClient } from './WebClient'; import { WebSocketService } from './services/WebSocketService'; import { ProtobufService } from './services/ProtobufService'; -import { StatusEnum } from './interfaces/StatusEnum'; +import { StatusEnum } from './types/StatusEnum'; import { Subject } from 'rxjs'; import { Mock } from 'vitest'; import { SocketTransport } from './services/ProtobufService'; import { WebSocketServiceConfig } from './services/WebSocketService'; -import type { IWebClientResponse, IWebClientRequest } from './interfaces'; -import type { ConnectTarget } from './interfaces/WebClientConfig'; +import type { IWebClientResponse } from './types/WebClientResponse'; +import type { IWebClientRequest } from './types/WebClientRequest'; +import type { ConnectTarget } from './types/WebClientConfig'; import { installMockWebSocket } from './__mocks__/helpers'; function makeMockResponse(): IWebClientResponse { diff --git a/webclient/src/websocket/WebClient.ts b/webclient/src/websocket/WebClient.ts index ddd197ce6..ec628440e 100644 --- a/webclient/src/websocket/WebClient.ts +++ b/webclient/src/websocket/WebClient.ts @@ -1,11 +1,9 @@ import { ping } from './commands/session'; import { CLIENT_OPTIONS } from './config'; -import type { - ConnectTarget, - IWebClientRequest, - IWebClientResponse, -} from './interfaces'; -import { StatusEnum } from './interfaces'; +import type { ConnectTarget } from './types/WebClientConfig'; +import type { IWebClientRequest } from './types/WebClientRequest'; +import type { IWebClientResponse } from './types/WebClientResponse'; +import { StatusEnum } from './types/StatusEnum'; import { ProtobufService } from './services/ProtobufService'; import { WebSocketService } from './services/WebSocketService'; diff --git a/webclient/src/websocket/__mocks__/WebClient.ts b/webclient/src/websocket/__mocks__/WebClient.ts index 1219c3466..2485830e0 100644 --- a/webclient/src/websocket/__mocks__/WebClient.ts +++ b/webclient/src/websocket/__mocks__/WebClient.ts @@ -17,9 +17,6 @@ * property, not a getter that throws. */ -// --------------------------------------------------------------------------- -// response.session (ISessionResponse) -// --------------------------------------------------------------------------- const session = { initialized: vi.fn(), connectionAttempted: vi.fn(), @@ -80,9 +77,6 @@ const session = { replayDownloaded: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.room (IRoomResponse) -// --------------------------------------------------------------------------- const room = { clearStore: vi.fn(), joinRoom: vi.fn(), @@ -97,9 +91,6 @@ const room = { joinedGame: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.game (IGameResponse) -// --------------------------------------------------------------------------- const game = { clearStore: vi.fn(), gameStateChanged: vi.fn(), @@ -133,9 +124,6 @@ const game = { zonePropertiesChanged: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.admin (IAdminResponse) -// --------------------------------------------------------------------------- const admin = { adjustMod: vi.fn(), reloadConfig: vi.fn(), @@ -143,9 +131,6 @@ const admin = { updateServerMessage: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.moderator (IModeratorResponse) -// --------------------------------------------------------------------------- const moderator = { banFromServer: vi.fn(), banHistory: vi.fn(), @@ -159,9 +144,6 @@ const moderator = { updateAdminNotes: vi.fn(), }; -// --------------------------------------------------------------------------- -// Exported mock — replaces the real WebClient module for all consumers. -// --------------------------------------------------------------------------- export const WebClient = { _instance: null as any, instance: { diff --git a/webclient/src/websocket/__mocks__/helpers.ts b/webclient/src/websocket/__mocks__/helpers.ts index c76fd9f17..7ef2ed718 100644 --- a/webclient/src/websocket/__mocks__/helpers.ts +++ b/webclient/src/websocket/__mocks__/helpers.ts @@ -1,27 +1,6 @@ /** * Shared mock factories for websocket layer unit tests. - * Import the helpers you need in each spec file via: - * import { makeMockWebSocket, useWebClientCleanup } from '../__mocks__/helpers'; */ -import { WebClient } from '../WebClient'; - -/** - * Resets the WebClient singleton to null. Call directly, or use - * `useWebClientCleanup()` to register automatic beforeEach/afterEach hooks. - */ -export function resetWebClientSingleton() { - (WebClient as unknown as { _instance: WebClient | null })._instance = null; -} - -/** - * Registers beforeEach/afterEach hooks that reset the WebClient singleton. - * Call at describe-level or file-level in any spec that mocks WebClient. - * Prevents isolate:false singleton leakage between spec files. - */ -export function useWebClientCleanup() { - beforeEach(() => resetWebClientSingleton()); - afterEach(() => resetWebClientSingleton()); -} /** Builds a mock WebSocket instance */ export function makeMockWebSocketInstance() { diff --git a/webclient/src/websocket/commands/admin/adminCommands.spec.ts b/webclient/src/websocket/commands/admin/adminCommands.spec.ts index 51764a627..08db9e409 100644 --- a/webclient/src/websocket/commands/admin/adminCommands.spec.ts +++ b/webclient/src/websocket/commands/admin/adminCommands.spec.ts @@ -20,9 +20,6 @@ const { invokeOnSuccess } = makeCallbackHelpers( 2 ); -// ---------------------------------------------------------------- -// adjustMod -// ---------------------------------------------------------------- describe('adjustMod', () => { it('calls sendAdminCommand with Command_AdjustMod extension and fields', () => { @@ -41,9 +38,6 @@ describe('adjustMod', () => { }); }); -// ---------------------------------------------------------------- -// reloadConfig -// ---------------------------------------------------------------- describe('reloadConfig', () => { it('calls sendAdminCommand with Command_ReloadConfig extension', () => { @@ -62,9 +56,6 @@ describe('reloadConfig', () => { }); }); -// ---------------------------------------------------------------- -// shutdownServer -// ---------------------------------------------------------------- describe('shutdownServer', () => { it('calls sendAdminCommand with Command_ShutdownServer extension and fields', () => { @@ -83,9 +74,6 @@ describe('shutdownServer', () => { }); }); -// ---------------------------------------------------------------- -// updateServerMessage -// ---------------------------------------------------------------- describe('updateServerMessage', () => { it('calls sendAdminCommand with Command_UpdateServerMessage extension', () => { diff --git a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts index 69ba240fd..4dd7fcf76 100644 --- a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts +++ b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts @@ -39,9 +39,6 @@ const { invokeOnSuccess } = makeCallbackHelpers( 2 ); -// ---------------------------------------------------------------- -// banFromServer -// ---------------------------------------------------------------- describe('banFromServer', () => { it('calls sendModeratorCommand with Command_BanFromServer', () => { @@ -60,9 +57,6 @@ describe('banFromServer', () => { }); }); -// ---------------------------------------------------------------- -// forceActivateUser -// ---------------------------------------------------------------- describe('forceActivateUser', () => { it('calls sendModeratorCommand with Command_ForceActivateUser', () => { @@ -79,9 +73,6 @@ describe('forceActivateUser', () => { }); }); -// ---------------------------------------------------------------- -// getAdminNotes -// ---------------------------------------------------------------- describe('getAdminNotes', () => { it('calls sendModeratorCommand with Command_GetAdminNotes', () => { @@ -101,9 +92,6 @@ describe('getAdminNotes', () => { }); }); -// ---------------------------------------------------------------- -// getBanHistory -// ---------------------------------------------------------------- describe('getBanHistory', () => { it('calls sendModeratorCommand with Command_GetBanHistory', () => { @@ -123,9 +111,6 @@ describe('getBanHistory', () => { }); }); -// ---------------------------------------------------------------- -// getWarnHistory -// ---------------------------------------------------------------- describe('getWarnHistory', () => { it('calls sendModeratorCommand with Command_GetWarnHistory', () => { @@ -145,9 +130,6 @@ describe('getWarnHistory', () => { }); }); -// ---------------------------------------------------------------- -// getWarnList -// ---------------------------------------------------------------- describe('getWarnList', () => { it('calls sendModeratorCommand with Command_GetWarnList', () => { @@ -167,9 +149,6 @@ describe('getWarnList', () => { }); }); -// ---------------------------------------------------------------- -// grantReplayAccess -// ---------------------------------------------------------------- describe('grantReplayAccess', () => { it('calls sendModeratorCommand with Command_GrantReplayAccess', () => { @@ -186,9 +165,6 @@ describe('grantReplayAccess', () => { }); }); -// ---------------------------------------------------------------- -// updateAdminNotes -// ---------------------------------------------------------------- describe('updateAdminNotes', () => { it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => { @@ -205,9 +181,6 @@ describe('updateAdminNotes', () => { }); }); -// ---------------------------------------------------------------- -// viewLogHistory -// ---------------------------------------------------------------- describe('viewLogHistory', () => { it('calls sendModeratorCommand with Command_ViewLogHistory', () => { @@ -229,9 +202,6 @@ describe('viewLogHistory', () => { }); }); -// ---------------------------------------------------------------- -// warnUser -// ---------------------------------------------------------------- describe('warnUser', () => { it('calls sendModeratorCommand with Command_WarnUser', () => { diff --git a/webclient/src/websocket/commands/room/roomCommands.spec.ts b/webclient/src/websocket/commands/room/roomCommands.spec.ts index 57dac994a..b748030a6 100644 --- a/webclient/src/websocket/commands/room/roomCommands.spec.ts +++ b/webclient/src/websocket/commands/room/roomCommands.spec.ts @@ -24,9 +24,6 @@ const { invokeOnSuccess } = makeCallbackHelpers( 3 ); -// ---------------------------------------------------------------- -// createGame -// ---------------------------------------------------------------- describe('createGame', () => { it('calls sendRoomCommand with Command_CreateGame', () => { @@ -43,9 +40,6 @@ describe('createGame', () => { }); }); -// ---------------------------------------------------------------- -// joinGame -// ---------------------------------------------------------------- describe('joinGame', () => { it('calls sendRoomCommand with Command_JoinGame', () => { @@ -62,9 +56,6 @@ describe('joinGame', () => { }); }); -// ---------------------------------------------------------------- -// leaveRoom -// ---------------------------------------------------------------- describe('leaveRoom', () => { it('calls sendRoomCommand with Command_LeaveRoom', () => { @@ -81,9 +72,6 @@ describe('leaveRoom', () => { }); }); -// ---------------------------------------------------------------- -// roomSay -// ---------------------------------------------------------------- describe('roomSay', () => { it('calls sendRoomCommand with trimmed message', () => { diff --git a/webclient/src/websocket/commands/session/activate.ts b/webclient/src/websocket/commands/session/activate.ts index 422ed937f..368e676f7 100644 --- a/webclient/src/websocket/commands/session/activate.ts +++ b/webclient/src/websocket/commands/session/activate.ts @@ -6,10 +6,10 @@ import { type ActivateParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { disconnect, login, updateStatus } from './'; export function activate(options: ConnectTarget & ActivateParams, password?: string, passwordSalt?: string): void { diff --git a/webclient/src/websocket/commands/session/connect.ts b/webclient/src/websocket/commands/session/connect.ts index 035f5a60a..d260ca8c2 100644 --- a/webclient/src/websocket/commands/session/connect.ts +++ b/webclient/src/websocket/commands/session/connect.ts @@ -1,5 +1,5 @@ import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; export function connect(target: ConnectTarget): void { WebClient.instance.connect(target); diff --git a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts index 7246580af..88b9da8e3 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts @@ -5,10 +5,10 @@ import { type ForgotPasswordChallengeParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { disconnect, updateStatus } from './'; export function forgotPasswordChallenge(options: ConnectTarget & ForgotPasswordChallengeParams): void { diff --git a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts index cf8246b30..8099072dc 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts @@ -6,10 +6,10 @@ import { type ForgotPasswordRequestParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { disconnect, updateStatus } from './'; export function forgotPasswordRequest(options: ConnectTarget & ForgotPasswordRequestParams): void { diff --git a/webclient/src/websocket/commands/session/forgotPasswordReset.ts b/webclient/src/websocket/commands/session/forgotPasswordReset.ts index 3acd946b7..74792c292 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordReset.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordReset.ts @@ -6,10 +6,10 @@ import { type ForgotPasswordResetParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { hashPassword } from '../../utils'; import { disconnect, updateStatus } from '.'; diff --git a/webclient/src/websocket/commands/session/login.ts b/webclient/src/websocket/commands/session/login.ts index 80fddfcc7..fc290ceda 100644 --- a/webclient/src/websocket/commands/session/login.ts +++ b/webclient/src/websocket/commands/session/login.ts @@ -8,10 +8,10 @@ import { type LoginParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { hashPassword } from '../../utils'; import { disconnect, diff --git a/webclient/src/websocket/commands/session/register.ts b/webclient/src/websocket/commands/session/register.ts index ec0b5ecfa..42eee407e 100644 --- a/webclient/src/websocket/commands/session/register.ts +++ b/webclient/src/websocket/commands/session/register.ts @@ -8,10 +8,10 @@ import { type RegisterParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { hashPassword } from '../../utils'; import { login, disconnect, updateStatus } from './'; diff --git a/webclient/src/websocket/commands/session/requestPasswordSalt.ts b/webclient/src/websocket/commands/session/requestPasswordSalt.ts index 73fed0aec..3c4e4ff35 100644 --- a/webclient/src/websocket/commands/session/requestPasswordSalt.ts +++ b/webclient/src/websocket/commands/session/requestPasswordSalt.ts @@ -7,10 +7,10 @@ import { type RequestPasswordSaltParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { updateStatus } from './'; export function requestPasswordSalt( diff --git a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts index daf90d083..dda7b8c05 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts @@ -18,8 +18,16 @@ import { Mock } from 'vitest'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; import { WebClient } from '../../WebClient'; import * as SessionIndexMocks from './'; -import { Enriched } from '@app/types'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { + WebSocketConnectReason, + type LoginConnectOptions, + type RegisterConnectOptions, + type ActivateConnectOptions, + type PasswordResetRequestConnectOptions, + type PasswordResetChallengeConnectOptions, + type PasswordResetConnectOptions, +} from '../../types/ConnectOptions'; +import { StatusEnum } from '../../types/StatusEnum'; import { Command_Activate_ext, Command_ForgotPasswordChallenge_ext, @@ -56,50 +64,50 @@ const { invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpe ); const baseTransport = { host: 'h', port: '1' }; -const makeLoginOpts = (overrides: Partial = {}): Enriched.LoginConnectOptions => ({ +const makeLoginOpts = (overrides: Partial = {}): LoginConnectOptions => ({ ...baseTransport, userName: 'alice', - reason: Enriched.WebSocketConnectReason.LOGIN, + reason: WebSocketConnectReason.LOGIN, ...overrides, }); const makeRegisterOpts = ( - overrides: Partial = {} -): Enriched.RegisterConnectOptions => ({ + overrides: Partial = {} +): RegisterConnectOptions => ({ ...baseTransport, userName: 'alice', password: 'pw', email: 'a@b.com', country: 'US', realName: 'Al', - reason: Enriched.WebSocketConnectReason.REGISTER, + reason: WebSocketConnectReason.REGISTER, ...overrides, }); const makeActivateOpts = ( - overrides: Partial = {} -): Enriched.ActivateConnectOptions => ({ + overrides: Partial = {} +): ActivateConnectOptions => ({ ...baseTransport, userName: 'alice', token: 'tok', - reason: Enriched.WebSocketConnectReason.ACTIVATE_ACCOUNT, + reason: WebSocketConnectReason.ACTIVATE_ACCOUNT, ...overrides, }); -const makeForgotRequestOpts = (): Enriched.PasswordResetRequestConnectOptions => ({ +const makeForgotRequestOpts = (): PasswordResetRequestConnectOptions => ({ ...baseTransport, userName: 'alice', - reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_REQUEST, + reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST, }); -const makeForgotChallengeOpts = (): Enriched.PasswordResetChallengeConnectOptions => ({ +const makeForgotChallengeOpts = (): PasswordResetChallengeConnectOptions => ({ ...baseTransport, userName: 'alice', email: 'a@b.com', - reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE, + reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE, }); -const makeForgotResetOpts = (): Enriched.PasswordResetConnectOptions => ({ +const makeForgotResetOpts = (): PasswordResetConnectOptions => ({ ...baseTransport, userName: 'alice', token: 'tok', newPassword: 'newpw', - reason: Enriched.WebSocketConnectReason.PASSWORD_RESET, + reason: WebSocketConnectReason.PASSWORD_RESET, }); @@ -109,9 +117,6 @@ beforeEach(() => { (passwordSaltSupported as Mock).mockReturnValue(0); }); -// ---------------------------------------------------------------- -// connect.ts -// ---------------------------------------------------------------- describe('connect', () => { it('calls WebClient.instance.connect with the target', () => { @@ -128,9 +133,6 @@ describe('testConnect', () => { }); }); -// ---------------------------------------------------------------- -// updateStatus.ts -// ---------------------------------------------------------------- describe('updateStatus', () => { it('calls WebClient.instance.response.session.updateStatus and WebClient.instance.updateStatus', () => { @@ -140,9 +142,6 @@ describe('updateStatus', () => { }); }); -// ---------------------------------------------------------------- -// login.ts -// ---------------------------------------------------------------- describe('login', () => { it('sends Command_Login with plain password when no salt', () => { @@ -194,7 +193,7 @@ describe('login', () => { }); it('onSuccess passes hashedPassword to loginSuccessful when salt is used', () => { - login({ host: 'h', port: '1', userName: 'alice', reason: Enriched.WebSocketConnectReason.LOGIN }, 'pw', 'salt'); + login({ host: 'h', port: '1', userName: 'alice', reason: WebSocketConnectReason.LOGIN }, 'pw', 'salt'); const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } }; invokeOnSuccess(loginResp, { responseCode: 0 }); const calledWith = (WebClient.instance.response.session.loginSuccessful as Mock).mock.calls[0][0]; @@ -266,9 +265,6 @@ describe('login', () => { }); }); -// ---------------------------------------------------------------- -// register.ts -// ---------------------------------------------------------------- describe('register', () => { it('sends Command_Register with plain password when no salt', () => { @@ -371,9 +367,6 @@ describe('register', () => { }); }); -// ---------------------------------------------------------------- -// activate.ts -// ---------------------------------------------------------------- describe('activate', () => { it('sends Command_Activate with userName and token, not password', () => { @@ -405,9 +398,6 @@ describe('activate', () => { }); }); -// ---------------------------------------------------------------- -// forgotPasswordChallenge.ts -// ---------------------------------------------------------------- describe('forgotPasswordChallenge', () => { it('sends Command_ForgotPasswordChallenge', () => { @@ -432,9 +422,6 @@ describe('forgotPasswordChallenge', () => { }); }); -// ---------------------------------------------------------------- -// forgotPasswordRequest.ts -// ---------------------------------------------------------------- describe('forgotPasswordRequest', () => { it('sends Command_ForgotPasswordRequest', () => { @@ -470,9 +457,6 @@ describe('forgotPasswordRequest', () => { }); }); -// ---------------------------------------------------------------- -// forgotPasswordReset.ts -// ---------------------------------------------------------------- describe('forgotPasswordReset', () => { it('sends Command_ForgotPasswordReset with plain newPassword when no salt', () => { @@ -508,9 +492,6 @@ describe('forgotPasswordReset', () => { }); }); -// ---------------------------------------------------------------- -// requestPasswordSalt.ts -// ---------------------------------------------------------------- describe('requestPasswordSalt', () => { it('sends Command_RequestPasswordSalt', () => { diff --git a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts index 18c8be728..289ca227a 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts @@ -92,7 +92,6 @@ beforeEach(() => { (passwordSaltSupported as Mock).mockReturnValue(0); }); -// ---------------------------------------------------------------- describe('accountEdit', () => { it('sends Command_AccountEdit with correct params', () => { diff --git a/webclient/src/websocket/commands/session/updateStatus.ts b/webclient/src/websocket/commands/session/updateStatus.ts index 52cb9ccbc..596b72f21 100644 --- a/webclient/src/websocket/commands/session/updateStatus.ts +++ b/webclient/src/websocket/commands/session/updateStatus.ts @@ -1,4 +1,4 @@ -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { WebClient } from '../../WebClient'; export function updateStatus(status: StatusEnum, description: string): void { diff --git a/webclient/src/websocket/events/game/attachCard.ts b/webclient/src/websocket/events/game/attachCard.ts index a3671cb05..9de5c6114 100644 --- a/webclient/src/websocket/events/game/attachCard.ts +++ b/webclient/src/websocket/events/game/attachCard.ts @@ -1,5 +1,5 @@ import type { Event_AttachCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function attachCard(data: Event_AttachCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/changeZoneProperties.ts b/webclient/src/websocket/events/game/changeZoneProperties.ts index 88d7f95f1..8afff45bf 100644 --- a/webclient/src/websocket/events/game/changeZoneProperties.ts +++ b/webclient/src/websocket/events/game/changeZoneProperties.ts @@ -1,5 +1,5 @@ import type { Event_ChangeZoneProperties } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function changeZoneProperties(data: Event_ChangeZoneProperties, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createArrow.ts b/webclient/src/websocket/events/game/createArrow.ts index 3f39fa064..ef2443365 100644 --- a/webclient/src/websocket/events/game/createArrow.ts +++ b/webclient/src/websocket/events/game/createArrow.ts @@ -1,5 +1,5 @@ import type { Event_CreateArrow } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createArrow(data: Event_CreateArrow, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createCounter.ts b/webclient/src/websocket/events/game/createCounter.ts index db9ca6086..54abcd0cf 100644 --- a/webclient/src/websocket/events/game/createCounter.ts +++ b/webclient/src/websocket/events/game/createCounter.ts @@ -1,5 +1,5 @@ import type { Event_CreateCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createCounter(data: Event_CreateCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createToken.ts b/webclient/src/websocket/events/game/createToken.ts index 1542d4418..9c406da16 100644 --- a/webclient/src/websocket/events/game/createToken.ts +++ b/webclient/src/websocket/events/game/createToken.ts @@ -1,5 +1,5 @@ import type { Event_CreateToken } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createToken(data: Event_CreateToken, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/delCounter.ts b/webclient/src/websocket/events/game/delCounter.ts index 1a128b0ad..bf4231a93 100644 --- a/webclient/src/websocket/events/game/delCounter.ts +++ b/webclient/src/websocket/events/game/delCounter.ts @@ -1,5 +1,5 @@ import type { Event_DelCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function delCounter(data: Event_DelCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/deleteArrow.ts b/webclient/src/websocket/events/game/deleteArrow.ts index df39f5d47..d3653313b 100644 --- a/webclient/src/websocket/events/game/deleteArrow.ts +++ b/webclient/src/websocket/events/game/deleteArrow.ts @@ -1,5 +1,5 @@ import type { Event_DeleteArrow } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function deleteArrow(data: Event_DeleteArrow, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/destroyCard.ts b/webclient/src/websocket/events/game/destroyCard.ts index 65cb87d38..de010fbfb 100644 --- a/webclient/src/websocket/events/game/destroyCard.ts +++ b/webclient/src/websocket/events/game/destroyCard.ts @@ -1,5 +1,5 @@ import type { Event_DestroyCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function destroyCard(data: Event_DestroyCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/drawCards.ts b/webclient/src/websocket/events/game/drawCards.ts index c9fed3078..c9ab5e26e 100644 --- a/webclient/src/websocket/events/game/drawCards.ts +++ b/webclient/src/websocket/events/game/drawCards.ts @@ -1,5 +1,5 @@ import type { Event_DrawCards } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function drawCards(data: Event_DrawCards, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/dumpZone.ts b/webclient/src/websocket/events/game/dumpZone.ts index 8302877ef..24f2f3e72 100644 --- a/webclient/src/websocket/events/game/dumpZone.ts +++ b/webclient/src/websocket/events/game/dumpZone.ts @@ -1,5 +1,5 @@ import type { Event_DumpZone } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function dumpZone(data: Event_DumpZone, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/flipCard.ts b/webclient/src/websocket/events/game/flipCard.ts index 1c4e74cd3..4b57e89c3 100644 --- a/webclient/src/websocket/events/game/flipCard.ts +++ b/webclient/src/websocket/events/game/flipCard.ts @@ -1,5 +1,5 @@ import type { Event_FlipCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function flipCard(data: Event_FlipCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameClosed.ts b/webclient/src/websocket/events/game/gameClosed.ts index 73fc445e2..40168cf0d 100644 --- a/webclient/src/websocket/events/game/gameClosed.ts +++ b/webclient/src/websocket/events/game/gameClosed.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameClosed(_data: {}, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameHostChanged.ts b/webclient/src/websocket/events/game/gameHostChanged.ts index 2cc7e5064..da89fc34d 100644 --- a/webclient/src/websocket/events/game/gameHostChanged.ts +++ b/webclient/src/websocket/events/game/gameHostChanged.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; /** diff --git a/webclient/src/websocket/events/game/gameSay.ts b/webclient/src/websocket/events/game/gameSay.ts index 7773720de..18a643e0f 100644 --- a/webclient/src/websocket/events/game/gameSay.ts +++ b/webclient/src/websocket/events/game/gameSay.ts @@ -1,5 +1,5 @@ import type { Event_GameSay } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameSay(data: Event_GameSay, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameStateChanged.ts b/webclient/src/websocket/events/game/gameStateChanged.ts index ff3d53fa3..cc5b95d7a 100644 --- a/webclient/src/websocket/events/game/gameStateChanged.ts +++ b/webclient/src/websocket/events/game/gameStateChanged.ts @@ -1,5 +1,5 @@ import type { Event_GameStateChanged } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameStateChanged(data: Event_GameStateChanged, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/index.ts b/webclient/src/websocket/events/game/index.ts index d19292e80..5798a1aaa 100644 --- a/webclient/src/websocket/events/game/index.ts +++ b/webclient/src/websocket/events/game/index.ts @@ -35,7 +35,7 @@ import { Event_ReverseTurn_ext, } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { attachCard } from './attachCard'; import { changeZoneProperties } from './changeZoneProperties'; diff --git a/webclient/src/websocket/events/game/joinGame.ts b/webclient/src/websocket/events/game/joinGame.ts index f0efd619b..b2ede9a4d 100644 --- a/webclient/src/websocket/events/game/joinGame.ts +++ b/webclient/src/websocket/events/game/joinGame.ts @@ -1,5 +1,5 @@ import type { Event_Join } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; diff --git a/webclient/src/websocket/events/game/kicked.ts b/webclient/src/websocket/events/game/kicked.ts index f63951dcc..ba7db0674 100644 --- a/webclient/src/websocket/events/game/kicked.ts +++ b/webclient/src/websocket/events/game/kicked.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function kicked(_data: {}, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/leaveGame.ts b/webclient/src/websocket/events/game/leaveGame.ts index 5d2df026e..738a34ce7 100644 --- a/webclient/src/websocket/events/game/leaveGame.ts +++ b/webclient/src/websocket/events/game/leaveGame.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function leaveGame(data: { reason: number }, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/moveCard.ts b/webclient/src/websocket/events/game/moveCard.ts index a553e727a..47910ad4e 100644 --- a/webclient/src/websocket/events/game/moveCard.ts +++ b/webclient/src/websocket/events/game/moveCard.ts @@ -1,5 +1,5 @@ import type { Event_MoveCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function moveCard(data: Event_MoveCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/playerPropertiesChanged.ts b/webclient/src/websocket/events/game/playerPropertiesChanged.ts index 073f5a408..d01a1578d 100644 --- a/webclient/src/websocket/events/game/playerPropertiesChanged.ts +++ b/webclient/src/websocket/events/game/playerPropertiesChanged.ts @@ -1,5 +1,5 @@ import type { Event_PlayerPropertiesChanged } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function playerPropertiesChanged(data: Event_PlayerPropertiesChanged, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/revealCards.ts b/webclient/src/websocket/events/game/revealCards.ts index 9eb08b82a..cce29c619 100644 --- a/webclient/src/websocket/events/game/revealCards.ts +++ b/webclient/src/websocket/events/game/revealCards.ts @@ -1,5 +1,5 @@ import type { Event_RevealCards } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function revealCards(data: Event_RevealCards, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/reverseTurn.ts b/webclient/src/websocket/events/game/reverseTurn.ts index ef953aba8..5340e5ae3 100644 --- a/webclient/src/websocket/events/game/reverseTurn.ts +++ b/webclient/src/websocket/events/game/reverseTurn.ts @@ -1,5 +1,5 @@ import type { Event_ReverseTurn } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function reverseTurn(data: Event_ReverseTurn, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/rollDie.ts b/webclient/src/websocket/events/game/rollDie.ts index ca5365144..da411fad4 100644 --- a/webclient/src/websocket/events/game/rollDie.ts +++ b/webclient/src/websocket/events/game/rollDie.ts @@ -1,5 +1,5 @@ import type { Event_RollDie } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function rollDie(data: Event_RollDie, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setActivePhase.ts b/webclient/src/websocket/events/game/setActivePhase.ts index 134651bdb..c7f92961c 100644 --- a/webclient/src/websocket/events/game/setActivePhase.ts +++ b/webclient/src/websocket/events/game/setActivePhase.ts @@ -1,5 +1,5 @@ import type { Event_SetActivePhase } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setActivePhase(data: Event_SetActivePhase, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setActivePlayer.ts b/webclient/src/websocket/events/game/setActivePlayer.ts index e84f44920..730f9614e 100644 --- a/webclient/src/websocket/events/game/setActivePlayer.ts +++ b/webclient/src/websocket/events/game/setActivePlayer.ts @@ -1,5 +1,5 @@ import type { Event_SetActivePlayer } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setActivePlayer(data: Event_SetActivePlayer, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCardAttr.ts b/webclient/src/websocket/events/game/setCardAttr.ts index 3733dc7b1..1ce1d63bc 100644 --- a/webclient/src/websocket/events/game/setCardAttr.ts +++ b/webclient/src/websocket/events/game/setCardAttr.ts @@ -1,5 +1,5 @@ import type { Event_SetCardAttr } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCardAttr(data: Event_SetCardAttr, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCardCounter.ts b/webclient/src/websocket/events/game/setCardCounter.ts index c96d50ac6..b78786f89 100644 --- a/webclient/src/websocket/events/game/setCardCounter.ts +++ b/webclient/src/websocket/events/game/setCardCounter.ts @@ -1,5 +1,5 @@ import type { Event_SetCardCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCardCounter(data: Event_SetCardCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCounter.ts b/webclient/src/websocket/events/game/setCounter.ts index 310fb9e28..c4cf794c2 100644 --- a/webclient/src/websocket/events/game/setCounter.ts +++ b/webclient/src/websocket/events/game/setCounter.ts @@ -1,5 +1,5 @@ import type { Event_SetCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCounter(data: Event_SetCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/shuffle.ts b/webclient/src/websocket/events/game/shuffle.ts index 9d1689472..2c404390d 100644 --- a/webclient/src/websocket/events/game/shuffle.ts +++ b/webclient/src/websocket/events/game/shuffle.ts @@ -1,5 +1,5 @@ import type { Event_Shuffle } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function shuffle(data: Event_Shuffle, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/session/connectionClosed.ts b/webclient/src/websocket/events/session/connectionClosed.ts index b3422172f..d292d5687 100644 --- a/webclient/src/websocket/events/session/connectionClosed.ts +++ b/webclient/src/websocket/events/session/connectionClosed.ts @@ -1,11 +1,10 @@ import { Event_ConnectionClosed_CloseReason, type Event_ConnectionClosed } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { updateStatus } from '../../commands/session'; export function connectionClosed({ reason, reasonStr, endTime }: Event_ConnectionClosed): void { let message: string; - // @TODO (5) if (reasonStr) { message = reasonStr; } else { diff --git a/webclient/src/websocket/events/session/serverIdentification.ts b/webclient/src/websocket/events/session/serverIdentification.ts index 6c7956f3f..57e999032 100644 --- a/webclient/src/websocket/events/session/serverIdentification.ts +++ b/webclient/src/websocket/events/session/serverIdentification.ts @@ -1,9 +1,9 @@ import type { Event_ServerIdentification } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { PROTOCOL_VERSION } from '../../config'; import { consumePendingOptions } from '../../utils/connectionState'; -import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; +import { WebSocketConnectReason } from '../../types/ConnectOptions'; import { generateSalt, passwordSaltSupported } from '../../utils'; import * as SessionCommands from '../../commands/session'; diff --git a/webclient/src/websocket/events/session/sessionEvents.spec.ts b/webclient/src/websocket/events/session/sessionEvents.spec.ts index c627a6856..7383a13d5 100644 --- a/webclient/src/websocket/events/session/sessionEvents.spec.ts +++ b/webclient/src/websocket/events/session/sessionEvents.spec.ts @@ -58,8 +58,8 @@ import * as Config from '../../config'; import * as SessionCmds from '../../commands/session'; import { consumePendingOptions } from '../../utils/connectionState'; import { passwordSaltSupported } from '../../utils'; -import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { WebSocketConnectReason } from '../../types/ConnectOptions'; +import { StatusEnum } from '../../types/StatusEnum'; import { Mock } from 'vitest'; import { gameJoined } from './gameJoined'; import { notifyUser } from './notifyUser'; @@ -78,9 +78,6 @@ import { serverIdentification } from './serverIdentification'; const ConfigMock = Config as { -readonly [K in keyof typeof Config]: (typeof Config)[K] }; -// ---------------------------------------------------------------- -// gameJoined -// ---------------------------------------------------------------- describe('gameJoined', () => { it('calls WebClient.instance.response.session.gameJoined', () => { @@ -90,9 +87,6 @@ describe('gameJoined', () => { }); }); -// ---------------------------------------------------------------- -// notifyUser -// ---------------------------------------------------------------- describe('notifyUser', () => { it('calls WebClient.instance.response.session.notifyUser', () => { @@ -102,9 +96,6 @@ describe('notifyUser', () => { }); }); -// ---------------------------------------------------------------- -// replayAdded -// ---------------------------------------------------------------- describe('replayAdded', () => { it('calls WebClient.instance.response.session.replayAdded with matchInfo', () => { @@ -116,9 +107,6 @@ describe('replayAdded', () => { }); }); -// ---------------------------------------------------------------- -// serverCompleteList -// ---------------------------------------------------------------- describe('serverCompleteList', () => { it('calls WebClient.instance.response.session.updateUsers and WebClient.instance.response.room.updateRooms', () => { @@ -129,9 +117,6 @@ describe('serverCompleteList', () => { }); }); -// ---------------------------------------------------------------- -// serverMessage -// ---------------------------------------------------------------- describe('serverMessage', () => { it('calls WebClient.instance.response.session.serverMessage with message', () => { @@ -140,9 +125,6 @@ describe('serverMessage', () => { }); }); -// ---------------------------------------------------------------- -// serverShutdown -// ---------------------------------------------------------------- describe('serverShutdown', () => { it('calls WebClient.instance.response.session.serverShutdown', () => { @@ -152,9 +134,6 @@ describe('serverShutdown', () => { }); }); -// ---------------------------------------------------------------- -// userJoined -// ---------------------------------------------------------------- describe('userJoined', () => { it('calls WebClient.instance.response.session.userJoined with userInfo', () => { @@ -166,9 +145,6 @@ describe('userJoined', () => { }); }); -// ---------------------------------------------------------------- -// userLeft -// ---------------------------------------------------------------- describe('userLeft', () => { it('calls WebClient.instance.response.session.userLeft with name', () => { @@ -177,9 +153,6 @@ describe('userLeft', () => { }); }); -// ---------------------------------------------------------------- -// userMessage -// ---------------------------------------------------------------- describe('userMessage', () => { it('calls WebClient.instance.response.session.userMessage', () => { @@ -189,9 +162,6 @@ describe('userMessage', () => { }); }); -// ---------------------------------------------------------------- -// addToList -// ---------------------------------------------------------------- describe('addToList', () => { let logSpy: ReturnType; beforeEach(() => { @@ -225,9 +195,6 @@ describe('addToList', () => { }); }); -// ---------------------------------------------------------------- -// removeFromList -// ---------------------------------------------------------------- describe('removeFromList', () => { it('buddy list → removeFromBuddyList', () => { @@ -248,9 +215,6 @@ describe('removeFromList', () => { }); }); -// ---------------------------------------------------------------- -// listRooms -// ---------------------------------------------------------------- describe('listRooms', () => { it('calls WebClient.instance.response.room.updateRooms', () => { @@ -279,9 +243,6 @@ describe('listRooms', () => { }); }); -// ---------------------------------------------------------------- -// connectionClosed -// ---------------------------------------------------------------- describe('connectionClosed', () => { it('uses reasonStr when provided', () => { @@ -371,9 +332,6 @@ describe('connectionClosed', () => { }); }); -// ---------------------------------------------------------------- -// serverIdentification -// ---------------------------------------------------------------- describe('serverIdentification', () => { const makeInfo = (overrides: Record = {}) => create(Event_ServerIdentificationSchema, { diff --git a/webclient/src/websocket/index.ts b/webclient/src/websocket/index.ts index 9d0cd9481..4afca0522 100644 --- a/webclient/src/websocket/index.ts +++ b/webclient/src/websocket/index.ts @@ -1,32 +1,10 @@ export * from './commands'; -export * from './interfaces'; export { WebClient } from './WebClient'; -export { StatusEnum } from './interfaces/StatusEnum'; -export type { WebClientConfig, ConnectTarget } from './interfaces/WebClientConfig'; -export type { - KeyOf, - GameEventMeta, - WebSocketSessionResponseOverrides, - WebSocketRoomResponseOverrides, -} from './interfaces/WebSocketConfig'; export { SessionEvents } from './events/session'; export { RoomEvents } from './events/room'; export { GameEvents } from './events/game'; export { generateSalt, passwordSaltSupported, hashPassword } from './utils'; - -export { WebSocketConnectReason } from './interfaces/ConnectOptions'; -export type { - LoginConnectOptions, - RegisterConnectOptions, - ActivateConnectOptions, - PasswordResetRequestConnectOptions, - PasswordResetChallengeConnectOptions, - PasswordResetConnectOptions, - TestConnectionOptions, - WebSocketConnectOptions, -} from './interfaces/ConnectOptions'; - export { setPendingOptions, consumePendingOptions } from './utils/connectionState'; diff --git a/webclient/src/websocket/interfaces/WebSocketConfig.ts b/webclient/src/websocket/interfaces/WebSocketConfig.ts deleted file mode 100644 index cc1dface5..000000000 --- a/webclient/src/websocket/interfaces/WebSocketConfig.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { - GameEventContext, - Response_Login, - Response, - Event_RoomSay, - ResponseMap, - RoomEventMap, -} from '@app/generated'; - -// ── KeyOf utility ──────────────────────────────────────────────────────────── -// Derives a type map key from a generated type. Allows interface methods to -// reference generated types instead of hardcoded string keys. -// -// T[KeyOf] -// ↓ resolves to ↓ -// T['Response_Login'] - -export type KeyOf = { [K in keyof Map]: Map[K] extends V ? K : never }[keyof Map]; - -// ── GameEventMeta ──────────────────────────────────────────────────────────── -// Per-container metadata passed to every game event handler alongside the -// event payload. Constructed by ProtobufService.processGameEvent from the -// GameEventContainer fields. Structurally identical to Enriched.GameEventMeta. - -export interface GameEventMeta { - gameId: number; - playerId: number; - context: GameEventContext | null; - secondsElapsed: number; - forcedByJudge: number; -} - -// ── Websocket-layer enrichments ────────────────────────────────────────────── -// Protocol-level enrichments of proto types — these are websocket concerns, -// not app concerns. Used as the DEFAULT generic on the response interfaces. - -export interface WebSocketSessionResponseOverrides extends ResponseMap { - Response_Login: Response_Login & { hashedPassword?: string }; - Response: Response & { host: string; port: string; userName: string }; -} - -export interface WebSocketRoomResponseOverrides extends RoomEventMap { - Event_RoomSay: Event_RoomSay & { timeReceived: number }; -} diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts index fe65593d3..44825a057 100644 --- a/webclient/src/websocket/services/ProtobufService.spec.ts +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -510,10 +510,6 @@ describe('ProtobufService', () => { }); -// ── Real protobuf round-trip test ───────────────────────────────────────────── -// This describe block does NOT mock @bufbuild/protobuf so it exercises real -// binary serialization. It proves that the schemas ProtobufService uses -// survive a toBinary → fromBinary cycle without data loss. describe('ProtobufService protobuf round-trip (real @bufbuild/protobuf)', () => { it('CommandContainer round-trips cmdId through toBinary → fromBinary', async () => { const { create, toBinary, fromBinary: realFromBinary } = diff --git a/webclient/src/websocket/services/ProtobufService.ts b/webclient/src/websocket/services/ProtobufService.ts index 2ffabacad..23ab52f06 100644 --- a/webclient/src/websocket/services/ProtobufService.ts +++ b/webclient/src/websocket/services/ProtobufService.ts @@ -26,7 +26,7 @@ import { import { GameEvents } from '../events/game'; import { RoomEvents } from '../events/room'; import { SessionEvents } from '../events/session'; -import type { GameEventMeta } from '../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../types/WebSocketConfig'; import { type CommandOptions, handleResponse } from './command-options'; export interface SocketTransport { diff --git a/webclient/src/websocket/services/WebSocketService.spec.ts b/webclient/src/websocket/services/WebSocketService.spec.ts index 553adbedf..83f1d6dc1 100644 --- a/webclient/src/websocket/services/WebSocketService.spec.ts +++ b/webclient/src/websocket/services/WebSocketService.spec.ts @@ -9,7 +9,7 @@ vi.mock('../config', () => ({ import { WebSocketService } from './WebSocketService'; import type { WebSocketServiceConfig } from './WebSocketService'; import { KeepAliveService } from './KeepAliveService'; -import { StatusEnum } from '../interfaces/StatusEnum'; +import { StatusEnum } from '../types/StatusEnum'; type WebSocketInternal = WebSocketService & { keepAliveService: KeepAliveService; diff --git a/webclient/src/websocket/services/WebSocketService.ts b/webclient/src/websocket/services/WebSocketService.ts index 32ebc5c33..b3e082c22 100644 --- a/webclient/src/websocket/services/WebSocketService.ts +++ b/webclient/src/websocket/services/WebSocketService.ts @@ -1,9 +1,9 @@ import { Subject } from 'rxjs'; -import { StatusEnum } from '../interfaces/StatusEnum'; +import { StatusEnum } from '../types/StatusEnum'; import { KeepAliveService } from './KeepAliveService'; import { CLIENT_OPTIONS } from '../config'; -import type { ConnectTarget } from '../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../types/WebClientConfig'; export interface WebSocketServiceConfig { keepAliveFn: (pingReceived: () => void) => void; @@ -16,7 +16,7 @@ export class WebSocketService { private config: WebSocketServiceConfig; private keepAliveService: KeepAliveService; - private errorFired = false; + private hasReportedError = false; public message$: Subject = new Subject(); @@ -68,7 +68,7 @@ export class WebSocketService { socket.onopen = () => { clearTimeout(connectionTimer); - this.errorFired = false; + this.hasReportedError = false; this.config.onStatusChange(StatusEnum.CONNECTED, 'Connected'); this.keepAliveService.startPingLoop(this.keepalive, (pingReceived: () => void) => { @@ -77,16 +77,17 @@ export class WebSocketService { }; socket.onclose = () => { - // dont overwrite failure messages - if (!this.errorFired) { + // @critical onerror + onclose both fire on failed connects; don't overwrite the richer error status. + // See .github/instructions/webclient.instructions.md#websocket-lifecycle. + if (!this.hasReportedError) { this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Closed'); } - this.errorFired = false; + this.hasReportedError = false; this.keepAliveService.endPingLoop(); }; socket.onerror = () => { - this.errorFired = true; + this.hasReportedError = true; this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Failed'); this.config.onConnectionFailed(); }; diff --git a/webclient/src/websocket/services/command-options.spec.ts b/webclient/src/websocket/services/command-options.spec.ts index 19dd644be..a898dfb5e 100644 --- a/webclient/src/websocket/services/command-options.spec.ts +++ b/webclient/src/websocket/services/command-options.spec.ts @@ -10,12 +10,6 @@ import { create, getExtension } from '@bufbuild/protobuf'; import { handleResponse } from './command-options'; -// NOTE: do NOT call `vi.resetAllMocks()` here — under `isolate: false` it -// resets `vi.fn()` implementations set inside other files' `vi.mock(...)` -// factories, which breaks any spec that relied on those factory defaults -// (e.g. ProtobufService.spec.ts expects `hasExtension` to return `false`). -// The root `setupTests.ts` afterEach already calls `vi.clearAllMocks()`. - describe('handleResponse', () => { it('calls onResponse and returns early when provided', () => { const onResponse = vi.fn(); diff --git a/webclient/src/websocket/interfaces/ConnectOptions.ts b/webclient/src/websocket/types/ConnectOptions.ts similarity index 80% rename from webclient/src/websocket/interfaces/ConnectOptions.ts rename to webclient/src/websocket/types/ConnectOptions.ts index a74402fb4..5929363b4 100644 --- a/webclient/src/websocket/interfaces/ConnectOptions.ts +++ b/webclient/src/websocket/types/ConnectOptions.ts @@ -10,12 +10,6 @@ export enum WebSocketConnectReason { TEST_CONNECTION, } -// ── Connect options ─────────────────────────────────────────────────────────── -// Each variant is the enriched input for one session flow: the network -// transport fields (host/port) + the subset of proto Command_* fields the UI -// actually produces (user-entered credentials, tokens, email, etc.) + a -// `reason` discriminator so the websocket layer can route. - interface ConnectTransport extends ConnectTarget { keepalive?: number; autojoinrooms?: boolean; diff --git a/webclient/src/websocket/types/SignalContexts.ts b/webclient/src/websocket/types/SignalContexts.ts new file mode 100644 index 000000000..e66b659a2 --- /dev/null +++ b/webclient/src/websocket/types/SignalContexts.ts @@ -0,0 +1,17 @@ +/** + * Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the + * activation dialog can resubmit against the same host/user without re-entering them. + */ +export interface PendingActivationContext { + host: string; + port: string; + userName: string; +} + +/** + * Payload for the LOGIN_SUCCESSFUL signal. Only carries what the UI needs to + * persist into the selected host record (hashedPassword for "remember me"). + */ +export interface LoginSuccessContext { + hashedPassword?: string; +} diff --git a/webclient/src/websocket/interfaces/StatusEnum.ts b/webclient/src/websocket/types/StatusEnum.ts similarity index 100% rename from webclient/src/websocket/interfaces/StatusEnum.ts rename to webclient/src/websocket/types/StatusEnum.ts diff --git a/webclient/src/websocket/interfaces/WebClientConfig.ts b/webclient/src/websocket/types/WebClientConfig.ts similarity index 100% rename from webclient/src/websocket/interfaces/WebClientConfig.ts rename to webclient/src/websocket/types/WebClientConfig.ts diff --git a/webclient/src/websocket/interfaces/WebClientRequest.ts b/webclient/src/websocket/types/WebClientRequest.ts similarity index 96% rename from webclient/src/websocket/interfaces/WebClientRequest.ts rename to webclient/src/websocket/types/WebClientRequest.ts index 16db5fd08..5caf83fda 100644 --- a/webclient/src/websocket/interfaces/WebClientRequest.ts +++ b/webclient/src/websocket/types/WebClientRequest.ts @@ -39,7 +39,6 @@ import type { import type { ConnectTarget } from './WebClientConfig'; import type { KeyOf } from './WebSocketConfig'; -// ── Auth request type map ──────────────────────────────────────────────────── // Keys = generated *Params type names composed with ConnectTarget. // @app/api overrides these with Enriched connect option types. diff --git a/webclient/src/websocket/interfaces/WebClientResponse.ts b/webclient/src/websocket/types/WebClientResponse.ts similarity index 100% rename from webclient/src/websocket/interfaces/WebClientResponse.ts rename to webclient/src/websocket/types/WebClientResponse.ts diff --git a/webclient/src/websocket/types/WebSocketConfig.ts b/webclient/src/websocket/types/WebSocketConfig.ts new file mode 100644 index 000000000..8037dcf2d --- /dev/null +++ b/webclient/src/websocket/types/WebSocketConfig.ts @@ -0,0 +1,28 @@ +import type { + GameEventContext, + Response_Login, + Response, + Event_RoomSay, + ResponseMap, + RoomEventMap, +} from '@app/generated'; + +// `KeyOf` resolves to `'Response_Login'`. +export type KeyOf = { [K in keyof Map]: Map[K] extends V ? K : never }[keyof Map]; + +export interface GameEventMeta { + gameId: number; + playerId: number; + context: GameEventContext | null; + secondsElapsed: number; + forcedByJudge: number; +} + +export interface WebSocketSessionResponseOverrides extends ResponseMap { + Response_Login: Response_Login & { hashedPassword?: string }; + Response: Response & { host: string; port: string; userName: string }; +} + +export interface WebSocketRoomResponseOverrides extends RoomEventMap { + Event_RoomSay: Event_RoomSay & { timeReceived: number }; +} diff --git a/webclient/src/websocket/types/index.ts b/webclient/src/websocket/types/index.ts new file mode 100644 index 000000000..3e3c127cc --- /dev/null +++ b/webclient/src/websocket/types/index.ts @@ -0,0 +1 @@ +export * as WebsocketTypes from './namespace'; diff --git a/webclient/src/websocket/interfaces/index.ts b/webclient/src/websocket/types/namespace.ts similarity index 87% rename from webclient/src/websocket/interfaces/index.ts rename to webclient/src/websocket/types/namespace.ts index 6c4459c91..84ae11770 100644 --- a/webclient/src/websocket/interfaces/index.ts +++ b/webclient/src/websocket/types/namespace.ts @@ -21,3 +21,5 @@ export type { export * from './WebClientConfig'; export * from './WebSocketConfig'; export * from './StatusEnum'; +export * from './ConnectOptions'; +export * from './SignalContexts'; diff --git a/webclient/src/websocket/utils/connectionState.ts b/webclient/src/websocket/utils/connectionState.ts index de4866462..02d0d7002 100644 --- a/webclient/src/websocket/utils/connectionState.ts +++ b/webclient/src/websocket/utils/connectionState.ts @@ -1,4 +1,4 @@ -import type { WebSocketConnectOptions } from '../interfaces/ConnectOptions'; +import type { WebSocketConnectOptions } from '../types/ConnectOptions'; let pendingOptions: WebSocketConnectOptions | null = null; diff --git a/webclient/src/websocket/utils/passwordHasher.ts b/webclient/src/websocket/utils/passwordHasher.ts index 6a230d9ea..e89302244 100644 --- a/webclient/src/websocket/utils/passwordHasher.ts +++ b/webclient/src/websocket/utils/passwordHasher.ts @@ -8,7 +8,6 @@ const SALT_LENGTH = 16; export const hashPassword = (salt: string, password: string): string => { let hashedPassword = salt + password; for (let i = 0; i < HASH_ROUNDS; i++) { - // WHY DO WE DO IT THIS WAY? hashedPassword = sha512(hashedPassword); } @@ -27,6 +26,6 @@ export const generateSalt = (): string => { } export const passwordSaltSupported = (serverOptions: number): number => { - // Intentional use of Bitwise operator b/c of how Servatrice Enums work + // @critical Servatrice ServerOptions is a bitmask. See .github/instructions/webclient.instructions.md#protocol-quirks. return serverOptions & Event_ServerIdentification_ServerOptions.SupportsPasswordHash; } diff --git a/webclient/tsconfig.json b/webclient/tsconfig.json index 1e851244e..b070278d5 100644 --- a/webclient/tsconfig.json +++ b/webclient/tsconfig.json @@ -35,6 +35,7 @@ "@app/store": ["./src/store/index.ts"], "@app/types": ["./src/types/index.ts"], "@app/websocket": ["./src/websocket/index.ts"], + "@app/websocket/types": ["./src/websocket/types/index.ts"], "@app/generated": ["./src/generated/index.ts"] } }, diff --git a/webclient/vitest.integration.config.ts b/webclient/vitest.integration.config.ts index 4308e2ccf..2ceabd4d8 100644 --- a/webclient/vitest.integration.config.ts +++ b/webclient/vitest.integration.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from 'vitest/config'; // Integration tests exercise the full inbound/outbound webclient pipeline // (ProtobufService → event handlers → persistence → Redux) with only the -// browser WebSocket constructor mocked. They live in `integration/` and run -// under their own config so they can use `isolate: true` without slowing down -// the unit suite (which relies on `isolate: false` for shared vi.mock state). +// browser WebSocket constructor mocked. They live in `integration/` with +// their own config so the include glob and longer testTimeout stay scoped +// to this suite; both suites run `isolate: true`. export default defineConfig({ plugins: [react()], resolve: {