Files
grafana/public/app/features/plugins/components/PluginErrorBoundary.test.tsx
Levente Balogh 77f84e494d Plugin Extensions: Add error boundaries (#107515)
* feat(grafana-data): expose PluginContext

This is aimed to be used in the `PluginErrorBoundary` (which is a class component, and cannot use the hook.)

* feat(PluginErrorBoundary): add an error boundary for plugins

* feat(ExtensionsErrorBoundary): add an error boundary for extensions)

* feat(Extensions/Utils): wrap components with error boundaries

* feat(Plugins): wrap root plugin page with an error boundary

* fix: Fallback component should always be visible for onClick() modals

* review: use object arguments instead of positional ones for `renderWithPluginContext()`

* review: update `wrapWithPluginContext()` to receive args as an object

* refactor(AppChromeExtensionPoint): remove the error boundary

We have an error boundary on the extensions-framework level now

* refactor(ExtensionSidebar): remove the ErrorBoundary from the extensions

This is handled on the extensions-framework level now.

* test(ExtensionSidebar): add tests

* chore: translation extraction

* chore: prettier formatting

* fix(PluginErrorBoundary): remove unnecessary type casting
2025-07-07 14:51:04 +02:00

185 lines
6.2 KiB
TypeScript

import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { PluginMeta, PluginType, PluginContext } from '@grafana/data';
import { getMockPlugin } from '@grafana/data/test';
import { PluginErrorBoundary } from './PluginErrorBoundary';
const ThrowingComponent = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error('Test error message');
}
return <div>Working component</div>;
};
const TestFallback = ({ error, errorInfo }: { error: Error | null; errorInfo: React.ErrorInfo | null }) => (
<div>
<div>Fallback rendered</div>
<div>Error: {error?.message}</div>
{errorInfo && <div>Error info available</div>}
</div>
);
const renderWithPluginContext = ({
children,
pluginMeta,
fallback,
onError,
}: {
children: React.ReactNode;
pluginMeta?: PluginMeta;
fallback?: React.ComponentType<{ error: Error | null; errorInfo: React.ErrorInfo | null }>;
onError?: (error: Error, info: React.ErrorInfo) => void;
}) => {
const mockPluginMeta = pluginMeta || getMockPlugin({ id: 'test-plugin', type: PluginType.panel });
return render(
<PluginContext.Provider value={{ meta: mockPluginMeta }}>
<PluginErrorBoundary fallback={fallback} onError={onError}>
{children}
</PluginErrorBoundary>
</PluginContext.Provider>
);
};
describe('PluginErrorBoundary', () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('should render children normally when no error occurs', () => {
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={false} /> });
expect(screen.getByText('Working component')).toBeInTheDocument();
});
it('should render null when an error occurs and no fallback is provided', () => {
const { container } = renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} /> });
expect(container.firstChild).toBeNull();
});
it('should render custom fallback component when an error occurs', () => {
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} />, fallback: TestFallback });
expect(screen.getByText('Fallback rendered')).toBeInTheDocument();
expect(screen.getByText('Error: Test error message')).toBeInTheDocument();
expect(screen.getByText('Error info available')).toBeInTheDocument();
});
it('should call onError callback when an error occurs', () => {
const onErrorMock = jest.fn();
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} />, onError: onErrorMock });
expect(onErrorMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Test error message' }),
expect.objectContaining({
componentStack: expect.any(String),
})
);
});
it('should log error to console with plugin ID when no onError callback is provided', () => {
const mockPluginMeta = getMockPlugin({ id: 'my-test-plugin', type: PluginType.datasource });
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} />, pluginMeta: mockPluginMeta });
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Plugin "my-test-plugin" failed to load:',
expect.objectContaining({ message: 'Test error message' }),
expect.objectContaining({
componentStack: expect.any(String),
})
);
});
it('should handle error when plugin context is not available', () => {
render(
<PluginErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</PluginErrorBoundary>
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Plugin "undefined" failed to load:',
expect.objectContaining({ message: 'Test error message' }),
expect.objectContaining({
componentStack: expect.any(String),
})
);
});
it('should update state correctly when error occurs', () => {
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} />, fallback: TestFallback });
// Verify that both error and errorInfo are available in the fallback
expect(screen.getByText('Error: Test error message')).toBeInTheDocument();
expect(screen.getByText('Error info available')).toBeInTheDocument();
});
it('should reset error state when children change to non-throwing component', () => {
const { rerender } = renderWithPluginContext({
children: <ThrowingComponent shouldThrow={true} />,
fallback: TestFallback,
});
// Initially should show fallback
expect(screen.getByText('Fallback rendered')).toBeInTheDocument();
// Re-render with non-throwing component
rerender(
<PluginContext.Provider value={{ meta: getMockPlugin({ id: 'test-plugin', type: PluginType.panel }) }}>
<PluginErrorBoundary fallback={TestFallback}>
<ThrowingComponent shouldThrow={false} />
</PluginErrorBoundary>
</PluginContext.Provider>
);
// Should still show fallback since error boundary doesn't reset automatically
expect(screen.getByText('Fallback rendered')).toBeInTheDocument();
});
it('should handle multiple children correctly', () => {
renderWithPluginContext({
children: (
<>
<div>First child</div>
<ThrowingComponent shouldThrow={false} />
<div>Third child</div>
</>
),
});
expect(screen.getByText('First child')).toBeInTheDocument();
expect(screen.getByText('Working component')).toBeInTheDocument();
expect(screen.getByText('Third child')).toBeInTheDocument();
});
it('should handle error in one of multiple children', () => {
renderWithPluginContext({
children: (
<>
<div>First child</div>
<ThrowingComponent shouldThrow={true} />
<div>Third child</div>
</>
),
fallback: TestFallback,
});
// Should show fallback and not render any of the children
expect(screen.getByText('Fallback rendered')).toBeInTheDocument();
expect(screen.queryByText('First child')).not.toBeInTheDocument();
expect(screen.queryByText('Third child')).not.toBeInTheDocument();
});
});