From 3dba6c96c3accb03c3c175a00bb7f48a5f16f942 Mon Sep 17 00:00:00 2001 From: Reidar Date: Mon, 24 Aug 2026 06:36:24 +0200 Subject: [PATCH] fix: forward-port preserved RFMS hardening --- docs/TEST_MATRIX.md | 2 + playwright.config.ts | 27 +++- server/src/__tests__/adapter-health.test.ts | 31 +++++ server/src/__tests__/bridge-server.test.ts | 75 ++++++++-- .../src/aircraft-adapters/adapter-health.ts | 49 +++++-- server/src/bridge-server.ts | 2 +- shared/src/__tests__/lskDispatcher.test.ts | 6 + shared/src/__tests__/rendererGrammar.test.ts | 34 +++++ shared/src/__tests__/routeActions.test.ts | 38 +++++ shared/src/fmc/actionHandlers/routeActions.ts | 31 +++++ shared/src/fmc/pages/boeing/route.grid.ts | 2 +- src/components/ConnectionStatus.tsx | 13 +- .../__tests__/ConnectionStatus.test.tsx | 75 ++++++++++ src/hooks/__tests__/useWakeLock.test.tsx | 130 ++++++++++++++++++ src/hooks/useWakeLock.ts | 123 ++++++++++++----- src/hooks/useWebSocket.ts | 9 +- 16 files changed, 582 insertions(+), 65 deletions(-) create mode 100644 src/components/__tests__/ConnectionStatus.test.tsx create mode 100644 src/hooks/__tests__/useWakeLock.test.tsx diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 93f353e..41dac9d 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -2,6 +2,8 @@ Status source: `docs/STATUS.md`. +Playwright starts an isolated strict-port RFMS dev server on `127.0.0.1:5174` by default. Set `PLAYWRIGHT_PORT` to another valid TCP port (1-65535), or use `PLAYWRIGHT_BASE_URL` only for intentional external-server validation. + ## Automated Gates | Gate | Command | Current status | Required status | diff --git a/playwright.config.ts b/playwright.config.ts index 338bbeb..9cdc5c1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,18 @@ import { defineConfig, devices } from '@playwright/test'; +const playwrightPort = process.env.PLAYWRIGHT_PORT ?? '5174'; +const parsedPlaywrightPort = Number(playwrightPort); +if ( + !/^\d+$/.test(playwrightPort) || + !Number.isInteger(parsedPlaywrightPort) || + parsedPlaywrightPort < 1 || + parsedPlaywrightPort > 65535 +) { + throw new Error(`Invalid PLAYWRIGHT_PORT: ${playwrightPort}`); +} +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${parsedPlaywrightPort}`; +const useExternalServer = Boolean(process.env.PLAYWRIGHT_BASE_URL); + export default defineConfig({ testDir: './e2e', snapshotPathTemplate: '{testDir}/{testFilePath}-snapshots/{arg}-{projectName}{ext}', @@ -9,7 +22,7 @@ export default defineConfig({ workers: process.env.CI ? '50%' : undefined, reporter: 'html', use: { - baseURL: 'http://localhost:5173', + baseURL, trace: 'on-first-retry', }, projects: [ @@ -46,9 +59,11 @@ export default defineConfig({ use: { ...devices['iPhone 14'] }, }, ], - webServer: { - command: 'npm run dev', - url: 'http://localhost:5173', - reuseExistingServer: !process.env.CI, - }, + webServer: useExternalServer + ? undefined + : { + command: `npm run dev -- --host 127.0.0.1 --port ${parsedPlaywrightPort} --strictPort`, + url: baseURL, + reuseExistingServer: process.env.PLAYWRIGHT_REUSE_SERVER === '1', + }, }); diff --git a/server/src/__tests__/adapter-health.test.ts b/server/src/__tests__/adapter-health.test.ts index fea5a1d..fa12973 100644 --- a/server/src/__tests__/adapter-health.test.ts +++ b/server/src/__tests__/adapter-health.test.ts @@ -2,6 +2,15 @@ import { describe, expect, it } from 'vitest'; import { getAdapterHealth, toAdapterCapabilities } from '../aircraft-adapters/adapter-health'; import { MockSimConnectAdapter } from '../aircraft-adapters/mock-simconnect'; +class CapabilityAdapter extends MockSimConnectAdapter { + readonly capabilities: string[]; + + constructor(capabilities: string[]) { + super(); + this.capabilities = capabilities; + } +} + describe('adapter health contract', () => { it('maps legacy string capabilities to structured production capabilities', () => { const adapter = new MockSimConnectAdapter(); @@ -16,6 +25,28 @@ describe('adapter health contract', () => { ); }); + it('normalizes real adapter capability aliases before mapping them', () => { + const adapter = new CapabilityAdapter([ + ' Display ', + 'POSITION', + 'heading', + 'speed', + 'altitude', + 'radios', + 'flightPlan', + 'AIRAC', + 'playback', + '', + ]); + + expect(toAdapterCapabilities(adapter)).toEqual({ + instruments: ['CDU', 'ND'], + commands: ['keyPress', 'lskPress'], + data: ['display', 'telemetry', 'flightPlan', 'navCycle', 'adapterVersion'], + replay: true, + }); + }); + it('reports profile-bound health without claiming live validation', async () => { const adapter = new MockSimConnectAdapter(); await adapter.connect(); diff --git a/server/src/__tests__/bridge-server.test.ts b/server/src/__tests__/bridge-server.test.ts index 4855173..de10a5c 100644 --- a/server/src/__tests__/bridge-server.test.ts +++ b/server/src/__tests__/bridge-server.test.ts @@ -1,10 +1,11 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; import type { ServerMessage } from '@virtual-cdu/shared'; import { createBridgeServer, type BridgeServer } from '../bridge-server'; import { MockSimConnectAdapter } from '../aircraft-adapters/mock-simconnect'; let bridge: BridgeServer | null = null; +const originalAuthToken = process.env.AUTH_TOKEN; class MessageCollector { private messages: ServerMessage[] = []; @@ -44,12 +45,54 @@ function waitOpen(ws: WebSocket): Promise { }); } +function waitRejectedUpgrade(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error('Timed out waiting for rejected upgrade')); + }, 2000); + + function cleanup() { + clearTimeout(timer); + ws.off('open', handleOpen); + ws.off('unexpected-response', handleUnexpectedResponse); + ws.off('error', handleError); + } + + function handleOpen() { + cleanup(); + reject(new Error('WebSocket unexpectedly opened')); + } + + function handleUnexpectedResponse(_request: unknown, response: { statusCode?: number; resume: () => void }) { + cleanup(); + response.resume(); + resolve(response.statusCode ?? 0); + } + + function handleError(error: Error) { + cleanup(); + reject(error); + } + + ws.once('open', handleOpen); + ws.once('unexpected-response', handleUnexpectedResponse); + ws.once('error', handleError); + }); +} + describe('bridge server', () => { + beforeEach(() => { + delete process.env.AUTH_TOKEN; + }); + afterEach(async () => { if (bridge) { await bridge.stop(); bridge = null; } + if (originalAuthToken === undefined) delete process.env.AUTH_TOKEN; + else process.env.AUTH_TOKEN = originalAuthToken; }); it('connects through the mock adapter and broadcasts CONTROL-mode display data', async () => { @@ -170,17 +213,27 @@ describe('bridge server', () => { headers: { Origin: 'https://evil.example.test' }, }); - await expect( - new Promise((resolve, reject) => { - ws.once('open', () => resolve()); - ws.once('unexpected-response', (_request, response) => { - reject(new Error(`unexpected-response:${response.statusCode}`)); - }); - ws.once('error', reject); - }), - ).rejects.toThrow('unexpected-response:403'); + await expect(waitRejectedUpgrade(ws)).resolves.toBe(403); + }); - ws.close(); + it('rejects clients with an invalid AUTH_TOKEN using an HTTP status code', async () => { + process.env.AUTH_TOKEN = 'expected-token'; + bridge = createBridgeServer({ aircraft: new MockSimConnectAdapter() }); + const port = await bridge.start(); + const ws = new WebSocket(`ws://127.0.0.1:${port}?token=wrong-token`); + + await expect(waitRejectedUpgrade(ws)).resolves.toBe(401); + }); + + it('keeps adapter capabilities off the unauthenticated health endpoint', async () => { + bridge = createBridgeServer({ aircraft: new MockSimConnectAdapter() }); + const port = await bridge.start(); + + const response = await fetch(`http://127.0.0.1:${port}/health`); + const health = await response.json(); + + expect(health).not.toHaveProperty('capabilities'); + expect(health).not.toHaveProperty('structuredCapabilities'); }); it('sets baseline security headers on HTTP responses', async () => { diff --git a/server/src/aircraft-adapters/adapter-health.ts b/server/src/aircraft-adapters/adapter-health.ts index 1aaea6b..d18baf9 100644 --- a/server/src/aircraft-adapters/adapter-health.ts +++ b/server/src/aircraft-adapters/adapter-health.ts @@ -1,17 +1,50 @@ import type { AdapterCapabilities, AdapterHealth } from '@virtual-cdu/shared'; import type { IAircraftAdapter } from './IAircraftAdapter'; +type CommandCapability = AdapterCapabilities['commands'][number]; +type DataCapability = AdapterCapabilities['data'][number]; + +const DISPLAY_CAPABILITIES = new Set(['display', 'displayreadback', 'cdudisplay', 'mcdudisplay']); +const TELEMETRY_CAPABILITIES = new Set([ + 'aircraftstate', + 'position', + 'heading', + 'speed', + 'altitude', + 'radios', + 'telemetry', +]); +const FLIGHT_PLAN_CAPABILITIES = new Set(['flightplan', 'route', 'fpln']); +const NAV_CYCLE_CAPABILITIES = new Set(['navcycle', 'navdata', 'airac']); +const REPLAY_CAPABILITIES = new Set(['latencysimulation', 'replay', 'playback']); + +function normalizeCapabilities(capabilities: readonly string[]): Set { + return new Set(capabilities.map((capability) => capability.trim().toLowerCase()).filter(Boolean)); +} + +function hasAny(raw: Set, candidates: Set): boolean { + for (const candidate of candidates) { + if (raw.has(candidate)) return true; + } + return false; +} + export function toAdapterCapabilities(adapter: IAircraftAdapter): AdapterCapabilities { - const raw = new Set(adapter.capabilities); + const raw = normalizeCapabilities(adapter.capabilities); + const commands: CommandCapability[] = ['keyPress', 'lskPress']; + const data: DataCapability[] = [ + ...(hasAny(raw, DISPLAY_CAPABILITIES) ? (['display'] as DataCapability[]) : []), + ...(hasAny(raw, TELEMETRY_CAPABILITIES) ? (['telemetry'] as DataCapability[]) : []), + ...(hasAny(raw, FLIGHT_PLAN_CAPABILITIES) ? (['flightPlan'] as DataCapability[]) : []), + ...(hasAny(raw, NAV_CYCLE_CAPABILITIES) ? (['navCycle'] as DataCapability[]) : []), + 'adapterVersion', + ]; + return { instruments: adapter.aircraftType === 'AIRBUS_A320' ? ['MCDU', 'ND'] : ['CDU', 'ND'], - commands: ['keyPress', 'lskPress'], - data: [ - ...(raw.has('displayReadback') ? ['display' as const] : []), - ...(raw.has('aircraftState') ? ['telemetry' as const] : []), - 'adapterVersion', - ], - replay: raw.has('latencySimulation'), + commands, + data, + replay: hasAny(raw, REPLAY_CAPABILITIES), }; } diff --git a/server/src/bridge-server.ts b/server/src/bridge-server.ts index 0e74d4a..822702d 100644 --- a/server/src/bridge-server.ts +++ b/server/src/bridge-server.ts @@ -80,7 +80,7 @@ export function createBridgeServer(options: BridgeServerOptions = {}): BridgeSer if (token !== authToken) { metrics.authRejected(); logger.warn(LogEvent.WS_AUTH_REJECTED, { ip: getClientIp(req) }); - done(false, 4001, 'Authentication failed'); + done(false, 401, 'Authentication failed'); return; } } diff --git a/shared/src/__tests__/lskDispatcher.test.ts b/shared/src/__tests__/lskDispatcher.test.ts index 26a8ca5..3985839 100644 --- a/shared/src/__tests__/lskDispatcher.test.ts +++ b/shared/src/__tests__/lskDispatcher.test.ts @@ -46,6 +46,12 @@ describe('dispatchLskAction', () => { expect(result.success?.patch).toBeDefined(); }); + it('dispatches route action (set_co_route)', () => { + const result = dispatchLskAction({ state: makeState(), action: 'set_co_route', scratchpad: 'KJFKDCA1' }); + expect(result.handled).toBe(true); + expect(getPatch(result).pendingRoute?.companyRoute).toBe('KJFKDCA1'); + }); + it('dispatches performance action (set_crz_alt)', () => { const result = dispatchLskAction({ state: makeState(), action: 'set_crz_alt', scratchpad: '350' }); expect(result.handled).toBe(true); diff --git a/shared/src/__tests__/rendererGrammar.test.ts b/shared/src/__tests__/rendererGrammar.test.ts index 83fc996..6acb600 100644 --- a/shared/src/__tests__/rendererGrammar.test.ts +++ b/shared/src/__tests__/rendererGrammar.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { validateDisplayGrid } from '../fmc/displayGridValidation'; import { displayDataToGrid } from '../fmc/displayGrid'; +import { dispatchLskAction } from '../fmc/actionHandlers/lskDispatcher'; import { buildInitialFMCState } from '../fmc/initialState'; import { getPageRenderer } from '../fmc/pages/index'; import { getAirbusPageRenderer } from '../fmc/pages/airbus/index'; @@ -78,6 +79,14 @@ const airbusData: Partial = { ident: { aircraftType: 'A320-214', engRating: 'CFM56-5B4', navDataVersion: '2501', opProgram: 'FMS2' }, }; +const rteScratchpadByAction: Record = { + set_origin: 'KJFK', + set_dest: 'KDCA', + set_co_route: 'KJFKDCA1', + set_flt_no: 'UA123', + set_route: 'KJFK DCT RBV DCT KDCA', +}; + describe('Boeing renderer grammar conformance', () => { const boeingPages: string[] = ['IDENT', 'POS_INIT', 'RTE', 'DEP_ARR', 'PERF_INIT', 'TAKEOFF_REF', 'LEGS', 'PROGRESS']; @@ -95,6 +104,31 @@ describe('Boeing renderer grammar conformance', () => { } }); } + + it('Boeing RTE emits only dispatcher-recognized LSK actions', () => { + const renderer = getPageRenderer('RTE'); + if (!renderer) throw new Error('No renderer for RTE'); + + for (const rteSubPage of [0, 1]) { + const rendererState = state({ + ...boeingData, + currentPage: 'RTE', + rteSubPage, + route: { origin: 'KJFK', destination: 'KDCA', flightNumber: 'UA123', companyRoute: '', routeString: '' }, + }); + const data = renderer(rendererState); + + for (const [slot, action] of Object.entries(data.lskActions)) { + if (!action) continue; + const result = dispatchLskAction({ + state: rendererState, + action, + scratchpad: rteScratchpadByAction[action] ?? '', + }); + expect(result.handled, `RTE ${rteSubPage + 1}/2 ${slot} emitted unhandled action "${action}"`).toBe(true); + } + } + }); }); describe('Airbus renderer grammar conformance', () => { diff --git a/shared/src/__tests__/routeActions.test.ts b/shared/src/__tests__/routeActions.test.ts index 171d5d7..c167b75 100644 --- a/shared/src/__tests__/routeActions.test.ts +++ b/shared/src/__tests__/routeActions.test.ts @@ -142,6 +142,44 @@ describe('handleSetFltNo (via dispatcher)', () => { }); }); +describe('handleSetCoRoute (via dispatcher)', () => { + it('returns handled:false when scratchpad is empty', () => { + const result = handleRouteAction('set_co_route', makeState(), ''); + expect(result.handled).toBe(false); + }); + + it('returns failure for malformed company route identifiers', () => { + const result = handleRouteAction('set_co_route', makeState(), 'KJFK KDCA'); + expect(result.handled).toBe(true); + expect(result.failure).toMatchObject({ + code: 'INVALID_FORMAT', + text: 'INVALID ENTRY', + source: 'routeActions.set_co_route', + }); + }); + + it('stages a company route for EXEC without replacing existing route fields', () => { + const state = makeState({ + route: { origin: 'KJFK', destination: 'KDCA', flightNumber: 'AAL123', companyRoute: '', routeString: '' }, + }); + const result = handleRouteAction('set_co_route', state, 'kjfkdca1'); + + expect(result.handled).toBe(true); + expect(result.success?.clearScratchpad).toBe(true); + const patch = getPatch(result); + expect(patch.pendingRoute).toMatchObject({ + origin: 'KJFK', + destination: 'KDCA', + flightNumber: 'AAL123', + companyRoute: 'KJFKDCA1', + coRoute: 'KJFKDCA1', + }); + expect(patch.isModified).toBe(true); + expect(patch.execLit).toBe(true); + expect(state.route.companyRoute).toBe(''); + }); +}); + describe('handleSetRoute (via dispatcher)', () => { it('returns handled:false when scratchpad is empty', () => { const result = handleRouteAction('set_route', makeState(), ''); diff --git a/shared/src/fmc/actionHandlers/routeActions.ts b/shared/src/fmc/actionHandlers/routeActions.ts index ca2fe0e..7017138 100644 --- a/shared/src/fmc/actionHandlers/routeActions.ts +++ b/shared/src/fmc/actionHandlers/routeActions.ts @@ -17,6 +17,8 @@ export function handleRouteAction( return handleSetDest(state, scratchpad); case 'set_flt_no': return handleSetFltNo(state, scratchpad); + case 'set_co_route': + return handleSetCoRoute(state, scratchpad); case 'set_route': return handleSetRoute(state, scratchpad); case 'set_direct_to': @@ -166,6 +168,35 @@ function handleSetFltNo(state: FMCState, scratchpad: string): FmcActionResult { }; } +function handleSetCoRoute(state: FMCState, scratchpad: string): FmcActionResult { + if (!scratchpad) return { handled: false }; + + const coRoute = scratchpad.trim().toUpperCase(); + if (!/^[A-Z0-9]{1,10}$/.test(coRoute)) { + return { + handled: true, + failure: { + code: 'INVALID_FORMAT' as const, + text: 'INVALID ENTRY', + source: 'routeActions.set_co_route', + }, + }; + } + + const route = state.pendingRoute ?? state.route; + return { + handled: true, + success: { + clearScratchpad: true, + patch: { + pendingRoute: { ...route, companyRoute: coRoute, coRoute }, + isModified: true, + execLit: true, + }, + }, + }; +} + function handleSetRoute(state: FMCState, scratchpad: string): FmcActionResult { if (!scratchpad) return { handled: false }; const routeStr = scratchpad.toUpperCase(); diff --git a/shared/src/fmc/pages/boeing/route.grid.ts b/shared/src/fmc/pages/boeing/route.grid.ts index 65249bb..f24645d 100644 --- a/shared/src/fmc/pages/boeing/route.grid.ts +++ b/shared/src/fmc/pages/boeing/route.grid.ts @@ -11,7 +11,7 @@ export function renderBoeingRteGrid(state: FMCState): DisplayData { const origin = route.origin || '[ ]'; const dest = route.destination || '[ ]'; const fltNo = route.flightNumber || '--------'; - const coRte = route.companyRoute || '---------'; + const coRte = route.companyRoute || route.coRoute || '---------'; return boeingPage( [ diff --git a/src/components/ConnectionStatus.tsx b/src/components/ConnectionStatus.tsx index 2b920c2..408107f 100644 --- a/src/components/ConnectionStatus.tsx +++ b/src/components/ConnectionStatus.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react'; import { useWebSocket, saveServerUrl, getServerUrl } from '../hooks/useWebSocket'; -import { useFMCStore } from '../store/useFMCStore'; import { useAircraftStore } from '../store/aircraftStore'; import { useConnectionStore } from '../store/connectionStore'; import { useCockpitLayoutStore } from '../store/cockpitLayoutStore'; @@ -92,8 +91,10 @@ export function ConnectionStatus() { typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : '---'; const handleConnect = () => { - saveServerUrl(serverUrl); - connect(); + const nextServerUrl = serverUrl.trim(); + if (!nextServerUrl) return; + saveServerUrl(nextServerUrl); + connect(nextServerUrl); }; const handleDisconnect = () => { @@ -172,8 +173,11 @@ export function ConnectionStatus() { )} - + setServerUrl(e.target.value)} @@ -194,6 +198,7 @@ export function ConnectionStatus() { label={connectionStatus === 'ERROR' ? 'RETRY MSFS' : 'CONNECT TO MSFS'} className="flex-1 h-8 text-[10px]" variant="exec" + disabled={!serverUrl.trim()} onPress={handleConnect} /> )} diff --git a/src/components/__tests__/ConnectionStatus.test.tsx b/src/components/__tests__/ConnectionStatus.test.tsx new file mode 100644 index 0000000..7c1a52a --- /dev/null +++ b/src/components/__tests__/ConnectionStatus.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConnectionStatus } from '../ConnectionStatus'; +import { useAircraftStore } from '../../store/aircraftStore'; +import { useCockpitLayoutStore } from '../../store/cockpitLayoutStore'; +import { useConnectionStore } from '../../store/connectionStore'; + +const connectSpy = vi.hoisted(() => vi.fn()); +const disconnectSpy = vi.hoisted(() => vi.fn()); +const sendSpy = vi.hoisted(() => vi.fn()); +const saveServerUrlSpy = vi.hoisted(() => vi.fn()); +const getServerUrlSpy = vi.hoisted(() => vi.fn(() => 'ws://localhost:8080')); + +vi.mock('../../hooks/useWebSocket', () => ({ + getServerUrl: getServerUrlSpy, + saveServerUrl: saveServerUrlSpy, + useWebSocket: () => ({ + connect: connectSpy, + disconnect: disconnectSpy, + send: sendSpy, + connectionStatus: 'DISCONNECTED', + }), +})); + +describe('ConnectionStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + getServerUrlSpy.mockReturnValue('ws://localhost:8080'); + + Object.defineProperty(navigator, 'onLine', { + configurable: true, + value: true, + }); + + useCockpitLayoutStore.setState({ cockpitMode: false, hiddenPanels: [] }); + useConnectionStore.setState({ + adapterHealth: null, + connectedAircraft: null, + connectedAircraftType: null, + connectedCapabilities: null, + connectionMode: 'STANDALONE', + connectionStatus: 'DISCONNECTED', + lastError: null, + latency: 0, + sessionStartTime: null, + structuredCapabilities: null, + }); + useAircraftStore.setState({ aircraft: 'BOEING_737', aircraftState: null }); + }); + + it('trims and passes the entered server URL into the immediate connect call', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /disconnected/i })); + + const input = screen.getByLabelText('Server URL (WebSocket)'); + fireEvent.change(input, { target: { value: ' ws://192.168.0.42:9090 ' } }); + fireEvent.click(screen.getByRole('button', { name: /connect to msfs/i })); + + expect(saveServerUrlSpy).toHaveBeenCalledWith('ws://192.168.0.42:9090'); + expect(connectSpy).toHaveBeenCalledWith('ws://192.168.0.42:9090'); + }); + + it('does not save or connect with an empty server URL', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /disconnected/i })); + + fireEvent.change(screen.getByLabelText('Server URL (WebSocket)'), { target: { value: ' ' } }); + const connectButton = screen.getByRole('button', { name: /connect to msfs/i }); + + expect(connectButton).toBeDisabled(); + fireEvent.click(connectButton); + expect(saveServerUrlSpy).not.toHaveBeenCalled(); + expect(connectSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/useWakeLock.test.tsx b/src/hooks/__tests__/useWakeLock.test.tsx new file mode 100644 index 0000000..b9f5058 --- /dev/null +++ b/src/hooks/__tests__/useWakeLock.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useWakeLock } from '../useWakeLock'; + +function WakeLockHarness({ enabled = true }: { enabled?: boolean }) { + const { isActive, isSupported } = useWakeLock(enabled); + return
; +} + +function createWakeLockSentinel(): WakeLockSentinel { + let released = false; + const sentinel = new EventTarget() as WakeLockSentinel; + + Object.defineProperties(sentinel, { + onrelease: { configurable: true, writable: true, value: null }, + released: { configurable: true, get: () => released }, + type: { configurable: true, get: () => 'screen' as WakeLockType }, + release: { + configurable: true, + value: vi.fn(async () => { + if (released) return; + released = true; + sentinel.dispatchEvent(new Event('release')); + }), + }, + }); + + return sentinel; +} + +function installWakeLock(...sentinels: WakeLockSentinel[]) { + const request = vi.fn(async () => { + const sentinel = sentinels.shift(); + if (!sentinel) throw new Error('No test wake lock sentinel available'); + return sentinel; + }); + + Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } }); + return request; +} + +let visibilityState: DocumentVisibilityState = 'visible'; +const originalWakeLockDescriptor = Object.getOwnPropertyDescriptor(navigator, 'wakeLock'); +const originalVisibilityDescriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState'); + +function setVisibility(state: DocumentVisibilityState) { + visibilityState = state; + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibilityState }); +} + +afterEach(() => { + vi.restoreAllMocks(); + + if (originalWakeLockDescriptor) { + Object.defineProperty(navigator, 'wakeLock', originalWakeLockDescriptor); + } else { + delete (navigator as unknown as { wakeLock?: WakeLock }).wakeLock; + } + + if (originalVisibilityDescriptor) { + Object.defineProperty(document, 'visibilityState', originalVisibilityDescriptor); + } else { + delete (document as unknown as { visibilityState?: DocumentVisibilityState }).visibilityState; + } + + visibilityState = 'visible'; +}); + +describe('useWakeLock', () => { + it('keeps one acquired sentinel without state-driven release churn', async () => { + setVisibility('visible'); + const sentinel = createWakeLockSentinel(); + const request = installWakeLock(sentinel); + + render(); + await waitFor(() => expect(screen.getByTestId('wake-lock-state')).toHaveAttribute('data-active', 'true')); + + expect(screen.getByTestId('wake-lock-state')).toHaveAttribute('data-supported', 'true'); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith('screen'); + expect(vi.mocked(sentinel.release)).not.toHaveBeenCalled(); + }); + + it('releases on hidden visibility and reacquires only while enabled', async () => { + setVisibility('visible'); + const firstSentinel = createWakeLockSentinel(); + const secondSentinel = createWakeLockSentinel(); + const request = installWakeLock(firstSentinel, secondSentinel); + const { rerender } = render(); + + await waitFor(() => expect(screen.getByTestId('wake-lock-state')).toHaveAttribute('data-active', 'true')); + setVisibility('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + await waitFor(() => expect(screen.getByTestId('wake-lock-state')).toHaveAttribute('data-active', 'false')); + expect(vi.mocked(firstSentinel.release)).toHaveBeenCalledTimes(1); + + setVisibility('visible'); + document.dispatchEvent(new Event('visibilitychange')); + await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByTestId('wake-lock-state')).toHaveAttribute('data-active', 'true')); + + rerender(); + await waitFor(() => expect(screen.getByTestId('wake-lock-state')).toHaveAttribute('data-active', 'false')); + expect(vi.mocked(secondSentinel.release)).toHaveBeenCalledTimes(1); + + document.dispatchEvent(new Event('visibilitychange')); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('releases an in-flight acquisition that resolves after disable', async () => { + setVisibility('visible'); + const sentinel = createWakeLockSentinel(); + let resolveRequest: ((value: WakeLockSentinel) => void) | undefined; + const request = vi.fn( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } }); + + const { rerender } = render(); + expect(request).toHaveBeenCalledTimes(1); + rerender(); + resolveRequest?.(sentinel); + + await waitFor(() => expect(vi.mocked(sentinel.release)).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('wake-lock-state')).toHaveAttribute('data-active', 'false'); + }); +}); diff --git a/src/hooks/useWakeLock.ts b/src/hooks/useWakeLock.ts index 010a37b..264b8ab 100644 --- a/src/hooks/useWakeLock.ts +++ b/src/hooks/useWakeLock.ts @@ -1,61 +1,122 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { devLog, devError } from '@shared'; +function hasWakeLockSupport(): boolean { + return typeof navigator !== 'undefined' && 'wakeLock' in navigator; +} + +function isDocumentVisible(): boolean { + return typeof document === 'undefined' || document.visibilityState === 'visible'; +} + +function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + export function useWakeLock(enabled: boolean = true) { - const [isSupported, setIsSupported] = useState('wakeLock' in navigator); + const [isSupported] = useState(hasWakeLockSupport); const [isActive, setIsActive] = useState(false); - const [sentinel, setSentinel] = useState(null); + const sentinelRef = useRef(null); + const enabledRef = useRef(enabled); + const requestSequenceRef = useRef(0); + const isRequestingRef = useRef(false); + + const releaseWakeLock = useCallback(async () => { + requestSequenceRef.current += 1; + isRequestingRef.current = false; + + const sentinel = sentinelRef.current; + sentinelRef.current = null; + + if (!sentinel) { + setIsActive(false); + return; + } + + try { + if (!sentinel.released) { + await sentinel.release(); + } + } catch (err) { + devError(`[WakeLock] Failed to release: ${getErrorMessage(err)}`); + } finally { + setIsActive(false); + } + }, []); const requestWakeLock = useCallback(async () => { - if (!('wakeLock' in navigator)) return; + if (!hasWakeLockSupport() || !enabledRef.current || !isDocumentVisible()) return; + if (sentinelRef.current || isRequestingRef.current) return; + + const requestSequence = requestSequenceRef.current + 1; + requestSequenceRef.current = requestSequence; + isRequestingRef.current = true; try { - const lock = await (navigator as any).wakeLock.request('screen'); - setSentinel(lock); + const lock = await navigator.wakeLock.request('screen'); + + if (requestSequence !== requestSequenceRef.current || !enabledRef.current || !isDocumentVisible()) { + await lock.release(); + return; + } + + sentinelRef.current = lock; setIsActive(true); devLog('[WakeLock] Screen lock acquired'); - lock.addEventListener('release', () => { + lock.addEventListener( + 'release', + () => { + if (sentinelRef.current === lock) { + sentinelRef.current = null; + setIsActive(false); + } + devLog('[WakeLock] Screen lock released'); + }, + { once: true }, + ); + } catch (err) { + if (requestSequence === requestSequenceRef.current) { setIsActive(false); - devLog('[WakeLock] Screen lock released'); - }); - } catch (err: any) { - setIsActive(false); - devError(`[WakeLock] Failed to acquire: ${err.message}`); - } - }, []); - - const releaseWakeLock = useCallback(async () => { - if (sentinel) { - try { - await sentinel.release(); - } catch (err: any) { - devError(`[WakeLock] Failed to release: ${err.message}`); } - setSentinel(null); + devError(`[WakeLock] Failed to acquire: ${getErrorMessage(err)}`); + } finally { + if (requestSequence === requestSequenceRef.current) { + isRequestingRef.current = false; + } } - }, [sentinel]); + }, []); useEffect(() => { + enabledRef.current = enabled; + if (enabled) { - requestWakeLock(); + void requestWakeLock(); } else { - releaseWakeLock(); + void releaseWakeLock(); } + }, [enabled, requestWakeLock, releaseWakeLock]); + + useEffect(() => { + if (typeof document === 'undefined') return; - // Re-acquire if visibility changes (browser requirement) const handleVisibilityChange = () => { - if (enabled && document.visibilityState === 'visible') { - requestWakeLock(); + if (document.visibilityState === 'visible') { + if (enabledRef.current) { + void requestWakeLock(); + } + } else { + void releaseWakeLock(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); - releaseWakeLock(); + enabledRef.current = false; + void releaseWakeLock(); }; - }, [enabled, requestWakeLock, releaseWakeLock]); + }, [requestWakeLock, releaseWakeLock]); - return { isSupported, isActive }; + return { isSupported, isActive, requestWakeLock, releaseWakeLock }; } diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index b251e24..c498049 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -16,9 +16,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { return webSocketClient.subscribe(setStatus); }, []); - const connect = useCallback(() => { - webSocketClient.connect(options.url); - }, [options.url]); + const connect = useCallback( + (url?: string) => { + webSocketClient.connect(url ?? options.url); + }, + [options.url], + ); const disconnect = useCallback(() => { webSocketClient.disconnect();