From dcd6dc00f4fbd715f45e79eb14f5ce15eef872da Mon Sep 17 00:00:00 2001 From: seavor Date: Sat, 18 Apr 2026 01:36:37 -0500 Subject: [PATCH 1/3] harden --- webclient/.env | 1 + webclient/src/__test-utils__/index.ts | 3 + webclient/src/__test-utils__/mockWebClient.ts | 56 ++++ .../__test-utils__/renderWithProviders.tsx | 71 +++++ webclient/src/__test-utils__/storeFixtures.ts | 123 +++++++++ .../src/api/response/GameResponseImpl.ts | 4 +- .../src/components/Guard/AuthGuard.spec.tsx | 33 +++ webclient/src/components/Guard/AuthGuard.tsx | 2 +- .../src/components/Guard/ModGuard.spec.tsx | 38 +++ .../components/InputField/InputField.spec.tsx | 30 ++ .../src/components/KnownHosts/KnownHosts.tsx | 8 +- .../src/components/Message/Message.spec.tsx | 19 ++ .../ThreePaneLayout/ThreePaneLayout.tsx | 3 +- .../UserDisplay/UserDisplay.spec.tsx | 46 +++ .../VirtualList/VirtualList.spec.tsx | 21 ++ .../components/VirtualList/VirtualList.tsx | 2 +- webclient/src/containers/Account/Account.tsx | 35 ++- webclient/src/containers/Login/Login.tsx | 10 +- webclient/src/containers/Logs/Logs.tsx | 3 +- .../src/containers/Room/OpenGames.spec.tsx | 35 +++ webclient/src/containers/Room/OpenGames.tsx | 15 +- webclient/src/containers/Room/Room.tsx | 8 +- webclient/src/containers/Server/Server.tsx | 6 +- webclient/src/forms/LoginForm/LoginForm.tsx | 2 - .../src/forms/RegisterForm/RegisterForm.tsx | 11 +- webclient/src/hooks/index.ts | 1 - webclient/src/hooks/useAutoConnect.spec.ts | 86 ++++++ webclient/src/hooks/useAutoConnect.ts | 32 +-- webclient/src/hooks/useDebounce.ts | 34 --- .../hooks/useFireOnce/useFireOnce.spec.tsx | 54 ++++ .../src/hooks/useFireOnce/useFireOnce.ts | 23 +- webclient/src/hooks/useLocaleSort.spec.ts | 80 ++++++ webclient/src/hooks/useLocaleSort.ts | 17 +- webclient/src/hooks/useReduxEffect.spec.tsx | 138 +++++++++ webclient/src/hooks/useWebClient.spec.tsx | 48 ++++ .../src/services/CardImporterService.spec.ts | 261 ++++++++++++++++++ webclient/src/services/CardImporterService.ts | 27 +- webclient/src/services/HostService.ts | 47 ++++ .../src/services/dexie/DexieDTOs/CardDTO.ts | 5 +- .../src/services/dexie/DexieDTOs/SetDTO.ts | 5 +- .../services/dexie/DexieDTOs/SettingDTO.ts | 4 +- .../src/services/dexie/DexieDTOs/TokenDTO.ts | 5 +- .../services/dexie/DexieSchemas/v1.schema.ts | 4 +- webclient/src/services/index.ts | 1 + webclient/src/setupTests.ts | 37 +++ .../src/store/actions/actionReducer.spec.ts | 26 +- webclient/src/store/actions/actionReducer.ts | 14 +- .../src/store/common/normalizers.spec.ts | 18 +- webclient/src/store/common/normalizers.ts | 9 +- .../src/store/game/game.dispatch.spec.ts | 4 +- webclient/src/store/game/game.dispatch.ts | 4 +- webclient/src/store/game/game.reducer.spec.ts | 126 ++++++++- webclient/src/store/game/game.reducer.ts | 38 +-- .../src/store/rooms/rooms.actions.spec.ts | 3 +- .../src/store/rooms/rooms.dispatch.spec.ts | 2 +- webclient/src/store/rooms/rooms.dispatch.tsx | 2 +- .../src/store/rooms/rooms.reducer.spec.ts | 1 + webclient/src/store/rooms/rooms.reducer.tsx | 21 +- .../store/server/__mocks__/server-fixtures.ts | 2 +- .../src/store/server/server.actions.spec.ts | 4 +- .../src/store/server/server.dispatch.spec.ts | 8 +- webclient/src/store/server/server.dispatch.ts | 4 +- .../src/store/server/server.interfaces.ts | 2 +- .../src/store/server/server.reducer.spec.ts | 44 ++- webclient/src/store/server/server.reducer.ts | 90 +++--- .../src/store/server/server.selectors.spec.ts | 18 +- .../src/store/server/server.selectors.ts | 4 +- webclient/src/types/app.ts | 2 +- webclient/src/types/countries.ts | 4 +- webclient/src/types/enriched.ts | 25 +- ...nstants.spec.ts => regex-patterns.spec.ts} | 2 +- .../types/{constants.ts => regex-patterns.ts} | 0 webclient/src/types/server.ts | 59 +--- .../session/sessionCommands-complex.spec.ts | 16 +- .../websocket/events/game/gameEvents.spec.ts | 4 +- .../src/websocket/events/game/gameSay.ts | 2 +- .../websocket/interfaces/WebClientResponse.ts | 2 +- .../services/KeepAliveService.spec.ts | 9 + .../websocket/services/KeepAliveService.ts | 1 + .../services/ProtobufService.spec.ts | 65 ++++- .../src/websocket/services/ProtobufService.ts | 49 ++-- .../services/WebSocketService.spec.ts | 6 + .../websocket/services/WebSocketService.ts | 3 + 83 files changed, 1797 insertions(+), 390 deletions(-) create mode 100644 webclient/src/__test-utils__/mockWebClient.ts create mode 100644 webclient/src/__test-utils__/renderWithProviders.tsx create mode 100644 webclient/src/__test-utils__/storeFixtures.ts create mode 100644 webclient/src/components/Guard/AuthGuard.spec.tsx create mode 100644 webclient/src/components/Guard/ModGuard.spec.tsx create mode 100644 webclient/src/components/InputField/InputField.spec.tsx create mode 100644 webclient/src/components/Message/Message.spec.tsx create mode 100644 webclient/src/components/UserDisplay/UserDisplay.spec.tsx create mode 100644 webclient/src/components/VirtualList/VirtualList.spec.tsx create mode 100644 webclient/src/containers/Room/OpenGames.spec.tsx create mode 100644 webclient/src/hooks/useAutoConnect.spec.ts delete mode 100644 webclient/src/hooks/useDebounce.ts create mode 100644 webclient/src/hooks/useLocaleSort.spec.ts create mode 100644 webclient/src/hooks/useReduxEffect.spec.tsx create mode 100644 webclient/src/hooks/useWebClient.spec.tsx create mode 100644 webclient/src/services/CardImporterService.spec.ts create mode 100644 webclient/src/services/HostService.ts rename webclient/src/types/{constants.spec.ts => regex-patterns.spec.ts} (98%) rename webclient/src/types/{constants.ts => regex-patterns.ts} (100%) diff --git a/webclient/.env b/webclient/.env index 8592b28b4..be70fd388 100644 --- a/webclient/.env +++ b/webclient/.env @@ -1 +1,2 @@ # Future template for server admin configuration +NODE_OPTIONS=--max-old-space-size=8192 \ No newline at end of file diff --git a/webclient/src/__test-utils__/index.ts b/webclient/src/__test-utils__/index.ts index 6d606210a..4deac33d2 100644 --- a/webclient/src/__test-utils__/index.ts +++ b/webclient/src/__test-utils__/index.ts @@ -1 +1,4 @@ export { withMockLocation, withEventRegistry } from './globalGuards'; +export { renderWithProviders } from './renderWithProviders'; +export { createMockWebClient } from './mockWebClient'; +export { disconnectedState, connectedState, connectedWithRoomsState, makeUser } from './storeFixtures'; diff --git a/webclient/src/__test-utils__/mockWebClient.ts b/webclient/src/__test-utils__/mockWebClient.ts new file mode 100644 index 000000000..3faafe391 --- /dev/null +++ b/webclient/src/__test-utils__/mockWebClient.ts @@ -0,0 +1,56 @@ +import type { WebClient } from '@app/websocket'; + +/** + * Creates a mock WebClient whose `request` property has vi.fn() stubs + * for every service method that containers/forms call. Inject this into + * tests via `renderWithProviders({ webClient: createMockWebClient() })`. + */ +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; +} diff --git a/webclient/src/__test-utils__/renderWithProviders.tsx b/webclient/src/__test-utils__/renderWithProviders.tsx new file mode 100644 index 000000000..c7ae2d242 --- /dev/null +++ b/webclient/src/__test-utils__/renderWithProviders.tsx @@ -0,0 +1,71 @@ +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'; + +// Minimal i18n instance for tests — returns keys as-is. +const testI18n = i18n.createInstance(); +testI18n.use(initReactI18next).init({ + lng: 'en', + resources: {}, + fallbackLng: 'en', + interpolation: { escapeValue: false }, +}); + +function createTestStore(preloadedState?: Partial) { + return configureStore({ + reducer: { + games: gamesReducer, + rooms: roomsReducer, + server: serverReducer, + action: actionReducer, + }, + preloadedState: preloadedState as any, + }); +} + +interface ExtendedRenderOptions extends Omit { + preloadedState?: Partial; + store?: EnhancedStore; + route?: string; +} + +export function renderWithProviders( + ui: ReactElement, + { + preloadedState, + store = createTestStore(preloadedState), + route = '/', + ...renderOptions + }: ExtendedRenderOptions = {}, +) { + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + + {children} + + + + + ); + } + + return { + store, + ...render(ui, { wrapper: Wrapper, ...renderOptions }), + }; +} diff --git a/webclient/src/__test-utils__/storeFixtures.ts b/webclient/src/__test-utils__/storeFixtures.ts new file mode 100644 index 000000000..9733fe6ef --- /dev/null +++ b/webclient/src/__test-utils__/storeFixtures.ts @@ -0,0 +1,123 @@ +import { App, Data, Enriched } from '@app/types'; +import type { RootState } from '../store/store'; + +/** + * Create a minimal ServerInfo_User object for testing. + */ +function makeUser(overrides: Partial = {}): 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 = { + server: { + initialized: false, + buddyList: {}, + ignoreList: {}, + status: { + connectionAttemptMade: false, + state: Enriched.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 = { + ...disconnectedState, + server: { + ...(disconnectedState.server as any), + initialized: true, + status: { + connectionAttemptMade: true, + state: Enriched.StatusEnum.LOGGED_IN, + description: null, + }, + info: { + message: 'Welcome', + 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 = { + ...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 }; diff --git a/webclient/src/api/response/GameResponseImpl.ts b/webclient/src/api/response/GameResponseImpl.ts index cb8b9fef7..01978e818 100644 --- a/webclient/src/api/response/GameResponseImpl.ts +++ b/webclient/src/api/response/GameResponseImpl.ts @@ -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 { diff --git a/webclient/src/components/Guard/AuthGuard.spec.tsx b/webclient/src/components/Guard/AuthGuard.spec.tsx new file mode 100644 index 000000000..92368cbab --- /dev/null +++ b/webclient/src/components/Guard/AuthGuard.spec.tsx @@ -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(); + return { ...actual, useWebClient: vi.fn(() => ({})) }; +}); + +describe('AuthGuard', () => { + it('redirects to LOGIN when disconnected', () => { + renderWithProviders(, { + 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(, { + 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(''); + }); +}); diff --git a/webclient/src/components/Guard/AuthGuard.tsx b/webclient/src/components/Guard/AuthGuard.tsx index 4bbb8e1d5..897556613 100644 --- a/webclient/src/components/Guard/AuthGuard.tsx +++ b/webclient/src/components/Guard/AuthGuard.tsx @@ -8,7 +8,7 @@ const AuthGuard = () => { const isConnected = useAppSelector(ServerSelectors.getIsConnected); return !isConnected ? - :
; + : <>; }; export default AuthGuard; diff --git a/webclient/src/components/Guard/ModGuard.spec.tsx b/webclient/src/components/Guard/ModGuard.spec.tsx new file mode 100644 index 000000000..962e74b33 --- /dev/null +++ b/webclient/src/components/Guard/ModGuard.spec.tsx @@ -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(); + return { ...actual, useWebClient: vi.fn(() => ({})) }; +}); + +describe('ModGuard', () => { + it('redirects when user is not a moderator', () => { + const { container } = renderWithProviders(, { + 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(, { + preloadedState: { + ...connectedState, + server: { + ...(connectedState.server as any), + user: modUser, + }, + }, + route: '/logs', + }); + + expect(container.textContent).toBe(''); + }); +}); diff --git a/webclient/src/components/InputField/InputField.spec.tsx b/webclient/src/components/InputField/InputField.spec.tsx new file mode 100644 index 000000000..1f535a175 --- /dev/null +++ b/webclient/src/components/InputField/InputField.spec.tsx @@ -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(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('shows error when touched and has error', () => { + render(); + expect(screen.getByText('Required')).toBeInTheDocument(); + }); + + it('shows warning when touched and has warning', () => { + render(); + expect(screen.getByText('Weak password')).toBeInTheDocument(); + }); + + it('does not show validation messages when not touched', () => { + render(); + expect(screen.queryByText('Required')).not.toBeInTheDocument(); + }); +}); diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index e233bc5ae..c16c4f67a 100644 --- a/webclient/src/components/KnownHosts/KnownHosts.tsx +++ b/webclient/src/components/KnownHosts/KnownHosts.tsx @@ -16,7 +16,7 @@ import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; import { useWebClient } from '@app/hooks'; import { KnownHostDialog } from '@app/dialogs'; import { useReduxEffect } from '@app/hooks'; -import { HostDTO } from '@app/services'; +import { DefaultHosts, HostDTO, getHostPort } from '@app/services'; import { ServerTypes } from '@app/store'; import { App } from '@app/types'; import Toast from '../Toast/Toast'; @@ -87,7 +87,7 @@ const KnownHosts = (props) => { if (!hosts?.length) { // @TODO: find a better pattern to seeding default data in indexedDB - await HostDTO.bulkAdd(App.DefaultHosts); + await HostDTO.bulkAdd(DefaultHosts); loadKnownHosts(); } else { const selectedHost = hosts.find(({ lastSelected }) => lastSelected) || hosts[0]; @@ -197,7 +197,7 @@ const KnownHosts = (props) => { const testConnection = () => { setTestingConnection(TestConnection.TESTING); - const options = { ...App.getHostPort(hostsState.selectedHost) }; + const options = { ...getHostPort(hostsState.selectedHost) }; webClient.request.authentication.testConnection(options); } @@ -238,7 +238,7 @@ const KnownHosts = (props) => { { hostsState.hosts.map((host, index) => { - const hostPort = App.getHostPort(hostsState.hosts[index]); + const hostPort = getHostPort(hostsState.hosts[index]); return ( diff --git a/webclient/src/components/Message/Message.spec.tsx b/webclient/src/components/Message/Message.spec.tsx new file mode 100644 index 000000000..542760127 --- /dev/null +++ b/webclient/src/components/Message/Message.spec.tsx @@ -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(); + + expect(screen.getByText('Hello world')).toBeInTheDocument(); + }); + + it('renders the message container', () => { + const message = { message: 'Test message' }; + const { container } = renderWithProviders(); + + expect(container.querySelector('.message')).toBeInTheDocument(); + }); +}); diff --git a/webclient/src/components/ThreePaneLayout/ThreePaneLayout.tsx b/webclient/src/components/ThreePaneLayout/ThreePaneLayout.tsx index 287e58382..0e12cd8ec 100644 --- a/webclient/src/components/ThreePaneLayout/ThreePaneLayout.tsx +++ b/webclient/src/components/ThreePaneLayout/ThreePaneLayout.tsx @@ -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 (
diff --git a/webclient/src/components/UserDisplay/UserDisplay.spec.tsx b/webclient/src/components/UserDisplay/UserDisplay.spec.tsx new file mode 100644 index 000000000..67c4dce8d --- /dev/null +++ b/webclient/src/components/UserDisplay/UserDisplay.spec.tsx @@ -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(); + 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(, { + preloadedState: connectedState, + }); + + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + it('renders country flag image', () => { + const user = makeUser({ name: 'TestPlayer', country: 'us' }); + renderWithProviders(, { + 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(, { + preloadedState: connectedState, + }); + + const link = screen.getByRole('link', { name: /TestPlayer/ }); + expect(link).toHaveAttribute('href', '/player/TestPlayer'); + }); +}); diff --git a/webclient/src/components/VirtualList/VirtualList.spec.tsx b/webclient/src/components/VirtualList/VirtualList.spec.tsx new file mode 100644 index 000000000..1e59b7a1a --- /dev/null +++ b/webclient/src/components/VirtualList/VirtualList.spec.tsx @@ -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(); + expect(container.querySelector('.virtual-list')).toBeInTheDocument(); + }); + + it('accepts className as a string', () => { + const { container } = render(); + expect(container.querySelector('.custom-class')).toBeInTheDocument(); + }); + + it('applies empty string as default className (not object)', () => { + const { container } = render(); + const list = container.querySelector('.virtual-list__list'); + // className should not contain "[object Object]" + expect(list?.className).not.toContain('[object Object]'); + }); +}); diff --git a/webclient/src/components/VirtualList/VirtualList.tsx b/webclient/src/components/VirtualList/VirtualList.tsx index fc2868f2d..90c15436e 100644 --- a/webclient/src/components/VirtualList/VirtualList.tsx +++ b/webclient/src/components/VirtualList/VirtualList.tsx @@ -15,7 +15,7 @@ const Row = ({ index, style, items }: RowComponentProps) => (
); -const VirtualList = ({ items, className = {}, size = 30 }) => ( +const VirtualList = ({ items, className = '', size = 30 }) => (
className={`virtual-list__list ${className}`} diff --git a/webclient/src/containers/Account/Account.tsx b/webclient/src/containers/Account/Account.tsx index ee06f590a..3902b31d6 100644 --- a/webclient/src/containers/Account/Account.tsx +++ b/webclient/src/containers/Account/Account.tsx @@ -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 = () => {
-
+
Buddies Online: ?/{buddyList.length}
( - + )) } /> -
+
-
+
Ignored Users Online: ?/{ignoreList.length}
( - + )) } /> -
+
- {name} + { avatarUrl && {name} }

{name}

Location: ({country?.toUpperCase()})

User Level: {userLevel}

@@ -95,7 +108,7 @@ const Account = () => { diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index 70896ce77..cf0a20420 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -11,7 +11,7 @@ import { LanguageDropdown } from '@app/components'; import { LoginForm } from '@app/forms'; import { useReduxEffect, useFireOnce, useWebClient } from '@app/hooks'; import { Images } from '@app/images'; -import { HostDTO, serverProps } from '@app/services'; +import { HostDTO, getHostPort, serverProps } from '@app/services'; import { App, Enriched } from '@app/types'; import { ServerSelectors, ServerTypes } from '@app/store'; import Layout from '../Layout/Layout'; @@ -125,7 +125,7 @@ const Login = () => { const { userName, password, selectedHost, remember } = loginForm; const options: Omit = { - ...App.getHostPort(selectedHost), + ...getHostPort(selectedHost), userName, password, }; @@ -154,7 +154,7 @@ const Login = () => { const { userName, password, email, country, realName, selectedHost } = registerForm; webClient.request.authentication.register({ - ...App.getHostPort(selectedHost), + ...getHostPort(selectedHost), userName, password, email, @@ -177,7 +177,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 +188,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 }); }; diff --git a/webclient/src/containers/Logs/Logs.tsx b/webclient/src/containers/Logs/Logs.tsx index 8ab9a181b..bf2fe9a6d 100644 --- a/webclient/src/containers/Logs/Logs.tsx +++ b/webclient/src/containers/Logs/Logs.tsx @@ -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'; diff --git a/webclient/src/containers/Room/OpenGames.spec.tsx b/webclient/src/containers/Room/OpenGames.spec.tsx new file mode 100644 index 000000000..6da3edf90 --- /dev/null +++ b/webclient/src/containers/Room/OpenGames.spec.tsx @@ -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(); + return { ...actual, useWebClient: vi.fn(() => ({})) }; +}); + +describe('OpenGames', () => { + const roomWithGames = { + info: { roomId: 1, name: 'Main Room' }, + }; + + it('renders the games table headers', () => { + renderWithProviders(, { + 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(, { + preloadedState: connectedWithRoomsState, + }); + + expect(container.querySelector('.games')).toBeInTheDocument(); + }); +}); diff --git a/webclient/src/containers/Room/OpenGames.tsx b/webclient/src/containers/Room/OpenGames.tsx index d5ab68f6f..59e721aa1 100644 --- a/webclient/src/containers/Room/OpenGames.tsx +++ b/webclient/src/containers/Room/OpenGames.tsx @@ -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 ( diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index 5877c57b5..d2f8455bc 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -25,7 +25,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 +37,10 @@ const Room = () => { } }, [joined]); + if (!room) { + return null; + } + const handleRoomSay = ({ message }) => { if (message) { webClient.request.rooms.roomSay(roomId, message); @@ -78,7 +82,7 @@ const Room = () => { ( - + )) } diff --git a/webclient/src/containers/Server/Server.tsx b/webclient/src/containers/Server/Server.tsx index d19e829bc..1620138fc 100644 --- a/webclient/src/containers/Server/Server.tsx +++ b/webclient/src/containers/Server/Server.tsx @@ -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={( + {/* message is sanitized via DOMPurify in websocket/events/session/serverMessage.ts */}
)} @@ -51,7 +51,7 @@ const Server = () => {
( - + )) } diff --git a/webclient/src/forms/LoginForm/LoginForm.tsx b/webclient/src/forms/LoginForm/LoginForm.tsx index 4d6ee4e37..f46764126 100644 --- a/webclient/src/forms/LoginForm/LoginForm.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.tsx @@ -43,8 +43,6 @@ const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginForm const handleOnSubmit = ({ userName, ...values }) => { userName = userName?.trim(); - console.log(userName, values); - onSubmit({ userName, ...values }); } diff --git a/webclient/src/forms/RegisterForm/RegisterForm.tsx b/webclient/src/forms/RegisterForm/RegisterForm.tsx index 66d8916be..3754c857c 100644 --- a/webclient/src/forms/RegisterForm/RegisterForm.tsx +++ b/webclient/src/forms/RegisterForm/RegisterForm.tsx @@ -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) => {
{({ 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 ( <> diff --git a/webclient/src/hooks/index.ts b/webclient/src/hooks/index.ts index a9385d50b..7c8e7c3ef 100644 --- a/webclient/src/hooks/index.ts +++ b/webclient/src/hooks/index.ts @@ -1,6 +1,5 @@ export * from './useAutoConnect'; export * from './useFireOnce'; -export * from './useDebounce'; export * from './useLocaleSort'; export * from './useReduxEffect'; export * from './useWebClient'; diff --git a/webclient/src/hooks/useAutoConnect.spec.ts b/webclient/src/hooks/useAutoConnect.spec.ts new file mode 100644 index 000000000..d6b09d0e8 --- /dev/null +++ b/webclient/src/hooks/useAutoConnect.spec.ts @@ -0,0 +1,86 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useAutoConnect } from './useAutoConnect'; + +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' }, +})); + +describe('useAutoConnect', () => { + beforeEach(() => { + storedSetting = null; + mockSave.mockClear(); + }); + + test('returns undefined initially, then resolves to stored autoConnect value', async () => { + storedSetting = { user: '*app', autoConnect: true, save: mockSave }; + + const { result } = renderHook(() => useAutoConnect()); + + expect(result.current[0]).toBeUndefined(); + + await waitFor(() => { + expect(result.current[0]).toBe(true); + }); + }); + + test('creates and saves a new SettingDTO when none exists', async () => { + storedSetting = null; + + const { result } = renderHook(() => useAutoConnect()); + + await waitFor(() => { + expect(result.current[0]).toBe(false); + }); + + // save() called once for the newly created setting + expect(mockSave).toHaveBeenCalledTimes(1); + }); + + test('persists when setAutoConnect is called', async () => { + storedSetting = { user: '*app', autoConnect: false, save: mockSave }; + + const { result } = renderHook(() => useAutoConnect()); + + await waitFor(() => { + expect(result.current[0]).toBe(false); + }); + + mockSave.mockClear(); + + act(() => { + result.current[1](true); + }); + + await waitFor(() => { + expect(result.current[0]).toBe(true); + }); + + expect(mockSave).toHaveBeenCalled(); + }); + + test('does not save redundantly on initial mount when setting exists', async () => { + storedSetting = { user: '*app', autoConnect: true, save: mockSave }; + + renderHook(() => useAutoConnect()); + + await waitFor(() => { + expect(mockSave).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/hooks/useAutoConnect.ts b/webclient/src/hooks/useAutoConnect.ts index f8daa78aa..a24a0b80f 100644 --- a/webclient/src/hooks/useAutoConnect.ts +++ b/webclient/src/hooks/useAutoConnect.ts @@ -1,35 +1,33 @@ -import { useEffect, useState } from 'react'; +import { Dispatch, SetStateAction, useEffect, useRef, 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); +export function useAutoConnect(): [boolean | undefined, Dispatch>] { + const [setting, setSetting] = useState(undefined); + const [autoConnect, setAutoConnect] = useState(undefined); + const prevAutoConnectRef = useRef(undefined); useEffect(() => { - SettingDTO.get(App.APP_USER).then((setting: SettingDTO) => { - if (!setting) { - setting = new SettingDTO(App.APP_USER); - setting.save(); + SettingDTO.get(App.APP_USER).then((loaded: SettingDTO) => { + if (!loaded) { + loaded = new SettingDTO(App.APP_USER); + loaded.save(); } - setSetting(setting); + setSetting(loaded); + setAutoConnect(loaded.autoConnect); + prevAutoConnectRef.current = loaded.autoConnect; }); }, []); useEffect(() => { - if (setting) { - setAutoConnect(setting.autoConnect); - } - }, [setting]); - - useEffect(() => { - if (setting) { + if (setting && autoConnect !== prevAutoConnectRef.current) { + prevAutoConnectRef.current = autoConnect; setting.autoConnect = autoConnect; setting.save(); } - }, [setting, autoConnect]); + }, [autoConnect]); return [autoConnect, setAutoConnect]; } diff --git a/webclient/src/hooks/useDebounce.ts b/webclient/src/hooks/useDebounce.ts deleted file mode 100644 index f8b62e565..000000000 --- a/webclient/src/hooks/useDebounce.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useCallback } from 'react'; - -type UseDebounceType = (...args: any) => any; -const DEBOUNCE_DELAY = 250; - -export interface DebouncedFn { - (...args: Parameters): void; - cancel(): void; -} - -function debounce(fn: T, timeout: number): DebouncedFn { - let timer: ReturnType | undefined; - const debounced = ((...args: Parameters): void => { - if (timer !== undefined) { - clearTimeout(timer); - } - timer = setTimeout(() => fn(...args), timeout); - }) as DebouncedFn; - debounced.cancel = (): void => { - if (timer !== undefined) { - clearTimeout(timer); - timer = undefined; - } - }; - return debounced; -} - -export function useDebounce( - fn: T, - deps: any[] = [], - timeout: number = DEBOUNCE_DELAY -): DebouncedFn { - return useCallback(debounce(fn, timeout), deps); -} diff --git a/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx b/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx index 9c87ccf64..501bb9403 100644 --- a/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx +++ b/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx @@ -2,6 +2,8 @@ import { render, fireEvent, waitFor, + renderHook, + act, } from '@testing-library/react'; import { useFireOnce } from './useFireOnce'; @@ -98,4 +100,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'); + }); }); diff --git a/webclient/src/hooks/useFireOnce/useFireOnce.ts b/webclient/src/hooks/useFireOnce/useFireOnce.ts index 54c184160..e4f5265de 100644 --- a/webclient/src/hooks/useFireOnce/useFireOnce.ts +++ b/webclient/src/hooks/useFireOnce/useFireOnce.ts @@ -1,15 +1,20 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; type UseFireOnceType = (...args: any) => any; -export function useFireOnce(fn: T): [boolean, any, any] { - const [actionIsInFlight, setActionIsInFlight] = useState(false) - const handleFireOnce = useCallback((args) => { +export function useFireOnce(fn: T): [boolean, () => void, (...args: Parameters) => void] { + const [actionIsInFlight, setActionIsInFlight] = useState(false); + const fnRef = useRef(fn); + fnRef.current = fn; + + const handleFireOnce = useCallback((...args: Parameters) => { setActionIsInFlight(true); - fn(args); - }, []) - function resetInFlightStatus() { + fnRef.current(...args); + }, []); + + const resetInFlightStatus = useCallback(() => { setActionIsInFlight(false); - } - return [actionIsInFlight, resetInFlightStatus, handleFireOnce] + }, []); + + return [actionIsInFlight, resetInFlightStatus, handleFireOnce]; } diff --git a/webclient/src/hooks/useLocaleSort.spec.ts b/webclient/src/hooks/useLocaleSort.spec.ts new file mode 100644 index 000000000..f13bdea46 --- /dev/null +++ b/webclient/src/hooks/useLocaleSort.spec.ts @@ -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 = { 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')); + }); +}); diff --git a/webclient/src/hooks/useLocaleSort.ts b/webclient/src/hooks/useLocaleSort.ts index 219292ed7..0b463a34f 100644 --- a/webclient/src/hooks/useLocaleSort.ts +++ b/webclient/src/hooks/useLocaleSort.ts @@ -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(arr); - const [sorted, setSorted] = useState([]); - +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]); } diff --git a/webclient/src/hooks/useReduxEffect.spec.tsx b/webclient/src/hooks/useReduxEffect.spec.tsx new file mode 100644 index 000000000..8b71d63ea --- /dev/null +++ b/webclient/src/hooks/useReduxEffect.spec.tsx @@ -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) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +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 ( + + {children} + + ); + } + + 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); + }); + +}); diff --git a/webclient/src/hooks/useWebClient.spec.tsx b/webclient/src/hooks/useWebClient.spec.tsx new file mode 100644 index 000000000..629de8714 --- /dev/null +++ b/webclient/src/hooks/useWebClient.spec.tsx @@ -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 {children}; +} + +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); + }); +}); diff --git a/webclient/src/services/CardImporterService.spec.ts b/webclient/src/services/CardImporterService.spec.ts new file mode 100644 index 000000000..4880230ab --- /dev/null +++ b/webclient/src/services/CardImporterService.spec.ts @@ -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 = ` + + + + + + +`; + + 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; + 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('')); + + 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 = ''; + 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('')); + + 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(''); + expect(result.name.value).toBe('Soldier'); + }); + + it('parses nested elements recursively', () => { + const result = parseXml(''); + expect(result.prop.value).toHaveProperty('cmc'); + expect(result.prop.value.cmc.value).toBe('2'); + }); + + it('includes XML attributes alongside value', () => { + const result = parseXml(''); + 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( + '' + ); + 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( + '' + ); + 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('Some card text'); + expect(result.text.value).toBe('Some card text'); + }); + }); +}); diff --git a/webclient/src/services/CardImporterService.ts b/webclient/src/services/CardImporterService.ts index 27014b57c..984de9005 100644 --- a/webclient/src/services/CardImporterService.ts +++ b/webclient/src/services/CardImporterService.ts @@ -1,12 +1,19 @@ // Fetch and parse card sets +import { App } from '@app/types'; + class CardImporterService { - importCards(url): Promise { + 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 { + importTokens(url: string): Promise[]> { 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 }); } }) } @@ -90,7 +101,7 @@ class CardImporterService { 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; diff --git a/webclient/src/services/HostService.ts b/webclient/src/services/HostService.ts new file mode 100644 index 000000000..c17c2a3cf --- /dev/null +++ b/webclient/src/services/HostService.ts @@ -0,0 +1,47 @@ +import { App } from '@app/types'; + +export const DefaultHosts: App.Host[] = [ + { + name: 'Chickatrice', + host: 'mtg.chickatrice.net', + port: '443', + localPort: '4748', + editable: false, + }, + { + name: 'Rooster', + host: 'server.cockatrice.us/servatrice', + port: '4748', + localHost: 'server.cockatrice.us', + editable: false, + }, + { + name: 'Rooster Beta', + host: 'beta.cockatrice.us/servatrice', + port: '4748', + localHost: 'beta.cockatrice.us', + editable: false, + }, + { + name: 'Tetrarch', + host: 'mtg.tetrarch.co/servatrice', + port: '443', + editable: false, + }, +]; + +export const getHostPort = (host: App.Host): { host: string, port: string } => { + const isLocal = window.location.hostname === 'localhost'; + + if (!host) { + return { + host: '', + port: '' + }; + } + + return { + host: !isLocal ? host.host : host.localHost || host.host, + port: !isLocal ? host.port : host.localPort || host.port, + }; +}; diff --git a/webclient/src/services/dexie/DexieDTOs/CardDTO.ts b/webclient/src/services/dexie/DexieDTOs/CardDTO.ts index dd43681ea..863c5107a 100644 --- a/webclient/src/services/dexie/DexieDTOs/CardDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/CardDTO.ts @@ -1,3 +1,4 @@ +import { IndexableType } from 'dexie'; import { App } from '@app/types'; import { dexieService } from '../DexieService'; @@ -7,11 +8,11 @@ export class CardDTO extends App.Card { return dexieService.cards.put(this); } - static get(name) { + static get(name: string) { return dexieService.cards.where('name').equalsIgnoreCase(name).first(); } - static bulkAdd(cards: CardDTO[]): Promise { + static bulkAdd(cards: CardDTO[]): Promise { return dexieService.cards.bulkPut(cards); } }; diff --git a/webclient/src/services/dexie/DexieDTOs/SetDTO.ts b/webclient/src/services/dexie/DexieDTOs/SetDTO.ts index 3bd02903c..ac90e19b7 100644 --- a/webclient/src/services/dexie/DexieDTOs/SetDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/SetDTO.ts @@ -1,3 +1,4 @@ +import { IndexableType } from 'dexie'; import { App } from '@app/types'; import { dexieService } from '../DexieService'; @@ -7,11 +8,11 @@ export class SetDTO extends App.Set { return dexieService.sets.put(this); } - static get(name) { + static get(name: string) { return dexieService.sets.where('name').equalsIgnoreCase(name).first(); } - static bulkAdd(sets: SetDTO[]): Promise { + static bulkAdd(sets: SetDTO[]): Promise { return dexieService.sets.bulkPut(sets); } }; diff --git a/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts b/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts index 357ebc5a7..451d34819 100644 --- a/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/SettingDTO.ts @@ -3,7 +3,7 @@ import { App } from '@app/types'; import { dexieService } from '../DexieService'; export class SettingDTO extends App.Setting { - constructor(user) { + constructor(user: string) { super(); this.user = user; @@ -14,7 +14,7 @@ export class SettingDTO extends App.Setting { return dexieService.settings.put(this); } - static get(user) { + static get(user: string) { return dexieService.settings.where('user').equalsIgnoreCase(user).first(); } }; diff --git a/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts b/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts index 04199e63d..1321fc437 100644 --- a/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts +++ b/webclient/src/services/dexie/DexieDTOs/TokenDTO.ts @@ -1,3 +1,4 @@ +import { IndexableType } from 'dexie'; import { App } from '@app/types'; import { dexieService } from '../DexieService'; @@ -7,11 +8,11 @@ export class TokenDTO extends App.Token { return dexieService.tokens.put(this); } - static get(name) { + static get(name: string) { return dexieService.tokens.where('name.value').equalsIgnoreCase(name).first(); } - static bulkAdd(tokens: TokenDTO[]): Promise { + static bulkAdd(tokens: TokenDTO[]): Promise { return dexieService.tokens.bulkPut(tokens); } }; diff --git a/webclient/src/services/dexie/DexieSchemas/v1.schema.ts b/webclient/src/services/dexie/DexieSchemas/v1.schema.ts index 18e7f35c4..32c29d2ed 100644 --- a/webclient/src/services/dexie/DexieSchemas/v1.schema.ts +++ b/webclient/src/services/dexie/DexieSchemas/v1.schema.ts @@ -1,3 +1,5 @@ +import Dexie from 'dexie'; + export enum Stores { SETTINGS = 'settings', CARDS = 'cards', @@ -6,7 +8,7 @@ export enum Stores { HOSTS = 'hosts', } -export const schemaV1 = (db) => { +export const schemaV1 = (db: Dexie) => { db.version(1).stores({ [Stores.CARDS]: 'name', [Stores.SETS]: 'code', diff --git a/webclient/src/services/index.ts b/webclient/src/services/index.ts index ebebeb65e..2d36a2628 100644 --- a/webclient/src/services/index.ts +++ b/webclient/src/services/index.ts @@ -1,3 +1,4 @@ export * from './CardImporterService'; +export * from './HostService'; export * from './ServerProps'; export * from './dexie'; diff --git a/webclient/src/setupTests.ts b/webclient/src/setupTests.ts index 5c422f120..219544389 100644 --- a/webclient/src/setupTests.ts +++ b/webclient/src/setupTests.ts @@ -5,6 +5,43 @@ import './polyfills'; // Ensure jest-dom matchers are available in every test file. import '@testing-library/jest-dom/vitest'; +// jsdom doesn't provide ResizeObserver; react-window needs it. +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as any; +} + +// Mock Dexie globally to prevent IndexedDB initialization in jsdom. +// Dexie eagerly opens IndexedDB on import, and jsdom's fake-indexeddb +// is memory-intensive. No UI test needs a real database. +vi.mock('dexie', () => { + const fakeTable = { + mapToClass: () => {}, + get: () => Promise.resolve(null), + put: () => Promise.resolve(), + add: () => Promise.resolve(1), + bulkAdd: () => Promise.resolve(), + delete: () => Promise.resolve(), + toArray: () => Promise.resolve([]), + where: () => ({ equals: () => ({ first: () => Promise.resolve(null) }) }), + }; + class FakeDexie { + version() { + return { stores: () => this }; + } + open() { + return Promise.resolve(this); + } + table() { + return fakeTable; + } + } + return { default: FakeDexie, __esModule: true }; +}); + // ── Global mock hygiene under `isolate: false` ──────────────────────────────── // // Vitest is configured with `test.isolate: false` for speed — every spec file diff --git a/webclient/src/store/actions/actionReducer.spec.ts b/webclient/src/store/actions/actionReducer.spec.ts index 351995dc5..5808d8b53 100644 --- a/webclient/src/store/actions/actionReducer.spec.ts +++ b/webclient/src/store/actions/actionReducer.spec.ts @@ -1,9 +1,8 @@ import { actionReducer } from './actionReducer'; describe('actionReducer', () => { - it('spreads the init action onto state and starts count at 1', () => { + it('stores the init action type and starts count at 1', () => { const result = actionReducer(undefined, { type: '@@INIT' }); - // actionReducer always spreads the action, so type reflects the dispatched action expect(result.type).toBe('@@INIT'); expect(result.payload).toBeNull(); expect(result.meta).toBeNull(); @@ -11,7 +10,7 @@ describe('actionReducer', () => { expect(result.count).toBe(1); }); - it('spreads action onto state and increments count', () => { + it('stores action type and cloned payload', () => { const result = actionReducer(undefined, { type: 'MY_ACTION', payload: { id: 1 } }); expect(result.type).toBe('MY_ACTION'); expect(result.payload).toEqual({ id: 1 }); @@ -25,16 +24,33 @@ describe('actionReducer', () => { expect(state3.count).toBe(3); }); - it('preserves existing state fields not overridden by action', () => { + it('replaces type from previous action', () => { const initial = actionReducer(undefined, { type: 'FIRST', payload: 'original' }); const result = actionReducer(initial, { type: 'SECOND' }); expect(result.type).toBe('SECOND'); + expect(result.payload).toBeNull(); expect(result.count).toBe(2); }); - it('spreads action.meta and action.error from action onto state', () => { + it('stores meta and error from action', () => { const result = actionReducer(undefined, { type: 'ERR', meta: { source: 'api' }, error: true }); expect(result.meta).toEqual({ source: 'api' }); expect(result.error).toBe(true); }); + + it('deep-clones payload to prevent shared references', () => { + const nested = { data: { counterInfo: { id: 1, count: 20 } } }; + const result = actionReducer(undefined, { type: 'TEST', payload: nested }); + expect(result.payload).toEqual(nested); + expect(result.payload).not.toBe(nested); + expect((result.payload as any).data).not.toBe(nested.data); + expect((result.payload as any).data.counterInfo).not.toBe(nested.data.counterInfo); + }); + + it('deep-clones meta to prevent shared references', () => { + const meta = { source: { nested: true } }; + const result = actionReducer(undefined, { type: 'TEST', meta }); + expect(result.meta).toEqual(meta); + expect(result.meta).not.toBe(meta); + }); }); diff --git a/webclient/src/store/actions/actionReducer.ts b/webclient/src/store/actions/actionReducer.ts index b3e883ee1..03e32d1d8 100644 --- a/webclient/src/store/actions/actionReducer.ts +++ b/webclient/src/store/actions/actionReducer.ts @@ -25,19 +25,21 @@ const initialState: InitialState = { } /** - * Calculates the application state. + * Stores the most recent action so `useReduxEffect` can react to dispatches. * - * @param state - * @param action - * @return {*} + * Payloads are deep-cloned to prevent shared object references between this + * slice and the slice that owns the action. Without the clone, Immer mutations + * in the target slice are detected as mutations of the stale payload stored here. */ export const actionReducer = ( state = initialState, action: UnknownAction, ): InitialState => { return { - ...state, - ...action, + type: action.type ?? null, + payload: 'payload' in action ? structuredClone(action.payload) : null, + meta: 'meta' in action ? structuredClone(action.meta) : null, + error: !!action.error, count: state.count + 1, } } diff --git a/webclient/src/store/common/normalizers.spec.ts b/webclient/src/store/common/normalizers.spec.ts index a6bd6d757..a4195ed2b 100644 --- a/webclient/src/store/common/normalizers.spec.ts +++ b/webclient/src/store/common/normalizers.spec.ts @@ -77,11 +77,23 @@ describe('normalizeLogs', () => { const result = normalizeLogs(logs); expect(result.room).toHaveLength(2); expect(result.game).toHaveLength(1); - expect(result.chat).toBeUndefined(); + expect(result.chat).toEqual([]); }); - it('returns empty object for empty logs', () => { - expect(normalizeLogs([])).toEqual({}); + it('returns all three keys as empty arrays for empty logs', () => { + expect(normalizeLogs([])).toEqual({ room: [], game: [], chat: [] }); + }); + + it('skips logs whose targetType is not one of the known buckets', () => { + const logs = [ + create(Data.ServerInfo_ChatMessageSchema, { targetType: 'room' }), + create(Data.ServerInfo_ChatMessageSchema, { targetType: '' }), + create(Data.ServerInfo_ChatMessageSchema, { targetType: 'unknown' }), + ]; + const result = normalizeLogs(logs); + expect(result.room).toHaveLength(1); + expect(result.game).toEqual([]); + expect(result.chat).toEqual([]); }); }); diff --git a/webclient/src/store/common/normalizers.ts b/webclient/src/store/common/normalizers.ts index 545c3d8b9..70ec4881d 100644 --- a/webclient/src/store/common/normalizers.ts +++ b/webclient/src/store/common/normalizers.ts @@ -50,12 +50,13 @@ export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enr /** Group a flat LogItem[] into { room, game, chat } buckets for the server store. */ export function normalizeLogs(logs: Data.ServerInfo_ChatMessage[]): Enriched.LogGroups { - return logs.reduce((obj, log) => { + return logs.reduce((obj, log) => { const type = log.targetType as keyof Enriched.LogGroups; - obj[type] = obj[type] || []; - obj[type]!.push(log); + if (obj[type]) { + obj[type].push(log); + } return obj; - }, {} as Enriched.LogGroups); + }, { room: [], game: [], chat: [] }); } /** diff --git a/webclient/src/store/game/game.dispatch.spec.ts b/webclient/src/store/game/game.dispatch.spec.ts index d7cab22fb..bf6d91254 100644 --- a/webclient/src/store/game/game.dispatch.spec.ts +++ b/webclient/src/store/game/game.dispatch.spec.ts @@ -197,7 +197,7 @@ describe('Dispatch', () => { }); it('gameSay dispatches Actions.gameSay()', () => { - Dispatch.gameSay(1, 2, 'gg wp'); - expect(mockDispatch).toHaveBeenCalledWith(Actions.gameSay({ gameId: 1, playerId: 2, message: 'gg wp' })); + Dispatch.gameSay(1, 2, 'gg wp', 1700000000000); + expect(mockDispatch).toHaveBeenCalledWith(Actions.gameSay({ gameId: 1, playerId: 2, message: 'gg wp', timeReceived: 1700000000000 })); }); }); diff --git a/webclient/src/store/game/game.dispatch.ts b/webclient/src/store/game/game.dispatch.ts index c84a559e8..294f4831a 100644 --- a/webclient/src/store/game/game.dispatch.ts +++ b/webclient/src/store/game/game.dispatch.ts @@ -127,7 +127,7 @@ export const Dispatch = { store.dispatch(Actions.zonePropertiesChanged({ gameId, playerId, data })); }, - gameSay: (gameId: number, playerId: number, message: string) => { - store.dispatch(Actions.gameSay({ gameId, playerId, message })); + gameSay: (gameId: number, playerId: number, message: string, timeReceived: number) => { + store.dispatch(Actions.gameSay({ gameId, playerId, message, timeReceived })); }, }; diff --git a/webclient/src/store/game/game.reducer.spec.ts b/webclient/src/store/game/game.reducer.spec.ts index 3d4b8abb8..4eadf7238 100644 --- a/webclient/src/store/game/game.reducer.spec.ts +++ b/webclient/src/store/game/game.reducer.spec.ts @@ -417,6 +417,69 @@ describe('2C: CARD_MOVED', () => { expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(1); expect(result.games[1].players[1].zones['nonexistent']).toBeUndefined(); }); + + it('CARD_MOVED → no-ops when neither cardId nor position resolve and newCardId < 0', () => { + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: 'deck', cards: [], cardCount: 5 }), + hand: makeZoneEntry({ name: 'hand', cards: [], cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, Actions.cardMoved({ + gameId: 1, + playerId: 1, + data: { + cardId: -1, cardName: '', startPlayerId: 1, startZone: 'deck', + position: -1, targetPlayerId: 1, targetZone: 'hand', + x: 0, y: 0, newCardId: -1, faceDown: false, newCardProviderId: '', + }, + })); + + expect(result.games[1].players[1].zones['deck'].cardCount).toBe(5); + expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(0); + }); + + it('CARD_MOVED → deep-clones counterList so moved card is independent', () => { + const cardCounter = create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 3 }); + const card = makeCard({ id: 10, counterList: [cardCounter] }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + hand: makeZoneEntry({ name: 'hand', cards: [card], cardCount: 1 }), + table: makeZoneEntry({ name: 'table', cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, Actions.cardMoved({ + gameId: 1, + playerId: 1, + data: { + cardId: 10, cardName: '', startPlayerId: 1, startZone: 'hand', + position: -1, targetPlayerId: 1, targetZone: 'table', + x: 0, y: 0, newCardId: -1, faceDown: false, newCardProviderId: '', + }, + })); + + const movedCard = cardsIn(result, 1, 1, 'table')[0]; + expect(movedCard.counterList).toHaveLength(1); + expect(movedCard.counterList).not.toBe(card.counterList); + }); }); // ── 2D: Card mutations ──────────────────────────────────────────────────────── @@ -696,6 +759,17 @@ describe('2H: Player counters', () => { expect(result.games[1].players[1].counters[5]).toEqual(counter); }); + it('COUNTER_CREATED → clones counterInfo to prevent shared references', () => { + const state = makeState(); + const counter = makeCounter({ id: 5, name: 'Life', count: 20 }); + const result = gamesReducer(state, Actions.counterCreated({ + gameId: 1, + playerId: 1, + data: { counterInfo: counter }, + })); + expect(result.games[1].players[1].counters[5]).not.toBe(counter); + }); + it('COUNTER_SET → updates counter.count to new value', () => { const counter = makeCounter({ id: 5, count: 20 }); const state = makeState({ @@ -845,6 +919,34 @@ describe('2I: Zone operations', () => { expect(cardsIn(result, 1, 1, 'deck')[1]).toEqual(newCard); }); + it('CARDS_REVEALED → clones counterList to prevent shared references', () => { + const cardCounter = create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 5 }); + const revealedCard = makeCard({ id: 3, counterList: [cardCounter] }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: 'deck', cards: [], cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, Actions.cardsRevealed({ + gameId: 1, + playerId: 1, + data: { zoneName: 'deck', cards: [revealedCard] }, + })); + + const stored = cardsIn(result, 1, 1, 'deck')[0]; + expect(stored.counterList).toEqual(revealedCard.counterList); + expect(stored.counterList).not.toBe(revealedCard.counterList); + }); + it('ZONE_PROPERTIES_CHANGED → sets alwaysRevealTopCard and alwaysLookAtTopCard', () => { const state = makeState(); const result = gamesReducer(state, Actions.zonePropertiesChanged({ @@ -882,21 +984,17 @@ describe('2J: Turn, phase, and chat', () => { expect(result.games[1].reversed).toBe(true); }); - it('GAME_SAY → appends message with mocked Date.now() as timeReceived', () => { + it('GAME_SAY → appends message with timeReceived from payload', () => { const state = makeState(); - const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456789); - try { - const result = gamesReducer(state, Actions.gameSay({ - gameId: 1, - playerId: 2, - message: 'gg', - })); + const result = gamesReducer(state, Actions.gameSay({ + gameId: 1, + playerId: 2, + message: 'gg', + timeReceived: 123456789, + })); - expect(result.games[1].messages).toHaveLength(1); - expect(result.games[1].messages[0]).toEqual({ playerId: 2, message: 'gg', timeReceived: 123456789 }); - } finally { - dateNowSpy.mockRestore(); - } + expect(result.games[1].messages).toHaveLength(1); + expect(result.games[1].messages[0]).toEqual({ playerId: 2, message: 'gg', timeReceived: 123456789 }); }); }); @@ -1332,6 +1430,6 @@ describe('2L: Null-guard / missing entity early-returns', () => { it('GAME_SAY with unknown gameId → state unchanged', () => { const state = makeState(); - expect(gamesReducer(state, Actions.gameSay({ gameId: UNKNOWN_GAME, playerId: 1, message: 'hi' }))).toBe(state); + expect(gamesReducer(state, Actions.gameSay({ gameId: UNKNOWN_GAME, playerId: 1, message: 'hi', timeReceived: 0 }))).toBe(state); }); }); diff --git a/webclient/src/store/game/game.reducer.ts b/webclient/src/store/game/game.reducer.ts index eff362c32..ec3c1f658 100644 --- a/webclient/src/store/game/game.reducer.ts +++ b/webclient/src/store/game/game.reducer.ts @@ -183,7 +183,7 @@ export const gamesSlice = createSlice({ state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_MoveCard }>, ) => { - const { gameId, playerId, data } = action.payload; + const { gameId, data } = action.payload; const { cardId, cardName, startPlayerId, startZone, position, targetPlayerId, targetZone, x, y, newCardId, faceDown, newCardProviderId, @@ -194,8 +194,7 @@ export const gamesSlice = createSlice({ return; } - const effectiveStartPlayerId = startPlayerId >= 0 ? startPlayerId : playerId; - const sourcePlayer = game.players[effectiveStartPlayerId]; + const sourcePlayer = game.players[startPlayerId]; const sourceZone = sourcePlayer?.zones[startZone]; if (!sourcePlayer || !sourceZone) { return; @@ -214,10 +213,16 @@ export const gamesSlice = createSlice({ resolvedCardId = sourceZone.order[position]; } - const removedCard: Data.ServerInfo_Card | undefined = - resolvedCardId >= 0 ? sourceZone.byId[resolvedCardId] : undefined; + // If the card can't be resolved and no newCardId is provided, the event + // is malformed — bail out to avoid creating phantom cards with id -1. + if (resolvedCardId < 0 && newCardId < 0) { + return; + } + // Remove from source zone if the card was resolved to a known entry + let removedCard: Data.ServerInfo_Card | undefined; if (resolvedCardId >= 0) { + removedCard = sourceZone.byId[resolvedCardId]; const idx = sourceZone.order.indexOf(resolvedCardId); if (idx >= 0) { sourceZone.order.splice(idx, 1); @@ -226,11 +231,12 @@ export const gamesSlice = createSlice({ } sourceZone.cardCount = Math.max(0, sourceZone.cardCount - 1); - const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? -1); + const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? resolvedCardId); const movedCard: Data.ServerInfo_Card = removedCard ? { ...removedCard, id: effectiveNewId, name: cardName || removedCard.name, x, y, faceDown, providerId: newCardProviderId || removedCard.providerId, + counterList: [...removedCard.counterList], } : buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? ''); @@ -342,8 +348,8 @@ export const gamesSlice = createSlice({ arrowCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateArrow }>) => { const { gameId, playerId, data } = action.payload; const player = state.games[gameId]?.players[playerId]; - if (player) { - player.arrows[data.arrowInfo.id] = data.arrowInfo; + if (player && data.arrowInfo) { + player.arrows[data.arrowInfo.id] = { ...data.arrowInfo }; } }, @@ -360,8 +366,8 @@ export const gamesSlice = createSlice({ counterCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateCounter }>) => { const { gameId, playerId, data } = action.payload; const player = state.games[gameId]?.players[playerId]; - if (player) { - player.counters[data.counterInfo.id] = data.counterInfo; + if (player && data.counterInfo) { + player.counters[data.counterInfo.id] = { ...data.counterInfo }; } }, @@ -417,12 +423,10 @@ export const gamesSlice = createSlice({ } for (const revealedCard of cards) { - if (zone.byId[revealedCard.id]) { - Object.assign(zone.byId[revealedCard.id], revealedCard); - } else { + if (!zone.byId[revealedCard.id]) { zone.order.push(revealedCard.id); - zone.byId[revealedCard.id] = revealedCard; } + zone.byId[revealedCard.id] = { ...revealedCard, counterList: [...revealedCard.counterList] }; } }, @@ -465,8 +469,8 @@ export const gamesSlice = createSlice({ // ── Chat ────────────────────────────────────────────────────────────────── - gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string }>) => { - const { gameId, playerId, message } = action.payload; + gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string; timeReceived: number }>) => { + const { gameId, playerId, message, timeReceived } = action.payload; const game = state.games[gameId]; if (!game) { return; @@ -474,7 +478,7 @@ export const gamesSlice = createSlice({ if (game.messages.length >= MAX_GAME_MESSAGES) { game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1); } - game.messages.push({ playerId, message, timeReceived: Date.now() }); + game.messages.push({ playerId, message, timeReceived }); }, // ── Log-only events ───────────────────────────────────────────────────── diff --git a/webclient/src/store/rooms/rooms.actions.spec.ts b/webclient/src/store/rooms/rooms.actions.spec.ts index ba75c5f2a..59a806160 100644 --- a/webclient/src/store/rooms/rooms.actions.spec.ts +++ b/webclient/src/store/rooms/rooms.actions.spec.ts @@ -42,9 +42,10 @@ describe('Actions', () => { }); it('sortGames', () => { - expect(Actions.sortGames({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })).toEqual({ + expect(Actions.sortGames({ roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })).toEqual({ type: Types.SORT_GAMES, payload: { + roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC, }, diff --git a/webclient/src/store/rooms/rooms.dispatch.spec.ts b/webclient/src/store/rooms/rooms.dispatch.spec.ts index 0cec338f3..bcbfa1977 100644 --- a/webclient/src/store/rooms/rooms.dispatch.spec.ts +++ b/webclient/src/store/rooms/rooms.dispatch.spec.ts @@ -74,7 +74,7 @@ describe('Dispatch', () => { it('sortGames dispatches Actions.sortGames()', () => { Dispatch.sortGames(1, App.GameSortField.START_TIME, App.SortDirection.ASC); expect(mockDispatch).toHaveBeenCalledWith( - Actions.sortGames({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC }) + Actions.sortGames({ roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC }) ); }); diff --git a/webclient/src/store/rooms/rooms.dispatch.tsx b/webclient/src/store/rooms/rooms.dispatch.tsx index a6eb8dd03..224eff0d9 100644 --- a/webclient/src/store/rooms/rooms.dispatch.tsx +++ b/webclient/src/store/rooms/rooms.dispatch.tsx @@ -37,7 +37,7 @@ export const Dispatch = { }, sortGames: (roomId: number, field: App.GameSortField, order: App.SortDirection) => { - store.dispatch(Actions.sortGames({ field, order })); + store.dispatch(Actions.sortGames({ roomId, field, order })); }, removeMessages: (roomId: number, name: string, amount: number) => { diff --git a/webclient/src/store/rooms/rooms.reducer.spec.ts b/webclient/src/store/rooms/rooms.reducer.spec.ts index ea09fefc7..ce5b687dd 100644 --- a/webclient/src/store/rooms/rooms.reducer.spec.ts +++ b/webclient/src/store/rooms/rooms.reducer.spec.ts @@ -243,6 +243,7 @@ describe('SORT_GAMES', () => { it('updates sortGamesBy on state (sorting itself is now derived in selectors)', () => { const state = makeRoomsState({ rooms: {} }); const result = roomsReducer(state, Actions.sortGames({ + roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC, })); diff --git a/webclient/src/store/rooms/rooms.reducer.tsx b/webclient/src/store/rooms/rooms.reducer.tsx index efd2c916d..e6157952e 100644 --- a/webclient/src/store/rooms/rooms.reducer.tsx +++ b/webclient/src/store/rooms/rooms.reducer.tsx @@ -82,14 +82,14 @@ export const roomsSlice = createSlice({ addMessage: (state, action: PayloadAction<{ roomId: number; message: Enriched.Message }>) => { const { roomId, message } = action.payload; - const existing = state.messages[roomId] ?? []; - const normalized = normalizeUserMessage(message); - const next = - existing.length >= MAX_ROOM_MESSAGES - ? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized] - : [...existing, normalized]; - - state.messages[roomId] = next; + if (!state.messages[roomId]) { + state.messages[roomId] = []; + } + const msgs = state.messages[roomId]; + if (msgs.length >= MAX_ROOM_MESSAGES) { + state.messages[roomId] = msgs.slice(msgs.length - MAX_ROOM_MESSAGES + 1); + } + state.messages[roomId].push(normalizeUserMessage(message)); }, updateGames: (state, action: PayloadAction<{ roomId: number; games: Data.ServerInfo_Game[] }>) => { @@ -145,8 +145,9 @@ export const roomsSlice = createSlice({ delete room.users[name]; }, - sortGames: (state, action: PayloadAction<{ field: App.GameSortField; order: App.SortDirection }>) => { - // Sort is now derived in selectors; the reducer only stores the sort config. + sortGames: (state, action: PayloadAction<{ roomId: number; field: App.GameSortField; order: App.SortDirection }>) => { + // Sort is derived in selectors; the reducer stores the sort config. + // roomId is passed through for future per-room sorting support. const { field, order } = action.payload; state.sortGamesBy = { field, order }; }, diff --git a/webclient/src/store/server/__mocks__/server-fixtures.ts b/webclient/src/store/server/__mocks__/server-fixtures.ts index be47e93e2..fd805dbbb 100644 --- a/webclient/src/store/server/__mocks__/server-fixtures.ts +++ b/webclient/src/store/server/__mocks__/server-fixtures.ts @@ -143,7 +143,7 @@ export function makeServerState(overrides: Partial = {}): ServerSta ignoreList: {}, status: { connectionAttemptMade: false, - state: App.StatusEnum.DISCONNECTED, + state: Enriched.StatusEnum.DISCONNECTED, description: null, }, info: { diff --git a/webclient/src/store/server/server.actions.spec.ts b/webclient/src/store/server/server.actions.spec.ts index 3638201b7..5bc31dbd7 100644 --- a/webclient/src/store/server/server.actions.spec.ts +++ b/webclient/src/store/server/server.actions.spec.ts @@ -1,5 +1,5 @@ import { Actions } from './server.actions'; -import { App, Data } from '@app/types'; +import { Data, Enriched } from '@app/types'; import { Types } from './server.types'; import { create } from '@bufbuild/protobuf'; import { @@ -88,7 +88,7 @@ describe('Actions', () => { }); it('updateStatus', () => { - const status = { state: App.StatusEnum.CONNECTED, description: 'connected' }; + const status = { state: Enriched.StatusEnum.CONNECTED, description: 'connected' }; expect(Actions.updateStatus({ status })).toEqual({ type: Types.UPDATE_STATUS, payload: { status } }); }); diff --git a/webclient/src/store/server/server.dispatch.spec.ts b/webclient/src/store/server/server.dispatch.spec.ts index 3d89c6330..bd7804850 100644 --- a/webclient/src/store/server/server.dispatch.spec.ts +++ b/webclient/src/store/server/server.dispatch.spec.ts @@ -6,7 +6,7 @@ vi.mock('..', () => ({ store: { dispatch: mockDispatch } })); import { Actions } from './server.actions'; import { Dispatch } from './server.dispatch'; -import { App, Data } from '@app/types'; +import { Data, Enriched } from '@app/types'; import { create } from '@bufbuild/protobuf'; import { makeBanHistoryItem, @@ -106,8 +106,10 @@ describe('Dispatch', () => { }); it('updateStatus dispatches Actions.updateStatus({ status: { state, description } })', () => { - Dispatch.updateStatus(App.StatusEnum.CONNECTED, 'ok'); - expect(mockDispatch).toHaveBeenCalledWith(Actions.updateStatus({ status: { state: App.StatusEnum.CONNECTED, description: 'ok' } })); + Dispatch.updateStatus(Enriched.StatusEnum.CONNECTED, 'ok'); + expect(mockDispatch).toHaveBeenCalledWith( + Actions.updateStatus({ status: { state: Enriched.StatusEnum.CONNECTED, description: 'ok' } }) + ); }); it('updateUser dispatches Actions.updateUser()', () => { diff --git a/webclient/src/store/server/server.dispatch.ts b/webclient/src/store/server/server.dispatch.ts index 77a2845df..fad027a57 100644 --- a/webclient/src/store/server/server.dispatch.ts +++ b/webclient/src/store/server/server.dispatch.ts @@ -1,6 +1,6 @@ import { Actions } from './server.actions'; import { store } from '..'; -import { App, Data, Enriched } from '@app/types'; +import { Data, Enriched } from '@app/types'; export const Dispatch = { initialized: () => { @@ -48,7 +48,7 @@ export const Dispatch = { updateInfo: (name: string, version: string) => { store.dispatch(Actions.updateInfo({ info: { name, version } })); }, - updateStatus: (state: App.StatusEnum, description: string) => { + updateStatus: (state: Enriched.StatusEnum, description: string) => { store.dispatch(Actions.updateStatus({ status: { state, description } })); }, updateUser: (user: Data.ServerInfo_User) => { diff --git a/webclient/src/store/server/server.interfaces.ts b/webclient/src/store/server/server.interfaces.ts index 2a4e1ca5a..eb7cd4e69 100644 --- a/webclient/src/store/server/server.interfaces.ts +++ b/webclient/src/store/server/server.interfaces.ts @@ -43,7 +43,7 @@ export interface ServerState { export interface ServerStateStatus { connectionAttemptMade: boolean; description: string | null; - state: App.StatusEnum; + state: Enriched.StatusEnum; } export interface ServerStateInfo { diff --git a/webclient/src/store/server/server.reducer.spec.ts b/webclient/src/store/server/server.reducer.spec.ts index 99ac79e97..c0edac090 100644 --- a/webclient/src/store/server/server.reducer.spec.ts +++ b/webclient/src/store/server/server.reducer.spec.ts @@ -1,6 +1,6 @@ -import { App, Data } from '@app/types'; +import { Data, Enriched } from '@app/types'; import { create } from '@bufbuild/protobuf'; -import { serverReducer } from './server.reducer'; +import { serverReducer, MAX_USER_MESSAGES } from './server.reducer'; import { Actions } from './server.actions'; import { makeBanHistoryItem, @@ -25,7 +25,7 @@ describe('Initialisation', () => { const result = serverReducer(undefined, { type: '@@INIT' }); expect(result.initialized).toBe(false); expect(result.buddyList).toEqual({}); - expect(result.status.state).toBe(App.StatusEnum.DISCONNECTED); + expect(result.status.state).toBe(Enriched.StatusEnum.DISCONNECTED); }); it('INITIALIZED → resets to initialState with initialized: true', () => { @@ -37,7 +37,7 @@ describe('Initialisation', () => { }); it('CLEAR_STORE → resets to initialState but preserves status', () => { - const status = { state: App.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true }; + const status = { state: Enriched.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true }; const state = makeServerState({ status, banUser: 'someone' }); const result = serverReducer(state, Actions.clearStore()); expect(result.banUser).toBe(''); @@ -56,7 +56,7 @@ describe('Initialisation', () => { describe('Account & Connection', () => { it('CONNECTION_ATTEMPTED → sets connectionAttemptMade to true', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.DISCONNECTED, description: null } }); const result = serverReducer(state, Actions.connectionAttempted()); expect(result.status.connectionAttemptMade).toBe(true); }); @@ -131,9 +131,9 @@ describe('Server Info & Status', () => { it('UPDATE_STATUS → merges state and description into status', () => { const state = makeServerState(); - const update = { state: App.StatusEnum.LOGGED_IN, description: 'ok' }; + const update = { state: Enriched.StatusEnum.LOGGED_IN, description: 'ok' }; const result = serverReducer(state, Actions.updateStatus({ status: update })); - expect(result.status.state).toBe(App.StatusEnum.LOGGED_IN); + expect(result.status.state).toBe(Enriched.StatusEnum.LOGGED_IN); expect(result.status.description).toBe('ok'); expect(result.status.connectionAttemptMade).toBe(false); }); @@ -256,6 +256,14 @@ describe('Logs', () => { expect(result.logs.room).toEqual([log]); }); + it('VIEW_LOGS with empty array → produces all three keys as empty arrays', () => { + const state = makeServerState(); + const result = serverReducer(state, Actions.viewLogs({ logs: [] })); + expect(result.logs.room).toEqual([]); + expect(result.logs.game).toEqual([]); + expect(result.logs.chat).toEqual([]); + }); + it('CLEAR_LOGS → resets logs to empty arrays', () => { const state = makeServerState({ logs: { room: [makeLogItem()], game: [], chat: [] } }); const result = serverReducer(state, Actions.clearLogs()); @@ -284,6 +292,13 @@ describe('Messaging', () => { expect(result.messages['Alice'][0]).toEqual(messageData); }); + it('USER_MESSAGE → no-ops when user is null (not yet logged in)', () => { + const state = makeServerState({ user: null, messages: {} }); + const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hi' } as Data.Event_UserMessage; + const result = serverReducer(state, Actions.userMessage({ messageData })); + expect(result.messages).toEqual({}); + }); + it('USER_MESSAGE → appends to existing messages for that user', () => { const existingMsg = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'first' }); const state = makeServerState({ @@ -294,6 +309,21 @@ describe('Messaging', () => { const result = serverReducer(state, Actions.userMessage({ messageData: newMsg })); expect(result.messages['Alice']).toHaveLength(2); }); + + it(`USER_MESSAGE → caps messages at MAX_USER_MESSAGES (${MAX_USER_MESSAGES})`, () => { + const messages = Array.from({ length: MAX_USER_MESSAGES }, (_, i) => + create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: `msg-${i}` }) + ); + const state = makeServerState({ + user: makeUser({ name: 'Bob' }), + messages: { Alice: messages }, + }); + const newMsg = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'overflow' }); + const result = serverReducer(state, Actions.userMessage({ messageData: newMsg })); + expect(result.messages['Alice']).toHaveLength(MAX_USER_MESSAGES); + expect(result.messages['Alice'][MAX_USER_MESSAGES - 1]).toEqual(newMsg); + expect(result.messages['Alice'][0].message).not.toBe('msg-0'); + }); }); // ── User Info & Notifications ───────────────────────────────────────────────── diff --git a/webclient/src/store/server/server.reducer.ts b/webclient/src/store/server/server.reducer.ts index a7e7682fa..9e3033961 100644 --- a/webclient/src/store/server/server.reducer.ts +++ b/webclient/src/store/server/server.reducer.ts @@ -6,6 +6,8 @@ import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, no import { ServerState, ServerStateStatus } from './server.interfaces'; +export const MAX_USER_MESSAGES = 1000; + function splitPath(path: string): string[] { return path ? path.split('/') : []; } @@ -71,7 +73,7 @@ const initialState: ServerState = { status: { connectionAttemptMade: false, - state: App.StatusEnum.DISCONNECTED, + state: Enriched.StatusEnum.DISCONNECTED, description: null }, info: { @@ -172,17 +174,20 @@ export const serverSlice = createSlice({ updateStatus: (state, action: PayloadAction<{ status: Pick }>) => { const { status } = action.payload; - state.status = { ...state.status, ...status }; + state.status.state = status.state; + state.status.description = status.description; - if (status.state === App.StatusEnum.DISCONNECTED) { + if (status.state === Enriched.StatusEnum.DISCONNECTED) { state.status.connectionAttemptMade = false; } }, updateUser: (state, action: PayloadAction<{ user: Partial }>) => { - state.user = state.user - ? { ...state.user, ...action.payload.user } as Data.ServerInfo_User - : action.payload.user as Data.ServerInfo_User; + if (state.user) { + Object.assign(state.user, action.payload.user); + } else { + state.user = action.payload.user as Data.ServerInfo_User; + } }, updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => { @@ -203,7 +208,7 @@ export const serverSlice = createSlice({ }, viewLogs: (state, action: PayloadAction<{ logs: Data.ServerInfo_ChatMessage[] }>) => { - state.logs = { ...normalizeLogs(action.payload.logs) }; + state.logs = normalizeLogs(action.payload.logs); }, clearLogs: (state) => { @@ -211,11 +216,18 @@ export const serverSlice = createSlice({ }, userMessage: (state, action: PayloadAction<{ messageData: Data.Event_UserMessage }>) => { + if (!state.user) { + return; + } const { senderName, receiverName } = action.payload.messageData; - const userName = state.user!.name === senderName ? receiverName : senderName; + const userName = state.user.name === senderName ? receiverName : senderName; if (!state.messages[userName]) { state.messages[userName] = []; } + const msgs = state.messages[userName]; + if (msgs.length >= MAX_USER_MESSAGES) { + state.messages[userName] = msgs.slice(msgs.length - MAX_USER_MESSAGES + 1); + } state.messages[userName].push(action.payload.messageData); }, @@ -273,7 +285,7 @@ export const serverSlice = createSlice({ newLevel = shouldBeJudge ? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsJudge) : (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsJudge); - state.users[userName] = { ...user, userLevel: newLevel }; + user.userLevel = newLevel; }, replayList: (state, action: PayloadAction<{ matchList: Data.ServerInfo_ReplayMatch[] }>) => { @@ -295,7 +307,7 @@ export const serverSlice = createSlice({ if (!existing) { return; } - state.replays[gameId] = { ...existing, doNotHide }; + existing.doNotHide = doNotHide; }, replayDeleteMatch: (state, action: PayloadAction<{ gameId: number }>) => { @@ -379,39 +391,43 @@ export const serverSlice = createSlice({ }, accountEditChanged: (state, action: PayloadAction<{ user: Partial }>) => { - state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User; + if (state.user) { + Object.assign(state.user, action.payload.user); + } }, accountImageChanged: (state, action: PayloadAction<{ user: Partial }>) => { - state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User; + if (state.user) { + Object.assign(state.user, action.payload.user); + } }, // Signal-only action types — no state mutation, defined so type strings are generated - accountAwaitingActivation: (_state, _action: PayloadAction) => {}, - accountActivationFailed: (_state, _action: PayloadAction) => {}, - accountActivationSuccess: (_state, _action: PayloadAction) => {}, - loginSuccessful: (_state, _action: PayloadAction) => {}, - loginFailed: (_state, _action: PayloadAction) => {}, - connectionFailed: (_state, _action: PayloadAction) => {}, - testConnectionSuccessful: (_state, _action: PayloadAction) => {}, - testConnectionFailed: (_state, _action: PayloadAction) => {}, - registrationRequiresEmail: (_state, _action: PayloadAction) => {}, - registrationSuccess: (_state, _action: PayloadAction) => {}, - registrationEmailError: (_state, _action: PayloadAction) => {}, - registrationPasswordError: (_state, _action: PayloadAction) => {}, - registrationUserNameError: (_state, _action: PayloadAction) => {}, - resetPassword: (_state, _action: PayloadAction) => {}, - resetPasswordFailed: (_state, _action: PayloadAction) => {}, - resetPasswordChallenge: (_state, _action: PayloadAction) => {}, - resetPasswordSuccess: (_state, _action: PayloadAction) => {}, - reloadConfig: (_state, _action: PayloadAction) => {}, - shutdownServer: (_state, _action: PayloadAction) => {}, - updateServerMessage: (_state, _action: PayloadAction) => {}, - accountPasswordChange: (_state, _action: PayloadAction) => {}, - addToList: (_state, _action: PayloadAction) => {}, - removeFromList: (_state, _action: PayloadAction) => {}, - grantReplayAccess: (_state, _action: PayloadAction) => {}, - forceActivateUser: (_state, _action: PayloadAction) => {}, + accountAwaitingActivation: (_state, _action: PayloadAction<{ options: Enriched.PendingActivationContext }>) => {}, + accountActivationFailed: (_state) => {}, + accountActivationSuccess: (_state) => {}, + loginSuccessful: (_state, _action: PayloadAction<{ options: Enriched.LoginSuccessContext }>) => {}, + loginFailed: (_state) => {}, + connectionFailed: (_state) => {}, + testConnectionSuccessful: (_state) => {}, + testConnectionFailed: (_state) => {}, + registrationRequiresEmail: (_state) => {}, + registrationSuccess: (_state) => {}, + registrationEmailError: (_state, _action: PayloadAction<{ error: string }>) => {}, + registrationPasswordError: (_state, _action: PayloadAction<{ error: string }>) => {}, + registrationUserNameError: (_state, _action: PayloadAction<{ error: string }>) => {}, + resetPassword: (_state) => {}, + resetPasswordFailed: (_state) => {}, + resetPasswordChallenge: (_state) => {}, + resetPasswordSuccess: (_state) => {}, + reloadConfig: (_state) => {}, + shutdownServer: (_state) => {}, + updateServerMessage: (_state) => {}, + accountPasswordChange: (_state) => {}, + addToList: (_state, _action: PayloadAction<{ list: string; userName: string }>) => {}, + removeFromList: (_state, _action: PayloadAction<{ list: string; userName: string }>) => {}, + grantReplayAccess: (_state, _action: PayloadAction<{ replayId: number; moderatorName: string }>) => {}, + forceActivateUser: (_state, _action: PayloadAction<{ usernameToActivate: string; moderatorName: string }>) => {}, }, }); diff --git a/webclient/src/store/server/server.selectors.spec.ts b/webclient/src/store/server/server.selectors.spec.ts index 8d9a75097..44286f950 100644 --- a/webclient/src/store/server/server.selectors.spec.ts +++ b/webclient/src/store/server/server.selectors.spec.ts @@ -6,7 +6,7 @@ import { makeServerState, makeUser, } from './__mocks__/server-fixtures'; -import { App, Data } from '@app/types'; +import { Data, Enriched } from '@app/types'; function rootState(server: ServerState) { return { server }; @@ -34,17 +34,17 @@ describe('Selectors', () => { }); it('getDescription → returns status.description', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.CONNECTED, description: 'ok' } }); + const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.CONNECTED, description: 'ok' } }); expect(Selectors.getDescription(rootState(state))).toBe('ok'); }); it('getState → returns status.state', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.LOGGED_IN, description: null } }); - expect(Selectors.getState(rootState(state))).toBe(App.StatusEnum.LOGGED_IN); + const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.LOGGED_IN, description: null } }); + expect(Selectors.getState(rootState(state))).toBe(Enriched.StatusEnum.LOGGED_IN); }); it('getConnectionAttemptMade → returns status.connectionAttemptMade', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.DISCONNECTED, description: null } }); expect(Selectors.getConnectionAttemptMade(rootState(state))).toBe(true); }); @@ -153,17 +153,17 @@ describe('Selectors', () => { // ── derived selectors (createSelector) ────────────────────────────── it('getIsConnected → true when state is LOGGED_IN', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } }); + const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.LOGGED_IN, description: null } }); expect(Selectors.getIsConnected(rootState(state))).toBe(true); }); it('getIsConnected → false when state is CONNECTED', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.CONNECTED, description: null } }); + const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.CONNECTED, description: null } }); expect(Selectors.getIsConnected(rootState(state))).toBe(false); }); it('getIsConnected → false when state is DISCONNECTED', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.DISCONNECTED, description: null } }); expect(Selectors.getIsConnected(rootState(state))).toBe(false); }); @@ -189,7 +189,7 @@ describe('Selectors', () => { // ── createSelector reference stability ────────────────────────────── it('getIsConnected → returns same value reference for identical state', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } }); + const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.LOGGED_IN, description: null } }); const root = rootState(state); const a = Selectors.getIsConnected(root); const b = Selectors.getIsConnected(root); diff --git a/webclient/src/store/server/server.selectors.ts b/webclient/src/store/server/server.selectors.ts index 535f3c43e..32f5a2ac6 100644 --- a/webclient/src/store/server/server.selectors.ts +++ b/webclient/src/store/server/server.selectors.ts @@ -1,5 +1,5 @@ import { createSelector } from '@reduxjs/toolkit'; -import { App, Data } from '@app/types'; +import { Data, Enriched } from '@app/types'; import { SortUtil } from '../common'; import { ServerState } from './server.interfaces'; @@ -23,7 +23,7 @@ export const Selectors = { /** True when the server status has reached LOGGED_IN. */ getIsConnected: createSelector( [({ server }: State) => server.status.state], - (state): boolean => state === App.StatusEnum.LOGGED_IN + (state): boolean => state === Enriched.StatusEnum.LOGGED_IN ), /** True when the currently logged-in user has the IsModerator level flag. */ diff --git a/webclient/src/types/app.ts b/webclient/src/types/app.ts index 2ec7c2a68..1921e8a44 100644 --- a/webclient/src/types/app.ts +++ b/webclient/src/types/app.ts @@ -1,5 +1,5 @@ export * from './cards'; -export * from './constants'; +export * from './regex-patterns'; export * from './countries'; export * from './languages'; export * from './routes'; diff --git a/webclient/src/types/countries.ts b/webclient/src/types/countries.ts index 0289b63ad..edc525877 100644 --- a/webclient/src/types/countries.ts +++ b/webclient/src/types/countries.ts @@ -250,4 +250,6 @@ export const countryCodes = [ 'XK', 'ZM', 'ZW', -]; +] as const; + +export type CountryCode = typeof countryCodes[number]; diff --git a/webclient/src/types/enriched.ts b/webclient/src/types/enriched.ts index b111c0e5e..6cf45316d 100644 --- a/webclient/src/types/enriched.ts +++ b/webclient/src/types/enriched.ts @@ -1,6 +1,5 @@ import type { Event_RoomSay, - GameEventContext, ServerInfo_Arrow, ServerInfo_Card, ServerInfo_ChatMessage, @@ -110,32 +109,20 @@ export interface GameMessage { timeReceived: number; } -/** - * Passed to every game event handler alongside the event payload. - * Contains per-container metadata from GameEventContainer. - * Not stored in Redux — transient routing metadata only. - */ -export interface GameEventMeta { - gameId: number; - playerId: number; - /** Raw protobuf GameEventContext object. Not stored in Redux. */ - context: GameEventContext | null; - secondsElapsed: number; - /** Proto type is uint32. Non-zero means the action was forced by a judge. */ - forcedByJudge: number; -} - export interface LogGroups { room: ServerInfo_ChatMessage[]; game: ServerInfo_ChatMessage[]; chat: ServerInfo_ChatMessage[]; } -// ── Connect options (re-exported from @app/websocket) ──────────────────────── -// Source of truth lives in src/websocket/connectOptions.ts. Re-exported here -// so UI code can use the Enriched.* namespace without importing @app/websocket. +// ── Websocket re-exports ───────────────────────────────────────────────────── +// Source of truth lives in @app/websocket. Re-exported here so app code can +// reach these via the Enriched.* namespace without importing @app/websocket. + +export { StatusEnum, WebSocketConnectReason } from '@app/websocket'; export type { + GameEventMeta, LoginConnectOptions, RegisterConnectOptions, ActivateConnectOptions, diff --git a/webclient/src/types/constants.spec.ts b/webclient/src/types/regex-patterns.spec.ts similarity index 98% rename from webclient/src/types/constants.spec.ts rename to webclient/src/types/regex-patterns.spec.ts index d324867dc..c34f8f3fb 100644 --- a/webclient/src/types/constants.spec.ts +++ b/webclient/src/types/regex-patterns.spec.ts @@ -2,7 +2,7 @@ import { URL_REGEX, MESSAGE_SENDER_REGEX, MENTION_REGEX, -} from './constants'; +} from './regex-patterns'; describe('RegEx', () => { describe('URL_REGEX', () => { diff --git a/webclient/src/types/constants.ts b/webclient/src/types/regex-patterns.ts similarity index 100% rename from webclient/src/types/constants.ts rename to webclient/src/types/regex-patterns.ts diff --git a/webclient/src/types/server.ts b/webclient/src/types/server.ts index 19135bc8f..59eb399c0 100644 --- a/webclient/src/types/server.ts +++ b/webclient/src/types/server.ts @@ -1,5 +1,4 @@ -export { StatusEnum, WebSocketConnectReason } from '@app/websocket'; -import type { StatusEnum } from '@app/websocket'; +import type { StatusEnum } from './enriched'; export interface ServerStatus { status: StatusEnum; @@ -19,59 +18,3 @@ export class Host { hashedPassword?: string; remember?: boolean; } - -export const DefaultHosts: Host[] = [ - { - name: 'Chickatrice', - host: 'mtg.chickatrice.net', - port: '443', - localPort: '4748', - editable: false, - }, - { - name: 'Rooster', - host: 'server.cockatrice.us/servatrice', - port: '4748', - localHost: 'server.cockatrice.us', - editable: false, - }, - { - name: 'Rooster Beta', - host: 'beta.cockatrice.us/servatrice', - port: '4748', - localHost: 'beta.cockatrice.us', - editable: false, - }, - { - name: 'Tetrarch', - host: 'mtg.tetrarch.co/servatrice', - port: '443', - editable: false, - }, -]; - -export const getHostPort = (host: Host): { host: string, port: string } => { - const isLocal = window.location.hostname === 'localhost'; - - if (!host) { - return { - host: '', - port: '' - }; - } - - return { - host: !isLocal ? host.host : host.localHost || host.host, - port: !isLocal ? host.port : host.localPort || host.port, - } -}; - -export enum KnownHost { - ROOSTER = 'Rooster', - TETRARCH = 'Tetrarch', -} - -export const KnownHosts = { - [KnownHost.ROOSTER]: { port: 4748, host: 'server.cockatrice.us', }, - [KnownHost.TETRARCH]: { port: 443, host: 'mtg.tetrarch.co/servatrice' }, -} diff --git a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts index 7f0b9e2c6..daf90d083 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts @@ -18,7 +18,7 @@ import { Mock } from 'vitest'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; import { WebClient } from '../../WebClient'; import * as SessionIndexMocks from './'; -import { App, Enriched } from '@app/types'; +import { Enriched } from '@app/types'; import { StatusEnum } from '../../interfaces/StatusEnum'; import { Command_Activate_ext, @@ -59,7 +59,7 @@ const baseTransport = { host: 'h', port: '1' }; const makeLoginOpts = (overrides: Partial = {}): Enriched.LoginConnectOptions => ({ ...baseTransport, userName: 'alice', - reason: App.WebSocketConnectReason.LOGIN, + reason: Enriched.WebSocketConnectReason.LOGIN, ...overrides, }); const makeRegisterOpts = ( @@ -71,7 +71,7 @@ const makeRegisterOpts = ( email: 'a@b.com', country: 'US', realName: 'Al', - reason: App.WebSocketConnectReason.REGISTER, + reason: Enriched.WebSocketConnectReason.REGISTER, ...overrides, }); const makeActivateOpts = ( @@ -80,26 +80,26 @@ const makeActivateOpts = ( ...baseTransport, userName: 'alice', token: 'tok', - reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT, + reason: Enriched.WebSocketConnectReason.ACTIVATE_ACCOUNT, ...overrides, }); const makeForgotRequestOpts = (): Enriched.PasswordResetRequestConnectOptions => ({ ...baseTransport, userName: 'alice', - reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST, + reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_REQUEST, }); const makeForgotChallengeOpts = (): Enriched.PasswordResetChallengeConnectOptions => ({ ...baseTransport, userName: 'alice', email: 'a@b.com', - reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE, + reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE, }); const makeForgotResetOpts = (): Enriched.PasswordResetConnectOptions => ({ ...baseTransport, userName: 'alice', token: 'tok', newPassword: 'newpw', - reason: App.WebSocketConnectReason.PASSWORD_RESET, + reason: Enriched.WebSocketConnectReason.PASSWORD_RESET, }); @@ -194,7 +194,7 @@ describe('login', () => { }); it('onSuccess passes hashedPassword to loginSuccessful when salt is used', () => { - login({ host: 'h', port: '1', userName: 'alice', reason: App.WebSocketConnectReason.LOGIN }, 'pw', 'salt'); + login({ host: 'h', port: '1', userName: 'alice', reason: Enriched.WebSocketConnectReason.LOGIN }, 'pw', 'salt'); const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } }; invokeOnSuccess(loginResp, { responseCode: 0 }); const calledWith = (WebClient.instance.response.session.loginSuccessful as Mock).mock.calls[0][0]; diff --git a/webclient/src/websocket/events/game/gameEvents.spec.ts b/webclient/src/websocket/events/game/gameEvents.spec.ts index 1276f1724..a5756bb34 100644 --- a/webclient/src/websocket/events/game/gameEvents.spec.ts +++ b/webclient/src/websocket/events/game/gameEvents.spec.ts @@ -115,10 +115,10 @@ describe('playerPropertiesChanged event', () => { }); describe('gameSay event', () => { - it('delegates to WebClient.instance.response.game.gameSay with gameId, playerId, message', () => { + it('delegates to WebClient.instance.response.game.gameSay with gameId, playerId, message, timeReceived', () => { const data = create(Event_GameSaySchema, { message: 'gg' }); gameSay(data, meta); - expect(WebClient.instance.response.game.gameSay).toHaveBeenCalledWith(5, 2, 'gg'); + expect(WebClient.instance.response.game.gameSay).toHaveBeenCalledWith(5, 2, 'gg', expect.any(Number)); }); }); diff --git a/webclient/src/websocket/events/game/gameSay.ts b/webclient/src/websocket/events/game/gameSay.ts index 72a585fef..7773720de 100644 --- a/webclient/src/websocket/events/game/gameSay.ts +++ b/webclient/src/websocket/events/game/gameSay.ts @@ -3,5 +3,5 @@ import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameSay(data: Event_GameSay, meta: GameEventMeta): void { - WebClient.instance.response.game.gameSay(meta.gameId, meta.playerId, data.message); + WebClient.instance.response.game.gameSay(meta.gameId, meta.playerId, data.message, Date.now()); } diff --git a/webclient/src/websocket/interfaces/WebClientResponse.ts b/webclient/src/websocket/interfaces/WebClientResponse.ts index cf3bd40d5..4000021ee 100644 --- a/webclient/src/websocket/interfaces/WebClientResponse.ts +++ b/webclient/src/websocket/interfaces/WebClientResponse.ts @@ -134,7 +134,7 @@ export interface IGameResponse { gameClosed(gameId: number): void; gameHostChanged(gameId: number, hostId: number): void; kicked(gameId: number): void; - gameSay(gameId: number, playerId: number, message: string): void; + gameSay(gameId: number, playerId: number, message: string, timeReceived: number): void; cardMoved(gameId: number, playerId: number, data: Event_MoveCard): void; cardFlipped(gameId: number, playerId: number, data: Event_FlipCard): void; cardDestroyed(gameId: number, playerId: number, data: Event_DestroyCard): void; diff --git a/webclient/src/websocket/services/KeepAliveService.spec.ts b/webclient/src/websocket/services/KeepAliveService.spec.ts index c14beed1b..81e023658 100644 --- a/webclient/src/websocket/services/KeepAliveService.spec.ts +++ b/webclient/src/websocket/services/KeepAliveService.spec.ts @@ -64,5 +64,14 @@ describe('KeepAliveService', () => { expect(service.endPingLoop).toHaveBeenCalled(); }); + + it('should clear previous interval when startPingLoop is called again', () => { + const clearSpy = vi.spyOn(globalThis, 'clearInterval'); + const previousCb = (service as KeepAliveInternal).keepalivecb; + + service.startPingLoop(interval, ping); + + expect(clearSpy).toHaveBeenCalledWith(previousCb); + }); }); }); diff --git a/webclient/src/websocket/services/KeepAliveService.ts b/webclient/src/websocket/services/KeepAliveService.ts index 4b275cf3c..03f42d2a1 100644 --- a/webclient/src/websocket/services/KeepAliveService.ts +++ b/webclient/src/websocket/services/KeepAliveService.ts @@ -13,6 +13,7 @@ export class KeepAliveService { } public startPingLoop(interval: number, ping: (onPong: () => void) => void): void { + this.endPingLoop(); this.keepalivecb = setInterval(() => { // check if the previous ping got no reply if (this.lastPingPending) { diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts index 7880b9099..fe65593d3 100644 --- a/webclient/src/websocket/services/ProtobufService.spec.ts +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -112,6 +112,67 @@ describe('ProtobufService', () => { expect((service as ProtobufInternal).cmdId).toBe(0); expect((service as ProtobufInternal).pendingCommands.size).toBe(0); }); + + it('returns true when command is sent', () => { + const service = new ProtobufService(mockSocket); + const result = service.sendCommand(create(CommandContainerSchema), vi.fn()); + expect(result).toBe(true); + }); + + it('returns false when transport is closed', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const result = service.sendCommand(create(CommandContainerSchema), vi.fn()); + expect(result).toBe(false); + }); + }); + + describe('send*Command when transport is closed', () => { + it('calls onError when sendSessionCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendSessionCommand(sessionExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('calls onError when sendRoomCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendRoomCommand(42, roomExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('calls onError when sendGameCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendGameCommand(7, gameExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('calls onError when sendModeratorCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendModeratorCommand(moderatorExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('calls onError when sendAdminCommand is dropped', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + const onError = vi.fn(); + service.sendAdminCommand(adminExt, {}, { onError }); + expect(onError).toHaveBeenCalledWith(-1, expect.any(Object)); + }); + + it('does not throw when command is dropped with no options', () => { + const service = new ProtobufService(mockSocket); + mockSocket.isOpen.mockReturnValue(false); + expect(() => service.sendSessionCommand(sessionExt, {})).not.toThrow(); + }); }); describe('sendSessionCommand', () => { @@ -311,9 +372,9 @@ describe('ProtobufService', () => { expect(processGameEvent).toHaveBeenCalled(); }); - it('logs unknown message types (default case)', () => { + it('warns on unknown message types (default case)', () => { const service = new ProtobufService(mockSocket); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.mocked(fromBinary).mockReturnValue( create(ServerMessageSchema, { diff --git a/webclient/src/websocket/services/ProtobufService.ts b/webclient/src/websocket/services/ProtobufService.ts index 05437f80e..2ffabacad 100644 --- a/webclient/src/websocket/services/ProtobufService.ts +++ b/webclient/src/websocket/services/ProtobufService.ts @@ -54,11 +54,7 @@ export class ProtobufService { const gameCmd = create(GameCommandSchema); setExtension(gameCmd, ext, value); const cmd = create(CommandContainerSchema, { gameId, gameCommand: [gameCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } public sendRoomCommand( @@ -70,11 +66,7 @@ export class ProtobufService { const roomCmd = create(RoomCommandSchema); setExtension(roomCmd, ext, value); const cmd = create(CommandContainerSchema, { roomId, roomCommand: [roomCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } public sendSessionCommand( @@ -85,11 +77,7 @@ export class ProtobufService { const sesCmd = create(SessionCommandSchema); setExtension(sesCmd, ext, value); const cmd = create(CommandContainerSchema, { sessionCommand: [sesCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } public sendModeratorCommand( @@ -100,11 +88,7 @@ export class ProtobufService { const modCmd = create(ModeratorCommandSchema); setExtension(modCmd, ext, value); const cmd = create(CommandContainerSchema, { moderatorCommand: [modCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } public sendAdminCommand( @@ -115,22 +99,31 @@ export class ProtobufService { const adminCmd = create(AdminCommandSchema); setExtension(adminCmd, ext, value); const cmd = create(CommandContainerSchema, { adminCommand: [adminCmd] }); - this.sendCommand(cmd, raw => { - if (options) { - handleResponse(ext.typeName, raw, options); - } - }); + this.dispatchCommand(ext.typeName, cmd, options); } - public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void) { + private dispatchCommand(typeName: string, cmd: CommandContainer, options?: CommandOptions): void { + const sent = this.sendCommand(cmd, raw => { + if (options) { + handleResponse(typeName, raw, options); + } + }); + + if (!sent) { + options?.onError?.(-1, {} as Response); + } + } + + public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void): boolean { if (!this.transport.isOpen()) { - return; + return false; } this.cmdId++; cmd.cmdId = BigInt(this.cmdId); this.pendingCommands.set(this.cmdId, callback); this.transport.send(toBinary(CommandContainerSchema, cmd)); + return true; } public handleMessageEvent({ data }: MessageEvent): void { @@ -153,7 +146,7 @@ export class ProtobufService { this.processGameEvent(msg.gameEventContainer); break; default: - console.log(msg); + console.warn('Unknown message type:', msg); break; } } diff --git a/webclient/src/websocket/services/WebSocketService.spec.ts b/webclient/src/websocket/services/WebSocketService.spec.ts index c565ac74e..553adbedf 100644 --- a/webclient/src/websocket/services/WebSocketService.spec.ts +++ b/webclient/src/websocket/services/WebSocketService.spec.ts @@ -198,6 +198,12 @@ describe('WebSocketService', () => { service.send(data); expect(mockInstance.send).toHaveBeenCalledWith(data); }); + + it('does not throw when socket is undefined (before connect)', () => { + const service = new WebSocketService(mockConfig); + const data = new Uint8Array([1, 2, 3]); + expect(() => service.send(data)).not.toThrow(); + }); }); describe('checkReadyState', () => { diff --git a/webclient/src/websocket/services/WebSocketService.ts b/webclient/src/websocket/services/WebSocketService.ts index cf2321667..32ebc5c33 100644 --- a/webclient/src/websocket/services/WebSocketService.ts +++ b/webclient/src/websocket/services/WebSocketService.ts @@ -54,6 +54,9 @@ export class WebSocketService { } public send(message: Uint8Array): void { + if (!this.socket) { + return; + } this.socket.send(message as unknown as ArrayBufferView); } From bd2382c94eb7d0c2fb5829afaca792c4a379dd0f Mon Sep 17 00:00:00 2001 From: seavor Date: Sat, 18 Apr 2026 10:14:31 -0500 Subject: [PATCH 2/3] refactor login flow and hooks, address autologin issues --- webclient/eslint.boundaries.mjs | 4 +- webclient/integration/src/app/helpers.tsx | 33 ++ .../src/app/login-autoconnect.spec.tsx | 192 +++++++++++ webclient/integration/src/helpers/setup.ts | 4 + .../src/services/dexie/hosts.spec.ts | 91 +++++ .../src/services/dexie/resetDexie.ts | 12 + .../src/services/dexie/settings.spec.ts | 69 ++++ .../src/{ => websocket}/admin.spec.ts | 6 +- .../{ => websocket}/authentication.spec.ts | 6 +- .../src/{ => websocket}/connection.spec.ts | 8 +- .../src/{ => websocket}/deck.spec.ts | 6 +- .../src/{ => websocket}/game.spec.ts | 6 +- .../src/{ => websocket}/keep-alive.spec.ts | 6 +- .../src/{ => websocket}/moderator.spec.ts | 6 +- .../{ => websocket}/password-reset.spec.ts | 6 +- .../src/{ => websocket}/rooms.spec.ts | 6 +- .../src/{ => websocket}/server-events.spec.ts | 4 +- .../src/{ => websocket}/users.spec.ts | 6 +- webclient/package-lock.json | 11 + webclient/package.json | 1 + .../__test-utils__/renderWithProviders.tsx | 13 +- .../src/components/KnownHosts/KnownHosts.tsx | 324 ++++++++---------- .../LanguageDropdown/LanguageDropdown.tsx | 7 +- webclient/src/containers/Login/Login.spec.tsx | 225 ++++++++++++ webclient/src/containers/Login/Login.tsx | 32 +- .../src/forms/LoginForm/LoginForm.spec.tsx | 100 ++++++ webclient/src/forms/LoginForm/LoginForm.tsx | 314 +++++++++-------- .../src/hooks/__mocks__/useKnownHosts.ts | 40 +++ webclient/src/hooks/__mocks__/useSettings.ts | 20 ++ webclient/src/hooks/index.ts | 5 +- webclient/src/hooks/useAutoConnect.spec.ts | 86 ----- webclient/src/hooks/useAutoConnect.ts | 33 -- webclient/src/hooks/useAutoLogin.spec.tsx | 169 +++++++++ webclient/src/hooks/useAutoLogin.ts | 76 ++++ webclient/src/hooks/useKnownHosts.spec.ts | 211 ++++++++++++ webclient/src/hooks/useKnownHosts.ts | 132 +++++++ webclient/src/hooks/useSettings.spec.ts | 98 ++++++ webclient/src/hooks/useSettings.ts | 52 +++ webclient/src/hooks/useSharedStore.spec.ts | 102 ++++++ webclient/src/hooks/useSharedStore.ts | 127 +++++++ webclient/src/hooks/useWebClient.tsx | 5 +- webclient/vite.config.ts | 3 + webclient/vitest.integration.config.ts | 6 +- 43 files changed, 2179 insertions(+), 484 deletions(-) create mode 100644 webclient/integration/src/app/helpers.tsx create mode 100644 webclient/integration/src/app/login-autoconnect.spec.tsx create mode 100644 webclient/integration/src/services/dexie/hosts.spec.ts create mode 100644 webclient/integration/src/services/dexie/resetDexie.ts create mode 100644 webclient/integration/src/services/dexie/settings.spec.ts rename webclient/integration/src/{ => websocket}/admin.spec.ts (92%) rename webclient/integration/src/{ => websocket}/authentication.spec.ts (97%) rename webclient/integration/src/{ => websocket}/connection.spec.ts (95%) rename webclient/integration/src/{ => websocket}/deck.spec.ts (95%) rename webclient/integration/src/{ => websocket}/game.spec.ts (98%) rename webclient/integration/src/{ => websocket}/keep-alive.spec.ts (92%) rename webclient/integration/src/{ => websocket}/moderator.spec.ts (95%) rename webclient/integration/src/{ => websocket}/password-reset.spec.ts (94%) rename webclient/integration/src/{ => websocket}/rooms.spec.ts (98%) rename webclient/integration/src/{ => websocket}/server-events.spec.ts (97%) rename webclient/integration/src/{ => websocket}/users.spec.ts (95%) create mode 100644 webclient/src/containers/Login/Login.spec.tsx create mode 100644 webclient/src/forms/LoginForm/LoginForm.spec.tsx create mode 100644 webclient/src/hooks/__mocks__/useKnownHosts.ts create mode 100644 webclient/src/hooks/__mocks__/useSettings.ts delete mode 100644 webclient/src/hooks/useAutoConnect.spec.ts delete mode 100644 webclient/src/hooks/useAutoConnect.ts create mode 100644 webclient/src/hooks/useAutoLogin.spec.tsx create mode 100644 webclient/src/hooks/useAutoLogin.ts create mode 100644 webclient/src/hooks/useKnownHosts.spec.ts create mode 100644 webclient/src/hooks/useKnownHosts.ts create mode 100644 webclient/src/hooks/useSettings.spec.ts create mode 100644 webclient/src/hooks/useSettings.ts create mode 100644 webclient/src/hooks/useSharedStore.spec.ts create mode 100644 webclient/src/hooks/useSharedStore.ts diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs index 05bb6cf83..d2ca95aa1 100644 --- a/webclient/eslint.boundaries.mjs +++ b/webclient/eslint.boundaries.mjs @@ -19,15 +19,15 @@ const types = (...types) => types.map((type) => ({ to: { type } })); const rules = [ { from: { type: 'generated' }, allow: [] }, + { from: { type: 'websocket' }, allow: types('generated') }, { from: { type: 'types' }, allow: types('generated', 'websocket') }, - { from: { type: 'websocket' }, allow: types('generated') }, { from: { type: 'store' }, allow: types('types') }, { from: { type: 'api' }, allow: types('store', 'types', 'websocket') }, - { 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') }, { from: { type: 'components' }, diff --git a/webclient/integration/src/app/helpers.tsx b/webclient/integration/src/app/helpers.tsx new file mode 100644 index 000000000..ed4484e3a --- /dev/null +++ b/webclient/integration/src/app/helpers.tsx @@ -0,0 +1,33 @@ +// Shared render helper for the app integration suite. +// +// Two non-obvious choices: +// +// 1. WebClientContext is provided directly (not via production +// ) 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( + + {ui} + , + { store } + ); +} + +export { store }; diff --git a/webclient/integration/src/app/login-autoconnect.spec.tsx b/webclient/integration/src/app/login-autoconnect.spec.tsx new file mode 100644 index 000000000..8f11c1843 --- /dev/null +++ b/webclient/integration/src/app/login-autoconnect.spec.tsx @@ -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 => { + 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 { autoLoginSession } from '../../../src/hooks/useAutoLogin'; +import { settingsStore } from '../../../src/hooks/useSettings'; +import { knownHostsStore } from '../../../src/hooks/useKnownHosts'; +import Login from '../../../src/containers/Login/Login'; +import { HostDTO, SettingDTO } from '@app/services'; +import { App } from '@app/types'; +import { ServerSelectors, ServerDispatch } from '@app/store'; +import { StatusEnum } from '@app/websocket'; + +import { 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(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(); + autoLoginSession.startupCheckRan = false; +}); + +describe('autoconnect — cold start', () => { + it('auto-logs in when Dexie has autoConnect=true + host with stored credentials', async () => { + await seedAutoConnect(); + + renderAppScreen(); + + await waitFor(() => { + expect(attempted()).toBe(true); + }); + }); + + it('does NOT attempt login when Dexie has no settings row', async () => { + renderAppScreen(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + autoLoginSession.startupCheckRan = false; + + renderAppScreen(); + await waitFor(() => { + expect(attempted()).toBe(true); + }); + }); +}); diff --git a/webclient/integration/src/helpers/setup.ts b/webclient/integration/src/helpers/setup.ts index 8e4413d98..190f29d97 100644 --- a/webclient/integration/src/helpers/setup.ts +++ b/webclient/integration/src/helpers/setup.ts @@ -9,6 +9,10 @@ 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'; diff --git a/webclient/integration/src/services/dexie/hosts.spec.ts b/webclient/integration/src/services/dexie/hosts.spec.ts new file mode 100644 index 000000000..ffedeb73b --- /dev/null +++ b/webclient/integration/src/services/dexie/hosts.spec.ts @@ -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 => ({ + 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); + }); +}); diff --git a/webclient/integration/src/services/dexie/resetDexie.ts b/webclient/integration/src/services/dexie/resetDexie.ts new file mode 100644 index 000000000..52ef321b1 --- /dev/null +++ b/webclient/integration/src/services/dexie/resetDexie.ts @@ -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 { + await Promise.all([ + dexieService.settings.clear(), + dexieService.hosts.clear(), + ]); +} diff --git a/webclient/integration/src/services/dexie/settings.spec.ts b/webclient/integration/src/services/dexie/settings.spec.ts new file mode 100644 index 000000000..a3744063f --- /dev/null +++ b/webclient/integration/src/services/dexie/settings.spec.ts @@ -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'); + }); +}); diff --git a/webclient/integration/src/admin.spec.ts b/webclient/integration/src/websocket/admin.spec.ts similarity index 92% rename from webclient/integration/src/admin.spec.ts rename to webclient/integration/src/websocket/admin.spec.ts index f67bcfc6f..2c2e02ff1 100644 --- a/webclient/integration/src/admin.spec.ts +++ b/webclient/integration/src/websocket/admin.spec.ts @@ -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', () => { diff --git a/webclient/integration/src/authentication.spec.ts b/webclient/integration/src/websocket/authentication.spec.ts similarity index 97% rename from webclient/integration/src/authentication.spec.ts rename to webclient/integration/src/websocket/authentication.spec.ts index c5149c809..2da411aad 100644 --- a/webclient/integration/src/authentication.spec.ts +++ b/webclient/integration/src/websocket/authentication.spec.ts @@ -8,13 +8,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; -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, { diff --git a/webclient/integration/src/connection.spec.ts b/webclient/integration/src/websocket/connection.spec.ts similarity index 95% rename from webclient/integration/src/connection.spec.ts rename to webclient/integration/src/websocket/connection.spec.ts index 31cd41b83..2304df399 100644 --- a/webclient/integration/src/connection.spec.ts +++ b/webclient/integration/src/websocket/connection.spec.ts @@ -9,7 +9,7 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum } from '@app/websocket'; -import { PROTOCOL_VERSION } from '../../src/websocket/config'; +import { PROTOCOL_VERSION } from '../../../src/websocket/config'; import { getMockWebSocket, @@ -17,14 +17,14 @@ import { openMockWebSocket, setPendingOptions, connectAndHandshake, -} from './helpers/setup'; +} from '../helpers/setup'; import type { WebSocketConnectOptions } from '@app/websocket'; import { WebSocketConnectReason } from '@app/websocket'; import { buildSessionEventMessage, deliverMessage, -} from './helpers/protobuf-builders'; -import { findLastSessionCommand } from './helpers/command-capture'; +} from '../helpers/protobuf-builders'; +import { findLastSessionCommand } from '../helpers/command-capture'; function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions { return { diff --git a/webclient/integration/src/deck.spec.ts b/webclient/integration/src/websocket/deck.spec.ts similarity index 95% rename from webclient/integration/src/deck.spec.ts rename to webclient/integration/src/websocket/deck.spec.ts index 76ed032be..a7a8da0eb 100644 --- a/webclient/integration/src/deck.spec.ts +++ b/webclient/integration/src/websocket/deck.spec.ts @@ -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', () => { diff --git a/webclient/integration/src/game.spec.ts b/webclient/integration/src/websocket/game.spec.ts similarity index 98% rename from webclient/integration/src/game.spec.ts rename to webclient/integration/src/websocket/game.spec.ts index eb88e6b2b..4775b5332 100644 --- a/webclient/integration/src/game.spec.ts +++ b/webclient/integration/src/websocket/game.spec.ts @@ -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( diff --git a/webclient/integration/src/keep-alive.spec.ts b/webclient/integration/src/websocket/keep-alive.spec.ts similarity index 92% rename from webclient/integration/src/keep-alive.spec.ts rename to webclient/integration/src/websocket/keep-alive.spec.ts index c4889e0fc..90ee634e0 100644 --- a/webclient/integration/src/keep-alive.spec.ts +++ b/webclient/integration/src/websocket/keep-alive.spec.ts @@ -6,13 +6,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum } from '@app/websocket'; -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', () => { diff --git a/webclient/integration/src/moderator.spec.ts b/webclient/integration/src/websocket/moderator.spec.ts similarity index 95% rename from webclient/integration/src/moderator.spec.ts rename to webclient/integration/src/websocket/moderator.spec.ts index c90c3b026..088d254dc 100644 --- a/webclient/integration/src/moderator.spec.ts +++ b/webclient/integration/src/websocket/moderator.spec.ts @@ -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', () => { diff --git a/webclient/integration/src/password-reset.spec.ts b/webclient/integration/src/websocket/password-reset.spec.ts similarity index 94% rename from webclient/integration/src/password-reset.spec.ts rename to webclient/integration/src/websocket/password-reset.spec.ts index ec842c3ec..998390304 100644 --- a/webclient/integration/src/password-reset.spec.ts +++ b/webclient/integration/src/websocket/password-reset.spec.ts @@ -8,13 +8,13 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; -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', () => { diff --git a/webclient/integration/src/rooms.spec.ts b/webclient/integration/src/websocket/rooms.spec.ts similarity index 98% rename from webclient/integration/src/rooms.spec.ts rename to webclient/integration/src/websocket/rooms.spec.ts index 5bd776d08..77236d8c7 100644 --- a/webclient/integration/src/rooms.spec.ts +++ b/webclient/integration/src/websocket/rooms.spec.ts @@ -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<{ diff --git a/webclient/integration/src/server-events.spec.ts b/webclient/integration/src/websocket/server-events.spec.ts similarity index 97% rename from webclient/integration/src/server-events.spec.ts rename to webclient/integration/src/websocket/server-events.spec.ts index 16ac3a6bf..a0eefebf1 100644 --- a/webclient/integration/src/server-events.spec.ts +++ b/webclient/integration/src/websocket/server-events.spec.ts @@ -8,11 +8,11 @@ import { Data } from '@app/types'; import { store } from '@app/store'; import { StatusEnum } from '@app/websocket'; -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', () => { diff --git a/webclient/integration/src/users.spec.ts b/webclient/integration/src/websocket/users.spec.ts similarity index 95% rename from webclient/integration/src/users.spec.ts rename to webclient/integration/src/websocket/users.spec.ts index 36062963d..e1cc5777a 100644 --- a/webclient/integration/src/users.spec.ts +++ b/webclient/integration/src/websocket/users.spec.ts @@ -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, { diff --git a/webclient/package-lock.json b/webclient/package-lock.json index ed7f1854a..8697b0d3e 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -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", diff --git a/webclient/package.json b/webclient/package.json index 774cef831..422b2e517 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -71,6 +71,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", diff --git a/webclient/src/__test-utils__/renderWithProviders.tsx b/webclient/src/__test-utils__/renderWithProviders.tsx index c7ae2d242..4d0546083 100644 --- a/webclient/src/__test-utils__/renderWithProviders.tsx +++ b/webclient/src/__test-utils__/renderWithProviders.tsx @@ -14,12 +14,17 @@ import { actionReducer } from '../store/actions'; import { ToastProvider } from '../components/Toast/ToastContext'; import type { RootState } from '../store/store'; -// Minimal i18n instance for tests — returns keys as-is. +// Minimal i18n instance for tests — returns keys as-is. A non-empty +// `resources` entry is required so i18next registers `en-US` as a known +// language; otherwise `i18n.resolvedLanguage` stays `undefined`, which +// LanguageDropdown seeds into a MUI Select and MUI warns "out-of-range +// value `undefined`". Value is an empty translation map, since tests +// already assert on i18n keys directly. const testI18n = i18n.createInstance(); testI18n.use(initReactI18next).init({ - lng: 'en', - resources: {}, - fallbackLng: 'en', + lng: 'en-US', + resources: { 'en-US': { translation: {} } }, + fallbackLng: 'en-US', interpolation: { escapeValue: false }, }); diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index c16c4f67a..416de9875 100644 --- a/webclient/src/components/KnownHosts/KnownHosts.tsx +++ b/webclient/src/components/KnownHosts/KnownHosts.tsx @@ -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 { DefaultHosts, HostDTO, getHostPort } 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(null); + const [testingConnection, setTestingConnection] = useState(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(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 => { - 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 = { ...getHostPort(hostsState.selectedHost) }; - webClient.request.authentication.testConnection(options); - } - return ( - - { touched && ( -
- { - (error && -
- {error} - -
- ) || - - (warning &&
{warning}
) - } + + {touched && ( +
+ {(error && ( +
+ {error} + +
+ )) || + (warning &&
{warning}
)}
- ) } + )} - { t('KnownHosts.label') } + {t('KnownHosts.label')}
@@ -280,9 +250,15 @@ const KnownHosts = (props) => { onSubmit={handleDialogSubmit} handleClose={closeKnownHostDialog} /> - setShowCreateToast(false)}>{ t('KnownHosts.toast', { mode: 'created' }) } - setShowDeleteToast(false)}>{ t('KnownHosts.toast', { mode: 'deleted' }) } - setShowEditToast(false)}>{ t('KnownHosts.toast', { mode: 'edited' }) } + setShowCreateToast(false)}> + {t('KnownHosts.toast', { mode: 'created' })} + + setShowDeleteToast(false)}> + {t('KnownHosts.toast', { mode: 'deleted' })} + + setShowEditToast(false)}> + {t('KnownHosts.toast', { mode: 'edited' })} + ); }; diff --git a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx index d48aa2e7b..5043ca36c 100644 --- a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx +++ b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx @@ -11,7 +11,12 @@ import './LanguageDropdown.css'; const LanguageDropdown = () => { const { t, i18n } = useTranslation(); - const [language, setLanguage] = useState(i18n.resolvedLanguage); + // `resolvedLanguage` can be undefined when i18next hasn't matched the + // active lng against any registered resource yet — most often at the + // first render in tests with a minimal i18n instance. Fall back to + // `i18n.language` (always set to whatever was passed to init) and then + // to empty string so MUI's Select has a concrete, in-range value. + const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? ''); useEffect(() => { if (language !== i18n.resolvedLanguage) { diff --git a/webclient/src/containers/Login/Login.spec.tsx b/webclient/src/containers/Login/Login.spec.tsx new file mode 100644 index 000000000..4f59e37b1 --- /dev/null +++ b/webclient/src/containers/Login/Login.spec.tsx @@ -0,0 +1,225 @@ +/** + * Login auto-connect integration tests. + * + * Exercises the full wire from `useAutoLogin` through the Login container + * into `webClient.request.authentication.login`. Scenarios mirror the user- + * visible cycles we care about: + * - cold start with / without auto-connect + * - logout within the same session must NOT re-auto-connect + * - page refresh (fresh JS context) resets the gate + * + * The startup-check gate lives on the `autoLoginSession` object exported by + * `useAutoLogin.ts`. Tests flip it back to false in `beforeEach` to stand in + * for a page refresh between scenarios. `vi.resetModules()` would be the + * natural equivalent but is prohibitively slow in the full suite because it + * forces every imported module to re-evaluate. + */ + +import { act, waitFor } from '@testing-library/react'; + +import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__'; + +// Lets pending microtasks resolve inside an act() scope so that the state +// updates they trigger (useFormState subscribers, useFireOnce state, etc.) +// are captured. Without this, useAutoLogin's Promise.all resolves *after* +// render returns, and React warns "update ... was not wrapped in act". +const flushEffects = async (): Promise => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; +import { makeSettings, makeSettingsHook } from '../../hooks/__mocks__/useSettings'; +import { makeHost, makeKnownHostsHook } from '../../hooks/__mocks__/useKnownHosts'; +import { autoLoginSession } 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 () => { + // Absorb any state updates that lingered past the test body (e.g. + // useAutoLogin's Promise.all resolving a moment too late) so they're + // wrapped in act and don't trip React's warning during teardown. + await flushEffects(); +}); + +beforeEach(() => { + // "Page refresh" between tests: reset the session gate that useAutoLogin + // uses to prevent re-firing within a JS session. Production code only + // writes this flag once, from inside the startup effect; tests flip it + // back to false here to simulate a fresh browser tab. + autoLoginSession.startupCheckRan = false; + + // clearAllMocks in the global afterEach only clears call history; mock + // implementations (mockResolvedValue, mockReturnValue) persist. Reset + // them explicitly so a previous test's arming doesn't leak into this one. + hoisted.getSettings.mockReset(); + hoisted.getKnownHosts.mockReset(); + hoisted.useSettings.mockReset(); + 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(, { 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(, { 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(, { 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(); + + // First mount: Login appears, useAutoLogin fires login. + const first = renderWithProviders(, { preloadedState: disconnectedState }); + await waitFor(() => { + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + + // Simulate arriving at /server and then logging out: Login unmounts, + // then a fresh Login mounts again with disconnected state. + first.unmount(); + renderWithProviders(, { preloadedState: disconnectedState }); + + await flushEffects(); + + // No second login call — the session gate is latched. + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + + test('does not auto-connect when user enabled autoConnect mid-session and then logged out', async () => { + // Scenario: user manually logs in with autoConnect=false. They tick the + // auto-connect checkbox during that session (the setting flips true). + // They log out. Returning to /login must NOT auto-connect — the setting + // change was a preference for NEXT launch, not a signal to log in. + + // First mount: autoConnect=false, so the startup check runs and finds + // nothing to do. The gate latches anyway. + const first = renderWithProviders(, { preloadedState: disconnectedState }); + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + + first.unmount(); + + // Mid-session, user ticked the checkbox. Future getSettings resolves + // return the new value, but the session gate prevents a re-check. + hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); + + renderWithProviders(, { preloadedState: disconnectedState }); + await flushEffects(); + expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); + }); +}); + +describe('Login — refresh cycle', () => { + // `beforeEach` flips autoLoginSession.startupCheckRan back to false, which + // stands in for a page refresh. This test just re-asserts the positive + // case: a refresh re-enables auto-connect when the persisted preference + // still says yes. + test('a fresh session gate re-fires auto-login when conditions still hold', async () => { + armAutoConnect(); + + renderWithProviders(, { preloadedState: disconnectedState }); + + await waitFor(() => { + expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index cf0a20420..be097a7b8 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -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,9 +9,9 @@ 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, getHostPort, serverProps } from '@app/services'; +import { getHostPort, serverProps } from '@app/services'; import { App, Enriched } from '@app/types'; import { ServerSelectors, ServerTypes } from '@app/store'; import Layout from '../Layout/Layout'; @@ -66,12 +66,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(null); - const [rememberLogin, setRememberLogin] = useState(null); + const rememberLoginRef = useRef(null); + const knownHosts = useKnownHosts(); const [dialogState, setDialogState] = useState({ passwordResetRequestDialog: false, resetPasswordDialog: false, @@ -113,15 +115,17 @@ 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 = { @@ -139,18 +143,18 @@ 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({ diff --git a/webclient/src/forms/LoginForm/LoginForm.spec.tsx b/webclient/src/forms/LoginForm/LoginForm.spec.tsx new file mode 100644 index 000000000..ed6178eec --- /dev/null +++ b/webclient/src/forms/LoginForm/LoginForm.spec.tsx @@ -0,0 +1,100 @@ +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(); + 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( + , + { preloadedState: disconnectedState } + ); + + // After mount + all host-sync effects settle, the form has updated its + // local fields to reflect the selected host. What MUST NOT happen is a + // write to the persisted autoConnect setting. + expect(update).not.toHaveBeenCalled(); + }); + + 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( + , + { preloadedState: disconnectedState } + ); + + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/forms/LoginForm/LoginForm.tsx b/webclient/src/forms/LoginForm/LoginForm.tsx index f46764126..8323989b9 100644 --- a/webclient/src/forms/LoginForm/LoginForm.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.tsx @@ -1,29 +1,192 @@ 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); + + // Host-sync: when the selected host changes, mirror its username + stored- + // password hint into the form. Deliberately does NOT touch autoConnect — the + // persisted setting is decoupled from which host is currently picked. + 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]); + + // Mirror the persisted autoConnect setting into the form checkbox so the + // field reflects truth as soon as settings load. + useEffect(() => { + if (settings.status !== LoadingState.READY) { + return; + } + 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) => { + // When the user unchecks "remember password", the auto-connect checkbox + // can't meaningfully stay on (there are no saved credentials to use), so + // reflect that in the form UI. Note: this writes only to the form field, + // NOT to the persisted setting — toggling host-level remember is not a + // user intent to change the app-level auto-connect preference. + if (!checked && values.autoConnect) { + form.change('autoConnect', false); + } + + togglePasswordLabel(canUseStoredPassword(checked, values.password)); + }; + + // User-initiated toggle of the auto-connect checkbox. This is the ONLY path + // that writes to the persisted setting — wired directly to the Checkbox's + // native onChange (see JSX below), not to a listener, because + // OnChange fires on programmatic form.change calls too (host-sync effects + // etc.) and would leak those into Dexie. + 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 ( + +
+
+ + {onUserNameChange} +
+
+ setUseStoredPasswordLabel(false)} + onBlur={() => + togglePasswordLabel(canUseStoredPassword(values.remember, values.password)) + } + name="password" + type="password" + component={InputField} + autoComplete="new-password" + /> +
+
+ + {onRememberChange} + + +
+
+ +
+
+ + {({ input }) => ( + onUserToggleAutoConnect(checked, input.onChange)} + color="primary" + /> + } + /> + )} + +
+
+ + + ); +}; + +const LoginForm = (props: LoginFormProps) => { + const { t } = useTranslation(); + + const validate = (values: any) => { const errors: any = {}; if (!values.userName) { @@ -34,137 +197,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(); - onSubmit({ userName, ...values }); - } + props.onSubmit({ userName, ...values }); + }; return (
- {({ 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 ( - -
-
- - {onUserNameChange} -
-
- setUseStoredPasswordLabel(false)} - onBlur={() => togglePasswordLabel(useStoredPassword(values.remember, values.password))} - name='password' - type='password' - component={InputField} - autoComplete='new-password' - /> -
-
- - {onRememberChange} - - -
-
- - {setHost} -
-
- - {onAutoConnectChange} -
-
- -
- ) - }} + {({ handleSubmit, form }) => ( + + )} ); }; -interface LoginFormProps { - onSubmit: any; - disableSubmitButton: boolean, - onResetPassword: any; -} - export default LoginForm; diff --git a/webclient/src/hooks/__mocks__/useKnownHosts.ts b/webclient/src/hooks/__mocks__/useKnownHosts.ts new file mode 100644 index 000000000..341144939 --- /dev/null +++ b/webclient/src/hooks/__mocks__/useKnownHosts.ts @@ -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 => + ({ + 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 => { + const host = makeHost(); + return { hosts: [host], selectedHost: host, ...overrides }; +}; + +export const makeKnownHostsHook = (overrides: Partial = {}): 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>(() => + Promise.resolve(makeKnownHostsValue()) +); diff --git a/webclient/src/hooks/__mocks__/useSettings.ts b/webclient/src/hooks/__mocks__/useSettings.ts new file mode 100644 index 000000000..27d8a6c3f --- /dev/null +++ b/webclient/src/hooks/__mocks__/useSettings.ts @@ -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 => + ({ user: '*app', autoConnect: false, save: vi.fn(), ...overrides }) as SettingDTO; + +export const makeSettingsHook = (overrides: Partial = {}): 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>(() => + Promise.resolve(makeSettings()) +); diff --git a/webclient/src/hooks/index.ts b/webclient/src/hooks/index.ts index 7c8e7c3ef..679bca669 100644 --- a/webclient/src/hooks/index.ts +++ b/webclient/src/hooks/index.ts @@ -1,5 +1,8 @@ -export * from './useAutoConnect'; +export * from './useAutoLogin'; export * from './useFireOnce'; +export * from './useKnownHosts'; export * from './useLocaleSort'; export * from './useReduxEffect'; +export * from './useSettings'; +export * from './useSharedStore'; export * from './useWebClient'; diff --git a/webclient/src/hooks/useAutoConnect.spec.ts b/webclient/src/hooks/useAutoConnect.spec.ts deleted file mode 100644 index d6b09d0e8..000000000 --- a/webclient/src/hooks/useAutoConnect.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { renderHook, act, waitFor } from '@testing-library/react'; -import { useAutoConnect } from './useAutoConnect'; - -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' }, -})); - -describe('useAutoConnect', () => { - beforeEach(() => { - storedSetting = null; - mockSave.mockClear(); - }); - - test('returns undefined initially, then resolves to stored autoConnect value', async () => { - storedSetting = { user: '*app', autoConnect: true, save: mockSave }; - - const { result } = renderHook(() => useAutoConnect()); - - expect(result.current[0]).toBeUndefined(); - - await waitFor(() => { - expect(result.current[0]).toBe(true); - }); - }); - - test('creates and saves a new SettingDTO when none exists', async () => { - storedSetting = null; - - const { result } = renderHook(() => useAutoConnect()); - - await waitFor(() => { - expect(result.current[0]).toBe(false); - }); - - // save() called once for the newly created setting - expect(mockSave).toHaveBeenCalledTimes(1); - }); - - test('persists when setAutoConnect is called', async () => { - storedSetting = { user: '*app', autoConnect: false, save: mockSave }; - - const { result } = renderHook(() => useAutoConnect()); - - await waitFor(() => { - expect(result.current[0]).toBe(false); - }); - - mockSave.mockClear(); - - act(() => { - result.current[1](true); - }); - - await waitFor(() => { - expect(result.current[0]).toBe(true); - }); - - expect(mockSave).toHaveBeenCalled(); - }); - - test('does not save redundantly on initial mount when setting exists', async () => { - storedSetting = { user: '*app', autoConnect: true, save: mockSave }; - - renderHook(() => useAutoConnect()); - - await waitFor(() => { - expect(mockSave).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/webclient/src/hooks/useAutoConnect.ts b/webclient/src/hooks/useAutoConnect.ts deleted file mode 100644 index a24a0b80f..000000000 --- a/webclient/src/hooks/useAutoConnect.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; - -import { SettingDTO } from '@app/services'; -import { App } from '@app/types'; - -export function useAutoConnect(): [boolean | undefined, Dispatch>] { - const [setting, setSetting] = useState(undefined); - const [autoConnect, setAutoConnect] = useState(undefined); - const prevAutoConnectRef = useRef(undefined); - - useEffect(() => { - SettingDTO.get(App.APP_USER).then((loaded: SettingDTO) => { - if (!loaded) { - loaded = new SettingDTO(App.APP_USER); - loaded.save(); - } - - setSetting(loaded); - setAutoConnect(loaded.autoConnect); - prevAutoConnectRef.current = loaded.autoConnect; - }); - }, []); - - useEffect(() => { - if (setting && autoConnect !== prevAutoConnectRef.current) { - prevAutoConnectRef.current = autoConnect; - setting.autoConnect = autoConnect; - setting.save(); - } - }, [autoConnect]); - - return [autoConnect, setAutoConnect]; -} diff --git a/webclient/src/hooks/useAutoLogin.spec.tsx b/webclient/src/hooks/useAutoLogin.spec.tsx new file mode 100644 index 000000000..064f683d3 --- /dev/null +++ b/webclient/src/hooks/useAutoLogin.spec.tsx @@ -0,0 +1,169 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +vi.mock('./useSettings'); +vi.mock('./useKnownHosts'); + +type AnyRecord = Record; + +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 the module-level hasFiredThisSession flag 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 () => { + // Regression: the flag tracks whether the startup check RAN, not whether + // it FIRED. Without that distinction, a first-session manual login (where + // the hook saw conditions unmet) would leave the flag unset, and the + // next mount (after logout) would find conditions met and auto-connect. + const onLogin = vi.fn(); + + // First mount: autoConnect=false, so the check runs but doesn't fire. + configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); + const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + + // User logs in manually and later hits logout; Login re-mounts with + // autoConnect now flipped on (they ticked the box during the session). + unmount(); + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); + + test('ticking the auto-connect checkbox after mount does NOT trigger a login', async () => { + // This is the specific regression: editing the persisted preference is a + // settings write, not a "log in now" signal. Because useAutoLogin reads + // via whenReady (one-shot) instead of subscribing, a subsequent settings + // change cannot re-run the orchestrator. + const onLogin = vi.fn(); + configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); + + const { rerender } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + + // Swap to a "settings.autoConnect=true" world and rerender. Since + // getSettings is a one-shot that already resolved with the old value, + // changing its mockResolvedValue doesn't retroactively matter. + configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); + rerender(); + + await Promise.resolve(); + await Promise.resolve(); + expect(onLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/hooks/useAutoLogin.ts b/webclient/src/hooks/useAutoLogin.ts new file mode 100644 index 000000000..bf4944dca --- /dev/null +++ b/webclient/src/hooks/useAutoLogin.ts @@ -0,0 +1,76 @@ +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; +} + +// Auto-login is a *startup* concern — the persisted preference is consulted +// once per JS session, after both stores have loaded. A logout within the +// same session is an explicit user action; returning to /login should not +// re-auto-connect (matches Cockatrice desktop behaviour). The flag is +// module-scope so it persists across Login remounts and is naturally reset +// on page refresh, which is the one time we do want another try. +// +// The flag tracks whether the *check* has run, not whether it *fired* — a +// manual first login followed by a logout must not re-trigger auto-login +// either, so the outcome of the check is irrelevant; only that it happened. +// +// Exported as a mutable object (rather than a bare `let`) so integration +// tests can reset `startupCheckRan = false` between scenarios without +// resorting to `vi.resetModules`, which is prohibitively slow in the full +// suite. Production code only writes the flag from inside the effect. +export const autoLoginSession = { startupCheckRan: false }; + +// Deliberately does NOT subscribe to the settings / known-hosts stores — +// user actions that change those stores (ticking the auto-connect checkbox, +// picking a different host) are preference edits, not "log in now" signals. +export function useAutoLogin( + onLogin: (values: LoginFormValues) => void, + connectionAttemptMade: boolean, +): void { + useEffect(() => { + if (autoLoginSession.startupCheckRan) { + return; + } + if (connectionAttemptMade) { + return; + } + + let cancelled = false; + + Promise.all([getSettings(), getKnownHosts()]).then(([settings, hosts]) => { + if (cancelled || autoLoginSession.startupCheckRan) { + return; + } + autoLoginSession.startupCheckRan = 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]); +} diff --git a/webclient/src/hooks/useKnownHosts.spec.ts b/webclient/src/hooks/useKnownHosts.spec.ts new file mode 100644 index 000000000..40de6a8af --- /dev/null +++ b/webclient/src/hooks/useKnownHosts.spec.ts @@ -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; +}; + +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>(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); + }); +}); diff --git a/webclient/src/hooks/useKnownHosts.ts b/webclient/src/hooks/useKnownHosts.ts new file mode 100644 index 000000000..c7e95c89c --- /dev/null +++ b/webclient/src/hooks/useKnownHosts.ts @@ -0,0 +1,132 @@ +import { DefaultHosts, HostDTO } from '@app/services'; +import { App } from '@app/types'; + +import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; + +// Shared-store scope justification: multiple components on the login screen +// read the same host list and selected host simultaneously (KnownHosts +// dropdown, LoginForm's host-sync effect, useAutoLogin, and the Login +// container's post-login write). Collapsing to useState inside one component +// would duplicate Dexie reads and race on lastSelected updates — exactly the +// bug we set out to fix. +export interface KnownHostsValue { + hosts: HostDTO[]; + selectedHost: HostDTO; +} + +const loadAll = async (): Promise => { + let hosts = await HostDTO.getAll(); + if (!hosts?.length) { + await HostDTO.bulkAdd(DefaultHosts); + hosts = await HostDTO.getAll(); + } + return hosts; +}; + +const normalize = async (hosts: HostDTO[]): Promise => { + const existing = hosts.find((h) => h.lastSelected); + if (existing) { + return { hosts, selectedHost: existing }; + } + + // No row marked lastSelected yet (first-ever load after seeding, or legacy + // data). Pin hosts[0] and persist so subsequent boots are deterministic. + const selected = hosts[0]; + selected.lastSelected = true; + await selected.save(); + return { hosts, selectedHost: selected }; +}; + +// Exported for integration-test reset; see settingsStore for the rationale. +export const knownHostsStore = createSharedStore(async () => { + const hosts = await loadAll(); + return normalize(hosts); +}); +const store = knownHostsStore; + +export type KnownHostsHook = Loadable & { + select: (id: number) => Promise; + add: (host: App.Host) => Promise; + update: (id: number, patch: Partial) => Promise; + remove: (id: number) => Promise; +}; + +// Guard for mutators. Mutators run outside React render, so we can't gate +// them through the hook's status; peek + throw is the fail-fast alternative. +const requireValue = (method: string): KnownHostsValue => { + const current = store.peek(); + if (!current) { + throw new Error(`useKnownHosts.${method} called before hosts loaded`); + } + return current; +}; + +const select = async (id: number): Promise => { + 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[] = []; + 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 => { + 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): Promise => { + 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 => { + 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 }; +} + +// Non-reactive one-shot accessor, mirroring getSettings. See the comment on +// that export in useSettings.ts for the rationale. +export const getKnownHosts = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useSettings.spec.ts b/webclient/src/hooks/useSettings.spec.ts new file mode 100644 index 000000000..6fe9361f8 --- /dev/null +++ b/webclient/src/hooks/useSettings.spec.ts @@ -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(); + }); + }); +}); diff --git a/webclient/src/hooks/useSettings.ts b/webclient/src/hooks/useSettings.ts new file mode 100644 index 000000000..9636634cf --- /dev/null +++ b/webclient/src/hooks/useSettings.ts @@ -0,0 +1,52 @@ +import { SettingDTO } from '@app/services'; +import { App } from '@app/types'; + +import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; + +// First-time bootstrap: SettingDTO.get returns undefined when no row exists +// for the app user yet (fresh install, or a user who has never hit the +// settings code path before). We materialize a default DTO and persist it so +// subsequent loads always see a non-null row. +// +// Exported as `settingsStore` so integration tests can call +// `settingsStore.reset()` between scenarios — the module cache would +// otherwise serve stale data across per-test Dexie resets. Production code +// goes through `useSettings()` / `getSettings()` and doesn't touch this. +export const settingsStore = createSharedStore(async () => { + let loaded = await SettingDTO.get(App.APP_USER); + if (!loaded) { + loaded = new SettingDTO(App.APP_USER); + await loaded.save(); + } + return loaded; +}); +const store = settingsStore; + +export type SettingsHook = Loadable & { + update: (patch: Partial) => Promise; +}; + +export function useSettings(): SettingsHook { + const state = useSharedStore(store); + + const update = async (patch: Partial) => { + // Fail-fast if a caller tries to write before the initial load resolves. + // Shouldn't happen in normal flow (the checkbox is gated on the hook's + // ready status), so surface the bug loudly instead of silently no-oping. + const current = store.peek(); + if (!current) { + throw new Error('useSettings.update called before settings loaded'); + } + Object.assign(current, patch); + await current.save(); + store.setValue(current); + }; + + return { ...state, update }; +} + +// Non-reactive one-shot accessor. Use this from code that wants the loaded +// value exactly once and does NOT want to re-run when the user subsequently +// edits their settings — e.g. the auto-login orchestrator, which consults +// the persisted preference at startup only. +export const getSettings = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useSharedStore.spec.ts b/webclient/src/hooks/useSharedStore.spec.ts new file mode 100644 index 000000000..a104e16d8 --- /dev/null +++ b/webclient/src/hooks/useSharedStore.spec.ts @@ -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(); + }); +}); diff --git a/webclient/src/hooks/useSharedStore.ts b/webclient/src/hooks/useSharedStore.ts new file mode 100644 index 000000000..66505a168 --- /dev/null +++ b/webclient/src/hooks/useSharedStore.ts @@ -0,0 +1,127 @@ +import { useSyncExternalStore } from 'react'; + +export enum LoadingState { + LOADING = 'loading', + READY = 'ready', + ERROR = 'error', +} + +export interface Loadable { + status: LoadingState; + value?: T; + error?: Error; +} + +export interface SharedStore { + // Reactive surface: subscribe + snapshot back useSyncExternalStore so + // consuming components re-render on every store update. + subscribe: (cb: () => void) => () => void; + getSnapshot: () => Loadable; + + // One-shot surface: whenReady resolves with the initial loaded value and + // never fires again. Callers that only need "read once after init" (e.g. + // the auto-login orchestrator) use this to avoid subscribing to updates + // they don't care about — which would otherwise turn a user preference + // toggle into a re-evaluation of startup logic. + whenReady: () => Promise; + + // Mutator-side helpers, not for consumption inside render. + setValue: (value: T) => void; + peek: () => T | undefined; + + // Clear cached state and the resolved readyPromise; the next subscribe / + // whenReady call triggers a fresh load. In production nobody calls this; + // integration tests use it to discard per-test Dexie state without + // paying the cost of vi.resetModules across the whole dep graph. + reset: () => void; +} + +export function createSharedStore(load: () => Promise): SharedStore { + let state: Loadable = { status: LoadingState.LOADING }; + const subscribers = new Set<() => void>(); + let loadStarted = false; + + // whenReady is lazy: we only attach a promise once someone asks for one. + // This avoids Node's unhandled-rejection bookkeeping for stores whose + // loader fails but never had a whenReady caller. + let readyPromise: Promise | 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((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(store: SharedStore): Loadable { + return useSyncExternalStore(store.subscribe, store.getSnapshot); +} diff --git a/webclient/src/hooks/useWebClient.tsx b/webclient/src/hooks/useWebClient.tsx index 469f03dc9..37570345c 100644 --- a/webclient/src/hooks/useWebClient.tsx +++ b/webclient/src/hooks/useWebClient.tsx @@ -2,7 +2,10 @@ import { createContext, useContext, useState, ReactNode } from 'react'; import { WebClient } from '@app/websocket'; import { createWebClientRequest, createWebClientResponse } from '@app/api'; -const WebClientContext = createContext(null); +// Exported so integration tests can inject the WebClient singleton built +// by their shared setup without going through the production provider +// (which would attempt to `new WebClient(...)` a second time and throw). +export const WebClientContext = createContext(null); export function WebClientProvider({ children }: { children: ReactNode }) { const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse())); diff --git a/webclient/vite.config.ts b/webclient/vite.config.ts index 1c1157f0a..225e64adc 100644 --- a/webclient/vite.config.ts +++ b/webclient/vite.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ }, server: { open: true, + watch: { + ignored: ['build', 'coverage', 'integration'] + } }, test: { globals: true, diff --git a/webclient/vitest.integration.config.ts b/webclient/vitest.integration.config.ts index 0b88f927a..4308e2ccf 100644 --- a/webclient/vitest.integration.config.ts +++ b/webclient/vitest.integration.config.ts @@ -15,7 +15,11 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./integration/src/helpers/setup.ts'], - include: ['integration/src/**/*.spec.ts'], + include: ['integration/src/**/*.spec.{ts,tsx}'], + // App-suite tests render the full Login container against real Dexie + // (fake-indexeddb) + real WebClient. Under CI/disk load the default + // 5s timeout is tight; 10s leaves headroom without masking real hangs. + testTimeout: 10000, coverage: { provider: 'v8', reporter: ['text', 'html'], From ef6cea6f6ce9fac9048124f66f28984a267a7d37 Mon Sep 17 00:00:00 2001 From: seavor Date: Sat, 18 Apr 2026 15:32:50 -0500 Subject: [PATCH 3/3] cleanup testing utilities, documentation, and AI commentary --- .../instructions/webclient.instructions.md | 168 +++++++++++++++++ webclient/CLAUDE.md | 170 ------------------ webclient/README.md | 92 +++++----- webclient/architecture.png | Bin 54120 -> 0 bytes webclient/architecture/README.md | 70 ++++++++ webclient/architecture/detailed.mmd | 123 +++++++++++++ webclient/architecture/detailed.png | Bin 0 -> 62884 bytes webclient/architecture/flow.mmd | 41 +++++ webclient/architecture/flow.png | Bin 0 -> 94869 bytes webclient/architecture/simple.mmd | 58 ++++++ webclient/architecture/simple.png | Bin 0 -> 44175 bytes webclient/eslint.boundaries.mjs | 20 ++- .../src/app/login-autoconnect.spec.tsx | 10 +- webclient/integration/src/helpers/setup.ts | 23 +-- .../src/websocket/authentication.spec.ts | 14 +- .../src/websocket/connection.spec.ts | 22 ++- .../src/websocket/keep-alive.spec.ts | 10 +- .../src/websocket/password-reset.spec.ts | 14 +- .../src/websocket/server-events.spec.ts | 4 +- webclient/package.json | 6 +- webclient/src/__test-utils__/globalGuards.ts | 32 +--- webclient/src/__test-utils__/index.ts | 2 +- webclient/src/__test-utils__/mockWebClient.ts | 5 +- .../__test-utils__/renderWithProviders.tsx | 8 +- webclient/src/__test-utils__/storeFixtures.ts | 7 +- webclient/src/api/request/AdminRequestImpl.ts | 4 +- .../api/request/AuthenticationRequestImpl.ts | 70 +++----- webclient/src/api/request/GameRequestImpl.ts | 4 +- .../src/api/request/ModeratorRequestImpl.ts | 4 +- webclient/src/api/request/RoomsRequestImpl.ts | 4 +- .../src/api/request/SessionRequestImpl.ts | 4 +- webclient/src/api/request/index.ts | 4 +- .../src/api/response/AdminResponseImpl.ts | 4 +- .../src/api/response/GameResponseImpl.ts | 4 +- .../src/api/response/ModeratorResponseImpl.ts | 4 +- .../src/api/response/RoomResponseImpl.ts | 6 +- .../src/api/response/SessionResponseImpl.ts | 13 +- webclient/src/api/response/index.ts | 4 +- .../components/CardDetails/CardDetails.tsx | 2 +- .../LanguageDropdown/LanguageDropdown.tsx | 7 +- .../ScrollToBottomOnChanges.tsx | 1 - webclient/src/containers/App/AppShell.tsx | 1 - webclient/src/containers/Login/Login.spec.tsx | 52 +----- webclient/src/containers/Login/Login.tsx | 7 +- webclient/src/containers/Room/Room.tsx | 1 - .../src/forms/LoginForm/LoginForm.spec.tsx | 3 - webclient/src/forms/LoginForm/LoginForm.tsx | 19 +- webclient/src/hooks/useAutoLogin.spec.tsx | 16 +- webclient/src/hooks/useAutoLogin.ts | 26 +-- .../hooks/useFireOnce/useFireOnce.spec.tsx | 12 -- webclient/src/hooks/useKnownHosts.ts | 13 -- webclient/src/hooks/useSettings.ts | 16 -- webclient/src/hooks/useSharedStore.ts | 21 +-- webclient/src/hooks/useWebClient.tsx | 3 - webclient/src/index.tsx | 3 +- webclient/src/material-theme.ts | 34 ---- webclient/src/polyfills.ts | 16 +- webclient/src/services/CardImporterService.ts | 2 +- webclient/src/setupTests.ts | 61 +------ webclient/src/store/common/SortUtil.spec.ts | 5 - webclient/src/store/common/normalizers.ts | 25 +-- webclient/src/store/game/game.reducer.spec.ts | 12 -- webclient/src/store/game/game.reducer.ts | 7 - .../src/store/rooms/rooms.dispatch.spec.ts | 9 +- .../src/store/rooms/rooms.reducer.spec.ts | 11 -- webclient/src/store/rooms/rooms.reducer.tsx | 14 +- .../src/store/rooms/rooms.selectors.spec.ts | 1 - .../store/server/__mocks__/server-fixtures.ts | 11 +- .../src/store/server/server.actions.spec.ts | 5 +- .../src/store/server/server.dispatch.spec.ts | 11 +- webclient/src/store/server/server.dispatch.ts | 9 +- .../src/store/server/server.interfaces.ts | 3 +- .../src/store/server/server.reducer.spec.ts | 30 +--- webclient/src/store/server/server.reducer.ts | 9 +- .../src/store/server/server.selectors.spec.ts | 35 ++-- .../src/store/server/server.selectors.ts | 5 +- webclient/src/store/store.ts | 7 +- webclient/src/types/enriched.ts | 75 +------- webclient/src/types/server.ts | 7 - webclient/src/websocket/WebClient.spec.ts | 7 +- webclient/src/websocket/WebClient.ts | 10 +- .../src/websocket/__mocks__/WebClient.ts | 18 -- webclient/src/websocket/__mocks__/helpers.ts | 21 --- .../commands/admin/adminCommands.spec.ts | 12 -- .../moderator/moderatorCommands.spec.ts | 30 ---- .../commands/room/roomCommands.spec.ts | 12 -- .../websocket/commands/session/activate.ts | 4 +- .../src/websocket/commands/session/connect.ts | 2 +- .../session/forgotPasswordChallenge.ts | 4 +- .../commands/session/forgotPasswordRequest.ts | 4 +- .../commands/session/forgotPasswordReset.ts | 4 +- .../src/websocket/commands/session/login.ts | 4 +- .../websocket/commands/session/register.ts | 4 +- .../commands/session/requestPasswordSalt.ts | 4 +- .../session/sessionCommands-complex.spec.ts | 69 +++---- .../session/sessionCommands-simple.spec.ts | 1 - .../commands/session/updateStatus.ts | 2 +- .../src/websocket/events/game/attachCard.ts | 2 +- .../events/game/changeZoneProperties.ts | 2 +- .../src/websocket/events/game/createArrow.ts | 2 +- .../websocket/events/game/createCounter.ts | 2 +- .../src/websocket/events/game/createToken.ts | 2 +- .../src/websocket/events/game/delCounter.ts | 2 +- .../src/websocket/events/game/deleteArrow.ts | 2 +- .../src/websocket/events/game/destroyCard.ts | 2 +- .../src/websocket/events/game/drawCards.ts | 2 +- .../src/websocket/events/game/dumpZone.ts | 2 +- .../src/websocket/events/game/flipCard.ts | 2 +- .../src/websocket/events/game/gameClosed.ts | 2 +- .../websocket/events/game/gameHostChanged.ts | 2 +- .../src/websocket/events/game/gameSay.ts | 2 +- .../websocket/events/game/gameStateChanged.ts | 2 +- webclient/src/websocket/events/game/index.ts | 2 +- .../src/websocket/events/game/joinGame.ts | 2 +- webclient/src/websocket/events/game/kicked.ts | 2 +- .../src/websocket/events/game/leaveGame.ts | 2 +- .../src/websocket/events/game/moveCard.ts | 2 +- .../events/game/playerPropertiesChanged.ts | 2 +- .../src/websocket/events/game/revealCards.ts | 2 +- .../src/websocket/events/game/reverseTurn.ts | 2 +- .../src/websocket/events/game/rollDie.ts | 2 +- .../websocket/events/game/setActivePhase.ts | 2 +- .../websocket/events/game/setActivePlayer.ts | 2 +- .../src/websocket/events/game/setCardAttr.ts | 2 +- .../websocket/events/game/setCardCounter.ts | 2 +- .../src/websocket/events/game/setCounter.ts | 2 +- .../src/websocket/events/game/shuffle.ts | 2 +- .../events/session/connectionClosed.ts | 3 +- .../events/session/serverIdentification.ts | 4 +- .../events/session/sessionEvents.spec.ts | 46 +---- webclient/src/websocket/index.ts | 22 --- .../websocket/interfaces/WebSocketConfig.ts | 44 ----- .../services/ProtobufService.spec.ts | 4 - .../src/websocket/services/ProtobufService.ts | 2 +- .../services/WebSocketService.spec.ts | 2 +- .../websocket/services/WebSocketService.ts | 17 +- .../services/command-options.spec.ts | 6 - .../{interfaces => types}/ConnectOptions.ts | 6 - .../src/websocket/types/SignalContexts.ts | 17 ++ .../{interfaces => types}/StatusEnum.ts | 0 .../{interfaces => types}/WebClientConfig.ts | 0 .../{interfaces => types}/WebClientRequest.ts | 1 - .../WebClientResponse.ts | 0 .../src/websocket/types/WebSocketConfig.ts | 28 +++ webclient/src/websocket/types/index.ts | 1 + .../index.ts => types/namespace.ts} | 2 + .../src/websocket/utils/connectionState.ts | 2 +- .../src/websocket/utils/passwordHasher.ts | 3 +- webclient/tsconfig.json | 1 + webclient/vitest.integration.config.ts | 6 +- 150 files changed, 891 insertions(+), 1233 deletions(-) create mode 100644 .github/instructions/webclient.instructions.md delete mode 100644 webclient/CLAUDE.md delete mode 100644 webclient/architecture.png create mode 100644 webclient/architecture/README.md create mode 100644 webclient/architecture/detailed.mmd create mode 100644 webclient/architecture/detailed.png create mode 100644 webclient/architecture/flow.mmd create mode 100644 webclient/architecture/flow.png create mode 100644 webclient/architecture/simple.mmd create mode 100644 webclient/architecture/simple.png delete mode 100644 webclient/src/websocket/interfaces/WebSocketConfig.ts rename webclient/src/websocket/{interfaces => types}/ConnectOptions.ts (80%) create mode 100644 webclient/src/websocket/types/SignalContexts.ts rename webclient/src/websocket/{interfaces => types}/StatusEnum.ts (100%) rename webclient/src/websocket/{interfaces => types}/WebClientConfig.ts (100%) rename webclient/src/websocket/{interfaces => types}/WebClientRequest.ts (96%) rename webclient/src/websocket/{interfaces => types}/WebClientResponse.ts (100%) create mode 100644 webclient/src/websocket/types/WebSocketConfig.ts create mode 100644 webclient/src/websocket/types/index.ts rename webclient/src/websocket/{interfaces/index.ts => types/namespace.ts} (87%) diff --git a/.github/instructions/webclient.instructions.md b/.github/instructions/webclient.instructions.md new file mode 100644 index 000000000..378359ad8 --- /dev/null +++ b/.github/instructions/webclient.instructions.md @@ -0,0 +1,168 @@ +--- +applyTo: "webclient/**" +--- + +# Webclient instructions + +Applies to the React/TypeScript SPA in `webclient/` (Webatrice) that connects to a Servatrice server over a WebSocket. The package is self-contained; the only thing it shares with the rest of the repo (C++ desktop/server stack) is the protobuf protocol in `../libcockatrice_protocol/`. Anything outside `webclient/` is out of scope unless a task explicitly touches the protocol. + +Canonical AI-tool instruction surface for this package — invariants, policy, and external facts. When a source comment ends with `See .github/instructions/webclient.instructions.md#`, the section with that anchor lives here. Source comments tagged `// @critical` guard cross-file invariants; do not remove them without updating the relevant section. For commands, stack, and getting-started, see [webclient/README.md](../../webclient/README.md). + +## Architecture + +### Protocol layer + +`src/generated/proto/` is buf-generated from `../libcockatrice_protocol/` (gitignored, never hand-edit). Runtime is `@bufbuild/protobuf`. `src/types/` re-exports the bindings namespaced as `Data` (raw proto), `Enriched` (UI/domain composition — proto extended with client-only sibling fields), and `App` (pure client types; no proto dependency). Consumer pattern: `import { Data, Enriched, App } from '@app/types'` then `Data.ServerInfo_User`, `Enriched.GameEntry`, `App.RouteEnum`. **UI, store, hooks, and api code must import proto types through `@app/types`, never `@app/generated` directly. `src/websocket/` is the exception and imports `@app/generated` by design.** + +Websocket protocol/transport types (`StatusEnum`, `WebSocketConnectReason`, the `*ConnectOptions` family, signal payload contexts `PendingActivationContext` / `LoginSuccessContext`, `GameEventMeta`, the `I*Request` / `I*Response` contracts, `WebClientConfig`) live separately under `@app/websocket/types` and are exposed as a single `WebsocketTypes` namespace: `import { WebsocketTypes } from '@app/websocket/types'` then `WebsocketTypes.StatusEnum`, `WebsocketTypes.LoginConnectOptions`, etc. This is the only public surface of the websocket layer's types — store, hooks, api, and UI code must access websocket types through this namespace. **Don't re-export websocket types through `Enriched`**; that namespace is strictly UI/domain composition. `@app/websocket` (the broader index) only exposes runtime values (`WebClient`, command groups, `setPendingOptions`, etc.) — not types. Inside `src/websocket/` use relative paths to specific files under `types/` (e.g. `from '../types/StatusEnum'`) rather than either alias. + +### WebSocket layer (`src/websocket/`) + +Outbound commands in `commands//`, inbound handlers in `events//`, transport in `services/`, type declarations in `types/` (request/response contracts, `StatusEnum`, `WebSocketConnectReason`, connect-options union, signal contexts — all exposed to outside consumers as the `WebsocketTypes` namespace via `@app/websocket/types`). `WebClient` is a singleton; `new WebClient(...)` is called only inside `WebClientProvider` ([webclient/src/hooks/useWebClient.tsx](../../webclient/src/hooks/useWebClient.tsx)), never at module load. + +**Layering invariant (enforced, zero violations today — keep it that way):** + +1. Containers and components call `useWebClient()` to get the `WebClient`, then `client.request..(…)`. Never import from `@app/websocket` in UI code (`@app/websocket/types` is fine — type-only); never call `new WebClient(...)` outside `WebClientProvider`. +2. `src/api/request/*RequestImpl` methods translate UI intent into `src/websocket/commands/*` calls. `src/api/response/*ResponseImpl` methods are invoked by command callbacks and event handlers and dispatch to the store. +3. Only `*.dispatch.ts` helpers inside `src/store/` and the `*ResponseImpl` classes may touch the Redux store. + +If you find yourself wanting to skip a layer (dispatching from an event handler, calling `@app/websocket` from a container, reaching into `@app/generated` from a component/store), stop. `eslint.boundaries.mjs` enforces this via the element types `api` / `components` / `containers` / `hooks` / `services` / `store` / `types` / `websocket` / `websocket-types`; `websocket-types` is deliberately a narrower surface than `websocket` so UI/store can reach protocol types without pulling in transport internals. + +### ProtobufService: request/response correlation + +- Every outbound `CommandContainer` gets a monotonically increasing `cmdId` (cast to `BigInt` for the proto field — the wire type is `int64`). A `Map` stores the response handler keyed by that ID; `processServerResponse` looks up and invokes the callback on `RESPONSE`, then deletes the entry. The `number` ↔ `BigInt` sides stay in sync because the counter never realistically exceeds `Number.MAX_SAFE_INTEGER`. +- **No timeout or retry** at the transport layer. `resetCommands()` (called on reconnect) zeros `cmdId` and clears the pending map, silently dropping any in-flight callbacks. Reconnection resilience is a caller concern. +- `sendCommand` is a no-op write if the transport isn't open — it still registers the callback, so a stale pending entry can accumulate until the next reset. +- Inbound event dispatch is extension-based: `processRoomEvent` / `processSessionEvent` / `processGameEvent` iterate the relevant registry array (entries built with `makeEntry(ext, handler)`) and invoke the first handler whose extension is set on the message. Adding a new handler means appending a `makeEntry(ExtSymbol, handler)` line to the relevant registry. + +### command-options contract (`src/websocket/services/command-options.ts`) + +Every `send*Command` call accepts an optional `CommandOptions`: + +- `responseExt?: GenExtension` — the response payload extension to unwrap on success. +- `onSuccess?: (response: R, raw: Response) => void` — called when `responseCode === RespOk`. If `responseExt` is absent, the overload becomes `() => void`. +- `onResponseCode?: { [code: number]: (raw: Response) => void }` — per-error-code handlers. +- `onError?: (code: number, raw: Response) => void` — fallback for codes not in `onResponseCode`. +- `onResponse?: (raw: Response) => void` — if set, handles the raw response and bypasses every other hook. Use when you need the full response object regardless of code. + +If none of the hooks fire for a non-OK response, `handleResponse` logs via `console.error` with the command's proto type name. Practical rule: `onSuccess` funnels into a `*ResponseImpl` method, `onError` funnels into a `*ResponseImpl` method (usually to flip connection state or show a toast), `onResponse` is rare. + +### Public API for UI (`src/api/`) + +One `*RequestImpl` / `*ResponseImpl` class per scope (session / rooms / game / admin / moderator; plus `AuthenticationRequestImpl` — auth has no inbound events). Request methods return `void` — fire-and-forget; response flows back via `command-options` callbacks → `*ResponseImpl` → store. `*ResponseImpl` classes are the only place outside `src/store/*.dispatch.ts` that calls `*Dispatch` helpers. **UI code never imports from `src/api/` directly — use `useWebClient()`.** Never call `client.response.*` from UI. + +### State (`src/store/`) + +Slices: `server/`, `rooms/`, `game/`. Consumers import through the `@app/store` barrel (`GameSelectors`, `GameDispatch`, `GameTypes`, same for `Server`/`Rooms`). **Don't deep-import from `src/store//*` — add the symbol to the barrel's `index.ts` instead.** This rule generalizes: deep paths through any `@app/*` barrel target are a smell. + +Shape notes worth knowing before you touch a reducer: + +- `game/` is deeply normalized: `games[gameId].players[playerId].zones[zoneName].cards`. Selectors are plain getters so lookups stay O(1); `createSelector` is reserved for the few that build derived lists (e.g. `getActiveGameIds`). +- Selectors return module-scope `EMPTY_ARRAY` / `EMPTY_OBJECT` constants for missing data to preserve referential equality and avoid spurious re-renders. +- `rooms/` is *partially* normalized: rooms are keyed by ID but each room also carries denormalized `gameList` / `userList` arrays. Server updates often omit those lists, so the reducer merges new metadata while preserving the existing arrays. Standing TODO to clean this up. +- `server/` is mostly flat maps keyed by username (`messages`, `userInfo`, buddy/ignore lists) plus connection state. + +### Local persistence + +Dexie (IndexedDB) holds cards, sets, tokens, known hosts, and settings; separate from Redux (persists across reloads). Stubbed globally in `setupTests.ts` so unit specs never hit a real IndexedDB. + +### UI + +Route-level containers in `containers/` (one subdir per route plus `AppShell` root and shared `Layout`); routing in `containers/App/AppShellRoutes.tsx`. Two hooks are load-bearing: **`useWebClient`** (context accessor — the only way UI code is allowed to reach the server; see the Layering invariant) and **`useAutoLogin`** (owns the once-per-session gate; see [#startup--session-invariants](#startup--session-invariants)). `WebClientProvider` ([webclient/src/hooks/useWebClient.tsx](../../webclient/src/hooks/useWebClient.tsx)) owns the singleton; `WebClientContext` is exported so integration tests can inject a pre-built `WebClient`. UI kit: MUI v9 + `@emotion`; i18n via `react-i18next` + ICU (Transifex). + +## Build pipeline and generated files + +`npm start` / `npm run build` run `prestart`/`prebuild` hooks: `proto:generate` followed by `node prebuild.js`. `prebuild.js` writes `src/server-props.json` (git SHA), merges `src/**/*.i18n.json` into `src/i18n-default.json` (**throws on duplicate keys** — namespace your i18n keys), and copies country flags from `../cockatrice/resources/countries`. + +| File | Tracked? | Regenerate with | +|---|---|---| +| `src/generated/proto/**` | Gitignored | `npm run proto:generate` | +| `src/server-props.json` | Gitignored | `npm start` / `npm run build` (prebuild writes it) | +| `src/i18n-default.json` | **Committed** | `npm run translate` (or the prebuild hook) | + +`.env.development`, `.env.production`, `.env.test` exist but are empty. No `import.meta.env` configuration surface; server URLs resolve through the login UI / `server-props.json`. + +## Testing + +Vitest + Testing Library + jsdom. [webclient/src/setupTests.ts](../../webclient/src/setupTests.ts) registers jest-dom matchers and installs a global Dexie mock. + +Unit specs run under [webclient/vite.config.ts](../../webclient/vite.config.ts) with `test.isolate: true`: every spec file gets a fresh module graph, but tests **within the same file share it**. `vi.clearAllMocks()` (clears call logs) runs in the global `afterEach` and is safe. **Never add `vi.resetAllMocks()` to `setupTests.ts`** — it resets `vi.fn()` instances created inside `vi.mock(...)` factories at file load, breaking any spec that mocks something once (e.g. `store.dispatch`) and expects it to persist across tests in the file. + +Integration specs run under [webclient/vitest.integration.config.ts](../../webclient/vitest.integration.config.ts) via `npm run test:integration` — slower; exercise the wired-up `WebClient` against fakes in `src/__test-utils__/`. + +**Globals that leak within a file.** `vi.restoreAllMocks()` only restores `vi.spyOn` targets. Bare `Object.defineProperty` writes (e.g. on `window.location`) and global reassignments (e.g. `globalThis.WebSocket = ...`) leak between tests in the same file — `setupTests.ts` does not auto-restore them. Use `withMockLocation` from [webclient/src/__test-utils__/globalGuards.ts](../../webclient/src/__test-utils__/globalGuards.ts) for scoped overrides that clean up after themselves. + +**Shared scaffolding.** [webclient/src/__test-utils__/](../../webclient/src/__test-utils__/) provides render helpers, a mock-client builder, and global guards. Prefer these over hand-rolling providers — the integration suite depends on injecting pre-built `WebClient` instances through them. Store slices have co-located `__mocks__/fixtures.ts` files exposing `make*` factories that build protobuf messages via `create(Schema, overrides)`; reuse them instead of hand-rolling proto objects. + +`npm run golden` (lint + unit + integration) is the CI gate — run it before declaring work done. + +## Protocol changes + +When a task edits `.proto` files in `../libcockatrice_protocol/`: + +1. Run `npm run proto:generate`. +2. Update any command / event / `*RequestImpl` / `*ResponseImpl` code that consumes the changed messages. +3. Commit consumer changes only — `src/generated/proto/**` is gitignored and must not be committed. + +--- + +## Domain Knowledge + +Facts that can't be read off the code — external systems (Servatrice protocol, Protobuf-ES runtime, browser WebSocket semantics) and invariants the code relies on but cannot itself express. + +### Initialization order + +Protobuf-ES maps proto `int64` / `uint64` fields to native `BigInt`. `BigInt.prototype` has no `toJSON`, so `JSON.stringify` throws on any state that contains one — which Redux DevTools, structured logging, and React error-boundary dumps all do. [webclient/src/polyfills.ts](../../webclient/src/polyfills.ts) installs a `BigInt.prototype.toJSON` that returns `this.toString()`, coercing to string on serialize. + +Coercion is one-way: `JSON.parse` does not round-trip back to `BigInt`. That is acceptable because in-memory state still holds real `BigInt`s; only serialized surfaces (devtools, logs) see the coerced form. + +The polyfill must execute before any module creates the store, or the first devtools dump throws. Enforced by making `./polyfills` the first import in [webclient/src/index.tsx](../../webclient/src/index.tsx) and [webclient/src/setupTests.ts](../../webclient/src/setupTests.ts). + +### Startup / session invariants + +Product requirement: **auto-login runs at most once per JS session, and logout within the same session does NOT re-trigger it.** Only a full page refresh does. This matches the Cockatrice desktop client. + +The gate lives at module scope in [webclient/src/hooks/useAutoLogin.ts](../../webclient/src/hooks/useAutoLogin.ts) as `autoLoginGate.hasChecked`. It flips to `true` after the startup check completes, regardless of whether the check actually fired a login — so a check that determined "don't auto-connect" (preference off, no saved password, etc.) still latches the gate. The gate is exported as a mutable object so integration tests can reset it without `vi.resetModules()`. + +`useAutoLogin` consults settings via `getSettings()` (one-shot), not by subscribing to `settingsStore`. Editing the persisted auto-connect preference is a preference write, not a login signal. + +### Data structure invariants + +`Enriched.Room` and `Enriched.GameEntry` compose a raw proto (`info`) with client-side sibling fields. The TypeScript types cannot distinguish which fields stay fresh and which go stale, so this is a convention: + +- **`info` is a wire snapshot at one point in time.** For `Room` it's the last `UPDATE_ROOMS` / `JOIN_ROOM` payload; for `GameEntry` it's the `Event_GameJoined` payload. +- **Fields on `info` that evolve via later events immediately go stale.** Read the sibling, never `info.*`: + +| Type | Stale on `info` | Read instead | +|---|---|---| +| `Room` | `info.gameList` | `room.games` | +| `Room` | `info.userList` | `room.users` | +| `Room` | `info.gametypeList` | `room.gametypeMap` | +| `GameEntry` | `info.started` | `game.started` | +| `GameEntry` | `info.activePlayerId` etc. | top-level twin fields | + +Adding a new field that updates via events means adding a top-level twin in [webclient/src/types/enriched.ts](../../webclient/src/types/enriched.ts) and never reading `info.` after the initial snapshot. + +### Reducer merge rules + +Servatrice's `UPDATE_ROOMS` event carries room metadata only: the repeated `gameList` / `userList` / `gametypeList` collections on each `ServerInfo_Room` may be absent or stale. The reducer at [webclient/src/store/rooms/rooms.reducer.tsx](../../webclient/src/store/rooms/rooms.reducer.tsx) replaces `info`, `gametypeMap`, and `order` on existing rooms but preserves the normalized `games` and `users` maps, which are maintained by their own events (`updateGames`, `userJoined`, `userLeft`). + +### Shared store pattern + +`createSharedStore` in [webclient/src/hooks/useSharedStore.ts](../../webclient/src/hooks/useSharedStore.ts) exposes two surfaces with different semantics. Pick the right one per caller: + +- **`subscribe` / `getSnapshot` (via `useSharedStore`)** — reactive. The component re-renders on every store update. Use from inside render. +- **`whenReady()`** — one-shot. Resolves with the first loaded value, then never fires again. Use from code that must read the loaded value exactly once and must NOT re-run on later updates (notably, startup orchestrators reading persisted preferences). + +Subscribing in a startup orchestrator turns a later user action (ticking a preference) into a re-evaluation of startup logic, which is almost always wrong. + +### Protocol quirks + +Servatrice-side behavior the client has to accommodate: + +- **`ServerOptions` is a bitmask.** [webclient/src/websocket/utils/passwordHasher.ts](../../webclient/src/websocket/utils/passwordHasher.ts) `passwordSaltSupported` uses `&`, not `===`. Don't "fix" it. +- **System-injected user messages can omit the username** (e.g. ban notifications where the target is the current user, or server announcements). [webclient/src/store/common/normalizers.ts](../../webclient/src/store/common/normalizers.ts) `normalizeUserMessage` handles this at the dispatch layer so the store always holds a clean user-facing string. + +### WebSocket lifecycle + +A failed `WebSocket` connect fires both `onerror` and `onclose`. `onerror` runs first with the richer status; [webclient/src/websocket/services/WebSocketService.ts](../../webclient/src/websocket/services/WebSocketService.ts) guards the `onclose` handler with `hasReportedError` so the generic "Connection Closed" doesn't overwrite the specific "Connection Failed". The flag clears on `onopen` and at the end of each `onclose` cycle. diff --git a/webclient/CLAUDE.md b/webclient/CLAUDE.md deleted file mode 100644 index 8c1b59078..000000000 --- a/webclient/CLAUDE.md +++ /dev/null @@ -1,170 +0,0 @@ -# CLAUDE.md - -Guidance for Claude Code when working inside `webclient/` — the React/TypeScript SPA (Webatrice) that connects to a Servatrice server over a WebSocket. It is a self-contained application; the only thing it shares with the rest of the repo (C++ desktop/server stack) is the protobuf protocol in `../libcockatrice_protocol/`. Anything outside `webclient/` is out-of-scope unless a task explicitly touches the protocol. - -All commands below are run from this directory. - -## Commands - -```bash -npm start # Vite dev server (runs proto:generate + prebuild.js first) -npm run build # production build (same prebuild hooks) -npm test # vitest run (one-shot) -npm run test:watch # vitest watch -npm run lint # eslint src/ -npm run lint:fix -npm run golden # lint + test — the CI-equivalent gate to run before declaring work done -npm run proto:generate # `npx buf generate` — regenerates TS bindings into src/generated/proto -``` - -Single test file: `npx vitest run path/to/file.spec.ts`. Filter by name: `npx vitest run -t "partial test name"`. - -The dev server has `server.open: true`, so `npm start` pops a browser tab automatically. - -## Architecture - -The webclient is a Redux Toolkit + RxJS app. Its defining abstraction is a layered WebSocket client that speaks the Cockatrice protobuf protocol to Servatrice. Understanding the layering is essential before editing anything under `src/websocket/`, `src/api/`, or `src/store/`. - -### Protocol layer - -- **`src/generated/proto/`** — auto-generated from `../libcockatrice_protocol/**/*.proto` by `buf` (see `buf.gen.yaml`). Never edit by hand. Runtime is `@bufbuild/protobuf` (Protobuf-ES); the codebase was recently migrated off the older `protobufjs`, so if you find any stray references to the old runtime, they're bugs. -- **`src/types/` is the only public surface for generated code.** `src/types/data.ts` hand-rolls an `export *` barrel over every proto file that consumers use, and `src/types/index.ts` re-exports it as `Data`, plus `Enriched` (protocol types extended with client-only fields) and `App` (pure client types). Import as `import { Data, Enriched } from '@app/types'` and use `Data.Command_Login`, `Data.ServerInfo_User`, etc. **Never import directly from `@app/generated/proto/*` outside `src/types/`.** When a new proto file starts being consumed, add an `export *` line to `src/types/data.ts` — there is a standing TODO to replace this rollup with a protobuf-es plugin. - -### WebSocket layer (`src/websocket/`) - -A strict inbound/outbound split sits on top of a transport core: - -- **`services/`** — transport: `WebSocketService` (socket lifecycle), `ProtobufService` (encode/decode + request/response correlation), `KeepAliveService` (ping/pong), `command-options` (per-command response config). This layer has no knowledge of Redux. -- **`commands/`** — *outbound*. Organised by scope (`session/`, `room/`, `game/`, `admin/`, `moderator/`). Each command builds a protobuf request and hands it to `ProtobufService.send{Session,Room,Game,Admin,Moderator}Command` along with a `CommandOptions` describing how to handle the response. -- **`events/`** — *inbound*. Handlers for server-pushed events, same scopes. They translate protobuf events into calls on the persistence layer. -- **`persistence/`** — the **only** bridge from the websocket layer into app state. `SessionPersistence`, `RoomPersistence`, `GamePersistence`, `AdminPersistence`, `ModeratorPersistence` dispatch Redux actions and/or write to Dexie. -- **`WebClient.ts`** — singleton facade that wires the services, commands, events, and persistence together. - -**Layering invariant (enforced on this branch, not aspirational):** - -1. Containers and components call `src/api/*` services — never `src/websocket/*` directly. -2. Commands and event handlers call `*Persistence` methods — never `store.dispatch` directly. -3. Only `*.dispatch.ts` helpers inside `src/store/` and persistence code may touch the Redux store. - -If you find yourself wanting to skip a layer (dispatching from an event handler, calling a command from a container, reaching into `src/generated/proto/` from a component), stop — the refactor on `webclient-websocket-layer` exists precisely to eliminate those shortcuts. There are currently zero violations; keep it that way. - -### ProtobufService: request/response correlation - -- Every outbound `CommandContainer` gets a monotonically increasing `cmdId` (cast to `BigInt` for the proto field). A `Map` stores the response handler keyed by that ID; when `ServerMessage.RESPONSE` arrives, `processServerResponse` looks up and invokes the callback, then deletes the entry. -- There is **no timeout or retry**. `resetCommands()` (called on reconnect) zeros `cmdId` and clears the pending map, silently dropping any in-flight callbacks. Code that needs reconnection resilience has to handle it at a higher layer. -- `sendCommand` is a no-op write if the transport isn't open — it still registers the callback, so a stale pending entry can accumulate until the next reset. -- Inbound event dispatch is extension-based: `processRoomEvent` / `processSessionEvent` / `processGameEvent` iterate `RoomEvents` / `SessionEvents` / `GameEvents` (tuples of `[extension, handler]`) and invoke the first handler whose extension is set on the message. Adding a new event handler means appending to those arrays. - -### command-options contract (`src/websocket/services/command-options.ts`) - -Every `send*Command` call accepts an optional `CommandOptions`: - -- `responseExt?: GenExtension` — the response payload extension to unwrap on success. -- `onSuccess?: (response: R, raw: Response) => void` — called when `responseCode === RespOk`. If `responseExt` is absent, the overload becomes `() => void`. -- `onResponseCode?: { [code: number]: (raw: Response) => void }` — per-error-code handlers. -- `onError?: (code: number, raw: Response) => void` — fallback for codes not in `onResponseCode`. -- `onResponse?: (raw: Response) => void` — if set, it handles the raw response and bypasses every other hook. Use this when you need the full response object regardless of code. - -If none of the hooks fire for a non-OK response, `handleResponse` logs the failure via `console.error` with the command's proto type name. The practical rule: `onSuccess` funnels into persistence, `onError` funnels into persistence (usually to flip connection state or show a toast), and `onResponse` is rare. - -### Public API for UI (`src/api/`) - -Thin service wrappers (`AuthenticationService`, `SessionService`, `RoomsService`, `GameService`, `ModeratorService`, `AdminService`) that expose websocket commands to UI code. A few things to know: - -- **All command methods are `static` and return `void`.** They're fire-and-forget — the response flows back through the `command-options` callbacks plumbed inside the command itself, into persistence, into the store. Don't try to await them. -- A handful of methods return `boolean` (e.g. `AuthenticationService.isConnected`, `isModerator`) — those are pure sync predicates, not command sends. -- Files use the `.tsx` extension even though they contain no JSX. That's a leftover convention; don't "fix" it. - -### State (`src/store/`) - -Redux Toolkit store (`store.ts`, `rootReducer.ts`) split by feature. Each slice follows the same file layout: - -- `*.actions.ts` — action creators -- `*.reducer.ts` — slice reducer -- `*.selectors.ts` — selectors (mostly plain getters; `createSelector` only for derived lists) -- `*.dispatch.ts` — dispatch helpers called by the persistence layer -- `*.interfaces.ts` / `*.types.ts` — state shape and enums - -Slices: `server/`, `rooms/`, `game/`, plus shared `actions/` and `common/` helpers (`SortUtil`, `normalizers`). Consumers import through the `@app/store` barrel — `GameSelectors`, `GameDispatch`, `GameTypes`, and the same prefixed set for `Server`/`Rooms`. **Don't deep-import from `src/store/game/game.selectors.ts` etc.** — go through `@app/store`. - -Shape notes worth knowing before you touch a reducer: - -- `game/` is deeply normalized: `games[gameId].players[playerId].zones[zoneName].cards`. Selectors are plain getters so lookups stay O(1); `createSelector` is reserved for the few that build derived lists (e.g. `getActiveGameIds`). -- Selectors return module-scope `EMPTY_ARRAY` / `EMPTY_OBJECT` constants for missing data to preserve referential equality and avoid spurious re-renders. -- `rooms/` is *partially* normalized: rooms are keyed by ID, but each room also carries denormalized `gameList` / `userList` arrays. Server updates often omit those lists, so the reducer merges new metadata while preserving the existing arrays. There is a standing TODO to clean this up. -- `server/` is mostly flat maps keyed by username (`messages`, `userInfo`, buddy/ignore lists) plus connection state. - -### Local persistence (`src/services/dexie/`) - -IndexedDB storage via Dexie for cards, sets, tokens, known hosts, and settings. DTOs live in `DexieDTOs/`. This is separate from the Redux store — used for data that should survive a reload (card database, user settings, host list). Dexie is not mocked in unit tests; code that writes to Dexie is typically exercised only in integration paths. - -### UI - -- **`containers/`** — route-level, Redux-connected. Top-level routes: `App`, `Initialize`, `Login`, `Server`, `Room`, `Game`, `Player`, `Decks`, `Account`, `Logs`, `Layout`, `Unsupported`. Routing lives in `containers/App/AppShellRoutes.tsx`. -- **`components/`** — presentational, mostly unconnected. -- **`forms/`** — `react-final-form` forms (e.g. `LoginForm`). -- **`dialogs/`** — MUI dialogs. -- **`hooks/`** — shared hooks (e.g. `useAutoConnect`). -- **`i18n.ts` / `i18n-backend.ts`** — `react-i18next` + ICU; translations managed via Transifex. -- UI kit: MUI v7 (`@mui/material`, `@emotion`). - -### Path aliases - -`tsconfig.json` defines the following (resolved at build time by `vite-tsconfig-paths`): - -``` -@app/api @app/components @app/containers @app/dialogs -@app/forms @app/hooks @app/images @app/services -@app/store @app/types @app/websocket @app/generated/* -``` - -Prefer these in new code over relative imports when crossing top-level directory boundaries. Deep paths into a barrel target (e.g. `@app/store/game/...`) are a smell — add the symbol to the relevant `index.ts` barrel instead. - -### End-to-end data flow - -User action in a container → `src/api/*Service` → `src/websocket/commands/*` → `ProtobufService.send*Command` → socket. -Server reply/event → `src/websocket/events/*` (or the `command-options` callback on the original command) → `src/websocket/persistence/*` → `*.dispatch.ts` helpers → Redux / Dexie → selectors → container re-render. - -## Build pipeline and generated files - -`npm start` and `npm run build` both run `prestart`/`prebuild` hooks that invoke `proto:generate` and then `node prebuild.js`. `prebuild.js` does three things: - -1. Copies shared country flag assets from `../cockatrice/resources/countries` into `src/images/countries`. -2. Writes `src/server-props.json` containing `REACT_APP_VERSION` = current `git rev-parse HEAD`. -3. Walks `src/**/*.i18n.json`, merges them into `src/i18n-default.json`, and **throws on duplicate keys** (`i18n key collision: ${key}`). Namespace your i18n keys — collisions fail the build. - -Files you should never edit by hand (all auto-generated, all committed): - -- `src/generated/proto/**` -- `src/i18n-default.json` -- `src/server-props.json` - -If `npm start` seems to be ignoring a new `.i18n.json` file or a fresh proto, run `npm run proto:generate && node prebuild.js` directly — the hooks only fire on `start`/`build`, not on `test` or `lint`. - -`.env.development`, `.env.production`, and `.env.test` exist but are empty. There is currently no env-var configuration surface; server URLs and the like are resolved through the login UI / `server-props.json`, not `import.meta.env`. - -## Testing - -Vitest + Testing Library + jsdom; `setupTests.ts` registers jest-dom matchers. - -**Vitest runs with `test.isolate: false`.** Every spec file in a worker shares the same module graph, so `vi.mock(...)` factories and any mocks they create persist across tests. Consequences: - -- The global `afterEach` in `setupTests.ts` calls `vi.clearAllMocks()` + `vi.restoreAllMocks()` + `vi.useRealTimers()`. It deliberately does **not** call `vi.resetAllMocks()`, because that would reset the implementations of `vi.fn()` instances created inside `vi.mock(...)` factories and break every spec that mocks `store.dispatch` once at file load. -- A test that installs a custom `mockReturnValue` / `mockImplementation` should not assume the next test resets it — either overwrite it or rely on `clearAllMocks` wiping only call histories. -- Always use real timers at the end of a test that switched to fake ones; the global teardown will catch leaks, but relying on it is fragile across files. - -Other conventions: - -- **Fixtures.** Store slices have co-located `__mocks__/fixtures.ts` files (notably `src/store/game/__mocks__/fixtures.ts`) exposing factories like `makeCard`, `makeGameEntry`, `makePlayerProperties`, `makeState`. They build protobuf messages via `create(Schema, overrides)`. Reuse them in new tests instead of hand-rolling proto objects. -- **Websocket mocks.** `src/websocket/__mocks__/` holds shared mock builders (e.g. `makeMockWebSocket`, `makeWebClientMock`, `makeSessionPersistenceMock`). Command and event specs install these with `vi.mock(...)` at the top of the file. -- **Slice tests are per-concern.** Each slice ships parallel `*.actions.spec.ts`, `*.reducer.spec.ts`, `*.selectors.spec.ts`, and `*.dispatch.spec.ts` files; tests don't cross concerns. - -`npm run golden` (lint + test) is the CI gate — run it before declaring work done. - -## Protocol changes - -When a task requires editing `.proto` files in `../libcockatrice_protocol/`, run `npm run proto:generate` afterwards, and: - -1. If the change introduces a new proto *file* that code outside `src/types/` needs to consume, add an `export *` line for it in `src/types/data.ts`. -2. Update any command/event/persistence code that consumes the changed messages. -3. Commit the regenerated files under `src/generated/proto/`. diff --git a/webclient/README.md b/webclient/README.md index 436ab4fad..774b84ffe 100644 --- a/webclient/README.md +++ b/webclient/README.md @@ -1,73 +1,67 @@ +# Webatrice + +The Cockatrice web client — a React/TypeScript SPA that connects to a Servatrice server over a WebSocket. + ## Application Architecture -![Application Architecture](architecture.png?raw=true "Application Architecture") -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). +![Application Architecture](architecture/simple.png?raw=true "Application Architecture") -## Available Scripts +For the full set of diagrams (detailed layer map + command/response/event sequence) and the `npm run diagram` scripts that regenerate them, see [architecture/](architecture/). For prose — WebSocket layering, Redux store shape, test conventions — see [.github/instructions/webclient.instructions.md](../.github/instructions/webclient.instructions.md). -In the project directory, you can run: +## Stack -### `npm start` +React 19 + TypeScript, built with [Vite](https://vite.dev/) 8. State via Redux Toolkit + RxJS, UI via MUI v9, tests via Vitest, protobuf bindings generated by [buf](https://buf.build/) into Protobuf-ES. -Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser. +## Prerequisites -The page will reload if you make edits.
-You will also see any lint errors in the console. +- Node.js and npm +- Run every command below from the `webclient/` directory -### `npm test` +## Getting started -Launches the test runner in the interactive watch mode.
-See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. +```bash +npm install +npm start +``` -### `npm run build` +`npm start` boots the Vite dev server and opens a browser tab at [http://localhost:5173](http://localhost:5173) automatically (configured via `server.open` in `vite.config.ts`). The first start runs `proto:generate` and `prebuild.js` via the `prestart` hook, so give it a moment. -Builds the app for production to the `build` folder.
-It correctly bundles React in production mode and optimizes the build for the best performance. +## Scripts -The build is minified and the filenames include the hashes.
-Your app is ready to be deployed! +### Dev & build -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. +- `npm start` — start the Vite dev server (runs `proto:generate` + `prebuild.js` first via `prestart`) +- `npm run build` — production build into `build/` (also runs the prebuild hooks) +- `npm run preview` — serve the built `build/` output locally to smoke-test a production build -### `npm run eject` +### Tests -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** +- `npm test` — one-shot Vitest run (unit specs) +- `npm run test:watch` — Vitest in watch mode +- `npm run test:integration` — integration specs via `vitest.integration.config.ts` +- `npm run test:coverage` / `npm run test:integration:coverage` — the above with v8 coverage -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. +### Quality -Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. +- `npm run lint` / `npm run lint:fix` — ESLint over `src/` +- `npm run golden` — `lint` + `test` + `test:integration`; the CI-equivalent gate to run before declaring work done -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. +### Codegen & i18n -## Learn More +- `npm run proto:generate` — regenerate Protobuf-ES bindings from `../libcockatrice_protocol` via `buf generate` +- `npm run translate` — re-run the i18n merge only (`prebuild.js -i18nOnly`) -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). +## Generated files -To learn React, check out the [React documentation](https://reactjs.org/). +Produced by `proto:generate` and `prebuild.js` on every `npm start` / `npm run build`. Don't edit them by hand: -## To-Do List +| File | Tracked? | Notes | +|---|---|---| +| `src/generated/proto/**` | Gitignored | Protobuf bindings. Regenerate with `npm run proto:generate`; only appears after a first local run. | +| `src/server-props.json` | Gitignored | Build metadata including the current git SHA. Written by `prebuild.js`; only appears after a first local run. | +| `src/i18n-default.json` | **Committed** | Merged i18n catalog. Regenerate with `npm run translate` and commit whenever it changes. | -1) RefreshGuard modal - - there is no browser support for displaying custom output to window.onbeforeunload - - we should also display a custom modal explaining why they shouldnt refresh or navigate from the site - - ideally, the custom popup can be synced with the alert, so when the alert is closed, the modal closes too +## Further reading -2) Disable AutoScrollToBottom when the user has scrolled up - - when the user scrolls back to bottom, it should renable - - renable after a period of inactivity (3 minutes?) - -3) Figure out how to type components w/ RouteComponentProps - - Component> - -4) clear input onSubmit - -5) figure out how to reflect server status changes in the ui - -6) Account page - -7) Register/Reset Password forms - -8) Message User - -9) Main Nav scheme +- [.github/instructions/webclient.instructions.md](../.github/instructions/webclient.instructions.md) — architecture deep dive, conventions, and domain-knowledge invariants for working in this directory (the canonical AI-tool instruction surface for this package) +- [Vite docs](https://vite.dev/guide/) · [React docs](https://react.dev/) · [Vitest docs](https://vitest.dev/) diff --git a/webclient/architecture.png b/webclient/architecture.png deleted file mode 100644 index 0226eb201a3c2c0bccfad58988877e9d61328010..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54120 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 diff --git a/webclient/architecture/README.md b/webclient/architecture/README.md new file mode 100644 index 000000000..55a9b9d64 --- /dev/null +++ b/webclient/architecture/README.md @@ -0,0 +1,70 @@ +# Webatrice architecture diagrams + +Three views of the same architecture at different zoom levels. The `.mmd` files are the source of truth — edit them and re-render when the architecture changes. The `.png` files are committed so the README and GitHub file view render everywhere, including offline. + +For the prose counterpart and conventions, see [../.github/instructions/webclient.instructions.md](../../.github/instructions/webclient.instructions.md) (the canonical AI-tool instruction surface for this package). + +## When to look at which + +| Diagram | Use it when | +|---|---| +| **[simple](simple.mmd)** | You need a mental model of the request/response loop in ten seconds. Good for onboarding. | +| **[detailed](detailed.mmd)** | You're making a structural change and want to see every module, which layer it belongs to, and how data moves between them. | +| **[flow](flow.mmd)** | You're debugging a specific round-trip and need to see the runtime order. Shows the `cmdId`-correlated response path vs. the extension-dispatched event path, plus the "no timeout, no retry" caveat. | + +## Simple — high-level flow + +![Simple architecture](simple.png) + +Application on the left, Servatrice on the right, two-lane racetrack in between. The top lane is outbound (`client.request.*` → `Commands`), the bottom lane is inbound (`Events · Responses` → `client.response.*`), and both lanes ride the same WebSocket. Redux hangs off Application as its in-memory store; IndexedDB sits under Servatrice as the browser-side persistent store reached from hooks via Dexie. Both are stores, both sit outside the racetrack. + +**Color = role:** + +- Blue — application code (UI, hooks, API seams, WebClient) +- Purple — transport (WebSocket layer, services) +- Amber — state / data stores (Redux, protocol types) +- Gray — external systems (Servatrice, IndexedDB) + +## Detailed — layers & dependencies + +![Detailed architecture](detailed.png) + +Every meaningful module in the webclient, arranged as a three-lane racetrack: outbound (`src/api/request/` → `commands/`) on top, transport (`WebClientProvider` → `WebClient` → `services/`) in the middle, inbound (`events/` → `src/api/response/`) on the bottom. Application bookend on the left holds UI, hooks, Redux store, and the Dexie persistence pair; Servatrice sits on the right. The protocol satellite (`src/types/` + `src/generated/proto/`) is drawn below with dashed edges up to the modules it types — it's cross-cutting, not on the flow path. Same four-role palette as the simple diagram. + +Load-bearing invariants (enforced on `webclient-websocket-layer`; keep it that way): + +- **UI never imports `@app/websocket` or `@app/api`** — always go through `useWebClient()`. +- **Only `src/types/` imports from `@app/generated`** — everywhere else uses `Data` / `Enriched` / `App`. +- **Only `*.dispatch.ts` helpers and `*ResponseImpl` classes call `store.dispatch`** — the API response layer is the single inbound seam into Redux. + +## Flow — command → response → event round-trip + +![Sequence: join room](flow.png) + +Scenario: user joins a room. The sequence shows the outbound command path (steps 1–6), the correlated response path matched by `cmdId` in `ProtobufService`'s pending map (steps 7–10), and an unsolicited server event dispatched by proto-extension match against the event registry in `processRoomEvent` / `processSessionEvent` / `processGameEvent` (steps 11–15). + +Read the footnote: `ProtobufService` has no timeout and no retry, and `resetCommands()` on reconnect silently drops in-flight callbacks. Code that needs reconnection resilience has to handle it at a higher layer. + +## Rendering + +npm scripts are defined in [../package.json](../package.json) — no separate build step, no added runtime dependency (everything runs via `npx`). + +```bash +# from the webclient/ directory: + +npm run diagram # render all three (simple + detailed + flow) +npm run diagram:simple # render just simple.png +npm run diagram:detailed # render just detailed.png +npm run diagram:flow # render just flow.png +``` + +Under the hood each command is: + +```bash +npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc \ + -i architecture/.mmd -o architecture/.png -b white -s 2 +``` + +`-s 2` renders at 2× scale so the PNG stays crisp on high-DPI displays; `-b white` gives the diagrams a light-mode background that looks right in both GitHub's light and dark themes. + +If `mmdc` fails locally (it spawns headless Chromium — some sandboxed environments block that), paste the `.mmd` contents into [mermaid.live](https://mermaid.live) and export to PNG. The `.mmd` sources remain canonical either way. diff --git a/webclient/architecture/detailed.mmd b/webclient/architecture/detailed.mmd new file mode 100644 index 000000000..112aca658 --- /dev/null +++ b/webclient/architecture/detailed.mmd @@ -0,0 +1,123 @@ +--- +config: + layout: elk + theme: base + themeVariables: + background: "#ffffff" + primaryColor: "#ffffff" + primaryBorderColor: "#1f2937" + primaryTextColor: "#0b1220" + lineColor: "#1f2937" + textColor: "#0b1220" + edgeLabelBackground: "#ffffff" + fontSize: "20px" + clusterBkg: "#fafafa" + clusterBorder: "#9ca3af" + flowchart: + htmlLabels: true + curve: basis + nodeSpacing: 60 + rankSpacing: 90 +--- +flowchart LR + %% ========================================================= + %% Left bookend — browser-side Application + %% ========================================================= + subgraph LBE["Application"] + direction TB + UI["UI
containers · components
forms · dialogs
"] + Hooks["hooks/
useWebClient · useAutoLogin
useSettings · useKnownHosts
"] + Store[("@app/store
server · rooms · game
actions · common
")] + DTOs["dexie DTOs
Card · Host · Set
Setting · Token
"] + IDB[("IndexedDB")] + end + + %% ========================================================= + %% Racetrack — three lanes: outbound / transport / inbound + %% ========================================================= + subgraph RACE[" "] + direction TB + + subgraph TOP["Outbound lane"] + direction LR + Req["src/api/request/
Authentication · Session · Rooms
Game · Admin · Moderator
"] + Cmds["commands/
session · room · game
admin · moderator
"] + end + + subgraph MID["Transport"] + direction LR + Provider["WebClientProvider"] + WC[["WebClient
singleton · request · response"]] + Svc["services/
ProtobufService · WebSocketService
KeepAliveService · command-options
"] + end + + subgraph BOT["Inbound lane"] + direction LR + Evts["events/
session · room · game"] + Res["src/api/response/
Session · Room · Game
Admin · Moderator
"] + end + end + + %% ========================================================= + %% Right bookend — Servatrice + %% ========================================================= + Srv[("Servatrice")] + + %% ========================================================= + %% Protocol satellite — cross-cutting types + %% ========================================================= + subgraph PROTO["Protocol (cross-cutting)"] + direction LR + Types["src/types/
Data · Enriched · App"] + Gen["src/generated/proto/
@bufbuild/protobuf"] + end + + %% ========================================================= + %% UI-side wiring + %% ========================================================= + UI --> Hooks + Hooks -- "useWebClient()" --> Provider + Provider --> WC + UI -- "selectors (read)" --> Store + Hooks --> DTOs + DTOs <--> IDB + + %% ========================================================= + %% Outbound — request goes up through the top lane to Srv + %% ========================================================= + WC --> Req + Req --> Cmds + Cmds --> Svc + Svc -- "frames" --> Srv + + %% ========================================================= + %% Inbound — Srv comes back through services, splits to + %% cmdId response (direct) and event-registry dispatch + %% ========================================================= + Srv -- "frames" --> Svc + Svc --> Evts + Svc -- "response by cmdId" --> Res + Evts --> Res + Res -- "dispatch" --> Store + + %% ========================================================= + %% Protocol edges — dashed, cross-cutting + %% ========================================================= + Req -.-> Types + Res -.-> Types + Cmds -.-> Types + Evts -.-> Types + Types --> Gen + + %% ========================================================= + %% Palette — four roles + %% ========================================================= + classDef app fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef transport fill:#ede9fe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef store fill:#fde68a,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef external fill:#e5e7eb,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + + class UI,Hooks,DTOs,Provider,WC app + class Req,Cmds,Svc,Evts,Res transport + class Store,Types,Gen store + class Srv,IDB external diff --git a/webclient/architecture/detailed.png b/webclient/architecture/detailed.png new file mode 100644 index 0000000000000000000000000000000000000000..16d21239237b431969be84497e7fb0af664e71ce GIT binary patch literal 62884 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�TZShLe4vu2g}b^mq+*V+RoVHh_NEl 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 literal 0 HcmV?d00001 diff --git a/webclient/architecture/flow.mmd b/webclient/architecture/flow.mmd new file mode 100644 index 000000000..030800782 --- /dev/null +++ b/webclient/architecture/flow.mmd @@ -0,0 +1,41 @@ +%%{init: {"theme": "base", "themeVariables": {"background": "#ffffff", "primaryColor": "#ffffff", "primaryBorderColor": "#1f2937", "primaryTextColor": "#0b1220", "lineColor": "#1f2937", "textColor": "#0b1220", "signalColor": "#1f2937", "signalTextColor": "#0b1220", "actorBkg": "#ffedd5", "actorBorder": "#1f2937", "actorTextColor": "#0b1220", "actorLineColor": "#1f2937", "sequenceNumberColor": "#ffffff", "noteBkgColor": "#fef3c7", "noteBorderColor": "#1f2937", "noteTextColor": "#0b1220", "labelBoxBkgColor": "#ffffff", "labelTextColor": "#0b1220"}, "sequence": {"showSequenceNumbers": true, "mirrorActors": false, "messageFontSize": "13px", "actorFontSize": "13px", "noteFontSize": "12px", "actorMargin": 14, "boxMargin": 6, "boxTextMargin": 3, "noteMargin": 8, "messageMargin": 48, "diagramMarginX": 8, "diagramMarginY": 8, "width": 120}}}%% +sequenceDiagram + autonumber + + participant C as Application + participant RQ as API Request + participant CMD as Command + participant PB as Protobuf + participant WS as WebSocket + participant S as Servatrice + participant EV as Event + participant RS as API Response + + rect rgb(219, 234, 254) + Note over C,S: Request — user action → command + C->>RQ: joinRoom(roomId) + RQ->>CMD: build RoomCommand + CMD->>PB: sendRoomCommand(cmd, onSuccess/onError) + PB->>PB: assign cmdId,
register callback + PB->>WS: send(bytes) + WS->>S: Command + end + + rect rgb(254, 243, 199) + Note over S,RS: Response — correlated by cmdId + S-->>WS: Response (cmdId) + WS-->>PB: processServerResponse + PB->>RS: onSuccess(response) + RS->>RS: dispatch → Redux + end + + rect rgb(220, 252, 231) + Note over S,RS: Event — no cmdId — dispatched by extension + S-->>WS: Event + WS-->>PB: processRoomEvent + PB->>EV: pick handler by extension + EV->>RS: roomEvent(...) + RS->>RS: dispatch → Redux + end + + Note over PB,WS: No timeout, no retry.
resetCommands() on reconnect
silently drops pending callbacks. diff --git a/webclient/architecture/flow.png b/webclient/architecture/flow.png new file mode 100644 index 0000000000000000000000000000000000000000..39a82cef7a30caea8a566f8a739c1727554ef74b GIT binary patch literal 94869 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 literal 0 HcmV?d00001 diff --git a/webclient/architecture/simple.mmd b/webclient/architecture/simple.mmd new file mode 100644 index 000000000..19bba7d21 --- /dev/null +++ b/webclient/architecture/simple.mmd @@ -0,0 +1,58 @@ +--- +config: + theme: base + themeVariables: + background: "#ffffff" + primaryColor: "#ffffff" + primaryBorderColor: "#1f2937" + primaryTextColor: "#0b1220" + lineColor: "#1f2937" + textColor: "#0b1220" + edgeLabelBackground: "#ffffff" + fontSize: "18px" + clusterBkg: "#ffffff" + clusterBorder: "#9ca3af" + flowchart: + htmlLabels: true + curve: basis + nodeSpacing: 55 + rankSpacing: 90 +--- +flowchart LR + subgraph APP_COL[" "] + direction TB + App["Application
containers · components · hooks"] + Rdx[("Redux
in-memory state")] + end + + subgraph SRV_COL[" "] + direction TB + Srv[("Servatrice")] + IDB[("IndexedDB
local persistent store")] + end + + Req["client.request"] + Res["client.response"] + + %% Outbound lane (top) + App -- "useWebClient()" --> Req + Req -- "Commands" --> Srv + + %% Inbound lane (bottom) + Srv -- "Events · Responses" --> Res + Res -- "dispatch · rerender" --> App + + %% Local stores — Application owns both; edges only to IndexedDB + %% (Redux state is implicit — reducers sit under dispatch, selectors under rerender) + App -. "Dexie: settings · hosts · cards" .-> IDB + + %% Palette — four roles + classDef app fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef seam fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef store fill:#fde68a,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + classDef external fill:#e5e7eb,stroke:#1f2937,stroke-width:1.5px,color:#0b1220 + + class App app + class Req,Res seam + class Rdx store + class Srv,IDB external diff --git a/webclient/architecture/simple.png b/webclient/architecture/simple.png new file mode 100644 index 0000000000000000000000000000000000000000..0e040a2bbca889a68f40e3f1da21cdf18ddc84a2 GIT binary patch literal 44175 zcmd42RaBf?v^7{!APE)%1b26LcXzko4#C|52@b*C-5r8f2=1;`Nbo`m*Fw5-PR_kO z`l-kGAG-I`hiqMQuf1fhaAid)WEc0^x+HT!B|PT(->h$NP9?QD<_g;W+)i0;=oNyLNBGaWH>J=C(9X zJXu}{xl~@5s=BNM_A-in%p-7VMe_rxdByR-qJ^bD+c~Jf`l=mx8!heR#PSS2+du6_ z=Mw3^Z($1JPk)y2rv=K2{CxTECjgN2CGda#iT2eqjQ@VG{^mL5f8V7a(EsVHe65d! zq@?JjQ{yl2T-jXE_r|JpEiXD5_Zgt{2#xvA#KHvTHYT~1rNV&n(X2tM=Ky7{^$$G% zJJ3t{|Gt%J5L5sA&Q<-l_n)7Ii~f&R`hPM3sVa1~bg$$kz$U#|p)g68o$T)d=PK?4 zAr5A)11QVzKgKLLF7h1e|2LEJ!!T;K-Xg^7uUAK0eDTInd3v?X=((mK*@ zwqyy2rSgK6Hu8H2Pc8yKY4G#M! zUGO*6)}A0|7<@PSo+9P=##7IZlBr}`Ri@61ZKqknWiDBE-=>@)oeM61<#UpMC0KeI z5gDnbYSb$j9_y2i1|uhd%J}+}3If+~U8NR9Wro>O0hSJRGN|-z#C%B4T-0_D!JuJ#FNcTrUM%H;%LR@Gy2|yK3Rk z`iA^`A|Zc#Yz)}NRTg5+W{D?;9&OW}x!GcgRxIJl1qC_odjOym>F;fuWnOUnmev0G z7Tib7$(O=R)h>hUdNf<4HqgAR%e!19Gd7i1g+k!D>A2SAcX1yP#C44fa(A;TVVj;d>1B2ql6((WQX!~+)sS&Y zMhfsaa3Pt%M~8l%xIys6*xjDHm6Q#K;byVSrevod?8~aw+38mv(ikmV=|3gp>@(Cn38*;qY&66-Y`w-;kx=ag>Pl28Ry#jc0Sa zK{XdU9~|(UrttxR%Ku10D7NQ!=2^RRu0w7PR9wPo3!uD$;+^%`u&&#woZz^(uB|e` z!G|{_WL3Ax|KNlr;c2DzS7Qr0Tr2(#>=6e5ZoCe`z9r@rNVCYnch{JhN#p?K=xj>< z0?4##3D3@pJM?u{sI_Nd)m7%#@q`j4PhTc&{Y(kCMnwFh5d(*vA1yVmvpW%I4Hv5Q zbsP4Ho&)Bfr4viZ3`yS^{qn36DBteh^VrWDarHp2AalWRO^+u0N7E;1)cE^l@@TZr z*-i!mejb0n=P$b1lzMI+79KtYy6GkVBRJ7~!GB*4Ta~hr^{O?2H(=q?WEOBK-WV`D$0iSno%nI}h z#bqPAH-Y!?2-Gs*J08`Plbw-qT<3!q`4!sv3=o3YHu{f-K{F&p7PpXZ;9`N+*-iw> z%vg9FPR=hohgmzCs-}=rl;HDu?TD_MhkrvRC{d`#wGc_&G5CWKg#R$IkXJQ+HD-%K z$g|^iILnH?fLX1_d(eDb3aWO211@q_6*Zr5rJV@VpNe&(Tg;1RDGf#K8!C=8e1p{g z-E$nuC;|zDr@-8E>@N;q*NR?bQ7=i00u!>kx(s2>yl}9~C}`d^pID2UkdGyi=I_}X z8S-_O^LU3(u&5mctQdX5b{{e!u* zs*)nw(#yZ4r^7WMvYUoEg#>BuhE;ZA>IFT`t<0~6-rg;22&4m2rLK$8@^MkasK#Xg z#puAc><9eu3_STqyZ``k;XVria?;w|_aEFN!axiHPYGPBxpX97 z;)jPLBE~Rvsj1l=S1To4UiIyarE_V0hpU@%mF373YbG|s4(Wij?8goB*PRMJY1wgc z8g22q>H9BW*Llx$LYwVBeAoiED_(TJ1Q4tL)gg#w&U(yAa#HOa1TQ_#&)#lqVr=G6 zbb6*`$Hyn9CHe7Oc{?~5LL>?c$?;^Qr8)C)r=KoRI+K!<-&@w<%CINqdO(n~mX-DM zNf?B8ISUgYvTxq3?f@C62brkpw`$MN&eHfC*RZfAd2VlSFZ@LwLHtl@L598$0ear; z^$Ho(w9R{qT@0NX)@6lLvKn$~aNG+chyH8Td zi)vWIK(nerPZnYwq!yJ~AglC)Lr+D)c`w{m73YxoI&EdHkpdD*2PNiv(!71O`FKEI zA#iU-S@u3tcz~tB{9Uxy^#!On7Po;cMp(k>`Obc6Ms*A^U@j!Vb=>@WcJ-$PlBKl< zXBa6Uusor@NBL;xvE1xtLs)LF=U9zjMQ+QWRb35B5jQ0`8quXJ_H6LZ zr(iRP>Mz`L3sl;Xpd=G6Ue`Kf7sWNr)xBMafZQu-)cjjmYL1Q2BLV*7GrVI(&BWvB zE^oPChyX-%xS)tbmUThYaa7MpzJyWig`koQQBCwG%X_X9jgojb@0@K}ejOv)bZrT9 ztvm0$B)2b1y2$LCVeIJk_w?{{`Ym@{#mY)7h@l!o zXM;3bB!gB@eX4I<(njm5=xMvMgn-x4hFI5K;@^lBJohVXGX>g@^i7Hc4 znGATU>8wJmy-go^2ZRt57Qi7A4mpV10btbA-Rdo`6N<+#Q&b(Q;X7z-UmdGLA~BlX zZ1nnA8{WL&^fHS}M&Wv%Io7C+>8U@;tnwL6E!BB`N;P~zT5rrb-={Z23023#UHRVF z->)%AUSHJns3q7!Hs-8A$WPKK<-O~P`bXbiIkn#Istlb&C|OZ5P-CV%zFj)LgUg5} zy>C(Ve4n?(NlI{keN#z+lQ1MKwQ~#YByx}Q}7h{m{a+*{BD90Rpl56Ry0Ce zX7{|ZQEw3153TxphLA%PD z3r4n*St;`9YLbxDEDL4!o{DHSxYa<;y6*dCMKF80lf<64QK+xeTT^1VfUdZTem~C| zLO@#zoI)&dp#cxf*$O-er~jDF@6{q)xl42;``y&!OW!_d7cs>LpNjqR%KZGf0-%>m|w(GCKtYsCi7Gql%m zJ(`PaMo1`Ry(_kgumz3^J-rv&;OWM=l;pmpI(c+($zEIK>9!h`fg(`(9XQcD577ns z;D0^{(Q;TY54a>_f!();q7Sv)vxdzVK9;W;B9V0ukbU>~^fQBFFcMZGz#jNbz`zo8 z5TAnvG!3ka_8@1igcWde0{0H?-z+fnNV2i0simvonCCVne5TbgEXPqPbr#xA!P61F4_^&L3;$<7H!&a$?tO9M2`?687tl6>h- zc#IY5Tasx4{3|oeQ*gduIJy*clr-Tf?>j%slY=R8zwcYq>$@5K2o_ULC1`b79PNP! zX`hW_A#`_zC9JoYiLa?SoKbxXS7R9ZFB@<`Rr_>1sb2ZxQ$&o@><@i*g{<78<9)iD?Zf1zw z$HQ?Fe9h=P7^_)l;)@gB&Nx;Pnm1T~vt10+cwLxK6+J5$8;Hi*dw93hgD;?k2kZA( zP~gSMgnrW73m}c59y_SGKT7(X!9}w~c)nso%Vanhb7{b8_04GTo`Hn3LOpwujGV&s z;Z4?=MKc%6#J9D_un4!by^BJSP|1@UyU8-sq*x{fTR*p*8xQ}O4?YkZtJAB*gNZYq z3i52`UCC|8>yQt+dKr%y9#NA?+5~d^pYE);W#kpCeqKnhxnZ62n-@-Q3XONd;^3~Z z#cYLE&5s{HTveQ$PJjLSrRt&c_3M4jGA?`R)9)Us?#gc)$ZJ?#4<;9wCiU-(6EP=&mulR3%<(&DeRlN+>Dp#4CV9eLOi z@B=ceDp#~O!B_8|pNAg_sU?{T_ziy^4*ZU;bkAf5FLgGX=2e6H!dHcsO+cILbS&a6 z(cuTC9{HvoQ(yt-gdvZLD|!w8@Z+I4duhgu+O9f@^RUYc612-akOu>#q?K~L+v?viAW=G~e&kQ^G^U|WEz}!Ht@%I>;i4l|(cpSqc6WHJ+tJzxOCwqvwM4$K? zW+!ZCKRh*c5!-1O8;DCmm-5F-Rcn#FeRXS;oM@%3d~y{Tel;AMj|f>fr`t6x&Pf#m zwxw{owBHjrd_PN0Tw zeWhjH$uQ}xZ3I@P&Cc9y?VZ}L`S_}4=!t3q$q``jSaHr+piTih_TIMzTqqy!uX{cL zsZAz>daLy|hA78I7jo8m=x=sn%L%o6(ga{hP!=6Nqfaq9H^krXs;C>Wd z7u;=b?;C~=3{#Mhs3-%neNxwiBXN}OFGE7?73dNj+uS$ zF*0%KQw`fXQcFKNUQ?I4h&i3MfLn-a_#M4Fy)Ppph0dt_@1-LmN4n%7Y+^Y?yzy5X z+fxaa)hj<9es6|q(lQ|V_Epuk_YnytXBh%Tf@6sUPaY}XT=7saAw0F{omi@zynnl@ zQe>1q9UveFWWi-eyFK|W0F*Cl$xltSQYkagw7ANEhfZh*xwcmrI@!qYuirHgUz<~b zAG`&Sl_KyNj4OGy|^np>6q5Dy)?L;|| zvXF$kYvy2}P)0T;>5Z8c&p=-oo&n8;2R_O6W?Z}?Dw+CVY^0JR<=AS#P2~iccjC?M zE~~elP8&d=5DUCbV3;CQVOD0Ms^5D%E_JOGA;c$Ll!JyhTO(C2sVn^%H?Ds5x3TTd z&WZSi9H0VFRBpT29v9>|V>xpiB7q@?Y#~2pOrnsIirGxSTL)^EXqoZvKv$0K14%X; z&y!FnO#1@6BwBycg*57J{hp4+Ca#~Ig$#PUC{Dy9DJ!IQ1BEz%1T(VTc3E(297=#Y zHFRB;q2&%Kq0F4)$<pd`3QgA!_yLAbYQQ6eMvU?AR!e!uqG z=st;lDp#Zvl_=t1!SeA0>58W#CD>w~g2{U*r!PK5`y__;VLI{gK<6%+g!C-PAxp?` zze-vxcVgATHQ`uL<1CVkL4oQmk9<4@qQOXi4bLGsHp&DlWD#QGAb3WJJ%hG?d4PWh z(!GEZtl$Q0^@MsHuXaA27!MTVfakPtES_v7y9x!4CaU#PyzeX(>k^c%SB|AFn&oX_ zSp@{p)FccA%T)BQ-BbKLzcz%^N zQ^ulD%!ojh0N%pn#HACYLw6QPKdjAJ9i5o*2#}uOF>()|TD=4&KTc8{OP z+H)l(`Y0=HWQn>uo>1#bpviExckQuPh0q%Ua%~#B*N|tQ7_G14GOS}J=ShI@UksqQ z(%obNX8GY}?fSr^67Js+Ok8$cTGGRg!uSCfJWAm#8U67ry6A%)N}-kac(#zF>1!$5 zQQ)q}g_d*->eE$n)ScdUvd~WbaJY@D@sMVkir%alAb+;hloJwf(Gbc^6y4PxKVf}+ zDBgV6IYd&$3{Ki!razmSags0IXH;ph^`U#4d9^X^=Ei-Oe#a!}s z+DO+BcKTcrH_aw}|Ez$FX)Zz1_ImxU%Ao^YqHjk4`I2f;+i`7M@oD`1&6@(k*J_l@ zXe91btZi}PJ)F8WTP?R1mNswZBN+@BTA;B_4X94QvHFX3f4wU)DZVq^wR^0p%;2BS zvNU?~GcLN{O>ED{jwUi`^E6tma29 zCxJ0{+nlk}%#-ATIETN?IA^G`qFl>HrNk-(NV;g)1z~4kt5>%cYo1YS{E}6RF3*+E z;Sturv$~~LYo-~aN&0Y{J!$Xdw-)3~K;gQtqLDjei61gXIIZ)iRa;FF(kP>g+!{H0 z1c2HDmJcjk^DP2scI|Xfpx{c08$5DY%@o5c~ zC98S-Eb-2|o^>|G^-R`qi)OsK`_RipGFf;mIXB3{mIIezDGHx*sZ_;co=%nHgWGJl5MMNp(XLbq zX~R9!QbkIZ4#f&)fNdnnXiBnm*#(O_`OEkBUDUP}agkq%50}c^)|=Sj8|dlK*-$ZzW>>S-MgWJUXAjX zTuSheIx4n) ztb}A#`JP+t4lsc=cZh2rZdL{j=$!9pkGJ10X{#bqAG_rFi& ze249%^+m)YC7eac;-#q?vM=!RC``cxGp(KYANpTSxPy&&Ffea%=*Y%V9kg9|AVtynaIo+Eh~VdO z%Foon?f2W>`;xGOFmTqKO4#YqAxB(BEmRZ8RRpEAVlA;v(tjZl2-5F*F2yz{Z>Z=# z^?U#$)Z>B0nqDDAT0@nM`2$&5Hk(D3%qKRc7gXotK{|SAf)PoWph=LPC1c%)-mrKC zxm`vJY2rc=B^BGe&-`74k;YN{L=ur5^+^@OzUz!-8)i-;2Ymt;6W!&^a++C0Zzd1+ zn4jyZC4P$G*2L5#S29`n!PB+JXVQSAsR0755dS*kV4@1#m@AX3&r*=g}IrNun zVh-3RT9JR>EPWgY<0ajiqbeO)J1a1b<#?sGA5hB!xA}V*f+4}0!Rq0og}bg&EWMgQ z*EQ12U&r)w$xHUD2E5>H%^?T2uFM1M@R90ms@nfz0k+n%=AXcz76Mv8-4`BH7%CHH(>59j>rd)S(RO!+Q#uqs2vRwW7#4jUPLL-f<~ zH^sA#1Ba!REIv8;;?5>eod%d$PA3T|)#MRceZGD!*h=%F1XE+C02-}9HxTTNk}av&Kpq5f{6cW;qu-? z(-QyBskMnE@IolqCZPF!0US4vcOS2&Js^s)|zF}X8*}$&-rUq2mo1tFAQ2nI-!!A8^tak2;tyz3W$Yt4c zxAOG(^4x*@5jl>;WdC$B`)%HEFHTP%X&}zSmIdOD8|W_OHy7P`4?>&yNC6pTDZ%6y zq}*z0&t%`TQLo)3ykc&~LxDl>qyD2fR6-7|?>T0C!y|r{(m!Q9S`_3S(>ZK&r#?A5e&tjK?bS%=KkgE~wPs&3f0E0gV;3j2O+s?zR^FghMj+!M^z193WSy_MV^ zcd@MnDm2FDi-fSlGqf{W>Npwvyj4nuQ0)7Y1cjb5=;G@1oBQgK9kCwJ_Pkm40@tU> zSt;8|MLUY!)P6G!b-rhKB5k5SIHEEbn;UuhL}BVoe71!pC-8X7LhrU|4USCpP5IKa zz|xL#zg+vq#gk|w_56`rNf0P%KU;;Czr|e@!D;&d5f4CO?e20(Ge9(W^*QM{oaS9|R9|uLLj>=$jP}W{?%H4&;uN zY+q;+>i6VO6wi)1Otd}A(X`&c-D>FV*kXw&J5bWz{rRBYkFGYYL6=r();#I+APnV` z^{88TueLfAVxo%AiOZ@%fjkg+GJ;3Xdva|Za?U?#{WFySY*>%X`I@ES28L3GV(Q+P zQ&(M|&9Rp~Z0qeq)7|XA?2{@@m9lNCe0dkNbz{4yi^rY{MZ{nn8gD(Di- zD0qQbHSnxG%~i&>YvS^U)Nhq;he#jY3pV0kvQzzZ>`>hCSPaz-vO}sC4{iSZ*^%=Z zDZab$6t2`$KB{<19v&ke#7cflpI8lh?yk2MIb;?-!LmomMe5$mtq*+rjbG?!(Xad@Mib0$~c>8ftpHSdl#wQHOw%3-6ignODxF3(TelLFEf zgQXrQqne2U$}Z%TB}K(WcsTx7BD@=qJz)s3q^)8z{jA+%DMO>)b4@9?YjpD;ulE(!+eIxP55E`6?Rx0)Jb0)- zH$|QYgHrarsn(R+5sj(V%oQ@WFYb_iM%q~IgL~$!ma@p}O=Ao)YP43rq#1*r3^!b8 z7-EOAniR0^I|qx&&eM3dbM1{5xGzti4z6#0LQk+mq;((J=nowy)bB_hruGf*i;j6c zoKF~SSS}CKUhUW6c}Ih?#xfYoX6H;!Upw%Xsn-NOa8V>K=eNy3-eP{lwj0C00;pXQUNwe_xE4czh~%q9A>e*DN2boFz{W~yDXA@ zL*aE9eVCnk7)co=5;P;8rpPNWIc|z*@HTk)P|#>096nS&b3%8E|3xO&NveA ziq|qr8{gbOUA}7M`>Bvpnh#$52^@&Yba|Me;Xw+Wcb*{aZ4!Fa0G&KCv| ziSYAt${}Iwa1Af>UhaI+@ETd*CJLURofP}|QW=jw%Ky;sezr~F35CEuV51S3n7Cy( zKy&T*ig2;hd^}3X8_&b>zDh2s`?%z2y|aQjaLr>U>%-jUYK$(NA+cVeEim5}n|G_^ za8s;X zxHL*YA~{%e&gGSY#qANcBNuO4jQTsPVAWQP+e6^D(>2P3RzU_o#4QVrBI9jrb2{7k z*p>6HVQQ}Qu)$MFye|zx3UjBFjGNJ+zhH#U(P@*Plsqn@6gO9<fNn zoYZ` zB$7{box;mw-)C_ieiWD#>@e(UmoxDNqbGb&f_5ExgwOaeBMEw?S$nZJ75;t?>xw-W z@?q?=cw5cFFq$BBtuQKhQyyL4S>E>KVa z#20V^$Lu?ur5Z-VHg6$X-h1dnS7&s83}Khch|Qlx{Iub)4=y-|4Nwm^Pdq5J0%QBalvR9Y|mYJcD+0=k$AT-5W#M!=W zQ{+*im8y%zJyjxa9mQl48i`I!Qb!6Wvx(V#ptOdQ3rmeB5ikOkN5)2kejjl4Idzww zqR?0~4J>IJ@dWs$&Da${fBBG@z?W3&twUuweug-*ahd+%X`O_N$K(t*y4oQ1AScI& z?_rj06+Bvdi$}Lto=XwW6-442-?=j%bj9vm4u9dMZ4T0Z>nE}=LPZT%X zt6R037zw|i;+d#FoEa|W3sQfiK+=xafK&iwB&+V1#D(w^#dW7T6J4JRvgj;Xj%C}+ zhf}Bk0MB{{Z-Xwcr?cGYqcpcn%By@K-tk2qYR}=@LpeD~cWqTxCHe)6;e3ZR{i6Pm zaDwZqP9_UZmmlj}990gIy|A;$7pBp&*E$KGA4q(@?VXp)mxnN=@$jQDdFKEK$h zW)%_w;&wy>JZP1 zO)3vE!jQFgB0N~53`UxR0pH9EN3q~>c)*_+a6$$*iG+WEV8<;bf;A7A1}0M zSxuIiKV<~F{vi(wlFga#-;D38nt=ICRF^FxS0-}OCL_3N7QSr`WRAJsxP+(L`HTSV zmwi35oHgG{%FssVEFR2++g5g0Troi_hS#Fc4rVMLGT66Nr1L&Mh z3cnQk*dZ-GP2^rttOUx@x)C&Zy3+j$(T6cP;+ZPfE0Pv1JDx@POQ+=0HqA}pJDN7x zcaHS2No8V>6?D)1E}*)M9y@x<1lm6R4uhd00o3I?H#Rov)ldm9GnkjZ0EFF!hKzI0 z+Sgs~I-(k9mCql=5Hjkth2}FTY7!jDdK6#g;+k7@?k|trA7k1^`CL*e`xv0S(LdU_ zU%#TTrjdEBtOw^sFU%^dXsQ#7+AY(UzzM>7^Hs#klK(zcF2lWVI5O@GpyHBT@m7nH za?!#HF1xD_px@}m9%MPaRPHm!vop%TP-Cq=EYZ#Zz)dHdYV_05{lQ{!XlA$Pc!h}L z*B5`6Y1t-i%9YqCHWDPHdPs@j7{otXZc^I4q;9eW->1>RlyxF6We7SKHloPZPB zZUmg27cD0c2>CZ=bUWw5jDtC=9y0!55fJzt9osVPiZlNOfs(wp|EA`RO3qBoIToH9 z7xTXHCvcCjPT8wx064d0jtOp#6bM?1Io&C0cP|Am`1OU8A$v_#4CTM4 zpc}MM86HEw`}s@F+8dPC?KUSL+e^*PsCTf`3w1H<3S~=pNUc;_-I`8e~zFuTAN-oKn;Tz&7tMvLiO5mvx||hiRzl6fcN!&UI{1$Flz%3&6bNV zt_+$AT4H2V#Nx4;+{WL+pHz6;k5VK6%>>2p%X$0XE=MaoLH)Di=NAe~tcf-WSK815 zml2;~{A9vHa~<0}VL?K2f}NUrf876ULKh*gQWk^NNu>`q67x+P9^w&W8V=jI$i^-hh-TM;T!o` zle?IITQH!6n23&dov5Zh+f~K+tkhbYKKVAV%Qbg1C0MLx4H0HJW)aLtU_C94fE51i zWv+!N+%%zqp?Dsha+~nDPRi7DDrB%z0^Fv{1Cr-!v~q-xF6(Z{o&~Pu899<&i%C6< zBj~2M6L3%(Ve5$s=q6TFE5 z9B@WhU{2$}!(f09s$wI7*DaXEcLLy+oM=Y2`!q(O%$0j7D13Ra)LAjd?6RN$THD%+ z!ZTF}{0?VMj%Yb}p0YknuG+uMZQh$}z$4J4*YkwmtD}=Jjf3Xt!ZM(B&0{eoJmCpn z3ji)J)J+LF_f4sFJXkg#-`-A)RgzBT#pswEY>u&O|zvGZ{TGwddux0OYnkWJ0UumX^5G zQtD@DCU9i=|9lKy?Ot$%c+5*Q{sGmTn7X>z>6bJ(xw#V5=rY~7ly$-T4#$eKHoyo@ zfh{S#&E)lg{wn&mvGLY|Pd=OZ8(`MGU*O$dt;y0a+q{+@s5rfwjwQGN6zEUs>L?5d zQT=}?yYQY>L6C>nVidlh>2l#~Da-Ua+gxK1YQq$3XPbY&L z1uM|(pp=?AZTe0Wd%k`5y=k2mv7=7+qu^4hYp76%3)k7|1NB#nvkPXsj%ds>mH$<8 z0CnBTxdUvbS4JJKZs1gOgZ8~kPN*Uv1PLzCV{WH?aP!lZ-n&DoSnH718j64DjzWc$ zujTofX4qoyGLc%*-FH|0ytV38$R5TCF5CB-94iXubhBp7zB<0mp(K}-6o~jign7F@ z1#0;^tr~wZbqSd(=$DX#CscB2Um76s91(f)-Di6}b-^jDkmA@_0PzR7+2fn^)Hecp z1X<3meEJq_V2Wc+3jT-AdjCBgj~Qw5ep|6OxE&o83id&}T{kxLf0k8RmRDU~Ub$MH z1Sjh9mRA-^hiK!ECA?3_rl1f~@Jzlr^jaQCaW#R5>D#6UD_sVv4K9|gS|~Yio>P|c zx}vTVeZCd_ndE)BZ{4iPMKoDrfM@^w1eS|R8R!hBjIVZ>)-)I2Jw9>LG?#V{WWfcY z9LZrvJ$_L1HD;zJT5Z~}Um!fz{9DKYmxDL{8Ljtq3spQFoprMaWS*Sn>~=~}a#0ZF(C?u(qHl}g2O zJ+!xbx!k(ZVYSTv^nl+DfHK=;V`-7~cflE)yFoK|xM|JC*adfCEmirj!j2iwFykn9@^Wfd)XuEo@X&rrPtbk5^f^E{HnS!~?J} zr#_UeAA#;WzTAI-yLT;LzkYRGwDYElO;h~|GGX)LNlKPwU)^6)Fsl2fe#53p{luJ5 zm|}?2o^`c90!3zs27b@Rboc0&N~hL?{KgQ{Ipg9;0CRszo{W|wFW}nS`;^smb+pWY z7XmMm00iPzH+j?X`m)r)iQDwIUXm8>k`|ioR|i}?b{1Q62eXYSaLqksN^;gfPL4Z` z+U8uZV3xtDp`w|Xk^<)r*HzkKzzOD9V;N<6GOvU&)ihMHndj3{Pz#81+C9CXMk9TF zI>893>8N{jnF};ubYH_)^7{j({c4?^DPVO&-@fV|K=%`y+jgT+3}iP7Z|b5N-c-M8rJmelYH*luen;u+$0*oI7};00^O!42l@Ia%}{G zy2Qq|w#M;Y9O=paK;zFC?UK>tk}LL|JzwR{$MCRX- z_BqGqZ5s5H(vG;d9eQ6p1=1nO40S+SyB;1$#3S-t;M60F-bf2SRvMy)w?ufthSzu>fAwbMLLQxk#iBCxyZ5M)qRQ0%<&!tQpk!_{sNK{LZp9QjqcMwA zVLsdp-Hq|#aV4KFHnmEeTl{Nz0Ay+48h{?sDnq2^<>hNqUQplAQ2p}Nw8!%@cDO9x zZg15t*xxbSv`Y@d@w$ zo~iIkuwvK0b`X5c|Np2Y{NJL=a;b=2VmE;-oxGPec>g?ce|B?{@%cZNymHUq!V~x{ zUHeKwAwkPRNnWe7bt- zp&84(!)Wq}_voEa>Kpgzx_0S+JCIWf?qQm)gP&|$fXSM!P-a%+d8(~Uoz~hKC~0u$b6l&dWxyHFKy?5?pUV%WR4+O6UmT<*95j3 z?v`G~jZ}S+CB@_??5}>`jASZ6#CEV}Jqf|n8~sCYUDSjVTs6zwz}I}|H#3{*wu)kO z{@V=Lw|=@wHNm%g57ujCzP-&;Bh`ugaudHyoOQq|?zIVJb9cERr2e;{Y__N@rn+!6 zv7hQbW?%2Q{C;$Mia;~=c?_jiTveDu?{zaLC0={;lEsw#c^2R;(3!l_MIS?~(3^qZ z6Lz&3G6-wAel#|=UAl@?jlh_Yc+Fscxui;ScOIkPmV-7#j|pk-m#3Yw@2tr^jnU8H z2m)i?jZNnT$)6;Bm5MUJ!dlnUN`Q?SutgS)z}x;|3QmD2d5 z?vGGj3`GU?$3_&6_D+(~3y^UTW3e;&g5zAaRlJu8=K1$A{rlEP@U@;5u>@fa7+17Q z$IBfHIVDQ-gzm&sPpO!E_ZMk<>Ut7MQOOGLrd$GOkd5TmDnIJKC*mL3 z_bdb&0*%NNT5pxiiK)Ir2Fq6wGHlb^qQ5~(OR}(H#)tUN1)cvN6^WHlp~b6s1=@eQi2 z)KJw3o6?D`Ss^JLII|a6T2tsS0(UL9w^tin=Ku`55bWv4>JMQ5g^7ju^$NguVzKNA3T^k3uRbv z&#K0tb>{V-{k?&?QN1vh5b6q=hu(jVV=4=il;nII=b)qo1N;r3B$>_Bx&!a)8B}p*qMvG;0r#`O zHB_xQn8L4^&prer0#3xA@-Wr^+-ofME zd+K$ceIiuzz9rIsrf2|Fo^4@!BI|(QsR9=A_W*w9TYRFHR28rs=LXTi8GKQrNQX!3zBof)2gaSaD8}USGHu|zG&|(Xz;x=6?yu1 zMFC{QQzPC5zI0$-$a+$wpkEudB4Vn4=qN_E$VKSarlJodEwKoGONNYmhr0Aa0eAHi zzWX}bpEw;Wseg})g!;!kYu$o5i&IR^7=QjAY4^^yOgLhDs(7FfqC7% z*81dQpC(>B=ve)%WxIC`+!gUdt81D~7xymO0%dK{WKs2n|BJVst&bOEHT9MHL#hiY zr+0Sy7X0ph+E{V*y4>(=f!+izgOu}&tk#N+>vXf)(e8B+#{|D%`dU;$#_gSD2sP~x z2C0zit*U*zn|bT20I?l+Dqla*uP|Lb_z4F020N&*XW$FCL-t5aLd1`pp7JXkb>yzm ziVJR1Hv_iFc+?L~I4F8n7W9*~S2zCa=ZV*5&9nd?b0{ukJyQ`2MD zf&pWG7EyD%V;rmf2bejELcFoxhIFW6rj4k~LFY((qiMGYt7c7? z8ZJl;Y7+Y_9I)?5R>5yVt3K1swIp5^wG44ZR@dm zAX!l%m7Yyp@OkUPvRaD}?uLbJt|6(iesH z+c7`b4E&y&8r4f_iVC{@D(bi1J?gS9yZo90(=9m0byIeXnhS&AY}=Qm98MSZiuBK)0wFqO2E7j$TM)H)+U~cauO*m z(JQph*+sje)a5R<6S8a>TT%Fe^Iy>e&_oea%XMU~S9Kf;T6eW|p}(`maJJlc=m;>1 zC_X2opVhFXq$S^7rWH}sI`LtSjM0Mh)Y6aV=|aMAs?L#3$DLq@y=}*vhB0x8F+aFb zMN=#IHFI-({|;YDfyS)CBRNxP<{Wm|(;X1?uj^kaIY$~x6EGU&Ty>Ev{YNWg{={z9BYUZ03c+<}!u`SsnW za!vSQhQ|jxP8xm5uE*fQ)q~0?z^m-W?~^*z7VA-Cj@)Z!O?c%jksoJeA^m3#E6D-r zH0E8?h7U6Hoi|dds+|zHsk*KtfbNK|op> zq@=sMyG!X1knT`Hq@}yNMY@&l8bZ3eb7-E0{?ECeGwlzYE2`h%+nuLYpTCPsFBIrm5Gas zq#X~vu_wNlqik#qy6tpM7r^iu6pe+>OIj~W-S0VHNmJ;ZvGj4D8o!uWJVVAwpb0wS zm^2&VZU{S-_oRcZ@>E?CG2~JtVMbxoHt1VmPQFg_&!xbM&9u5urFi|{Lv(^@zg5eV zmmwynqvuH&_c>WXm^a6RO_Qn1eRtNw1 zzqpW(LCGJLl3f|>mo+xdXQQ9j1^)fb4FWq4N6Vu1@0l9J&oiHE9G}DhTmuL}U^RK% z-+7JjtjA^wBzzJWl)b%JGI|t&WC$Yr&whXV7XQjeG@55#QCC+ulY|h6xU&&zW&<%I zL&<@&Hpcy*oo^WqYL}Iv`q|k{vQl0ViRY1Fd1l6ZGV&nZqG4=jXBR8^1t~K#6E$9k zlG$=NonJsBK+ zN>$5@VcCi*K%eo&d-{)DBN!TOpm?}yu?+PyQ&>ss;FFV-e?OZ2`E$)@cslGNH|&rj~QE#JJMj~Uk~h{P*$J27?Kl$?%9y*S^SY0usmii}L7$l;ao zCc?ZXKRjlervqVqJsy{_gJP@ng@Pn+DzHPh!u=ar-;Z?;2Y2H;H8!))T3qf)nr4KB zgk)5_j@!!tE2U&yr%F|wv`P0;^Yx7V71Emx&Ft)mzAI;;}?3tEpf}`V} zz1_4LRS_vc%KHF_FOdHP?-?K2R@X0mX+fyD$^BMb`lph{t$ZK$0L$)lozzMv!s8Lk z68QkQZ1jVD5LE<#i`gd)HI{Si+~(ElEIUxEZ*^MudlO(-3JL!m(RYD{h4%Va1ZJ1Q zRgRf4d!)JfY{j*&cTt7j19aJe-hi*0~a- z*?cYi{xJj=YmRug8=P01mCN*7Ku+b1RkZXJlN?X_^k6ax&FvDgrgdMp-rl-Lpwxpm ztXTi9p1(h~yKP&Fwtk^%$Qg*Nq z?xH|E=c4V<4$@-J4Ogn?u)p+! zyd4IFI!pi79nfo@ZVWyrQP-8__q=&Mnhy7=P-x-N+0G+ zuVyj(M+rHM0KEw}%oinH7tgY=JYw?O^9F2pkCT=LuO`@w=k8}HHy9)pr$~DWWQRpL z`^2Rzw9z*&8>zv@^Sb!Y?ri_=2Y5BQ>v1dhq_#l{h~gtcF6Zrwo(jo0%O?o3SP{!y zl0XdoYe=y7VSiIh?eE_$;c>>yXC^?~NK@NEfsD!S)YMW4C|tU%NW-%M%)+%;NR3!3 z)!&^Bi+oCQIe#btgxiM_`Tc*D#TpzsFDVG94SaP{TeQzccUIFbJ9My8T6G?=sNOjKP}o`3_=3RWrNmT!=k}W- zyxiE~Gk0|9cwS&mYEESDFdOGU(piU!#^8Fd+wO09pNok0nDv{pxDp5d(~m_(CQk_^9!s|{l?k486&?? zy<7J)^t}ZJGI}*#Sx7okd36f6XSZOs44XXRlX!C*!+q@>Txf(xDKJ-c>kK$Eb`hPUsXR>YTa3 zsk*GB@L`Uwkw1w)!*?r?E<+Bk3my(^FD2IrG-!#~&Bo+N@VmOI(sm5hd8aePxIJL3 zkoGwSqn|rGe2G5t$9IHmCY4>oOIo{0eP}T8&x@Iq@>QG3*&(|pE)Epo!2#E`@nT39 z4z$?JeV(D6X#1A8I2}B(?Ho9PSS^~%y(RsL7RD9w9N%hot0Lw<4|pB@k<*1~K4;sH zYV7gLMj}wCTr9a66J28@!id6+@nKFOE?*rFC{AmANqyk|)R|eYV8DY;NXF=%`Zi?5 z>}$cBIm2m+G_&>nS?)Ca0?~*0d-DQzFB{_iVZPbp+jwnEsSc5N7siFwZxW7; zyR{G2JZrQ5G7hG)bIopYPZ1H}A#7bBQ80InrguAZe|5Sol%89_lhe^L_2Q`Rr z8TvCHyN;HVds!YWzWOD^&SX`nZ^H_4Rf+Wed zhZ|kvw^eR=4&17`w`NgC5-bVW)mu(f*}-q3}`923Ms79?)hCyM*MjeUlqV7RliES7wLQaYH)5^}pjf;&hdidmw3?z~3x zkuqjY4~b#LM;Yk3#OkA-Urj}1X;Xvf$AFDkHw2$D=888_i6P50kA3Ni51VB@Hjy&L zej$x*ur9iAfUGxl?)jM>=Eo*+!l;s(nuI~xQx>}^ajvUVTOX$)`MHRx>@0z=Mkzk! z?6+SI5_qLR1YdQtTXXxwwpI3E3fs;$W4rIv9!!bocWs?ITUR;FR9P>`=LM&hR#v`* zD~%qj1Z5_~pknVO<~7|GSITImRMaQa+)lm*+)bA^I?b;(y5Sx-oxo|Jd0ZnHoRTOS zji;!rYI=26x;YD^`Y;-|m=!X!bny5toQ+xvjEajK4RbaH{ZA}lr>~3h@cNl_qXD?_ znV=0J_)D~0A=CrU@`J_HV2w{$vBfHi%%{KZSofn>;x6;2}oocc&f9 znax6nuJPDGcJ=DH2G;X%*J^8q60>=4 zC!)>_>eZ4{QC)8kW6sT0UtAI-3e02x{p8ye GNJceafB}YA>U9lfe$TezQ_7CO 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 types.map((type) => ({ to: { type } })); const rules = [ { from: { type: 'generated' }, allow: [] }, - { from: { type: 'websocket' }, allow: types('generated') }, - { from: { type: 'types' }, allow: types('generated', 'websocket') }, + { from: { type: 'websocket-types' }, allow: types('generated') }, + { from: { type: 'websocket' }, allow: types('generated', 'websocket-types') }, + { from: { type: 'types' }, allow: types('generated') }, - { from: { type: 'store' }, allow: types('types') }, - { from: { type: 'api' }, allow: types('store', 'types', 'websocket') }, + { from: { type: 'store' }, allow: types('types', 'websocket-types') }, + { from: { type: 'api' }, allow: types('store', 'types', 'websocket', 'websocket-types') }, { from: { type: 'images' }, allow: types('types') }, { from: { type: 'services' }, allow: types('api', 'store', 'types') }, - { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket') }, + { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket', 'websocket-types') }, { from: { type: 'components' }, - allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types') }, { from: { type: 'containers' }, - allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types') }, - { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types') }, - { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types') }, + { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'websocket-types') }, + { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'websocket-types') }, ]; export const boundariesConfig = [ diff --git a/webclient/integration/src/app/login-autoconnect.spec.tsx b/webclient/integration/src/app/login-autoconnect.spec.tsx index 8f11c1843..f68d1ef6d 100644 --- a/webclient/integration/src/app/login-autoconnect.spec.tsx +++ b/webclient/integration/src/app/login-autoconnect.spec.tsx @@ -25,14 +25,14 @@ const flushStoresAndEffects = async (): Promise => { }); }; -import { autoLoginSession } from '../../../src/hooks/useAutoLogin'; +import { autoLoginGate } from '../../../src/hooks/useAutoLogin'; import { settingsStore } from '../../../src/hooks/useSettings'; import { knownHostsStore } from '../../../src/hooks/useKnownHosts'; import Login from '../../../src/containers/Login/Login'; import { HostDTO, SettingDTO } from '@app/services'; import { App } from '@app/types'; import { ServerSelectors, ServerDispatch } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { resetDexie } from '../services/dexie/resetDexie'; import { renderAppScreen, store } from './helpers'; @@ -41,7 +41,7 @@ import { renderAppScreen, store } from './helpers'; // dispatching updateStatus(DISCONNECTED) is what the real reducer uses to // clear connectionAttemptMade (clearStore intentionally preserves status). const simulateLogout = () => { - ServerDispatch.updateStatus(StatusEnum.DISCONNECTED, null); + ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null); }; const seedAutoConnect = async () => { @@ -86,7 +86,7 @@ beforeEach(async () => { // cached values). settingsStore.reset(); knownHostsStore.reset(); - autoLoginSession.startupCheckRan = false; + autoLoginGate.hasChecked = false; }); describe('autoconnect — cold start', () => { @@ -182,7 +182,7 @@ describe('autoconnect — refresh', () => { // Simulate a browser refresh: the session gate naturally resets on a // fresh JS context, and the real connection flag resets too. simulateLogout(); - autoLoginSession.startupCheckRan = false; + autoLoginGate.hasChecked = false; renderAppScreen(); await waitFor(() => { diff --git a/webclient/integration/src/helpers/setup.ts b/webclient/integration/src/helpers/setup.ts index 190f29d97..e9bfc56de 100644 --- a/webclient/integration/src/helpers/setup.ts +++ b/webclient/integration/src/helpers/setup.ts @@ -19,13 +19,8 @@ import { afterEach, beforeEach, vi } from 'vitest'; import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store'; import { Data } from '@app/types'; -import { - WebClient, - StatusEnum, - WebSocketConnectReason, - setPendingOptions, -} from '@app/websocket'; -import type { WebSocketConnectOptions } from '@app/websocket'; +import { WebClient, setPendingOptions } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { PROTOCOL_VERSION } from '../../../src/websocket/config'; import { createWebClientRequest, createWebClientResponse } from '@app/api'; @@ -109,7 +104,7 @@ function resetAll(): void { } client.protobuf.resetCommands(); - client.status = StatusEnum.DISCONNECTED; + client.status = WebsocketTypes.StatusEnum.DISCONNECTED; ServerDispatch.clearStore(); RoomsDispatch.clearStore(); @@ -128,8 +123,8 @@ function resetAll(): void { // ── Shared connect helpers ────────────────────────────────────────────────── -const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = { - reason: WebSocketConnectReason.LOGIN, +const DEFAULT_LOGIN_OPTIONS: WebsocketTypes.WebSocketConnectOptions = { + reason: WebsocketTypes.WebSocketConnectReason.LOGIN, host: 'localhost', port: '4748', userName: 'alice', @@ -137,16 +132,16 @@ const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = { }; export function connectRaw( - overrides: Partial = {} + overrides: Partial = {} ): void { const opts = { ...DEFAULT_LOGIN_OPTIONS, ...overrides }; - setPendingOptions(opts as WebSocketConnectOptions); + setPendingOptions(opts as WebsocketTypes.WebSocketConnectOptions); getWebClient().connect({ host: opts.host, port: opts.port }); openMockWebSocket(); } export function connectAndHandshake( - overrides: Partial = {} + overrides: Partial = {} ): void { connectRaw(overrides); deliverMessage(buildSessionEventMessage( @@ -160,7 +155,7 @@ export function connectAndHandshake( } export function connectAndHandshakeWithSalt( - overrides: Partial = {} + overrides: Partial = {} ): void { connectRaw(overrides); deliverMessage(buildSessionEventMessage( diff --git a/webclient/integration/src/websocket/authentication.spec.ts b/webclient/integration/src/websocket/authentication.spec.ts index 2da411aad..c2fc04428 100644 --- a/webclient/integration/src/websocket/authentication.spec.ts +++ b/webclient/integration/src/websocket/authentication.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { connectAndHandshake, connectAndHandshakeWithSalt } from '../helpers/setup'; import { @@ -44,7 +44,7 @@ describe('authentication', () => { }))); const state = store.getState().server; - expect(state.status.state).toBe(StatusEnum.LOGGED_IN); + expect(state.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); expect(state.status.description).toBe('Logged in.'); expect(state.user?.name).toBe('alice'); expect(Object.keys(state.buddyList)).toEqual(['bob']); @@ -64,7 +64,7 @@ describe('authentication', () => { }))); const state = store.getState().server; - expect(state.status.state).toBe(StatusEnum.DISCONNECTED); + expect(state.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(state.user).toBeNull(); expect(state.buddyList).toEqual({}); }); @@ -72,7 +72,7 @@ describe('authentication', () => { describe('register', () => { const registerOptions = { - reason: WebSocketConnectReason.REGISTER as const, + reason: WebsocketTypes.WebSocketConnectReason.REGISTER as const, host: 'localhost', port: '4748', userName: 'newbie', @@ -107,7 +107,7 @@ describe('authentication', () => { responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation, }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); }); }); @@ -115,7 +115,7 @@ describe('authentication', () => { describe('activate', () => { it('auto-logs-in on RespActivationAccepted', () => { connectAndHandshake({ - reason: WebSocketConnectReason.ACTIVATE_ACCOUNT as const, + reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT as const, host: 'localhost', port: '4748', userName: 'alice', @@ -171,7 +171,7 @@ describe('authentication', () => { }), }))); - expect(store.getState().server.status.state).toBe(StatusEnum.LOGGED_IN); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); }); }); }); diff --git a/webclient/integration/src/websocket/connection.spec.ts b/webclient/integration/src/websocket/connection.spec.ts index 2304df399..1903bb75d 100644 --- a/webclient/integration/src/websocket/connection.spec.ts +++ b/webclient/integration/src/websocket/connection.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { PROTOCOL_VERSION } from '../../../src/websocket/config'; @@ -18,17 +18,15 @@ import { setPendingOptions, connectAndHandshake, } from '../helpers/setup'; -import type { WebSocketConnectOptions } from '@app/websocket'; -import { WebSocketConnectReason } from '@app/websocket'; import { buildSessionEventMessage, deliverMessage, } from '../helpers/protobuf-builders'; import { findLastSessionCommand } from '../helpers/command-capture'; -function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions { +function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebsocketTypes.WebSocketConnectOptions { return { - reason: WebSocketConnectReason.LOGIN, + reason: WebsocketTypes.WebSocketConnectReason.LOGIN, host: 'localhost', port: '4748', userName: overrides.userName ?? 'alice', @@ -36,7 +34,7 @@ function loginOptions(overrides: Partial<{ userName: string; password: string }> }; } -function connectWithOptions(opts: WebSocketConnectOptions): void { +function connectWithOptions(opts: WebsocketTypes.WebSocketConnectOptions): void { setPendingOptions(opts); getWebClient().connect({ host: opts.host, port: opts.port }); } @@ -63,7 +61,7 @@ describe('connection lifecycle', () => { openMockWebSocket(); - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); expect(store.getState().server.status.description).toBe('Connected'); }); @@ -73,7 +71,7 @@ describe('connection lifecycle', () => { deliverMessage(serverIdentification()); - expect(store.getState().server.status.state).toBe(StatusEnum.LOGGING_IN); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.LOGGING_IN); expect(store.getState().server.info.name).toBe('TestServer'); expect(store.getState().server.info.version).toBe('2.8.0'); @@ -90,7 +88,7 @@ describe('connection lifecycle', () => { const mock = getMockWebSocket(); expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow(); }); @@ -103,7 +101,7 @@ describe('connection lifecycle', () => { vi.advanceTimersByTime(5000); expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('releases keep-alive ping loop on explicit disconnect', () => { @@ -115,7 +113,7 @@ describe('connection lifecycle', () => { getWebClient().disconnect(); expect(mock.close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('drops pending commands and clears state on unexpected socket close', () => { @@ -129,6 +127,6 @@ describe('connection lifecycle', () => { mock.readyState = 3; mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); \ No newline at end of file diff --git a/webclient/integration/src/websocket/keep-alive.spec.ts b/webclient/integration/src/websocket/keep-alive.spec.ts index 90ee634e0..0f6d6f4b5 100644 --- a/webclient/integration/src/websocket/keep-alive.spec.ts +++ b/webclient/integration/src/websocket/keep-alive.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { connectRaw, getMockWebSocket } from '../helpers/setup'; import { @@ -32,7 +32,7 @@ describe('keep-alive', () => { vi.advanceTimersByTime(5000); const second = findLastSessionCommand(Data.Command_Ping_ext); expect(second.cmdId).toBeGreaterThan(first.cmdId); - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); }); it('stays CONNECTED while pongs arrive before the next tick', () => { @@ -47,7 +47,7 @@ describe('keep-alive', () => { }))); } - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); expect(getMockWebSocket().close).not.toHaveBeenCalled(); }); @@ -56,11 +56,11 @@ describe('keep-alive', () => { vi.advanceTimersByTime(5000); expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow(); - expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED); vi.advanceTimersByTime(5000); expect(getMockWebSocket().close).toHaveBeenCalled(); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); diff --git a/webclient/integration/src/websocket/password-reset.spec.ts b/webclient/integration/src/websocket/password-reset.spec.ts index 998390304..f4aa48901 100644 --- a/webclient/integration/src/websocket/password-reset.spec.ts +++ b/webclient/integration/src/websocket/password-reset.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum, WebSocketConnectReason } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { connectAndHandshake } from '../helpers/setup'; import { @@ -19,7 +19,7 @@ import { findLastSessionCommand } from '../helpers/command-capture'; describe('password reset', () => { it('forgotPasswordRequest sends command and disconnects on success', () => { connectAndHandshake({ - reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST as const, + reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST as const, host: 'localhost', port: '4748', userName: 'alice', @@ -37,12 +37,12 @@ describe('password reset', () => { }), }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('forgotPasswordChallenge sends command with userName and email', () => { connectAndHandshake({ - reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const, + reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const, host: 'localhost', port: '4748', userName: 'alice', @@ -58,12 +58,12 @@ describe('password reset', () => { responseCode: Data.Response_ResponseCode.RespOk, }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('forgotPasswordReset sends command with userName, token, and newPassword', () => { connectAndHandshake({ - reason: WebSocketConnectReason.PASSWORD_RESET as const, + reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET as const, host: 'localhost', port: '4748', userName: 'alice', @@ -81,6 +81,6 @@ describe('password reset', () => { responseCode: Data.Response_ResponseCode.RespOk, }))); - expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); \ No newline at end of file diff --git a/webclient/integration/src/websocket/server-events.spec.ts b/webclient/integration/src/websocket/server-events.spec.ts index a0eefebf1..0a27a9669 100644 --- a/webclient/integration/src/websocket/server-events.spec.ts +++ b/webclient/integration/src/websocket/server-events.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; import { Data } from '@app/types'; import { store } from '@app/store'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { connectAndHandshake } from '../helpers/setup'; import { @@ -73,7 +73,7 @@ describe('server events', () => { )); const status = store.getState().server.status; - expect(status.state).toBe(StatusEnum.DISCONNECTED); + expect(status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); expect(status.description).toBe('kicked by admin'); }); diff --git a/webclient/package.json b/webclient/package.json index 422b2e517..386070a37 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -19,7 +19,11 @@ "golden:coverage": "npm run lint && npm run test:coverage && npm run test:integration:coverage", "prepare": "cd .. && husky", "translate": "node prebuild.js -i18nOnly", - "proto:generate": "npx buf generate" + "proto:generate": "npx buf generate", + "diagram": "npm run diagram:simple && npm run diagram:detailed && npm run diagram:flow", + "diagram:simple": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/simple.mmd -o architecture/simple.png -b white -s 2", + "diagram:detailed": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/detailed.mmd -o architecture/detailed.png -b white -s 2", + "diagram:flow": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/flow.mmd -o architecture/flow.png -b white -s 2" }, "dependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/webclient/src/__test-utils__/globalGuards.ts b/webclient/src/__test-utils__/globalGuards.ts index b350d440f..ee39ee223 100644 --- a/webclient/src/__test-utils__/globalGuards.ts +++ b/webclient/src/__test-utils__/globalGuards.ts @@ -1,15 +1,9 @@ -// Shared lifecycle helpers for test files that need to mutate global state. -// -// The root `setupTests.ts` guards catch leaks even when callers forget to -// clean up, but opt-in helpers make intent explicit at the call site and -// avoid piling cleanup logic onto the shared safety net. - /** * Temporarily override fields on `window.location` and return a restore fn. * - * `Object.defineProperty(window, 'location', ...)` is not a `vi.spyOn` target, - * so `vi.restoreAllMocks()` will NOT undo it. Always pair with the returned - * `restore` callback (ideally in `afterEach`). + * @critical `Object.defineProperty(window, 'location', ...)` isn't a vi.spyOn + * target, so `vi.restoreAllMocks()` will NOT undo it. Always invoke the + * returned restore callback. */ export function withMockLocation(overrides: Partial): () => void { const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); @@ -26,23 +20,3 @@ export function withMockLocation(overrides: Partial): () => void { } }; } - -/** - * Push an entry onto a shared event-handler registry array and return a - * teardown function that removes exactly that entry. - * - * Used by ProtobufService specs which install temporary handlers into the - * (mocked) `GameEvents` / `RoomEvents` / `SessionEvents` arrays. Manual - * `.push()`/`.pop()` inside a test body corrupts the array if an assertion - * throws between them — this helper makes the teardown safe to run in - * `afterEach`. - */ -export function withEventRegistry(registry: T[], entry: T): () => void { - registry.push(entry); - return () => { - const index = registry.lastIndexOf(entry); - if (index !== -1) { - registry.splice(index, 1); - } - }; -} diff --git a/webclient/src/__test-utils__/index.ts b/webclient/src/__test-utils__/index.ts index 4deac33d2..7ee47e601 100644 --- a/webclient/src/__test-utils__/index.ts +++ b/webclient/src/__test-utils__/index.ts @@ -1,4 +1,4 @@ -export { withMockLocation, withEventRegistry } from './globalGuards'; +export { withMockLocation } from './globalGuards'; export { renderWithProviders } from './renderWithProviders'; export { createMockWebClient } from './mockWebClient'; export { disconnectedState, connectedState, connectedWithRoomsState, makeUser } from './storeFixtures'; diff --git a/webclient/src/__test-utils__/mockWebClient.ts b/webclient/src/__test-utils__/mockWebClient.ts index 3faafe391..875a4a9d9 100644 --- a/webclient/src/__test-utils__/mockWebClient.ts +++ b/webclient/src/__test-utils__/mockWebClient.ts @@ -2,8 +2,9 @@ import type { WebClient } from '@app/websocket'; /** * Creates a mock WebClient whose `request` property has vi.fn() stubs - * for every service method that containers/forms call. Inject this into - * tests via `renderWithProviders({ webClient: createMockWebClient() })`. + * for every service method that containers/forms call. Inject via a + * vi.hoisted reference returned from a `vi.mock('@app/hooks', ...)` stub + * of `useWebClient`; see LoginForm.spec.tsx for the canonical pattern. */ export function createMockWebClient() { return { diff --git a/webclient/src/__test-utils__/renderWithProviders.tsx b/webclient/src/__test-utils__/renderWithProviders.tsx index 4d0546083..7d78f171b 100644 --- a/webclient/src/__test-utils__/renderWithProviders.tsx +++ b/webclient/src/__test-utils__/renderWithProviders.tsx @@ -14,12 +14,8 @@ import { actionReducer } from '../store/actions'; import { ToastProvider } from '../components/Toast/ToastContext'; import type { RootState } from '../store/store'; -// Minimal i18n instance for tests — returns keys as-is. A non-empty -// `resources` entry is required so i18next registers `en-US` as a known -// language; otherwise `i18n.resolvedLanguage` stays `undefined`, which -// LanguageDropdown seeds into a MUI Select and MUI warns "out-of-range -// value `undefined`". Value is an empty translation map, since tests -// already assert on i18n keys directly. +// Non-empty `resources` registers en-US so `resolvedLanguage` is defined; +// without it MUI warns about out-of-range Select values. const testI18n = i18n.createInstance(); testI18n.use(initReactI18next).init({ lng: 'en-US', diff --git a/webclient/src/__test-utils__/storeFixtures.ts b/webclient/src/__test-utils__/storeFixtures.ts index 9733fe6ef..2d7a57878 100644 --- a/webclient/src/__test-utils__/storeFixtures.ts +++ b/webclient/src/__test-utils__/storeFixtures.ts @@ -1,4 +1,5 @@ -import { App, Data, Enriched } from '@app/types'; +import { App, Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import type { RootState } from '../store/store'; /** @@ -30,7 +31,7 @@ export const disconnectedState: Partial = { ignoreList: {}, status: { connectionAttemptMade: false, - state: Enriched.StatusEnum.DISCONNECTED, + state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null, }, info: { message: null, name: null, version: null }, @@ -77,7 +78,7 @@ export const connectedState: Partial = { initialized: true, status: { connectionAttemptMade: true, - state: Enriched.StatusEnum.LOGGED_IN, + state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null, }, info: { diff --git a/webclient/src/api/request/AdminRequestImpl.ts b/webclient/src/api/request/AdminRequestImpl.ts index 5cc21695b..1c22d7be6 100644 --- a/webclient/src/api/request/AdminRequestImpl.ts +++ b/webclient/src/api/request/AdminRequestImpl.ts @@ -1,7 +1,7 @@ -import type { IAdminRequest } from '@app/websocket'; import { AdminCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class AdminRequestImpl implements IAdminRequest { +export class AdminRequestImpl implements WebsocketTypes.IAdminRequest { adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void { AdminCommands.adjustMod(userName, shouldBeMod, shouldBeJudge); } diff --git a/webclient/src/api/request/AuthenticationRequestImpl.ts b/webclient/src/api/request/AuthenticationRequestImpl.ts index 91788d862..ece8a1891 100644 --- a/webclient/src/api/request/AuthenticationRequestImpl.ts +++ b/webclient/src/api/request/AuthenticationRequestImpl.ts @@ -1,70 +1,58 @@ import { WebClient, - StatusEnum, SessionCommands, - WebSocketConnectReason, setPendingOptions, } from '@app/websocket'; -import type { - IAuthenticationRequest, - AuthRequestMap, - LoginConnectOptions, - TestConnectionOptions, - RegisterConnectOptions, - ActivateConnectOptions, - PasswordResetRequestConnectOptions, - PasswordResetChallengeConnectOptions, - PasswordResetConnectOptions, -} from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -interface AppAuthRequestOverrides extends AuthRequestMap { - LoginParams: Omit; - ConnectTarget: Omit; - RegisterParams: Omit; - ActivateParams: Omit; - ForgotPasswordRequestParams: Omit; - ForgotPasswordChallengeParams: Omit; - ForgotPasswordResetParams: Omit; +interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap { + LoginParams: Omit; + ConnectTarget: Omit; + RegisterParams: Omit; + ActivateParams: Omit; + ForgotPasswordRequestParams: Omit; + ForgotPasswordChallengeParams: Omit; + ForgotPasswordResetParams: Omit; } -export class AuthenticationRequestImpl implements IAuthenticationRequest { - login(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.LOGIN }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); +export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest { + login(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.LOGIN }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - testConnection(options: Omit): void { + testConnection(options: Omit): void { WebClient.instance.testConnect({ host: options.host, port: options.port }); } - register(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.REGISTER }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + register(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.REGISTER }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - activateAccount(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + activateAccount(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordRequest(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + resetPasswordRequest(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordChallenge(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + resetPasswordChallenge(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPassword(options: Omit): void { - setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET }); - SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); + resetPassword(options: Omit): void { + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } diff --git a/webclient/src/api/request/GameRequestImpl.ts b/webclient/src/api/request/GameRequestImpl.ts index e594118cc..78a9dc829 100644 --- a/webclient/src/api/request/GameRequestImpl.ts +++ b/webclient/src/api/request/GameRequestImpl.ts @@ -1,9 +1,9 @@ -import type { IGameRequest } from '@app/websocket'; import { GameCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { Data } from '@app/types'; -export class GameRequestImpl implements IGameRequest { +export class GameRequestImpl implements WebsocketTypes.IGameRequest { leaveGame(gameId: number): void { GameCommands.leaveGame(gameId); } diff --git a/webclient/src/api/request/ModeratorRequestImpl.ts b/webclient/src/api/request/ModeratorRequestImpl.ts index 9f5c063da..97984e397 100644 --- a/webclient/src/api/request/ModeratorRequestImpl.ts +++ b/webclient/src/api/request/ModeratorRequestImpl.ts @@ -1,8 +1,8 @@ import { Data } from '@app/types'; -import type { IModeratorRequest } from '@app/websocket'; import { ModeratorCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class ModeratorRequestImpl implements IModeratorRequest { +export class ModeratorRequestImpl implements WebsocketTypes.IModeratorRequest { banFromServer( minutes: number, userName?: string, diff --git a/webclient/src/api/request/RoomsRequestImpl.ts b/webclient/src/api/request/RoomsRequestImpl.ts index 62b1f4e9b..e7264ca3b 100644 --- a/webclient/src/api/request/RoomsRequestImpl.ts +++ b/webclient/src/api/request/RoomsRequestImpl.ts @@ -1,7 +1,7 @@ -import type { IRoomsRequest } from '@app/websocket'; import { RoomCommands, SessionCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class RoomsRequestImpl implements IRoomsRequest { +export class RoomsRequestImpl implements WebsocketTypes.IRoomsRequest { joinRoom(roomId: number): void { SessionCommands.joinRoom(roomId); } diff --git a/webclient/src/api/request/SessionRequestImpl.ts b/webclient/src/api/request/SessionRequestImpl.ts index c7b0e267a..d0ddf472f 100644 --- a/webclient/src/api/request/SessionRequestImpl.ts +++ b/webclient/src/api/request/SessionRequestImpl.ts @@ -1,7 +1,7 @@ -import type { ISessionRequest } from '@app/websocket'; import { SessionCommands } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; -export class SessionRequestImpl implements ISessionRequest { +export class SessionRequestImpl implements WebsocketTypes.ISessionRequest { addToBuddyList(userName: string): void { SessionCommands.addToBuddyList(userName); } diff --git a/webclient/src/api/request/index.ts b/webclient/src/api/request/index.ts index b5934d72a..77093ff38 100644 --- a/webclient/src/api/request/index.ts +++ b/webclient/src/api/request/index.ts @@ -1,4 +1,4 @@ -import type { IWebClientRequest } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { AuthenticationRequestImpl } from './AuthenticationRequestImpl'; import { SessionRequestImpl } from './SessionRequestImpl'; @@ -9,7 +9,7 @@ import { ModeratorRequestImpl } from './ModeratorRequestImpl'; export { AuthenticationRequestImpl, SessionRequestImpl, RoomsRequestImpl, GameRequestImpl, AdminRequestImpl, ModeratorRequestImpl }; -export function createWebClientRequest(): IWebClientRequest { +export function createWebClientRequest(): WebsocketTypes.IWebClientRequest { return { authentication: new AuthenticationRequestImpl(), session: new SessionRequestImpl(), diff --git a/webclient/src/api/response/AdminResponseImpl.ts b/webclient/src/api/response/AdminResponseImpl.ts index a47b0f40b..511357bee 100644 --- a/webclient/src/api/response/AdminResponseImpl.ts +++ b/webclient/src/api/response/AdminResponseImpl.ts @@ -1,7 +1,7 @@ -import type { IAdminResponse } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { ServerDispatch } from '@app/store'; -export class AdminResponseImpl implements IAdminResponse { +export class AdminResponseImpl implements WebsocketTypes.IAdminResponse { adjustMod(userName: string, shouldBeMod: boolean, shouldBeJudge: boolean): void { ServerDispatch.adjustMod(userName, shouldBeMod, shouldBeJudge); } diff --git a/webclient/src/api/response/GameResponseImpl.ts b/webclient/src/api/response/GameResponseImpl.ts index 01978e818..723b8dbcb 100644 --- a/webclient/src/api/response/GameResponseImpl.ts +++ b/webclient/src/api/response/GameResponseImpl.ts @@ -1,8 +1,8 @@ import { Data } from '@app/types'; -import type { IGameResponse } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { GameDispatch } from '@app/store'; -export class GameResponseImpl implements IGameResponse { +export class GameResponseImpl implements WebsocketTypes.IGameResponse { clearStore(): void { GameDispatch.clearStore(); } diff --git a/webclient/src/api/response/ModeratorResponseImpl.ts b/webclient/src/api/response/ModeratorResponseImpl.ts index c855152af..6bb9b2b8a 100644 --- a/webclient/src/api/response/ModeratorResponseImpl.ts +++ b/webclient/src/api/response/ModeratorResponseImpl.ts @@ -1,8 +1,8 @@ import { Data } from '@app/types'; -import type { IModeratorResponse } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { ServerDispatch } from '@app/store'; -export class ModeratorResponseImpl implements IModeratorResponse { +export class ModeratorResponseImpl implements WebsocketTypes.IModeratorResponse { banFromServer(userName: string): void { ServerDispatch.banFromServer(userName); } diff --git a/webclient/src/api/response/RoomResponseImpl.ts b/webclient/src/api/response/RoomResponseImpl.ts index d450158c0..38a8ee7d4 100644 --- a/webclient/src/api/response/RoomResponseImpl.ts +++ b/webclient/src/api/response/RoomResponseImpl.ts @@ -1,10 +1,10 @@ import { Data } from '@app/types'; -import type { IRoomResponse, WebSocketRoomResponseOverrides } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { RoomsDispatch } from '@app/store'; -type Message = WebSocketRoomResponseOverrides['Event_RoomSay']; +type Message = WebsocketTypes.WebSocketRoomResponseOverrides['Event_RoomSay']; -export class RoomResponseImpl implements IRoomResponse { +export class RoomResponseImpl implements WebsocketTypes.IRoomResponse { clearStore(): void { RoomsDispatch.clearStore(); } diff --git a/webclient/src/api/response/SessionResponseImpl.ts b/webclient/src/api/response/SessionResponseImpl.ts index 59892d08d..150a942f7 100644 --- a/webclient/src/api/response/SessionResponseImpl.ts +++ b/webclient/src/api/response/SessionResponseImpl.ts @@ -1,12 +1,11 @@ import { Data } from '@app/types'; -import type { ISessionResponse, WebSocketSessionResponseOverrides } from '@app/websocket'; -import { StatusEnum } from '@app/websocket'; +import { WebsocketTypes } from '@app/websocket/types'; import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store'; -type LoginSuccess = WebSocketSessionResponseOverrides['Response_Login']; -type PendingActivation = WebSocketSessionResponseOverrides['Response']; +type LoginSuccess = WebsocketTypes.WebSocketSessionResponseOverrides['Response_Login']; +type PendingActivation = WebsocketTypes.WebSocketSessionResponseOverrides['Response']; -export class SessionResponseImpl implements ISessionResponse { +export class SessionResponseImpl implements WebsocketTypes.ISessionResponse { initialized(): void { ServerDispatch.initialized(); } @@ -67,8 +66,8 @@ export class SessionResponseImpl implements ISessionResponse { return ( diff --git a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx index 5043ca36c..d98defb2b 100644 --- a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx +++ b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx @@ -11,11 +11,8 @@ import './LanguageDropdown.css'; const LanguageDropdown = () => { const { t, i18n } = useTranslation(); - // `resolvedLanguage` can be undefined when i18next hasn't matched the - // active lng against any registered resource yet — most often at the - // first render in tests with a minimal i18n instance. Fall back to - // `i18n.language` (always set to whatever was passed to init) and then - // to empty string so MUI's Select has a concrete, in-range value. + // i18next `resolvedLanguage` is undefined until a registered resource matches; + // MUI Select requires a concrete, in-range value. const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? ''); useEffect(() => { diff --git a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx index 939a7d753..95f10f62c 100644 --- a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx +++ b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useRef } from 'react'; const ScrollToBottomOnChanges = ({ content, changes }) => { const messagesEndRef = useRef(null); - // @TODO (2) const scrollToBottom = () => { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) } diff --git a/webclient/src/containers/App/AppShell.tsx b/webclient/src/containers/App/AppShell.tsx index 66618d865..4a0856bc5 100644 --- a/webclient/src/containers/App/AppShell.tsx +++ b/webclient/src/containers/App/AppShell.tsx @@ -12,7 +12,6 @@ import { ToastProvider } from '@app/components' function AppShell() { useEffect(() => { - // @TODO (1) window.onbeforeunload = () => true; }, []); diff --git a/webclient/src/containers/Login/Login.spec.tsx b/webclient/src/containers/Login/Login.spec.tsx index 4f59e37b1..e2a398066 100644 --- a/webclient/src/containers/Login/Login.spec.tsx +++ b/webclient/src/containers/Login/Login.spec.tsx @@ -1,28 +1,7 @@ -/** - * Login auto-connect integration tests. - * - * Exercises the full wire from `useAutoLogin` through the Login container - * into `webClient.request.authentication.login`. Scenarios mirror the user- - * visible cycles we care about: - * - cold start with / without auto-connect - * - logout within the same session must NOT re-auto-connect - * - page refresh (fresh JS context) resets the gate - * - * The startup-check gate lives on the `autoLoginSession` object exported by - * `useAutoLogin.ts`. Tests flip it back to false in `beforeEach` to stand in - * for a page refresh between scenarios. `vi.resetModules()` would be the - * natural equivalent but is prohibitively slow in the full suite because it - * forces every imported module to re-evaluate. - */ - import { act, waitFor } from '@testing-library/react'; import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__'; -// Lets pending microtasks resolve inside an act() scope so that the state -// updates they trigger (useFormState subscribers, useFireOnce state, etc.) -// are captured. Without this, useAutoLogin's Promise.all resolves *after* -// render returns, and React warns "update ... was not wrapped in act". const flushEffects = async (): Promise => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -30,7 +9,7 @@ const flushEffects = async (): Promise => { }; import { makeSettings, makeSettingsHook } from '../../hooks/__mocks__/useSettings'; import { makeHost, makeKnownHostsHook } from '../../hooks/__mocks__/useKnownHosts'; -import { autoLoginSession } from '../../hooks/useAutoLogin'; +import { autoLoginGate } from '../../hooks/useAutoLogin'; import { LoadingState } from '@app/hooks'; import Login from './Login'; @@ -62,22 +41,12 @@ beforeAll(() => { }); afterEach(async () => { - // Absorb any state updates that lingered past the test body (e.g. - // useAutoLogin's Promise.all resolving a moment too late) so they're - // wrapped in act and don't trip React's warning during teardown. await flushEffects(); }); beforeEach(() => { - // "Page refresh" between tests: reset the session gate that useAutoLogin - // uses to prevent re-firing within a JS session. Production code only - // writes this flag once, from inside the startup effect; tests flip it - // back to false here to simulate a fresh browser tab. - autoLoginSession.startupCheckRan = false; + autoLoginGate.hasChecked = false; - // clearAllMocks in the global afterEach only clears call history; mock - // implementations (mockResolvedValue, mockReturnValue) persist. Reset - // them explicitly so a previous test's arming doesn't leak into this one. hoisted.getSettings.mockReset(); hoisted.getKnownHosts.mockReset(); hoisted.useSettings.mockReset(); @@ -167,39 +136,26 @@ describe('Login — logout cycle (same JS session)', () => { test('does not re-auto-connect after first auto-login + logout', async () => { armAutoConnect(); - // First mount: Login appears, useAutoLogin fires login. const first = renderWithProviders(, { preloadedState: disconnectedState }); await waitFor(() => { expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); }); - // Simulate arriving at /server and then logging out: Login unmounts, - // then a fresh Login mounts again with disconnected state. first.unmount(); renderWithProviders(, { preloadedState: disconnectedState }); await flushEffects(); - // No second login call — the session gate is latched. expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); }); test('does not auto-connect when user enabled autoConnect mid-session and then logged out', async () => { - // Scenario: user manually logs in with autoConnect=false. They tick the - // auto-connect checkbox during that session (the setting flips true). - // They log out. Returning to /login must NOT auto-connect — the setting - // change was a preference for NEXT launch, not a signal to log in. - - // First mount: autoConnect=false, so the startup check runs and finds - // nothing to do. The gate latches anyway. const first = renderWithProviders(, { preloadedState: disconnectedState }); await flushEffects(); expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); first.unmount(); - // Mid-session, user ticked the checkbox. Future getSettings resolves - // return the new value, but the session gate prevents a re-check. hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); renderWithProviders(, { preloadedState: disconnectedState }); @@ -209,10 +165,6 @@ describe('Login — logout cycle (same JS session)', () => { }); describe('Login — refresh cycle', () => { - // `beforeEach` flips autoLoginSession.startupCheckRan back to false, which - // stands in for a page refresh. This test just re-asserts the positive - // case: a refresh re-enables auto-connect when the persisted preference - // still says yes. test('a fresh session gate re-fires auto-login when conditions still hold', async () => { armAutoConnect(); diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index be097a7b8..78bcdf242 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -12,7 +12,8 @@ import { LoginForm } from '@app/forms'; import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { Images } from '@app/images'; import { getHostPort, serverProps } from '@app/services'; -import { App, Enriched } from '@app/types'; +import { App } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { ServerSelectors, ServerTypes } from '@app/store'; import Layout from '../Layout/Layout'; import { useAppSelector } from '@app/store'; @@ -70,7 +71,7 @@ const Login = () => { const webClient = useWebClient(); const { t } = useTranslation(); - const [pendingActivationOptions, setPendingActivationOptions] = useState(null); + const [pendingActivationOptions, setPendingActivationOptions] = useState(null); const rememberLoginRef = useRef(null); const knownHosts = useKnownHosts(); @@ -128,7 +129,7 @@ const Login = () => { rememberLoginRef.current = loginForm; const { userName, password, selectedHost, remember } = loginForm; - const options: Omit = { + const options: Omit = { ...getHostPort(selectedHost), userName, password, diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index d2f8455bc..2da6b2871 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -17,7 +17,6 @@ import SayMessage from './SayMessage'; import './Room.css'; -// @TODO (3) const Room = () => { const joined = useAppSelector(state => RoomsSelectors.getJoinedRooms(state)); const rooms = useAppSelector(state => RoomsSelectors.getRooms(state)); diff --git a/webclient/src/forms/LoginForm/LoginForm.spec.tsx b/webclient/src/forms/LoginForm/LoginForm.spec.tsx index ed6178eec..0dcc8f071 100644 --- a/webclient/src/forms/LoginForm/LoginForm.spec.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.spec.tsx @@ -58,9 +58,6 @@ describe('LoginForm — regression: settings.autoConnect is not clobbered by hos { preloadedState: disconnectedState } ); - // After mount + all host-sync effects settle, the form has updated its - // local fields to reflect the selected host. What MUST NOT happen is a - // write to the persisted autoConnect setting. expect(update).not.toHaveBeenCalled(); }); diff --git a/webclient/src/forms/LoginForm/LoginForm.tsx b/webclient/src/forms/LoginForm/LoginForm.tsx index 8323989b9..b09470f55 100644 --- a/webclient/src/forms/LoginForm/LoginForm.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.tsx @@ -47,9 +47,7 @@ const LoginFormBody = ({ const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on); - // Host-sync: when the selected host changes, mirror its username + stored- - // password hint into the form. Deliberately does NOT touch autoConnect — the - // persisted setting is decoupled from which host is currently picked. + // @critical Host-sync must not touch autoConnect — app-level setting, not per-host. useEffect(() => { if (!selectedHost) { return; @@ -65,8 +63,6 @@ const LoginFormBody = ({ ); }, [selectedHost, form]); - // Mirror the persisted autoConnect setting into the form checkbox so the - // field reflects truth as soon as settings load. useEffect(() => { if (settings.status !== LoadingState.READY) { return; @@ -82,11 +78,7 @@ const LoginFormBody = ({ }; const onRememberChange = (checked: boolean) => { - // When the user unchecks "remember password", the auto-connect checkbox - // can't meaningfully stay on (there are no saved credentials to use), so - // reflect that in the form UI. Note: this writes only to the form field, - // NOT to the persisted setting — toggling host-level remember is not a - // user intent to change the app-level auto-connect preference. + // @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit. if (!checked && values.autoConnect) { form.change('autoConnect', false); } @@ -94,11 +86,8 @@ const LoginFormBody = ({ togglePasswordLabel(canUseStoredPassword(checked, values.password)); }; - // User-initiated toggle of the auto-connect checkbox. This is the ONLY path - // that writes to the persisted setting — wired directly to the Checkbox's - // native onChange (see JSX below), not to a listener, because - // OnChange fires on programmatic form.change calls too (host-sync effects - // etc.) and would leak those into Dexie. + // @critical Only persist-path for autoConnect; wired to native onChange, not , + // to avoid leaking form.change() writes into Dexie. const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => { fieldOnChange(checked); diff --git a/webclient/src/hooks/useAutoLogin.spec.tsx b/webclient/src/hooks/useAutoLogin.spec.tsx index 064f683d3..b825bb370 100644 --- a/webclient/src/hooks/useAutoLogin.spec.tsx +++ b/webclient/src/hooks/useAutoLogin.spec.tsx @@ -12,7 +12,7 @@ let makeSettings: (o?: AnyRecord) => AnyRecord; let makeHost: (o?: AnyRecord) => AnyRecord; beforeEach(async () => { - // Fresh module graph per test so the module-level hasFiredThisSession flag resets. + // Fresh module graph per test so autoLoginGate.hasChecked resets. vi.resetModules(); useAutoLoginModule = await import('./useAutoLogin'); const settingsMockModule = await import('./__mocks__/useSettings'); @@ -119,21 +119,14 @@ describe('useAutoLogin', () => { }); test('manual login then logout does NOT auto-connect on return to /login', async () => { - // Regression: the flag tracks whether the startup check RAN, not whether - // it FIRED. Without that distinction, a first-session manual login (where - // the hook saw conditions unmet) would leave the flag unset, and the - // next mount (after logout) would find conditions met and auto-connect. const onLogin = vi.fn(); - // First mount: autoConnect=false, so the check runs but doesn't fire. configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); await Promise.resolve(); await Promise.resolve(); expect(onLogin).not.toHaveBeenCalled(); - // User logs in manually and later hits logout; Login re-mounts with - // autoConnect now flipped on (they ticked the box during the session). unmount(); configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false)); @@ -144,10 +137,6 @@ describe('useAutoLogin', () => { }); test('ticking the auto-connect checkbox after mount does NOT trigger a login', async () => { - // This is the specific regression: editing the persisted preference is a - // settings write, not a "log in now" signal. Because useAutoLogin reads - // via whenReady (one-shot) instead of subscribing, a subsequent settings - // change cannot re-run the orchestrator. const onLogin = vi.fn(); configure({ autoConnect: false, remember: true, hashedPassword: 'hp' }); @@ -156,9 +145,6 @@ describe('useAutoLogin', () => { await Promise.resolve(); expect(onLogin).not.toHaveBeenCalled(); - // Swap to a "settings.autoConnect=true" world and rerender. Since - // getSettings is a one-shot that already resolved with the old value, - // changing its mockResolvedValue doesn't retroactively matter. configure({ autoConnect: true, remember: true, hashedPassword: 'hp' }); rerender(); diff --git a/webclient/src/hooks/useAutoLogin.ts b/webclient/src/hooks/useAutoLogin.ts index bf4944dca..4e8cf0770 100644 --- a/webclient/src/hooks/useAutoLogin.ts +++ b/webclient/src/hooks/useAutoLogin.ts @@ -13,32 +13,14 @@ export interface LoginFormValues { autoConnect?: boolean; } -// Auto-login is a *startup* concern — the persisted preference is consulted -// once per JS session, after both stores have loaded. A logout within the -// same session is an explicit user action; returning to /login should not -// re-auto-connect (matches Cockatrice desktop behaviour). The flag is -// module-scope so it persists across Login remounts and is naturally reset -// on page refresh, which is the one time we do want another try. -// -// The flag tracks whether the *check* has run, not whether it *fired* — a -// manual first login followed by a logout must not re-trigger auto-login -// either, so the outcome of the check is irrelevant; only that it happened. -// -// Exported as a mutable object (rather than a bare `let`) so integration -// tests can reset `startupCheckRan = false` between scenarios without -// resorting to `vi.resetModules`, which is prohibitively slow in the full -// suite. Production code only writes the flag from inside the effect. -export const autoLoginSession = { startupCheckRan: false }; +export const autoLoginGate = { hasChecked: false }; -// Deliberately does NOT subscribe to the settings / known-hosts stores — -// user actions that change those stores (ticking the auto-connect checkbox, -// picking a different host) are preference edits, not "log in now" signals. export function useAutoLogin( onLogin: (values: LoginFormValues) => void, connectionAttemptMade: boolean, ): void { useEffect(() => { - if (autoLoginSession.startupCheckRan) { + if (autoLoginGate.hasChecked) { return; } if (connectionAttemptMade) { @@ -48,10 +30,10 @@ export function useAutoLogin( let cancelled = false; Promise.all([getSettings(), getKnownHosts()]).then(([settings, hosts]) => { - if (cancelled || autoLoginSession.startupCheckRan) { + if (cancelled || autoLoginGate.hasChecked) { return; } - autoLoginSession.startupCheckRan = true; + autoLoginGate.hasChecked = true; if (!settings.autoConnect) { return; diff --git a/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx b/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx index 501bb9403..da80c876e 100644 --- a/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx +++ b/webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx @@ -9,7 +9,6 @@ import { useFireOnce } from './useFireOnce'; describe('useFireOnce hook', () => { test('it only fires once when button is clicked twice', async () => { - // Mock a promise with a delay const onClickWithPromise = vi.fn((e) => { e.preventDefault() return new Promise((resolve) => { @@ -25,25 +24,20 @@ describe('useFireOnce hook', () => { return } - // render the button const { getByRole } = render( ); - //Grab the button from the DOM and confirm it initialized in an enabled state const button = getByRole('button', { name: 'Click Me!' }); expect(button).toBeEnabled(); - // Simulate two click events in a row fireEvent.click(button); fireEvent.click(button); - // Confirm that it's disabled await waitFor(() => { expect(button).toBeDisabled(); }); - // Confirm it became enabled after the timeout and that the click event was only fired once await waitFor( () => { expect(onClickWithPromise).toHaveBeenCalledTimes(1); @@ -53,7 +47,6 @@ describe('useFireOnce hook', () => { }); test('it only fires once when form is submitted twice', async () => { - // Mock a promise with a delay const onClickWithPromise = vi.fn((e) => { e.preventDefault() return new Promise((resolve) => { @@ -74,25 +67,20 @@ describe('useFireOnce hook', () => { ) } - // render the form const { getByRole } = render(

); - //Grab the button from the DOM and confirm it initialized in an enabled state const button = getByRole('button', { name: 'Click Me!' }); expect(button).toBeEnabled(); - // Simulate two click events in a row fireEvent.click(button); fireEvent.click(button); - // Confirm that it's disabled await waitFor(() => { expect(button).toBeDisabled(); }); - // Confirm it became enabled after the timeout and that the click event was only fired once await waitFor( () => { expect(onClickWithPromise).toHaveBeenCalledTimes(1); diff --git a/webclient/src/hooks/useKnownHosts.ts b/webclient/src/hooks/useKnownHosts.ts index c7e95c89c..d3ebf0c29 100644 --- a/webclient/src/hooks/useKnownHosts.ts +++ b/webclient/src/hooks/useKnownHosts.ts @@ -3,12 +3,6 @@ import { App } from '@app/types'; import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; -// Shared-store scope justification: multiple components on the login screen -// read the same host list and selected host simultaneously (KnownHosts -// dropdown, LoginForm's host-sync effect, useAutoLogin, and the Login -// container's post-login write). Collapsing to useState inside one component -// would duplicate Dexie reads and race on lastSelected updates — exactly the -// bug we set out to fix. export interface KnownHostsValue { hosts: HostDTO[]; selectedHost: HostDTO; @@ -29,15 +23,12 @@ const normalize = async (hosts: HostDTO[]): Promise => { return { hosts, selectedHost: existing }; } - // No row marked lastSelected yet (first-ever load after seeding, or legacy - // data). Pin hosts[0] and persist so subsequent boots are deterministic. const selected = hosts[0]; selected.lastSelected = true; await selected.save(); return { hosts, selectedHost: selected }; }; -// Exported for integration-test reset; see settingsStore for the rationale. export const knownHostsStore = createSharedStore(async () => { const hosts = await loadAll(); return normalize(hosts); @@ -51,8 +42,6 @@ export type KnownHostsHook = Loadable & { remove: (id: number) => Promise; }; -// Guard for mutators. Mutators run outside React render, so we can't gate -// them through the hook's status; peek + throw is the fail-fast alternative. const requireValue = (method: string): KnownHostsValue => { const current = store.peek(); if (!current) { @@ -127,6 +116,4 @@ export function useKnownHosts(): KnownHostsHook { return { ...state, select, add, update, remove }; } -// Non-reactive one-shot accessor, mirroring getSettings. See the comment on -// that export in useSettings.ts for the rationale. export const getKnownHosts = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useSettings.ts b/webclient/src/hooks/useSettings.ts index 9636634cf..e4792b833 100644 --- a/webclient/src/hooks/useSettings.ts +++ b/webclient/src/hooks/useSettings.ts @@ -3,15 +3,6 @@ import { App } from '@app/types'; import { createSharedStore, Loadable, useSharedStore } from './useSharedStore'; -// First-time bootstrap: SettingDTO.get returns undefined when no row exists -// for the app user yet (fresh install, or a user who has never hit the -// settings code path before). We materialize a default DTO and persist it so -// subsequent loads always see a non-null row. -// -// Exported as `settingsStore` so integration tests can call -// `settingsStore.reset()` between scenarios — the module cache would -// otherwise serve stale data across per-test Dexie resets. Production code -// goes through `useSettings()` / `getSettings()` and doesn't touch this. export const settingsStore = createSharedStore(async () => { let loaded = await SettingDTO.get(App.APP_USER); if (!loaded) { @@ -30,9 +21,6 @@ export function useSettings(): SettingsHook { const state = useSharedStore(store); const update = async (patch: Partial) => { - // Fail-fast if a caller tries to write before the initial load resolves. - // Shouldn't happen in normal flow (the checkbox is gated on the hook's - // ready status), so surface the bug loudly instead of silently no-oping. const current = store.peek(); if (!current) { throw new Error('useSettings.update called before settings loaded'); @@ -45,8 +33,4 @@ export function useSettings(): SettingsHook { return { ...state, update }; } -// Non-reactive one-shot accessor. Use this from code that wants the loaded -// value exactly once and does NOT want to re-run when the user subsequently -// edits their settings — e.g. the auto-login orchestrator, which consults -// the persisted preference at startup only. export const getSettings = (): Promise => store.whenReady(); diff --git a/webclient/src/hooks/useSharedStore.ts b/webclient/src/hooks/useSharedStore.ts index 66505a168..668545f79 100644 --- a/webclient/src/hooks/useSharedStore.ts +++ b/webclient/src/hooks/useSharedStore.ts @@ -12,27 +12,14 @@ export interface Loadable { error?: Error; } +// @critical Two surfaces: subscribe (reactive) vs whenReady (one-shot). +// See .github/instructions/webclient.instructions.md#shared-store-pattern. export interface SharedStore { - // Reactive surface: subscribe + snapshot back useSyncExternalStore so - // consuming components re-render on every store update. subscribe: (cb: () => void) => () => void; getSnapshot: () => Loadable; - - // One-shot surface: whenReady resolves with the initial loaded value and - // never fires again. Callers that only need "read once after init" (e.g. - // the auto-login orchestrator) use this to avoid subscribing to updates - // they don't care about — which would otherwise turn a user preference - // toggle into a re-evaluation of startup logic. whenReady: () => Promise; - - // Mutator-side helpers, not for consumption inside render. setValue: (value: T) => void; peek: () => T | undefined; - - // Clear cached state and the resolved readyPromise; the next subscribe / - // whenReady call triggers a fresh load. In production nobody calls this; - // integration tests use it to discard per-test Dexie state without - // paying the cost of vi.resetModules across the whole dep graph. reset: () => void; } @@ -41,9 +28,7 @@ export function createSharedStore(load: () => Promise): SharedStore { const subscribers = new Set<() => void>(); let loadStarted = false; - // whenReady is lazy: we only attach a promise once someone asks for one. - // This avoids Node's unhandled-rejection bookkeeping for stores whose - // loader fails but never had a whenReady caller. + // Lazy to avoid unhandled-rejection bookkeeping when no caller awaits it. let readyPromise: Promise | null = null; const notify = () => { diff --git a/webclient/src/hooks/useWebClient.tsx b/webclient/src/hooks/useWebClient.tsx index 37570345c..3d9c02697 100644 --- a/webclient/src/hooks/useWebClient.tsx +++ b/webclient/src/hooks/useWebClient.tsx @@ -2,9 +2,6 @@ import { createContext, useContext, useState, ReactNode } from 'react'; import { WebClient } from '@app/websocket'; import { createWebClientRequest, createWebClientResponse } from '@app/api'; -// Exported so integration tests can inject the WebClient singleton built -// by their shared setup without going through the production provider -// (which would attempt to `new WebClient(...)` a second time and throw). export const WebClientContext = createContext(null); export function WebClientProvider({ children }: { children: ReactNode }) { diff --git a/webclient/src/index.tsx b/webclient/src/index.tsx index be51341a4..b0137f943 100644 --- a/webclient/src/index.tsx +++ b/webclient/src/index.tsx @@ -1,5 +1,4 @@ -// MUST be first: installs BigInt.prototype.toJSON before any module that -// creates the Redux store or connects to Redux DevTools. +// @critical Must be the first import. See .github/instructions/webclient.instructions.md#initialization-order. import './polyfills'; import { StrictMode } from 'react'; diff --git a/webclient/src/material-theme.ts b/webclient/src/material-theme.ts index beee1146e..8f646c85a 100644 --- a/webclient/src/material-theme.ts +++ b/webclient/src/material-theme.ts @@ -32,35 +32,9 @@ const palette = { A400: '#303030', A700: '#616161', }, - // secondary: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, - // error: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, - // warning: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, - // info: { - // main: '', - // light: '', - // dark: '', - // contrastText: '', - // }, success: { main: '#6CDF39', light: '#6CDF39', - // dark: '', - // contrastText: '', }, }; @@ -201,10 +175,6 @@ export const materialTheme = createTheme({ fontSize: 24, fontWeight: 'bold', }, - // h3: {}, - // h4: {}, - // h5: {}, - // h6: {}, subtitle1: { fontSize: 14, fontWeight: 'bold', @@ -219,10 +189,6 @@ export const materialTheme = createTheme({ fontSize: '.75rem', lineHeight: 1.4, }, - // body2: {}, - // button: {}, - // caption: {}, - // overline: {}, }, spacing: 8, diff --git a/webclient/src/polyfills.ts b/webclient/src/polyfills.ts index d18a0c94c..e38af7101 100644 --- a/webclient/src/polyfills.ts +++ b/webclient/src/polyfills.ts @@ -1,17 +1,5 @@ -// Runtime polyfills that must execute before any other application module. -// Import this file first from `src/index.tsx`. - -// ── BigInt.prototype.toJSON ─────────────────────────────────────────────────── -// Protobuf-ES maps proto `int64`/`uint64` fields to native `BigInt`. Those -// land in Redux state (e.g. `ServerInfo_User.accountageSecs`, -// `Response_Register.deniedEndTime`, the outbound `cmdId`), and any consumer -// that JSON-stringifies state — notably the Redux DevTools browser -// extension, but also logging and error-boundary dumps — throws with -// "Do not know how to serialize a BigInt" because `BigInt.prototype` has no -// `toJSON`. Installing one globally makes `JSON.stringify` coerce -// `BigInt → string` instead of throwing. Coercion is lossy but only affects -// serialized representations; the in-memory Redux state still holds real -// `BigInt`s and every consumer reads them via the generated proto accessors. +// @critical Must be imported before any module that can JSON-stringify Redux state +// (BigInt proto fields throw without toJSON). See .github/instructions/webclient.instructions.md#initialization-order. (BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function bigIntToJSON() { return this.toString(); }; diff --git a/webclient/src/services/CardImporterService.ts b/webclient/src/services/CardImporterService.ts index 984de9005..20d155214 100644 --- a/webclient/src/services/CardImporterService.ts +++ b/webclient/src/services/CardImporterService.ts @@ -96,7 +96,7 @@ class CardImporterService { }; } - // @TODO: clean this up and normalize what i'm returning + // @TODO clean this up and normalize what i'm returning if (attributes[child.tagName]) { if (Array.isArray(attributes[child.tagName])) { attributes[child.tagName].push(parsedAttributes) diff --git a/webclient/src/setupTests.ts b/webclient/src/setupTests.ts index 219544389..645094434 100644 --- a/webclient/src/setupTests.ts +++ b/webclient/src/setupTests.ts @@ -1,8 +1,6 @@ -// Install runtime polyfills (BigInt.prototype.toJSON) before any module -// under test loads — matches the production boot order in src/index.tsx. +// @critical Must match the production boot order in src/index.tsx. See .github/instructions/webclient.instructions.md#initialization-order. import './polyfills'; -// Ensure jest-dom matchers are available in every test file. import '@testing-library/jest-dom/vitest'; // jsdom doesn't provide ResizeObserver; react-window needs it. @@ -14,9 +12,7 @@ if (typeof globalThis.ResizeObserver === 'undefined') { } as any; } -// Mock Dexie globally to prevent IndexedDB initialization in jsdom. -// Dexie eagerly opens IndexedDB on import, and jsdom's fake-indexeddb -// is memory-intensive. No UI test needs a real database. +// Dexie eagerly opens IndexedDB on import; jsdom's fake-indexeddb is memory-intensive. vi.mock('dexie', () => { const fakeTable = { mapToClass: () => {}, @@ -42,58 +38,11 @@ vi.mock('dexie', () => { return { default: FakeDexie, __esModule: true }; }); -// ── Global mock hygiene under `isolate: false` ──────────────────────────────── -// -// Vitest is configured with `test.isolate: false` for speed — every spec file -// in a worker shares the same module graph and the same `vi.mock` factories. -// Without aggressive per-test cleanup, state leaks trivially between tests: -// -// - A test accumulates `.mock.calls` on a shared `vi.fn()`. Later tests -// either see stale history or accidentally match on prior invocations. -// - A test installs `vi.spyOn` on a real method. Without restore, the spy -// persists into every following test and file. -// - A test swaps to fake timers. Real-time code in later tests hangs. -// -// `vi.clearAllMocks()` clears `.mock.calls` on every tracked mock without -// touching implementations — safe for module factories that produce `vi.fn()` -// instances at the top of a spec file and rely on those instances sticking -// around. `vi.restoreAllMocks()` restores original implementations on -// `vi.spyOn` targets. `vi.useRealTimers()` drops any fake-timer installation. -// -// NOTE: we intentionally do NOT call `vi.resetAllMocks()` — it resets the -// implementations of `vi.fn()` instances created inside `vi.mock(...)` -// factories, which breaks any spec that expects those mocks to persist -// across tests in the same file (e.g. `store.dispatch` mocked once at file -// load). -// -// If a specific test needs to install its own `mockReturnValue` / -// `mockImplementation`, it should set it in that test's body and rely on -// the next test overwriting or the global `clearAllMocks` clearing calls — -// it should NOT assume the mock is reset to its factory default automatically. -// -// Global snapshot/restore guards for non-`vi.spyOn` globals that tests mutate -// directly. `vi.restoreAllMocks()` only restores `vi.spyOn` targets, so bare -// `Object.defineProperty` writes on `window.location` and `globalThis.WebSocket` -// reassignments leak between tests unless we explicitly capture and restore them. -let _locationDescriptor: PropertyDescriptor | undefined; -let _originalWebSocket: typeof globalThis.WebSocket | undefined; - -beforeEach(() => { - _locationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); - _originalWebSocket = globalThis.WebSocket; -}); - +// Tests within a file share the module graph (vite.config.ts sets isolate: true +// between files, not within them). Never add vi.resetAllMocks() — it resets +// vi.fn() instances created inside vi.mock(...) factories at file load. afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); vi.useRealTimers(); - - const currentLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); - if (currentLocationDescriptor !== _locationDescriptor && _locationDescriptor) { - Object.defineProperty(window, 'location', _locationDescriptor); - } - - if (globalThis.WebSocket !== _originalWebSocket) { - globalThis.WebSocket = _originalWebSocket as typeof globalThis.WebSocket; - } }); diff --git a/webclient/src/store/common/SortUtil.spec.ts b/webclient/src/store/common/SortUtil.spec.ts index 5a79b0e29..78f9c6aef 100644 --- a/webclient/src/store/common/SortUtil.spec.ts +++ b/webclient/src/store/common/SortUtil.spec.ts @@ -2,7 +2,6 @@ import { create } from '@bufbuild/protobuf'; import { App, Data } from '@app/types'; import SortUtil from './SortUtil'; -// ── sortByField ─────────────────────────────────────────────────────────────── describe('sortByField', () => { it('sorts string field ASC alphabetically', () => { @@ -55,7 +54,6 @@ describe('sortByField', () => { }); }); -// ── sortByFields ────────────────────────────────────────────────────────────── describe('sortByFields', () => { it('sorts by the first key when all items have distinct first-key values', () => { @@ -114,7 +112,6 @@ describe('sortByFields', () => { }); }); -// ── sortUsersByField ────────────────────────────────────────────────────────── describe('sortUsersByField', () => { it('sorts by userLevel DESC first, then name ASC', () => { @@ -147,7 +144,6 @@ describe('sortUsersByField', () => { }); }); -// ── toggleSortBy ────────────────────────────────────────────────────────────── describe('toggleSortBy', () => { it('same field + ASC → returns DESC', () => { @@ -166,7 +162,6 @@ describe('toggleSortBy', () => { }); }); -// ── resolveFieldChain with numeric index ───────────────────────────────────── describe('resolveFieldChain via sortByField (numeric index)', () => { it('resolves numeric index in dot-notation chain', () => { diff --git a/webclient/src/store/common/normalizers.ts b/webclient/src/store/common/normalizers.ts index 70ec4881d..3648cc8fb 100644 --- a/webclient/src/store/common/normalizers.ts +++ b/webclient/src/store/common/normalizers.ts @@ -1,6 +1,5 @@ import { Data, Enriched } from '@app/types'; -/** Flatten a gametype list into a lookup map of { gameTypeId → description }. */ export function normalizeGametypeMap(gametypeList: Data.ServerInfo_GameType[]): Enriched.GametypeMap { return gametypeList.reduce((map, type) => { map[type.gameTypeId] = type.description; @@ -8,13 +7,6 @@ export function normalizeGametypeMap(gametypeList: Data.ServerInfo_GameType[]): }, {}); } -/** - * Build an Enriched.Room (composition shape) from a raw proto. The proto is - * stored verbatim on `info` and the repeated collections are normalized into - * keyed maps alongside it. `info.gameList`, `info.userList`, `info.gametypeList` - * are left as the wire snapshot — callers should always read the normalized - * fields, never those. - */ export function normalizeRoomInfo(roomInfo: Data.ServerInfo_Room): Enriched.Room { const gametypeMap = normalizeGametypeMap(roomInfo.gametypeList); @@ -38,7 +30,6 @@ export function normalizeRoomInfo(roomInfo: Data.ServerInfo_Room): Enriched.Room }; } -/** Wrap a raw ServerInfo_Game in the composition shape with cached gameType. */ export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enriched.GametypeMap): Enriched.Game { const { gameTypes } = game; const hasType = gameTypes && gameTypes.length; @@ -48,7 +39,6 @@ export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enr }; } -/** Group a flat LogItem[] into { room, game, chat } buckets for the server store. */ export function normalizeLogs(logs: Data.ServerInfo_ChatMessage[]): Enriched.LogGroups { return logs.reduce((obj, log) => { const type = log.targetType as keyof Enriched.LogGroups; @@ -59,12 +49,8 @@ export function normalizeLogs(logs: Data.ServerInfo_ChatMessage[]): Enriched.Log }, { room: [], game: [], chat: [] }); } -/** - * Prepend "name: " to the message text when a sender name is present. - * Messages from the current user are sent without a name by the server, - * so this is a no-op for those. - * Returns a new Message — does not mutate the original. - */ +// @critical Server omits `name` on messages from the current user; preserves that as a no-op. +// See .github/instructions/webclient.instructions.md#protocol-quirks. export function normalizeUserMessage(message: Enriched.Message): Enriched.Message { if (!message.name) { return message; @@ -72,12 +58,7 @@ export function normalizeUserMessage(message: Enriched.Message): Enriched.Messag return { ...message, message: `${message.name}: ${message.message}` }; } -/** - * Build the user-facing ban error string from raw server data. - * The server sends a reason string and an endTime epoch ms (0 = permanent). - * Messages from the current user do not carry the username — this quirk is - * handled at the dispatch layer so the redux store always stores a clean string. - */ +// endTime is epoch ms; 0 means permanent. export function normalizeBannedUserError(reason: string, endTime: number): string { let error: string; diff --git a/webclient/src/store/game/game.reducer.spec.ts b/webclient/src/store/game/game.reducer.spec.ts index 4eadf7238..6a467f145 100644 --- a/webclient/src/store/game/game.reducer.spec.ts +++ b/webclient/src/store/game/game.reducer.spec.ts @@ -20,7 +20,6 @@ function cardsIn(state: GamesState, gameId: number, playerId: number, zoneName: return zone ? zone.order.map(id => zone.byId[id]) : []; } -// ── 2A: Initialisation & lifecycle ─────────────────────────────────────────── describe('2A: Initialisation & lifecycle', () => { it('returns initialState ({ games: {} }) when called with undefined state', () => { @@ -77,7 +76,6 @@ describe('2A: Initialisation & lifecycle', () => { }); }); -// ── 2B: Game state & player management ─────────────────────────────────────── describe('2B: Game state & player management', () => { it('GAME_STATE_CHANGED with playerList → replaces players via normalizePlayers', () => { @@ -165,7 +163,6 @@ describe('2B: Game state & player management', () => { }); }); -// ── 2C: CARD_MOVED ──────────────────────────────────────────────────────────── describe('2C: CARD_MOVED', () => { function stateWithCard(cardOverrides: Parameters[0] = {}) { @@ -482,7 +479,6 @@ describe('2C: CARD_MOVED', () => { }); }); -// ── 2D: Card mutations ──────────────────────────────────────────────────────── describe('2D: Card mutations', () => { function stateWithCardInZone(zoneName: string) { @@ -587,7 +583,6 @@ describe('2D: Card mutations', () => { }); }); -// ── 2E: CARD_ATTR_CHANGED ───────────────────────────────────────────────────── describe('2E: CARD_ATTR_CHANGED', () => { function stateWithCard() { @@ -660,7 +655,6 @@ describe('2E: CARD_ATTR_CHANGED', () => { }); }); -// ── 2F: CARD_COUNTER_CHANGED ───────────────────────────────────────────────── describe('2F: CARD_COUNTER_CHANGED', () => { function stateWithCard(existingCounters: any[] = []) { @@ -711,7 +705,6 @@ describe('2F: CARD_COUNTER_CHANGED', () => { }); }); -// ── 2G: Arrows ──────────────────────────────────────────────────────────────── describe('2G: Arrows', () => { it('ARROW_CREATED → inserts arrowInfo into player.arrows keyed by id', () => { @@ -745,7 +738,6 @@ describe('2G: Arrows', () => { }); }); -// ── 2H: Player counters ─────────────────────────────────────────────────────── describe('2H: Player counters', () => { it('COUNTER_CREATED → inserts counterInfo into player.counters keyed by id', () => { @@ -809,7 +801,6 @@ describe('2H: Player counters', () => { }); }); -// ── 2I: Zone operations ─────────────────────────────────────────────────────── describe('2I: Zone operations', () => { it('CARDS_DRAWN → decrements deck.cardCount, appends cards to hand, increments hand.cardCount', () => { @@ -963,7 +954,6 @@ describe('2I: Zone operations', () => { }); }); -// ── 2J: Turn / phase / chat ─────────────────────────────────────────────────── describe('2J: Turn, phase, and chat', () => { it('ACTIVE_PLAYER_SET → sets game.activePlayerId', () => { @@ -998,7 +988,6 @@ describe('2J: Turn, phase, and chat', () => { }); }); -// ── 2K: No-op / passthrough actions ────────────────────────────────────────── describe('2K: No-op / passthrough actions', () => { it('ZONE_SHUFFLED → returns state unchanged (identity)', () => { @@ -1026,7 +1015,6 @@ describe('2K: No-op / passthrough actions', () => { }); }); -// ── 2L: Null-guard / missing entity early-returns ───────────────────────────── // Each test dispatches an action with a non-existent gameId (999) or playerId/zone // to exercise the `if (!game) return state` / `if (!player) return state` guards. diff --git a/webclient/src/store/game/game.reducer.ts b/webclient/src/store/game/game.reducer.ts index ec3c1f658..62e06da21 100644 --- a/webclient/src/store/game/game.reducer.ts +++ b/webclient/src/store/game/game.reducer.ts @@ -177,7 +177,6 @@ export const gamesSlice = createSlice({ } }, - // ── Card manipulation ──────────────────────────────────────────────────── cardMoved: ( state, @@ -343,7 +342,6 @@ export const gamesSlice = createSlice({ } }, - // ── Arrows ─────────────────────────────────────────────────────────────── arrowCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateArrow }>) => { const { gameId, playerId, data } = action.payload; @@ -361,7 +359,6 @@ export const gamesSlice = createSlice({ } }, - // ── Player counters ─────────────────────────────────────────────────────── counterCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateCounter }>) => { const { gameId, playerId, data } = action.payload; @@ -387,7 +384,6 @@ export const gamesSlice = createSlice({ } }, - // ── Zone operations ─────────────────────────────────────────────────────── cardsDrawn: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DrawCards }>) => { const { gameId, playerId, data } = action.payload; @@ -444,7 +440,6 @@ export const gamesSlice = createSlice({ } }, - // ── Turn / phase ────────────────────────────────────────────────────────── activePlayerSet: (state, action: PayloadAction<{ gameId: number; activePlayerId: number }>) => { const game = state.games[action.payload.gameId]; @@ -467,7 +462,6 @@ export const gamesSlice = createSlice({ } }, - // ── Chat ────────────────────────────────────────────────────────────────── gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string; timeReceived: number }>) => { const { gameId, playerId, message, timeReceived } = action.payload; @@ -481,7 +475,6 @@ export const gamesSlice = createSlice({ game.messages.push({ playerId, message, timeReceived }); }, - // ── Log-only events ───────────────────────────────────────────────────── zoneShuffled: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_Shuffle }>) => {}, zoneDumped: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DumpZone }>) => {}, dieRolled: (_state, _action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_RollDie }>) => {}, diff --git a/webclient/src/store/rooms/rooms.dispatch.spec.ts b/webclient/src/store/rooms/rooms.dispatch.spec.ts index bcbfa1977..83affda0a 100644 --- a/webclient/src/store/rooms/rooms.dispatch.spec.ts +++ b/webclient/src/store/rooms/rooms.dispatch.spec.ts @@ -1,10 +1,5 @@ -// Use `vi.hoisted` so the mocked `store.dispatch` reference stays stable across -// re-runs of the factory under `isolate: false`. Other dispatch specs mock the -// same `..` path with their own factories; under the shared module graph, the -// cache entry for `..` can flip between competing `vi.fn()` instances. Asserting -// against the hoisted `mockDispatch` directly (rather than reaching through -// `store.dispatch`) decouples the assertions from whatever the module cache -// currently resolves `store` to. +// Hoisted so the mockDispatch reference is available inside the vi.mock factory +// below and can be asserted against directly from each test. const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); vi.mock('..', () => ({ store: { dispatch: mockDispatch } })); diff --git a/webclient/src/store/rooms/rooms.reducer.spec.ts b/webclient/src/store/rooms/rooms.reducer.spec.ts index ce5b687dd..6e7f5fbfc 100644 --- a/webclient/src/store/rooms/rooms.reducer.spec.ts +++ b/webclient/src/store/rooms/rooms.reducer.spec.ts @@ -4,7 +4,6 @@ import { Actions } from './rooms.actions'; import { MAX_ROOM_MESSAGES } from './rooms.types'; import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures'; -// ── Initialisation ─────────────────────────────────────────────────────────── describe('Initialisation', () => { it('returns initialState when called with undefined state', () => { @@ -27,7 +26,6 @@ describe('Initialisation', () => { }); }); -// ── UPDATE_ROOMS ────────────────────────────────────────────────────────────── describe('UPDATE_ROOMS', () => { it('creates RoomEntry with empty normalized games/users for new room', () => { @@ -77,7 +75,6 @@ describe('UPDATE_ROOMS', () => { }); }); -// ── JOIN_ROOM ────────────────────────────────────────────────────────────────── describe('JOIN_ROOM', () => { it('normalizes raw room into keyed games/users maps and marks joined', () => { @@ -97,7 +94,6 @@ describe('JOIN_ROOM', () => { }); }); -// ── LEAVE_ROOM ──────────────────────────────────────────────────────────────── describe('LEAVE_ROOM', () => { it('removes joinedRoomIds entry and messages for roomId', () => { @@ -111,7 +107,6 @@ describe('LEAVE_ROOM', () => { }); }); -// ── ADD_MESSAGE ─────────────────────────────────────────────────────────────── describe('ADD_MESSAGE', () => { it('appends message preserving the timeReceived from the event handler', () => { @@ -157,7 +152,6 @@ describe('ADD_MESSAGE', () => { }); }); -// ── UPDATE_GAMES ────────────────────────────────────────────────────────────── describe('UPDATE_GAMES', () => { it('removes closed games from the keyed games map', () => { @@ -211,7 +205,6 @@ describe('UPDATE_GAMES', () => { }); }); -// ── USER_JOINED / USER_LEFT ─────────────────────────────────────────────────── describe('USER_JOINED', () => { it('inserts user into the keyed users map', () => { @@ -237,7 +230,6 @@ describe('USER_LEFT', () => { }); }); -// ── SORT_GAMES ──────────────────────────────────────────────────────────────── describe('SORT_GAMES', () => { it('updates sortGamesBy on state (sorting itself is now derived in selectors)', () => { @@ -251,7 +243,6 @@ describe('SORT_GAMES', () => { }); }); -// ── REMOVE_MESSAGES ─────────────────────────────────────────────────────────── describe('REMOVE_MESSAGES', () => { it('removes messages starting with "name:" up to amount, in reverse scan order', () => { @@ -294,7 +285,6 @@ describe('REMOVE_MESSAGES', () => { }); }); -// ── GAME_CREATED ────────────────────────────────────────────────────────────── describe('GAME_CREATED', () => { it('returns state unchanged', () => { @@ -304,7 +294,6 @@ describe('GAME_CREATED', () => { }); }); -// ── JOINED_GAME ─────────────────────────────────────────────────────────────── describe('JOINED_GAME', () => { it('sets joinedGameIds[roomId][gameId] = true', () => { diff --git a/webclient/src/store/rooms/rooms.reducer.tsx b/webclient/src/store/rooms/rooms.reducer.tsx index e6157952e..0482b4e3a 100644 --- a/webclient/src/store/rooms/rooms.reducer.tsx +++ b/webclient/src/store/rooms/rooms.reducer.tsx @@ -31,9 +31,8 @@ export const roomsSlice = createSlice({ updateRooms: (state, action: PayloadAction<{ rooms: Data.ServerInfo_Room[] }>) => { const { rooms } = action.payload; - // UPDATE_ROOMS carries metadata only. For existing rooms, replace - // `info`, `gametypeMap` and `order`; preserve the normalized `games` - // and `users` maps (those are maintained by their own events). + // @critical Partial merge — preserve normalized games/users maps. + // See .github/instructions/webclient.instructions.md#reducer-merge-rules. rooms.forEach((rawRoom, order) => { const { roomId } = rawRoom; const existing = state.rooms[roomId]; @@ -96,8 +95,6 @@ export const roomsSlice = createSlice({ const { roomId, games } = action.payload; const room = state.rooms[roomId]; - // An empty games array means no game updates — skip to avoid - // accidentally wiping the existing normalized games map. if (!room || !games?.length) { return; } @@ -111,7 +108,6 @@ export const roomsSlice = createSlice({ } const existing = room.games[rawGame.gameId]; if (existing) { - // Merge the incoming proto into the existing snapshot. const merged: Data.ServerInfo_Game = { ...existing.info, ...rawGame }; room.games[rawGame.gameId] = { info: merged, @@ -146,8 +142,6 @@ export const roomsSlice = createSlice({ }, sortGames: (state, action: PayloadAction<{ roomId: number; field: App.GameSortField; order: App.SortDirection }>) => { - // Sort is derived in selectors; the reducer stores the sort config. - // roomId is passed through for future per-room sorting support. const { field, order } = action.payload; state.sortGamesBy = { field, order }; }, @@ -160,8 +154,6 @@ export const roomsSlice = createSlice({ return; } - // Drop the `amount` most-recent messages whose text starts with `${name}:`. - // Walk newest → oldest so we remove the N latest matches. const prefix = `${name}:`; const keep = new Array(roomMessages.length).fill(true); let remaining = amount; @@ -184,7 +176,7 @@ export const roomsSlice = createSlice({ state.joinedGameIds[roomId][gameId] = true; }, - // Signal-only — no state mutation needed; explicit for discriminated-union exhaustiveness + // Signal-only; kept for discriminated-union exhaustiveness. gameCreated: (_state, _action: PayloadAction<{ roomId: number }>) => {}, }, }); diff --git a/webclient/src/store/rooms/rooms.selectors.spec.ts b/webclient/src/store/rooms/rooms.selectors.spec.ts index adbbcfe04..4308aba86 100644 --- a/webclient/src/store/rooms/rooms.selectors.spec.ts +++ b/webclient/src/store/rooms/rooms.selectors.spec.ts @@ -146,7 +146,6 @@ describe('Selectors', () => { expect(Selectors.getSortedRoomUsers(rootState(state), 999)).toHaveLength(0); }); - // ── createSelector reference stability ────────────────────────────── it('getSortedRoomGames → returns same array reference for identical state', () => { const game = makeGame({ gameId: 1 }); diff --git a/webclient/src/store/server/__mocks__/server-fixtures.ts b/webclient/src/store/server/__mocks__/server-fixtures.ts index fd805dbbb..9bb7c8cf4 100644 --- a/webclient/src/store/server/__mocks__/server-fixtures.ts +++ b/webclient/src/store/server/__mocks__/server-fixtures.ts @@ -1,4 +1,5 @@ import { App, Data, Enriched } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import type { MessageInitShape } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf'; @@ -117,8 +118,8 @@ export function makeGame(overrides: MakeGameOverrides = {}): Enriched.Game { } export function makeLoginSuccessContext( - overrides: Partial = {} -): Enriched.LoginSuccessContext { + overrides: Partial = {} +): WebsocketTypes.LoginSuccessContext { return { hashedPassword: 'hash', ...overrides, @@ -126,8 +127,8 @@ export function makeLoginSuccessContext( } export function makePendingActivationContext( - overrides: Partial = {} -): Enriched.PendingActivationContext { + overrides: Partial = {} +): WebsocketTypes.PendingActivationContext { return { host: 'localhost', port: '4747', @@ -143,7 +144,7 @@ export function makeServerState(overrides: Partial = {}): ServerSta ignoreList: {}, status: { connectionAttemptMade: false, - state: Enriched.StatusEnum.DISCONNECTED, + state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null, }, info: { diff --git a/webclient/src/store/server/server.actions.spec.ts b/webclient/src/store/server/server.actions.spec.ts index 5bc31dbd7..f741086c5 100644 --- a/webclient/src/store/server/server.actions.spec.ts +++ b/webclient/src/store/server/server.actions.spec.ts @@ -1,5 +1,6 @@ import { Actions } from './server.actions'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { Types } from './server.types'; import { create } from '@bufbuild/protobuf'; import { @@ -88,7 +89,7 @@ describe('Actions', () => { }); it('updateStatus', () => { - const status = { state: Enriched.StatusEnum.CONNECTED, description: 'connected' }; + const status = { state: WebsocketTypes.StatusEnum.CONNECTED, description: 'connected' }; expect(Actions.updateStatus({ status })).toEqual({ type: Types.UPDATE_STATUS, payload: { status } }); }); diff --git a/webclient/src/store/server/server.dispatch.spec.ts b/webclient/src/store/server/server.dispatch.spec.ts index bd7804850..bab47faa7 100644 --- a/webclient/src/store/server/server.dispatch.spec.ts +++ b/webclient/src/store/server/server.dispatch.spec.ts @@ -1,12 +1,11 @@ -// Use `vi.hoisted` so the mocked `store.dispatch` reference stays stable across -// re-runs of the factory under `isolate: false`. See rooms.dispatch.spec.ts for -// the same pattern and rationale. +// @critical See rooms.dispatch.spec.ts — same hoisted-mockDispatch pattern. const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); vi.mock('..', () => ({ store: { dispatch: mockDispatch } })); import { Actions } from './server.actions'; import { Dispatch } from './server.dispatch'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { create } from '@bufbuild/protobuf'; import { makeBanHistoryItem, @@ -106,9 +105,9 @@ describe('Dispatch', () => { }); it('updateStatus dispatches Actions.updateStatus({ status: { state, description } })', () => { - Dispatch.updateStatus(Enriched.StatusEnum.CONNECTED, 'ok'); + Dispatch.updateStatus(WebsocketTypes.StatusEnum.CONNECTED, 'ok'); expect(mockDispatch).toHaveBeenCalledWith( - Actions.updateStatus({ status: { state: Enriched.StatusEnum.CONNECTED, description: 'ok' } }) + Actions.updateStatus({ status: { state: WebsocketTypes.StatusEnum.CONNECTED, description: 'ok' } }) ); }); diff --git a/webclient/src/store/server/server.dispatch.ts b/webclient/src/store/server/server.dispatch.ts index fad027a57..823085e0b 100644 --- a/webclient/src/store/server/server.dispatch.ts +++ b/webclient/src/store/server/server.dispatch.ts @@ -1,6 +1,7 @@ import { Actions } from './server.actions'; import { store } from '..'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; export const Dispatch = { initialized: () => { @@ -12,7 +13,7 @@ export const Dispatch = { connectionAttempted: () => { store.dispatch(Actions.connectionAttempted()); }, - loginSuccessful: (options: Enriched.LoginSuccessContext) => { + loginSuccessful: (options: WebsocketTypes.LoginSuccessContext) => { store.dispatch(Actions.loginSuccessful({ options })); }, loginFailed: () => { @@ -48,7 +49,7 @@ export const Dispatch = { updateInfo: (name: string, version: string) => { store.dispatch(Actions.updateInfo({ info: { name, version } })); }, - updateStatus: (state: Enriched.StatusEnum, description: string) => { + updateStatus: (state: WebsocketTypes.StatusEnum, description: string) => { store.dispatch(Actions.updateStatus({ status: { state, description } })); }, updateUser: (user: Data.ServerInfo_User) => { @@ -93,7 +94,7 @@ export const Dispatch = { registrationUserNameError: (error: string) => { store.dispatch(Actions.registrationUserNameError({ error })); }, - accountAwaitingActivation: (options: Enriched.PendingActivationContext) => { + accountAwaitingActivation: (options: WebsocketTypes.PendingActivationContext) => { store.dispatch(Actions.accountAwaitingActivation({ options })); }, accountActivationSuccess: () => { diff --git a/webclient/src/store/server/server.interfaces.ts b/webclient/src/store/server/server.interfaces.ts index eb7cd4e69..ea7f61d15 100644 --- a/webclient/src/store/server/server.interfaces.ts +++ b/webclient/src/store/server/server.interfaces.ts @@ -1,4 +1,5 @@ import { App, Data, Enriched } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; export interface ServerState { initialized: boolean; @@ -43,7 +44,7 @@ export interface ServerState { export interface ServerStateStatus { connectionAttemptMade: boolean; description: string | null; - state: Enriched.StatusEnum; + state: WebsocketTypes.StatusEnum; } export interface ServerStateInfo { diff --git a/webclient/src/store/server/server.reducer.spec.ts b/webclient/src/store/server/server.reducer.spec.ts index c0edac090..328e10aa5 100644 --- a/webclient/src/store/server/server.reducer.spec.ts +++ b/webclient/src/store/server/server.reducer.spec.ts @@ -1,4 +1,5 @@ -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { create } from '@bufbuild/protobuf'; import { serverReducer, MAX_USER_MESSAGES } from './server.reducer'; import { Actions } from './server.actions'; @@ -18,14 +19,13 @@ import { const UserLevelFlag = Data.ServerInfo_User_UserLevelFlag; -// ── Initialisation ─────────────────────────────────────────────────────────── describe('Initialisation', () => { it('returns initialState when called with undefined state', () => { const result = serverReducer(undefined, { type: '@@INIT' }); expect(result.initialized).toBe(false); expect(result.buddyList).toEqual({}); - expect(result.status.state).toBe(Enriched.StatusEnum.DISCONNECTED); + expect(result.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); it('INITIALIZED → resets to initialState with initialized: true', () => { @@ -37,7 +37,7 @@ describe('Initialisation', () => { }); it('CLEAR_STORE → resets to initialState but preserves status', () => { - const status = { state: Enriched.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true }; + const status = { state: WebsocketTypes.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true }; const state = makeServerState({ status, banUser: 'someone' }); const result = serverReducer(state, Actions.clearStore()); expect(result.banUser).toBe(''); @@ -52,11 +52,12 @@ describe('Initialisation', () => { }); }); -// ── Account & Connection ───────────────────────────────────────────────────── describe('Account & Connection', () => { it('CONNECTION_ATTEMPTED → sets connectionAttemptMade to true', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, + }); const result = serverReducer(state, Actions.connectionAttempted()); expect(result.status.connectionAttemptMade).toBe(true); }); @@ -81,7 +82,6 @@ describe('Account & Connection', () => { }); }); -// ── Registration ────────────────────────────────────────────────────────────── describe('Registration', () => { it('REGISTRATION_FAILED → stores normalized error (plain reason)', () => { @@ -110,7 +110,6 @@ describe('Registration', () => { }); }); -// ── Server Info & Status ────────────────────────────────────────────────────── describe('Server Info & Status', () => { it('SERVER_MESSAGE → merges message into state.info', () => { @@ -131,15 +130,14 @@ describe('Server Info & Status', () => { it('UPDATE_STATUS → merges state and description into status', () => { const state = makeServerState(); - const update = { state: Enriched.StatusEnum.LOGGED_IN, description: 'ok' }; + const update = { state: WebsocketTypes.StatusEnum.LOGGED_IN, description: 'ok' }; const result = serverReducer(state, Actions.updateStatus({ status: update })); - expect(result.status.state).toBe(Enriched.StatusEnum.LOGGED_IN); + expect(result.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); expect(result.status.description).toBe('ok'); expect(result.status.connectionAttemptMade).toBe(false); }); }); -// ── User ────────────────────────────────────────────────────────────────────── describe('User', () => { it('UPDATE_USER → merges action.payload.user into state.user', () => { @@ -163,7 +161,6 @@ describe('User', () => { }); }); -// ── Users List ──────────────────────────────────────────────────────────────── describe('Users List', () => { it('UPDATE_USERS → replaces users map keyed by name', () => { @@ -192,7 +189,6 @@ describe('Users List', () => { }); }); -// ── Buddy & Ignore Lists ────────────────────────────────────────────────────── describe('Buddy List', () => { it('UPDATE_BUDDY_LIST → replaces map keyed by name', () => { @@ -246,7 +242,6 @@ describe('Ignore List', () => { }); }); -// ── Logs ───────────────────────────────────────────────────────────────────── describe('Logs', () => { it('VIEW_LOGS → groups LogItem[] into room/game/chat buckets', () => { @@ -273,7 +268,6 @@ describe('Logs', () => { }); }); -// ── Messaging ───────────────────────────────────────────────────────────────── describe('Messaging', () => { it('USER_MESSAGE → uses receiverName as key when current user is sender', () => { @@ -326,7 +320,6 @@ describe('Messaging', () => { }); }); -// ── User Info & Notifications ───────────────────────────────────────────────── describe('User Info & Notifications', () => { it('GET_USER_INFO → adds userInfo keyed by name', () => { @@ -352,7 +345,6 @@ describe('User Info & Notifications', () => { }); }); -// ── Moderation ──────────────────────────────────────────────────────────────── describe('Moderation', () => { it('BAN_FROM_SERVER → sets banUser', () => { @@ -401,7 +393,6 @@ describe('Moderation', () => { }); }); -// ── ADJUST_MOD ──────────────────────────────────────────────────────────────── describe('ADJUST_MOD', () => { const baseUserLevel = UserLevelFlag.IsUser | UserLevelFlag.IsRegistered | UserLevelFlag.IsModerator | UserLevelFlag.IsJudge; @@ -459,7 +450,6 @@ describe('ADJUST_MOD', () => { }); }); -// ── Replays ─────────────────────────────────────────────────────────────────── describe('Replays', () => { it('REPLAY_LIST → replaces replays map keyed by gameId', () => { @@ -512,7 +502,6 @@ describe('Replays', () => { }); }); -// ── Deck Storage ────────────────────────────────────────────────────────────── describe('Deck Storage', () => { it('BACKEND_DECKS → sets backendDecks', () => { @@ -675,7 +664,6 @@ describe('Deck Storage', () => { }); }); -// ── GAMES_OF_USER ───────────────────────────────────────────────────────────── describe('GAMES_OF_USER', () => { it('stores normalized games keyed by userName and gameId', () => { diff --git a/webclient/src/store/server/server.reducer.ts b/webclient/src/store/server/server.reducer.ts index 9e3033961..1aa7efc8b 100644 --- a/webclient/src/store/server/server.reducer.ts +++ b/webclient/src/store/server/server.reducer.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { App, Data, Enriched } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { create } from '@bufbuild/protobuf'; import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common'; @@ -73,7 +74,7 @@ const initialState: ServerState = { status: { connectionAttemptMade: false, - state: Enriched.StatusEnum.DISCONNECTED, + state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, info: { @@ -177,7 +178,7 @@ export const serverSlice = createSlice({ state.status.state = status.state; state.status.description = status.description; - if (status.state === Enriched.StatusEnum.DISCONNECTED) { + if (status.state === WebsocketTypes.StatusEnum.DISCONNECTED) { state.status.connectionAttemptMade = false; } }, @@ -403,10 +404,10 @@ export const serverSlice = createSlice({ }, // Signal-only action types — no state mutation, defined so type strings are generated - accountAwaitingActivation: (_state, _action: PayloadAction<{ options: Enriched.PendingActivationContext }>) => {}, + accountAwaitingActivation: (_state, _action: PayloadAction<{ options: WebsocketTypes.PendingActivationContext }>) => {}, accountActivationFailed: (_state) => {}, accountActivationSuccess: (_state) => {}, - loginSuccessful: (_state, _action: PayloadAction<{ options: Enriched.LoginSuccessContext }>) => {}, + loginSuccessful: (_state, _action: PayloadAction<{ options: WebsocketTypes.LoginSuccessContext }>) => {}, loginFailed: (_state) => {}, connectionFailed: (_state) => {}, testConnectionSuccessful: (_state) => {}, diff --git a/webclient/src/store/server/server.selectors.spec.ts b/webclient/src/store/server/server.selectors.spec.ts index 44286f950..df04e38bd 100644 --- a/webclient/src/store/server/server.selectors.spec.ts +++ b/webclient/src/store/server/server.selectors.spec.ts @@ -6,7 +6,8 @@ import { makeServerState, makeUser, } from './__mocks__/server-fixtures'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; function rootState(server: ServerState) { return { server }; @@ -34,17 +35,23 @@ describe('Selectors', () => { }); it('getDescription → returns status.description', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.CONNECTED, description: 'ok' } }); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.CONNECTED, description: 'ok' }, + }); expect(Selectors.getDescription(rootState(state))).toBe('ok'); }); it('getState → returns status.state', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.LOGGED_IN, description: null } }); - expect(Selectors.getState(rootState(state))).toBe(Enriched.StatusEnum.LOGGED_IN); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null }, + }); + expect(Selectors.getState(rootState(state))).toBe(WebsocketTypes.StatusEnum.LOGGED_IN); }); it('getConnectionAttemptMade → returns status.connectionAttemptMade', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, + }); expect(Selectors.getConnectionAttemptMade(rootState(state))).toBe(true); }); @@ -150,20 +157,25 @@ describe('Selectors', () => { expect(Selectors.getRegistrationError(rootState(state))).toBe('bad input'); }); - // ── derived selectors (createSelector) ────────────────────────────── it('getIsConnected → true when state is LOGGED_IN', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.LOGGED_IN, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null }, + }); expect(Selectors.getIsConnected(rootState(state))).toBe(true); }); it('getIsConnected → false when state is CONNECTED', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.CONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.CONNECTED, description: null }, + }); expect(Selectors.getIsConnected(rootState(state))).toBe(false); }); it('getIsConnected → false when state is DISCONNECTED', () => { - const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.DISCONNECTED, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: false, state: WebsocketTypes.StatusEnum.DISCONNECTED, description: null }, + }); expect(Selectors.getIsConnected(rootState(state))).toBe(false); }); @@ -186,10 +198,11 @@ describe('Selectors', () => { expect(Selectors.getIsUserModerator(rootState(state))).toBe(false); }); - // ── createSelector reference stability ────────────────────────────── it('getIsConnected → returns same value reference for identical state', () => { - const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.LOGGED_IN, description: null } }); + const state = makeServerState({ + status: { connectionAttemptMade: true, state: WebsocketTypes.StatusEnum.LOGGED_IN, description: null }, + }); const root = rootState(state); const a = Selectors.getIsConnected(root); const b = Selectors.getIsConnected(root); diff --git a/webclient/src/store/server/server.selectors.ts b/webclient/src/store/server/server.selectors.ts index 32f5a2ac6..fb681bb90 100644 --- a/webclient/src/store/server/server.selectors.ts +++ b/webclient/src/store/server/server.selectors.ts @@ -1,5 +1,6 @@ import { createSelector } from '@reduxjs/toolkit'; -import { Data, Enriched } from '@app/types'; +import { Data } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; import { SortUtil } from '../common'; import { ServerState } from './server.interfaces'; @@ -23,7 +24,7 @@ export const Selectors = { /** True when the server status has reached LOGGED_IN. */ getIsConnected: createSelector( [({ server }: State) => server.status.state], - (state): boolean => state === Enriched.StatusEnum.LOGGED_IN + (state): boolean => state === WebsocketTypes.StatusEnum.LOGGED_IN ), /** True when the currently logged-in user has the IsModerator level flag. */ diff --git a/webclient/src/store/store.ts b/webclient/src/store/store.ts index 997d97661..b5261b782 100644 --- a/webclient/src/store/store.ts +++ b/webclient/src/store/store.ts @@ -3,11 +3,8 @@ import { isMessage } from '@bufbuild/protobuf'; import { useDispatch, useSelector } from 'react-redux'; import rootReducer from './rootReducer'; -// Protobuf-es v2 messages are already plain objects (no class prototype, unlike v1). -// They carry $typeName (string, identifies the message) and $unknown (binary unknown -// fields) — both are serializable and harmless in Redux state. No conversion needed. -// Fields may include Uint8Array (bytes) and BigInt (int64/uint64), which fail Redux -// Toolkit’s default serializable check, so we extend it to accept these types. +// Protobuf-es v2 messages are plain objects with $typeName/$unknown siblings; +// bytes fields are Uint8Array and int64/uint64 are BigInt. All four pass through. function isSerializable(value: unknown): boolean { return isPlain(value) || isMessage(value) || value instanceof Uint8Array || typeof value === 'bigint'; } diff --git a/webclient/src/types/enriched.ts b/webclient/src/types/enriched.ts index 6cf45316d..cc3de313c 100644 --- a/webclient/src/types/enriched.ts +++ b/webclient/src/types/enriched.ts @@ -10,30 +10,21 @@ import type { ServerInfo_User, } from '@app/generated'; -// ── Domain model types (composition: raw proto + client-side fields) ────────── -// -// `info` holds the proto snapshot verbatim. Normalized/client-only fields -// live as siblings. For `Room`, the repeated collections on `info` -// (gameList, userList, gametypeList) are the *wire snapshot* from the last -// full update — they become stale after subsequent events. Always read from -// the normalized `games`, `users`, and `gametypeMap` fields. +// @critical `info` is the wire snapshot; repeated collections on it go stale. Read normalized siblings. +// See .github/instructions/webclient.instructions.md#data-structure-invariants. export interface GametypeMap { [index: number]: string } -/** Room directory listing — composition of raw proto with normalized collections. */ export interface Room { info: ServerInfo_Room; gametypeMap: GametypeMap; - /** Server-determined display order from the UPDATE_ROOMS sequence. */ order: number; games: { [gameId: number]: Game }; users: { [userName: string]: ServerInfo_User }; } -/** Room directory game listing — composition of raw proto with cached gameType. */ export interface Game { info: ServerInfo_Game; - /** Cached display string resolved from the owning room's gametypeMap at ingest. */ gameType: string; } @@ -41,27 +32,17 @@ export type Message = Event_RoomSay & { timeReceived: number; }; -// ── Active game runtime state (game slice) ─────────────────────────────────── -// -// Composition pattern: the raw proto from Event_GameJoined is stored verbatim -// on `info`. Fields that evolve via in-game events live at the top level. -// -// Convention: `info` is the wire snapshot taken at join time. Fields with a -// proto twin (e.g. `started`) diverge after the first event update — always -// read the top-level field for "current value"; `info.*` is the initial -// server snapshot only. - +// @critical `info` = wire snapshot at join time; top-level twins hold live values updated by game events. +// See .github/instructions/webclient.instructions.md#data-structure-invariants. export interface GameEntry { info: ServerInfo_Game; - // From the Event_GameJoined wrapper (not on ServerInfo_Game itself). hostId: number; localPlayerId: number; spectator: boolean; judge: boolean; resuming: boolean; - // Client-tracked runtime state, updated by game events. started: boolean; activePlayerId: number; activePhase: number; @@ -72,32 +53,22 @@ export interface GameEntry { messages: GameMessage[]; } -/** Normalized from ServerInfo_Player — keyed collections for O(1) lookup. */ export interface PlayerEntry { properties: ServerInfo_PlayerProperties; deckList: string; - /** Zones keyed by zone name (e.g. "hand", "deck", "table"). */ zones: { [zoneName: string]: ZoneEntry }; - /** Player-level counters (e.g. life) keyed by counter id. */ counters: { [counterId: number]: ServerInfo_Counter }; - /** Arrows keyed by arrow id. */ arrows: { [arrowId: number]: ServerInfo_Arrow }; } -/** - * Normalized from ServerInfo_Zone — cards indexed by id for O(1) mutation, - * with `order` preserving display sequence. Iterate via `order.map(id => byId[id])`. - */ export interface ZoneEntry { name: string; /** ZoneType enum value (0=Private, 1=Public, 2=Hidden). */ type: number; withCoords: boolean; - /** Authoritative card count. For hidden zones this may exceed `order.length`. */ + /** Authoritative count; for hidden zones this may exceed `order.length`. */ cardCount: number; - /** Card ids in display order. */ order: number[]; - /** Card lookup by id. */ byId: { [cardId: number]: ServerInfo_Card }; alwaysRevealTopCard: boolean; alwaysLookAtTopCard: boolean; @@ -114,39 +85,3 @@ export interface LogGroups { game: ServerInfo_ChatMessage[]; chat: ServerInfo_ChatMessage[]; } - -// ── Websocket re-exports ───────────────────────────────────────────────────── -// Source of truth lives in @app/websocket. Re-exported here so app code can -// reach these via the Enriched.* namespace without importing @app/websocket. - -export { StatusEnum, WebSocketConnectReason } from '@app/websocket'; - -export type { - GameEventMeta, - LoginConnectOptions, - RegisterConnectOptions, - ActivateConnectOptions, - PasswordResetRequestConnectOptions, - PasswordResetChallengeConnectOptions, - PasswordResetConnectOptions, - TestConnectionOptions, - WebSocketConnectOptions, -} from '@app/websocket'; - -/** - * Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the - * activation dialog can resubmit against the same host/user without re-entering them. - */ -export interface PendingActivationContext { - host: string; - port: string; - userName: string; -} - -/** - * Payload for the LOGIN_SUCCESSFUL signal. Only carries what the UI needs to - * persist into the selected host record (hashedPassword for "remember me"). - */ -export interface LoginSuccessContext { - hashedPassword?: string; -} diff --git a/webclient/src/types/server.ts b/webclient/src/types/server.ts index 59eb399c0..4b23cc02d 100644 --- a/webclient/src/types/server.ts +++ b/webclient/src/types/server.ts @@ -1,10 +1,3 @@ -import type { StatusEnum } from './enriched'; - -export interface ServerStatus { - status: StatusEnum; - description: string; -} - export class Host { id?: number; name: string; diff --git a/webclient/src/websocket/WebClient.spec.ts b/webclient/src/websocket/WebClient.spec.ts index 2fab4d695..d2fdb9b5e 100644 --- a/webclient/src/websocket/WebClient.spec.ts +++ b/webclient/src/websocket/WebClient.spec.ts @@ -29,13 +29,14 @@ vi.mock('./services/ProtobufService', () => ({ import { WebClient } from './WebClient'; import { WebSocketService } from './services/WebSocketService'; import { ProtobufService } from './services/ProtobufService'; -import { StatusEnum } from './interfaces/StatusEnum'; +import { StatusEnum } from './types/StatusEnum'; import { Subject } from 'rxjs'; import { Mock } from 'vitest'; import { SocketTransport } from './services/ProtobufService'; import { WebSocketServiceConfig } from './services/WebSocketService'; -import type { IWebClientResponse, IWebClientRequest } from './interfaces'; -import type { ConnectTarget } from './interfaces/WebClientConfig'; +import type { IWebClientResponse } from './types/WebClientResponse'; +import type { IWebClientRequest } from './types/WebClientRequest'; +import type { ConnectTarget } from './types/WebClientConfig'; import { installMockWebSocket } from './__mocks__/helpers'; function makeMockResponse(): IWebClientResponse { diff --git a/webclient/src/websocket/WebClient.ts b/webclient/src/websocket/WebClient.ts index ddd197ce6..ec628440e 100644 --- a/webclient/src/websocket/WebClient.ts +++ b/webclient/src/websocket/WebClient.ts @@ -1,11 +1,9 @@ import { ping } from './commands/session'; import { CLIENT_OPTIONS } from './config'; -import type { - ConnectTarget, - IWebClientRequest, - IWebClientResponse, -} from './interfaces'; -import { StatusEnum } from './interfaces'; +import type { ConnectTarget } from './types/WebClientConfig'; +import type { IWebClientRequest } from './types/WebClientRequest'; +import type { IWebClientResponse } from './types/WebClientResponse'; +import { StatusEnum } from './types/StatusEnum'; import { ProtobufService } from './services/ProtobufService'; import { WebSocketService } from './services/WebSocketService'; diff --git a/webclient/src/websocket/__mocks__/WebClient.ts b/webclient/src/websocket/__mocks__/WebClient.ts index 1219c3466..2485830e0 100644 --- a/webclient/src/websocket/__mocks__/WebClient.ts +++ b/webclient/src/websocket/__mocks__/WebClient.ts @@ -17,9 +17,6 @@ * property, not a getter that throws. */ -// --------------------------------------------------------------------------- -// response.session (ISessionResponse) -// --------------------------------------------------------------------------- const session = { initialized: vi.fn(), connectionAttempted: vi.fn(), @@ -80,9 +77,6 @@ const session = { replayDownloaded: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.room (IRoomResponse) -// --------------------------------------------------------------------------- const room = { clearStore: vi.fn(), joinRoom: vi.fn(), @@ -97,9 +91,6 @@ const room = { joinedGame: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.game (IGameResponse) -// --------------------------------------------------------------------------- const game = { clearStore: vi.fn(), gameStateChanged: vi.fn(), @@ -133,9 +124,6 @@ const game = { zonePropertiesChanged: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.admin (IAdminResponse) -// --------------------------------------------------------------------------- const admin = { adjustMod: vi.fn(), reloadConfig: vi.fn(), @@ -143,9 +131,6 @@ const admin = { updateServerMessage: vi.fn(), }; -// --------------------------------------------------------------------------- -// response.moderator (IModeratorResponse) -// --------------------------------------------------------------------------- const moderator = { banFromServer: vi.fn(), banHistory: vi.fn(), @@ -159,9 +144,6 @@ const moderator = { updateAdminNotes: vi.fn(), }; -// --------------------------------------------------------------------------- -// Exported mock — replaces the real WebClient module for all consumers. -// --------------------------------------------------------------------------- export const WebClient = { _instance: null as any, instance: { diff --git a/webclient/src/websocket/__mocks__/helpers.ts b/webclient/src/websocket/__mocks__/helpers.ts index c76fd9f17..7ef2ed718 100644 --- a/webclient/src/websocket/__mocks__/helpers.ts +++ b/webclient/src/websocket/__mocks__/helpers.ts @@ -1,27 +1,6 @@ /** * Shared mock factories for websocket layer unit tests. - * Import the helpers you need in each spec file via: - * import { makeMockWebSocket, useWebClientCleanup } from '../__mocks__/helpers'; */ -import { WebClient } from '../WebClient'; - -/** - * Resets the WebClient singleton to null. Call directly, or use - * `useWebClientCleanup()` to register automatic beforeEach/afterEach hooks. - */ -export function resetWebClientSingleton() { - (WebClient as unknown as { _instance: WebClient | null })._instance = null; -} - -/** - * Registers beforeEach/afterEach hooks that reset the WebClient singleton. - * Call at describe-level or file-level in any spec that mocks WebClient. - * Prevents isolate:false singleton leakage between spec files. - */ -export function useWebClientCleanup() { - beforeEach(() => resetWebClientSingleton()); - afterEach(() => resetWebClientSingleton()); -} /** Builds a mock WebSocket instance */ export function makeMockWebSocketInstance() { diff --git a/webclient/src/websocket/commands/admin/adminCommands.spec.ts b/webclient/src/websocket/commands/admin/adminCommands.spec.ts index 51764a627..08db9e409 100644 --- a/webclient/src/websocket/commands/admin/adminCommands.spec.ts +++ b/webclient/src/websocket/commands/admin/adminCommands.spec.ts @@ -20,9 +20,6 @@ const { invokeOnSuccess } = makeCallbackHelpers( 2 ); -// ---------------------------------------------------------------- -// adjustMod -// ---------------------------------------------------------------- describe('adjustMod', () => { it('calls sendAdminCommand with Command_AdjustMod extension and fields', () => { @@ -41,9 +38,6 @@ describe('adjustMod', () => { }); }); -// ---------------------------------------------------------------- -// reloadConfig -// ---------------------------------------------------------------- describe('reloadConfig', () => { it('calls sendAdminCommand with Command_ReloadConfig extension', () => { @@ -62,9 +56,6 @@ describe('reloadConfig', () => { }); }); -// ---------------------------------------------------------------- -// shutdownServer -// ---------------------------------------------------------------- describe('shutdownServer', () => { it('calls sendAdminCommand with Command_ShutdownServer extension and fields', () => { @@ -83,9 +74,6 @@ describe('shutdownServer', () => { }); }); -// ---------------------------------------------------------------- -// updateServerMessage -// ---------------------------------------------------------------- describe('updateServerMessage', () => { it('calls sendAdminCommand with Command_UpdateServerMessage extension', () => { diff --git a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts index 69ba240fd..4dd7fcf76 100644 --- a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts +++ b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts @@ -39,9 +39,6 @@ const { invokeOnSuccess } = makeCallbackHelpers( 2 ); -// ---------------------------------------------------------------- -// banFromServer -// ---------------------------------------------------------------- describe('banFromServer', () => { it('calls sendModeratorCommand with Command_BanFromServer', () => { @@ -60,9 +57,6 @@ describe('banFromServer', () => { }); }); -// ---------------------------------------------------------------- -// forceActivateUser -// ---------------------------------------------------------------- describe('forceActivateUser', () => { it('calls sendModeratorCommand with Command_ForceActivateUser', () => { @@ -79,9 +73,6 @@ describe('forceActivateUser', () => { }); }); -// ---------------------------------------------------------------- -// getAdminNotes -// ---------------------------------------------------------------- describe('getAdminNotes', () => { it('calls sendModeratorCommand with Command_GetAdminNotes', () => { @@ -101,9 +92,6 @@ describe('getAdminNotes', () => { }); }); -// ---------------------------------------------------------------- -// getBanHistory -// ---------------------------------------------------------------- describe('getBanHistory', () => { it('calls sendModeratorCommand with Command_GetBanHistory', () => { @@ -123,9 +111,6 @@ describe('getBanHistory', () => { }); }); -// ---------------------------------------------------------------- -// getWarnHistory -// ---------------------------------------------------------------- describe('getWarnHistory', () => { it('calls sendModeratorCommand with Command_GetWarnHistory', () => { @@ -145,9 +130,6 @@ describe('getWarnHistory', () => { }); }); -// ---------------------------------------------------------------- -// getWarnList -// ---------------------------------------------------------------- describe('getWarnList', () => { it('calls sendModeratorCommand with Command_GetWarnList', () => { @@ -167,9 +149,6 @@ describe('getWarnList', () => { }); }); -// ---------------------------------------------------------------- -// grantReplayAccess -// ---------------------------------------------------------------- describe('grantReplayAccess', () => { it('calls sendModeratorCommand with Command_GrantReplayAccess', () => { @@ -186,9 +165,6 @@ describe('grantReplayAccess', () => { }); }); -// ---------------------------------------------------------------- -// updateAdminNotes -// ---------------------------------------------------------------- describe('updateAdminNotes', () => { it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => { @@ -205,9 +181,6 @@ describe('updateAdminNotes', () => { }); }); -// ---------------------------------------------------------------- -// viewLogHistory -// ---------------------------------------------------------------- describe('viewLogHistory', () => { it('calls sendModeratorCommand with Command_ViewLogHistory', () => { @@ -229,9 +202,6 @@ describe('viewLogHistory', () => { }); }); -// ---------------------------------------------------------------- -// warnUser -// ---------------------------------------------------------------- describe('warnUser', () => { it('calls sendModeratorCommand with Command_WarnUser', () => { diff --git a/webclient/src/websocket/commands/room/roomCommands.spec.ts b/webclient/src/websocket/commands/room/roomCommands.spec.ts index 57dac994a..b748030a6 100644 --- a/webclient/src/websocket/commands/room/roomCommands.spec.ts +++ b/webclient/src/websocket/commands/room/roomCommands.spec.ts @@ -24,9 +24,6 @@ const { invokeOnSuccess } = makeCallbackHelpers( 3 ); -// ---------------------------------------------------------------- -// createGame -// ---------------------------------------------------------------- describe('createGame', () => { it('calls sendRoomCommand with Command_CreateGame', () => { @@ -43,9 +40,6 @@ describe('createGame', () => { }); }); -// ---------------------------------------------------------------- -// joinGame -// ---------------------------------------------------------------- describe('joinGame', () => { it('calls sendRoomCommand with Command_JoinGame', () => { @@ -62,9 +56,6 @@ describe('joinGame', () => { }); }); -// ---------------------------------------------------------------- -// leaveRoom -// ---------------------------------------------------------------- describe('leaveRoom', () => { it('calls sendRoomCommand with Command_LeaveRoom', () => { @@ -81,9 +72,6 @@ describe('leaveRoom', () => { }); }); -// ---------------------------------------------------------------- -// roomSay -// ---------------------------------------------------------------- describe('roomSay', () => { it('calls sendRoomCommand with trimmed message', () => { diff --git a/webclient/src/websocket/commands/session/activate.ts b/webclient/src/websocket/commands/session/activate.ts index 422ed937f..368e676f7 100644 --- a/webclient/src/websocket/commands/session/activate.ts +++ b/webclient/src/websocket/commands/session/activate.ts @@ -6,10 +6,10 @@ import { type ActivateParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { disconnect, login, updateStatus } from './'; export function activate(options: ConnectTarget & ActivateParams, password?: string, passwordSalt?: string): void { diff --git a/webclient/src/websocket/commands/session/connect.ts b/webclient/src/websocket/commands/session/connect.ts index 035f5a60a..d260ca8c2 100644 --- a/webclient/src/websocket/commands/session/connect.ts +++ b/webclient/src/websocket/commands/session/connect.ts @@ -1,5 +1,5 @@ import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; export function connect(target: ConnectTarget): void { WebClient.instance.connect(target); diff --git a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts index 7246580af..88b9da8e3 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts @@ -5,10 +5,10 @@ import { type ForgotPasswordChallengeParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { disconnect, updateStatus } from './'; export function forgotPasswordChallenge(options: ConnectTarget & ForgotPasswordChallengeParams): void { diff --git a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts index cf8246b30..8099072dc 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts @@ -6,10 +6,10 @@ import { type ForgotPasswordRequestParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { disconnect, updateStatus } from './'; export function forgotPasswordRequest(options: ConnectTarget & ForgotPasswordRequestParams): void { diff --git a/webclient/src/websocket/commands/session/forgotPasswordReset.ts b/webclient/src/websocket/commands/session/forgotPasswordReset.ts index 3acd946b7..74792c292 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordReset.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordReset.ts @@ -6,10 +6,10 @@ import { type ForgotPasswordResetParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { hashPassword } from '../../utils'; import { disconnect, updateStatus } from '.'; diff --git a/webclient/src/websocket/commands/session/login.ts b/webclient/src/websocket/commands/session/login.ts index 80fddfcc7..fc290ceda 100644 --- a/webclient/src/websocket/commands/session/login.ts +++ b/webclient/src/websocket/commands/session/login.ts @@ -8,10 +8,10 @@ import { type LoginParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { hashPassword } from '../../utils'; import { disconnect, diff --git a/webclient/src/websocket/commands/session/register.ts b/webclient/src/websocket/commands/session/register.ts index ec0b5ecfa..42eee407e 100644 --- a/webclient/src/websocket/commands/session/register.ts +++ b/webclient/src/websocket/commands/session/register.ts @@ -8,10 +8,10 @@ import { type RegisterParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { hashPassword } from '../../utils'; import { login, disconnect, updateStatus } from './'; diff --git a/webclient/src/websocket/commands/session/requestPasswordSalt.ts b/webclient/src/websocket/commands/session/requestPasswordSalt.ts index 73fed0aec..3c4e4ff35 100644 --- a/webclient/src/websocket/commands/session/requestPasswordSalt.ts +++ b/webclient/src/websocket/commands/session/requestPasswordSalt.ts @@ -7,10 +7,10 @@ import { type RequestPasswordSaltParams, } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../../types/WebClientConfig'; import { updateStatus } from './'; export function requestPasswordSalt( diff --git a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts index daf90d083..dda7b8c05 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts @@ -18,8 +18,16 @@ import { Mock } from 'vitest'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; import { WebClient } from '../../WebClient'; import * as SessionIndexMocks from './'; -import { Enriched } from '@app/types'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { + WebSocketConnectReason, + type LoginConnectOptions, + type RegisterConnectOptions, + type ActivateConnectOptions, + type PasswordResetRequestConnectOptions, + type PasswordResetChallengeConnectOptions, + type PasswordResetConnectOptions, +} from '../../types/ConnectOptions'; +import { StatusEnum } from '../../types/StatusEnum'; import { Command_Activate_ext, Command_ForgotPasswordChallenge_ext, @@ -56,50 +64,50 @@ const { invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpe ); const baseTransport = { host: 'h', port: '1' }; -const makeLoginOpts = (overrides: Partial = {}): Enriched.LoginConnectOptions => ({ +const makeLoginOpts = (overrides: Partial = {}): LoginConnectOptions => ({ ...baseTransport, userName: 'alice', - reason: Enriched.WebSocketConnectReason.LOGIN, + reason: WebSocketConnectReason.LOGIN, ...overrides, }); const makeRegisterOpts = ( - overrides: Partial = {} -): Enriched.RegisterConnectOptions => ({ + overrides: Partial = {} +): RegisterConnectOptions => ({ ...baseTransport, userName: 'alice', password: 'pw', email: 'a@b.com', country: 'US', realName: 'Al', - reason: Enriched.WebSocketConnectReason.REGISTER, + reason: WebSocketConnectReason.REGISTER, ...overrides, }); const makeActivateOpts = ( - overrides: Partial = {} -): Enriched.ActivateConnectOptions => ({ + overrides: Partial = {} +): ActivateConnectOptions => ({ ...baseTransport, userName: 'alice', token: 'tok', - reason: Enriched.WebSocketConnectReason.ACTIVATE_ACCOUNT, + reason: WebSocketConnectReason.ACTIVATE_ACCOUNT, ...overrides, }); -const makeForgotRequestOpts = (): Enriched.PasswordResetRequestConnectOptions => ({ +const makeForgotRequestOpts = (): PasswordResetRequestConnectOptions => ({ ...baseTransport, userName: 'alice', - reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_REQUEST, + reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST, }); -const makeForgotChallengeOpts = (): Enriched.PasswordResetChallengeConnectOptions => ({ +const makeForgotChallengeOpts = (): PasswordResetChallengeConnectOptions => ({ ...baseTransport, userName: 'alice', email: 'a@b.com', - reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE, + reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE, }); -const makeForgotResetOpts = (): Enriched.PasswordResetConnectOptions => ({ +const makeForgotResetOpts = (): PasswordResetConnectOptions => ({ ...baseTransport, userName: 'alice', token: 'tok', newPassword: 'newpw', - reason: Enriched.WebSocketConnectReason.PASSWORD_RESET, + reason: WebSocketConnectReason.PASSWORD_RESET, }); @@ -109,9 +117,6 @@ beforeEach(() => { (passwordSaltSupported as Mock).mockReturnValue(0); }); -// ---------------------------------------------------------------- -// connect.ts -// ---------------------------------------------------------------- describe('connect', () => { it('calls WebClient.instance.connect with the target', () => { @@ -128,9 +133,6 @@ describe('testConnect', () => { }); }); -// ---------------------------------------------------------------- -// updateStatus.ts -// ---------------------------------------------------------------- describe('updateStatus', () => { it('calls WebClient.instance.response.session.updateStatus and WebClient.instance.updateStatus', () => { @@ -140,9 +142,6 @@ describe('updateStatus', () => { }); }); -// ---------------------------------------------------------------- -// login.ts -// ---------------------------------------------------------------- describe('login', () => { it('sends Command_Login with plain password when no salt', () => { @@ -194,7 +193,7 @@ describe('login', () => { }); it('onSuccess passes hashedPassword to loginSuccessful when salt is used', () => { - login({ host: 'h', port: '1', userName: 'alice', reason: Enriched.WebSocketConnectReason.LOGIN }, 'pw', 'salt'); + login({ host: 'h', port: '1', userName: 'alice', reason: WebSocketConnectReason.LOGIN }, 'pw', 'salt'); const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } }; invokeOnSuccess(loginResp, { responseCode: 0 }); const calledWith = (WebClient.instance.response.session.loginSuccessful as Mock).mock.calls[0][0]; @@ -266,9 +265,6 @@ describe('login', () => { }); }); -// ---------------------------------------------------------------- -// register.ts -// ---------------------------------------------------------------- describe('register', () => { it('sends Command_Register with plain password when no salt', () => { @@ -371,9 +367,6 @@ describe('register', () => { }); }); -// ---------------------------------------------------------------- -// activate.ts -// ---------------------------------------------------------------- describe('activate', () => { it('sends Command_Activate with userName and token, not password', () => { @@ -405,9 +398,6 @@ describe('activate', () => { }); }); -// ---------------------------------------------------------------- -// forgotPasswordChallenge.ts -// ---------------------------------------------------------------- describe('forgotPasswordChallenge', () => { it('sends Command_ForgotPasswordChallenge', () => { @@ -432,9 +422,6 @@ describe('forgotPasswordChallenge', () => { }); }); -// ---------------------------------------------------------------- -// forgotPasswordRequest.ts -// ---------------------------------------------------------------- describe('forgotPasswordRequest', () => { it('sends Command_ForgotPasswordRequest', () => { @@ -470,9 +457,6 @@ describe('forgotPasswordRequest', () => { }); }); -// ---------------------------------------------------------------- -// forgotPasswordReset.ts -// ---------------------------------------------------------------- describe('forgotPasswordReset', () => { it('sends Command_ForgotPasswordReset with plain newPassword when no salt', () => { @@ -508,9 +492,6 @@ describe('forgotPasswordReset', () => { }); }); -// ---------------------------------------------------------------- -// requestPasswordSalt.ts -// ---------------------------------------------------------------- describe('requestPasswordSalt', () => { it('sends Command_RequestPasswordSalt', () => { diff --git a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts index 18c8be728..289ca227a 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts @@ -92,7 +92,6 @@ beforeEach(() => { (passwordSaltSupported as Mock).mockReturnValue(0); }); -// ---------------------------------------------------------------- describe('accountEdit', () => { it('sends Command_AccountEdit with correct params', () => { diff --git a/webclient/src/websocket/commands/session/updateStatus.ts b/webclient/src/websocket/commands/session/updateStatus.ts index 52cb9ccbc..596b72f21 100644 --- a/webclient/src/websocket/commands/session/updateStatus.ts +++ b/webclient/src/websocket/commands/session/updateStatus.ts @@ -1,4 +1,4 @@ -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { WebClient } from '../../WebClient'; export function updateStatus(status: StatusEnum, description: string): void { diff --git a/webclient/src/websocket/events/game/attachCard.ts b/webclient/src/websocket/events/game/attachCard.ts index a3671cb05..9de5c6114 100644 --- a/webclient/src/websocket/events/game/attachCard.ts +++ b/webclient/src/websocket/events/game/attachCard.ts @@ -1,5 +1,5 @@ import type { Event_AttachCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function attachCard(data: Event_AttachCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/changeZoneProperties.ts b/webclient/src/websocket/events/game/changeZoneProperties.ts index 88d7f95f1..8afff45bf 100644 --- a/webclient/src/websocket/events/game/changeZoneProperties.ts +++ b/webclient/src/websocket/events/game/changeZoneProperties.ts @@ -1,5 +1,5 @@ import type { Event_ChangeZoneProperties } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function changeZoneProperties(data: Event_ChangeZoneProperties, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createArrow.ts b/webclient/src/websocket/events/game/createArrow.ts index 3f39fa064..ef2443365 100644 --- a/webclient/src/websocket/events/game/createArrow.ts +++ b/webclient/src/websocket/events/game/createArrow.ts @@ -1,5 +1,5 @@ import type { Event_CreateArrow } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createArrow(data: Event_CreateArrow, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createCounter.ts b/webclient/src/websocket/events/game/createCounter.ts index db9ca6086..54abcd0cf 100644 --- a/webclient/src/websocket/events/game/createCounter.ts +++ b/webclient/src/websocket/events/game/createCounter.ts @@ -1,5 +1,5 @@ import type { Event_CreateCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createCounter(data: Event_CreateCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createToken.ts b/webclient/src/websocket/events/game/createToken.ts index 1542d4418..9c406da16 100644 --- a/webclient/src/websocket/events/game/createToken.ts +++ b/webclient/src/websocket/events/game/createToken.ts @@ -1,5 +1,5 @@ import type { Event_CreateToken } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createToken(data: Event_CreateToken, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/delCounter.ts b/webclient/src/websocket/events/game/delCounter.ts index 1a128b0ad..bf4231a93 100644 --- a/webclient/src/websocket/events/game/delCounter.ts +++ b/webclient/src/websocket/events/game/delCounter.ts @@ -1,5 +1,5 @@ import type { Event_DelCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function delCounter(data: Event_DelCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/deleteArrow.ts b/webclient/src/websocket/events/game/deleteArrow.ts index df39f5d47..d3653313b 100644 --- a/webclient/src/websocket/events/game/deleteArrow.ts +++ b/webclient/src/websocket/events/game/deleteArrow.ts @@ -1,5 +1,5 @@ import type { Event_DeleteArrow } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function deleteArrow(data: Event_DeleteArrow, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/destroyCard.ts b/webclient/src/websocket/events/game/destroyCard.ts index 65cb87d38..de010fbfb 100644 --- a/webclient/src/websocket/events/game/destroyCard.ts +++ b/webclient/src/websocket/events/game/destroyCard.ts @@ -1,5 +1,5 @@ import type { Event_DestroyCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function destroyCard(data: Event_DestroyCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/drawCards.ts b/webclient/src/websocket/events/game/drawCards.ts index c9fed3078..c9ab5e26e 100644 --- a/webclient/src/websocket/events/game/drawCards.ts +++ b/webclient/src/websocket/events/game/drawCards.ts @@ -1,5 +1,5 @@ import type { Event_DrawCards } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function drawCards(data: Event_DrawCards, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/dumpZone.ts b/webclient/src/websocket/events/game/dumpZone.ts index 8302877ef..24f2f3e72 100644 --- a/webclient/src/websocket/events/game/dumpZone.ts +++ b/webclient/src/websocket/events/game/dumpZone.ts @@ -1,5 +1,5 @@ import type { Event_DumpZone } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function dumpZone(data: Event_DumpZone, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/flipCard.ts b/webclient/src/websocket/events/game/flipCard.ts index 1c4e74cd3..4b57e89c3 100644 --- a/webclient/src/websocket/events/game/flipCard.ts +++ b/webclient/src/websocket/events/game/flipCard.ts @@ -1,5 +1,5 @@ import type { Event_FlipCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function flipCard(data: Event_FlipCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameClosed.ts b/webclient/src/websocket/events/game/gameClosed.ts index 73fc445e2..40168cf0d 100644 --- a/webclient/src/websocket/events/game/gameClosed.ts +++ b/webclient/src/websocket/events/game/gameClosed.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameClosed(_data: {}, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameHostChanged.ts b/webclient/src/websocket/events/game/gameHostChanged.ts index 2cc7e5064..da89fc34d 100644 --- a/webclient/src/websocket/events/game/gameHostChanged.ts +++ b/webclient/src/websocket/events/game/gameHostChanged.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; /** diff --git a/webclient/src/websocket/events/game/gameSay.ts b/webclient/src/websocket/events/game/gameSay.ts index 7773720de..18a643e0f 100644 --- a/webclient/src/websocket/events/game/gameSay.ts +++ b/webclient/src/websocket/events/game/gameSay.ts @@ -1,5 +1,5 @@ import type { Event_GameSay } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameSay(data: Event_GameSay, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameStateChanged.ts b/webclient/src/websocket/events/game/gameStateChanged.ts index ff3d53fa3..cc5b95d7a 100644 --- a/webclient/src/websocket/events/game/gameStateChanged.ts +++ b/webclient/src/websocket/events/game/gameStateChanged.ts @@ -1,5 +1,5 @@ import type { Event_GameStateChanged } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameStateChanged(data: Event_GameStateChanged, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/index.ts b/webclient/src/websocket/events/game/index.ts index d19292e80..5798a1aaa 100644 --- a/webclient/src/websocket/events/game/index.ts +++ b/webclient/src/websocket/events/game/index.ts @@ -35,7 +35,7 @@ import { Event_ReverseTurn_ext, } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { attachCard } from './attachCard'; import { changeZoneProperties } from './changeZoneProperties'; diff --git a/webclient/src/websocket/events/game/joinGame.ts b/webclient/src/websocket/events/game/joinGame.ts index f0efd619b..b2ede9a4d 100644 --- a/webclient/src/websocket/events/game/joinGame.ts +++ b/webclient/src/websocket/events/game/joinGame.ts @@ -1,5 +1,5 @@ import type { Event_Join } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; diff --git a/webclient/src/websocket/events/game/kicked.ts b/webclient/src/websocket/events/game/kicked.ts index f63951dcc..ba7db0674 100644 --- a/webclient/src/websocket/events/game/kicked.ts +++ b/webclient/src/websocket/events/game/kicked.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function kicked(_data: {}, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/leaveGame.ts b/webclient/src/websocket/events/game/leaveGame.ts index 5d2df026e..738a34ce7 100644 --- a/webclient/src/websocket/events/game/leaveGame.ts +++ b/webclient/src/websocket/events/game/leaveGame.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function leaveGame(data: { reason: number }, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/moveCard.ts b/webclient/src/websocket/events/game/moveCard.ts index a553e727a..47910ad4e 100644 --- a/webclient/src/websocket/events/game/moveCard.ts +++ b/webclient/src/websocket/events/game/moveCard.ts @@ -1,5 +1,5 @@ import type { Event_MoveCard } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function moveCard(data: Event_MoveCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/playerPropertiesChanged.ts b/webclient/src/websocket/events/game/playerPropertiesChanged.ts index 073f5a408..d01a1578d 100644 --- a/webclient/src/websocket/events/game/playerPropertiesChanged.ts +++ b/webclient/src/websocket/events/game/playerPropertiesChanged.ts @@ -1,5 +1,5 @@ import type { Event_PlayerPropertiesChanged } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function playerPropertiesChanged(data: Event_PlayerPropertiesChanged, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/revealCards.ts b/webclient/src/websocket/events/game/revealCards.ts index 9eb08b82a..cce29c619 100644 --- a/webclient/src/websocket/events/game/revealCards.ts +++ b/webclient/src/websocket/events/game/revealCards.ts @@ -1,5 +1,5 @@ import type { Event_RevealCards } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function revealCards(data: Event_RevealCards, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/reverseTurn.ts b/webclient/src/websocket/events/game/reverseTurn.ts index ef953aba8..5340e5ae3 100644 --- a/webclient/src/websocket/events/game/reverseTurn.ts +++ b/webclient/src/websocket/events/game/reverseTurn.ts @@ -1,5 +1,5 @@ import type { Event_ReverseTurn } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function reverseTurn(data: Event_ReverseTurn, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/rollDie.ts b/webclient/src/websocket/events/game/rollDie.ts index ca5365144..da411fad4 100644 --- a/webclient/src/websocket/events/game/rollDie.ts +++ b/webclient/src/websocket/events/game/rollDie.ts @@ -1,5 +1,5 @@ import type { Event_RollDie } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function rollDie(data: Event_RollDie, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setActivePhase.ts b/webclient/src/websocket/events/game/setActivePhase.ts index 134651bdb..c7f92961c 100644 --- a/webclient/src/websocket/events/game/setActivePhase.ts +++ b/webclient/src/websocket/events/game/setActivePhase.ts @@ -1,5 +1,5 @@ import type { Event_SetActivePhase } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setActivePhase(data: Event_SetActivePhase, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setActivePlayer.ts b/webclient/src/websocket/events/game/setActivePlayer.ts index e84f44920..730f9614e 100644 --- a/webclient/src/websocket/events/game/setActivePlayer.ts +++ b/webclient/src/websocket/events/game/setActivePlayer.ts @@ -1,5 +1,5 @@ import type { Event_SetActivePlayer } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setActivePlayer(data: Event_SetActivePlayer, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCardAttr.ts b/webclient/src/websocket/events/game/setCardAttr.ts index 3733dc7b1..1ce1d63bc 100644 --- a/webclient/src/websocket/events/game/setCardAttr.ts +++ b/webclient/src/websocket/events/game/setCardAttr.ts @@ -1,5 +1,5 @@ import type { Event_SetCardAttr } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCardAttr(data: Event_SetCardAttr, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCardCounter.ts b/webclient/src/websocket/events/game/setCardCounter.ts index c96d50ac6..b78786f89 100644 --- a/webclient/src/websocket/events/game/setCardCounter.ts +++ b/webclient/src/websocket/events/game/setCardCounter.ts @@ -1,5 +1,5 @@ import type { Event_SetCardCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCardCounter(data: Event_SetCardCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCounter.ts b/webclient/src/websocket/events/game/setCounter.ts index 310fb9e28..c4cf794c2 100644 --- a/webclient/src/websocket/events/game/setCounter.ts +++ b/webclient/src/websocket/events/game/setCounter.ts @@ -1,5 +1,5 @@ import type { Event_SetCounter } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCounter(data: Event_SetCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/shuffle.ts b/webclient/src/websocket/events/game/shuffle.ts index 9d1689472..2c404390d 100644 --- a/webclient/src/websocket/events/game/shuffle.ts +++ b/webclient/src/websocket/events/game/shuffle.ts @@ -1,5 +1,5 @@ import type { Event_Shuffle } from '@app/generated'; -import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../../types/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function shuffle(data: Event_Shuffle, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/session/connectionClosed.ts b/webclient/src/websocket/events/session/connectionClosed.ts index b3422172f..d292d5687 100644 --- a/webclient/src/websocket/events/session/connectionClosed.ts +++ b/webclient/src/websocket/events/session/connectionClosed.ts @@ -1,11 +1,10 @@ import { Event_ConnectionClosed_CloseReason, type Event_ConnectionClosed } from '@app/generated'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { updateStatus } from '../../commands/session'; export function connectionClosed({ reason, reasonStr, endTime }: Event_ConnectionClosed): void { let message: string; - // @TODO (5) if (reasonStr) { message = reasonStr; } else { diff --git a/webclient/src/websocket/events/session/serverIdentification.ts b/webclient/src/websocket/events/session/serverIdentification.ts index 6c7956f3f..57e999032 100644 --- a/webclient/src/websocket/events/session/serverIdentification.ts +++ b/webclient/src/websocket/events/session/serverIdentification.ts @@ -1,9 +1,9 @@ import type { Event_ServerIdentification } from '@app/generated'; import { WebClient } from '../../WebClient'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { StatusEnum } from '../../types/StatusEnum'; import { PROTOCOL_VERSION } from '../../config'; import { consumePendingOptions } from '../../utils/connectionState'; -import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; +import { WebSocketConnectReason } from '../../types/ConnectOptions'; import { generateSalt, passwordSaltSupported } from '../../utils'; import * as SessionCommands from '../../commands/session'; diff --git a/webclient/src/websocket/events/session/sessionEvents.spec.ts b/webclient/src/websocket/events/session/sessionEvents.spec.ts index c627a6856..7383a13d5 100644 --- a/webclient/src/websocket/events/session/sessionEvents.spec.ts +++ b/webclient/src/websocket/events/session/sessionEvents.spec.ts @@ -58,8 +58,8 @@ import * as Config from '../../config'; import * as SessionCmds from '../../commands/session'; import { consumePendingOptions } from '../../utils/connectionState'; import { passwordSaltSupported } from '../../utils'; -import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; -import { StatusEnum } from '../../interfaces/StatusEnum'; +import { WebSocketConnectReason } from '../../types/ConnectOptions'; +import { StatusEnum } from '../../types/StatusEnum'; import { Mock } from 'vitest'; import { gameJoined } from './gameJoined'; import { notifyUser } from './notifyUser'; @@ -78,9 +78,6 @@ import { serverIdentification } from './serverIdentification'; const ConfigMock = Config as { -readonly [K in keyof typeof Config]: (typeof Config)[K] }; -// ---------------------------------------------------------------- -// gameJoined -// ---------------------------------------------------------------- describe('gameJoined', () => { it('calls WebClient.instance.response.session.gameJoined', () => { @@ -90,9 +87,6 @@ describe('gameJoined', () => { }); }); -// ---------------------------------------------------------------- -// notifyUser -// ---------------------------------------------------------------- describe('notifyUser', () => { it('calls WebClient.instance.response.session.notifyUser', () => { @@ -102,9 +96,6 @@ describe('notifyUser', () => { }); }); -// ---------------------------------------------------------------- -// replayAdded -// ---------------------------------------------------------------- describe('replayAdded', () => { it('calls WebClient.instance.response.session.replayAdded with matchInfo', () => { @@ -116,9 +107,6 @@ describe('replayAdded', () => { }); }); -// ---------------------------------------------------------------- -// serverCompleteList -// ---------------------------------------------------------------- describe('serverCompleteList', () => { it('calls WebClient.instance.response.session.updateUsers and WebClient.instance.response.room.updateRooms', () => { @@ -129,9 +117,6 @@ describe('serverCompleteList', () => { }); }); -// ---------------------------------------------------------------- -// serverMessage -// ---------------------------------------------------------------- describe('serverMessage', () => { it('calls WebClient.instance.response.session.serverMessage with message', () => { @@ -140,9 +125,6 @@ describe('serverMessage', () => { }); }); -// ---------------------------------------------------------------- -// serverShutdown -// ---------------------------------------------------------------- describe('serverShutdown', () => { it('calls WebClient.instance.response.session.serverShutdown', () => { @@ -152,9 +134,6 @@ describe('serverShutdown', () => { }); }); -// ---------------------------------------------------------------- -// userJoined -// ---------------------------------------------------------------- describe('userJoined', () => { it('calls WebClient.instance.response.session.userJoined with userInfo', () => { @@ -166,9 +145,6 @@ describe('userJoined', () => { }); }); -// ---------------------------------------------------------------- -// userLeft -// ---------------------------------------------------------------- describe('userLeft', () => { it('calls WebClient.instance.response.session.userLeft with name', () => { @@ -177,9 +153,6 @@ describe('userLeft', () => { }); }); -// ---------------------------------------------------------------- -// userMessage -// ---------------------------------------------------------------- describe('userMessage', () => { it('calls WebClient.instance.response.session.userMessage', () => { @@ -189,9 +162,6 @@ describe('userMessage', () => { }); }); -// ---------------------------------------------------------------- -// addToList -// ---------------------------------------------------------------- describe('addToList', () => { let logSpy: ReturnType; beforeEach(() => { @@ -225,9 +195,6 @@ describe('addToList', () => { }); }); -// ---------------------------------------------------------------- -// removeFromList -// ---------------------------------------------------------------- describe('removeFromList', () => { it('buddy list → removeFromBuddyList', () => { @@ -248,9 +215,6 @@ describe('removeFromList', () => { }); }); -// ---------------------------------------------------------------- -// listRooms -// ---------------------------------------------------------------- describe('listRooms', () => { it('calls WebClient.instance.response.room.updateRooms', () => { @@ -279,9 +243,6 @@ describe('listRooms', () => { }); }); -// ---------------------------------------------------------------- -// connectionClosed -// ---------------------------------------------------------------- describe('connectionClosed', () => { it('uses reasonStr when provided', () => { @@ -371,9 +332,6 @@ describe('connectionClosed', () => { }); }); -// ---------------------------------------------------------------- -// serverIdentification -// ---------------------------------------------------------------- describe('serverIdentification', () => { const makeInfo = (overrides: Record = {}) => create(Event_ServerIdentificationSchema, { diff --git a/webclient/src/websocket/index.ts b/webclient/src/websocket/index.ts index 9d0cd9481..4afca0522 100644 --- a/webclient/src/websocket/index.ts +++ b/webclient/src/websocket/index.ts @@ -1,32 +1,10 @@ export * from './commands'; -export * from './interfaces'; export { WebClient } from './WebClient'; -export { StatusEnum } from './interfaces/StatusEnum'; -export type { WebClientConfig, ConnectTarget } from './interfaces/WebClientConfig'; -export type { - KeyOf, - GameEventMeta, - WebSocketSessionResponseOverrides, - WebSocketRoomResponseOverrides, -} from './interfaces/WebSocketConfig'; export { SessionEvents } from './events/session'; export { RoomEvents } from './events/room'; export { GameEvents } from './events/game'; export { generateSalt, passwordSaltSupported, hashPassword } from './utils'; - -export { WebSocketConnectReason } from './interfaces/ConnectOptions'; -export type { - LoginConnectOptions, - RegisterConnectOptions, - ActivateConnectOptions, - PasswordResetRequestConnectOptions, - PasswordResetChallengeConnectOptions, - PasswordResetConnectOptions, - TestConnectionOptions, - WebSocketConnectOptions, -} from './interfaces/ConnectOptions'; - export { setPendingOptions, consumePendingOptions } from './utils/connectionState'; diff --git a/webclient/src/websocket/interfaces/WebSocketConfig.ts b/webclient/src/websocket/interfaces/WebSocketConfig.ts deleted file mode 100644 index cc1dface5..000000000 --- a/webclient/src/websocket/interfaces/WebSocketConfig.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { - GameEventContext, - Response_Login, - Response, - Event_RoomSay, - ResponseMap, - RoomEventMap, -} from '@app/generated'; - -// ── KeyOf utility ──────────────────────────────────────────────────────────── -// Derives a type map key from a generated type. Allows interface methods to -// reference generated types instead of hardcoded string keys. -// -// T[KeyOf] -// ↓ resolves to ↓ -// T['Response_Login'] - -export type KeyOf = { [K in keyof Map]: Map[K] extends V ? K : never }[keyof Map]; - -// ── GameEventMeta ──────────────────────────────────────────────────────────── -// Per-container metadata passed to every game event handler alongside the -// event payload. Constructed by ProtobufService.processGameEvent from the -// GameEventContainer fields. Structurally identical to Enriched.GameEventMeta. - -export interface GameEventMeta { - gameId: number; - playerId: number; - context: GameEventContext | null; - secondsElapsed: number; - forcedByJudge: number; -} - -// ── Websocket-layer enrichments ────────────────────────────────────────────── -// Protocol-level enrichments of proto types — these are websocket concerns, -// not app concerns. Used as the DEFAULT generic on the response interfaces. - -export interface WebSocketSessionResponseOverrides extends ResponseMap { - Response_Login: Response_Login & { hashedPassword?: string }; - Response: Response & { host: string; port: string; userName: string }; -} - -export interface WebSocketRoomResponseOverrides extends RoomEventMap { - Event_RoomSay: Event_RoomSay & { timeReceived: number }; -} diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts index fe65593d3..44825a057 100644 --- a/webclient/src/websocket/services/ProtobufService.spec.ts +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -510,10 +510,6 @@ describe('ProtobufService', () => { }); -// ── Real protobuf round-trip test ───────────────────────────────────────────── -// This describe block does NOT mock @bufbuild/protobuf so it exercises real -// binary serialization. It proves that the schemas ProtobufService uses -// survive a toBinary → fromBinary cycle without data loss. describe('ProtobufService protobuf round-trip (real @bufbuild/protobuf)', () => { it('CommandContainer round-trips cmdId through toBinary → fromBinary', async () => { const { create, toBinary, fromBinary: realFromBinary } = diff --git a/webclient/src/websocket/services/ProtobufService.ts b/webclient/src/websocket/services/ProtobufService.ts index 2ffabacad..23ab52f06 100644 --- a/webclient/src/websocket/services/ProtobufService.ts +++ b/webclient/src/websocket/services/ProtobufService.ts @@ -26,7 +26,7 @@ import { import { GameEvents } from '../events/game'; import { RoomEvents } from '../events/room'; import { SessionEvents } from '../events/session'; -import type { GameEventMeta } from '../interfaces/WebSocketConfig'; +import type { GameEventMeta } from '../types/WebSocketConfig'; import { type CommandOptions, handleResponse } from './command-options'; export interface SocketTransport { diff --git a/webclient/src/websocket/services/WebSocketService.spec.ts b/webclient/src/websocket/services/WebSocketService.spec.ts index 553adbedf..83f1d6dc1 100644 --- a/webclient/src/websocket/services/WebSocketService.spec.ts +++ b/webclient/src/websocket/services/WebSocketService.spec.ts @@ -9,7 +9,7 @@ vi.mock('../config', () => ({ import { WebSocketService } from './WebSocketService'; import type { WebSocketServiceConfig } from './WebSocketService'; import { KeepAliveService } from './KeepAliveService'; -import { StatusEnum } from '../interfaces/StatusEnum'; +import { StatusEnum } from '../types/StatusEnum'; type WebSocketInternal = WebSocketService & { keepAliveService: KeepAliveService; diff --git a/webclient/src/websocket/services/WebSocketService.ts b/webclient/src/websocket/services/WebSocketService.ts index 32ebc5c33..b3e082c22 100644 --- a/webclient/src/websocket/services/WebSocketService.ts +++ b/webclient/src/websocket/services/WebSocketService.ts @@ -1,9 +1,9 @@ import { Subject } from 'rxjs'; -import { StatusEnum } from '../interfaces/StatusEnum'; +import { StatusEnum } from '../types/StatusEnum'; import { KeepAliveService } from './KeepAliveService'; import { CLIENT_OPTIONS } from '../config'; -import type { ConnectTarget } from '../interfaces/WebClientConfig'; +import type { ConnectTarget } from '../types/WebClientConfig'; export interface WebSocketServiceConfig { keepAliveFn: (pingReceived: () => void) => void; @@ -16,7 +16,7 @@ export class WebSocketService { private config: WebSocketServiceConfig; private keepAliveService: KeepAliveService; - private errorFired = false; + private hasReportedError = false; public message$: Subject = new Subject(); @@ -68,7 +68,7 @@ export class WebSocketService { socket.onopen = () => { clearTimeout(connectionTimer); - this.errorFired = false; + this.hasReportedError = false; this.config.onStatusChange(StatusEnum.CONNECTED, 'Connected'); this.keepAliveService.startPingLoop(this.keepalive, (pingReceived: () => void) => { @@ -77,16 +77,17 @@ export class WebSocketService { }; socket.onclose = () => { - // dont overwrite failure messages - if (!this.errorFired) { + // @critical onerror + onclose both fire on failed connects; don't overwrite the richer error status. + // See .github/instructions/webclient.instructions.md#websocket-lifecycle. + if (!this.hasReportedError) { this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Closed'); } - this.errorFired = false; + this.hasReportedError = false; this.keepAliveService.endPingLoop(); }; socket.onerror = () => { - this.errorFired = true; + this.hasReportedError = true; this.config.onStatusChange(StatusEnum.DISCONNECTED, 'Connection Failed'); this.config.onConnectionFailed(); }; diff --git a/webclient/src/websocket/services/command-options.spec.ts b/webclient/src/websocket/services/command-options.spec.ts index 19dd644be..a898dfb5e 100644 --- a/webclient/src/websocket/services/command-options.spec.ts +++ b/webclient/src/websocket/services/command-options.spec.ts @@ -10,12 +10,6 @@ import { create, getExtension } from '@bufbuild/protobuf'; import { handleResponse } from './command-options'; -// NOTE: do NOT call `vi.resetAllMocks()` here — under `isolate: false` it -// resets `vi.fn()` implementations set inside other files' `vi.mock(...)` -// factories, which breaks any spec that relied on those factory defaults -// (e.g. ProtobufService.spec.ts expects `hasExtension` to return `false`). -// The root `setupTests.ts` afterEach already calls `vi.clearAllMocks()`. - describe('handleResponse', () => { it('calls onResponse and returns early when provided', () => { const onResponse = vi.fn(); diff --git a/webclient/src/websocket/interfaces/ConnectOptions.ts b/webclient/src/websocket/types/ConnectOptions.ts similarity index 80% rename from webclient/src/websocket/interfaces/ConnectOptions.ts rename to webclient/src/websocket/types/ConnectOptions.ts index a74402fb4..5929363b4 100644 --- a/webclient/src/websocket/interfaces/ConnectOptions.ts +++ b/webclient/src/websocket/types/ConnectOptions.ts @@ -10,12 +10,6 @@ export enum WebSocketConnectReason { TEST_CONNECTION, } -// ── Connect options ─────────────────────────────────────────────────────────── -// Each variant is the enriched input for one session flow: the network -// transport fields (host/port) + the subset of proto Command_* fields the UI -// actually produces (user-entered credentials, tokens, email, etc.) + a -// `reason` discriminator so the websocket layer can route. - interface ConnectTransport extends ConnectTarget { keepalive?: number; autojoinrooms?: boolean; diff --git a/webclient/src/websocket/types/SignalContexts.ts b/webclient/src/websocket/types/SignalContexts.ts new file mode 100644 index 000000000..e66b659a2 --- /dev/null +++ b/webclient/src/websocket/types/SignalContexts.ts @@ -0,0 +1,17 @@ +/** + * Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the + * activation dialog can resubmit against the same host/user without re-entering them. + */ +export interface PendingActivationContext { + host: string; + port: string; + userName: string; +} + +/** + * Payload for the LOGIN_SUCCESSFUL signal. Only carries what the UI needs to + * persist into the selected host record (hashedPassword for "remember me"). + */ +export interface LoginSuccessContext { + hashedPassword?: string; +} diff --git a/webclient/src/websocket/interfaces/StatusEnum.ts b/webclient/src/websocket/types/StatusEnum.ts similarity index 100% rename from webclient/src/websocket/interfaces/StatusEnum.ts rename to webclient/src/websocket/types/StatusEnum.ts diff --git a/webclient/src/websocket/interfaces/WebClientConfig.ts b/webclient/src/websocket/types/WebClientConfig.ts similarity index 100% rename from webclient/src/websocket/interfaces/WebClientConfig.ts rename to webclient/src/websocket/types/WebClientConfig.ts diff --git a/webclient/src/websocket/interfaces/WebClientRequest.ts b/webclient/src/websocket/types/WebClientRequest.ts similarity index 96% rename from webclient/src/websocket/interfaces/WebClientRequest.ts rename to webclient/src/websocket/types/WebClientRequest.ts index 16db5fd08..5caf83fda 100644 --- a/webclient/src/websocket/interfaces/WebClientRequest.ts +++ b/webclient/src/websocket/types/WebClientRequest.ts @@ -39,7 +39,6 @@ import type { import type { ConnectTarget } from './WebClientConfig'; import type { KeyOf } from './WebSocketConfig'; -// ── Auth request type map ──────────────────────────────────────────────────── // Keys = generated *Params type names composed with ConnectTarget. // @app/api overrides these with Enriched connect option types. diff --git a/webclient/src/websocket/interfaces/WebClientResponse.ts b/webclient/src/websocket/types/WebClientResponse.ts similarity index 100% rename from webclient/src/websocket/interfaces/WebClientResponse.ts rename to webclient/src/websocket/types/WebClientResponse.ts diff --git a/webclient/src/websocket/types/WebSocketConfig.ts b/webclient/src/websocket/types/WebSocketConfig.ts new file mode 100644 index 000000000..8037dcf2d --- /dev/null +++ b/webclient/src/websocket/types/WebSocketConfig.ts @@ -0,0 +1,28 @@ +import type { + GameEventContext, + Response_Login, + Response, + Event_RoomSay, + ResponseMap, + RoomEventMap, +} from '@app/generated'; + +// `KeyOf` resolves to `'Response_Login'`. +export type KeyOf = { [K in keyof Map]: Map[K] extends V ? K : never }[keyof Map]; + +export interface GameEventMeta { + gameId: number; + playerId: number; + context: GameEventContext | null; + secondsElapsed: number; + forcedByJudge: number; +} + +export interface WebSocketSessionResponseOverrides extends ResponseMap { + Response_Login: Response_Login & { hashedPassword?: string }; + Response: Response & { host: string; port: string; userName: string }; +} + +export interface WebSocketRoomResponseOverrides extends RoomEventMap { + Event_RoomSay: Event_RoomSay & { timeReceived: number }; +} diff --git a/webclient/src/websocket/types/index.ts b/webclient/src/websocket/types/index.ts new file mode 100644 index 000000000..3e3c127cc --- /dev/null +++ b/webclient/src/websocket/types/index.ts @@ -0,0 +1 @@ +export * as WebsocketTypes from './namespace'; diff --git a/webclient/src/websocket/interfaces/index.ts b/webclient/src/websocket/types/namespace.ts similarity index 87% rename from webclient/src/websocket/interfaces/index.ts rename to webclient/src/websocket/types/namespace.ts index 6c4459c91..84ae11770 100644 --- a/webclient/src/websocket/interfaces/index.ts +++ b/webclient/src/websocket/types/namespace.ts @@ -21,3 +21,5 @@ export type { export * from './WebClientConfig'; export * from './WebSocketConfig'; export * from './StatusEnum'; +export * from './ConnectOptions'; +export * from './SignalContexts'; diff --git a/webclient/src/websocket/utils/connectionState.ts b/webclient/src/websocket/utils/connectionState.ts index de4866462..02d0d7002 100644 --- a/webclient/src/websocket/utils/connectionState.ts +++ b/webclient/src/websocket/utils/connectionState.ts @@ -1,4 +1,4 @@ -import type { WebSocketConnectOptions } from '../interfaces/ConnectOptions'; +import type { WebSocketConnectOptions } from '../types/ConnectOptions'; let pendingOptions: WebSocketConnectOptions | null = null; diff --git a/webclient/src/websocket/utils/passwordHasher.ts b/webclient/src/websocket/utils/passwordHasher.ts index 6a230d9ea..e89302244 100644 --- a/webclient/src/websocket/utils/passwordHasher.ts +++ b/webclient/src/websocket/utils/passwordHasher.ts @@ -8,7 +8,6 @@ const SALT_LENGTH = 16; export const hashPassword = (salt: string, password: string): string => { let hashedPassword = salt + password; for (let i = 0; i < HASH_ROUNDS; i++) { - // WHY DO WE DO IT THIS WAY? hashedPassword = sha512(hashedPassword); } @@ -27,6 +26,6 @@ export const generateSalt = (): string => { } export const passwordSaltSupported = (serverOptions: number): number => { - // Intentional use of Bitwise operator b/c of how Servatrice Enums work + // @critical Servatrice ServerOptions is a bitmask. See .github/instructions/webclient.instructions.md#protocol-quirks. return serverOptions & Event_ServerIdentification_ServerOptions.SupportsPasswordHash; } diff --git a/webclient/tsconfig.json b/webclient/tsconfig.json index 1e851244e..b070278d5 100644 --- a/webclient/tsconfig.json +++ b/webclient/tsconfig.json @@ -35,6 +35,7 @@ "@app/store": ["./src/store/index.ts"], "@app/types": ["./src/types/index.ts"], "@app/websocket": ["./src/websocket/index.ts"], + "@app/websocket/types": ["./src/websocket/types/index.ts"], "@app/generated": ["./src/generated/index.ts"] } }, diff --git a/webclient/vitest.integration.config.ts b/webclient/vitest.integration.config.ts index 4308e2ccf..2ceabd4d8 100644 --- a/webclient/vitest.integration.config.ts +++ b/webclient/vitest.integration.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from 'vitest/config'; // Integration tests exercise the full inbound/outbound webclient pipeline // (ProtobufService → event handlers → persistence → Redux) with only the -// browser WebSocket constructor mocked. They live in `integration/` and run -// under their own config so they can use `isolate: true` without slowing down -// the unit suite (which relies on `isolate: false` for shared vi.mock state). +// browser WebSocket constructor mocked. They live in `integration/` with +// their own config so the include glob and longer testTimeout stay scoped +// to this suite; both suites run `isolate: true`. export default defineConfig({ plugins: [react()], resolve: {