Skip to content
Merged
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
4 changes: 2 additions & 2 deletions assets/js/log-viewer/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { createRoot } from 'react-dom/client';
import { useStore } from 'zustand';
import { useShallow } from 'zustand/react/shallow';

import { useMonacoSync } from '../hooks/useMonacoSync';
import { type Monaco, MonacoEditor } from '../monaco';

import { createLogStore } from './store';
import { useMonacoSync } from './useMonacoSync';

export function mount(
el: HTMLElement,
Expand Down Expand Up @@ -85,7 +85,7 @@ const LogViewer = ({
// Define a simple tokenizer for the language
monaco.languages.setMonarchTokensProvider('openFnLogs', {
tokenizer: {
root: [[/^([A-Z\/]{2,4})/, 'logSource']],
root: [[/^([A-Z/]{2,4})/, 'logSource']],
},
});
}
Expand Down
File renamed without changes.
251 changes: 251 additions & 0 deletions assets/test/log-viewer/component.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
/**
* LogViewer Component Tests
*
* Tests the LogViewer component focusing on Monaco Editor
* initialization race conditions where logs arrive via WebSocket
* before Monaco is fully ready.
*
* This addresses the race condition fixed in PR #4110 where logs
* would disappear on browser refresh in the collaborative editor IDE.
*/

import { screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';

import { createLogStore, type LogLine } from '../../js/log-viewer/store';

// Mock @monaco-editor/react to prevent loading issues
vi.mock('@monaco-editor/react', () => ({
default: ({ value }: { value: string }) => (
<div data-testid="monaco-editor" data-value={value}>
{value || 'Loading...'}
</div>
),
}));

// Mock the monaco module that LogViewer imports
vi.mock('../../js/monaco', () => ({
MonacoEditor: ({
value,
loading,
}: {
value: string;
loading?: React.ReactNode;
}) => (
<div data-testid="monaco-editor" data-value={value || ''}>
{loading || value || 'Editor'}
</div>
),
}));

// Import mount after mocks are set up
const { mount } = await import('../../js/log-viewer/component');

describe('LogViewer Component - Monaco Initialization Race Conditions', () => {
let store: ReturnType<typeof createLogStore>;
let container: HTMLElement;

const sampleLogs: LogLine[] = [
{
id: 'log-1',
message: 'Test log message 1',
source: 'RTE',
level: 'info',
step_id: 'step-1',
timestamp: new Date('2025-01-01T00:00:00Z'),
},
{
id: 'log-2',
message: 'Test log message 2',
source: 'RTE',
level: 'info',
step_id: 'step-1',
timestamp: new Date('2025-01-01T00:00:01Z'),
},
];

beforeEach(() => {
vi.clearAllMocks();
store = createLogStore();
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(() => {
document.body.removeChild(container);
});

test('renders Monaco editor component', async () => {
mount(container, store);

await waitFor(() => {
const editor = container.querySelector('[data-testid="monaco-editor"]');
expect(editor).not.toBeNull();
});
});

test('handles logs that arrive before component mount (race condition)', async () => {
// Step 1: Add logs to store BEFORE mounting component
store.getState().addLogLines(sampleLogs);
const expectedContent = store.getState().formattedLogLines;

// Step 2: Mount component
mount(container, store);

// Step 3: Wait for Monaco to receive the value prop
await waitFor(() => {
const editor = screen.getByTestId('monaco-editor');
const dataValue = editor.getAttribute('data-value');
expect(dataValue).toBe(expectedContent);
});

// Verify both log messages are in the content
expect(expectedContent).toContain('Test log message 1');
expect(expectedContent).toContain('Test log message 2');
});

test('handles logs that arrive immediately after mount', async () => {
// Another race condition: logs arrive right after component mounts

// Step 1: Mount component first
mount(container, store);

// Step 2: Logs arrive immediately (within milliseconds)
store.getState().addLogLines(sampleLogs);
const expectedContent = store.getState().formattedLogLines;

// Step 3: useMonacoSync should handle this and apply the value
await waitFor(() => {
const editor = screen.getByTestId('monaco-editor');
const dataValue = editor.getAttribute('data-value');
expect(dataValue).toBe(expectedContent);
});
});

test('handles multiple rapid log updates before editor ready', async () => {
// Simulate multiple log batches arriving rapidly

mount(container, store);

// Multiple rapid updates
const logs1: LogLine[] = [
{
id: 'log-1',
message: 'First batch',
source: 'RTE',
level: 'info',
step_id: 'step-1',
timestamp: new Date('2025-01-01T00:00:00Z'),
},
];

const logs2: LogLine[] = [
{
id: 'log-2',
message: 'Second batch',
source: 'RTE',
level: 'info',
step_id: 'step-1',
timestamp: new Date('2025-01-01T00:00:01Z'),
},
];

const logs3: LogLine[] = [
{
id: 'log-3',
message: 'Third batch',
source: 'RTE',
level: 'info',
step_id: 'step-1',
timestamp: new Date('2025-01-01T00:00:02Z'),
},
];

store.getState().addLogLines(logs1);
store.getState().addLogLines(logs2);
store.getState().addLogLines(logs3);

const finalContent = store.getState().formattedLogLines;

await waitFor(() => {
const editor = screen.getByTestId('monaco-editor');
const dataValue = editor.getAttribute('data-value');
expect(dataValue).toBe(finalContent);
});

// All three batches should be in the final content
expect(finalContent).toContain('First batch');
expect(finalContent).toContain('Second batch');
expect(finalContent).toContain('Third batch');
});

test('updates when new logs arrive after initial render', async () => {
// Test that subsequent log updates continue to work

mount(container, store);

// First batch of logs
store.getState().addLogLines([sampleLogs[0]]);

await waitFor(() => {
const editor = screen.getByTestId('monaco-editor');
const dataValue = editor.getAttribute('data-value') || '';
expect(dataValue).toContain('Test log message 1');
});

// Second batch arrives
store.getState().addLogLines([sampleLogs[1]]);

await waitFor(() => {
const editor = screen.getByTestId('monaco-editor');
const dataValue = editor.getAttribute('data-value') || '';
expect(dataValue).toContain('Test log message 1');
expect(dataValue).toContain('Test log message 2');
});
});

test('handles log level filtering', async () => {
// Test that log level filtering works with the sync

const debugLog: LogLine = {
id: 'log-debug',
message: 'Debug message',
source: 'RTE',
level: 'debug',
step_id: 'step-1',
timestamp: new Date('2025-01-01T00:00:00Z'),
};

const infoLog: LogLine = {
id: 'log-info',
message: 'Info message',
source: 'RTE',
level: 'info',
step_id: 'step-1',
timestamp: new Date('2025-01-01T00:00:01Z'),
};

// Set to info level (default) - debug should be filtered out
store.getState().setDesiredLogLevel('info');
store.getState().addLogLines([debugLog, infoLog]);

mount(container, store);

await waitFor(() => {
const editor = screen.getByTestId('monaco-editor');
const dataValue = editor.getAttribute('data-value') || '';
expect(dataValue).toContain('Info message');
expect(dataValue).not.toContain('Debug message');
});

// Change to debug level - both should appear
store.getState().setDesiredLogLevel('debug');

await waitFor(() => {
const editor = screen.getByTestId('monaco-editor');
const dataValue = editor.getAttribute('data-value') || '';
expect(dataValue).toContain('Info message');
expect(dataValue).toContain('Debug message');
});
});
});
2 changes: 1 addition & 1 deletion assets/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default defineConfig({
// Mock monaco-editor for tests (8MB+ package causes issues)
'monaco-editor': path.resolve(
__dirname,
'./test/_mocks/monaco-editor.ts'
'./test/__mocks__/monaco-editor.ts'
),
},
},
Expand Down