Skip to content
Open
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
119 changes: 65 additions & 54 deletions newIDE/app/src/Debugger/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type Props = {|
|};

type State = {|
debuggerServerState: 'started' | 'stopped',
debuggerServerState: 'started' | 'starting' | 'stopped',
debuggerServerError: ?any,
debuggerIds: Array<DebuggerId>,
unregisterDebuggerServerCallbacks: ?() => void,
Expand All @@ -78,6 +78,7 @@ export default class Debugger extends React.Component<Props, State> {
state = {
debuggerServerState: (this.props.previewDebuggerServer.getServerState():
| 'started'
| 'starting'
| 'stopped'),
debuggerServerError: null,
debuggerIds: (this.props.previewDebuggerServer.getExistingDebuggerIds(): Array<DebuggerId>),
Expand Down Expand Up @@ -368,17 +369,9 @@ export default class Debugger extends React.Component<Props, State> {
profilingInProgress,
} = this.state;

return (
<Background>
{debuggerServerState === 'stopped' && !debuggerServerError && (
<PlaceholderMessage>
<PlaceholderLoader />
<Text>
<Trans>Debugger is starting...</Trans>
</Text>
</PlaceholderMessage>
)}
{debuggerServerState === 'stopped' && debuggerServerError && (
if (debuggerServerState === 'stopped' && debuggerServerError) {
return (
<Background>
<PlaceholderMessage>
<Text>
<Trans>
Expand All @@ -387,50 +380,68 @@ export default class Debugger extends React.Component<Props, State> {
</Trans>
</Text>
</PlaceholderMessage>
)}
{debuggerServerState === 'started' && (
<Column expand noMargin>
<DebuggerSelector
selectedId={selectedId}
debuggerStatus={debuggerStatus}
onChooseDebugger={id =>
this.setState(
{
selectedId: id,
},
() => this.updateToolbar()
)
</Background>
);
}

if (debuggerServerState === 'starting') {
return (
<Background>
<PlaceholderMessage>
<PlaceholderLoader />
<Text>
<Trans>Debugger is starting...</Trans>
</Text>
</PlaceholderMessage>
</Background>
);
}

// 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 (
<Background>
<Column expand noMargin>
<DebuggerSelector
selectedId={selectedId}
debuggerStatus={debuggerStatus}
onChooseDebugger={id =>
this.setState(
{
selectedId: id,
},
() => this.updateToolbar()
)
}
/>
{this._hasSelectedDebugger() ? (
<DebuggerContent
ref={debuggerContent =>
(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() && (
<DebuggerContent
ref={debuggerContent =>
(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() && (
<EmptyMessage>
<Trans>
Run a preview and you will be able to inspect it with the
debugger.
</Trans>
</EmptyMessage>
)}
</Column>
)}
) : (
<EmptyMessage>
<Trans>
Run a preview and you will be able to inspect it with the
debugger.
</Trans>
</EmptyMessage>
)}
</Column>
</Background>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PreviewDebuggerServerCallbacks> = [];
const debuggerIds: Array<DebuggerId> = [];
Expand All @@ -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<DebuggerId> => [
...getExistingEmbeddedGameFrameDebuggerIds(),
...getExistingGameplayTestFrameDebuggerIds(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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.'
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -259,7 +269,7 @@ class LocalPreviewDebuggerServer {
});
return promise;
}
getServerState(): 'started' | 'stopped' {
getServerState(): 'started' | 'starting' | 'stopped' {
return debuggerServerState;
}
getExistingDebuggerIds(): Array<DebuggerId> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// @flow

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tends to mock the whole world so it's ok but to be challenged again in the future if this brings more complexity than actual real checks

import { type PreviewDebuggerServerCallbacks } from '../../PreviewLauncher.flow';

const mockIpcRendererListeners: Map<string, Array<Function>> = new Map();
const mockIpcRenderer = {
on: jest.fn<[string, Function], void>(),
removeAllListeners: jest.fn<[string], void>(),
send: jest.fn<Array<any>, 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<Array<any>, void>(),
onServerStateChanged: jest.fn<Array<any>, void>(),
onConnectionClosed: jest.fn<Array<any>, void>(),
onConnectionOpened: jest.fn<Array<any>, void>(),
onConnectionErrored: jest.fn<Array<any>, void>(),
onHandleParsedMessage: jest.fn<Array<any>, 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()
);
});
});
2 changes: 1 addition & 1 deletion newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export type ServerAddress = {
/** Interface to run a debugger server for previews. */
export interface PreviewDebuggerServer {
startServer({ origin?: string }): Promise<void>;
getServerState(): 'started' | 'stopped';
getServerState(): 'started' | 'starting' | 'stopped';
getExistingDebuggerIds(): Array<DebuggerId>;
getExistingEmbeddedGameFrameDebuggerIds(): Array<DebuggerId>;
getExistingPreviewDebuggerIds(): Array<DebuggerId>;
Expand Down
Loading