mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { triggerSentryTestEvent } from '../../../services/analytics';
|
||||
import { APP_ENVIRONMENT } from '../../../utils/config';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import SettingsMenuItem from '../components/SettingsMenuItem';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
@@ -134,8 +138,72 @@ const developerItems = [
|
||||
},
|
||||
];
|
||||
|
||||
type SentryTestStatus =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'sending' }
|
||||
| { kind: 'sent'; eventId: string | undefined }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
// Staging-only Sentry pipeline check (issue #1072). Removed once the
|
||||
// staging dashboard confirms events are landing with the right tags.
|
||||
const SentryTestRow = () => {
|
||||
const [status, setStatus] = useState<SentryTestStatus>({ kind: 'idle' });
|
||||
|
||||
const onClick = async () => {
|
||||
setStatus({ kind: 'sending' });
|
||||
try {
|
||||
const eventId = await triggerSentryTestEvent();
|
||||
setStatus({ kind: 'sent', eventId });
|
||||
} catch (err) {
|
||||
setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-amber-300 bg-amber-50">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-amber-900">Trigger Sentry Test (staging)</div>
|
||||
<div className="text-xs text-amber-800 mt-0.5">
|
||||
Fires a tagged error to verify the Sentry pipeline. Issue #1072 — remove after
|
||||
verification.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={status.kind === 'sending'}
|
||||
className="shrink-0 px-3 py-1.5 rounded-md bg-amber-600 hover:bg-amber-500 text-white text-xs font-medium transition-colors disabled:opacity-60">
|
||||
{status.kind === 'sending' ? 'Sending…' : 'Send test event'}
|
||||
</button>
|
||||
</div>
|
||||
{/*
|
||||
* Single live region so screen readers announce the result when
|
||||
* status flips from `sending` to `sent` / `error`. `aria-live=polite`
|
||||
* waits for any in-flight speech to finish; `aria-atomic` makes the
|
||||
* reader re-read the whole region rather than only the diff.
|
||||
*/}
|
||||
<div role="status" aria-live="polite" aria-atomic="true" className="mt-2 text-xs">
|
||||
{status.kind === 'sent' && (
|
||||
<span className="text-amber-900">
|
||||
Event sent.{' '}
|
||||
{status.eventId ? (
|
||||
<span className="font-mono">id: {status.eventId}</span>
|
||||
) : (
|
||||
<span>(no id — Sentry disabled in this build)</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{status.kind === 'error' && (
|
||||
<span className="text-coral-600">Failed: {status.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DeveloperOptionsPanel = () => {
|
||||
const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const showSentryTest = APP_ENVIRONMENT === 'staging';
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
@@ -147,6 +215,7 @@ const DeveloperOptionsPanel = () => {
|
||||
/>
|
||||
|
||||
<div>
|
||||
{showSentryTest && <SentryTestRow />}
|
||||
{developerItems.map((item, index) => (
|
||||
<SettingsMenuItem
|
||||
key={item.id}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Tests for the staging-only "Trigger Sentry Test" row that
|
||||
* `DeveloperOptionsPanel` renders at the top when
|
||||
* `APP_ENVIRONMENT === 'staging'`. Covers visibility gating, the
|
||||
* idle/sending/sent/error state machine, and the failure path.
|
||||
*/
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
trigger: vi.fn(),
|
||||
appEnvironment: 'staging' as 'staging' | 'production' | 'development',
|
||||
}));
|
||||
|
||||
vi.mock('../../../../services/analytics', () => ({ triggerSentryTestEvent: hoisted.trigger }));
|
||||
|
||||
vi.mock('../../../../utils/config', () => ({
|
||||
get APP_ENVIRONMENT() {
|
||||
return hoisted.appEnvironment;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateToSettings: vi.fn(),
|
||||
navigateBack: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
async function importPanel() {
|
||||
const mod = await import('../DeveloperOptionsPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
describe('DeveloperOptionsPanel — Sentry test row', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.trigger.mockReset();
|
||||
hoisted.appEnvironment = 'staging';
|
||||
});
|
||||
|
||||
test('does not render the row in production builds', async () => {
|
||||
hoisted.appEnvironment = 'production';
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
expect(screen.queryByText(/Trigger Sentry Test/i)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Send test event/i })).toBeNull();
|
||||
});
|
||||
|
||||
test('renders the staging row and fires the helper on click', async () => {
|
||||
hoisted.trigger.mockResolvedValue('event-id-xyz');
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
expect(screen.getByText(/Trigger Sentry Test/i)).toBeInTheDocument();
|
||||
const button = screen.getByRole('button', { name: /Send test event/i });
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.trigger).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Event sent\./i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/event-id-xyz/)).toBeInTheDocument();
|
||||
|
||||
// Status updates must announce via an accessible live region — without
|
||||
// role="status" + aria-live, screen readers stay silent on click.
|
||||
const live = screen.getByRole('status');
|
||||
expect(live).toHaveAttribute('aria-live', 'polite');
|
||||
expect(live).toHaveTextContent(/Event sent.*event-id-xyz/);
|
||||
});
|
||||
|
||||
test('shows "no id" branch when the helper resolves with undefined', async () => {
|
||||
hoisted.trigger.mockResolvedValue(undefined);
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send test event/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Event sent\./i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/no id/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('surfaces the error message when the helper throws', async () => {
|
||||
hoisted.trigger.mockRejectedValue(new Error('network broke'));
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send test event/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed: network broke/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// Hoisted mocks so tests can swap return values per case.
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
getClient: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
flush: vi.fn(() => Promise.resolve(true)),
|
||||
init: vi.fn(),
|
||||
// Integration stubs — these aren't introspected, just need to exist so
|
||||
// `Sentry.init()` accepts the integrations array without throwing.
|
||||
functionToStringIntegration: vi.fn(() => ({})),
|
||||
linkedErrorsIntegration: vi.fn(() => ({})),
|
||||
dedupeIntegration: vi.fn(() => ({})),
|
||||
browserApiErrorsIntegration: vi.fn(() => ({})),
|
||||
globalHandlersIntegration: vi.fn(() => ({})),
|
||||
analyticsEnabled: false,
|
||||
appEnvironment: 'staging' as 'staging' | 'production' | 'development',
|
||||
}));
|
||||
|
||||
vi.mock('@sentry/react', () => ({
|
||||
getClient: hoisted.getClient,
|
||||
captureException: hoisted.captureException,
|
||||
captureMessage: hoisted.captureMessage,
|
||||
flush: hoisted.flush,
|
||||
init: hoisted.init,
|
||||
functionToStringIntegration: hoisted.functionToStringIntegration,
|
||||
linkedErrorsIntegration: hoisted.linkedErrorsIntegration,
|
||||
dedupeIntegration: hoisted.dedupeIntegration,
|
||||
browserApiErrorsIntegration: hoisted.browserApiErrorsIntegration,
|
||||
globalHandlersIntegration: hoisted.globalHandlersIntegration,
|
||||
}));
|
||||
|
||||
// `initSentry()` reads `getCoreStateSnapshot().snapshot.analyticsEnabled` to
|
||||
// decide whether non-test events get dropped. Mock it so each test can flip
|
||||
// consent without instantiating the real Redux/persistence stack.
|
||||
vi.mock('../../lib/coreState/store', () => ({
|
||||
getCoreStateSnapshot: () => ({
|
||||
snapshot: { analyticsEnabled: hoisted.analyticsEnabled, currentUser: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
// `initSentry()` only does anything when SENTRY_DSN is truthy and IS_DEV is
|
||||
// false. Mock the whole config module so we control both gates. Use a
|
||||
// getter for APP_ENVIRONMENT so tests can flip staging/production per-case
|
||||
// to exercise the defense-in-depth gates added for the consent bypass.
|
||||
vi.mock('../../utils/config', () => ({
|
||||
get APP_ENVIRONMENT() {
|
||||
return hoisted.appEnvironment;
|
||||
},
|
||||
IS_DEV: false,
|
||||
SENTRY_DSN: 'https://abc@example.ingest.sentry.io/1',
|
||||
SENTRY_RELEASE: 'openhuman@test+abc',
|
||||
SENTRY_SMOKE_TEST: false,
|
||||
}));
|
||||
|
||||
describe('triggerSentryTestEvent', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.getClient.mockReset();
|
||||
hoisted.captureException.mockReset();
|
||||
hoisted.flush.mockReset();
|
||||
hoisted.flush.mockReturnValue(Promise.resolve(true));
|
||||
hoisted.init.mockReset();
|
||||
hoisted.appEnvironment = 'staging';
|
||||
});
|
||||
|
||||
test('refuses to fire outside staging (defense in depth)', async () => {
|
||||
hoisted.appEnvironment = 'production';
|
||||
hoisted.getClient.mockReturnValue({});
|
||||
const { triggerSentryTestEvent } = await import('../analytics');
|
||||
|
||||
const result = await triggerSentryTestEvent();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(hoisted.captureException).not.toHaveBeenCalled();
|
||||
expect(hoisted.flush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns undefined when Sentry client is not initialized', async () => {
|
||||
hoisted.getClient.mockReturnValue(undefined);
|
||||
const { triggerSentryTestEvent } = await import('../analytics');
|
||||
|
||||
const result = await triggerSentryTestEvent();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(hoisted.captureException).not.toHaveBeenCalled();
|
||||
expect(hoisted.flush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('captures a tagged staging-test exception and flushes', async () => {
|
||||
hoisted.getClient.mockReturnValue({});
|
||||
hoisted.captureException.mockReturnValue('event-id-abc');
|
||||
hoisted.flush.mockReturnValue(Promise.resolve(true));
|
||||
const { triggerSentryTestEvent } = await import('../analytics');
|
||||
|
||||
const result = await triggerSentryTestEvent();
|
||||
|
||||
expect(result).toBe('event-id-abc');
|
||||
expect(hoisted.captureException).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [thrown, ctx] = hoisted.captureException.mock.calls[0];
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect((thrown as Error).name).toBe('SentryStagingTestError');
|
||||
// Message is constant so Sentry groups every test click into one issue.
|
||||
expect((thrown as Error).message).toBe('Manual Sentry test from staging UI');
|
||||
expect(ctx).toMatchObject({
|
||||
tags: { test: 'manual-staging', source: 'developer-options-button' },
|
||||
level: 'error',
|
||||
});
|
||||
// Per-click timing rides on `extra`, not in the message — high cardinality
|
||||
// there would explode tag indexes and break grouping.
|
||||
expect((ctx as { extra: { triggered_at: string } }).extra.triggered_at).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
|
||||
);
|
||||
expect(hoisted.flush).toHaveBeenCalledWith(2000);
|
||||
});
|
||||
|
||||
test('throws when flush times out so the UI surfaces an error', async () => {
|
||||
hoisted.getClient.mockReturnValue({});
|
||||
hoisted.captureException.mockReturnValue('event-id-stuck');
|
||||
hoisted.flush.mockReturnValue(Promise.resolve(false));
|
||||
const { triggerSentryTestEvent } = await import('../analytics');
|
||||
|
||||
await expect(triggerSentryTestEvent()).rejects.toThrow(/timed out/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initSentry beforeSend manual-staging bypass', () => {
|
||||
/** Capture the `beforeSend` callback that `initSentry` registers. */
|
||||
async function captureBeforeSend(): Promise<
|
||||
(event: Record<string, unknown>) => Record<string, unknown> | null
|
||||
> {
|
||||
hoisted.init.mockReset();
|
||||
const { initSentry } = await import('../analytics');
|
||||
initSentry();
|
||||
expect(hoisted.init).toHaveBeenCalledTimes(1);
|
||||
const opts = hoisted.init.mock.calls[0][0] as {
|
||||
beforeSend: (event: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
};
|
||||
return opts.beforeSend.bind(opts);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.analyticsEnabled = false;
|
||||
hoisted.appEnvironment = 'staging';
|
||||
});
|
||||
|
||||
test('drops events when consent is off and event is not test-tagged', async () => {
|
||||
const beforeSend = await captureBeforeSend();
|
||||
const result = beforeSend({ message: 'something blew up', tags: {}, contexts: {} });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('lets manual-staging tagged events through even without consent', async () => {
|
||||
const beforeSend = await captureBeforeSend();
|
||||
const result = beforeSend({
|
||||
message: 'something blew up',
|
||||
tags: { test: 'manual-staging' },
|
||||
breadcrumbs: [{ message: 'should-be-stripped' }],
|
||||
request: { url: 'https://api.example.com/secret' },
|
||||
extra: { token: 'redacted-please' },
|
||||
contexts: { os: { name: 'macOS' }, app: { build: '123' } },
|
||||
}) as Record<string, unknown> | null;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// PII / breadcrumbs / request body / extras must all be stripped.
|
||||
expect((result as { breadcrumbs: unknown[] }).breadcrumbs).toEqual([]);
|
||||
expect(result).not.toHaveProperty('request');
|
||||
expect(result).not.toHaveProperty('extra');
|
||||
// `app` context is stripped — only os/browser/device kept.
|
||||
expect((result as { contexts: Record<string, unknown> }).contexts).not.toHaveProperty('app');
|
||||
expect((result as { contexts: Record<string, unknown> }).contexts).toHaveProperty('os');
|
||||
// `surface=react` is added so the dashboard can filter cleanly.
|
||||
expect((result as { tags: Record<string, string> }).tags).toMatchObject({
|
||||
test: 'manual-staging',
|
||||
surface: 'react',
|
||||
});
|
||||
});
|
||||
|
||||
test('still lets the smoke-test message through (existing behaviour)', async () => {
|
||||
const beforeSend = await captureBeforeSend();
|
||||
const result = beforeSend({ message: 'react-sentry-smoke-test', tags: {}, contexts: {} });
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test('drops manual-staging tagged events in production even with the tag', async () => {
|
||||
// Defense in depth: a stray `tags.test = 'manual-staging'` in production
|
||||
// must NOT bypass the consent gate. Capture beforeSend in staging, then
|
||||
// flip APP_ENVIRONMENT to production *before* invoking it, so the
|
||||
// `isManualTest` check inside beforeSend re-reads the live value via the
|
||||
// mocked getter.
|
||||
const beforeSend = await captureBeforeSend();
|
||||
hoisted.appEnvironment = 'production';
|
||||
const result = beforeSend({
|
||||
message: 'pretending to be a test event',
|
||||
tags: { test: 'manual-staging' },
|
||||
contexts: {},
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -60,8 +60,17 @@ export function initSentry(): void {
|
||||
// Always allow the smoke-test event through so pipeline validation works
|
||||
// even when the user hasn't opted into analytics yet on first boot.
|
||||
const isSmokeTest = event.message === 'react-sentry-smoke-test';
|
||||
// Manual staging test events fired from the Developer Options button
|
||||
// (#1072) bypass the consent gate so QA can validate the pipeline
|
||||
// without needing to flip user-facing analytics first. The bypass is
|
||||
// *also* gated on APP_ENVIRONMENT so a stray `manual-staging` tag in
|
||||
// production (whether accidental or malicious) cannot exfiltrate an
|
||||
// event past the consent gate — the only legitimate caller in this
|
||||
// codebase is `triggerSentryTestEvent` and it itself refuses to fire
|
||||
// outside staging.
|
||||
const isManualTest = APP_ENVIRONMENT === 'staging' && event.tags?.test === 'manual-staging';
|
||||
// Drop events when the user hasn't opted into analytics.
|
||||
if (!isSmokeTest && !isAnalyticsEnabled()) return null;
|
||||
if (!isSmokeTest && !isManualTest && !isAnalyticsEnabled()) return null;
|
||||
|
||||
// Strip anything that could carry Redux / localStorage / request bodies.
|
||||
event.breadcrumbs = [];
|
||||
@@ -136,3 +145,59 @@ export function syncAnalyticsConsent(enabled: boolean): void {
|
||||
void Sentry.flush(2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a manual diagnostic event for issue #1072: a staging-only "Trigger
|
||||
* Sentry Test" button uses this to validate the React → Sentry pipeline
|
||||
* end-to-end after a config change. Tagged so `beforeSend` lets it through
|
||||
* regardless of analytics consent, and so it's trivial to filter on the
|
||||
* dashboard side. Returns the event id Sentry assigns (or `undefined` if
|
||||
* Sentry is disabled in this build).
|
||||
*/
|
||||
export async function triggerSentryTestEvent(): Promise<string | undefined> {
|
||||
// Fail-fast outside staging. The UI button is only rendered when
|
||||
// `APP_ENVIRONMENT === 'staging'`, but this guard exists as defense in
|
||||
// depth so a programmatic caller (a stray import, a future refactor)
|
||||
// cannot fire diagnostic events from production. `beforeSend` already
|
||||
// re-checks the same gate before applying the consent bypass.
|
||||
if (APP_ENVIRONMENT !== 'staging') {
|
||||
console.warn(
|
||||
`[sentry-test] refusing to fire test event outside staging (APP_ENVIRONMENT=${APP_ENVIRONMENT})`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const client = Sentry.getClient();
|
||||
if (!client) {
|
||||
console.warn('[sentry-test] Sentry client not initialized — DSN missing or dev build');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Constant message so Sentry's default grouping algorithm collapses every
|
||||
// QA click into one issue (with N events) instead of one issue per click.
|
||||
// Per-click timing goes through `extra` so it's still visible on each
|
||||
// event but doesn't influence the fingerprint.
|
||||
const stamp = new Date().toISOString();
|
||||
const error = new Error('Manual Sentry test from staging UI');
|
||||
error.name = 'SentryStagingTestError';
|
||||
|
||||
const eventId = Sentry.captureException(error, {
|
||||
tags: { test: 'manual-staging', source: 'developer-options-button' },
|
||||
extra: { triggered_at: stamp },
|
||||
level: 'error',
|
||||
});
|
||||
|
||||
console.info('[sentry-test] captureException eventId=', eventId);
|
||||
// Surface flush timeouts as failures: a `false` here means the event
|
||||
// queue did not drain within 2s, so the network round-trip to Sentry is
|
||||
// unconfirmed. For a *diagnostic* tool, returning a successful-looking
|
||||
// eventId in that case would be a lie.
|
||||
const flushed = await Sentry.flush(2000);
|
||||
if (!flushed) {
|
||||
throw new Error(
|
||||
'Sentry.flush(2000) timed out — event may not have reached Sentry. ' +
|
||||
'Check network / DSN / Sentry status before retrying.'
|
||||
);
|
||||
}
|
||||
return eventId;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user