Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .github/workflows/deploy-production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ jobs:

- run: pnpm lint

- run: pnpm test:unit

- run: pnpm test:worker

- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium

- run: pnpm test:e2e

- name: Deploy Production
uses: cloudflare/wrangler-action@v3
with:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/deploy-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ jobs:

- run: pnpm lint

- run: pnpm test:unit

- run: pnpm test:worker

- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium

- run: pnpm test:e2e

- name: Deploy Staging
uses: cloudflare/wrangler-action@v3
with:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ dist-ssr
.env.local.development
*.wrangler
.env

# Test output
playwright-report
test-results
25 changes: 16 additions & 9 deletions apps/front/src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import * as React from 'react'
import { BrowserRouter } from 'react-router-dom'
import { getPathLanguage } from '../translations'
import { randomName } from '../utils/name'
import { Language } from './Language'
import { PlayerIdentityGate } from './PlayerIdentityGate'
import { PlayerIdentityTransfer } from './PlayerIdentityTransfer'
import { Router } from './Router'
import { MainContent, SideBarContainer, SideBarLayout } from './SideBar'
import {
MainContent,
SideBarActions,
SideBarContainer,
SideBarLayout
} from './SideBar'
import { Theme } from './Theme'

export function App() {
const mainContentRef = React.useRef<React.ElementRef<'div'>>(null)

React.useEffect(() => {
if (localStorage.getItem('playerId') === null) {
localStorage.setItem('playerId', randomName())
}
}, [])

return (
<BrowserRouter basename={getPathLanguage()}>
<div className='bg-slate-50 text-slate-900 transition-colors duration-150 ease-in-out dark:bg-slate-900 dark:text-slate-200'>
Expand All @@ -31,7 +31,14 @@ export function App() {
/>

<MainContent ref={mainContentRef}>
<Router />
<PlayerIdentityGate>
<>
<SideBarActions>
<PlayerIdentityTransfer />
</SideBarActions>
<Router />
</>
</PlayerIdentityGate>
</MainContent>
</SideBarLayout>
</div>
Expand Down
137 changes: 137 additions & 0 deletions apps/front/src/components/GameContext/useGameSetup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as React from 'react'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
GameState,
Player,
toGameStateMessage,
type IGameState
} from '@knucklebones/common'
import { act, renderHook, waitFor } from '@testing-library/react'
import { createWebSocketTicket, initGame, play } from '../../utils/api'
import { useGameSetup } from './useGameSetup'

const socket = vi.hoisted(() => ({
lastJsonMessage: null as unknown,
readyState: 0
}))

vi.mock('react-use-websocket', () => ({
default: () => socket,
ReadyState: { CLOSED: 3, OPEN: 1 }
}))
vi.mock('../../hooks/useRoomKey', () => ({
useRoomKey: () => 'room-one'
}))
vi.mock('../../utils/api', () => ({
createWebSocketTicket: vi.fn(),
deleteDisplayName: vi.fn(),
initGame: vi.fn(),
play: vi.fn(),
updateDisplayName: vi.fn(),
voteRematch: vi.fn()
}))

const playerId = '22222222-2222-4222-8222-222222222222'

function wrapper({ children }: React.PropsWithChildren) {
return <MemoryRouter>{children}</MemoryRouter>
}

function createGameState(
revision: number,
displayName = 'Current Name'
): IGameState {
const playerOne = new Player(playerId, displayName, undefined, 4)
const playerTwo = new Player('player-two', 'Player Two')

return new GameState({
revision,
playerOne,
playerTwo,
nextPlayer: playerOne,
outcome: 'ongoing',
boType: 1
}).toJson()
}

function emitMessage(message: unknown, rerender: () => void) {
act(() => {
socket.lastJsonMessage = message
rerender()
})
}

describe('useGameSetup', () => {
beforeEach(() => {
socket.lastJsonMessage = null
socket.readyState = 0
localStorage.setItem('playerId', playerId)
vi.mocked(createWebSocketTicket).mockReset()
vi.mocked(initGame).mockReset().mockResolvedValue(undefined)
vi.mocked(play).mockReset().mockResolvedValue(undefined)
})

it('ignores stale, foreign-room, and malformed state messages', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { rerender, result } = renderHook(() => useGameSetup(), { wrapper })

emitMessage(toGameStateMessage(createGameState(3), 'room-one'), rerender)
await waitFor(() => expect(result.current?.revision).toBe(3))

emitMessage(
toGameStateMessage(createGameState(2, 'Stale Name'), 'room-one'),
rerender
)
expect(result.current?.revision).toBe(3)
expect(result.current?.playerOne.displayName).toBe('Current Name')

emitMessage(
toGameStateMessage(createGameState(4, 'Foreign Name'), 'room-two'),
rerender
)
expect(result.current?.revision).toBe(3)

emitMessage({ type: 'game.state', version: 1 }, rerender)
expect(result.current?.revision).toBe(3)
expect(consoleError).toHaveBeenCalledWith(
'Ignored an invalid game-state message.'
)
})

it('reinitializes the room whenever the socket reconnects', async () => {
const { rerender } = renderHook(() => useGameSetup(), { wrapper })
expect(initGame).not.toHaveBeenCalled()

act(() => {
socket.readyState = 1
rerender()
})
await waitFor(() => expect(initGame).toHaveBeenCalledTimes(1))

act(() => {
socket.readyState = 3
rerender()
})
act(() => {
socket.readyState = 1
rerender()
})
await waitFor(() => expect(initGame).toHaveBeenCalledTimes(2))
})

it('rolls an optimistic move back after a network failure', async () => {
vi.mocked(play).mockRejectedValueOnce(new Error('network unavailable'))
const { rerender, result } = renderHook(() => useGameSetup(), { wrapper })
emitMessage(toGameStateMessage(createGameState(1), 'room-one'), rerender)
await waitFor(() => expect(result.current).not.toBeNull())

await act(async () => {
await result.current?.sendPlay(0)
})

expect(result.current?.playerOne.columns).toEqual([[], [], []])
expect(result.current?.errorMessage).toBe('network unavailable')
expect(result.current?.isLoading).toBe(false)
})
})
63 changes: 56 additions & 7 deletions apps/front/src/components/GameContext/useGameSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import * as React from 'react'
import { useLocation } from 'react-router-dom'
import useWebSocketImport, { ReadyState } from 'react-use-websocket'
import {
AI_PLAYER_ID,
compatibleGameStateMessageSchema,
GameState,
type IGameState,
isEmptyOrBlank,
type GameSettings
} from '@knucklebones/common'
import { useRoomKey } from '../../hooks/useRoomKey'
import {
createWebSocketTicket,
deleteDisplayName,
updateDisplayName,
initGame,
Expand All @@ -32,12 +35,19 @@ export function useGameSetup() {
const [isLoading, setIsLoading] = React.useState(true)
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
const roomKey = useRoomKey()
const latestRevision = React.useRef({ roomKey, value: -1 })
const state = useLocation().state as GameSettings | undefined
const { lastJsonMessage, readyState } = useWebSocket(getWebSocketUrl(roomKey))
const playerId = localStorage.getItem('playerId')!
const getAuthenticatedWebSocketUrl = React.useCallback(async () => {
const { ticket } = await createWebSocketTicket({ roomKey, playerId })
return getWebSocketUrl(roomKey, ticket)
}, [playerId, roomKey])
const { lastJsonMessage, readyState } = useWebSocket(
getAuthenticatedWebSocketUrl
)

const isGameStateReady = gameState !== null

const playerId = localStorage.getItem('playerId')!
const playerSide = isGameStateReady
? getPlayerSide(playerId, gameState)
: 'spectator'
Expand All @@ -52,13 +62,52 @@ export function useGameSetup() {

React.useEffect(() => {
if (lastJsonMessage !== null) {
// Can use Zod to parse the message safely
const gameState = lastJsonMessage as IGameState
setGameState(gameState)
const parsedGameState =
compatibleGameStateMessageSchema.safeParse(lastJsonMessage)

if (!parsedGameState.success) {
console.error('Ignored an invalid game-state message.')
return
}

const hasRevision =
typeof lastJsonMessage === 'object' &&
lastJsonMessage !== null &&
'revision' in lastJsonMessage

const messageRoomKey =
typeof lastJsonMessage === 'object' &&
lastJsonMessage !== null &&
'roomKey' in lastJsonMessage &&
typeof lastJsonMessage.roomKey === 'string'
? lastJsonMessage.roomKey
: undefined

if (messageRoomKey !== undefined && messageRoomKey !== roomKey) {
return
}

const latestRoomRevision =
latestRevision.current.roomKey === roomKey
? latestRevision.current.value
: -1

if (hasRevision && parsedGameState.data.revision <= latestRoomRevision) {
return
}

if (hasRevision) {
latestRevision.current = {
roomKey,
value: parsedGameState.data.revision
}
}

setGameState(parsedGameState.data)
setIsLoading(false)
setErrorMessage(null)
}
}, [lastJsonMessage])
}, [lastJsonMessage, roomKey])

React.useEffect(() => {
if (readyState === ReadyState.OPEN) {
Expand All @@ -70,7 +119,7 @@ export function useGameSetup() {
// À déplacer côté serveur
if (state?.playerType === 'ai') {
await initGame(
{ roomKey, playerId: 'beep-boop' },
{ roomKey, playerId: AI_PLAYER_ID },
{
playerType: 'ai',
difficulty: state?.difficulty,
Expand Down
4 changes: 2 additions & 2 deletions apps/front/src/components/GameContext/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function preparePlayers(
]
}

export function getWebSocketUrl(roomKey: string) {
export function getWebSocketUrl(roomKey: string, ticket: string) {
let hostname = import.meta.env.VITE_WORKER_URL

if (hostname.startsWith('http://')) {
Expand All @@ -23,5 +23,5 @@ export function getWebSocketUrl(roomKey: string) {
hostname = hostname.replace('https', 'wss')
}

return `${hostname}/${roomKey}/websocket`
return `${hostname}/${roomKey}/websocket?ticket=${ticket}`
}
38 changes: 38 additions & 0 deletions apps/front/src/components/PlayerBoard/Name.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Name } from './Name'

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))

describe('Name', () => {
it('generates a friendly replacement instead of exposing the UUID', async () => {
const updateDisplayName = vi.fn()
const playerId = '22222222-2222-4222-8222-222222222222'

render(
<Name
id={playerId}
displayName='Custom Name'
isPlayerOne
isEditable
updateDisplayName={updateDisplayName}
/>
)

await userEvent.click(screen.getByRole('button'))
const input = screen.getByRole('textbox')
await userEvent.clear(input)
await userEvent.keyboard('{Enter}')

expect(updateDisplayName).toHaveBeenCalledOnce()
const generatedName = updateDisplayName.mock.calls[0][0] as string
expect(generatedName).not.toBe(playerId)
expect(generatedName).toMatch(/^[A-Z][A-Za-z]+$/)
expect(localStorage.getItem('displayName')).toBe(generatedName)
expect(screen.getByText(new RegExp(generatedName))).toBeInTheDocument()
})
})
Loading
Loading