mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
+7
-2
@@ -128,8 +128,13 @@ function App() {
|
||||
*/
|
||||
return (
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={({ error, componentStack, resetError }) => (
|
||||
<ErrorFallbackScreen error={error} componentStack={componentStack} onReset={resetError} />
|
||||
fallback={({ error, componentStack, resetError, eventId }) => (
|
||||
<ErrorFallbackScreen
|
||||
error={error}
|
||||
componentStack={componentStack}
|
||||
eventId={eventId}
|
||||
onReset={resetError}
|
||||
/>
|
||||
)}>
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={<PersistRehydrationScreen />} persistor={persistor}>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import ErrorFallbackScreen from './ErrorFallbackScreen';
|
||||
|
||||
// `t` echoes the key so assertions can target stable key strings.
|
||||
vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
openUrl: vi.fn(),
|
||||
safeInvoke: vi.fn().mockResolvedValue(undefined),
|
||||
isAnalyticsEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/openUrl', () => ({ openUrl: hoisted.openUrl }));
|
||||
vi.mock('../utils/tauriCommands/common', () => ({ safeInvoke: hoisted.safeInvoke }));
|
||||
vi.mock('../services/analytics', () => ({ isAnalyticsEnabled: hoisted.isAnalyticsEnabled }));
|
||||
vi.mock('../utils/config', () => ({
|
||||
SUPPORT_URL: 'https://support.example/help',
|
||||
LATEST_APP_DOWNLOAD_URL: 'https://downloads.example/latest',
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
error: new Error('boom went the render'),
|
||||
componentStack: ' at Foo\n at Bar',
|
||||
onReset: vi.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
hoisted.isAnalyticsEnabled.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('ErrorFallbackScreen', () => {
|
||||
test('shows a copyable Error ID and copies it on click', async () => {
|
||||
const originalClipboard = navigator.clipboard;
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
|
||||
render(<ErrorFallbackScreen {...baseProps} eventId="abc123def456" />);
|
||||
|
||||
expect(screen.getByText('app.errorFallback.eventIdLabel')).toBeInTheDocument();
|
||||
expect(screen.getByText('abc123def456')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('app.errorFallback.copyEventId'));
|
||||
expect(writeText).toHaveBeenCalledWith('abc123def456');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('app.errorFallback.eventIdCopied')).toBeInTheDocument()
|
||||
);
|
||||
|
||||
Object.assign(navigator, { clipboard: originalClipboard });
|
||||
});
|
||||
|
||||
test('hides the Error ID and support link when no event id', () => {
|
||||
render(<ErrorFallbackScreen {...baseProps} eventId={null} />);
|
||||
expect(screen.queryByText('app.errorFallback.eventIdLabel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('app.errorFallback.contactSupport')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides the Error ID when analytics is disabled (event was dropped)', () => {
|
||||
// Consent off → beforeSend drops the event, so the generated id maps to
|
||||
// nothing support can look up; the chip / support ref must stay hidden.
|
||||
hoisted.isAnalyticsEnabled.mockReturnValue(false);
|
||||
render(<ErrorFallbackScreen {...baseProps} eventId="abc123def456" />);
|
||||
expect(screen.queryByText('app.errorFallback.eventIdLabel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('app.errorFallback.contactSupport')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens the support deep link seeded with the (encoded) event id', () => {
|
||||
render(<ErrorFallbackScreen {...baseProps} eventId="id/with space" />);
|
||||
fireEvent.click(screen.getByText('app.errorFallback.contactSupport'));
|
||||
expect(hoisted.openUrl).toHaveBeenCalledWith(
|
||||
'https://support.example/help?ref=id%2Fwith%20space'
|
||||
);
|
||||
});
|
||||
|
||||
test('reveals the logs folder via the tauri command', () => {
|
||||
render(<ErrorFallbackScreen {...baseProps} eventId="x" />);
|
||||
fireEvent.click(screen.getByText('app.errorFallback.revealLogs'));
|
||||
expect(hoisted.safeInvoke).toHaveBeenCalledWith('reveal_logs_folder');
|
||||
});
|
||||
|
||||
test('always renders Reveal logs (escape hatch survives the bootstrap gap)', () => {
|
||||
render(<ErrorFallbackScreen {...baseProps} eventId={null} />);
|
||||
expect(screen.getByText('app.errorFallback.revealLogs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Try recover calls onReset', () => {
|
||||
const onReset = vi.fn();
|
||||
render(<ErrorFallbackScreen {...baseProps} onReset={onReset} eventId="x" />);
|
||||
fireEvent.click(screen.getByText('app.errorFallback.tryRecover'));
|
||||
expect(onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Download latest opens the release page', () => {
|
||||
render(<ErrorFallbackScreen {...baseProps} eventId={null} />);
|
||||
fireEvent.click(screen.getByText('app.errorFallback.downloadLatest'));
|
||||
expect(hoisted.openUrl).toHaveBeenCalledWith('https://downloads.example/latest');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { LATEST_APP_DOWNLOAD_URL } from '../utils/config';
|
||||
import { isAnalyticsEnabled } from '../services/analytics';
|
||||
import { LATEST_APP_DOWNLOAD_URL, SUPPORT_URL } from '../utils/config';
|
||||
import { openUrl } from '../utils/openUrl';
|
||||
import { safeInvoke as invoke } from '../utils/tauriCommands/common';
|
||||
|
||||
/**
|
||||
* ErrorFallbackScreen
|
||||
@@ -11,23 +15,67 @@ import { openUrl } from '../utils/openUrl';
|
||||
*
|
||||
* Errors caught by the boundary are auto-forwarded to Sentry by the
|
||||
* `Sentry.ErrorBoundary` wrapper in `App.tsx` (subject to user analytics
|
||||
* consent enforced in `analytics.ts::beforeSend`).
|
||||
* consent enforced in `analytics.ts::beforeSend`). When the capture
|
||||
* produces an event id, it is surfaced here as a copyable Error ID so the
|
||||
* user can share it with support, and a support deep link is pre-seeded
|
||||
* with it. When the user hasn't opted into analytics (no event id), the
|
||||
* Error ID / support affordances are hidden rather than shown empty.
|
||||
*
|
||||
* Recovery escalates: `Try recover` (in-place `resetError`) → `Reload app`
|
||||
* (hard reload to /home) → `Reveal logs` (open the logs folder so a user
|
||||
* trapped by a deterministic crash can still pull diagnostics for support).
|
||||
*/
|
||||
|
||||
interface ErrorFallbackScreenProps {
|
||||
error: unknown;
|
||||
componentStack?: string;
|
||||
/** Sentry event id for the captured crash, when analytics is enabled. */
|
||||
eventId?: string | null;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export default function ErrorFallbackScreen({
|
||||
error,
|
||||
componentStack,
|
||||
eventId,
|
||||
onReset,
|
||||
}: ErrorFallbackScreenProps) {
|
||||
const { t } = useT();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const errorName = error instanceof Error ? error.name : 'Error';
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
// Only surface the Error ID / support ref when analytics is on: with consent
|
||||
// off, `analytics.ts::beforeSend` drops the event, yet the SDK still hands the
|
||||
// boundary a generated id — showing it would let an opted-out user copy a ref
|
||||
// support can never look up (Codex P2 on #3980).
|
||||
const hasEventId = typeof eventId === 'string' && eventId.length > 0 && isAnalyticsEnabled();
|
||||
|
||||
const copyEventId = async () => {
|
||||
if (!hasEventId) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(eventId as string);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
// Clipboard unavailable (permissions / non-secure context) — no-op;
|
||||
// the id stays visible for manual copy.
|
||||
}
|
||||
};
|
||||
|
||||
const revealLogs = () => {
|
||||
// Diagnostics escape hatch: works even when the UI is otherwise dead.
|
||||
// `.catch` swallows the rejection on non-desktop / pre-bootstrap so a
|
||||
// fire-and-forget invoke can't surface as an unhandled rejection.
|
||||
void invoke('reveal_logs_folder').catch(() => {});
|
||||
};
|
||||
|
||||
const openSupport = () => {
|
||||
if (!hasEventId) return;
|
||||
// `&` when SUPPORT_URL already carries a query (env override) so the ref
|
||||
// never produces a malformed double-`?` link.
|
||||
const sep = SUPPORT_URL.includes('?') ? '&' : '?';
|
||||
openUrl(`${SUPPORT_URL}${sep}ref=${encodeURIComponent(eventId as string)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-gradient-to-b from-stone-950 to-stone-900">
|
||||
@@ -63,6 +111,27 @@ export default function ErrorFallbackScreen({
|
||||
</p>
|
||||
<p className="text-xs text-stone-500 text-center mb-6">{t('app.errorFallback.hint')}</p>
|
||||
|
||||
{/* Sentry Event ID — copyable; hidden when analytics produced no id */}
|
||||
{hasEventId && (
|
||||
<div className="flex items-center justify-between gap-3 bg-stone-800/50 border border-stone-700/50 rounded-xl px-3 py-2.5 mb-4">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] uppercase tracking-wide text-stone-500">
|
||||
{t('app.errorFallback.eventIdLabel')}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-stone-300 truncate">{eventId}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyEventId}
|
||||
className={`flex-none text-xs font-medium rounded-lg px-3 py-1.5 transition-colors ${
|
||||
copied
|
||||
? 'bg-primary-500/20 text-primary-300'
|
||||
: 'bg-stone-700 hover:bg-stone-600 text-white'
|
||||
}`}>
|
||||
{copied ? t('app.errorFallback.eventIdCopied') : t('app.errorFallback.copyEventId')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error details */}
|
||||
<div className="bg-stone-800/50 border border-stone-700/50 rounded-xl p-4 mb-6">
|
||||
<p className="text-sm font-medium text-coral-400 mb-1">{errorName}</p>
|
||||
@@ -79,7 +148,7 @@ export default function ErrorFallbackScreen({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{/* Primary actions — escalate recover → reload → reveal logs */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
@@ -94,9 +163,29 @@ export default function ErrorFallbackScreen({
|
||||
className="bg-coral-500 hover:bg-coral-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
|
||||
{t('app.errorFallback.reloadApp')}
|
||||
</button>
|
||||
{/* Always rendered — `isTauri()` is false during the CEF IPC
|
||||
bootstrap gap, which is exactly when an early deterministic
|
||||
crash needs this escape hatch; the invoke fails safe off-desktop
|
||||
(Codex P2 on #3980). */}
|
||||
<button
|
||||
onClick={revealLogs}
|
||||
className="bg-stone-800 hover:bg-stone-700 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors border border-stone-600">
|
||||
{t('app.errorFallback.revealLogs')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Secondary links */}
|
||||
<div className="flex items-center justify-center gap-4 mt-5 text-xs">
|
||||
{hasEventId && (
|
||||
<button
|
||||
onClick={openSupport}
|
||||
className="text-primary-400 hover:text-primary-300 hover:underline transition-colors">
|
||||
{t('app.errorFallback.contactSupport')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openUrl(LATEST_APP_DOWNLOAD_URL)}
|
||||
className="bg-stone-800 hover:bg-stone-700 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors border border-stone-600">
|
||||
className="text-stone-500 hover:text-stone-300 hover:underline transition-colors">
|
||||
{t('app.errorFallback.downloadLatest')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2670,6 +2670,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'غير متصل بالإنترنت',
|
||||
'app.connectionIndicator.reconnecting': 'إعادة الاتصال…',
|
||||
'app.errorFallback.componentStack': 'مكدس المكوّنات',
|
||||
'app.errorFallback.contactSupport': 'تواصل مع الدعم',
|
||||
'app.errorFallback.copyEventId': 'نسخ',
|
||||
'app.errorFallback.eventIdCopied': 'تم النسخ',
|
||||
'app.errorFallback.eventIdLabel': 'معرّف الخطأ',
|
||||
'app.errorFallback.revealLogs': 'إظهار السجلات',
|
||||
'app.errorFallback.downloadLatest': 'تنزيل الأحدث',
|
||||
'app.errorFallback.heading': 'العنوان',
|
||||
'app.errorFallback.hint': 'تلميح',
|
||||
|
||||
@@ -2727,6 +2727,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'অফলাইন',
|
||||
'app.connectionIndicator.reconnecting': 'পুনঃসংযোগ হচ্ছে…',
|
||||
'app.errorFallback.componentStack': 'কম্পোনেন্ট স্ট্যাক',
|
||||
'app.errorFallback.contactSupport': 'সহায়তায় যোগাযোগ করুন',
|
||||
'app.errorFallback.copyEventId': 'অনুলিপি',
|
||||
'app.errorFallback.eventIdCopied': 'অনুলিপি হয়েছে',
|
||||
'app.errorFallback.eventIdLabel': 'ত্রুটি আইডি',
|
||||
'app.errorFallback.revealLogs': 'লগ দেখান',
|
||||
'app.errorFallback.downloadLatest': 'সর্বশেষ ডাউনলোড করুন',
|
||||
'app.errorFallback.heading': 'শিরোনাম',
|
||||
'app.errorFallback.hint': 'হিন্ট',
|
||||
|
||||
@@ -2788,6 +2788,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'Offline',
|
||||
'app.connectionIndicator.reconnecting': 'Wieder verbinden…',
|
||||
'app.errorFallback.componentStack': 'Komponentenstapel',
|
||||
'app.errorFallback.contactSupport': 'Support kontaktieren',
|
||||
'app.errorFallback.copyEventId': 'Kopieren',
|
||||
'app.errorFallback.eventIdCopied': 'Kopiert',
|
||||
'app.errorFallback.eventIdLabel': 'Fehler-ID',
|
||||
'app.errorFallback.revealLogs': 'Protokolle anzeigen',
|
||||
'app.errorFallback.downloadLatest': 'Neueste herunterladen',
|
||||
'app.errorFallback.heading': 'Überschrift',
|
||||
'app.errorFallback.hint': 'Hinweis',
|
||||
|
||||
@@ -3244,11 +3244,16 @@ const en: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'Offline',
|
||||
'app.connectionIndicator.reconnecting': 'Reconnecting…',
|
||||
'app.errorFallback.componentStack': 'Component stack',
|
||||
'app.errorFallback.contactSupport': 'Contact support',
|
||||
'app.errorFallback.copyEventId': 'Copy',
|
||||
'app.errorFallback.downloadLatest': 'Download latest',
|
||||
'app.errorFallback.eventIdCopied': 'Copied',
|
||||
'app.errorFallback.eventIdLabel': 'Error ID',
|
||||
'app.errorFallback.heading': 'Something went wrong',
|
||||
'app.errorFallback.hint':
|
||||
'Try reloading the app. If the problem persists, download the latest version.',
|
||||
'app.errorFallback.reloadApp': 'Reload app',
|
||||
'app.errorFallback.revealLogs': 'Reveal logs',
|
||||
'app.errorFallback.subheading': 'An unexpected error occurred',
|
||||
'app.errorFallback.tryRecover': 'Try recover',
|
||||
'app.localAiDownload.installing': 'Installing...',
|
||||
|
||||
@@ -2774,6 +2774,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'Sin conexión',
|
||||
'app.connectionIndicator.reconnecting': 'Reconectando…',
|
||||
'app.errorFallback.componentStack': 'Pila de componentes',
|
||||
'app.errorFallback.contactSupport': 'Contactar con soporte',
|
||||
'app.errorFallback.copyEventId': 'Copiar',
|
||||
'app.errorFallback.eventIdCopied': 'Copiado',
|
||||
'app.errorFallback.eventIdLabel': 'ID de error',
|
||||
'app.errorFallback.revealLogs': 'Mostrar registros',
|
||||
'app.errorFallback.downloadLatest': 'Descargar la última versión',
|
||||
'app.errorFallback.heading': 'Encabezado',
|
||||
'app.errorFallback.hint': 'Sugerencia',
|
||||
|
||||
@@ -2786,6 +2786,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'Hors ligne',
|
||||
'app.connectionIndicator.reconnecting': 'Reconnexion…',
|
||||
'app.errorFallback.componentStack': 'Pile de composants',
|
||||
'app.errorFallback.contactSupport': 'Contacter le support',
|
||||
'app.errorFallback.copyEventId': 'Copier',
|
||||
'app.errorFallback.eventIdCopied': 'Copié',
|
||||
'app.errorFallback.eventIdLabel': "ID d'erreur",
|
||||
'app.errorFallback.revealLogs': 'Afficher les journaux',
|
||||
'app.errorFallback.downloadLatest': 'Télécharger la dernière version',
|
||||
'app.errorFallback.heading': 'Titre',
|
||||
'app.errorFallback.hint': 'Indice',
|
||||
|
||||
@@ -2724,6 +2724,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'ऑफलाइन',
|
||||
'app.connectionIndicator.reconnecting': 'पुनः कनेक्ट हो रहा है…',
|
||||
'app.errorFallback.componentStack': 'कम्पोनेंट स्टैक',
|
||||
'app.errorFallback.contactSupport': 'सहायता से संपर्क करें',
|
||||
'app.errorFallback.copyEventId': 'कॉपी करें',
|
||||
'app.errorFallback.eventIdCopied': 'कॉपी हो गया',
|
||||
'app.errorFallback.eventIdLabel': 'त्रुटि आईडी',
|
||||
'app.errorFallback.revealLogs': 'लॉग दिखाएं',
|
||||
'app.errorFallback.downloadLatest': 'लेटेस्ट डाउनलोड करें',
|
||||
'app.errorFallback.heading': 'शीर्षक',
|
||||
'app.errorFallback.hint': 'संकेत',
|
||||
|
||||
@@ -2728,6 +2728,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'Tidak online',
|
||||
'app.connectionIndicator.reconnecting': 'Menyambung ulang…',
|
||||
'app.errorFallback.componentStack': 'Stack komponen',
|
||||
'app.errorFallback.contactSupport': 'Hubungi dukungan',
|
||||
'app.errorFallback.copyEventId': 'Salin',
|
||||
'app.errorFallback.eventIdCopied': 'Tersalin',
|
||||
'app.errorFallback.eventIdLabel': 'ID Kesalahan',
|
||||
'app.errorFallback.revealLogs': 'Tampilkan log',
|
||||
'app.errorFallback.downloadLatest': 'Unduh terbaru',
|
||||
'app.errorFallback.heading': 'Judul',
|
||||
'app.errorFallback.hint': 'Petunjuk',
|
||||
|
||||
@@ -2767,6 +2767,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'Offline',
|
||||
'app.connectionIndicator.reconnecting': 'Riconnessione…',
|
||||
'app.errorFallback.componentStack': 'Stack del componente',
|
||||
'app.errorFallback.contactSupport': "Contatta l'assistenza",
|
||||
'app.errorFallback.copyEventId': 'Copia',
|
||||
'app.errorFallback.eventIdCopied': 'Copiato',
|
||||
'app.errorFallback.eventIdLabel': 'ID errore',
|
||||
'app.errorFallback.revealLogs': 'Mostra i log',
|
||||
'app.errorFallback.downloadLatest': "Scarica l'ultima versione",
|
||||
'app.errorFallback.heading': 'Intestazione',
|
||||
'app.errorFallback.hint': 'Suggerimento',
|
||||
|
||||
@@ -2700,6 +2700,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': '오프라인',
|
||||
'app.connectionIndicator.reconnecting': '다시 연결 중…',
|
||||
'app.errorFallback.componentStack': '컴포넌트 스택',
|
||||
'app.errorFallback.contactSupport': '지원팀에 문의',
|
||||
'app.errorFallback.copyEventId': '복사',
|
||||
'app.errorFallback.eventIdCopied': '복사됨',
|
||||
'app.errorFallback.eventIdLabel': '오류 ID',
|
||||
'app.errorFallback.revealLogs': '로그 보기',
|
||||
'app.errorFallback.downloadLatest': '최신 버전 다운로드',
|
||||
'app.errorFallback.heading': '제목',
|
||||
'app.errorFallback.hint': '힌트',
|
||||
|
||||
@@ -2750,6 +2750,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'Offline',
|
||||
'app.connectionIndicator.reconnecting': 'Ponowne łączenie…',
|
||||
'app.errorFallback.componentStack': 'Stos komponentów',
|
||||
'app.errorFallback.contactSupport': 'Skontaktuj się z pomocą',
|
||||
'app.errorFallback.copyEventId': 'Kopiuj',
|
||||
'app.errorFallback.eventIdCopied': 'Skopiowano',
|
||||
'app.errorFallback.eventIdLabel': 'Identyfikator błędu',
|
||||
'app.errorFallback.revealLogs': 'Pokaż dzienniki',
|
||||
'app.errorFallback.downloadLatest': 'Pobierz najnowszą wersję',
|
||||
'app.errorFallback.heading': 'Coś poszło nie tak',
|
||||
'app.errorFallback.hint': 'Wskazówka',
|
||||
|
||||
@@ -2772,6 +2772,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'Offline',
|
||||
'app.connectionIndicator.reconnecting': 'Reconectando…',
|
||||
'app.errorFallback.componentStack': 'Pilha de componentes',
|
||||
'app.errorFallback.contactSupport': 'Falar com o suporte',
|
||||
'app.errorFallback.copyEventId': 'Copiar',
|
||||
'app.errorFallback.eventIdCopied': 'Copiado',
|
||||
'app.errorFallback.eventIdLabel': 'ID do erro',
|
||||
'app.errorFallback.revealLogs': 'Mostrar registros',
|
||||
'app.errorFallback.downloadLatest': 'Baixar mais recente',
|
||||
'app.errorFallback.heading': 'Título',
|
||||
'app.errorFallback.hint': 'Dica',
|
||||
|
||||
@@ -2746,6 +2746,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': 'Офлайн',
|
||||
'app.connectionIndicator.reconnecting': 'Переподключение…',
|
||||
'app.errorFallback.componentStack': 'Стек компонентов',
|
||||
'app.errorFallback.contactSupport': 'Связаться с поддержкой',
|
||||
'app.errorFallback.copyEventId': 'Копировать',
|
||||
'app.errorFallback.eventIdCopied': 'Скопировано',
|
||||
'app.errorFallback.eventIdLabel': 'Идентификатор ошибки',
|
||||
'app.errorFallback.revealLogs': 'Показать журналы',
|
||||
'app.errorFallback.downloadLatest': 'Скачать последнюю версию',
|
||||
'app.errorFallback.heading': 'Заголовок',
|
||||
'app.errorFallback.hint': 'Подсказка',
|
||||
|
||||
@@ -2595,6 +2595,11 @@ const messages: TranslationMap = {
|
||||
'app.connectionIndicator.offline': '离线',
|
||||
'app.connectionIndicator.reconnecting': '重新连接中…',
|
||||
'app.errorFallback.componentStack': '组件堆栈',
|
||||
'app.errorFallback.contactSupport': '联系支持',
|
||||
'app.errorFallback.copyEventId': '复制',
|
||||
'app.errorFallback.eventIdCopied': '已复制',
|
||||
'app.errorFallback.eventIdLabel': '错误 ID',
|
||||
'app.errorFallback.revealLogs': '显示日志',
|
||||
'app.errorFallback.downloadLatest': '下载最新版本',
|
||||
'app.errorFallback.heading': '出现错误',
|
||||
'app.errorFallback.hint': '提示',
|
||||
|
||||
@@ -73,6 +73,7 @@ vi.mock('../../utils/config', () => ({
|
||||
SENTRY_DSN: 'https://abc@example.ingest.sentry.io/1',
|
||||
SENTRY_RELEASE: 'openhuman@test+abc',
|
||||
SENTRY_SMOKE_TEST: false,
|
||||
SUPPORT_URL: 'https://support.example/help',
|
||||
TAURI_CARGO_VERSION: '0.57.4',
|
||||
// analytics.ts now imports CoreRpcError from coreRpcClient, whose
|
||||
// dependency chain reads CORE_RPC_URL and CORE_RPC_TIMEOUT_MS. Provide
|
||||
@@ -231,6 +232,30 @@ describe('initSentry beforeSend manual-staging bypass', () => {
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test('seeds a support_url tag keyed on the event id (#3980)', async () => {
|
||||
const beforeSend = await captureBeforeSend();
|
||||
const result = beforeSend({
|
||||
message: 'react-sentry-smoke-test', // bypasses consent gate
|
||||
event_id: 'deadbeefcafe',
|
||||
tags: {},
|
||||
contexts: {},
|
||||
}) as { tags: Record<string, string> } | null;
|
||||
expect(result).not.toBeNull();
|
||||
// Set as a TAG (extras are scrubbed); carries only the event's own id.
|
||||
expect(result?.tags.support_url).toBe('https://support.example/help?ref=deadbeefcafe');
|
||||
// Extras still dropped — the support link must not reintroduce a leak path.
|
||||
expect(result).not.toHaveProperty('extra');
|
||||
});
|
||||
|
||||
test('omits support_url when the event has no id', async () => {
|
||||
const beforeSend = await captureBeforeSend();
|
||||
const result = beforeSend({ message: 'react-sentry-smoke-test', tags: {}, contexts: {} }) as {
|
||||
tags: Record<string, string>;
|
||||
} | null;
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.tags).not.toHaveProperty('support_url');
|
||||
});
|
||||
|
||||
test('drops CoreRpcError with kind=timeout via the originalException hint', async () => {
|
||||
// Regression for OPENHUMAN-REACT-15/11/10/12/Z/Y: a missed `.catch()` at
|
||||
// any `await callCoreRpc(...)` chain in the team panels surfaced as an
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
SENTRY_DSN,
|
||||
SENTRY_RELEASE,
|
||||
SENTRY_SMOKE_TEST,
|
||||
SUPPORT_URL,
|
||||
TAURI_CARGO_VERSION,
|
||||
} from '../utils/config';
|
||||
import { CoreRpcError } from './coreRpcClient';
|
||||
@@ -217,6 +218,17 @@ export function initSentry(): void {
|
||||
// Tag with surface so events filter cleanly inside `openhuman-react`.
|
||||
event.tags = { ...(event.tags ?? {}), surface: 'react' };
|
||||
|
||||
// Seed a support deep link keyed on this event's own id so the crash
|
||||
// report links back to the support channel (#3980). Set as a TAG, not
|
||||
// an `extra` — the privacy scrub above deletes `event.extra`, but the
|
||||
// event id is known here and tags survive. The URL carries only the
|
||||
// event's own id + a static base (no PII). Mirrors the id the user
|
||||
// sees + copies on `ErrorFallbackScreen`.
|
||||
if (event.event_id) {
|
||||
const sep = SUPPORT_URL.includes('?') ? '&' : '?';
|
||||
event.tags.support_url = `${SUPPORT_URL}${sep}ref=${event.event_id}`;
|
||||
}
|
||||
|
||||
// Strip PII; keep a stable account id only.
|
||||
const userId = getCoreStateSnapshot().snapshot.currentUser?._id;
|
||||
event.user = userId ? { id: userId } : undefined;
|
||||
|
||||
@@ -208,6 +208,11 @@ export const LATEST_APP_DOWNLOAD_URL =
|
||||
(import.meta.env.VITE_LATEST_APP_DOWNLOAD_URL as string | undefined)?.trim() ||
|
||||
'https://github.com/tinyhumansai/openhuman/releases/latest';
|
||||
|
||||
/** Support page base URL. The crash screen appends `?ref=<sentryEventId>` so support can correlate a user's pasted Error ID to the exact Sentry event. Override via VITE_SUPPORT_URL for deployment-specific support endpoints. */
|
||||
export const SUPPORT_URL =
|
||||
(import.meta.env.VITE_SUPPORT_URL as string | undefined)?.trim() ||
|
||||
'https://tinyhumans.ai/support';
|
||||
|
||||
/**
|
||||
* Set `VITE_SENTRY_SMOKE_TEST=true` in one build (or in `.env.local`) to
|
||||
* fire a one-shot diagnostic event at `initSentry()` time and verify the
|
||||
|
||||
Reference in New Issue
Block a user