Merge branch 'master' into feature/analytics_uptime_dashboard

# Conflicts:
#	.env.example
#	aidlc-docs/features/react-frontend/aidlc-state.md
#	aidlc-docs/features/react-frontend/audit.md
#	aidlc-docs/features/react-frontend/operations/monitoring/monitoring-plan.md
#	aidlc-docs/features/react-frontend/operations/monitoring/monitoring-setup.md
#	aidlc-docs/features/react-frontend/operations/production-readiness-checklist.md
#	src/components/RootLayout.tsx
#	src/vite-env.d.ts
This commit is contained in:
2026-07-25 20:14:16 +02:00
26 changed files with 619 additions and 128 deletions
+2
View File
@@ -1,5 +1,6 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import * as Sentry from '@sentry/react';
interface ErrorBoundaryProps {
children: ReactNode;
@@ -22,6 +23,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Unexpected application error', error, info);
Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
}
render(): ReactNode {
+2
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { Nav } from './Nav';
import { Footer } from './Footer';
import { UmamiAnalytics } from './UmamiAnalytics';
import { SentryTestButton } from './SentryTestButton';
export function RootLayout({ children }: { children: ReactNode }) {
return (
@@ -10,6 +11,7 @@ export function RootLayout({ children }: { children: ReactNode }) {
<Nav />
{children}
<Footer />
<SentryTestButton />
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import * as Sentry from '@sentry/react';
/**
* Alleen zichtbaar tijdens lokale development (`pnpm dev`) en in de testomgeving
* (waar de build met `VITE_APP_ENV=test` wordt gebouwd, zie continuous_integration.yaml).
* In een productiebuild (VITE_APP_ENV ontbreekt of is 'production') is deze knop
* altijd verborgen, ook als iemand vergeet de variabele expliciet te zetten.
*/
const isVisible = import.meta.env.DEV || import.meta.env.VITE_APP_ENV === 'test';
/** Hoe lang de bevestigingsmelding zichtbaar blijft voordat hij vanzelf verdwijnt. */
const TOAST_DURATION_MS = 4000;
/**
* Losstaand van het component zodat dit direct (zonder DOM/React event-dispatch)
* getest kan worden. React vangt fouten uit event handlers namelijk NIET op via
* een ErrorBoundary (die vangt alleen render-/lifecycle-fouten). De fout hieronder
* blijft dus bewust een onafgevangen (uncaught) fout, die door Sentry's eigen
* globale `window.onerror`-handler wordt opgepikt — precies zoals bedoeld voor
* deze test-knop.
*/
export function handleSentryTestErrorClick(): void {
// Send a log before throwing the error
Sentry.logger.info('User triggered test error', {
action: 'test_error_button_click',
});
// Send a test metric before throwing the error
Sentry.metrics.count('test_counter', 1);
throw new Error('This is your first error!');
}
/**
* Tijdelijke testknop om te verifiëren dat Sentry errors, logs en metrics
* daadwerkelijk binnenkomen.
*/
export function SentryTestButton() {
const [showToast, setShowToast] = useState(false);
// Verberg de melding automatisch na een paar seconden.
useEffect(() => {
if (!showToast) return;
const hideTimer = setTimeout(() => setShowToast(false), TOAST_DURATION_MS);
return () => clearTimeout(hideTimer);
}, [showToast]);
if (!isVisible) {
return null;
}
function onClick() {
setShowToast(true);
// De daadwerkelijke fout (met log + metric) pas na deze tick versturen,
// zodat de melding hierboven eerst kan renderen/schilderen voordat de
// onafgevangen fout verderop wordt gegooid.
setTimeout(handleSentryTestErrorClick, 0);
}
return (
<>
{showToast && (
<div
role="status"
className="fixed bottom-20 right-4 z-50 rounded bg-green-600 px-4 py-2 text-sm font-semibold text-white shadow-lg"
>
Test error verzonden naar Sentry
</div>
)}
<button
type="button"
onClick={onClick}
className="fixed bottom-4 right-4 z-50 rounded bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-red-700"
>
Break the world
</button>
</>
);
}
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as Sentry from '@sentry/react';
import { ErrorBoundary } from '../ErrorBoundary';
vi.mock('@sentry/react', () => ({
captureException: vi.fn(),
}));
function ThrowingChild(): never {
throw new Error('boom');
}
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children when there is no error', () => {
render(
<ErrorBoundary>
<p>Alles werkt</p>
</ErrorBoundary>,
);
expect(screen.getByText('Alles werkt')).toBeInTheDocument();
});
it('renders the generic fallback message when a child throws', () => {
// Suppress the expected React error boundary console.error noise for this test.
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
);
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
expect(screen.getByText('Er ging iets mis. Probeer de pagina te vernieuwen.')).toBeInTheDocument();
consoleErrorSpy.mockRestore();
});
it('reports the caught error to Sentry', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
);
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ componentStack: expect.any(String) }) }),
);
consoleErrorSpy.mockRestore();
});
});
@@ -0,0 +1,81 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as Sentry from '@sentry/react';
import { SentryTestButton, handleSentryTestErrorClick } from '../SentryTestButton';
vi.mock('@sentry/react', () => ({
captureException: vi.fn(),
logger: { info: vi.fn() },
metrics: { count: vi.fn() },
}));
/**
* De echte fout wordt bewust ongevangen via een `setTimeout` gegooid (zie
* SentryTestButton.tsx). Om dat in deze fake-timer-tests te simuleren zonder
* dat de test zelf crasht op die intentionele throw, wordt het "doortikken"
* van de timers hierin afgevangen.
*/
function advanceTimersIgnoringIntentionalThrow(ms: number) {
try {
act(() => {
vi.advanceTimersByTime(ms);
});
} catch {
// verwacht: de intentionele test-fout uit handleSentryTestErrorClick
}
}
describe('SentryTestButton', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('is visible in the current (development/test) build', () => {
render(<SentryTestButton />);
expect(screen.getByRole('button', { name: 'Break the world' })).toBeInTheDocument();
});
it('sends a log and a metric to Sentry, then throws an intentional test error', () => {
expect(() => handleSentryTestErrorClick()).toThrow('This is your first error!');
expect(Sentry.logger.info).toHaveBeenCalledWith('User triggered test error', {
action: 'test_error_button_click',
});
expect(Sentry.metrics.count).toHaveBeenCalledWith('test_counter', 1);
});
it('shows a confirmation toast immediately when clicked, before the test error fires', () => {
render(<SentryTestButton />);
fireEvent.click(screen.getByRole('button', { name: 'Break the world' }));
expect(screen.getByRole('status')).toHaveTextContent('Test error verzonden naar Sentry');
// De log/metric/throw zijn pas gepland (setTimeout), nog niet uitgevoerd.
expect(Sentry.logger.info).not.toHaveBeenCalled();
advanceTimersIgnoringIntentionalThrow(0);
expect(Sentry.logger.info).toHaveBeenCalledWith('User triggered test error', {
action: 'test_error_button_click',
});
expect(Sentry.metrics.count).toHaveBeenCalledWith('test_counter', 1);
});
it('hides the toast again after it has been shown for a while', () => {
render(<SentryTestButton />);
fireEvent.click(screen.getByRole('button', { name: 'Break the world' }));
expect(screen.getByRole('status')).toBeInTheDocument();
// De intentionele fout (0ms-timer) stopt het "doortikken" van timers
// binnen dezelfde advanceTimersByTime-call, dus eerst die apart afhandelen
// voordat de resterende hide-timer (4000ms) doorgetikt kan worden.
advanceTimersIgnoringIntentionalThrow(0);
advanceTimersIgnoringIntentionalThrow(4000);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});