From ed566f798cf2783e9675d47a118f0995f3d20d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:22:49 +0200 Subject: [PATCH] Improve debugger loader message --- newIDE/app/src/Debugger/index.js | 119 +++++++------- .../LocalPreviewDebuggerServer.js | 30 ++-- .../LocalPreviewDebuggerServer.spec.js | 150 ++++++++++++++++++ .../ExportAndShare/PreviewLauncher.flow.js | 2 +- 4 files changed, 236 insertions(+), 65 deletions(-) create mode 100644 newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.spec.js diff --git a/newIDE/app/src/Debugger/index.js b/newIDE/app/src/Debugger/index.js index 68455edf21f7..269c6612333f 100644 --- a/newIDE/app/src/Debugger/index.js +++ b/newIDE/app/src/Debugger/index.js @@ -57,7 +57,7 @@ type Props = {| |}; type State = {| - debuggerServerState: 'started' | 'stopped', + debuggerServerState: 'started' | 'starting' | 'stopped', debuggerServerError: ?any, debuggerIds: Array, unregisterDebuggerServerCallbacks: ?() => void, @@ -78,6 +78,7 @@ export default class Debugger extends React.Component { state = { debuggerServerState: (this.props.previewDebuggerServer.getServerState(): | 'started' + | 'starting' | 'stopped'), debuggerServerError: null, debuggerIds: (this.props.previewDebuggerServer.getExistingDebuggerIds(): Array), @@ -368,17 +369,9 @@ export default class Debugger extends React.Component { profilingInProgress, } = this.state; - return ( - - {debuggerServerState === 'stopped' && !debuggerServerError && ( - - - - Debugger is starting... - - - )} - {debuggerServerState === 'stopped' && debuggerServerError && ( + if (debuggerServerState === 'stopped' && debuggerServerError) { + return ( + @@ -387,50 +380,68 @@ export default class Debugger extends React.Component { - )} - {debuggerServerState === 'started' && ( - - - this.setState( - { - selectedId: id, - }, - () => this.updateToolbar() - ) + + ); + } + + if (debuggerServerState === 'starting') { + return ( + + + + + Debugger is starting... + + + + ); + } + + // The debugger server is only started when a preview is launched, so a + // stopped server is displayed like a started one without any preview + // running (it will be started as soon as a preview is launched). + return ( + + + + this.setState( + { + selectedId: id, + }, + () => this.updateToolbar() + ) + } + /> + {this._hasSelectedDebugger() ? ( + + (this._debuggerContents[selectedId] = debuggerContent) } + gameData={debuggerGameData[selectedId]} + onPlay={() => this._play(selectedId)} + onPause={() => this._pause(selectedId)} + onRefresh={() => this._refresh(selectedId)} + onEdit={(path, args) => this._edit(selectedId, path, args)} + onCall={(path, args) => this._call(selectedId, path, args)} + onStartProfiler={() => this._startProfiler(selectedId)} + onStopProfiler={() => this._stopProfiler(selectedId)} + profilerOutput={profilerOutputs[selectedId]} + profilingInProgress={profilingInProgress[selectedId]} + logsManager={this._getLogsManager(selectedId)} + onOpenedEditorsChanged={this.updateToolbar} /> - {this._hasSelectedDebugger() && ( - - (this._debuggerContents[selectedId] = debuggerContent) - } - gameData={debuggerGameData[selectedId]} - onPlay={() => this._play(selectedId)} - onPause={() => this._pause(selectedId)} - onRefresh={() => this._refresh(selectedId)} - onEdit={(path, args) => this._edit(selectedId, path, args)} - onCall={(path, args) => this._call(selectedId, path, args)} - onStartProfiler={() => this._startProfiler(selectedId)} - onStopProfiler={() => this._stopProfiler(selectedId)} - profilerOutput={profilerOutputs[selectedId]} - profilingInProgress={profilingInProgress[selectedId]} - logsManager={this._getLogsManager(selectedId)} - onOpenedEditorsChanged={this.updateToolbar} - /> - )} - {!this._hasSelectedDebugger() && ( - - - Run a preview and you will be able to inspect it with the - debugger. - - - )} - - )} + ) : ( + + + Run a preview and you will be able to inspect it with the + debugger. + + + )} + ); } diff --git a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.js b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.js index 5cab9600f5ac..e1666fec37d7 100644 --- a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.js +++ b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.js @@ -9,7 +9,7 @@ import { const electron = optionalRequire('electron'); const ipcRenderer = electron ? electron.ipcRenderer : null; -let debuggerServerState: 'started' | 'stopped' = 'stopped'; +let debuggerServerState: 'started' | 'starting' | 'stopped' = 'stopped'; let debuggerServerAddress: ?ServerAddress = null; const callbacksList: Array = []; const debuggerIds: Array = []; @@ -20,6 +20,15 @@ let embeddedGameFrameWindow: WindowProxy | null = null; let gameplayTestFrameWindow: WindowProxy | null = null; let isWindowMessageListenerRegistered = false; +const setDebuggerServerState = ( + newState: 'started' | 'starting' | 'stopped' +) => { + if (debuggerServerState === newState) return; + + debuggerServerState = newState; + callbacksList.forEach(({ onServerStateChanged }) => onServerStateChanged()); +}; + const getExistingDebuggerIds = (): Array => [ ...getExistingEmbeddedGameFrameDebuggerIds(), ...getExistingGameplayTestFrameDebuggerIds(), @@ -118,11 +127,12 @@ class LocalPreviewDebuggerServer { const serverStartPromise = new Promise((resolve, reject) => { let serverStartPromiseCompleted = false; - debuggerServerState = 'stopped'; debuggerServerAddress = null; removeServerListeners(); + setDebuggerServerState('starting'); ipcRenderer.on('debugger-error-received', (event, err) => { + setDebuggerServerState('stopped'); if (!serverStartPromiseCompleted) { reject(err); serverStartPromiseCompleted = true; @@ -162,16 +172,12 @@ class LocalPreviewDebuggerServer { ipcRenderer.on('debugger-start-server-done', (event, { address }) => { console.info('Local preview debugger started'); - debuggerServerState = 'started'; debuggerServerAddress = address; + setDebuggerServerState('started'); if (!serverStartPromiseCompleted) { resolve(); serverStartPromiseCompleted = true; } - - callbacksList.forEach(({ onServerStateChanged }) => - onServerStateChanged() - ); }); ipcRenderer.on('debugger-message-received', (event, { id, message }) => { @@ -194,6 +200,10 @@ class LocalPreviewDebuggerServer { // after 5s. const serverStartTimeoutPromise = new Promise((resolve, reject) => { setTimeout(() => { + // The server can still be started later (the listeners are kept), but + // don't leave the debugger waiting for it indefinitely. + if (debuggerServerState === 'starting') + setDebuggerServerState('stopped'); reject( new Error( 'Debugger server not started or errored after 5s - aborting.' @@ -228,8 +238,8 @@ class LocalPreviewDebuggerServer { } if (!ipcRenderer) return; - if (debuggerServerState === 'stopped') { - console.error('Cannot send message when debugger server is stopped.'); + if (debuggerServerState !== 'started') { + console.error('Cannot send message when debugger server is not started.'); return; } @@ -259,7 +269,7 @@ class LocalPreviewDebuggerServer { }); return promise; } - getServerState(): 'started' | 'stopped' { + getServerState(): 'started' | 'starting' | 'stopped' { return debuggerServerState; } getExistingDebuggerIds(): Array { diff --git a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.spec.js b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.spec.js new file mode 100644 index 000000000000..35d08f20eea7 --- /dev/null +++ b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/LocalPreviewDebuggerServer.spec.js @@ -0,0 +1,150 @@ +// @flow +import { type PreviewDebuggerServerCallbacks } from '../../PreviewLauncher.flow'; + +const mockIpcRendererListeners: Map> = new Map(); +const mockIpcRenderer = { + on: jest.fn<[string, Function], void>(), + removeAllListeners: jest.fn<[string], void>(), + send: jest.fn, void>(), +}; + +jest.mock('../../../Utils/OptionalRequire', () => + jest.fn((moduleName: string) => + moduleName === 'electron' ? { ipcRenderer: mockIpcRenderer } : null + ) +); + +/** Simulate a message sent by the Electron main process. */ +const emitFromMainProcess = (channel: string, payload: any) => + (mockIpcRendererListeners.get(channel) || []).forEach(listener => + listener({}, payload) + ); + +/** + * The debugger server keeps its state at the module level, so each test must + * start from a freshly loaded module. + */ +const loadDebuggerServer = () => { + jest.resetModules(); + // $FlowFixMe[unsupported-syntax] - required to get a fresh module state. + return require('./LocalPreviewDebuggerServer').localPreviewDebuggerServer; +}; + +const makeCallbacks = (): PreviewDebuggerServerCallbacks => ({ + onErrorReceived: jest.fn, void>(), + onServerStateChanged: jest.fn, void>(), + onConnectionClosed: jest.fn, void>(), + onConnectionOpened: jest.fn, void>(), + onConnectionErrored: jest.fn, void>(), + onHandleParsedMessage: jest.fn, void>(), +}); + +describe('LocalPreviewDebuggerServer', () => { + beforeEach(() => { + // The server registers a listener for the embedded game frames on the window. + global.window = { addEventListener: jest.fn() }; + mockIpcRendererListeners.clear(); + // `resetMocks` is enabled, so the implementations are set for each test. + mockIpcRenderer.on.mockImplementation( + (channel: string, listener: Function) => { + mockIpcRendererListeners.set(channel, [ + ...(mockIpcRendererListeners.get(channel) || []), + listener, + ]); + } + ); + mockIpcRenderer.removeAllListeners.mockImplementation((channel: string) => { + mockIpcRendererListeners.delete(channel); + }); + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + delete global.window; + }); + + it('is stopped until the server is started', () => { + const debuggerServer = loadDebuggerServer(); + + expect(debuggerServer.getServerState()).toBe('stopped'); + }); + + it('is starting while waiting for the server to listen', () => { + const debuggerServer = loadDebuggerServer(); + const callbacks = makeCallbacks(); + debuggerServer.registerCallbacks(callbacks); + + const startPromise = debuggerServer.startServer({}); + startPromise.catch(() => {}); + + expect(debuggerServer.getServerState()).toBe('starting'); + expect(callbacks.onServerStateChanged).toHaveBeenCalledTimes(1); + }); + + it('is started once the server is listening', () => { + const debuggerServer = loadDebuggerServer(); + const callbacks = makeCallbacks(); + debuggerServer.registerCallbacks(callbacks); + + debuggerServer.startServer({}).catch(() => {}); + emitFromMainProcess('debugger-start-server-done', { + address: { address: '127.0.0.1', port: 3030 }, + }); + + expect(debuggerServer.getServerState()).toBe('started'); + expect(callbacks.onServerStateChanged).toHaveBeenCalledTimes(2); + }); + + it('goes back to stopped if the server does not start in time', async () => { + const debuggerServer = loadDebuggerServer(); + const callbacks = makeCallbacks(); + debuggerServer.registerCallbacks(callbacks); + + const startPromise = debuggerServer.startServer({}); + const startError = startPromise.catch(error => error); + jest.advanceTimersByTime(5000); + + expect(await startError).toEqual(expect.any(Error)); + expect(debuggerServer.getServerState()).toBe('stopped'); + }); + + it('stays started when the start timeout is reached after the server started', () => { + const debuggerServer = loadDebuggerServer(); + const callbacks = makeCallbacks(); + debuggerServer.registerCallbacks(callbacks); + + debuggerServer.startServer({}).catch(() => {}); + emitFromMainProcess('debugger-start-server-done', { + address: { address: '127.0.0.1', port: 3030 }, + }); + jest.advanceTimersByTime(5000); + + expect(debuggerServer.getServerState()).toBe('started'); + }); + + it('goes back to stopped when the server errors', () => { + const debuggerServer = loadDebuggerServer(); + const callbacks = makeCallbacks(); + debuggerServer.registerCallbacks(callbacks); + + debuggerServer.startServer({}).catch(() => {}); + emitFromMainProcess('debugger-error-received', new Error('Some error')); + + expect(debuggerServer.getServerState()).toBe('stopped'); + expect(callbacks.onErrorReceived).toHaveBeenCalledTimes(1); + }); + + it('does not send messages to a server that is not started yet', () => { + const debuggerServer = loadDebuggerServer(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + const startPromise = debuggerServer.startServer({}); + startPromise.catch(() => {}); + debuggerServer.sendMessage('preview-ws-0', { command: 'play' }); + + expect(mockIpcRenderer.send).not.toHaveBeenCalledWith( + 'debugger-send-message', + expect.anything() + ); + }); +}); diff --git a/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js b/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js index 842414c86931..5816d6188d12 100644 --- a/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js +++ b/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js @@ -134,7 +134,7 @@ export type ServerAddress = { /** Interface to run a debugger server for previews. */ export interface PreviewDebuggerServer { startServer({ origin?: string }): Promise; - getServerState(): 'started' | 'stopped'; + getServerState(): 'started' | 'starting' | 'stopped'; getExistingDebuggerIds(): Array; getExistingEmbeddedGameFrameDebuggerIds(): Array; getExistingPreviewDebuggerIds(): Array;