Handle unhandled rejections more gracefully. (#4417)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
parent
fb751c542b
commit
21fef1620d
|
@ -6,12 +6,13 @@
|
||||||
|
|
||||||
import stripAnsi from 'strip-ansi';
|
import stripAnsi from 'strip-ansi';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { main } from './gemini.js';
|
import { main, setupUnhandledRejectionHandler } from './gemini.js';
|
||||||
import {
|
import {
|
||||||
LoadedSettings,
|
LoadedSettings,
|
||||||
SettingsFile,
|
SettingsFile,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
} from './config/settings.js';
|
} from './config/settings.js';
|
||||||
|
import { appEvents, AppEvent } from './utils/events.js';
|
||||||
|
|
||||||
// Custom error to identify mock process.exit calls
|
// Custom error to identify mock process.exit calls
|
||||||
class MockProcessExitError extends Error {
|
class MockProcessExitError extends Error {
|
||||||
|
@ -55,6 +56,16 @@ vi.mock('update-notifier', () => ({
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./utils/events.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('./utils/events.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
appEvents: {
|
||||||
|
emit: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('./utils/sandbox.js', () => ({
|
vi.mock('./utils/sandbox.js', () => ({
|
||||||
sandbox_command: vi.fn(() => ''), // Default to no sandbox command
|
sandbox_command: vi.fn(() => ''), // Default to no sandbox command
|
||||||
start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves
|
start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves
|
||||||
|
@ -65,6 +76,8 @@ describe('gemini.tsx main function', () => {
|
||||||
let loadSettingsMock: ReturnType<typeof vi.mocked<typeof loadSettings>>;
|
let loadSettingsMock: ReturnType<typeof vi.mocked<typeof loadSettings>>;
|
||||||
let originalEnvGeminiSandbox: string | undefined;
|
let originalEnvGeminiSandbox: string | undefined;
|
||||||
let originalEnvSandbox: string | undefined;
|
let originalEnvSandbox: string | undefined;
|
||||||
|
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||||
|
[];
|
||||||
|
|
||||||
const processExitSpy = vi
|
const processExitSpy = vi
|
||||||
.spyOn(process, 'exit')
|
.spyOn(process, 'exit')
|
||||||
|
@ -82,6 +95,8 @@ describe('gemini.tsx main function', () => {
|
||||||
delete process.env.SANDBOX;
|
delete process.env.SANDBOX;
|
||||||
|
|
||||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
initialUnhandledRejectionListeners =
|
||||||
|
process.listeners('unhandledRejection');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -96,6 +111,15 @@ describe('gemini.tsx main function', () => {
|
||||||
} else {
|
} else {
|
||||||
delete process.env.SANDBOX;
|
delete process.env.SANDBOX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentListeners = process.listeners('unhandledRejection');
|
||||||
|
const addedListener = currentListeners.find(
|
||||||
|
(listener) => !initialUnhandledRejectionListeners.includes(listener),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addedListener) {
|
||||||
|
process.removeListener('unhandledRejection', addedListener);
|
||||||
|
}
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -145,7 +169,45 @@ describe('gemini.tsx main function', () => {
|
||||||
'Please fix /test/settings.json and try again.',
|
'Please fix /test/settings.json and try again.',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify process.exit was called (indirectly, via the thrown error)
|
// Verify process.exit was called.
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should log unhandled promise rejections and open debug console on first error', async () => {
|
||||||
|
const appEventsMock = vi.mocked(appEvents);
|
||||||
|
const rejectionError = new Error('Test unhandled rejection');
|
||||||
|
|
||||||
|
setupUnhandledRejectionHandler();
|
||||||
|
// Simulate an unhandled rejection.
|
||||||
|
// We are not using Promise.reject here as vitest will catch it.
|
||||||
|
// Instead we will dispatch the event manually.
|
||||||
|
process.emit('unhandledRejection', rejectionError, Promise.resolve());
|
||||||
|
|
||||||
|
// We need to wait for the rejection handler to be called.
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole);
|
||||||
|
expect(appEventsMock.emit).toHaveBeenCalledWith(
|
||||||
|
AppEvent.LogError,
|
||||||
|
expect.stringContaining('Unhandled Promise Rejection'),
|
||||||
|
);
|
||||||
|
expect(appEventsMock.emit).toHaveBeenCalledWith(
|
||||||
|
AppEvent.LogError,
|
||||||
|
expect.stringContaining('Please file a bug report using the /bug tool.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate a second rejection
|
||||||
|
const secondRejectionError = new Error('Second test unhandled rejection');
|
||||||
|
process.emit('unhandledRejection', secondRejectionError, Promise.resolve());
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
// Ensure emit was only called once for OpenDebugConsole
|
||||||
|
const openDebugConsoleCalls = appEventsMock.emit.mock.calls.filter(
|
||||||
|
(call) => call[0] === AppEvent.OpenDebugConsole,
|
||||||
|
);
|
||||||
|
expect(openDebugConsoleCalls.length).toBe(1);
|
||||||
|
|
||||||
|
// Avoid the process.exit error from being thrown.
|
||||||
|
processExitSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -40,6 +40,7 @@ import {
|
||||||
import { validateAuthMethod } from './config/auth.js';
|
import { validateAuthMethod } from './config/auth.js';
|
||||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||||
|
import { appEvents, AppEvent } from './utils/events.js';
|
||||||
|
|
||||||
function getNodeMemoryArgs(config: Config): string[] {
|
function getNodeMemoryArgs(config: Config): string[] {
|
||||||
const totalMemoryMB = os.totalmem() / (1024 * 1024);
|
const totalMemoryMB = os.totalmem() / (1024 * 1024);
|
||||||
|
@ -86,7 +87,30 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
|
||||||
}
|
}
|
||||||
import { runAcpPeer } from './acp/acpPeer.js';
|
import { runAcpPeer } from './acp/acpPeer.js';
|
||||||
|
|
||||||
|
export function setupUnhandledRejectionHandler() {
|
||||||
|
let unhandledRejectionOccurred = false;
|
||||||
|
process.on('unhandledRejection', (reason, _promise) => {
|
||||||
|
const errorMessage = `=========================================
|
||||||
|
This is an unexpected error. Please file a bug report using the /bug tool.
|
||||||
|
CRITICAL: Unhandled Promise Rejection!
|
||||||
|
=========================================
|
||||||
|
Reason: ${reason}${
|
||||||
|
reason instanceof Error && reason.stack
|
||||||
|
? `
|
||||||
|
Stack trace:
|
||||||
|
${reason.stack}`
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
appEvents.emit(AppEvent.LogError, errorMessage);
|
||||||
|
if (!unhandledRejectionOccurred) {
|
||||||
|
unhandledRejectionOccurred = true;
|
||||||
|
appEvents.emit(AppEvent.OpenDebugConsole);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function main() {
|
export async function main() {
|
||||||
|
setupUnhandledRejectionHandler();
|
||||||
const workspaceRoot = process.cwd();
|
const workspaceRoot = process.cwd();
|
||||||
const settings = loadSettings(workspaceRoot);
|
const settings = loadSettings(workspaceRoot);
|
||||||
|
|
||||||
|
@ -272,21 +296,6 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Global Unhandled Rejection Handler ---
|
|
||||||
process.on('unhandledRejection', (reason, _promise) => {
|
|
||||||
// Log other unexpected unhandled rejections as critical errors
|
|
||||||
console.error('=========================================');
|
|
||||||
console.error('CRITICAL: Unhandled Promise Rejection!');
|
|
||||||
console.error('=========================================');
|
|
||||||
console.error('Reason:', reason);
|
|
||||||
console.error('Stack trace may follow:');
|
|
||||||
if (!(reason instanceof Error)) {
|
|
||||||
console.error(reason);
|
|
||||||
}
|
|
||||||
// Exit for genuinely unhandled errors
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadNonInteractiveConfig(
|
async function loadNonInteractiveConfig(
|
||||||
config: Config,
|
config: Config,
|
||||||
extensions: Extension[],
|
extensions: Extension[],
|
||||||
|
|
|
@ -20,7 +20,8 @@ import {
|
||||||
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
|
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
import { StreamingState } from './types.js';
|
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||||
|
import { StreamingState, ConsoleMessageItem } from './types.js';
|
||||||
import { Tips } from './components/Tips.js';
|
import { Tips } from './components/Tips.js';
|
||||||
|
|
||||||
// Define a more complete mock server config based on actual Config
|
// Define a more complete mock server config based on actual Config
|
||||||
|
@ -192,6 +193,14 @@ vi.mock('./hooks/useLogger', () => ({
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./hooks/useConsoleMessages.js', () => ({
|
||||||
|
useConsoleMessages: vi.fn(() => ({
|
||||||
|
consoleMessages: [],
|
||||||
|
handleNewMessage: vi.fn(),
|
||||||
|
clearConsoleMessages: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../config/config.js', async (importOriginal) => {
|
vi.mock('../config/config.js', async (importOriginal) => {
|
||||||
const actual = await importOriginal();
|
const actual = await importOriginal();
|
||||||
return {
|
return {
|
||||||
|
@ -692,4 +701,35 @@ describe('App UI', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('errorCount', () => {
|
||||||
|
it('should correctly sum the counts of error messages', async () => {
|
||||||
|
const mockConsoleMessages: ConsoleMessageItem[] = [
|
||||||
|
{ type: 'error', content: 'First error', count: 1 },
|
||||||
|
{ type: 'log', content: 'some log', count: 1 },
|
||||||
|
{ type: 'error', content: 'Second error', count: 3 },
|
||||||
|
{ type: 'warn', content: 'a warning', count: 1 },
|
||||||
|
{ type: 'error', content: 'Third error', count: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||||
|
consoleMessages: mockConsoleMessages,
|
||||||
|
handleNewMessage: vi.fn(),
|
||||||
|
clearConsoleMessages: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lastFrame, unmount } = render(
|
||||||
|
<App
|
||||||
|
config={mockConfig as unknown as ServerConfig}
|
||||||
|
settings={mockSettings}
|
||||||
|
version={mockVersion}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
currentUnmount = unmount;
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
// Total error count should be 1 + 3 + 1 = 5
|
||||||
|
expect(lastFrame()).toContain('5 errors');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -87,6 +87,7 @@ import ansiEscapes from 'ansi-escapes';
|
||||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||||
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
||||||
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
||||||
|
import { appEvents, AppEvent } from '../utils/events.js';
|
||||||
|
|
||||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||||
|
|
||||||
|
@ -176,13 +177,38 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const openDebugConsole = () => {
|
||||||
|
setShowErrorDetails(true);
|
||||||
|
setConstrainHeight(false); // Make sure the user sees the full message.
|
||||||
|
};
|
||||||
|
appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole);
|
||||||
|
|
||||||
|
const logErrorHandler = (errorMessage: unknown) => {
|
||||||
|
handleNewMessage({
|
||||||
|
type: 'error',
|
||||||
|
content: String(errorMessage),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
appEvents.on(AppEvent.LogError, logErrorHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole);
|
||||||
|
appEvents.off(AppEvent.LogError, logErrorHandler);
|
||||||
|
};
|
||||||
|
}, [handleNewMessage]);
|
||||||
|
|
||||||
const openPrivacyNotice = useCallback(() => {
|
const openPrivacyNotice = useCallback(() => {
|
||||||
setShowPrivacyNotice(true);
|
setShowPrivacyNotice(true);
|
||||||
}, []);
|
}, []);
|
||||||
const initialPromptSubmitted = useRef(false);
|
const initialPromptSubmitted = useRef(false);
|
||||||
|
|
||||||
const errorCount = useMemo(
|
const errorCount = useMemo(
|
||||||
() => consoleMessages.filter((msg) => msg.type === 'error').length,
|
() =>
|
||||||
|
consoleMessages
|
||||||
|
.filter((msg) => msg.type === 'error')
|
||||||
|
.reduce((total, msg) => total + msg.count, 0),
|
||||||
[consoleMessages],
|
[consoleMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -5,127 +5,105 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
import { useConsoleMessages } from './useConsoleMessages.js';
|
import { vi } from 'vitest';
|
||||||
import { ConsoleMessageItem } from '../types.js';
|
import { useConsoleMessages } from './useConsoleMessages';
|
||||||
|
import { useCallback } from 'react';
|
||||||
// Mock setTimeout and clearTimeout
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
describe('useConsoleMessages', () => {
|
describe('useConsoleMessages', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const useTestableConsoleMessages = () => {
|
||||||
|
const { handleNewMessage, ...rest } = useConsoleMessages();
|
||||||
|
const log = useCallback(
|
||||||
|
(content: string) => handleNewMessage({ type: 'log', content, count: 1 }),
|
||||||
|
[handleNewMessage],
|
||||||
|
);
|
||||||
|
const error = useCallback(
|
||||||
|
(content: string) =>
|
||||||
|
handleNewMessage({ type: 'error', content, count: 1 }),
|
||||||
|
[handleNewMessage],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
log,
|
||||||
|
error,
|
||||||
|
clearConsoleMessages: rest.clearConsoleMessages,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
it('should initialize with an empty array of console messages', () => {
|
it('should initialize with an empty array of console messages', () => {
|
||||||
const { result } = renderHook(() => useConsoleMessages());
|
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||||
expect(result.current.consoleMessages).toEqual([]);
|
expect(result.current.consoleMessages).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add a new message', () => {
|
it('should add a new message when log is called', async () => {
|
||||||
const { result } = renderHook(() => useConsoleMessages());
|
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||||
const message: ConsoleMessageItem = {
|
|
||||||
type: 'log',
|
|
||||||
content: 'Test message',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleNewMessage(message);
|
result.current.log('Test message');
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
await act(async () => {
|
||||||
vi.runAllTimers(); // Process the queue
|
await vi.advanceTimersByTimeAsync(20);
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.consoleMessages).toEqual([{ ...message, count: 1 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should consolidate identical consecutive messages', () => {
|
|
||||||
const { result } = renderHook(() => useConsoleMessages());
|
|
||||||
const message: ConsoleMessageItem = {
|
|
||||||
type: 'log',
|
|
||||||
content: 'Test message',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleNewMessage(message);
|
|
||||||
result.current.handleNewMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.runAllTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.consoleMessages).toEqual([{ ...message, count: 2 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not consolidate different messages', () => {
|
|
||||||
const { result } = renderHook(() => useConsoleMessages());
|
|
||||||
const message1: ConsoleMessageItem = {
|
|
||||||
type: 'log',
|
|
||||||
content: 'Test message 1',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
const message2: ConsoleMessageItem = {
|
|
||||||
type: 'error',
|
|
||||||
content: 'Test message 2',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleNewMessage(message1);
|
|
||||||
result.current.handleNewMessage(message2);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.runAllTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.consoleMessages).toEqual([
|
expect(result.current.consoleMessages).toEqual([
|
||||||
{ ...message1, count: 1 },
|
{ type: 'log', content: 'Test message', count: 1 },
|
||||||
{ ...message2, count: 1 },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not consolidate messages if type is different', () => {
|
it('should batch and count identical consecutive messages', async () => {
|
||||||
const { result } = renderHook(() => useConsoleMessages());
|
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||||
const message1: ConsoleMessageItem = {
|
|
||||||
type: 'log',
|
|
||||||
content: 'Test message',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
const message2: ConsoleMessageItem = {
|
|
||||||
type: 'error',
|
|
||||||
content: 'Test message',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleNewMessage(message1);
|
result.current.log('Test message');
|
||||||
result.current.handleNewMessage(message2);
|
result.current.log('Test message');
|
||||||
|
result.current.log('Test message');
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
await act(async () => {
|
||||||
vi.runAllTimers();
|
await vi.advanceTimersByTimeAsync(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.consoleMessages).toEqual([
|
expect(result.current.consoleMessages).toEqual([
|
||||||
{ ...message1, count: 1 },
|
{ type: 'log', content: 'Test message', count: 3 },
|
||||||
{ ...message2, count: 1 },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear console messages', () => {
|
it('should not batch different messages', async () => {
|
||||||
const { result } = renderHook(() => useConsoleMessages());
|
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||||
const message: ConsoleMessageItem = {
|
|
||||||
type: 'log',
|
|
||||||
content: 'Test message',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleNewMessage(message);
|
result.current.log('First message');
|
||||||
|
result.current.error('Second message');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.consoleMessages).toEqual([
|
||||||
|
{ type: 'log', content: 'First message', count: 1 },
|
||||||
|
{ type: 'error', content: 'Second message', count: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear all messages when clearConsoleMessages is called', async () => {
|
||||||
|
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.runAllTimers();
|
result.current.log('A message');
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.consoleMessages).toHaveLength(1);
|
expect(result.current.consoleMessages).toHaveLength(1);
|
||||||
|
@ -134,79 +112,36 @@ describe('useConsoleMessages', () => {
|
||||||
result.current.clearConsoleMessages();
|
result.current.clearConsoleMessages();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.consoleMessages).toEqual([]);
|
expect(result.current.consoleMessages).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear pending timeout on clearConsoleMessages', () => {
|
it('should clear the pending timeout when clearConsoleMessages is called', () => {
|
||||||
const { result } = renderHook(() => useConsoleMessages());
|
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||||
const message: ConsoleMessageItem = {
|
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||||
type: 'log',
|
|
||||||
content: 'Test message',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleNewMessage(message); // This schedules a timeout
|
result.current.log('A message');
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.clearConsoleMessages();
|
result.current.clearConsoleMessages();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure the queue is empty and no more messages are processed
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||||
act(() => {
|
clearTimeoutSpy.mockRestore();
|
||||||
vi.runAllTimers(); // If timeout wasn't cleared, this would process the queue
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.consoleMessages).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear message queue on clearConsoleMessages', () => {
|
it('should clean up the timeout on unmount', () => {
|
||||||
const { result } = renderHook(() => useConsoleMessages());
|
const { result, unmount } = renderHook(() => useTestableConsoleMessages());
|
||||||
const message: ConsoleMessageItem = {
|
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||||
type: 'log',
|
|
||||||
content: 'Test message',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
// Add a message but don't process the queue yet
|
result.current.log('A message');
|
||||||
result.current.handleNewMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.clearConsoleMessages();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process any pending timeouts (should be none related to message queue)
|
|
||||||
act(() => {
|
|
||||||
vi.runAllTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The consoleMessages should be empty because the queue was cleared before processing
|
|
||||||
expect(result.current.consoleMessages).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cleanup timeout on unmount', () => {
|
|
||||||
const { result, unmount } = renderHook(() => useConsoleMessages());
|
|
||||||
const message: ConsoleMessageItem = {
|
|
||||||
type: 'log',
|
|
||||||
content: 'Test message',
|
|
||||||
count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleNewMessage(message);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
// This is a bit indirect. We check that clearTimeout was called.
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||||
// If clearTimeout was not called, and we run timers, an error might occur
|
clearTimeoutSpy.mockRestore();
|
||||||
// or the state might change, which it shouldn't after unmount.
|
|
||||||
// Vitest's vi.clearAllTimers() or specific checks for clearTimeout calls
|
|
||||||
// would be more direct if available and easy to set up here.
|
|
||||||
// For now, we rely on the useEffect cleanup pattern.
|
|
||||||
expect(vi.getTimerCount()).toBe(0); // Check if all timers are cleared
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,7 +4,13 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useTransition,
|
||||||
|
} from 'react';
|
||||||
import { ConsoleMessageItem } from '../types.js';
|
import { ConsoleMessageItem } from '../types.js';
|
||||||
|
|
||||||
export interface UseConsoleMessagesReturn {
|
export interface UseConsoleMessagesReturn {
|
||||||
|
@ -13,75 +19,90 @@ export interface UseConsoleMessagesReturn {
|
||||||
clearConsoleMessages: () => void;
|
clearConsoleMessages: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConsoleMessages(): UseConsoleMessagesReturn {
|
type Action =
|
||||||
const [consoleMessages, setConsoleMessages] = useState<ConsoleMessageItem[]>(
|
| { type: 'ADD_MESSAGES'; payload: ConsoleMessageItem[] }
|
||||||
[],
|
| { type: 'CLEAR' };
|
||||||
);
|
|
||||||
const messageQueueRef = useRef<ConsoleMessageItem[]>([]);
|
|
||||||
const messageQueueTimeoutRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const processMessageQueue = useCallback(() => {
|
function consoleMessagesReducer(
|
||||||
if (messageQueueRef.current.length === 0) {
|
state: ConsoleMessageItem[],
|
||||||
return;
|
action: Action,
|
||||||
}
|
): ConsoleMessageItem[] {
|
||||||
|
switch (action.type) {
|
||||||
const newMessagesToAdd = messageQueueRef.current;
|
case 'ADD_MESSAGES': {
|
||||||
messageQueueRef.current = [];
|
const newMessages = [...state];
|
||||||
|
for (const queuedMessage of action.payload) {
|
||||||
setConsoleMessages((prevMessages) => {
|
const lastMessage = newMessages[newMessages.length - 1];
|
||||||
const newMessages = [...prevMessages];
|
|
||||||
newMessagesToAdd.forEach((queuedMessage) => {
|
|
||||||
if (
|
if (
|
||||||
newMessages.length > 0 &&
|
lastMessage &&
|
||||||
newMessages[newMessages.length - 1].type === queuedMessage.type &&
|
lastMessage.type === queuedMessage.type &&
|
||||||
newMessages[newMessages.length - 1].content === queuedMessage.content
|
lastMessage.content === queuedMessage.content
|
||||||
) {
|
) {
|
||||||
newMessages[newMessages.length - 1].count =
|
// Create a new object for the last message to ensure React detects
|
||||||
(newMessages[newMessages.length - 1].count || 1) + 1;
|
// the change, preventing mutation of the existing state object.
|
||||||
|
newMessages[newMessages.length - 1] = {
|
||||||
|
...lastMessage,
|
||||||
|
count: lastMessage.count + 1,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
newMessages.push({ ...queuedMessage, count: 1 });
|
newMessages.push({ ...queuedMessage, count: 1 });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
return newMessages;
|
return newMessages;
|
||||||
});
|
|
||||||
|
|
||||||
messageQueueTimeoutRef.current = null; // Allow next scheduling
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scheduleQueueProcessing = useCallback(() => {
|
|
||||||
if (messageQueueTimeoutRef.current === null) {
|
|
||||||
messageQueueTimeoutRef.current = setTimeout(
|
|
||||||
processMessageQueue,
|
|
||||||
0,
|
|
||||||
) as unknown as number;
|
|
||||||
}
|
}
|
||||||
}, [processMessageQueue]);
|
case 'CLEAR':
|
||||||
|
return [];
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||||
|
const [consoleMessages, dispatch] = useReducer(consoleMessagesReducer, []);
|
||||||
|
const messageQueueRef = useRef<ConsoleMessageItem[]>([]);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const processQueue = useCallback(() => {
|
||||||
|
if (messageQueueRef.current.length > 0) {
|
||||||
|
const messagesToProcess = messageQueueRef.current;
|
||||||
|
messageQueueRef.current = [];
|
||||||
|
startTransition(() => {
|
||||||
|
dispatch({ type: 'ADD_MESSAGES', payload: messagesToProcess });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleNewMessage = useCallback(
|
const handleNewMessage = useCallback(
|
||||||
(message: ConsoleMessageItem) => {
|
(message: ConsoleMessageItem) => {
|
||||||
messageQueueRef.current.push(message);
|
messageQueueRef.current.push(message);
|
||||||
scheduleQueueProcessing();
|
if (!timeoutRef.current) {
|
||||||
|
// Batch updates using a timeout. 16ms is a reasonable delay to batch
|
||||||
|
// rapid-fire messages without noticeable lag.
|
||||||
|
timeoutRef.current = setTimeout(processQueue, 16);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[scheduleQueueProcessing],
|
[processQueue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearConsoleMessages = useCallback(() => {
|
const clearConsoleMessages = useCallback(() => {
|
||||||
setConsoleMessages([]);
|
if (timeoutRef.current) {
|
||||||
if (messageQueueTimeoutRef.current !== null) {
|
clearTimeout(timeoutRef.current);
|
||||||
clearTimeout(messageQueueTimeoutRef.current);
|
timeoutRef.current = null;
|
||||||
messageQueueTimeoutRef.current = null;
|
|
||||||
}
|
}
|
||||||
messageQueueRef.current = [];
|
messageQueueRef.current = [];
|
||||||
|
startTransition(() => {
|
||||||
|
dispatch({ type: 'CLEAR' });
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() => () => {
|
||||||
// Cleanup on unmount
|
if (timeoutRef.current) {
|
||||||
() => {
|
clearTimeout(timeoutRef.current);
|
||||||
if (messageQueueTimeoutRef.current !== null) {
|
}
|
||||||
clearTimeout(messageQueueTimeoutRef.current);
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export enum AppEvent {
|
||||||
|
OpenDebugConsole = 'open-debug-console',
|
||||||
|
LogError = 'log-error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appEvents = new EventEmitter();
|
Loading…
Reference in New Issue