diff --git a/frontend/src/__tests__/pi-linux-mode-restart.test.ts b/frontend/src/__tests__/pi-linux-mode-restart.test.ts new file mode 100644 index 00000000..4d2255b4 --- /dev/null +++ b/frontend/src/__tests__/pi-linux-mode-restart.test.ts @@ -0,0 +1,77 @@ +/** + * Turning on Linux mode must actually boot the guest. + * + * The button pins the mode and restarts the board: stopBoard, then + * startBoard a moment later. That second call has to reach the board's + * bridge and open its WebSocket — when it silently did not, the user got + * no guest, no error, and a toolbar still showing Stop. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// The bridge opens a real WebSocket; capture the instances instead. +const opened: string[] = []; +class FakeSocket { + static OPEN = 1; + static CONNECTING = 0; + static CLOSING = 2; + static CLOSED = 3; + readyState = 0; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + sent: string[] = []; + constructor(public url: string) { + opened.push(url); + } + send(d: string) { + this.sent.push(d); + } + close() { + this.readyState = FakeSocket.CLOSING; + } +} + +beforeEach(() => { + opened.length = 0; + (globalThis as unknown as { WebSocket: unknown }).WebSocket = FakeSocket; +}); + +describe('Linux mode restart', () => { + it('opens the guest WebSocket when the board is pinned to linux', async () => { + const { useSimulatorStore, getBoardBridge } = await import( + '../store/useSimulatorStore' + ); + + const id = useSimulatorStore.getState().addBoard('raspberry-pi-3', 10, 10); + expect(getBoardBridge(id), 'the board must get a bridge on add').toBeTruthy(); + + // What the Linux-mode button does: pin, stop, start. + useSimulatorStore.getState().updateBoard(id, { enginePinned: 'linux' }); + useSimulatorStore.getState().stopBoard(id); + useSimulatorStore.getState().startBoard(id); + + expect(opened.filter((u) => u.includes('/simulation/ws/')).length).toBe(1); + const board = useSimulatorStore.getState().boards.find((b) => b.id === id); + expect(board?.engineMode).toBe('linux'); + expect(board?.running, 'the toolbar must show a running board').toBe(true); + }); + + it('reconnects even when the previous socket is still closing', async () => { + const { useSimulatorStore, getBoardBridge } = await import( + '../store/useSimulatorStore' + ); + const id = useSimulatorStore.getState().addBoard('raspberry-pi-4', 10, 10); + const bridge = getBoardBridge(id)!; + + useSimulatorStore.getState().updateBoard(id, { enginePinned: 'linux' }); + useSimulatorStore.getState().startBoard(id); + expect(opened.length).toBe(1); + + // Stop closes the socket; a CLOSING socket used to look "alive" and + // the next start became a no-op. + useSimulatorStore.getState().stopBoard(id); + bridge.connect(); + expect(opened.length).toBe(2); + }); +}); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 0bf8cea4..ffe0e914 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -1912,6 +1912,7 @@ export const useSimulatorStore = create((set, get) => { }, startBoard: (boardId: string) => { + const boardKindOfBoard = (b: { boardKind: string }): string => b.boardKind; const board = get().boards.find((b) => b.id === boardId); if (!board) return; @@ -1954,7 +1955,22 @@ export const useSimulatorStore = create((set, get) => { }); }); } else { - getBoardBridge(boardId)?.connect(); + const bridge = getBoardBridge(boardId); + if (bridge) { + bridge.connect(); + } else { + // A Pi with no bridge cannot boot, and the optional-chain + // version of this call failed in silence: no guest, no error, + // and a toolbar still showing Stop. Say so. + console.warn( + `[${boardKindOfBoard(board)}] no bridge for ${boardId}: the guest cannot start`, + ); + set((s) => ({ + boards: s.boards.map((b) => + b.id === boardId ? { ...b, running: false } : b, + ), + })); + } } } else if (isEsp32Kind(board.boardKind)) { // Pre-register sensors connected to this board so the QEMU worker