Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions apps/client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {

import { App } from './App';
import type { LobbyClient } from './lobby-client';
import { resetPerformanceDiagnosticsForTests } from './performance-diagnostics';
import type { LobbySessionStore, StoredLobbySession } from './session-store';

const controllerPlayer: PlayerState = {
Expand Down Expand Up @@ -352,6 +353,7 @@ async function chooseTap(user: ReturnType<typeof userEvent.setup>) {
describe('Stage 4B display and player room routes', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/');
resetPerformanceDiagnosticsForTests();
});

it('automatically creates one passive display room at the root', async () => {
Expand Down Expand Up @@ -595,6 +597,69 @@ describe('Stage 4B display and player room routes', () => {
expect(client.createDisplay).not.toHaveBeenCalled();
});

it('keeps opt-in diagnostics through join navigation and hides them otherwise', async () => {
const user = userEvent.setup();
const stopDiagnostics = vi.fn();
const enableDiagnostics = vi.fn(() => stopDiagnostics);
const client = createFakeClient({
joinPlayer: vi.fn(
async (): Promise<PlayerActionResponse> => controllerSuccess,
),
enablePerformanceDiagnostics: enableDiagnostics,
getPerformanceDiagnostics: () => ({
connected: true,
transport: 'websocket',
connections: 1,
reconnects: 0,
connectionStatusTransitions: 1,
transportUpgrades: 1,
roomStatesReceived: 1,
roomErrorsReceived: 0,
enginePacketsReceived: 3,
enginePacketsSent: 2,
}),
});
window.history.replaceState({}, '', '/join/ABC234?perf=1');

const view = render(
<App client={client} sessionStore={createFakeSessionStore()} />,
);
await user.type(
screen.getByRole('textbox', { name: 'Display name' }),
'Silver Owl',
);
await user.click(screen.getByRole('button', { name: 'Join Room' }));

expect(
await screen.findByRole('heading', { name: 'Performance Diagnostics' }),
).toBeVisible();
expect(enableDiagnostics).toHaveBeenCalledTimes(1);
expect(window.location.pathname).toBe('/room/ABC234');
expect(window.location.search).toBe('');
view.unmount();
expect(stopDiagnostics).toHaveBeenCalledTimes(1);

resetPerformanceDiagnosticsForTests();
window.history.replaceState({}, '', '/join/ABC234');
const normalClient = createFakeClient({
joinPlayer: vi.fn(
async (): Promise<PlayerActionResponse> => controllerSuccess,
),
});
render(
<App client={normalClient} sessionStore={createFakeSessionStore()} />,
);
await user.type(
screen.getByRole('textbox', { name: 'Display name' }),
'Silver Owl',
);
await user.click(screen.getByRole('button', { name: 'Join Room' }));
await screen.findByRole('region', { name: 'Puzzle' });
expect(
screen.queryByRole('heading', { name: 'Performance Diagnostics' }),
).toBeNull();
});

it('shows the first joining phone player as the game host', async () => {
const user = userEvent.setup();
const client = createFakeClient({
Expand Down Expand Up @@ -2191,6 +2256,67 @@ describe('Stage 4B display and player room routes', () => {
expect(commits).toBe(commitsBeforeDuplicate);
});

it('counts accepted, duplicate, and rejected snapshots without changing acceptance', async () => {
let reportRoomState: ((room: RoomState) => void) | undefined;
window.history.replaceState({}, '', '/room/ABC234?perf=1');
const client = createFakeClient({
reconnectPlayer: vi.fn(
async (): Promise<PlayerActionResponse> => controllerWithPlayersSuccess,
),
onRoomState: (listener) => {
reportRoomState = listener;
return () => undefined;
},
enablePerformanceDiagnostics: () => () => undefined,
});
render(
<App
routePath="/room/ABC234"
client={client}
sessionStore={createFakeSessionStore({
role: 'player',
roomCode: 'ABC234',
playerId: controllerPlayer.id,
playerReconnectToken: 'r'.repeat(43),
displayName: controllerPlayer.displayName,
})}
/>,
);
await screen.findByRole('button', { name: 'Start Round' });

act(() => reportRoomState?.(controllerWithPlayersSuccess.room));
act(() =>
reportRoomState?.({
...controllerWithPlayersSuccess.room,
stateVersion: 0,
}),
);
act(() =>
reportRoomState?.({
...controllerWithPlayersSuccess.room,
stateVersion: 2,
settings: {
...controllerWithPlayersSuccess.room.settings,
gridSize: 6,
},
}),
);
await userEvent.click(
screen.getByRole('button', { name: 'Refresh Diagnostics' }),
);

const snapshot = screen.getByLabelText('Diagnostic snapshot');
expect(snapshot).toHaveTextContent('Room states accepted: 2');
expect(snapshot).toHaveTextContent('Duplicates ignored: 1');
expect(snapshot).toHaveTextContent(
'Stale/conflicting snapshots rejected: 1',
);
expect(screen.getByRole('button', { name: '6 × 6' })).toHaveAttribute(
'aria-pressed',
'true',
);
});

it('still accepts a meaningful same-version snapshot with the same server time', async () => {
let reportRoomState: ((room: RoomState) => void) | undefined;
const client = createFakeClient({
Expand Down
34 changes: 34 additions & 0 deletions apps/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { AppShell } from './components/AppShell';
import { JoinRoomForm } from './components/JoinRoomForm';
import { LobbyError } from './components/LobbyError';
import { NotFound } from './components/NotFound';
import { PerformanceDiagnostics } from './components/PerformanceDiagnostics';
import { PlayerPrototype } from './components/PlayerPrototype';
import { RoomLobby } from './components/RoomLobby';
import { useDisplayAudio } from './hooks/useDisplayAudio';
Expand All @@ -34,6 +35,10 @@ import {
type LobbySessionStore,
type StoredLobbySession,
} from './session-store';
import {
incrementPerformanceCounter,
initializePerformanceDiagnostics,
} from './performance-diagnostics';

type AppProps = {
routePath?: string;
Expand Down Expand Up @@ -285,6 +290,8 @@ export function App({
client = defaultLobbyClient,
sessionStore = defaultSessionStore,
}: AppProps) {
const diagnosticsEnabled = initializePerformanceDiagnostics();
incrementPerformanceCounter('appRenders');
const [currentPath, setCurrentPath] = useState(
canonicalPath(routePath ?? window.location.pathname),
);
Expand All @@ -311,6 +318,13 @@ export function App({
const displayRecoveryTimerRef = useRef<number | null>(null);
const attemptedRoomCodeRef = useRef<string | null>(null);

useEffect(() => {
if (!diagnosticsEnabled) {
return;
}
return client.enablePerformanceDiagnostics?.();
}, [client, diagnosticsEnabled]);

const cancelDisplayRecovery = useCallback(() => {
if (displayRecoveryTimerRef.current !== null) {
window.clearTimeout(displayRecoveryTimerRef.current);
Expand All @@ -332,6 +346,7 @@ export function App({
const currentRoom = roomRef.current;
if (currentRoom?.code === nextRoom.code) {
if (nextRoom.stateVersion < currentRoom.stateVersion) {
incrementPerformanceCounter('roomSnapshotsRejected');
return;
}
if (nextRoom.stateVersion === currentRoom.stateVersion) {
Expand All @@ -341,6 +356,7 @@ export function App({
nextServerTime < currentServerTime ||
!finalizedResultsMatch(currentRoom, nextRoom)
) {
incrementPerformanceCounter('roomSnapshotsRejected');
return;
}
// Action acknowledgements can be followed by the exact same
Expand All @@ -350,13 +366,15 @@ export function App({
nextServerTime === currentServerTime &&
roomSnapshotsMatch(currentRoom, nextRoom)
) {
incrementPerformanceCounter('roomSnapshotDuplicatesIgnored');
return;
}
}
}

const renderRoom = shareStableRoundReferences(currentRoom, nextRoom);
roomRef.current = renderRoom;
incrementPerformanceCounter('roomSnapshotsAccepted');
setRoom(renderRoom);

if (currentSession.role === 'player' && nextRoom.round) {
Expand Down Expand Up @@ -402,6 +420,7 @@ export function App({
sessionRef.current = nextSession;
setSession(nextSession);
roomRef.current = nextRoom;
incrementPerformanceCounter('roomSnapshotsAccepted');
setRoom(nextRoom);
if (nextSession.role === 'player' && nextRoom.round) {
const isParticipant = nextRoom.round.participants.some(
Expand Down Expand Up @@ -985,6 +1004,19 @@ export function App({
let pageClassName: string;
const routeRoomCode = roomCodeFromPath(currentPath);
const routeJoinRoomCode = joinRoomCodeFromPath(currentPath);
const diagnosticsPanel =
diagnosticsEnabled && room && session ? (
<PerformanceDiagnostics
role={session.role}
phase={room.phase}
isController={
session.role === 'player' &&
room.controllerPlayerId === session.playerId
}
connectionStatus={connectionStatus}
client={client}
/>
) : null;

if (currentPath === '/') {
if (room && session?.role === 'display') {
Expand All @@ -1004,6 +1036,7 @@ export function App({
submissionState={null}
onSubmitWord={submitWord}
/>
{diagnosticsPanel}
</>
);
} else if (displayStarting || !roomError) {
Expand Down Expand Up @@ -1070,6 +1103,7 @@ export function App({
submissionState={submissionState}
onSubmitWord={submitWord}
/>
{diagnosticsPanel}
</>
);
} else if (reconnecting) {
Expand Down
2 changes: 2 additions & 0 deletions apps/client/src/components/LetterGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from 'react';

import type { WordEntryMode } from '../utils/word-entry';
import { incrementPerformanceCounter } from '../performance-diagnostics';
import {
resolveTraceSegment,
type TracePoint,
Expand Down Expand Up @@ -52,6 +53,7 @@ export const LetterGrid = memo(function LetterGrid({
onTraceEnd,
onTraceCancel,
}: LetterGridProps) {
incrementPerformanceCounter('letterGridRenders');
const gridRef = useRef<HTMLDivElement>(null);
const tileElementsRef = useRef<Array<HTMLElement | null>>([]);
const activePointerIdRef = useRef<number | null>(null);
Expand Down
73 changes: 73 additions & 0 deletions apps/client/src/components/PerformanceDiagnostics.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { LobbyClient } from '../lobby-client';
import {
enablePerformanceDiagnosticsForTests,
incrementPerformanceCounter,
resetPerformanceDiagnosticsForTests,
} from '../performance-diagnostics';
import { PerformanceDiagnostics } from './PerformanceDiagnostics';

let now: number;

beforeEach(() => {
now = 1_000;
vi.spyOn(performance, 'now').mockImplementation(() => now);
enablePerformanceDiagnosticsForTests(now);
});

afterEach(() => {
resetPerformanceDiagnosticsForTests();
vi.restoreAllMocks();
});

describe('PerformanceDiagnostics', () => {
it('stays static until explicitly refreshed and exposes no sensitive data', () => {
const interval = vi.spyOn(window, 'setInterval');
const animationFrame = vi.spyOn(window, 'requestAnimationFrame');
const client = {
getPerformanceDiagnostics: () => ({
connected: true,
transport: 'polling',
connections: 1,
reconnects: 0,
connectionStatusTransitions: 1,
transportUpgrades: 0,
roomStatesReceived: 2,
roomErrorsReceived: 0,
enginePacketsReceived: 7,
enginePacketsSent: 4,
}),
} as LobbyClient;
incrementPerformanceCounter('appRenders');

render(
<PerformanceDiagnostics
role="player"
phase="LOBBY"
isController
connectionStatus="connected"
client={client}
/>,
);

const snapshot = screen.getByLabelText('Diagnostic snapshot');
expect(snapshot).toHaveTextContent('Elapsed: 0m 00s');
expect(snapshot).toHaveTextContent('Transport: polling');
expect(snapshot).toHaveTextContent('Controller: Yes');
expect(snapshot).not.toHaveTextContent('ABC234');
expect(snapshot).not.toHaveTextContent('token');

now += 600_000;
incrementPerformanceCounter('roomLobbyRenders');
expect(snapshot).toHaveTextContent('Elapsed: 0m 00s');
fireEvent.click(
screen.getByRole('button', { name: 'Refresh Diagnostics' }),
);
expect(snapshot).toHaveTextContent('Elapsed: 10m 00s');
expect(snapshot).toHaveTextContent('RoomLobby renders: 1');
expect(interval).not.toHaveBeenCalled();
expect(animationFrame).not.toHaveBeenCalled();
});
});
Loading