diff --git a/aidlc-docs/features/react-frontend/operations/monitoring/monitoring-setup.md b/aidlc-docs/features/react-frontend/operations/monitoring/monitoring-setup.md index 15bdd54..5f1c89a 100644 --- a/aidlc-docs/features/react-frontend/operations/monitoring/monitoring-setup.md +++ b/aidlc-docs/features/react-frontend/operations/monitoring/monitoring-setup.md @@ -30,6 +30,7 @@ Both the console and Sentry are now active (original Question 3 resolved as a co ### Temporary manual test tool: `SentryTestButton` - `src/components/SentryTestButton.tsx` renders a "Break the world" button, mounted globally via `RootLayout.tsx`, used to manually verify that errors, logs (`Sentry.logger.info`), and metrics (`Sentry.metrics.count`) actually arrive in Sentry end-to-end. - **Visibility**: only shown during local development (`pnpm dev`, via Vite's `import.meta.env.DEV`) and in the test environment (via the new build-time `VITE_APP_ENV` variable, set to `test` by `continuous_integration.yaml`'s `Build` step). It is hidden by default (including in any future production build) unless one of those conditions is explicitly true. +- **Visual feedback**: clicking the button immediately shows a green confirmation toast ("Test error verzonden naar Sentry ✅", `role="status"`) that auto-hides after 4 seconds, so the user gets clear confirmation that the test action fired — without this, the resulting uncaught error/blank state gave no indication anything happened. The actual log/metric/throw (`handleSentryTestErrorClick`) is fired on the next tick (`setTimeout(..., 0)`) so the toast has a chance to render/paint first. - `Sentry.logger.*` requires `enableLogs: true` in `Sentry.init()` (`src/main.tsx`) — added specifically to support this test button (and any future structured logging). - The thrown error is **intentionally uncaught**: React error boundaries do not catch errors thrown from event handlers (only render/lifecycle errors), so this relies on Sentry's own global `window.onerror` handler, exactly like the official Sentry test snippet. - This is a temporary verification tool, not a permanent feature — remove `SentryTestButton` (and its usage in `RootLayout.tsx`) once Sentry has been confirmed to receive test errors/logs/metrics end-to-end. diff --git a/src/components/SentryTestButton.tsx b/src/components/SentryTestButton.tsx index 11ca63b..dabfe77 100644 --- a/src/components/SentryTestButton.tsx +++ b/src/components/SentryTestButton.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import * as Sentry from '@sentry/react'; /** @@ -8,6 +9,9 @@ import * as Sentry from '@sentry/react'; */ 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 @@ -31,17 +35,44 @@ export function handleSentryTestErrorClick(): void { * 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 && ( +
+ Test error verzonden naar Sentry ✅ +
+ )} + + ); } diff --git a/src/components/__tests__/SentryTestButton.test.tsx b/src/components/__tests__/SentryTestButton.test.tsx index 4185b07..adba502 100644 --- a/src/components/__tests__/SentryTestButton.test.tsx +++ b/src/components/__tests__/SentryTestButton.test.tsx @@ -1,5 +1,5 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +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'; @@ -9,9 +9,30 @@ vi.mock('@sentry/react', () => ({ 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', () => { @@ -27,4 +48,34 @@ describe('SentryTestButton', () => { }); expect(Sentry.metrics.count).toHaveBeenCalledWith('test_counter', 1); }); + + it('shows a confirmation toast immediately when clicked, before the test error fires', () => { + render(); + 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(); + 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(); + }); });