mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-01 11:03:54 -07:00
Compare commits
3 commits
d04aa83258
...
ef6cea6f6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef6cea6f6c | ||
|
|
bd2382c94e | ||
|
|
dcd6dc00f4 |
214 changed files with 4526 additions and 1766 deletions
168
.github/instructions/webclient.instructions.md
vendored
Normal file
168
.github/instructions/webclient.instructions.md
vendored
Normal file
|
|
@ -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#<anchor>`, 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/<scope>/`, inbound handlers in `events/<scope>/`, 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.<scope>.<method>(…)`. 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<number, callback>` 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<R>`:
|
||||
|
||||
- `responseExt?: GenExtension<Response, R>` — 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/<slice>/*` — 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.<same-name>` 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.
|
||||
|
|
@ -1 +1,2 @@
|
|||
# Future template for server admin configuration
|
||||
NODE_OPTIONS=--max-old-space-size=8192
|
||||
|
|
@ -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<number, callback>` 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<R>`:
|
||||
|
||||
- `responseExt?: GenExtension<Response, R>` — 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/`.
|
||||
|
|
@ -1,73 +1,67 @@
|
|||
# Webatrice
|
||||
|
||||
The Cockatrice web client — a React/TypeScript SPA that connects to a Servatrice server over a WebSocket.
|
||||
|
||||
## Application Architecture
|
||||

|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||

|
||||
|
||||
## 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.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
## Prerequisites
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
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.<br />
|
||||
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.<br />
|
||||
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.<br />
|
||||
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<RouteComponentProps<???, ???, ???>>
|
||||
|
||||
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/)
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
70
webclient/architecture/README.md
Normal file
70
webclient/architecture/README.md
Normal file
|
|
@ -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
|
||||
|
||||

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

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

|
||||
|
||||
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/<name>.mmd -o architecture/<name>.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.
|
||||
123
webclient/architecture/detailed.mmd
Normal file
123
webclient/architecture/detailed.mmd
Normal file
|
|
@ -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["<b>Application</b>"]
|
||||
direction TB
|
||||
UI["<b>UI</b><br/><span style='font-size:15px'>containers · components<br/>forms · dialogs</span>"]
|
||||
Hooks["<b>hooks/</b><br/><span style='font-size:15px'>useWebClient · useAutoLogin<br/>useSettings · useKnownHosts</span>"]
|
||||
Store[("<b>@app/store</b><br/><span style='font-size:15px'>server · rooms · game<br/>actions · common</span>")]
|
||||
DTOs["<b>dexie DTOs</b><br/><span style='font-size:15px'>Card · Host · Set<br/>Setting · Token</span>"]
|
||||
IDB[("<b>IndexedDB</b>")]
|
||||
end
|
||||
|
||||
%% =========================================================
|
||||
%% Racetrack — three lanes: outbound / transport / inbound
|
||||
%% =========================================================
|
||||
subgraph RACE[" "]
|
||||
direction TB
|
||||
|
||||
subgraph TOP["<b>Outbound lane</b>"]
|
||||
direction LR
|
||||
Req["<b>src/api/request/</b><br/><span style='font-size:15px'>Authentication · Session · Rooms<br/>Game · Admin · Moderator</span>"]
|
||||
Cmds["<b>commands/</b><br/><span style='font-size:15px'>session · room · game<br/>admin · moderator</span>"]
|
||||
end
|
||||
|
||||
subgraph MID["<b>Transport</b>"]
|
||||
direction LR
|
||||
Provider["<b>WebClientProvider</b>"]
|
||||
WC[["<b>WebClient</b><br/><span style='font-size:15px'>singleton · request · response</span>"]]
|
||||
Svc["<b>services/</b><br/><span style='font-size:15px'>ProtobufService · WebSocketService<br/>KeepAliveService · command-options</span>"]
|
||||
end
|
||||
|
||||
subgraph BOT["<b>Inbound lane</b>"]
|
||||
direction LR
|
||||
Evts["<b>events/</b><br/><span style='font-size:15px'>session · room · game</span>"]
|
||||
Res["<b>src/api/response/</b><br/><span style='font-size:15px'>Session · Room · Game<br/>Admin · Moderator</span>"]
|
||||
end
|
||||
end
|
||||
|
||||
%% =========================================================
|
||||
%% Right bookend — Servatrice
|
||||
%% =========================================================
|
||||
Srv[("<b>Servatrice</b>")]
|
||||
|
||||
%% =========================================================
|
||||
%% Protocol satellite — cross-cutting types
|
||||
%% =========================================================
|
||||
subgraph PROTO["<b>Protocol (cross-cutting)</b>"]
|
||||
direction LR
|
||||
Types["<b>src/types/</b><br/><span style='font-size:15px'>Data · Enriched · App</span>"]
|
||||
Gen["<b>src/generated/proto/</b><br/><span style='font-size:15px'>@bufbuild/protobuf</span>"]
|
||||
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
|
||||
BIN
webclient/architecture/detailed.png
Normal file
BIN
webclient/architecture/detailed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
41
webclient/architecture/flow.mmd
Normal file
41
webclient/architecture/flow.mmd
Normal file
|
|
@ -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,<br/>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.<br/>resetCommands() on reconnect<br/>silently drops pending callbacks.
|
||||
BIN
webclient/architecture/flow.png
Normal file
BIN
webclient/architecture/flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
58
webclient/architecture/simple.mmd
Normal file
58
webclient/architecture/simple.mmd
Normal file
|
|
@ -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["<b>Application</b><br/><span style='font-size:13px'>containers · components · hooks</span>"]
|
||||
Rdx[("<b>Redux</b><br/><span style='font-size:12px'>in-memory state</span>")]
|
||||
end
|
||||
|
||||
subgraph SRV_COL[" "]
|
||||
direction TB
|
||||
Srv[("<b>Servatrice</b>")]
|
||||
IDB[("<b>IndexedDB</b><br/><span style='font-size:12px'>local persistent store</span>")]
|
||||
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
|
||||
BIN
webclient/architecture/simple.png
Normal file
BIN
webclient/architecture/simple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
|
|
@ -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: '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: 'websocket' }, 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: 'hooks' }, allow: types('api', 'services', 'types', 'websocket') },
|
||||
{ from: { type: 'images' }, allow: types('types') },
|
||||
{ from: { type: 'services' }, allow: types('api', 'store', 'types') },
|
||||
{ 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 = [
|
||||
|
|
|
|||
33
webclient/integration/src/app/helpers.tsx
Normal file
33
webclient/integration/src/app/helpers.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Shared render helper for the app integration suite.
|
||||
//
|
||||
// Two non-obvious choices:
|
||||
//
|
||||
// 1. WebClientContext is provided directly (not via production
|
||||
// <WebClientProvider>) because the shared integration setup.ts already
|
||||
// instantiates the WebClient singleton in beforeEach. The production
|
||||
// provider would `new WebClient(...)` a second time and throw.
|
||||
//
|
||||
// 2. We pass the REAL Redux store from @app/store — not renderWithProviders'
|
||||
// default test-local store. The real WebClient dispatches against the
|
||||
// real store (that's what createWebClientResponse wires to). Asserting
|
||||
// against a different in-memory store would silently miss every
|
||||
// dispatch. setup.ts's resetAll + afterEach clears the real store
|
||||
// between tests, so each test still starts from a clean slate.
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
import { renderWithProviders } from '../../../src/__test-utils__';
|
||||
import { store } from '@app/store';
|
||||
import { WebClientContext } from '@app/hooks';
|
||||
import { WebClient } from '@app/websocket';
|
||||
|
||||
export function renderAppScreen(ui: ReactElement) {
|
||||
return renderWithProviders(
|
||||
<WebClientContext.Provider value={WebClient.instance}>
|
||||
{ui}
|
||||
</WebClientContext.Provider>,
|
||||
{ store }
|
||||
);
|
||||
}
|
||||
|
||||
export { store };
|
||||
192
webclient/integration/src/app/login-autoconnect.spec.tsx
Normal file
192
webclient/integration/src/app/login-autoconnect.spec.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Full-stack autoconnect integration. Only outbound surfaces are mocked
|
||||
// (WebSocket via the shared setup, IndexedDB via fake-indexeddb in setup).
|
||||
// Everything in between — Dexie, DTOs, useSettings/useKnownHosts, useAutoLogin,
|
||||
// the Login container, WebClient, Redux — runs as shipped code.
|
||||
//
|
||||
// We assert auto-login via `connectionAttemptMade` on the real server slice,
|
||||
// not via the WebSocket mock's call count: KnownHosts fires a testConnection
|
||||
// on mount for the UX indicator, which also constructs sockets, so raw
|
||||
// socket counts are noisy. Only the login path dispatches CONNECTION_ATTEMPTED.
|
||||
|
||||
import { act, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Store loads notify React subscribers synchronously when the Dexie
|
||||
// promise resolves. Awaiting whenReady() directly would let those
|
||||
// notifications fire outside an act() scope, which trips React's
|
||||
// "update was not wrapped in act" warning. Wrapping here captures
|
||||
// both the store resolution and any resulting component re-renders.
|
||||
const flushStoresAndEffects = async (): Promise<void> => {
|
||||
await act(async () => {
|
||||
await settingsStore.whenReady();
|
||||
await knownHostsStore.whenReady();
|
||||
// Let dependent effects (host-sync, settings-sync) commit.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
};
|
||||
|
||||
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 { WebsocketTypes } from '@app/websocket/types';
|
||||
|
||||
import { resetDexie } from '../services/dexie/resetDexie';
|
||||
import { renderAppScreen, store } from './helpers';
|
||||
|
||||
// Mimics the production "user logged out / connection dropped" transition:
|
||||
// dispatching updateStatus(DISCONNECTED) is what the real reducer uses to
|
||||
// clear connectionAttemptMade (clearStore intentionally preserves status).
|
||||
const simulateLogout = () => {
|
||||
ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null);
|
||||
};
|
||||
|
||||
const seedAutoConnect = async () => {
|
||||
const setting = new SettingDTO(App.APP_USER);
|
||||
setting.autoConnect = true;
|
||||
await setting.save();
|
||||
|
||||
const id = (await HostDTO.add({
|
||||
name: 'Test Server',
|
||||
host: 'server.example',
|
||||
port: '4748',
|
||||
editable: false,
|
||||
})) as number;
|
||||
const host = (await HostDTO.get(id))!;
|
||||
host.remember = true;
|
||||
host.userName = 'alice';
|
||||
host.hashedPassword = 'stored-hash';
|
||||
host.lastSelected = true;
|
||||
await host.save();
|
||||
};
|
||||
|
||||
const attempted = (): boolean =>
|
||||
ServerSelectors.getConnectionAttemptMade(store.getState());
|
||||
|
||||
afterEach(async () => {
|
||||
// Absorb any state updates that lingered past the test body (stores
|
||||
// resolving after unmount, trailing effect commits) so they're wrapped
|
||||
// in act and don't trip React's warning during teardown.
|
||||
await flushStoresAndEffects();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// setup.ts's beforeEach installs fake timers and re-creates the WebClient
|
||||
// singleton. Dexie + React async need real timers; module caches persist
|
||||
// across tests and need explicit reset.
|
||||
vi.useRealTimers();
|
||||
await resetDexie();
|
||||
|
||||
// Reset the module-level caches that load from Dexie. Without this, a
|
||||
// test would read the PREVIOUS test's snapshot (the Dexie clear only
|
||||
// truncates storage, not the useSettings / useKnownHosts subscribers'
|
||||
// cached values).
|
||||
settingsStore.reset();
|
||||
knownHostsStore.reset();
|
||||
autoLoginGate.hasChecked = false;
|
||||
});
|
||||
|
||||
describe('autoconnect — cold start', () => {
|
||||
it('auto-logs in when Dexie has autoConnect=true + host with stored credentials', async () => {
|
||||
await seedAutoConnect();
|
||||
|
||||
renderAppScreen(<Login />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(attempted()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT attempt login when Dexie has no settings row', async () => {
|
||||
renderAppScreen(<Login />);
|
||||
|
||||
await flushStoresAndEffects();
|
||||
|
||||
expect(attempted()).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT attempt login when autoConnect=true but lastSelected host lacks credentials', async () => {
|
||||
const setting = new SettingDTO(App.APP_USER);
|
||||
setting.autoConnect = true;
|
||||
await setting.save();
|
||||
await HostDTO.add({
|
||||
name: 'Unremembered',
|
||||
host: 'server.example',
|
||||
port: '4748',
|
||||
editable: false,
|
||||
lastSelected: true,
|
||||
});
|
||||
|
||||
renderAppScreen(<Login />);
|
||||
|
||||
await flushStoresAndEffects();
|
||||
|
||||
expect(attempted()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoconnect — logout cycle (same session)', () => {
|
||||
it('does not auto-reconnect after first auto-login + logout within the same JS session', async () => {
|
||||
await seedAutoConnect();
|
||||
|
||||
const first = renderAppScreen(<Login />);
|
||||
await waitFor(() => {
|
||||
expect(attempted()).toBe(true);
|
||||
});
|
||||
|
||||
// Simulate "logged out and returned to /login": unmount, clear the
|
||||
// store's connectionAttemptMade flag (the app-level equivalent of
|
||||
// DISCONNECTED → status.connectionAttemptMade reset), remount.
|
||||
first.unmount();
|
||||
simulateLogout();
|
||||
|
||||
renderAppScreen(<Login />);
|
||||
await flushStoresAndEffects();
|
||||
|
||||
// The session gate must have kept useAutoLogin silent; the flag stays
|
||||
// false.
|
||||
expect(attempted()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not auto-connect when the user enabled autoConnect mid-session and then logged out', async () => {
|
||||
// First mount with autoConnect=false — gate latches without firing.
|
||||
const first = renderAppScreen(<Login />);
|
||||
await flushStoresAndEffects();
|
||||
expect(attempted()).toBe(false);
|
||||
first.unmount();
|
||||
|
||||
// Mid-session: user ticked the checkbox → Dexie flipped to autoConnect=true.
|
||||
await seedAutoConnect();
|
||||
|
||||
// Remount (post-logout). The gate MUST keep useAutoLogin silent.
|
||||
renderAppScreen(<Login />);
|
||||
await flushStoresAndEffects();
|
||||
|
||||
expect(attempted()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoconnect — refresh', () => {
|
||||
it('auto-connects again after resetting the session gate (page-refresh equivalent)', async () => {
|
||||
await seedAutoConnect();
|
||||
|
||||
const first = renderAppScreen(<Login />);
|
||||
await waitFor(() => {
|
||||
expect(attempted()).toBe(true);
|
||||
});
|
||||
first.unmount();
|
||||
|
||||
// Simulate a browser refresh: the session gate naturally resets on a
|
||||
// fresh JS context, and the real connection flag resets too.
|
||||
simulateLogout();
|
||||
autoLoginGate.hasChecked = false;
|
||||
|
||||
renderAppScreen(<Login />);
|
||||
await waitFor(() => {
|
||||
expect(attempted()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -9,19 +9,18 @@
|
|||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import '../../../src/polyfills';
|
||||
// fake-indexeddb polyfills globalThis.indexedDB. MUST be imported before any
|
||||
// module that opens a Dexie database (Dexie opens on first table access).
|
||||
// Harmless for the websocket suite, which doesn't touch Dexie.
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
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';
|
||||
|
||||
|
|
@ -105,7 +104,7 @@ function resetAll(): void {
|
|||
}
|
||||
|
||||
client.protobuf.resetCommands();
|
||||
client.status = StatusEnum.DISCONNECTED;
|
||||
client.status = WebsocketTypes.StatusEnum.DISCONNECTED;
|
||||
|
||||
ServerDispatch.clearStore();
|
||||
RoomsDispatch.clearStore();
|
||||
|
|
@ -124,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',
|
||||
|
|
@ -133,16 +132,16 @@ const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = {
|
|||
};
|
||||
|
||||
export function connectRaw(
|
||||
overrides: Partial<WebSocketConnectOptions> = {}
|
||||
overrides: Partial<WebsocketTypes.WebSocketConnectOptions> = {}
|
||||
): 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<WebSocketConnectOptions> = {}
|
||||
overrides: Partial<WebsocketTypes.WebSocketConnectOptions> = {}
|
||||
): void {
|
||||
connectRaw(overrides);
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
|
|
@ -156,7 +155,7 @@ export function connectAndHandshake(
|
|||
}
|
||||
|
||||
export function connectAndHandshakeWithSalt(
|
||||
overrides: Partial<WebSocketConnectOptions> = {}
|
||||
overrides: Partial<WebsocketTypes.WebSocketConnectOptions> = {}
|
||||
): void {
|
||||
connectRaw(overrides);
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
|
|
|
|||
91
webclient/integration/src/services/dexie/hosts.spec.ts
Normal file
91
webclient/integration/src/services/dexie/hosts.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Real round-trip tests for HostDTO through Dexie into fake-indexeddb.
|
||||
// Exercises the full static method surface (add, get, getAll, bulkAdd,
|
||||
// delete) plus instance save().
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HostDTO } from '@app/services';
|
||||
import type { App } from '@app/types';
|
||||
|
||||
import { resetDexie } from './resetDexie';
|
||||
|
||||
const makeRow = (overrides: Partial<App.Host> = {}): App.Host => ({
|
||||
name: 'Test',
|
||||
host: 'host.example',
|
||||
port: '4747',
|
||||
editable: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Shared setup.ts installs fake timers for the websocket suite's
|
||||
// KeepAliveService; Dexie / fake-indexeddb need real timers.
|
||||
vi.useRealTimers();
|
||||
await resetDexie();
|
||||
});
|
||||
|
||||
describe('HostDTO (real Dexie)', () => {
|
||||
it('getAll returns empty on a fresh store', async () => {
|
||||
const all = await HostDTO.getAll();
|
||||
expect(all).toEqual([]);
|
||||
});
|
||||
|
||||
it('add returns an auto-incremented id and makes the row retrievable by get(id)', async () => {
|
||||
const id = (await HostDTO.add(makeRow({ name: 'A' }))) as number;
|
||||
expect(typeof id).toBe('number');
|
||||
|
||||
const loaded = await HostDTO.get(id);
|
||||
expect(loaded).toBeDefined();
|
||||
expect(loaded!.name).toBe('A');
|
||||
expect(loaded!.id).toBe(id);
|
||||
expect(loaded).toBeInstanceOf(HostDTO);
|
||||
});
|
||||
|
||||
it('bulkAdd seeds multiple rows and they are all retrievable via getAll', async () => {
|
||||
await HostDTO.bulkAdd([
|
||||
makeRow({ name: 'A' }),
|
||||
makeRow({ name: 'B' }),
|
||||
makeRow({ name: 'C' }),
|
||||
]);
|
||||
|
||||
const all = await HostDTO.getAll();
|
||||
expect(all.map((h) => h.name).sort()).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('save() on a loaded instance upserts the same row (does not duplicate)', async () => {
|
||||
const id = (await HostDTO.add(makeRow({ name: 'A', remember: false }))) as number;
|
||||
|
||||
const loaded = await HostDTO.get(id);
|
||||
loaded!.remember = true;
|
||||
loaded!.userName = 'alice';
|
||||
loaded!.hashedPassword = 'stored';
|
||||
await loaded!.save();
|
||||
|
||||
const all = await HostDTO.getAll();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].remember).toBe(true);
|
||||
expect(all[0].userName).toBe('alice');
|
||||
expect(all[0].hashedPassword).toBe('stored');
|
||||
});
|
||||
|
||||
it('delete removes the row by id', async () => {
|
||||
const idA = (await HostDTO.add(makeRow({ name: 'A' }))) as number;
|
||||
await HostDTO.add(makeRow({ name: 'B' }));
|
||||
|
||||
await HostDTO.delete(idA as unknown as string);
|
||||
|
||||
const all = await HostDTO.getAll();
|
||||
expect(all.map((h) => h.name)).toEqual(['B']);
|
||||
});
|
||||
|
||||
it('lastSelected round-trips as a boolean column', async () => {
|
||||
const idA = (await HostDTO.add(makeRow({ name: 'A', lastSelected: true }))) as number;
|
||||
await HostDTO.add(makeRow({ name: 'B', lastSelected: false }));
|
||||
|
||||
const all = await HostDTO.getAll();
|
||||
const selected = all.find((h) => h.id === idA)!;
|
||||
expect(selected.lastSelected).toBe(true);
|
||||
const other = all.find((h) => h.name === 'B')!;
|
||||
expect(other.lastSelected).toBe(false);
|
||||
});
|
||||
});
|
||||
12
webclient/integration/src/services/dexie/resetDexie.ts
Normal file
12
webclient/integration/src/services/dexie/resetDexie.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Clears every table the services suite touches so each test starts from
|
||||
// empty storage. Dexie is a real singleton, the database a real (fake-
|
||||
// indexeddb) instance, so state leaks between tests otherwise.
|
||||
|
||||
import { dexieService } from '@app/services';
|
||||
|
||||
export async function resetDexie(): Promise<void> {
|
||||
await Promise.all([
|
||||
dexieService.settings.clear(),
|
||||
dexieService.hosts.clear(),
|
||||
]);
|
||||
}
|
||||
69
webclient/integration/src/services/dexie/settings.spec.ts
Normal file
69
webclient/integration/src/services/dexie/settings.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Real round-trip tests for SettingDTO through Dexie into fake-indexeddb.
|
||||
// Nothing is mocked past the IndexedDB boundary — the DTO class, the Dexie
|
||||
// schema, and the table's put/where/first pipeline all run as shipped code.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SettingDTO } from '@app/services';
|
||||
import { App } from '@app/types';
|
||||
|
||||
import { resetDexie } from './resetDexie';
|
||||
|
||||
beforeEach(async () => {
|
||||
// Shared setup.ts installs vi.useFakeTimers() for the websocket suite's
|
||||
// KeepAliveService needs. Dexie + fake-indexeddb rely on real microtasks
|
||||
// and will hang under fake timers, so flip back here.
|
||||
vi.useRealTimers();
|
||||
await resetDexie();
|
||||
});
|
||||
|
||||
describe('SettingDTO (real Dexie)', () => {
|
||||
it('returns undefined for a user with no row yet', async () => {
|
||||
const loaded = await SettingDTO.get(App.APP_USER);
|
||||
expect(loaded).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips a fresh setting via save()', async () => {
|
||||
const dto = new SettingDTO(App.APP_USER);
|
||||
dto.autoConnect = true;
|
||||
await dto.save();
|
||||
|
||||
const loaded = await SettingDTO.get(App.APP_USER);
|
||||
expect(loaded).toBeDefined();
|
||||
expect(loaded!.user).toBe(App.APP_USER);
|
||||
expect(loaded!.autoConnect).toBe(true);
|
||||
});
|
||||
|
||||
it('upserts on repeated save for the same user key', async () => {
|
||||
const first = new SettingDTO(App.APP_USER);
|
||||
first.autoConnect = false;
|
||||
await first.save();
|
||||
|
||||
const loaded = await SettingDTO.get(App.APP_USER);
|
||||
loaded!.autoConnect = true;
|
||||
await loaded!.save();
|
||||
|
||||
const reloaded = await SettingDTO.get(App.APP_USER);
|
||||
expect(reloaded!.autoConnect).toBe(true);
|
||||
});
|
||||
|
||||
it('matches user lookups case-insensitively (equalsIgnoreCase in DTO.get)', async () => {
|
||||
const dto = new SettingDTO(App.APP_USER);
|
||||
await dto.save();
|
||||
|
||||
const loaded = await SettingDTO.get(App.APP_USER.toUpperCase());
|
||||
expect(loaded).toBeDefined();
|
||||
expect(loaded!.user).toBe(App.APP_USER);
|
||||
});
|
||||
|
||||
it('preserves the SettingDTO class on load (mapToClass binding)', async () => {
|
||||
const dto = new SettingDTO(App.APP_USER);
|
||||
await dto.save();
|
||||
|
||||
const loaded = await SettingDTO.get(App.APP_USER);
|
||||
expect(loaded).toBeInstanceOf(SettingDTO);
|
||||
// The save() instance method must be present on the retrieved row so
|
||||
// call sites (useSettings.update) can round-trip without reinstantiation.
|
||||
expect(typeof loaded!.save).toBe('function');
|
||||
});
|
||||
});
|
||||
|
|
@ -8,14 +8,14 @@ import { Data } from '@app/types';
|
|||
import { store } from '@app/store';
|
||||
import { AdminCommands } from '@app/websocket';
|
||||
|
||||
import { connectAndLogin } from './helpers/setup';
|
||||
import { connectAndLogin } from '../helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastAdminCommand } from './helpers/command-capture';
|
||||
} from '../helpers/protobuf-builders';
|
||||
import { findLastAdminCommand } from '../helpers/command-capture';
|
||||
|
||||
describe('admin commands', () => {
|
||||
it('adjustMod modifies the user level bitflags on success', () => {
|
||||
|
|
@ -6,15 +6,15 @@ 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 { connectAndHandshake, connectAndHandshakeWithSalt } from '../helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
} from '../helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from '../helpers/command-capture';
|
||||
|
||||
function makeUser(name: string): Data.ServerInfo_User {
|
||||
return create(Data.ServerInfo_UserSchema, {
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,9 +7,9 @@ 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';
|
||||
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
|
||||
|
||||
import {
|
||||
getMockWebSocket,
|
||||
|
|
@ -17,18 +17,16 @@ import {
|
|||
openMockWebSocket,
|
||||
setPendingOptions,
|
||||
connectAndHandshake,
|
||||
} from './helpers/setup';
|
||||
import type { WebSocketConnectOptions } from '@app/websocket';
|
||||
import { WebSocketConnectReason } from '@app/websocket';
|
||||
} from '../helpers/setup';
|
||||
import {
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,13 +8,13 @@ import { Data } from '@app/types';
|
|||
import { store } from '@app/store';
|
||||
import { SessionCommands } from '@app/websocket';
|
||||
|
||||
import { connectAndLogin } from './helpers/setup';
|
||||
import { connectAndLogin } from '../helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
} from '../helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from '../helpers/command-capture';
|
||||
|
||||
describe('deck operations', () => {
|
||||
it('populates backendDecks from deckList response', () => {
|
||||
|
|
@ -8,7 +8,7 @@ import { Data } from '@app/types';
|
|||
import { store } from '@app/store';
|
||||
import { GameCommands, RoomCommands } from '@app/websocket';
|
||||
|
||||
import { connectAndHandshake, connectAndLogin } from './helpers/setup';
|
||||
import { connectAndHandshake, connectAndLogin } from '../helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
|
|
@ -16,8 +16,8 @@ import {
|
|||
buildRoomEventMessage,
|
||||
buildGameEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from './helpers/command-capture';
|
||||
} from '../helpers/protobuf-builders';
|
||||
import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from '../helpers/command-capture';
|
||||
|
||||
function joinGame(gameId: number): void {
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
|
|
@ -4,15 +4,15 @@ 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 { connectRaw, getMockWebSocket } from '../helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
} from '../helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from '../helpers/command-capture';
|
||||
|
||||
describe('keep-alive', () => {
|
||||
it('sends a Command_Ping on every keepalive interval tick', () => {
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,13 +9,13 @@ import { Data } from '@app/types';
|
|||
import { store } from '@app/store';
|
||||
import { ModeratorCommands } from '@app/websocket';
|
||||
|
||||
import { connectAndLogin } from './helpers/setup';
|
||||
import { connectAndLogin } from '../helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastModeratorCommand } from './helpers/command-capture';
|
||||
} from '../helpers/protobuf-builders';
|
||||
import { findLastModeratorCommand } from '../helpers/command-capture';
|
||||
|
||||
describe('moderator commands', () => {
|
||||
it('getBanHistory populates server.banHistory on success', () => {
|
||||
|
|
@ -6,20 +6,20 @@ 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 { connectAndHandshake } from '../helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
} from '../helpers/protobuf-builders';
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,15 +8,15 @@ import { Data } from '@app/types';
|
|||
import { store } from '@app/store';
|
||||
import { RoomCommands } from '@app/websocket';
|
||||
|
||||
import { connectAndHandshake } from './helpers/setup';
|
||||
import { connectAndHandshake } from '../helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
buildRoomEventMessage,
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from './helpers/command-capture';
|
||||
} from '../helpers/protobuf-builders';
|
||||
import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from '../helpers/command-capture';
|
||||
import { fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
|
||||
|
||||
function makeRoom(overrides: Partial<{
|
||||
|
|
@ -6,13 +6,13 @@ 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 { connectAndHandshake } from '../helpers/setup';
|
||||
import {
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
} from '../helpers/protobuf-builders';
|
||||
|
||||
describe('server events', () => {
|
||||
it('writes the server banner into server.info.message on Event_ServerMessage', () => {
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
@ -6,14 +6,14 @@ import { describe, expect, it } from 'vitest';
|
|||
import { Data } from '@app/types';
|
||||
import { store } from '@app/store';
|
||||
|
||||
import { connectAndLogin } from './helpers/setup';
|
||||
import { connectAndLogin } from '../helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
} from '../helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from '../helpers/command-capture';
|
||||
|
||||
function makeUser(name: string): Data.ServerInfo_User {
|
||||
return create(Data.ServerInfo_UserSchema, {
|
||||
11
webclient/package-lock.json
generated
11
webclient/package-lock.json
generated
|
|
@ -57,6 +57,7 @@
|
|||
"eslint": "^10.2.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^6.0.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"fs-extra": "^11.3.4",
|
||||
"globals": "^17.5.0",
|
||||
"husky": "^9.1.7",
|
||||
|
|
@ -3372,6 +3373,16 @@
|
|||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fake-indexeddb": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -71,6 +75,7 @@
|
|||
"eslint": "^10.2.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^6.0.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"fs-extra": "^11.3.4",
|
||||
"globals": "^17.5.0",
|
||||
"husky": "^9.1.7",
|
||||
|
|
|
|||
|
|
@ -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<Location>): () => void {
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
|
||||
|
|
@ -26,23 +20,3 @@ export function withMockLocation(overrides: Partial<Location>): () => 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<T>(registry: T[], entry: T): () => void {
|
||||
registry.push(entry);
|
||||
return () => {
|
||||
const index = registry.lastIndexOf(entry);
|
||||
if (index !== -1) {
|
||||
registry.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +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';
|
||||
|
|
|
|||
57
webclient/src/__test-utils__/mockWebClient.ts
Normal file
57
webclient/src/__test-utils__/mockWebClient.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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 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 {
|
||||
request: {
|
||||
authentication: {
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
activateAccount: vi.fn(),
|
||||
resetPasswordRequest: vi.fn(),
|
||||
resetPasswordChallenge: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
addToBuddyList: vi.fn(),
|
||||
removeFromBuddyList: vi.fn(),
|
||||
addToIgnoreList: vi.fn(),
|
||||
removeFromIgnoreList: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
accountEdit: vi.fn(),
|
||||
accountPassword: vi.fn(),
|
||||
accountImage: vi.fn(),
|
||||
listUsers: vi.fn(),
|
||||
},
|
||||
rooms: {
|
||||
joinRoom: vi.fn(),
|
||||
leaveRoom: vi.fn(),
|
||||
roomSay: vi.fn(),
|
||||
createGame: vi.fn(),
|
||||
},
|
||||
game: {
|
||||
joinGame: vi.fn(),
|
||||
leaveGame: vi.fn(),
|
||||
},
|
||||
admin: {
|
||||
adjustMod: vi.fn(),
|
||||
reloadConfig: vi.fn(),
|
||||
shutdownServer: vi.fn(),
|
||||
updateServerMessage: vi.fn(),
|
||||
},
|
||||
moderator: {
|
||||
viewLogHistory: vi.fn(),
|
||||
banFromServer: vi.fn(),
|
||||
warnUser: vi.fn(),
|
||||
warnHistory: vi.fn(),
|
||||
banHistory: vi.fn(),
|
||||
},
|
||||
},
|
||||
} as unknown as WebClient;
|
||||
}
|
||||
72
webclient/src/__test-utils__/renderWithProviders.tsx
Normal file
72
webclient/src/__test-utils__/renderWithProviders.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { ReactElement } from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { configureStore, EnhancedStore } from '@reduxjs/toolkit';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { gamesReducer } from '../store/game';
|
||||
import { roomsReducer } from '../store/rooms';
|
||||
import { serverReducer } from '../store/server';
|
||||
import { actionReducer } from '../store/actions';
|
||||
import { ToastProvider } from '../components/Toast/ToastContext';
|
||||
import type { RootState } from '../store/store';
|
||||
|
||||
// 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',
|
||||
resources: { 'en-US': { translation: {} } },
|
||||
fallbackLng: 'en-US',
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
function createTestStore(preloadedState?: Partial<RootState>) {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
games: gamesReducer,
|
||||
rooms: roomsReducer,
|
||||
server: serverReducer,
|
||||
action: actionReducer,
|
||||
},
|
||||
preloadedState: preloadedState as any,
|
||||
});
|
||||
}
|
||||
|
||||
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
preloadedState?: Partial<RootState>;
|
||||
store?: EnhancedStore;
|
||||
route?: string;
|
||||
}
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
{
|
||||
preloadedState,
|
||||
store = createTestStore(preloadedState),
|
||||
route = '/',
|
||||
...renderOptions
|
||||
}: ExtendedRenderOptions = {},
|
||||
) {
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<I18nextProvider i18n={testI18n}>
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
</ToastProvider>
|
||||
</I18nextProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
...render(ui, { wrapper: Wrapper, ...renderOptions }),
|
||||
};
|
||||
}
|
||||
124
webclient/src/__test-utils__/storeFixtures.ts
Normal file
124
webclient/src/__test-utils__/storeFixtures.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { App, Data } from '@app/types';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
import type { RootState } from '../store/store';
|
||||
|
||||
/**
|
||||
* Create a minimal ServerInfo_User object for testing.
|
||||
*/
|
||||
function makeUser(overrides: Partial<Data.ServerInfo_User> = {}): Data.ServerInfo_User {
|
||||
return {
|
||||
name: 'testUser',
|
||||
realName: '',
|
||||
country: 'us',
|
||||
userLevel: 0,
|
||||
avatarBmp: new Uint8Array(),
|
||||
accountageSecs: BigInt(0),
|
||||
$typeName: 'ServerInfo_User' as any,
|
||||
$unknown: undefined,
|
||||
gender: 0,
|
||||
...overrides,
|
||||
} as Data.ServerInfo_User;
|
||||
}
|
||||
|
||||
/**
|
||||
* A disconnected (default) store state. This is the state before any
|
||||
* connection to a server has been made.
|
||||
*/
|
||||
export const disconnectedState: Partial<RootState> = {
|
||||
server: {
|
||||
initialized: false,
|
||||
buddyList: {},
|
||||
ignoreList: {},
|
||||
status: {
|
||||
connectionAttemptMade: false,
|
||||
state: WebsocketTypes.StatusEnum.DISCONNECTED,
|
||||
description: null,
|
||||
},
|
||||
info: { message: null, name: null, version: null },
|
||||
logs: { room: [], game: [], chat: [] },
|
||||
user: null,
|
||||
users: {},
|
||||
sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC },
|
||||
messages: {},
|
||||
userInfo: {},
|
||||
notifications: [],
|
||||
serverShutdown: null,
|
||||
banUser: '',
|
||||
banHistory: {},
|
||||
warnHistory: {},
|
||||
warnListOptions: [],
|
||||
warnUser: '',
|
||||
adminNotes: {},
|
||||
replays: {},
|
||||
backendDecks: null,
|
||||
downloadedDeck: null,
|
||||
downloadedReplay: null,
|
||||
gamesOfUser: {},
|
||||
registrationError: null,
|
||||
},
|
||||
rooms: {
|
||||
rooms: {},
|
||||
joinedRoomIds: {},
|
||||
joinedGameIds: {},
|
||||
messages: {},
|
||||
sortGamesBy: { field: App.GameSortField.START_TIME, order: App.SortDirection.DESC },
|
||||
sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC },
|
||||
},
|
||||
games: { games: {} },
|
||||
action: { type: null, payload: null, meta: null, error: false, count: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A connected (logged-in) store state with a basic user and server info.
|
||||
*/
|
||||
export const connectedState: Partial<RootState> = {
|
||||
...disconnectedState,
|
||||
server: {
|
||||
...(disconnectedState.server as any),
|
||||
initialized: true,
|
||||
status: {
|
||||
connectionAttemptMade: true,
|
||||
state: WebsocketTypes.StatusEnum.LOGGED_IN,
|
||||
description: null,
|
||||
},
|
||||
info: {
|
||||
message: '<b>Welcome</b>',
|
||||
name: 'Test Server',
|
||||
version: '1.0.0',
|
||||
},
|
||||
user: makeUser(),
|
||||
users: {
|
||||
testUser: makeUser(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Connected state with rooms and a joined room containing games and users.
|
||||
*/
|
||||
export const connectedWithRoomsState: Partial<RootState> = {
|
||||
...connectedState,
|
||||
server: {
|
||||
...(connectedState.server as any),
|
||||
users: {
|
||||
testUser: makeUser(),
|
||||
otherUser: makeUser({ name: 'otherUser' }),
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
...(disconnectedState.rooms as any),
|
||||
rooms: {
|
||||
1: {
|
||||
info: { roomId: 1, name: 'Main Room', description: 'The main room', autoJoin: true, permissionLevel: 0 },
|
||||
gameList: [],
|
||||
userList: [makeUser(), makeUser({ name: 'otherUser' })],
|
||||
},
|
||||
},
|
||||
joinedRoomIds: { 1: true },
|
||||
messages: {
|
||||
1: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { makeUser };
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LoginConnectOptions, 'reason'>;
|
||||
ConnectTarget: Omit<TestConnectionOptions, 'reason'>;
|
||||
RegisterParams: Omit<RegisterConnectOptions, 'reason'>;
|
||||
ActivateParams: Omit<ActivateConnectOptions, 'reason'>;
|
||||
ForgotPasswordRequestParams: Omit<PasswordResetRequestConnectOptions, 'reason'>;
|
||||
ForgotPasswordChallengeParams: Omit<PasswordResetChallengeConnectOptions, 'reason'>;
|
||||
ForgotPasswordResetParams: Omit<PasswordResetConnectOptions, 'reason'>;
|
||||
interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap {
|
||||
LoginParams: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>;
|
||||
ConnectTarget: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>;
|
||||
RegisterParams: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>;
|
||||
ActivateParams: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>;
|
||||
ForgotPasswordRequestParams: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>;
|
||||
ForgotPasswordChallengeParams: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>;
|
||||
ForgotPasswordResetParams: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>;
|
||||
}
|
||||
|
||||
export class AuthenticationRequestImpl implements IAuthenticationRequest<AppAuthRequestOverrides> {
|
||||
login(options: Omit<LoginConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.LOGIN });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest<AppAuthRequestOverrides> {
|
||||
login(options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>): 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<TestConnectionOptions, 'reason'>): void {
|
||||
testConnection(options: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>): void {
|
||||
WebClient.instance.testConnect({ host: options.host, port: options.port });
|
||||
}
|
||||
|
||||
register(options: Omit<RegisterConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.REGISTER });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
register(options: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>): 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<ActivateConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
activateAccount(options: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>): 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<PasswordResetRequestConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
resetPasswordRequest(options: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>): 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<PasswordResetChallengeConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
resetPasswordChallenge(options: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>): 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<PasswordResetConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET });
|
||||
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
|
||||
resetPassword(options: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>): void {
|
||||
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET });
|
||||
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
|
||||
WebClient.instance.connect({ host: options.host, port: options.port });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -35,8 +35,8 @@ export class GameResponseImpl implements IGameResponse {
|
|||
GameDispatch.kicked(gameId);
|
||||
}
|
||||
|
||||
gameSay(gameId: number, playerId: number, message: string): void {
|
||||
GameDispatch.gameSay(gameId, playerId, message);
|
||||
gameSay(gameId: number, playerId: number, message: string, timeReceived: number): void {
|
||||
GameDispatch.gameSay(gameId, playerId, message, timeReceived);
|
||||
}
|
||||
|
||||
cardMoved(gameId: number, playerId: number, data: Data.Event_MoveCard): void {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WebSocketRoomResponseOverrides> {
|
||||
export class RoomResponseImpl implements WebsocketTypes.IRoomResponse<WebsocketTypes.WebSocketRoomResponseOverrides> {
|
||||
clearStore(): void {
|
||||
RoomsDispatch.clearStore();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WebSocketSessionResponseOverrides> {
|
||||
export class SessionResponseImpl implements WebsocketTypes.ISessionResponse<WebsocketTypes.WebSocketSessionResponseOverrides> {
|
||||
initialized(): void {
|
||||
ServerDispatch.initialized();
|
||||
}
|
||||
|
|
@ -67,8 +66,8 @@ export class SessionResponseImpl implements ISessionResponse<WebSocketSessionRes
|
|||
ServerDispatch.updateInfo(name, version);
|
||||
}
|
||||
|
||||
updateStatus(state: StatusEnum, description: string): void {
|
||||
if (state === StatusEnum.DISCONNECTED) {
|
||||
updateStatus(state: WebsocketTypes.StatusEnum, description: string): void {
|
||||
if (state === WebsocketTypes.StatusEnum.DISCONNECTED) {
|
||||
GameDispatch.clearStore();
|
||||
RoomsDispatch.clearStore();
|
||||
ServerDispatch.clearStore();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IWebClientResponse } from '@app/websocket';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
|
||||
import { SessionResponseImpl } from './SessionResponseImpl';
|
||||
import { RoomResponseImpl } from './RoomResponseImpl';
|
||||
|
|
@ -8,7 +8,7 @@ import { ModeratorResponseImpl } from './ModeratorResponseImpl';
|
|||
|
||||
export { SessionResponseImpl, RoomResponseImpl, GameResponseImpl, AdminResponseImpl, ModeratorResponseImpl };
|
||||
|
||||
export function createWebClientResponse(): IWebClientResponse {
|
||||
export function createWebClientResponse(): WebsocketTypes.IWebClientResponse {
|
||||
return {
|
||||
session: new SessionResponseImpl(),
|
||||
room: new RoomResponseImpl(),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ interface CardProps {
|
|||
card: CardDTO;
|
||||
}
|
||||
|
||||
// @TODO: add missing fields (loyalty, hand, etc)
|
||||
// @TODO add missing fields (loyalty, hand, etc)
|
||||
|
||||
const CardDetails = ({ card }: CardProps) => {
|
||||
return (
|
||||
|
|
|
|||
33
webclient/src/components/Guard/AuthGuard.spec.tsx
Normal file
33
webclient/src/components/Guard/AuthGuard.spec.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders, connectedState, disconnectedState } from '../../__test-utils__';
|
||||
import AuthGuard from './AuthGuard';
|
||||
|
||||
vi.mock('@app/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@app/hooks')>();
|
||||
return { ...actual, useWebClient: vi.fn(() => ({})) };
|
||||
});
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
it('redirects to LOGIN when disconnected', () => {
|
||||
renderWithProviders(<AuthGuard />, {
|
||||
preloadedState: disconnectedState,
|
||||
route: '/server',
|
||||
});
|
||||
|
||||
// Navigate triggers a route change — AuthGuard itself renders no text.
|
||||
// We verify it doesn't render any meaningful content.
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing visible when connected', () => {
|
||||
const { container } = renderWithProviders(<AuthGuard />, {
|
||||
preloadedState: connectedState,
|
||||
route: '/server',
|
||||
});
|
||||
|
||||
// AuthGuard renders an empty fragment when connected.
|
||||
// The only DOM is from provider wrappers (e.g. ToastProvider's container div).
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ const AuthGuard = () => {
|
|||
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||
return !isConnected
|
||||
? <Navigate to={App.RouteEnum.LOGIN} />
|
||||
: <div></div>;
|
||||
: <></>;
|
||||
};
|
||||
|
||||
export default AuthGuard;
|
||||
|
|
|
|||
38
webclient/src/components/Guard/ModGuard.spec.tsx
Normal file
38
webclient/src/components/Guard/ModGuard.spec.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { renderWithProviders, connectedState, makeUser } from '../../__test-utils__';
|
||||
import { Data } from '@app/types';
|
||||
import ModGuard from './ModGuard';
|
||||
|
||||
vi.mock('@app/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@app/hooks')>();
|
||||
return { ...actual, useWebClient: vi.fn(() => ({})) };
|
||||
});
|
||||
|
||||
describe('ModGuard', () => {
|
||||
it('redirects when user is not a moderator', () => {
|
||||
const { container } = renderWithProviders(<ModGuard />, {
|
||||
preloadedState: connectedState,
|
||||
route: '/logs',
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing visible when user is a moderator', () => {
|
||||
const modUser = makeUser({
|
||||
userLevel: Data.ServerInfo_User_UserLevelFlag.IsModerator,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<ModGuard />, {
|
||||
preloadedState: {
|
||||
...connectedState,
|
||||
server: {
|
||||
...(connectedState.server as any),
|
||||
user: modUser,
|
||||
},
|
||||
},
|
||||
route: '/logs',
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
30
webclient/src/components/InputField/InputField.spec.tsx
Normal file
30
webclient/src/components/InputField/InputField.spec.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import InputField from './InputField';
|
||||
|
||||
describe('InputField', () => {
|
||||
const defaultProps = {
|
||||
input: { name: 'test', value: '', onChange: vi.fn(), onBlur: vi.fn(), onFocus: vi.fn() },
|
||||
meta: { touched: false, error: null, warning: null },
|
||||
label: 'Test Field',
|
||||
};
|
||||
|
||||
it('renders a text field with label', () => {
|
||||
render(<InputField {...defaultProps} />);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when touched and has error', () => {
|
||||
render(<InputField {...defaultProps} meta={{ touched: true, error: 'Required', warning: null }} />);
|
||||
expect(screen.getByText('Required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning when touched and has warning', () => {
|
||||
render(<InputField {...defaultProps} meta={{ touched: true, error: null, warning: 'Weak password' }} />);
|
||||
expect(screen.getByText('Weak password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show validation messages when not touched', () => {
|
||||
render(<InputField {...defaultProps} meta={{ touched: false, error: 'Required', warning: null }} />);
|
||||
expect(screen.queryByText('Required')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select, MenuItem } from '@mui/material';
|
||||
|
|
@ -13,10 +13,9 @@ import AddIcon from '@mui/icons-material/Add';
|
|||
import EditRoundedIcon from '@mui/icons-material/Edit';
|
||||
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
||||
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||
import { KnownHostDialog } from '@app/dialogs';
|
||||
import { useReduxEffect } from '@app/hooks';
|
||||
import { HostDTO } from '@app/services';
|
||||
import { getHostPort, HostDTO } from '@app/services';
|
||||
import { ServerTypes } from '@app/store';
|
||||
import { App } from '@app/types';
|
||||
import Toast from '../Toast/Toast';
|
||||
|
|
@ -32,244 +31,215 @@ enum TestConnection {
|
|||
const PREFIX = 'KnownHosts';
|
||||
|
||||
const classes = {
|
||||
root: `${PREFIX}-root`
|
||||
root: `${PREFIX}-root`,
|
||||
};
|
||||
|
||||
const Root = styled('div')(({ theme }) => ({
|
||||
[`&.${classes.root}`]: {
|
||||
'& .KnownHosts-error': {
|
||||
color: theme.palette.error.main
|
||||
color: theme.palette.error.main,
|
||||
},
|
||||
|
||||
'& .KnownHosts-warning': {
|
||||
color: theme.palette.warning.main
|
||||
color: theme.palette.warning.main,
|
||||
},
|
||||
|
||||
'& .KnownHosts-item': {
|
||||
[`& .${TestConnection.TESTING}`]: {
|
||||
color: theme.palette.warning.main
|
||||
color: theme.palette.warning.main,
|
||||
},
|
||||
[`& .${TestConnection.FAILED}`]: {
|
||||
color: theme.palette.error.main
|
||||
color: theme.palette.error.main,
|
||||
},
|
||||
[`& .${TestConnection.SUCCESS}`]: {
|
||||
color: theme.palette.success.main
|
||||
}
|
||||
}
|
||||
}
|
||||
color: theme.palette.success.main,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const KnownHosts = (props) => {
|
||||
const { input: { onChange }, meta, disabled } = props;
|
||||
const KnownHosts = (props: any) => {
|
||||
const { input, meta, disabled } = props;
|
||||
const onChange: (value: HostDTO) => void = input.onChange;
|
||||
const { touched, error, warning } = meta;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const webClient = useWebClient();
|
||||
const knownHosts = useKnownHosts();
|
||||
|
||||
const [hostsState, setHostsState] = useState({
|
||||
hosts: [],
|
||||
selectedHost: {} as any,
|
||||
});
|
||||
|
||||
const [dialogState, setDialogState] = useState({
|
||||
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
|
||||
open: false,
|
||||
edit: null,
|
||||
});
|
||||
|
||||
const [testingConnection, setTestingConnection] = useState<TestConnection>(null);
|
||||
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
|
||||
|
||||
const [showCreateToast, setShowCreateToast] = useState(false);
|
||||
const [showDeleteToast, setShowDeleteToast] = useState(false);
|
||||
const [showEditToast, setShowEditToast] = useState(false);
|
||||
|
||||
const loadKnownHosts = useCallback(async () => {
|
||||
const hosts = await HostDTO.getAll();
|
||||
const selectedHost =
|
||||
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
|
||||
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
|
||||
|
||||
if (!hosts?.length) {
|
||||
// @TODO: find a better pattern to seeding default data in indexedDB
|
||||
await HostDTO.bulkAdd(App.DefaultHosts);
|
||||
loadKnownHosts();
|
||||
} else {
|
||||
const selectedHost = hosts.find(({ lastSelected }) => lastSelected) || hosts[0];
|
||||
setHostsState(s => ({ ...s, hosts, selectedHost }));
|
||||
}
|
||||
}, []);
|
||||
const testConnection = (host: HostDTO) => {
|
||||
setTestingConnection(TestConnection.TESTING);
|
||||
webClient.request.authentication.testConnection({ ...getHostPort(host) });
|
||||
};
|
||||
|
||||
// Mirror the store's selectedHost into the form field. Also kick off a
|
||||
// connection test so the user sees the green/red indicator on mount.
|
||||
useEffect(() => {
|
||||
loadKnownHosts();
|
||||
}, [loadKnownHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
const { selectedHost } = hostsState;
|
||||
|
||||
if (selectedHost?.id) {
|
||||
updateLastSelectedHost(selectedHost.id).then(() => {
|
||||
onChange(selectedHost);
|
||||
});
|
||||
if (!selectedHost) {
|
||||
return;
|
||||
}
|
||||
}, [hostsState, onChange]);
|
||||
onChange(selectedHost);
|
||||
testConnection(selectedHost);
|
||||
}, [selectedHost]);
|
||||
|
||||
useReduxEffect(() => {
|
||||
setTestingConnection(TestConnection.SUCCESS);
|
||||
}, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []);
|
||||
useReduxEffect(
|
||||
() => {
|
||||
setTestingConnection(TestConnection.SUCCESS);
|
||||
},
|
||||
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
|
||||
[]
|
||||
);
|
||||
|
||||
useReduxEffect(() => {
|
||||
setTestingConnection(TestConnection.FAILED);
|
||||
}, ServerTypes.TEST_CONNECTION_FAILED, []);
|
||||
useReduxEffect(
|
||||
() => {
|
||||
setTestingConnection(TestConnection.FAILED);
|
||||
},
|
||||
ServerTypes.TEST_CONNECTION_FAILED,
|
||||
[]
|
||||
);
|
||||
|
||||
const selectHost = (selectedHost) => {
|
||||
setHostsState(s => ({ ...s, selectedHost }));
|
||||
const onPick = async (host: HostDTO) => {
|
||||
if (knownHosts.status !== LoadingState.READY) {
|
||||
return;
|
||||
}
|
||||
onChange(host);
|
||||
await knownHosts.select(host.id!);
|
||||
testConnection(host);
|
||||
};
|
||||
|
||||
const openAddKnownHostDialog = () => {
|
||||
setDialogState(s => ({ ...s, open: true, edit: null }));
|
||||
setDialogState((s) => ({ ...s, open: true, edit: null }));
|
||||
};
|
||||
|
||||
const openEditKnownHostDialog = (host: HostDTO) => {
|
||||
setDialogState(s => ({ ...s, open: true, edit: host }));
|
||||
setDialogState((s) => ({ ...s, open: true, edit: host }));
|
||||
};
|
||||
|
||||
const closeKnownHostDialog = () => {
|
||||
setDialogState(s => ({ ...s, open: false }));
|
||||
}
|
||||
|
||||
const handleDialogRemove = async ({ id }) => {
|
||||
setHostsState(s => ({
|
||||
...s,
|
||||
hosts: s.hosts.filter(host => host.id !== id),
|
||||
selectedHost: s.selectedHost.id === id ? s.hosts[0] : s.selectedHost,
|
||||
}));
|
||||
|
||||
closeKnownHostDialog();
|
||||
HostDTO.delete(id);
|
||||
setShowDeleteToast(true)
|
||||
setDialogState((s) => ({ ...s, open: false }));
|
||||
};
|
||||
|
||||
const handleDialogSubmit = async ({ id, name, host, port }) => {
|
||||
if (id) {
|
||||
const hostDTO = await HostDTO.get(id);
|
||||
hostDTO.name = name;
|
||||
hostDTO.host = host;
|
||||
hostDTO.port = port;
|
||||
await hostDTO.save();
|
||||
const handleDialogRemove = async ({ id }: { id: number }) => {
|
||||
if (knownHosts.status !== LoadingState.READY) {
|
||||
return;
|
||||
}
|
||||
await knownHosts.remove(id);
|
||||
closeKnownHostDialog();
|
||||
setShowDeleteToast(true);
|
||||
};
|
||||
|
||||
setHostsState(s => ({
|
||||
...s,
|
||||
hosts: s.hosts.map(h => h.id === id ? hostDTO : h),
|
||||
selectedHost: hostDTO
|
||||
}));
|
||||
setShowEditToast(true)
|
||||
const handleDialogSubmit = async ({
|
||||
id,
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
}: {
|
||||
id?: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: string;
|
||||
}) => {
|
||||
if (knownHosts.status !== LoadingState.READY) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await knownHosts.update(id, { name, host, port });
|
||||
setShowEditToast(true);
|
||||
} else {
|
||||
const newHost: App.Host = { name, host, port, editable: true };
|
||||
newHost.id = await HostDTO.add(newHost) as number;
|
||||
|
||||
setHostsState(s => ({
|
||||
...s,
|
||||
hosts: [...s.hosts, newHost],
|
||||
selectedHost: newHost,
|
||||
}));
|
||||
setShowCreateToast(true)
|
||||
await knownHosts.add(newHost);
|
||||
setShowCreateToast(true);
|
||||
}
|
||||
|
||||
closeKnownHostDialog();
|
||||
};
|
||||
|
||||
const updateLastSelectedHost = (hostId): Promise<any[]> => {
|
||||
testConnection();
|
||||
|
||||
return HostDTO.getAll().then(hosts =>
|
||||
hosts.map(async host => {
|
||||
if (host.id === hostId) {
|
||||
host.lastSelected = true;
|
||||
return await host.save();
|
||||
}
|
||||
|
||||
if (host.lastSelected) {
|
||||
host.lastSelected = false;
|
||||
return await host.save();
|
||||
}
|
||||
|
||||
return host;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const testConnection = () => {
|
||||
setTestingConnection(TestConnection.TESTING);
|
||||
|
||||
const options = { ...App.getHostPort(hostsState.selectedHost) };
|
||||
webClient.request.authentication.testConnection(options);
|
||||
}
|
||||
|
||||
return (
|
||||
<Root className={'KnownHosts ' + classes.root}>
|
||||
<FormControl className='KnownHosts-form' size='small' variant='outlined'>
|
||||
{ touched && (
|
||||
<div className='KnownHosts-validation'>
|
||||
{
|
||||
(error &&
|
||||
<div className='KnownHosts-error'>
|
||||
{error}
|
||||
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
|
||||
</div>
|
||||
) ||
|
||||
|
||||
(warning && <div className='KnownHosts-warning'>{warning}</div>)
|
||||
}
|
||||
<FormControl className="KnownHosts-form" size="small" variant="outlined">
|
||||
{touched && (
|
||||
<div className="KnownHosts-validation">
|
||||
{(error && (
|
||||
<div className="KnownHosts-error">
|
||||
{error}
|
||||
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
|
||||
</div>
|
||||
)) ||
|
||||
(warning && <div className="KnownHosts-warning">{warning}</div>)}
|
||||
</div>
|
||||
) }
|
||||
)}
|
||||
|
||||
<InputLabel id='KnownHosts-select'>{ t('KnownHosts.label') }</InputLabel>
|
||||
<InputLabel id="KnownHosts-select">{t('KnownHosts.label')}</InputLabel>
|
||||
<Select
|
||||
id='KnownHosts-select'
|
||||
labelId='KnownHosts-label'
|
||||
label='Host'
|
||||
margin='dense'
|
||||
name='host'
|
||||
value={hostsState.selectedHost}
|
||||
id="KnownHosts-select"
|
||||
labelId="KnownHosts-label"
|
||||
label="Host"
|
||||
margin="dense"
|
||||
name="host"
|
||||
value={selectedHost ?? ''}
|
||||
fullWidth={true}
|
||||
onChange={e => selectHost(e.target.value)}
|
||||
onChange={(e) => onPick(e.target.value as unknown as HostDTO)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Button value={hostsState.selectedHost} onClick={openAddKnownHostDialog}>
|
||||
<span>{ t('KnownHosts.add') }</span>
|
||||
<AddIcon fontSize='small' color='primary' />
|
||||
<Button value={selectedHost} onClick={openAddKnownHostDialog}>
|
||||
<span>{t('KnownHosts.add')}</span>
|
||||
<AddIcon fontSize="small" color="primary" />
|
||||
</Button>
|
||||
|
||||
{
|
||||
hostsState.hosts.map((host, index) => {
|
||||
const hostPort = App.getHostPort(hostsState.hosts[index]);
|
||||
{hosts.map((host, index) => {
|
||||
const hostPort = getHostPort(host);
|
||||
|
||||
return (
|
||||
<MenuItem value={host} key={index}>
|
||||
<div className='KnownHosts-item'>
|
||||
<div className='KnownHosts-item__wrapper'>
|
||||
<div className={'KnownHosts-item__status ' + testingConnection}>
|
||||
{
|
||||
testingConnection === TestConnection.FAILED
|
||||
? <PortableWifiOffIcon fontSize="small" />
|
||||
: <WifiTetheringIcon fontSize="small" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className='KnownHosts-item__label'>
|
||||
<Check />
|
||||
<span>{host.name} ({ hostPort.host }:{hostPort.port})</span>
|
||||
</div>
|
||||
return (
|
||||
<MenuItem value={host as any} key={host.id ?? index}>
|
||||
<div className="KnownHosts-item">
|
||||
<div className="KnownHosts-item__wrapper">
|
||||
<div className={'KnownHosts-item__status ' + testingConnection}>
|
||||
{testingConnection === TestConnection.FAILED ? (
|
||||
<PortableWifiOffIcon fontSize="small" />
|
||||
) : (
|
||||
<WifiTetheringIcon fontSize="small" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ host.editable && (
|
||||
<IconButton className='KnownHosts-item__edit' size='small' color='primary' onClick={() => {
|
||||
openEditKnownHostDialog(hostsState.hosts[index]);
|
||||
}}>
|
||||
<EditRoundedIcon fontSize='small' />
|
||||
</IconButton>
|
||||
) }
|
||||
<div className="KnownHosts-item__label">
|
||||
<Check />
|
||||
<span>
|
||||
{host.name} ({hostPort.host}:{hostPort.port})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{host.editable && (
|
||||
<IconButton
|
||||
className="KnownHosts-item__edit"
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
openEditKnownHostDialog(host);
|
||||
}}
|
||||
>
|
||||
<EditRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
|
|
@ -280,9 +250,15 @@ const KnownHosts = (props) => {
|
|||
onSubmit={handleDialogSubmit}
|
||||
handleClose={closeKnownHostDialog}
|
||||
/>
|
||||
<Toast open={showCreateToast} onClose={() => setShowCreateToast(false)}>{ t('KnownHosts.toast', { mode: 'created' }) }</Toast>
|
||||
<Toast open={showDeleteToast} onClose={() => setShowDeleteToast(false)}>{ t('KnownHosts.toast', { mode: 'deleted' }) }</Toast>
|
||||
<Toast open={showEditToast} onClose={() => setShowEditToast(false)}>{ t('KnownHosts.toast', { mode: 'edited' }) }</Toast>
|
||||
<Toast open={showCreateToast} onClose={() => setShowCreateToast(false)}>
|
||||
{t('KnownHosts.toast', { mode: 'created' })}
|
||||
</Toast>
|
||||
<Toast open={showDeleteToast} onClose={() => setShowDeleteToast(false)}>
|
||||
{t('KnownHosts.toast', { mode: 'deleted' })}
|
||||
</Toast>
|
||||
<Toast open={showEditToast} onClose={() => setShowEditToast(false)}>
|
||||
{t('KnownHosts.toast', { mode: 'edited' })}
|
||||
</Toast>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import './LanguageDropdown.css';
|
|||
|
||||
const LanguageDropdown = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [language, setLanguage] = useState(i18n.resolvedLanguage);
|
||||
// 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(() => {
|
||||
if (language !== i18n.resolvedLanguage) {
|
||||
|
|
|
|||
19
webclient/src/components/Message/Message.spec.tsx
Normal file
19
webclient/src/components/Message/Message.spec.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../__test-utils__';
|
||||
import Message from './Message';
|
||||
|
||||
describe('Message', () => {
|
||||
it('renders a plain message', () => {
|
||||
const message = { message: 'Hello world' };
|
||||
renderWithProviders(<Message message={message} />);
|
||||
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the message container', () => {
|
||||
const message = { message: 'Test message' };
|
||||
const { container } = renderWithProviders(<Message message={message} />);
|
||||
|
||||
expect(container.querySelector('.message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import Grid from '@mui/material/Grid';
|
|||
|
||||
import './ThreePaneLayout.css';
|
||||
|
||||
// @DEPRECATED
|
||||
// This component sucks balls, dont use it. It will be removed sooner than later.
|
||||
/** @deprecated Scheduled for replacement with a more flexible layout component. */
|
||||
function ThreePaneLayout(props: ThreePaneLayoutProps) {
|
||||
return (
|
||||
<div className="three-pane-layout">
|
||||
|
|
|
|||
46
webclient/src/components/UserDisplay/UserDisplay.spec.tsx
Normal file
46
webclient/src/components/UserDisplay/UserDisplay.spec.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders, connectedState, makeUser, createMockWebClient } from '../../__test-utils__';
|
||||
import UserDisplay from './UserDisplay';
|
||||
|
||||
const mockWebClient = createMockWebClient();
|
||||
|
||||
vi.mock('@app/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@app/hooks')>();
|
||||
return { ...actual, useWebClient: vi.fn(() => mockWebClient) };
|
||||
});
|
||||
|
||||
vi.mock('@app/images', () => ({
|
||||
Images: { Countries: { us: 'us.png', de: 'de.png' } },
|
||||
}));
|
||||
|
||||
describe('UserDisplay', () => {
|
||||
it('renders user name', () => {
|
||||
const user = makeUser({ name: 'TestPlayer', country: 'us' });
|
||||
renderWithProviders(<UserDisplay user={user} />, {
|
||||
preloadedState: connectedState,
|
||||
});
|
||||
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders country flag image', () => {
|
||||
const user = makeUser({ name: 'TestPlayer', country: 'us' });
|
||||
renderWithProviders(<UserDisplay user={user} />, {
|
||||
preloadedState: connectedState,
|
||||
});
|
||||
|
||||
const img = screen.getByAltText('us');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', 'us.png');
|
||||
});
|
||||
|
||||
it('renders link to player profile', () => {
|
||||
const user = makeUser({ name: 'TestPlayer', country: 'us' });
|
||||
renderWithProviders(<UserDisplay user={user} />, {
|
||||
preloadedState: connectedState,
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /TestPlayer/ });
|
||||
expect(link).toHaveAttribute('href', '/player/TestPlayer');
|
||||
});
|
||||
});
|
||||
21
webclient/src/components/VirtualList/VirtualList.spec.tsx
Normal file
21
webclient/src/components/VirtualList/VirtualList.spec.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import VirtualList from './VirtualList';
|
||||
|
||||
describe('VirtualList', () => {
|
||||
it('renders without crashing with empty items', () => {
|
||||
const { container } = render(<VirtualList items={[]} />);
|
||||
expect(container.querySelector('.virtual-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts className as a string', () => {
|
||||
const { container } = render(<VirtualList items={[]} className="custom-class" />);
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies empty string as default className (not object)', () => {
|
||||
const { container } = render(<VirtualList items={[]} />);
|
||||
const list = container.querySelector('.virtual-list__list');
|
||||
// className should not contain "[object Object]"
|
||||
expect(list?.className).not.toContain('[object Object]');
|
||||
});
|
||||
});
|
||||
|
|
@ -15,7 +15,7 @@ const Row = ({ index, style, items }: RowComponentProps<RowData>) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const VirtualList = ({ items, className = {}, size = 30 }) => (
|
||||
const VirtualList = ({ items, className = '', size = 30 }) => (
|
||||
<div className="virtual-list">
|
||||
<List<RowData>
|
||||
className={`virtual-list__list ${className}`}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
// eslint-disable-next-line
|
||||
import React, { Component } from "react";
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
|
|
@ -25,7 +24,21 @@ const Account = () => {
|
|||
const user = useAppSelector(state => ServerSelectors.getUser(state));
|
||||
const webClient = useWebClient();
|
||||
const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {};
|
||||
let url = URL.createObjectURL(new Blob([avatarBmp as BlobPart], { 'type': 'image/png' }));
|
||||
|
||||
const avatarUrl = useMemo(() => {
|
||||
if (!avatarBmp) {
|
||||
return '';
|
||||
}
|
||||
return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' }));
|
||||
}, [avatarBmp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (avatarUrl) {
|
||||
URL.revokeObjectURL(avatarUrl);
|
||||
}
|
||||
};
|
||||
}, [avatarUrl]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -42,41 +55,41 @@ const Account = () => {
|
|||
<AuthGuard />
|
||||
<div className="account-column">
|
||||
<Paper className="account-list">
|
||||
<div className="">
|
||||
<div>
|
||||
Buddies Online: ?/{buddyList.length}
|
||||
</div>
|
||||
<VirtualList
|
||||
items={ buddyList.map(user => (
|
||||
<ListItemButton dense>
|
||||
<ListItemButton key={user.name} dense>
|
||||
<UserDisplay user={user} />
|
||||
</ListItemButton>
|
||||
)) }
|
||||
/>
|
||||
<div className="" style={{ borderTop: '1px solid' }}>
|
||||
<div style={{ borderTop: '1px solid' }}>
|
||||
<AddToBuddies onSubmit={handleAddToBuddies} />
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
<div className="account-column">
|
||||
<Paper className="account-list overflow-scroll">
|
||||
<div className="">
|
||||
<div>
|
||||
Ignored Users Online: ?/{ignoreList.length}
|
||||
</div>
|
||||
<VirtualList
|
||||
items={ ignoreList.map(user => (
|
||||
<ListItemButton dense>
|
||||
<ListItemButton key={user.name} dense>
|
||||
<UserDisplay user={user} />
|
||||
</ListItemButton>
|
||||
)) }
|
||||
/>
|
||||
<div className="" style={{ borderTop: '1px solid' }}>
|
||||
<div style={{ borderTop: '1px solid' }}>
|
||||
<AddToIgnore onSubmit={handleAddToIgnore} />
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
<div className="account-column overflow-scroll">
|
||||
<Paper className="account-details" style={{ margin: '0 0 5px 0' }}>
|
||||
<img src={url} alt={name} />
|
||||
{ avatarUrl && <img src={avatarUrl} alt={name} /> }
|
||||
<p><strong>{name}</strong></p>
|
||||
<p>Location: ({country?.toUpperCase()})</p>
|
||||
<p>User Level: {userLevel}</p>
|
||||
|
|
@ -95,7 +108,7 @@ const Account = () => {
|
|||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={() => request.authentication.disconnect()}
|
||||
onClick={() => webClient.request.authentication.disconnect()}
|
||||
>
|
||||
{ t('Common.disconnect') }
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { ToastProvider } from '@app/components'
|
|||
|
||||
function AppShell() {
|
||||
useEffect(() => {
|
||||
// @TODO (1)
|
||||
window.onbeforeunload = () => true;
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
177
webclient/src/containers/Login/Login.spec.tsx
Normal file
177
webclient/src/containers/Login/Login.spec.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { act, waitFor } from '@testing-library/react';
|
||||
|
||||
import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__';
|
||||
|
||||
const flushEffects = async (): Promise<void> => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
};
|
||||
import { makeSettings, makeSettingsHook } from '../../hooks/__mocks__/useSettings';
|
||||
import { makeHost, makeKnownHostsHook } from '../../hooks/__mocks__/useKnownHosts';
|
||||
import { autoLoginGate } from '../../hooks/useAutoLogin';
|
||||
import { LoadingState } from '@app/hooks';
|
||||
import Login from './Login';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockWebClient: undefined as any,
|
||||
getSettings: vi.fn(),
|
||||
getKnownHosts: vi.fn(),
|
||||
useSettings: vi.fn(),
|
||||
useKnownHosts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useSettings', () => ({
|
||||
useSettings: hoisted.useSettings,
|
||||
getSettings: hoisted.getSettings,
|
||||
}));
|
||||
vi.mock('../../hooks/useKnownHosts', () => ({
|
||||
useKnownHosts: hoisted.useKnownHosts,
|
||||
getKnownHosts: hoisted.getKnownHosts,
|
||||
}));
|
||||
vi.mock('../../hooks/useWebClient', () => ({
|
||||
useWebClient: () => hoisted.mockWebClient,
|
||||
WebClientProvider: ({ children }: { children: any }) => children,
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
const client = createMockWebClient();
|
||||
(client.request.authentication as any).testConnection = vi.fn();
|
||||
hoisted.mockWebClient = client;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
autoLoginGate.hasChecked = false;
|
||||
|
||||
hoisted.getSettings.mockReset();
|
||||
hoisted.getKnownHosts.mockReset();
|
||||
hoisted.useSettings.mockReset();
|
||||
hoisted.useKnownHosts.mockReset();
|
||||
|
||||
const defaultHost = makeHost({
|
||||
id: 1,
|
||||
remember: true,
|
||||
userName: 'alice',
|
||||
hashedPassword: 'stored-hash',
|
||||
lastSelected: true,
|
||||
});
|
||||
|
||||
hoisted.useSettings.mockReturnValue(
|
||||
makeSettingsHook({
|
||||
status: LoadingState.READY,
|
||||
value: makeSettings({ autoConnect: false }),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
);
|
||||
hoisted.useKnownHosts.mockReturnValue(
|
||||
makeKnownHostsHook({
|
||||
status: LoadingState.READY,
|
||||
value: { hosts: [defaultHost], selectedHost: defaultHost },
|
||||
})
|
||||
);
|
||||
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: false }));
|
||||
hoisted.getKnownHosts.mockResolvedValue({
|
||||
hosts: [defaultHost],
|
||||
selectedHost: defaultHost,
|
||||
});
|
||||
});
|
||||
|
||||
const armAutoConnect = () => {
|
||||
const host = makeHost({
|
||||
id: 1,
|
||||
remember: true,
|
||||
userName: 'alice',
|
||||
hashedPassword: 'stored-hash',
|
||||
lastSelected: true,
|
||||
});
|
||||
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true }));
|
||||
hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host });
|
||||
};
|
||||
|
||||
describe('Login — auto-connect cold start', () => {
|
||||
test('fires login when settings + host say go', async () => {
|
||||
armAutoConnect();
|
||||
|
||||
renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(hoisted.mockWebClient.request.authentication.login.mock.calls[0][0]).toMatchObject({
|
||||
userName: 'alice',
|
||||
hashedPassword: 'stored-hash',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not fire when autoConnect setting is off', async () => {
|
||||
renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
|
||||
await flushEffects();
|
||||
expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not fire when selected host has no stored credentials', async () => {
|
||||
const host = makeHost({
|
||||
id: 1,
|
||||
remember: false,
|
||||
userName: undefined,
|
||||
hashedPassword: undefined,
|
||||
lastSelected: true,
|
||||
});
|
||||
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true }));
|
||||
hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host });
|
||||
|
||||
renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
|
||||
await flushEffects();
|
||||
expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login — logout cycle (same JS session)', () => {
|
||||
test('does not re-auto-connect after first auto-login + logout', async () => {
|
||||
armAutoConnect();
|
||||
|
||||
const first = renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
await waitFor(() => {
|
||||
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
first.unmount();
|
||||
renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not auto-connect when user enabled autoConnect mid-session and then logged out', async () => {
|
||||
const first = renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
await flushEffects();
|
||||
expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled();
|
||||
|
||||
first.unmount();
|
||||
|
||||
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true }));
|
||||
|
||||
renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
await flushEffects();
|
||||
expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login — refresh cycle', () => {
|
||||
test('a fresh session gate re-fires auto-login when conditions still hold', async () => {
|
||||
armAutoConnect();
|
||||
|
||||
renderWithProviders(<Login />, { preloadedState: disconnectedState });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
|
@ -9,10 +9,11 @@ import Typography from '@mui/material/Typography';
|
|||
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs';
|
||||
import { LanguageDropdown } from '@app/components';
|
||||
import { LoginForm } from '@app/forms';
|
||||
import { useReduxEffect, useFireOnce, useWebClient } from '@app/hooks';
|
||||
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||
import { Images } from '@app/images';
|
||||
import { HostDTO, serverProps } from '@app/services';
|
||||
import { App, Enriched } from '@app/types';
|
||||
import { getHostPort, serverProps } from '@app/services';
|
||||
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';
|
||||
|
|
@ -66,12 +67,14 @@ const Root = styled('div')(({ theme }) => ({
|
|||
const Login = () => {
|
||||
const description = useAppSelector(s => ServerSelectors.getDescription(s));
|
||||
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||
const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade);
|
||||
const webClient = useWebClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [pendingActivationOptions, setPendingActivationOptions] = useState<Enriched.PendingActivationContext | null>(null);
|
||||
const [pendingActivationOptions, setPendingActivationOptions] = useState<WebsocketTypes.PendingActivationContext | null>(null);
|
||||
|
||||
const [rememberLogin, setRememberLogin] = useState(null);
|
||||
const rememberLoginRef = useRef<any>(null);
|
||||
const knownHosts = useKnownHosts();
|
||||
const [dialogState, setDialogState] = useState({
|
||||
passwordResetRequestDialog: false,
|
||||
resetPasswordDialog: false,
|
||||
|
|
@ -113,19 +116,21 @@ const Login = () => {
|
|||
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
|
||||
|
||||
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
|
||||
updateHost(hashedPassword, rememberLogin);
|
||||
}, ServerTypes.LOGIN_SUCCESSFUL, [rememberLogin]);
|
||||
if (rememberLoginRef.current) {
|
||||
updateHost(hashedPassword, rememberLoginRef.current);
|
||||
}
|
||||
}, ServerTypes.LOGIN_SUCCESSFUL, []);
|
||||
|
||||
const showDescription = () => {
|
||||
return !isConnected && description?.length;
|
||||
};
|
||||
|
||||
const onSubmitLogin = useCallback((loginForm) => {
|
||||
setRememberLogin(loginForm);
|
||||
rememberLoginRef.current = loginForm;
|
||||
const { userName, password, selectedHost, remember } = loginForm;
|
||||
|
||||
const options: Omit<Enriched.LoginConnectOptions, 'reason'> = {
|
||||
...App.getHostPort(selectedHost),
|
||||
const options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'> = {
|
||||
...getHostPort(selectedHost),
|
||||
userName,
|
||||
password,
|
||||
};
|
||||
|
|
@ -139,22 +144,22 @@ const Login = () => {
|
|||
|
||||
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
|
||||
|
||||
const updateHost = (hashedPassword, { selectedHost, remember, userName }) => {
|
||||
HostDTO.get(selectedHost.id).then(hostDTO => {
|
||||
hostDTO.remember = remember;
|
||||
hostDTO.userName = remember ? userName : null;
|
||||
hostDTO.hashedPassword = remember ? hashedPassword : null;
|
||||
useAutoLogin(handleLogin, connectionAttemptMade);
|
||||
|
||||
hostDTO.save();
|
||||
const updateHost = (hashedPassword, { selectedHost, remember, userName }) => {
|
||||
knownHosts.update(selectedHost.id, {
|
||||
remember,
|
||||
userName: remember ? userName : null,
|
||||
hashedPassword: remember ? hashedPassword : null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegistrationDialogSubmit = (registerForm) => {
|
||||
setRememberLogin(registerForm);
|
||||
rememberLoginRef.current = registerForm;
|
||||
const { userName, password, email, country, realName, selectedHost } = registerForm;
|
||||
|
||||
webClient.request.authentication.register({
|
||||
...App.getHostPort(selectedHost),
|
||||
...getHostPort(selectedHost),
|
||||
userName,
|
||||
password,
|
||||
email,
|
||||
|
|
@ -177,7 +182,7 @@ const Login = () => {
|
|||
|
||||
const handleRequestPasswordResetDialogSubmit = (form) => {
|
||||
const { userName, email, selectedHost } = form;
|
||||
const { host, port } = App.getHostPort(selectedHost);
|
||||
const { host, port } = getHostPort(selectedHost);
|
||||
|
||||
if (email) {
|
||||
webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port });
|
||||
|
|
@ -188,7 +193,7 @@ const Login = () => {
|
|||
};
|
||||
|
||||
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => {
|
||||
const { host, port } = App.getHostPort(selectedHost);
|
||||
const { host, port } = getHostPort(selectedHost);
|
||||
|
||||
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
// eslint-disable-next-line
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { AuthGuard, ModGuard } from '@app/components';
|
||||
import { SearchForm } from '@app/forms';
|
||||
|
|
|
|||
35
webclient/src/containers/Room/OpenGames.spec.tsx
Normal file
35
webclient/src/containers/Room/OpenGames.spec.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders, connectedWithRoomsState } from '../../__test-utils__';
|
||||
import OpenGames from './OpenGames';
|
||||
|
||||
vi.mock('@app/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@app/hooks')>();
|
||||
return { ...actual, useWebClient: vi.fn(() => ({})) };
|
||||
});
|
||||
|
||||
describe('OpenGames', () => {
|
||||
const roomWithGames = {
|
||||
info: { roomId: 1, name: 'Main Room' },
|
||||
};
|
||||
|
||||
it('renders the games table headers', () => {
|
||||
renderWithProviders(<OpenGames room={roomWithGames} />, {
|
||||
preloadedState: connectedWithRoomsState,
|
||||
});
|
||||
|
||||
expect(screen.getByText('Age')).toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Creator')).toBeInTheDocument();
|
||||
expect(screen.getByText('Type')).toBeInTheDocument();
|
||||
expect(screen.getByText('Players')).toBeInTheDocument();
|
||||
expect(screen.getByText('Spectators')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders without crashing when no games exist', () => {
|
||||
const { container } = renderWithProviders(<OpenGames room={roomWithGames} />, {
|
||||
preloadedState: connectedWithRoomsState,
|
||||
});
|
||||
|
||||
expect(container.querySelector('.games')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
// eslint-disable-next-line
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
|
|
@ -42,17 +41,17 @@ const OpenGames = ({ room }: OpenGamesProps) => {
|
|||
RoomsDispatch.sortGames(roomId, field, order);
|
||||
};
|
||||
|
||||
const isUnavailableGame = ({ started, maxPlayers, playerCount }) =>
|
||||
const isAvailable = ({ started, maxPlayers, playerCount }) =>
|
||||
!started && playerCount < maxPlayers;
|
||||
|
||||
const isPasswordProtectedGame = ({ withPassword }) => !withPassword;
|
||||
const isOpen = ({ withPassword }) => !withPassword;
|
||||
|
||||
const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies;
|
||||
const isPublic = ({ onlyBuddies }) => !onlyBuddies;
|
||||
|
||||
const games = sortedGames.filter(game => (
|
||||
isUnavailableGame(game.info) &&
|
||||
isPasswordProtectedGame(game.info) &&
|
||||
isBuddiesOnlyGame(game.info)
|
||||
isAvailable(game.info) &&
|
||||
isOpen(game.info) &&
|
||||
isPublic(game.info)
|
||||
));
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -25,7 +24,7 @@ const Room = () => {
|
|||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
|
||||
const roomId = parseInt(params.roomId, 0);
|
||||
const roomId = parseInt(params.roomId, 10);
|
||||
const room = rooms[roomId];
|
||||
const roomMessages = messages[roomId];
|
||||
const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId));
|
||||
|
|
@ -37,6 +36,10 @@ const Room = () => {
|
|||
}
|
||||
}, [joined]);
|
||||
|
||||
if (!room) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleRoomSay = ({ message }) => {
|
||||
if (message) {
|
||||
webClient.request.rooms.roomSay(roomId, message);
|
||||
|
|
@ -78,7 +81,7 @@ const Room = () => {
|
|||
<VirtualList
|
||||
className="room-view__side-list"
|
||||
items={ users.map(user => (
|
||||
<ListItemButton className="room-view__side-list__item">
|
||||
<ListItemButton key={user.name} className="room-view__side-list__item">
|
||||
<UserDisplay user={user} />
|
||||
</ListItemButton>
|
||||
)) }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
// eslint-disable-next-line
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
import { generatePath, useNavigate } from 'react-router-dom';
|
||||
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
|
|
@ -40,6 +39,7 @@ const Server = () => {
|
|||
|
||||
bottom={(
|
||||
<Paper className="serverMessage overflow-scroll">
|
||||
{/* message is sanitized via DOMPurify in websocket/events/session/serverMessage.ts */}
|
||||
<div className="serverMessage__content" dangerouslySetInnerHTML={{ __html: message }} />
|
||||
</Paper>
|
||||
)}
|
||||
|
|
@ -51,7 +51,7 @@ const Server = () => {
|
|||
</div>
|
||||
<VirtualList
|
||||
items={ users.map(user => (
|
||||
<ListItemButton dense>
|
||||
<ListItemButton key={user.name} dense>
|
||||
<UserDisplay user={user} />
|
||||
</ListItemButton>
|
||||
)) }
|
||||
|
|
|
|||
97
webclient/src/forms/LoginForm/LoginForm.spec.tsx
Normal file
97
webclient/src/forms/LoginForm/LoginForm.spec.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__';
|
||||
import { makeSettingsHook, makeSettings } from '../../hooks/__mocks__/useSettings';
|
||||
import { makeKnownHostsHook, makeHost } from '../../hooks/__mocks__/useKnownHosts';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockWebClient: undefined as any,
|
||||
mockUseSettings: vi.fn(),
|
||||
mockUseKnownHosts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@app/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@app/hooks')>();
|
||||
return {
|
||||
...actual,
|
||||
useWebClient: () => hoisted.mockWebClient,
|
||||
useSettings: hoisted.mockUseSettings,
|
||||
useKnownHosts: hoisted.mockUseKnownHosts,
|
||||
};
|
||||
});
|
||||
|
||||
import LoginForm from './LoginForm';
|
||||
import { LoadingState } from '@app/hooks';
|
||||
|
||||
beforeAll(() => {
|
||||
const client = createMockWebClient();
|
||||
(client.request.authentication as any).testConnection = vi.fn();
|
||||
hoisted.mockWebClient = client;
|
||||
});
|
||||
|
||||
describe('LoginForm — regression: settings.autoConnect is not clobbered by host state', () => {
|
||||
test('selecting a host with remember=false does NOT call settings.update', () => {
|
||||
const update = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
hoisted.mockUseSettings.mockReturnValue(
|
||||
makeSettingsHook({
|
||||
status: LoadingState.READY,
|
||||
value: makeSettings({ autoConnect: true }),
|
||||
update,
|
||||
})
|
||||
);
|
||||
|
||||
const host = makeHost({
|
||||
id: 1,
|
||||
remember: false,
|
||||
userName: undefined,
|
||||
hashedPassword: undefined,
|
||||
lastSelected: true,
|
||||
});
|
||||
hoisted.mockUseKnownHosts.mockReturnValue(
|
||||
makeKnownHostsHook({
|
||||
status: LoadingState.READY,
|
||||
value: { hosts: [host], selectedHost: host },
|
||||
})
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<LoginForm onSubmit={vi.fn()} disableSubmitButton={false} onResetPassword={vi.fn()} />,
|
||||
{ preloadedState: disconnectedState }
|
||||
);
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auto-login never fires from the form; that is now the container concern', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const update = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
hoisted.mockUseSettings.mockReturnValue(
|
||||
makeSettingsHook({
|
||||
status: LoadingState.READY,
|
||||
value: makeSettings({ autoConnect: true }),
|
||||
update,
|
||||
})
|
||||
);
|
||||
|
||||
const host = makeHost({
|
||||
id: 1,
|
||||
remember: true,
|
||||
userName: 'joe',
|
||||
hashedPassword: 'abc',
|
||||
lastSelected: true,
|
||||
});
|
||||
hoisted.mockUseKnownHosts.mockReturnValue(
|
||||
makeKnownHostsHook({
|
||||
status: LoadingState.READY,
|
||||
value: { hosts: [host], selectedHost: host },
|
||||
})
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<LoginForm onSubmit={onSubmit} disableSubmitButton={false} onResetPassword={vi.fn()} />,
|
||||
{ preloadedState: disconnectedState }
|
||||
);
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +1,181 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Form, Field } from 'react-final-form';
|
||||
import { Form, Field, useFormState, FormApi } from 'react-final-form';
|
||||
import { OnChange } from 'react-final-form-listeners';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
|
||||
import { CheckboxField, InputField, KnownHosts } from '@app/components';
|
||||
import { useAutoConnect } from '@app/hooks';
|
||||
import { HostDTO, SettingDTO } from '@app/services';
|
||||
import { App } from '@app/types';
|
||||
import { useAppSelector, ServerSelectors } from '@app/store';
|
||||
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
||||
|
||||
import './LoginForm.css';
|
||||
|
||||
const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginFormProps) => {
|
||||
interface LoginFormProps {
|
||||
onSubmit: (values: any) => void;
|
||||
disableSubmitButton: boolean;
|
||||
onResetPassword: () => void;
|
||||
}
|
||||
|
||||
interface LoginFormBodyProps extends LoginFormProps {
|
||||
form: FormApi;
|
||||
handleSubmit: (event?: React.SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
const LoginFormBody = ({
|
||||
form,
|
||||
handleSubmit,
|
||||
disableSubmitButton,
|
||||
onResetPassword,
|
||||
}: LoginFormBodyProps) => {
|
||||
const { t } = useTranslation();
|
||||
const PASSWORD_LABEL = t('Common.label.password');
|
||||
const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`;
|
||||
|
||||
const [host, setHost] = useState(null);
|
||||
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
|
||||
const [autoConnect, setAutoConnect] = useAutoConnect();
|
||||
const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade);
|
||||
const settings = useSettings();
|
||||
const hosts = useKnownHosts();
|
||||
const { values } = useFormState();
|
||||
|
||||
const validate = values => {
|
||||
const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined;
|
||||
|
||||
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
|
||||
const [storedHashInvalidated, setStoredHashInvalidated] = useState(false);
|
||||
|
||||
const canUseStoredPassword = (remember: boolean, password: string | undefined) =>
|
||||
Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated);
|
||||
|
||||
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
|
||||
|
||||
// @critical Host-sync must not touch autoConnect — app-level setting, not per-host.
|
||||
useEffect(() => {
|
||||
if (!selectedHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.change('userName', selectedHost.userName);
|
||||
form.change('password', '');
|
||||
form.change('remember', Boolean(selectedHost.remember));
|
||||
|
||||
setStoredHashInvalidated(false);
|
||||
togglePasswordLabel(
|
||||
Boolean(selectedHost.remember && selectedHost.hashedPassword)
|
||||
);
|
||||
}, [selectedHost, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.status !== LoadingState.READY) {
|
||||
return;
|
||||
}
|
||||
form.change('autoConnect', settings.value?.autoConnect);
|
||||
}, [settings, form]);
|
||||
|
||||
const onUserNameChange = (userName: string | undefined) => {
|
||||
const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase();
|
||||
if (canUseStoredPassword(values.remember, values.password) && fieldChanged) {
|
||||
setStoredHashInvalidated(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onRememberChange = (checked: boolean) => {
|
||||
// @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit.
|
||||
if (!checked && values.autoConnect) {
|
||||
form.change('autoConnect', false);
|
||||
}
|
||||
|
||||
togglePasswordLabel(canUseStoredPassword(checked, values.password));
|
||||
};
|
||||
|
||||
// @critical Only persist-path for autoConnect; wired to native onChange, not <OnChange>,
|
||||
// to avoid leaking form.change() writes into Dexie.
|
||||
const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => {
|
||||
fieldOnChange(checked);
|
||||
|
||||
if (settings.status === LoadingState.READY) {
|
||||
void settings.update({ autoConnect: checked });
|
||||
}
|
||||
|
||||
if (checked && !values.remember) {
|
||||
form.change('remember', true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="loginForm" onSubmit={handleSubmit}>
|
||||
<div className="loginForm-items">
|
||||
<div className="loginForm-item">
|
||||
<Field
|
||||
label={t('Common.label.username')}
|
||||
name="userName"
|
||||
component={InputField}
|
||||
autoComplete="username"
|
||||
/>
|
||||
<OnChange name="userName">{onUserNameChange}</OnChange>
|
||||
</div>
|
||||
<div className="loginForm-item">
|
||||
<Field
|
||||
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
|
||||
onFocus={() => setUseStoredPasswordLabel(false)}
|
||||
onBlur={() =>
|
||||
togglePasswordLabel(canUseStoredPassword(values.remember, values.password))
|
||||
}
|
||||
name="password"
|
||||
type="password"
|
||||
component={InputField}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="loginForm-actions">
|
||||
<Field
|
||||
label={t('LoginForm.label.savePassword')}
|
||||
name="remember"
|
||||
component={CheckboxField}
|
||||
/>
|
||||
<OnChange name="remember">{onRememberChange}</OnChange>
|
||||
|
||||
<Button color="primary" onClick={onResetPassword}>
|
||||
{t('LoginForm.label.forgot')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="loginForm-item">
|
||||
<Field name="selectedHost" component={KnownHosts} />
|
||||
</div>
|
||||
<div className="loginForm-actions">
|
||||
<Field name="autoConnect" type="checkbox">
|
||||
{({ input }) => (
|
||||
<FormControlLabel
|
||||
className="checkbox-field"
|
||||
label={t('LoginForm.label.autoConnect')}
|
||||
control={
|
||||
<Checkbox
|
||||
className="checkbox-field__box"
|
||||
checked={!!input.value}
|
||||
onChange={(_e, checked) => onUserToggleAutoConnect(checked, input.onChange)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="loginForm-submit rounded tall"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={disableSubmitButton}
|
||||
>
|
||||
{t('LoginForm.label.login')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const LoginForm = (props: LoginFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
|
||||
if (!values.userName) {
|
||||
|
|
@ -34,139 +186,20 @@ const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginForm
|
|||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
const useStoredPassword = (remember, password) => remember && host?.hashedPassword && !password;
|
||||
const togglePasswordLabel = (useStoredLabel) => {
|
||||
setUseStoredPasswordLabel(useStoredLabel);
|
||||
};
|
||||
|
||||
const handleOnSubmit = ({ userName, ...values }) => {
|
||||
const handleOnSubmit = ({ userName, ...values }: any) => {
|
||||
userName = userName?.trim();
|
||||
console.log(userName, values);
|
||||
|
||||
onSubmit({ userName, ...values });
|
||||
}
|
||||
props.onSubmit({ userName, ...values });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleOnSubmit} validate={validate}>
|
||||
{({ handleSubmit, form }) => {
|
||||
const { values } = form.getState();
|
||||
|
||||
useEffect(() => {
|
||||
SettingDTO.get(App.APP_USER).then((userSetting: SettingDTO) => {
|
||||
if (userSetting?.autoConnect && !connectionAttemptMade) {
|
||||
HostDTO.getAll().then(hosts => {
|
||||
let lastSelectedHost = hosts.find(({ lastSelected }) => lastSelected);
|
||||
|
||||
if (lastSelectedHost?.remember && lastSelectedHost?.hashedPassword) {
|
||||
togglePasswordLabel(true);
|
||||
|
||||
form.change('selectedHost', lastSelectedHost);
|
||||
form.change('userName', lastSelectedHost.userName);
|
||||
form.change('remember', true);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.change('userName', host.userName);
|
||||
form.change('password', '');
|
||||
|
||||
onRememberChange(host.remember);
|
||||
onAutoConnectChange(host.remember && autoConnect);
|
||||
togglePasswordLabel(useStoredPassword(host.remember, values.password));
|
||||
}, [host]);
|
||||
|
||||
const onUserNameChange = (userName) => {
|
||||
const fieldChanged = host?.userName?.toLowerCase() !== values.userName?.toLowerCase();
|
||||
if (useStoredPassword(values.remember, values.password) && fieldChanged) {
|
||||
setHost(({ hashedPassword: _hashedPassword, ...s }) => ({ ...s, userName }));
|
||||
}
|
||||
}
|
||||
|
||||
const onRememberChange = (checked) => {
|
||||
form.change('remember', checked);
|
||||
|
||||
if (!checked && values.autoConnect) {
|
||||
onAutoConnectChange(false);
|
||||
}
|
||||
|
||||
togglePasswordLabel(useStoredPassword(checked, values.password));
|
||||
}
|
||||
|
||||
const onAutoConnectChange = (checked) => {
|
||||
setAutoConnect(checked);
|
||||
|
||||
form.change('autoConnect', checked);
|
||||
|
||||
if (checked && !values.remember) {
|
||||
form.change('remember', true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className='loginForm' onSubmit={handleSubmit}>
|
||||
<div className='loginForm-items'>
|
||||
<div className='loginForm-item'>
|
||||
<Field label={t('Common.label.username')} name='userName' component={InputField} autoComplete='username' />
|
||||
<OnChange name="userName">{onUserNameChange}</OnChange>
|
||||
</div>
|
||||
<div className='loginForm-item'>
|
||||
<Field
|
||||
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
|
||||
onFocus={() => setUseStoredPasswordLabel(false)}
|
||||
onBlur={() => togglePasswordLabel(useStoredPassword(values.remember, values.password))}
|
||||
name='password'
|
||||
type='password'
|
||||
component={InputField}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</div>
|
||||
<div className='loginForm-actions'>
|
||||
<Field label={t('LoginForm.label.savePassword')} name='remember' component={CheckboxField} />
|
||||
<OnChange name="remember">{onRememberChange}</OnChange>
|
||||
|
||||
<Button color='primary' onClick={onResetPassword}>
|
||||
{ t('LoginForm.label.forgot') }
|
||||
</Button>
|
||||
</div>
|
||||
<div className='loginForm-item'>
|
||||
<Field name='selectedHost' component={KnownHosts} />
|
||||
<OnChange name="selectedHost">{setHost}</OnChange>
|
||||
</div>
|
||||
<div className='loginForm-actions'>
|
||||
<Field label={t('LoginForm.label.autoConnect')} name='autoConnect' component={CheckboxField} />
|
||||
<OnChange name="autoConnect">{onAutoConnectChange}</OnChange>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className='loginForm-submit rounded tall'
|
||||
color='primary'
|
||||
variant='contained'
|
||||
type='submit'
|
||||
disabled={disableSubmitButton}
|
||||
>
|
||||
{ t('LoginForm.label.login') }
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}}
|
||||
{({ handleSubmit, form }) => (
|
||||
<LoginFormBody {...props} form={form} handleSubmit={handleSubmit} />
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
interface LoginFormProps {
|
||||
onSubmit: any;
|
||||
disableSubmitButton: boolean,
|
||||
onResetPassword: any;
|
||||
}
|
||||
|
||||
export default LoginForm;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Form, Field } from 'react-final-form';
|
||||
import { OnChange } from 'react-final-form-listeners';
|
||||
|
|
@ -99,10 +99,11 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
|||
<Form onSubmit={handleOnSubmit} validate={validate} mutators={{ setFieldTouched }}>
|
||||
{({ handleSubmit, form }) => {
|
||||
|
||||
if (emailRequired) {
|
||||
// Allow form render to complete
|
||||
setTimeout(() => form.mutators.setFieldTouched('email', true))
|
||||
}
|
||||
useEffect(() => {
|
||||
if (emailRequired) {
|
||||
form.mutators.setFieldTouched('email', true);
|
||||
}
|
||||
}, [emailRequired]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
40
webclient/src/hooks/__mocks__/useKnownHosts.ts
Normal file
40
webclient/src/hooks/__mocks__/useKnownHosts.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { HostDTO } from '@app/services';
|
||||
import { LoadingState } from '../useSharedStore';
|
||||
import type { KnownHostsHook, KnownHostsValue } from '../useKnownHosts';
|
||||
|
||||
export const makeHost = (overrides: Partial<HostDTO> = {}): HostDTO =>
|
||||
({
|
||||
id: 1,
|
||||
name: 'Test Host',
|
||||
host: 'test.example',
|
||||
port: '4747',
|
||||
editable: false,
|
||||
lastSelected: true,
|
||||
userName: undefined,
|
||||
hashedPassword: undefined,
|
||||
remember: false,
|
||||
save: vi.fn(),
|
||||
...overrides,
|
||||
}) as unknown as HostDTO;
|
||||
|
||||
export const makeKnownHostsValue = (overrides: Partial<KnownHostsValue> = {}): KnownHostsValue => {
|
||||
const host = makeHost();
|
||||
return { hosts: [host], selectedHost: host, ...overrides };
|
||||
};
|
||||
|
||||
export const makeKnownHostsHook = (overrides: Partial<KnownHostsHook> = {}): KnownHostsHook =>
|
||||
({
|
||||
status: LoadingState.READY,
|
||||
value: makeKnownHostsValue(),
|
||||
select: vi.fn().mockResolvedValue(undefined),
|
||||
add: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
}) as KnownHostsHook;
|
||||
|
||||
export const useKnownHosts = vi.fn<() => KnownHostsHook>(() => makeKnownHostsHook());
|
||||
|
||||
export const getKnownHosts = vi.fn<() => Promise<KnownHostsValue>>(() =>
|
||||
Promise.resolve(makeKnownHostsValue())
|
||||
);
|
||||
20
webclient/src/hooks/__mocks__/useSettings.ts
Normal file
20
webclient/src/hooks/__mocks__/useSettings.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { SettingDTO } from '@app/services';
|
||||
import { LoadingState } from '../useSharedStore';
|
||||
import type { SettingsHook } from '../useSettings';
|
||||
|
||||
export const makeSettings = (overrides: Partial<SettingDTO> = {}): SettingDTO =>
|
||||
({ user: '*app', autoConnect: false, save: vi.fn(), ...overrides }) as SettingDTO;
|
||||
|
||||
export const makeSettingsHook = (overrides: Partial<SettingsHook> = {}): SettingsHook =>
|
||||
({
|
||||
status: LoadingState.READY,
|
||||
value: makeSettings(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
}) as SettingsHook;
|
||||
|
||||
export const useSettings = vi.fn<() => SettingsHook>(() => makeSettingsHook());
|
||||
|
||||
export const getSettings = vi.fn<() => Promise<SettingDTO>>(() =>
|
||||
Promise.resolve(makeSettings())
|
||||
);
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
export * from './useAutoConnect';
|
||||
export * from './useAutoLogin';
|
||||
export * from './useFireOnce';
|
||||
export * from './useDebounce';
|
||||
export * from './useKnownHosts';
|
||||
export * from './useLocaleSort';
|
||||
export * from './useReduxEffect';
|
||||
export * from './useSettings';
|
||||
export * from './useSharedStore';
|
||||
export * from './useWebClient';
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { SettingDTO } from '@app/services';
|
||||
import { App } from '@app/types';
|
||||
|
||||
export function useAutoConnect() {
|
||||
const [setting, setSetting] = useState(undefined);
|
||||
const [autoConnect, setAutoConnect] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
SettingDTO.get(App.APP_USER).then((setting: SettingDTO) => {
|
||||
if (!setting) {
|
||||
setting = new SettingDTO(App.APP_USER);
|
||||
setting.save();
|
||||
}
|
||||
|
||||
setSetting(setting);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (setting) {
|
||||
setAutoConnect(setting.autoConnect);
|
||||
}
|
||||
}, [setting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (setting) {
|
||||
setting.autoConnect = autoConnect;
|
||||
setting.save();
|
||||
}
|
||||
}, [setting, autoConnect]);
|
||||
|
||||
return [autoConnect, setAutoConnect];
|
||||
}
|
||||
155
webclient/src/hooks/useAutoLogin.spec.tsx
Normal file
155
webclient/src/hooks/useAutoLogin.spec.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('./useSettings');
|
||||
vi.mock('./useKnownHosts');
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
let useAutoLoginModule: typeof import('./useAutoLogin');
|
||||
let getSettingsMock: any;
|
||||
let getKnownHostsMock: any;
|
||||
let makeSettings: (o?: AnyRecord) => AnyRecord;
|
||||
let makeHost: (o?: AnyRecord) => AnyRecord;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh module graph per test so autoLoginGate.hasChecked resets.
|
||||
vi.resetModules();
|
||||
useAutoLoginModule = await import('./useAutoLogin');
|
||||
const settingsMockModule = await import('./__mocks__/useSettings');
|
||||
const hostsMockModule = await import('./__mocks__/useKnownHosts');
|
||||
getSettingsMock = settingsMockModule.getSettings;
|
||||
getKnownHostsMock = hostsMockModule.getKnownHosts;
|
||||
makeSettings = settingsMockModule.makeSettings as any;
|
||||
makeHost = hostsMockModule.makeHost as any;
|
||||
});
|
||||
|
||||
interface ConfigureOptions {
|
||||
autoConnect?: boolean;
|
||||
remember?: boolean;
|
||||
hashedPassword?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
const configure = ({
|
||||
autoConnect = false,
|
||||
remember = false,
|
||||
hashedPassword = undefined,
|
||||
userName = 'joe',
|
||||
}: ConfigureOptions) => {
|
||||
const settings = makeSettings({ autoConnect });
|
||||
const host = makeHost({ remember, hashedPassword, userName, lastSelected: true });
|
||||
|
||||
getSettingsMock.mockResolvedValue(settings);
|
||||
getKnownHostsMock.mockResolvedValue({ hosts: [host], selectedHost: host });
|
||||
};
|
||||
|
||||
describe('useAutoLogin', () => {
|
||||
test('fires onLogin when all conditions are met', async () => {
|
||||
const onLogin = vi.fn();
|
||||
configure({ autoConnect: true, remember: true, hashedPassword: 'hp', userName: 'joe' });
|
||||
|
||||
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
|
||||
await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1));
|
||||
expect(onLogin.mock.calls[0][0]).toMatchObject({
|
||||
userName: 'joe',
|
||||
remember: true,
|
||||
password: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not fire when settings.autoConnect is false', async () => {
|
||||
const onLogin = vi.fn();
|
||||
configure({ autoConnect: false, remember: true, hashedPassword: 'hp' });
|
||||
|
||||
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
|
||||
// Let the pending promise flush.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not fire when host lacks remember flag', async () => {
|
||||
const onLogin = vi.fn();
|
||||
configure({ autoConnect: true, remember: false, hashedPassword: 'hp' });
|
||||
|
||||
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not fire when host lacks hashedPassword', async () => {
|
||||
const onLogin = vi.fn();
|
||||
configure({ autoConnect: true, remember: true, hashedPassword: undefined });
|
||||
|
||||
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not fire when a connection attempt is already in flight', async () => {
|
||||
const onLogin = vi.fn();
|
||||
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
|
||||
|
||||
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, true));
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fires at most once per session, even across unmount + remount', async () => {
|
||||
const onLogin = vi.fn();
|
||||
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
|
||||
|
||||
const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1));
|
||||
|
||||
unmount();
|
||||
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('manual login then logout does NOT auto-connect on return to /login', async () => {
|
||||
const onLogin = vi.fn();
|
||||
|
||||
configure({ autoConnect: false, remember: true, hashedPassword: 'hp' });
|
||||
const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
|
||||
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ticking the auto-connect checkbox after mount does NOT trigger a login', async () => {
|
||||
const onLogin = vi.fn();
|
||||
configure({ autoConnect: false, remember: true, hashedPassword: 'hp' });
|
||||
|
||||
const { rerender } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
|
||||
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
|
||||
rerender();
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
58
webclient/src/hooks/useAutoLogin.ts
Normal file
58
webclient/src/hooks/useAutoLogin.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import type { HostDTO } from '@app/services';
|
||||
|
||||
import { getKnownHosts } from './useKnownHosts';
|
||||
import { getSettings } from './useSettings';
|
||||
|
||||
export interface LoginFormValues {
|
||||
userName: string;
|
||||
password?: string;
|
||||
selectedHost: HostDTO;
|
||||
remember: boolean;
|
||||
autoConnect?: boolean;
|
||||
}
|
||||
|
||||
export const autoLoginGate = { hasChecked: false };
|
||||
|
||||
export function useAutoLogin(
|
||||
onLogin: (values: LoginFormValues) => void,
|
||||
connectionAttemptMade: boolean,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (autoLoginGate.hasChecked) {
|
||||
return;
|
||||
}
|
||||
if (connectionAttemptMade) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all([getSettings(), getKnownHosts()]).then(([settings, hosts]) => {
|
||||
if (cancelled || autoLoginGate.hasChecked) {
|
||||
return;
|
||||
}
|
||||
autoLoginGate.hasChecked = true;
|
||||
|
||||
if (!settings.autoConnect) {
|
||||
return;
|
||||
}
|
||||
const { selectedHost } = hosts;
|
||||
if (!selectedHost?.remember || !selectedHost?.hashedPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLogin({
|
||||
selectedHost,
|
||||
userName: selectedHost.userName ?? '',
|
||||
remember: true,
|
||||
password: '',
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionAttemptMade, onLogin]);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
type UseDebounceType = (...args: any) => any;
|
||||
const DEBOUNCE_DELAY = 250;
|
||||
|
||||
export interface DebouncedFn<T extends UseDebounceType> {
|
||||
(...args: Parameters<T>): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
function debounce<T extends UseDebounceType>(fn: T, timeout: number): DebouncedFn<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const debounced = ((...args: Parameters<T>): void => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => fn(...args), timeout);
|
||||
}) as DebouncedFn<T>;
|
||||
debounced.cancel = (): void => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export function useDebounce<T extends UseDebounceType>(
|
||||
fn: T,
|
||||
deps: any[] = [],
|
||||
timeout: number = DEBOUNCE_DELAY
|
||||
): DebouncedFn<T> {
|
||||
return useCallback(debounce(fn, timeout), deps);
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@ import {
|
|||
render,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
renderHook,
|
||||
act,
|
||||
} from '@testing-library/react';
|
||||
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) => {
|
||||
|
|
@ -23,25 +24,20 @@ describe('useFireOnce hook', () => {
|
|||
return <button onClick={handleClickOnce} disabled={buttonIsDisabled}>{children}</button>
|
||||
}
|
||||
|
||||
// render the button
|
||||
const { getByRole } = render(
|
||||
<Button onClick={onClickWithPromise}>Click Me!</Button>
|
||||
);
|
||||
|
||||
//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);
|
||||
|
|
@ -51,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) => {
|
||||
|
|
@ -72,25 +67,20 @@ describe('useFireOnce hook', () => {
|
|||
)
|
||||
}
|
||||
|
||||
// render the form
|
||||
const { getByRole } = render(
|
||||
<Form onSubmit={onClickWithPromise} />
|
||||
);
|
||||
|
||||
//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);
|
||||
|
|
@ -98,4 +88,56 @@ describe('useFireOnce hook', () => {
|
|||
{ timeout: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
test('resetInFlightStatus re-enables firing', () => {
|
||||
const fn = vi.fn();
|
||||
const { result } = renderHook(() => useFireOnce(fn));
|
||||
|
||||
act(() => {
|
||||
result.current[2]();
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
result.current[1]();
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current[2]();
|
||||
});
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('calls the latest fn when parent updates it', () => {
|
||||
const fn1 = vi.fn();
|
||||
const fn2 = vi.fn();
|
||||
const { result, rerender } = renderHook(({ fn }) => useFireOnce(fn), {
|
||||
initialProps: { fn: fn1 },
|
||||
});
|
||||
|
||||
rerender({ fn: fn2 });
|
||||
|
||||
act(() => {
|
||||
result.current[2]();
|
||||
});
|
||||
|
||||
expect(fn1).not.toHaveBeenCalled();
|
||||
expect(fn2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('passes all arguments through to fn', () => {
|
||||
const fn = vi.fn();
|
||||
const { result } = renderHook(() => useFireOnce(fn));
|
||||
|
||||
act(() => {
|
||||
result.current[2]('a', 'b', 'c');
|
||||
});
|
||||
|
||||
expect(fn).toHaveBeenCalledWith('a', 'b', 'c');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
type UseFireOnceType = (...args: any) => any;
|
||||
|
||||
export function useFireOnce<T extends UseFireOnceType>(fn: T): [boolean, any, any] {
|
||||
const [actionIsInFlight, setActionIsInFlight] = useState(false)
|
||||
const handleFireOnce = useCallback((args) => {
|
||||
export function useFireOnce<T extends UseFireOnceType>(fn: T): [boolean, () => void, (...args: Parameters<T>) => void] {
|
||||
const [actionIsInFlight, setActionIsInFlight] = useState(false);
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
|
||||
const handleFireOnce = useCallback((...args: Parameters<T>) => {
|
||||
setActionIsInFlight(true);
|
||||
fn(args);
|
||||
}, [])
|
||||
function resetInFlightStatus() {
|
||||
fnRef.current(...args);
|
||||
}, []);
|
||||
|
||||
const resetInFlightStatus = useCallback(() => {
|
||||
setActionIsInFlight(false);
|
||||
}
|
||||
return [actionIsInFlight, resetInFlightStatus, handleFireOnce]
|
||||
}, []);
|
||||
|
||||
return [actionIsInFlight, resetInFlightStatus, handleFireOnce];
|
||||
}
|
||||
|
|
|
|||
211
webclient/src/hooks/useKnownHosts.spec.ts
Normal file
211
webclient/src/hooks/useKnownHosts.spec.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
type StoredHost = {
|
||||
id?: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: string;
|
||||
editable: boolean;
|
||||
lastSelected?: boolean;
|
||||
userName?: string;
|
||||
hashedPassword?: string;
|
||||
remember?: boolean;
|
||||
save?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
let stored: StoredHost[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
async function upsertStoredHost(this: StoredHost) {
|
||||
const idx = stored.findIndex((h) => h.id === this.id);
|
||||
if (idx >= 0) {
|
||||
stored[idx] = this;
|
||||
} else {
|
||||
this.id = this.id ?? nextId++;
|
||||
stored.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
const mockSave = vi.fn<(self: StoredHost) => Promise<void>>(upsertStoredHost);
|
||||
|
||||
vi.mock('@app/services', () => ({
|
||||
HostDTO: class MockHostDTO {
|
||||
id?: number;
|
||||
name!: string;
|
||||
host!: string;
|
||||
port!: string;
|
||||
editable!: boolean;
|
||||
lastSelected?: boolean;
|
||||
userName?: string;
|
||||
hashedPassword?: string;
|
||||
remember?: boolean;
|
||||
|
||||
save = function save(this: StoredHost) {
|
||||
return mockSave.call(this);
|
||||
};
|
||||
|
||||
static getAll = vi.fn(async () => {
|
||||
return stored.map((h) => {
|
||||
const inst = new MockHostDTO() as unknown as StoredHost;
|
||||
Object.assign(inst, h);
|
||||
(inst as unknown as MockHostDTO).save = function save() {
|
||||
return mockSave.call(this as unknown as StoredHost);
|
||||
};
|
||||
return inst;
|
||||
});
|
||||
});
|
||||
|
||||
static get = vi.fn(async (id: number) => {
|
||||
const match = stored.find((h) => h.id === id);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const inst = new MockHostDTO() as unknown as StoredHost;
|
||||
Object.assign(inst, match);
|
||||
(inst as unknown as MockHostDTO).save = function save() {
|
||||
return mockSave.call(this as unknown as StoredHost);
|
||||
};
|
||||
return inst;
|
||||
});
|
||||
|
||||
static add = vi.fn(async (host: StoredHost) => {
|
||||
const id = nextId++;
|
||||
stored.push({ ...host, id });
|
||||
return id;
|
||||
});
|
||||
|
||||
static bulkAdd = vi.fn(async (hosts: StoredHost[]) => {
|
||||
for (const h of hosts) {
|
||||
stored.push({ ...h, id: nextId++ });
|
||||
}
|
||||
});
|
||||
|
||||
static delete = vi.fn(async (id: number | string) => {
|
||||
const numericId = typeof id === 'string' ? Number(id) : id;
|
||||
stored = stored.filter((h) => h.id !== numericId);
|
||||
});
|
||||
},
|
||||
DefaultHosts: [
|
||||
{ name: 'A', host: 'a.x', port: '1', editable: false },
|
||||
{ name: 'B', host: 'b.x', port: '2', editable: false },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('@app/types', () => ({ App: {} }));
|
||||
|
||||
let useKnownHostsModule: typeof import('./useKnownHosts');
|
||||
let LoadingState: typeof import('./useSharedStore').LoadingState;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
stored = [];
|
||||
nextId = 1;
|
||||
mockSave.mockClear();
|
||||
useKnownHostsModule = await import('./useKnownHosts');
|
||||
({ LoadingState } = await import('./useSharedStore'));
|
||||
});
|
||||
|
||||
describe('useKnownHosts', () => {
|
||||
test('seeds DefaultHosts when the DB is empty and pins hosts[0] as lastSelected', async () => {
|
||||
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe(LoadingState.READY);
|
||||
});
|
||||
|
||||
if (result.current.status !== LoadingState.READY) {
|
||||
throw new Error('not ready');
|
||||
}
|
||||
expect(result.current.value.hosts).toHaveLength(2);
|
||||
expect(result.current.value.selectedHost.name).toBe('A');
|
||||
expect(result.current.value.selectedHost.lastSelected).toBe(true);
|
||||
});
|
||||
|
||||
test('select(id) flips lastSelected atomically — exactly one row true', async () => {
|
||||
stored = [
|
||||
{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true },
|
||||
{ id: 2, name: 'B', host: 'b', port: '2', editable: false, lastSelected: false },
|
||||
];
|
||||
nextId = 3;
|
||||
|
||||
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe(LoadingState.READY);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.select(2);
|
||||
});
|
||||
|
||||
if (result.current.status !== LoadingState.READY) {
|
||||
throw new Error('not ready');
|
||||
}
|
||||
expect(result.current.value.selectedHost.id).toBe(2);
|
||||
const lastSelectedCount = result.current.value.hosts.filter((h) => h.lastSelected).length;
|
||||
expect(lastSelectedCount).toBe(1);
|
||||
});
|
||||
|
||||
test('add() persists to Dexie and mirrors the new host into in-memory state', async () => {
|
||||
stored = [{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true }];
|
||||
nextId = 2;
|
||||
|
||||
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
|
||||
await waitFor(() => expect(result.current.status).toBe(LoadingState.READY));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.add({ name: 'C', host: 'c', port: '3', editable: true });
|
||||
});
|
||||
|
||||
if (result.current.status !== LoadingState.READY) {
|
||||
throw new Error('not ready');
|
||||
}
|
||||
expect(result.current.value.hosts).toHaveLength(2);
|
||||
expect(result.current.value.hosts.some((h) => h.name === 'C')).toBe(true);
|
||||
});
|
||||
|
||||
test('update() patches the host and replaces the snapshot reference', async () => {
|
||||
stored = [
|
||||
{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true, remember: false },
|
||||
];
|
||||
nextId = 2;
|
||||
|
||||
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
|
||||
await waitFor(() => expect(result.current.status).toBe(LoadingState.READY));
|
||||
|
||||
const before = result.current;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update(1, { remember: true, userName: 'joe' });
|
||||
});
|
||||
|
||||
expect(result.current).not.toBe(before);
|
||||
if (result.current.status !== LoadingState.READY) {
|
||||
throw new Error('not ready');
|
||||
}
|
||||
expect(result.current.value.hosts[0].remember).toBe(true);
|
||||
expect(result.current.value.hosts[0].userName).toBe('joe');
|
||||
});
|
||||
|
||||
test('remove() deletes and picks a new selectedHost when the removed row was selected', async () => {
|
||||
stored = [
|
||||
{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true },
|
||||
{ id: 2, name: 'B', host: 'b', port: '2', editable: false, lastSelected: false },
|
||||
];
|
||||
nextId = 3;
|
||||
|
||||
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
|
||||
await waitFor(() => expect(result.current.status).toBe(LoadingState.READY));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.remove(1);
|
||||
});
|
||||
|
||||
if (result.current.status !== LoadingState.READY) {
|
||||
throw new Error('not ready');
|
||||
}
|
||||
expect(result.current.value.hosts).toHaveLength(1);
|
||||
expect(result.current.value.selectedHost.id).toBe(2);
|
||||
expect(result.current.value.selectedHost.lastSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
119
webclient/src/hooks/useKnownHosts.ts
Normal file
119
webclient/src/hooks/useKnownHosts.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { DefaultHosts, HostDTO } from '@app/services';
|
||||
import { App } from '@app/types';
|
||||
|
||||
import { createSharedStore, Loadable, useSharedStore } from './useSharedStore';
|
||||
|
||||
export interface KnownHostsValue {
|
||||
hosts: HostDTO[];
|
||||
selectedHost: HostDTO;
|
||||
}
|
||||
|
||||
const loadAll = async (): Promise<HostDTO[]> => {
|
||||
let hosts = await HostDTO.getAll();
|
||||
if (!hosts?.length) {
|
||||
await HostDTO.bulkAdd(DefaultHosts);
|
||||
hosts = await HostDTO.getAll();
|
||||
}
|
||||
return hosts;
|
||||
};
|
||||
|
||||
const normalize = async (hosts: HostDTO[]): Promise<KnownHostsValue> => {
|
||||
const existing = hosts.find((h) => h.lastSelected);
|
||||
if (existing) {
|
||||
return { hosts, selectedHost: existing };
|
||||
}
|
||||
|
||||
const selected = hosts[0];
|
||||
selected.lastSelected = true;
|
||||
await selected.save();
|
||||
return { hosts, selectedHost: selected };
|
||||
};
|
||||
|
||||
export const knownHostsStore = createSharedStore<KnownHostsValue>(async () => {
|
||||
const hosts = await loadAll();
|
||||
return normalize(hosts);
|
||||
});
|
||||
const store = knownHostsStore;
|
||||
|
||||
export type KnownHostsHook = Loadable<KnownHostsValue> & {
|
||||
select: (id: number) => Promise<void>;
|
||||
add: (host: App.Host) => Promise<HostDTO>;
|
||||
update: (id: number, patch: Partial<HostDTO>) => Promise<HostDTO>;
|
||||
remove: (id: number) => Promise<void>;
|
||||
};
|
||||
|
||||
const requireValue = (method: string): KnownHostsValue => {
|
||||
const current = store.peek();
|
||||
if (!current) {
|
||||
throw new Error(`useKnownHosts.${method} called before hosts loaded`);
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const select = async (id: number): Promise<void> => {
|
||||
const { hosts } = requireValue('select');
|
||||
const target = hosts.find((h) => h.id === id);
|
||||
if (!target) {
|
||||
throw new Error(`useKnownHosts.select: unknown host id ${id}`);
|
||||
}
|
||||
|
||||
const writes: Promise<unknown>[] = [];
|
||||
for (const h of hosts) {
|
||||
if (h === target) {
|
||||
if (!h.lastSelected) {
|
||||
h.lastSelected = true;
|
||||
writes.push(h.save());
|
||||
}
|
||||
} else if (h.lastSelected) {
|
||||
h.lastSelected = false;
|
||||
writes.push(h.save());
|
||||
}
|
||||
}
|
||||
await Promise.all(writes);
|
||||
|
||||
store.setValue({ hosts: [...hosts], selectedHost: target });
|
||||
};
|
||||
|
||||
const add = async (host: App.Host): Promise<HostDTO> => {
|
||||
const { hosts, selectedHost } = requireValue('add');
|
||||
const created = await HostDTO.get((await HostDTO.add(host)) as number);
|
||||
store.setValue({ hosts: [...hosts, created], selectedHost });
|
||||
return created;
|
||||
};
|
||||
|
||||
const update = async (id: number, patch: Partial<HostDTO>): Promise<HostDTO> => {
|
||||
const { hosts, selectedHost } = requireValue('update');
|
||||
const existing = hosts.find((h) => h.id === id);
|
||||
if (!existing) {
|
||||
throw new Error(`useKnownHosts.update: unknown host id ${id}`);
|
||||
}
|
||||
Object.assign(existing, patch);
|
||||
await existing.save();
|
||||
store.setValue({
|
||||
hosts: [...hosts],
|
||||
selectedHost: selectedHost.id === id ? existing : selectedHost,
|
||||
});
|
||||
return existing;
|
||||
};
|
||||
|
||||
const remove = async (id: number): Promise<void> => {
|
||||
const { hosts, selectedHost } = requireValue('remove');
|
||||
await HostDTO.delete(id as unknown as string);
|
||||
const next = hosts.filter((h) => h.id !== id);
|
||||
let nextSelected = selectedHost;
|
||||
if (selectedHost.id === id) {
|
||||
nextSelected = next[0];
|
||||
if (nextSelected) {
|
||||
nextSelected.lastSelected = true;
|
||||
await nextSelected.save();
|
||||
}
|
||||
}
|
||||
store.setValue({ hosts: next, selectedHost: nextSelected });
|
||||
};
|
||||
|
||||
export function useKnownHosts(): KnownHostsHook {
|
||||
const state = useSharedStore(store);
|
||||
return { ...state, select, add, update, remove };
|
||||
}
|
||||
|
||||
export const getKnownHosts = (): Promise<KnownHostsValue> => store.whenReady();
|
||||
80
webclient/src/hooks/useLocaleSort.spec.ts
Normal file
80
webclient/src/hooks/useLocaleSort.spec.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import { useLocaleSort } from './useLocaleSort';
|
||||
|
||||
let mockLanguage = 'en';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
i18n: {
|
||||
get language() {
|
||||
return mockLanguage;
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useLocaleSort', () => {
|
||||
beforeEach(() => {
|
||||
mockLanguage = 'en';
|
||||
});
|
||||
|
||||
test('sorts strings by locale using the valueGetter', () => {
|
||||
const arr = ['c', 'a', 'b'];
|
||||
const { result } = renderHook(() => useLocaleSort(arr, (v) => v));
|
||||
|
||||
expect(result.current).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('sorts using valueGetter to resolve display values', () => {
|
||||
const lookup: Record<string, string> = { x: 'cherry', y: 'apple', z: 'banana' };
|
||||
const arr = ['x', 'y', 'z'];
|
||||
const { result } = renderHook(() => useLocaleSort(arr, (v) => lookup[v]));
|
||||
|
||||
expect(result.current).toEqual(['y', 'z', 'x']);
|
||||
});
|
||||
|
||||
test('handles empty array', () => {
|
||||
const { result } = renderHook(() => useLocaleSort([], (v) => v));
|
||||
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not mutate the input array', () => {
|
||||
const arr = ['c', 'a', 'b'];
|
||||
renderHook(() => useLocaleSort(arr, (v) => v));
|
||||
|
||||
expect(arr).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('updates when arr prop changes', () => {
|
||||
let arr = ['c', 'a'];
|
||||
const getter = (v: string) => v;
|
||||
const { result, rerender } = renderHook(() => useLocaleSort(arr, getter));
|
||||
|
||||
expect(result.current).toEqual(['a', 'c']);
|
||||
|
||||
arr = ['z', 'b', 'a'];
|
||||
rerender();
|
||||
|
||||
expect(result.current).toEqual(['a', 'b', 'z']);
|
||||
});
|
||||
|
||||
test('re-sorts when language changes', () => {
|
||||
// Swedish sorts ä after z; English sorts ä near a
|
||||
const arr = ['ä', 'b', 'z'];
|
||||
const getter = (v: string) => v;
|
||||
|
||||
mockLanguage = 'en';
|
||||
const { result, rerender } = renderHook(() => useLocaleSort(arr, getter));
|
||||
const englishOrder = [...result.current];
|
||||
|
||||
mockLanguage = 'sv';
|
||||
rerender();
|
||||
const swedishOrder = [...result.current];
|
||||
|
||||
// In Swedish, ä comes after z
|
||||
expect(swedishOrder[swedishOrder.length - 1]).toBe('ä');
|
||||
// In English, ä sorts near 'a', before 'b'
|
||||
expect(englishOrder.indexOf('ä')).toBeLessThan(englishOrder.indexOf('b'));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,18 +1,11 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useLocaleSort(arr: string[], valueGetter: (value: string) => string) {
|
||||
const [state] = useState<string[]>(arr);
|
||||
const [sorted, setSorted] = useState<string[]>([]);
|
||||
|
||||
export function useLocaleSort(arr: string[], valueGetter: (value: string) => string): string[] {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
return useMemo(() => {
|
||||
const collator = new Intl.Collator(i18n.language);
|
||||
const sorter = (a, b) => collator.compare(valueGetter(a), valueGetter(b));
|
||||
|
||||
setSorted(state.sort(sorter));
|
||||
}, [state, i18n.language]);
|
||||
|
||||
return sorted;
|
||||
return [...arr].sort((a, b) => collator.compare(valueGetter(a), valueGetter(b)));
|
||||
}, [arr, i18n.language, valueGetter]);
|
||||
}
|
||||
|
|
|
|||
138
webclient/src/hooks/useReduxEffect.spec.tsx
Normal file
138
webclient/src/hooks/useReduxEffect.spec.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { configureStore, combineReducers } from '@reduxjs/toolkit';
|
||||
import { Provider } from 'react-redux';
|
||||
import { StrictMode, ReactNode } from 'react';
|
||||
import { useReduxEffect } from './useReduxEffect';
|
||||
import { actionReducer } from '../store/actions/actionReducer';
|
||||
|
||||
function makeStore() {
|
||||
return configureStore({
|
||||
reducer: combineReducers({ action: actionReducer }),
|
||||
});
|
||||
}
|
||||
|
||||
function makeWrapper(store: ReturnType<typeof makeStore>) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('useReduxEffect', () => {
|
||||
test('fires callback when matching action type is dispatched', () => {
|
||||
const store = makeStore();
|
||||
const effect = vi.fn();
|
||||
|
||||
renderHook(() => useReduxEffect(effect, 'TEST_ACTION'), {
|
||||
wrapper: makeWrapper(store),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
store.dispatch({ type: 'TEST_ACTION', payload: 'hello' });
|
||||
});
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1);
|
||||
expect(effect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'TEST_ACTION' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not fire for non-matching action types', () => {
|
||||
const store = makeStore();
|
||||
const effect = vi.fn();
|
||||
|
||||
renderHook(() => useReduxEffect(effect, 'LISTEN_FOR'), {
|
||||
wrapper: makeWrapper(store),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
store.dispatch({ type: 'OTHER_ACTION' });
|
||||
});
|
||||
|
||||
expect(effect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles array of action types', () => {
|
||||
const store = makeStore();
|
||||
const effect = vi.fn();
|
||||
|
||||
renderHook(() => useReduxEffect(effect, ['TYPE_A', 'TYPE_B']), {
|
||||
wrapper: makeWrapper(store),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
store.dispatch({ type: 'TYPE_A' });
|
||||
});
|
||||
act(() => {
|
||||
store.dispatch({ type: 'TYPE_B' });
|
||||
});
|
||||
act(() => {
|
||||
store.dispatch({ type: 'TYPE_C' });
|
||||
});
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('does not double-fire in StrictMode', () => {
|
||||
const store = makeStore();
|
||||
const effect = vi.fn();
|
||||
|
||||
function StrictWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<StrictMode>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
renderHook(() => useReduxEffect(effect, 'TEST'), {
|
||||
wrapper: StrictWrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
store.dispatch({ type: 'TEST' });
|
||||
});
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('catches action dispatched before mount via sync check', () => {
|
||||
const store = makeStore();
|
||||
const effect = vi.fn();
|
||||
|
||||
// Dispatch before the hook mounts
|
||||
store.dispatch({ type: 'EARLY_ACTION' });
|
||||
|
||||
renderHook(() => useReduxEffect(effect, 'EARLY_ACTION'), {
|
||||
wrapper: makeWrapper(store),
|
||||
});
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1);
|
||||
expect(effect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'EARLY_ACTION' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses latest effect callback via ref', () => {
|
||||
const store = makeStore();
|
||||
const effect1 = vi.fn();
|
||||
const effect2 = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ cb }) => useReduxEffect(cb, 'TEST'),
|
||||
{
|
||||
wrapper: makeWrapper(store),
|
||||
initialProps: { cb: effect1 },
|
||||
},
|
||||
);
|
||||
|
||||
rerender({ cb: effect2 });
|
||||
|
||||
act(() => {
|
||||
store.dispatch({ type: 'TEST' });
|
||||
});
|
||||
|
||||
expect(effect1).not.toHaveBeenCalled();
|
||||
expect(effect2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
});
|
||||
98
webclient/src/hooks/useSettings.spec.ts
Normal file
98
webclient/src/hooks/useSettings.spec.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
const mockSave = vi.fn();
|
||||
let storedSetting: any = null;
|
||||
|
||||
vi.mock('@app/services', () => ({
|
||||
SettingDTO: class MockSettingDTO {
|
||||
user: string;
|
||||
autoConnect = false;
|
||||
constructor(user: string) {
|
||||
this.user = user;
|
||||
}
|
||||
save = mockSave;
|
||||
static get = vi.fn(() => Promise.resolve(storedSetting));
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/types', () => ({
|
||||
App: { APP_USER: '*app' },
|
||||
}));
|
||||
|
||||
// Each spec resets module state so the shared store starts fresh.
|
||||
let useSettingsModule: typeof import('./useSettings');
|
||||
let LoadingState: typeof import('./useSharedStore').LoadingState;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
storedSetting = null;
|
||||
mockSave.mockClear();
|
||||
useSettingsModule = await import('./useSettings');
|
||||
({ LoadingState } = await import('./useSharedStore'));
|
||||
});
|
||||
|
||||
describe('useSettings', () => {
|
||||
test('starts in loading state, then resolves to the stored setting', async () => {
|
||||
storedSetting = { user: '*app', autoConnect: true, save: mockSave };
|
||||
|
||||
const { result } = renderHook(() => useSettingsModule.useSettings());
|
||||
|
||||
expect(result.current.status).toBe(LoadingState.LOADING);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe(LoadingState.READY);
|
||||
});
|
||||
|
||||
if (result.current.status === LoadingState.READY) {
|
||||
expect(result.current.value.autoConnect).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('creates and saves a new SettingDTO when none exists', async () => {
|
||||
storedSetting = null;
|
||||
|
||||
const { result } = renderHook(() => useSettingsModule.useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe(LoadingState.READY);
|
||||
});
|
||||
|
||||
if (result.current.status === LoadingState.READY) {
|
||||
expect(result.current.value.autoConnect).toBe(false);
|
||||
}
|
||||
expect(mockSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('update() persists the patch and re-renders with a new snapshot', async () => {
|
||||
storedSetting = { user: '*app', autoConnect: false, save: mockSave };
|
||||
|
||||
const { result } = renderHook(() => useSettingsModule.useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe(LoadingState.READY);
|
||||
});
|
||||
|
||||
mockSave.mockClear();
|
||||
const before = result.current;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update({ autoConnect: true });
|
||||
});
|
||||
|
||||
expect(result.current).not.toBe(before);
|
||||
if (result.current.status === LoadingState.READY) {
|
||||
expect(result.current.value.autoConnect).toBe(true);
|
||||
}
|
||||
expect(mockSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not re-save on the initial load when setting already exists', async () => {
|
||||
storedSetting = { user: '*app', autoConnect: true, save: mockSave };
|
||||
|
||||
renderHook(() => useSettingsModule.useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
36
webclient/src/hooks/useSettings.ts
Normal file
36
webclient/src/hooks/useSettings.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { SettingDTO } from '@app/services';
|
||||
import { App } from '@app/types';
|
||||
|
||||
import { createSharedStore, Loadable, useSharedStore } from './useSharedStore';
|
||||
|
||||
export const settingsStore = createSharedStore<SettingDTO>(async () => {
|
||||
let loaded = await SettingDTO.get(App.APP_USER);
|
||||
if (!loaded) {
|
||||
loaded = new SettingDTO(App.APP_USER);
|
||||
await loaded.save();
|
||||
}
|
||||
return loaded;
|
||||
});
|
||||
const store = settingsStore;
|
||||
|
||||
export type SettingsHook = Loadable<SettingDTO> & {
|
||||
update: (patch: Partial<SettingDTO>) => Promise<void>;
|
||||
};
|
||||
|
||||
export function useSettings(): SettingsHook {
|
||||
const state = useSharedStore(store);
|
||||
|
||||
const update = async (patch: Partial<SettingDTO>) => {
|
||||
const current = store.peek();
|
||||
if (!current) {
|
||||
throw new Error('useSettings.update called before settings loaded');
|
||||
}
|
||||
Object.assign(current, patch);
|
||||
await current.save();
|
||||
store.setValue(current);
|
||||
};
|
||||
|
||||
return { ...state, update };
|
||||
}
|
||||
|
||||
export const getSettings = (): Promise<SettingDTO> => store.whenReady();
|
||||
102
webclient/src/hooks/useSharedStore.spec.ts
Normal file
102
webclient/src/hooks/useSharedStore.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { createSharedStore, LoadingState } from './useSharedStore';
|
||||
|
||||
describe('createSharedStore', () => {
|
||||
test('starts in LOADING state before any subscriber connects', () => {
|
||||
const store = createSharedStore(async () => 'value');
|
||||
expect(store.getSnapshot()).toEqual({ status: LoadingState.LOADING });
|
||||
expect(store.peek()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('triggers load on first subscribe and resolves to READY', async () => {
|
||||
let loadCalls = 0;
|
||||
const store = createSharedStore(async () => {
|
||||
loadCalls++;
|
||||
return 42;
|
||||
});
|
||||
|
||||
const cb = vi.fn();
|
||||
store.subscribe(cb);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getSnapshot()).toEqual({ status: LoadingState.READY, value: 42 });
|
||||
});
|
||||
|
||||
expect(loadCalls).toBe(1);
|
||||
expect(cb).toHaveBeenCalled();
|
||||
expect(store.peek()).toBe(42);
|
||||
});
|
||||
|
||||
test('multiple subscribers share a single load', async () => {
|
||||
let loadCalls = 0;
|
||||
const store = createSharedStore(async () => {
|
||||
loadCalls++;
|
||||
return 'shared';
|
||||
});
|
||||
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
store.subscribe(cb1);
|
||||
store.subscribe(cb2);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getSnapshot()).toEqual({ status: LoadingState.READY, value: 'shared' });
|
||||
});
|
||||
|
||||
expect(loadCalls).toBe(1);
|
||||
expect(cb1).toHaveBeenCalled();
|
||||
expect(cb2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setValue notifies subscribers with a new snapshot reference', async () => {
|
||||
const store = createSharedStore(async () => ({ n: 1 }));
|
||||
const cb = vi.fn();
|
||||
store.subscribe(cb);
|
||||
|
||||
await vi.waitFor(() => expect(store.getSnapshot().status).toBe(LoadingState.READY));
|
||||
|
||||
const before = store.getSnapshot();
|
||||
cb.mockClear();
|
||||
|
||||
store.setValue({ n: 2 });
|
||||
|
||||
const after = store.getSnapshot();
|
||||
expect(after).not.toBe(before);
|
||||
expect(after).toEqual({ status: LoadingState.READY, value: { n: 2 } });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('transitions to ERROR when loader rejects and notifies subscribers', async () => {
|
||||
const store = createSharedStore(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const cb = vi.fn();
|
||||
store.subscribe(cb);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getSnapshot().status).toBe(LoadingState.ERROR);
|
||||
});
|
||||
|
||||
const snapshot = store.getSnapshot();
|
||||
expect(snapshot.status).toBe(LoadingState.ERROR);
|
||||
if (snapshot.status === LoadingState.ERROR) {
|
||||
expect(snapshot.error.message).toBe('boom');
|
||||
}
|
||||
expect(cb).toHaveBeenCalled();
|
||||
expect(store.peek()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unsubscribe removes the callback', async () => {
|
||||
const store = createSharedStore(async () => 'x');
|
||||
const cb = vi.fn();
|
||||
const unsubscribe = store.subscribe(cb);
|
||||
|
||||
await vi.waitFor(() => expect(store.getSnapshot().status).toBe(LoadingState.READY));
|
||||
cb.mockClear();
|
||||
|
||||
unsubscribe();
|
||||
store.setValue('y');
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
112
webclient/src/hooks/useSharedStore.ts
Normal file
112
webclient/src/hooks/useSharedStore.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export enum LoadingState {
|
||||
LOADING = 'loading',
|
||||
READY = 'ready',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export interface Loadable<T> {
|
||||
status: LoadingState;
|
||||
value?: T;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
// @critical Two surfaces: subscribe (reactive) vs whenReady (one-shot).
|
||||
// See .github/instructions/webclient.instructions.md#shared-store-pattern.
|
||||
export interface SharedStore<T> {
|
||||
subscribe: (cb: () => void) => () => void;
|
||||
getSnapshot: () => Loadable<T>;
|
||||
whenReady: () => Promise<T>;
|
||||
setValue: (value: T) => void;
|
||||
peek: () => T | undefined;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function createSharedStore<T>(load: () => Promise<T>): SharedStore<T> {
|
||||
let state: Loadable<T> = { status: LoadingState.LOADING };
|
||||
const subscribers = new Set<() => void>();
|
||||
let loadStarted = false;
|
||||
|
||||
// Lazy to avoid unhandled-rejection bookkeeping when no caller awaits it.
|
||||
let readyPromise: Promise<T> | null = null;
|
||||
|
||||
const notify = () => {
|
||||
for (const cb of subscribers) {
|
||||
cb();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureLoaded = () => {
|
||||
if (loadStarted) {
|
||||
return;
|
||||
}
|
||||
loadStarted = true;
|
||||
load().then(
|
||||
(value) => {
|
||||
state = { status: LoadingState.READY, value };
|
||||
notify();
|
||||
},
|
||||
(error: unknown) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
state = { status: LoadingState.ERROR, error: err };
|
||||
notify();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const subscribe = (cb: () => void) => {
|
||||
subscribers.add(cb);
|
||||
ensureLoaded();
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
getSnapshot: () => state,
|
||||
whenReady: () => {
|
||||
ensureLoaded();
|
||||
if (!readyPromise) {
|
||||
readyPromise = new Promise<T>((resolve, reject) => {
|
||||
const settle = (): boolean => {
|
||||
if (state.status === LoadingState.READY) {
|
||||
resolve(state.value as T);
|
||||
return true;
|
||||
}
|
||||
if (state.status === LoadingState.ERROR && state.error) {
|
||||
reject(state.error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (settle()) {
|
||||
return;
|
||||
}
|
||||
const unsub = subscribe(() => {
|
||||
if (settle()) {
|
||||
unsub();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return readyPromise;
|
||||
},
|
||||
setValue: (value) => {
|
||||
state = { status: LoadingState.READY, value };
|
||||
notify();
|
||||
},
|
||||
peek: () => (state.status === LoadingState.READY ? (state.value as T) : undefined),
|
||||
reset: () => {
|
||||
state = { status: LoadingState.LOADING };
|
||||
loadStarted = false;
|
||||
readyPromise = null;
|
||||
notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useSharedStore<T>(store: SharedStore<T>): Loadable<T> {
|
||||
return useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||
}
|
||||
48
webclient/src/hooks/useWebClient.spec.tsx
Normal file
48
webclient/src/hooks/useWebClient.spec.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { WebClientProvider, useWebClient } from './useWebClient';
|
||||
|
||||
vi.mock('@app/websocket', () => ({
|
||||
WebClient: class MockWebClient {},
|
||||
}));
|
||||
|
||||
vi.mock('@app/api', () => ({
|
||||
createWebClientRequest: vi.fn(() => 'request'),
|
||||
createWebClientResponse: vi.fn(() => 'response'),
|
||||
}));
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <WebClientProvider>{children}</WebClientProvider>;
|
||||
}
|
||||
|
||||
describe('useWebClient', () => {
|
||||
test('provides the WebClient instance to children', () => {
|
||||
const { result } = renderHook(() => useWebClient(), { wrapper: Wrapper });
|
||||
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current.constructor.name).toBe('MockWebClient');
|
||||
});
|
||||
|
||||
test('throws when used outside WebClientProvider', () => {
|
||||
// Suppress React error boundary console output
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useWebClient());
|
||||
}).toThrow('useWebClient must be used within a WebClientProvider');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('returns the same instance across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useWebClient(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const first = result.current;
|
||||
rerender();
|
||||
const second = result.current;
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import { createContext, useContext, useState, ReactNode } from 'react';
|
|||
import { WebClient } from '@app/websocket';
|
||||
import { createWebClientRequest, createWebClientResponse } from '@app/api';
|
||||
|
||||
const WebClientContext = createContext<WebClient | null>(null);
|
||||
export const WebClientContext = createContext<WebClient | null>(null);
|
||||
|
||||
export function WebClientProvider({ children }: { children: ReactNode }) {
|
||||
const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse()));
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
261
webclient/src/services/CardImporterService.spec.ts
Normal file
261
webclient/src/services/CardImporterService.spec.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { cardImporterService } from './CardImporterService';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
function jsonResponse(body: unknown, contentType = 'application/json') {
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers({ 'Content-Type': contentType }),
|
||||
json: () => Promise.resolve(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function textResponse(body: string, ok = true) {
|
||||
return {
|
||||
ok,
|
||||
headers: new Headers({ 'Content-Type': 'application/xml' }),
|
||||
text: () => Promise.resolve(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function failedResponse(status = 500) {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
json: () => Promise.resolve({}),
|
||||
text: () => Promise.resolve(''),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
// Minimal MTGJSON-shaped fixture
|
||||
const mtgjsonFixture = {
|
||||
data: {
|
||||
SET_B: {
|
||||
code: 'SET_B',
|
||||
name: 'Set B',
|
||||
releaseDate: '2020-06-01',
|
||||
cards: [
|
||||
{ name: 'Zebra' },
|
||||
{ name: 'Alpha' },
|
||||
],
|
||||
tokens: [{ name: 'Token B' }],
|
||||
},
|
||||
SET_A: {
|
||||
code: 'SET_A',
|
||||
name: 'Set A',
|
||||
releaseDate: '2019-01-01',
|
||||
cards: [
|
||||
{ name: 'Alpha' },
|
||||
{ name: 'Beta' },
|
||||
],
|
||||
tokens: [{ name: 'Token A' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('CardImporterService', () => {
|
||||
describe('importCards', () => {
|
||||
it('fetches and parses valid MTGJSON data into sorted cards and sets', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture));
|
||||
|
||||
const { cards, sets } = await cardImporterService.importCards('http://example.com/cards.json');
|
||||
|
||||
expect(cards).toHaveLength(3);
|
||||
expect(cards[0].name).toBe('Alpha');
|
||||
expect(cards[1].name).toBe('Beta');
|
||||
expect(cards[2].name).toBe('Zebra');
|
||||
|
||||
expect(sets).toHaveLength(2);
|
||||
expect(sets[0].name).toBe('Set A');
|
||||
expect(sets[1].name).toBe('Set B');
|
||||
});
|
||||
|
||||
it('sorts sets by releaseDate ascending', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture));
|
||||
|
||||
const { sets } = await cardImporterService.importCards('http://example.com/cards.json');
|
||||
|
||||
expect(sets[0].code).toBe('SET_A');
|
||||
expect(sets[1].code).toBe('SET_B');
|
||||
});
|
||||
|
||||
it('deduplicates cards by name, keeping last occurrence (later set wins)', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture));
|
||||
|
||||
const { cards } = await cardImporterService.importCards('http://example.com/cards.json');
|
||||
|
||||
// Alpha appears in both SET_A and SET_B; SET_B is later so its version overwrites
|
||||
// 3 unique names: Alpha (deduped), Beta (SET_A only), Zebra (SET_B only)
|
||||
expect(cards).toHaveLength(3);
|
||||
expect(cards.map(c => c.name)).toEqual(['Alpha', 'Beta', 'Zebra']);
|
||||
});
|
||||
|
||||
it('maps set cards and tokens to name arrays', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture));
|
||||
|
||||
const { sets } = await cardImporterService.importCards('http://example.com/cards.json');
|
||||
|
||||
expect(sets[0].cards).toEqual(['Alpha', 'Beta']);
|
||||
expect(sets[0].tokens).toEqual(['Token A']);
|
||||
});
|
||||
|
||||
it('rejects when response is not ok', async () => {
|
||||
mockFetch.mockResolvedValue(failedResponse(404));
|
||||
|
||||
await expect(cardImporterService.importCards('http://example.com/cards.json'))
|
||||
.rejects.toThrow('Card import must be in valid MTG JSON format');
|
||||
});
|
||||
|
||||
it('rejects when Content-Type does not contain application/json', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ 'Content-Type': 'text/html' }),
|
||||
json: () => Promise.resolve({}),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(cardImporterService.importCards('http://example.com/cards.json'))
|
||||
.rejects.toThrow('Card import must be in valid MTG JSON format');
|
||||
});
|
||||
|
||||
it('accepts Content-Type with charset parameter', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture, 'application/json; charset=utf-8'));
|
||||
|
||||
const { cards } = await cardImporterService.importCards('http://example.com/cards.json');
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects when JSON structure is invalid (missing data key)', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ notData: {} }));
|
||||
|
||||
await expect(cardImporterService.importCards('http://example.com/cards.json'))
|
||||
.rejects.toThrow('Card import must be in valid MTG JSON format');
|
||||
});
|
||||
|
||||
it('preserves the original error as cause', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ notData: {} }));
|
||||
|
||||
try {
|
||||
await cardImporterService.importCards('http://example.com/cards.json');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err) {
|
||||
expect((err as Error).cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('importTokens', () => {
|
||||
const validXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<cockatrice_tokens>
|
||||
<card>
|
||||
<name value="Soldier" />
|
||||
<set value="M21" picURL="http://example.com/soldier.png" />
|
||||
<tablerow value="1" />
|
||||
</card>
|
||||
</cockatrice_tokens>`;
|
||||
|
||||
it('fetches and parses valid XML into token objects', async () => {
|
||||
mockFetch.mockResolvedValue(textResponse(validXml));
|
||||
|
||||
const tokens = await cardImporterService.importTokens('http://example.com/tokens.xml');
|
||||
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0]).toHaveProperty('name');
|
||||
});
|
||||
|
||||
it('parses token attributes correctly', async () => {
|
||||
mockFetch.mockResolvedValue(textResponse(validXml));
|
||||
|
||||
const tokens = await cardImporterService.importTokens('http://example.com/tokens.xml');
|
||||
|
||||
const token = tokens[0] as Record<string, any>;
|
||||
expect(token.name.value).toBe('Soldier');
|
||||
expect(token.set.value).toBe('M21');
|
||||
expect(token.set.picURL).toBe('http://example.com/soldier.png');
|
||||
});
|
||||
|
||||
it('rejects when response is not ok', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, text: () => Promise.resolve('') } as unknown as Response);
|
||||
|
||||
await expect(cardImporterService.importTokens('http://example.com/tokens.xml'))
|
||||
.rejects.toThrow('Failed to fetch');
|
||||
});
|
||||
|
||||
it('rejects when XML is malformed', async () => {
|
||||
mockFetch.mockResolvedValue(textResponse('<not-valid-xml>'));
|
||||
|
||||
await expect(cardImporterService.importTokens('http://example.com/tokens.xml'))
|
||||
.rejects.toThrow('Token import must be in valid MTG XML format');
|
||||
});
|
||||
|
||||
it('returns empty array when XML has no card elements', async () => {
|
||||
const emptyXml = '<?xml version="1.0"?><cockatrice_tokens></cockatrice_tokens>';
|
||||
mockFetch.mockResolvedValue(textResponse(emptyXml));
|
||||
|
||||
const tokens = await cardImporterService.importTokens('http://example.com/tokens.xml');
|
||||
expect(tokens).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves the original error as cause on parse failure', async () => {
|
||||
mockFetch.mockResolvedValue(textResponse('<not-valid-xml>'));
|
||||
|
||||
try {
|
||||
await cardImporterService.importTokens('http://example.com/tokens.xml');
|
||||
expect.fail('should have thrown');
|
||||
} catch (err) {
|
||||
expect((err as Error).cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseXmlAttributes', () => {
|
||||
function parseXml(xml: string) {
|
||||
const dom = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
return (cardImporterService as any).parseXmlAttributes(dom.documentElement);
|
||||
}
|
||||
|
||||
it('parses simple child elements into key-value pairs', () => {
|
||||
const result = parseXml('<card><name value="Soldier" /></card>');
|
||||
expect(result.name.value).toBe('Soldier');
|
||||
});
|
||||
|
||||
it('parses nested elements recursively', () => {
|
||||
const result = parseXml('<card><prop><cmc value="2" /></prop></card>');
|
||||
expect(result.prop.value).toHaveProperty('cmc');
|
||||
expect(result.prop.value.cmc.value).toBe('2');
|
||||
});
|
||||
|
||||
it('includes XML attributes alongside value', () => {
|
||||
const result = parseXml('<card><set value="M21" picURL="http://img.png" /></card>');
|
||||
expect(result.set.value).toBe('M21');
|
||||
expect(result.set.picURL).toBe('http://img.png');
|
||||
});
|
||||
|
||||
it('converts duplicate tag names into an array preserving all values', () => {
|
||||
const result = parseXml(
|
||||
'<card><related value="Token A" /><related value="Token B" /></card>'
|
||||
);
|
||||
expect(Array.isArray(result.related)).toBe(true);
|
||||
expect(result.related).toHaveLength(2);
|
||||
expect(result.related[0].value).toBe('Token A');
|
||||
expect(result.related[1].value).toBe('Token B');
|
||||
});
|
||||
|
||||
it('appends to existing array for 3+ duplicate tag names', () => {
|
||||
const result = parseXml(
|
||||
'<card><set value="A" /><set value="B" /><set value="C" /></card>'
|
||||
);
|
||||
expect(Array.isArray(result.set)).toBe(true);
|
||||
expect(result.set).toHaveLength(3);
|
||||
expect(result.set[0].value).toBe('A');
|
||||
expect(result.set[1].value).toBe('B');
|
||||
expect(result.set[2].value).toBe('C');
|
||||
});
|
||||
|
||||
it('reads innerHTML as value for leaf elements without children', () => {
|
||||
const result = parseXml('<card><text>Some card text</text></card>');
|
||||
expect(result.text.value).toBe('Some card text');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,19 @@
|
|||
// Fetch and parse card sets
|
||||
|
||||
import { App } from '@app/types';
|
||||
|
||||
class CardImporterService {
|
||||
importCards(url): Promise<any> {
|
||||
importCards(url: string): Promise<{ cards: App.Card[]; sets: App.Set[] }> {
|
||||
const error = 'Card import must be in valid MTG JSON format';
|
||||
|
||||
return fetch(url)
|
||||
.then(response => {
|
||||
if (response.headers.get('Content-Type') !== 'application/json') {
|
||||
if (!response.ok) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') ?? '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
|
|
@ -34,13 +41,13 @@ class CardImporterService {
|
|||
.map(key => unsortedCards[key]);
|
||||
|
||||
return { cards, sets };
|
||||
} catch {
|
||||
throw new Error(error);
|
||||
} catch (err) {
|
||||
throw new Error(error, { cause: err });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
importTokens(url): Promise<any> {
|
||||
importTokens(url: string): Promise<Record<string, unknown>[]> {
|
||||
const error = 'Token import must be in valid MTG XML format';
|
||||
|
||||
return fetch(url)
|
||||
|
|
@ -56,13 +63,17 @@ class CardImporterService {
|
|||
const parser = new DOMParser();
|
||||
const dom = parser.parseFromString(xmlString, 'application/xml');
|
||||
|
||||
if (dom.querySelector('parsererror')) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const tokens = Array.from(dom.querySelectorAll('card')).map(
|
||||
(tokenElement) => this.parseXmlAttributes(tokenElement)
|
||||
);
|
||||
|
||||
return tokens;
|
||||
} catch {
|
||||
throw new Error(error);
|
||||
} catch (err) {
|
||||
throw new Error(error, { cause: err });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -85,12 +96,12 @@ 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)
|
||||
} else {
|
||||
attributes[child.tagName] = [parsedAttributes];
|
||||
attributes[child.tagName] = [attributes[child.tagName], parsedAttributes];
|
||||
}
|
||||
} else {
|
||||
attributes[child.tagName] = parsedAttributes;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue