From db662626159a53127bd1dce144cbb98bfb335bba Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 19 May 2026 14:22:45 +0530 Subject: [PATCH 1/7] fix(core-rpc): classify AbortController timeout as `timeout` kind (#REACT-15) Promote local 30s AbortController timeout from bare `Error` to `CoreRpcError('timeout')` so Sentry, call-site `.catch()`, and any future filter can branch on `err.kind` instead of regex-matching the raw message. Adds the `timeout` variant to `CoreRpcErrorKind` and gives it precedence in `classifyRpcError` over the broader `transport` arm (mirrors the loopback-vs-transport precedence pattern in PR #2063). Sentry-Issue: OPENHUMAN-REACT-15 --- app/src/services/coreRpcClient.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 5870fc9ba0..911887ad5a 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -51,6 +51,7 @@ let resolvingCoreRpcToken: Promise | null = null; export type CoreRpcErrorKind = | 'auth_expired' | 'transport' + | 'timeout' | 'rate_limited' | 'budget_exceeded' | 'thread_not_found' @@ -94,6 +95,10 @@ export function classifyRpcError( if (/no backend session token/i.test(message)) return 'auth_expired'; if (/429.*rate.?limit/i.test(message)) return 'rate_limited'; if (/Budget exceeded|Insufficient budget/i.test(message)) return 'budget_exceeded'; + // Local AbortController hit `CORE_RPC_TIMEOUT_MS` — distinct from backend + // `client error (Connect): operation timed out`. Must run BEFORE the + // `transport` arm so the more specific kind wins. + if (/timed out after \d+ms/i.test(message)) return 'timeout'; if (/error sending request|client error \(Connect\)|timed out|ECONNREFUSED/i.test(message)) { return 'transport'; } @@ -378,7 +383,13 @@ export async function callCoreRpc({ }); } catch (fetchErr) { if (controller.signal.aborted) { - throw new Error(`Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`); + // Throw a fully-classified `CoreRpcError` here so the outer catch + // doesn't re-wrap a bare `Error` and so callers can branch on + // `err.kind === 'timeout'` (Sentry filter, soft toast skip). + throw new CoreRpcError( + `Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`, + 'timeout' + ); } throw fetchErr; } finally { From dc7ae0a066b2a3c4915e2762423d58df3bb9e988 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 19 May 2026 14:23:26 +0530 Subject: [PATCH 2/7] test(core-rpc): cover `timeout` kind classifier + AbortController throw (#REACT-15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Verbatim REACT-15/11/10/12 messages classify as `timeout`, not `transport`. - REACT-Z verbatim (`app_state_snapshot timed out after 30000ms`) classifies as `timeout` even when wrapped by the outer catch. - REACT-13 verbatim (backend-side `client error (Connect): operation timed out`) stays `transport` — the new arm must NOT swallow real network shapes. - Precedence guard: `timed out after \d+ms` wins over the generic `timed out` transport regex. - AbortController throw site rejects with `CoreRpcError` (kind=`timeout`), not bare `Error` — locks the OPENHUMAN-REACT-Z/Y regression. Sentry-Issue: OPENHUMAN-REACT-15 --- .../services/__tests__/coreRpcClient.test.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 33faf8aee2..e291ff0bdb 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -327,7 +327,14 @@ describe('coreRpcClient', () => { await vi.advanceTimersByTimeAsync(CORE_RPC_TIMEOUT_MS + 1); - await expect(pending).rejects.toThrow( + const err = await pending.catch(e => e); + // The timeout path must throw a CoreRpcError pre-classified as + // `timeout` so the outer catch does not re-wrap a bare `Error` and so + // Sentry / call-site `.catch()` can branch on `err.kind`. Regression + // guard for OPENHUMAN-REACT-Z/Y (the bare-Error shape pre-fix). + expect(err).toBeInstanceOf(CoreRpcError); + expect((err as CoreRpcError).kind).toBe('timeout'); + expect((err as Error).message).toBe( `Core RPC openhuman.threads_list timed out after ${CORE_RPC_TIMEOUT_MS}ms` ); } finally { @@ -519,6 +526,21 @@ describe('classifyRpcError', () => { ['client error (Connect) inner: dns', undefined, 'transport'], ['operation timed out after 30s', undefined, 'transport'], ['ECONNREFUSED 127.0.0.1:7788', undefined, 'transport'], + // OPENHUMAN-REACT-15/11/10/12 verbatim from Sentry — local AbortController + // timeout, NOT backend transport. Must classify as `timeout`. + ['Core RPC openhuman.team_list_teams timed out after 30000ms', undefined, 'timeout'], + ['Core RPC openhuman.team_list_members timed out after 30000ms', undefined, 'timeout'], + ['Core RPC openhuman.team_list_invites timed out after 30000ms', undefined, 'timeout'], + // OPENHUMAN-REACT-Z/Y verbatim (bare-Error shape pre-fix; now CoreRpcError + // with same message): still kind=timeout under the new classifier. + ['Core RPC openhuman.app_state_snapshot timed out after 30000ms', undefined, 'timeout'], + // OPENHUMAN-REACT-13 verbatim — backend-side connect timeout. Body never + // hits the `timed out after \d+ms` matcher and stays `transport`. + [ + 'backend request GET /teams: error sending request for url (https://api.tinyhumans.ai/teams): client error (Connect): operation timed out', + undefined, + 'transport', + ], ['some random message', undefined, 'unknown'], ] as const)('%s => %s', (message, status, expected) => { expect(classifyRpcError(message, status)).toBe(expected); @@ -537,6 +559,15 @@ describe('classifyRpcError', () => { classifyRpcError('thread thread-123 not found', undefined, { kind: 'ThreadNotFound' }) ).toBe('thread_not_found'); }); + + test('local AbortController timeout precedence wins over generic transport regex', () => { + // The `timed out` substring also matches the broader transport arm; the + // `timed out after \d+ms` arm MUST run first so callers can distinguish + // a local 30s ceiling from a backend `client error (Connect)` timeout. + expect(classifyRpcError('Core RPC openhuman.team_list_teams timed out after 30000ms')).toBe( + 'timeout' + ); + }); }); describe('coreRpcClient — typed errors + auth-expired event', () => { From f06acb50a0c09bfc428aebe8766d25422fa82289 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 19 May 2026 14:24:52 +0530 Subject: [PATCH 3/7] fix(core-state): swallow refresh timeouts post-write to silence REACT-Z/Y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `updateLocalState` and `storeSessionToken` awaited a follow-up `refresh()` without a `.catch()`, so a cold-boot `app_state_snapshot` timeout surfaced as an unhandled promise rejection at the caller — captured by Sentry's global handler as OPENHUMAN-REACT-Z/Y. Make the post-write refresh best-effort (sibling helpers like `setAnalyticsEnabled` / `setMeetAutoOrchestratorHandoff` already swallow here). The polling loop reconciles state within `POLL_MS` so any missed update is not user-visible. Sentry-Issue: OPENHUMAN-REACT-Z Sentry-Issue: OPENHUMAN-REACT-Y --- app/src/providers/CoreStateProvider.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index ad0578b2e7..6aad15355a 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -559,7 +559,15 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const updateLocalState = useCallback( async (params: Parameters[0]) => { await updateCoreLocalState(params); - await refresh(); + // The follow-up refresh is best-effort cache reconciliation, not part + // of the write contract — sibling helpers (setAnalyticsEnabled, + // setMeetAutoOrchestratorHandoff, …) already swallow here. An + // un-caught `app_state_snapshot` timeout used to bubble out of + // `setEncryptionKey` / `setOnboardingTasks` callers as an unhandled + // rejection → OPENHUMAN-REACT-Z/Y. The next poll tick will reconcile. + await refresh().catch(err => { + log('refresh failed after updateLocalState: %O', sanitizeError(err)); + }); }, [refresh] ); @@ -580,7 +588,13 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) // restartApp call here was redundant and skipped the persist purge, // letting redux-persist rehydrate the prior user's slices on launch // (#900). Restart now happens inside handleIdentityFlip after purge. - await refresh(); + // Swallow refresh failures here so a cold-boot `app_state_snapshot` + // timeout post-login doesn't surface as an unhandled rejection + // (OPENHUMAN-REACT-Z/Y) — the polling loop reconciles within + // `POLL_MS`. + await refresh().catch(err => { + log('refresh failed after session store: %O', sanitizeError(err)); + }); await refreshTeams().catch(err => { log('refreshTeams failed after session store: %O', sanitizeError(err)); }); From 53f54abea8b031e2499b786fbf3c0f986d949626 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 19 May 2026 14:24:53 +0530 Subject: [PATCH 4/7] fix(team-panels): swallow refresh rejections to stop unhandled-rejection leaks (#REACT-15 #REACT-11 #REACT-10 #REACT-12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three team-settings panels each fired a `void promise(...)` or `void promise.finally(...)` against a `refreshTeams` / `refreshTeamMembers` / `refreshTeamInvites` chain from inside `useEffect`. A rejection from any caller (cold core boot, backend 504, local AbortController 30 s ceiling) became an unhandled rejection captured by Sentry's `auto.browser.global_handlers.onunhandledrejection`, producing OPENHUMAN-REACT-15/11 (`team_list_teams`), REACT-10 (`team_list_members`), and REACT-12 (`team_list_invites`). Demote each to a logged `core-rpc:error` breadcrumb. The polling loop in `CoreStateProvider` reconciles state on the next tick, and any user-driven retry (revisiting the panel) re-runs the same chain — so a transient timeout is now silent, never user-visible noise, never Sentry noise. Sentry-Issue: OPENHUMAN-REACT-15 Sentry-Issue: OPENHUMAN-REACT-11 Sentry-Issue: OPENHUMAN-REACT-10 Sentry-Issue: OPENHUMAN-REACT-12 --- .../settings/panels/TeamInvitesPanel.tsx | 14 ++++++++++++- .../settings/panels/TeamMembersPanel.tsx | 14 ++++++++++++- .../components/settings/panels/TeamPanel.tsx | 20 ++++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/app/src/components/settings/panels/TeamInvitesPanel.tsx b/app/src/components/settings/panels/TeamInvitesPanel.tsx index e3df24b679..f0016ae5e6 100644 --- a/app/src/components/settings/panels/TeamInvitesPanel.tsx +++ b/app/src/components/settings/panels/TeamInvitesPanel.tsx @@ -1,12 +1,16 @@ +import debug from 'debug'; import { useEffect, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { useT } from '../../../lib/i18n/I18nContext'; import { useCoreState } from '../../../providers/CoreStateProvider'; import { teamApi } from '../../../services/api/teamApi'; +import { sanitizeError } from '../../../utils/sanitize'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +const log = debug('core-rpc:error'); + const TeamInvitesPanel = () => { const { t } = useT(); const { teamId } = useParams<{ teamId: string }>(); @@ -34,7 +38,15 @@ const TeamInvitesPanel = () => { useEffect(() => { if (!currentTeamId) return; setIsLoadingInvites(true); - void refreshTeamInvites(currentTeamId).finally(() => setIsLoadingInvites(false)); + // `.finally()` alone left this as `void promise(...)`, so any rejection + // (cold core boot, backend 504, local AbortController timeout) became an + // unhandled rejection → OPENHUMAN-REACT-12. Swallow into a logged + // breadcrumb; the user can retry by navigating away and back. + refreshTeamInvites(currentTeamId) + .catch(err => { + log('refreshTeamInvites failed in TeamInvitesPanel: %O', sanitizeError(err)); + }) + .finally(() => setIsLoadingInvites(false)); }, [currentTeamId, refreshTeamInvites]); const handleGenerate = async () => { diff --git a/app/src/components/settings/panels/TeamMembersPanel.tsx b/app/src/components/settings/panels/TeamMembersPanel.tsx index a7acaad0a2..3c15629c31 100644 --- a/app/src/components/settings/panels/TeamMembersPanel.tsx +++ b/app/src/components/settings/panels/TeamMembersPanel.tsx @@ -1,3 +1,4 @@ +import debug from 'debug'; import { useEffect, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; @@ -5,9 +6,12 @@ import { useT } from '../../../lib/i18n/I18nContext'; import { useCoreState } from '../../../providers/CoreStateProvider'; import { teamApi } from '../../../services/api/teamApi'; import type { TeamMember, TeamRole } from '../../../types/team'; +import { sanitizeError } from '../../../utils/sanitize'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +const log = debug('core-rpc:error'); + const ROLES: TeamRole[] = ['ADMIN', 'BILLING_MANAGER', 'MEMBER']; const TeamMembersPanel = () => { @@ -41,7 +45,15 @@ const TeamMembersPanel = () => { useEffect(() => { if (!currentTeamId) return; setIsLoadingMembers(true); - void refreshTeamMembers(currentTeamId).finally(() => setIsLoadingMembers(false)); + // `.finally()` alone left this as `void promise(...)`, so any rejection + // (cold core boot, backend 504, local AbortController timeout) became an + // unhandled rejection → OPENHUMAN-REACT-10. Swallow into a logged + // breadcrumb; the user can retry by navigating away and back. + refreshTeamMembers(currentTeamId) + .catch(err => { + log('refreshTeamMembers failed in TeamMembersPanel: %O', sanitizeError(err)); + }) + .finally(() => setIsLoadingMembers(false)); }, [currentTeamId, refreshTeamMembers]); const handleChangeRole = (member: TeamMember, newRole: TeamRole) => { diff --git a/app/src/components/settings/panels/TeamPanel.tsx b/app/src/components/settings/panels/TeamPanel.tsx index 66ca9be181..746d46d8bd 100644 --- a/app/src/components/settings/panels/TeamPanel.tsx +++ b/app/src/components/settings/panels/TeamPanel.tsx @@ -1,12 +1,17 @@ +import debug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { useCoreState } from '../../../providers/CoreStateProvider'; import { teamApi } from '../../../services/api/teamApi'; +import { CoreRpcError } from '../../../services/coreRpcClient'; import type { TeamWithRole } from '../../../types/team'; +import { sanitizeError } from '../../../utils/sanitize'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +const log = debug('core-rpc:error'); + const TeamPanel = () => { const { t } = useT(); const { navigateBack, navigateToTeamManagement, breadcrumbs } = useSettingsNavigation(); @@ -30,13 +35,26 @@ const TeamPanel = () => { setIsLoading(true); try { await refreshTeams(); + } catch (err) { + // Bootstrap-time `team_list_teams` failures (cold core boot, backend + // 504, local AbortController hit `CORE_RPC_TIMEOUT_MS`) used to leak + // as unhandled promise rejections via the `void` in the useEffect + // below, polluting Sentry as OPENHUMAN-REACT-15/11. The next visible + // user action retries, so swallow silently for transient kinds. + const kind = err instanceof CoreRpcError ? err.kind : 'unknown'; + log('refreshTeams failed in TeamPanel (kind=%s): %O', kind, sanitizeError(err)); } finally { setIsLoading(false); } }, [refreshTeams]); useEffect(() => { - void refreshTeamsWithLoading(); + // `refreshTeamsWithLoading` already absorbs rejections internally, but + // keep the `.catch()` as a belt-and-suspenders guard so a future refactor + // that re-throws cannot regress the unhandled-rejection family. + refreshTeamsWithLoading().catch(err => { + log('refreshTeamsWithLoading rethrew unexpectedly: %O', sanitizeError(err)); + }); }, [refreshTeamsWithLoading]); const handleCreateTeam = async () => { From 7f9d5c2d3771ba953d917321339862bee795e466 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 19 May 2026 14:25:21 +0530 Subject: [PATCH 5/7] fix(observability): drop CoreRpcError(kind=timeout) in Sentry beforeSend (#REACT-15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last-line-of-defense filter so a future `await callCoreRpc(...)` chain that forgets a `.catch()` cannot regress the REACT timeout family. Mirrors the Rust-side classifier demote in PR #2063. Match by `instanceof CoreRpcError` first (in-process Sentry hook); fall back to a duck-typed `name === 'CoreRpcError' && kind === 'timeout'` check so cross-realm-constructed errors (test harness, dynamic import, Vitest module isolation) still get demoted. Non-timeout `CoreRpcError` shapes (`transport`, `auth_expired`, `budget_exceeded`, `rate_limited`, `thread_not_found`) still surface in Sentry — only the local AbortController noise is suppressed. Sentry-Issue: OPENHUMAN-REACT-15 Sentry-Issue: OPENHUMAN-REACT-11 Sentry-Issue: OPENHUMAN-REACT-10 Sentry-Issue: OPENHUMAN-REACT-12 Sentry-Issue: OPENHUMAN-REACT-13 Sentry-Issue: OPENHUMAN-REACT-14 Sentry-Issue: OPENHUMAN-REACT-Z Sentry-Issue: OPENHUMAN-REACT-Y --- app/src/services/analytics.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 9277ffe0c9..5cd0966278 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -30,6 +30,7 @@ import { SENTRY_RELEASE, SENTRY_SMOKE_TEST, } from '../utils/config'; +import { CoreRpcError } from './coreRpcClient'; // --------------------------------------------------------------------------- // GA4 — module-level state @@ -70,6 +71,20 @@ export function isAnalyticsEnabled(): boolean { return getCoreStateSnapshot().snapshot.analyticsEnabled; } +/** + * Cross-realm-safe check for a `CoreRpcError` with `kind === 'timeout'`. + * `instanceof` can fail across module scopes (test harness, dynamic import, + * Vitest module isolation), so also accept a duck-typed match on `name` + * and `kind`. Used by the Sentry `beforeSend` filter to drop the + * OPENHUMAN-REACT-15/11/10/12/Z/Y family at the source. + */ +function isCoreRpcTimeoutError(err: unknown): boolean { + if (err instanceof CoreRpcError) return err.kind === 'timeout'; + if (typeof err !== 'object' || err === null) return false; + const candidate = err as { name?: unknown; kind?: unknown }; + return candidate.name === 'CoreRpcError' && candidate.kind === 'timeout'; +} + export function initSentry(): void { if (!SENTRY_DSN) return; @@ -105,7 +120,19 @@ export function initSentry(): void { ], sendDefaultPii: false, - beforeSend(event) { + beforeSend(event, hint) { + // Drop noisy local-AbortController RPC timeouts at the source so a + // missed `.catch()` at a future call site cannot regress the + // OPENHUMAN-REACT-15/11/10/12/Z/Y family. Sister to the Rust-side + // `is_session_expired_event` filter / loopback classifier in PR #2063. + // Cross-realm-safe: also accept a non-instanceof match on the + // class name + kind (test harness can construct CoreRpcError in a + // different module scope). + const original = hint?.originalException as unknown; + if (isCoreRpcTimeoutError(original)) { + return null; + } + // 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'; From c6c966f7ed995ae5682113a88f16548c275c6dac Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 19 May 2026 14:25:22 +0530 Subject: [PATCH 6/7] test(observability): cover CoreRpcError timeout drop in beforeSend (#REACT-15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drops `CoreRpcError(kind='timeout')` passed via the `originalException` hint (the live in-process path). - Cross-realm duck-typed match: rejects `name === 'CoreRpcError'` + `kind === 'timeout'` even when `instanceof` would fail. - Preserves non-timeout `CoreRpcError` shapes (transport, auth_expired, …) so the filter cannot suppress real errors. Sentry-Issue: OPENHUMAN-REACT-15 --- app/src/services/__tests__/analytics.test.ts | 69 +++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/app/src/services/__tests__/analytics.test.ts b/app/src/services/__tests__/analytics.test.ts index c3ea9c4036..a568961f3f 100644 --- a/app/src/services/__tests__/analytics.test.ts +++ b/app/src/services/__tests__/analytics.test.ts @@ -79,6 +79,11 @@ vi.mock('../../utils/config', () => ({ SENTRY_DSN: 'https://abc@example.ingest.sentry.io/1', SENTRY_RELEASE: 'openhuman@test+abc', SENTRY_SMOKE_TEST: false, + // analytics.ts now imports CoreRpcError from coreRpcClient, whose + // dependency chain reads CORE_RPC_URL and CORE_RPC_TIMEOUT_MS. Provide + // stub values so the module graph loads under this mock. + CORE_RPC_URL: 'http://127.0.0.1:7788/rpc', + CORE_RPC_TIMEOUT_MS: 30000, })); describe('triggerSentryTestEvent', () => { @@ -155,14 +160,20 @@ describe('triggerSentryTestEvent', () => { describe('initSentry beforeSend manual-staging bypass', () => { /** Capture the `beforeSend` callback that `initSentry` registers. */ async function captureBeforeSend(): Promise< - (event: Record) => Record | null + ( + event: Record, + hint?: { originalException?: unknown } + ) => Record | 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) => Record | null; + beforeSend: ( + event: Record, + hint?: { originalException?: unknown } + ) => Record | null; }; return opts.beforeSend.bind(opts); } @@ -223,6 +234,60 @@ describe('initSentry beforeSend manual-staging bypass', () => { expect(result).not.toBeNull(); }); + 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 + // unhandled rejection captured by `auto.browser.global_handlers`. Even + // with .catch() landed, future call sites must not regress the family + // — this filter is the last line of defense. + hoisted.analyticsEnabled = true; // consent on so non-test events normally pass. + const beforeSend = await captureBeforeSend(); + const { CoreRpcError } = await import('../coreRpcClient'); + const timeoutErr = new CoreRpcError( + 'Core RPC openhuman.team_list_teams timed out after 30000ms', + 'timeout' + ); + + const result = beforeSend( + { message: 'CoreRpcError', tags: {}, contexts: {} }, + { originalException: timeoutErr } + ); + expect(result).toBeNull(); + }); + + test('drops cross-realm CoreRpcError-shaped timeouts (name + kind match)', async () => { + // Test harnesses and dynamic imports can construct CoreRpcError in a + // separate module scope where `instanceof` fails. The filter must still + // demote them. + hoisted.analyticsEnabled = true; + const beforeSend = await captureBeforeSend(); + const fakeErr = Object.assign(new Error('Core RPC X timed out after 30000ms'), { + name: 'CoreRpcError', + kind: 'timeout', + }); + + const result = beforeSend( + { message: 'CoreRpcError', tags: {}, contexts: {} }, + { originalException: fakeErr } + ); + expect(result).toBeNull(); + }); + + test('lets non-timeout CoreRpcError shapes through (transport, auth_expired, …)', async () => { + hoisted.analyticsEnabled = true; + const beforeSend = await captureBeforeSend(); + const { CoreRpcError } = await import('../coreRpcClient'); + const transportErr = new CoreRpcError('error sending request', 'transport'); + + const result = beforeSend( + { message: 'CoreRpcError', tags: {}, contexts: {} }, + { originalException: transportErr } + ); + // Transport errors are still worth seeing — only the local 30s + // AbortController shape gets demoted at the source. + expect(result).not.toBeNull(); + }); + test('forwards release tag and registers httpContextIntegration (#1403)', async () => { // Regression for #1403: production events arrived in Sentry with no // `release` tag and no `os` context. The release must reach Sentry.init From d70abbc9fdb3eaf78ab294068cd39af8eedaaaf0 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 19 May 2026 17:29:09 +0530 Subject: [PATCH 7/7] =?UTF-8?q?test(team-panels,core-state):=20cover=20new?= =?UTF-8?q?=20.catch()=20arms=20for=20diff-cover=20=E2=89=A580%=20(#REACT-?= =?UTF-8?q?15+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage Gate on PR #2196 caught the new `.catch()` blocks at 0% on changed lines (`TeamPanel:44-45,55-56`, `TeamMembersPanel:52-54,56`, `TeamInvitesPanel:53-56`, `CoreStateProvider:579-580,606-607`). Add focused tests that mount each panel with a rejecting `refreshTeams` / `refreshTeamMembers` / `refreshTeamInvites` and assert `window.unhandledrejection` never fires, plus two CoreStateProvider cases that reject `fetchCoreAppSnapshot` on the follow-up refresh in `updateLocalState` (via `setEncryptionKey`) and `storeSessionToken`. Locks the regression behaviour in: every new `.catch()` arm has at least one execution path that exercises it. Sentry-Issue: OPENHUMAN-REACT-15 Sentry-Issue: OPENHUMAN-REACT-10 Sentry-Issue: OPENHUMAN-REACT-12 Sentry-Issue: OPENHUMAN-REACT-Z Sentry-Issue: OPENHUMAN-REACT-Y --- .../__tests__/TeamInvitesPanel.test.tsx | 63 +++++++++++++ .../__tests__/TeamMembersPanel.test.tsx | 63 +++++++++++++ .../panels/__tests__/TeamPanel.test.tsx | 88 +++++++++++++++++++ .../__tests__/CoreStateProvider.test.tsx | 73 +++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx create mode 100644 app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx create mode 100644 app/src/components/settings/panels/__tests__/TeamPanel.test.tsx diff --git a/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx b/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx new file mode 100644 index 0000000000..d542a8b3e2 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx @@ -0,0 +1,63 @@ +/** + * TeamInvitesPanel — unhandled-rejection guard test. + * + * Regression coverage for OPENHUMAN-REACT-12: bootstrap-time + * `team_list_invites` failures leaked through the + * `void refreshTeamInvites(...).finally(...)` pattern. The new explicit + * `.catch()` chained before `.finally()` must absorb the rejection + * silently. + */ +import { render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useCoreState } from '../../../../providers/CoreStateProvider'; +import { CoreRpcError } from '../../../../services/coreRpcClient'; +import TeamInvitesPanel from '../TeamInvitesPanel'; + +vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() })); +vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), +})); +vi.mock('../../components/SettingsHeader', () => ({ default: () => null })); +vi.mock('react-router-dom', () => ({ + useParams: () => ({ teamId: 'team-u1' }), + useLocation: () => ({ pathname: '/team/manage/team-u1' }), +})); + +describe('TeamInvitesPanel — unhandled-rejection guard (#REACT-12)', () => { + let urEvents: PromiseRejectionEvent[]; + const urHandler = (e: PromiseRejectionEvent) => { + urEvents.push(e); + }; + + beforeEach(() => { + urEvents = []; + window.addEventListener('unhandledrejection', urHandler); + }); + + afterEach(() => { + window.removeEventListener('unhandledrejection', urHandler); + vi.clearAllMocks(); + }); + + test('swallows refreshTeamInvites CoreRpcError(timeout) without unhandledrejection', async () => { + const refreshTeamInvites = vi + .fn() + .mockRejectedValue( + new CoreRpcError('Core RPC openhuman.team_list_invites timed out after 30000ms', 'timeout') + ); + vi.mocked(useCoreState).mockReturnValue({ + snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } }, + teams: [{ team: { _id: 'team-u1', name: 'T' }, role: 'ADMIN' }], + teamInvitesById: {}, + refreshTeamInvites, + } as never); + + render(); + await waitFor(() => expect(refreshTeamInvites).toHaveBeenCalledWith('team-u1')); + await new Promise(r => setTimeout(r, 20)); + + expect(urEvents).toHaveLength(0); + }); +}); diff --git a/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx b/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx new file mode 100644 index 0000000000..40217311f6 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx @@ -0,0 +1,63 @@ +/** + * TeamMembersPanel — unhandled-rejection guard test. + * + * Regression coverage for OPENHUMAN-REACT-10: bootstrap-time + * `team_list_members` failures leaked through the + * `void refreshTeamMembers(...).finally(...)` pattern. The new explicit + * `.catch()` chained before `.finally()` must absorb the rejection + * silently. + */ +import { render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useCoreState } from '../../../../providers/CoreStateProvider'; +import { CoreRpcError } from '../../../../services/coreRpcClient'; +import TeamMembersPanel from '../TeamMembersPanel'; + +vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() })); +vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), +})); +vi.mock('../../components/SettingsHeader', () => ({ default: () => null })); +vi.mock('react-router-dom', () => ({ + useParams: () => ({ teamId: 'team-u1' }), + useLocation: () => ({ pathname: '/team/manage/team-u1' }), +})); + +describe('TeamMembersPanel — unhandled-rejection guard (#REACT-10)', () => { + let urEvents: PromiseRejectionEvent[]; + const urHandler = (e: PromiseRejectionEvent) => { + urEvents.push(e); + }; + + beforeEach(() => { + urEvents = []; + window.addEventListener('unhandledrejection', urHandler); + }); + + afterEach(() => { + window.removeEventListener('unhandledrejection', urHandler); + vi.clearAllMocks(); + }); + + test('swallows refreshTeamMembers CoreRpcError(timeout) without unhandledrejection', async () => { + const refreshTeamMembers = vi + .fn() + .mockRejectedValue( + new CoreRpcError('Core RPC openhuman.team_list_members timed out after 30000ms', 'timeout') + ); + vi.mocked(useCoreState).mockReturnValue({ + snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } }, + teams: [{ team: { _id: 'team-u1', name: 'T' }, role: 'ADMIN' }], + teamMembersById: {}, + refreshTeamMembers, + } as never); + + render(); + await waitFor(() => expect(refreshTeamMembers).toHaveBeenCalledWith('team-u1')); + await new Promise(r => setTimeout(r, 20)); + + expect(urEvents).toHaveLength(0); + }); +}); diff --git a/app/src/components/settings/panels/__tests__/TeamPanel.test.tsx b/app/src/components/settings/panels/__tests__/TeamPanel.test.tsx new file mode 100644 index 0000000000..c6ab803fab --- /dev/null +++ b/app/src/components/settings/panels/__tests__/TeamPanel.test.tsx @@ -0,0 +1,88 @@ +/** + * TeamPanel — unhandled-rejection guard tests. + * + * Regression coverage for OPENHUMAN-REACT-15 / REACT-11: bootstrap-time + * `team_list_teams` failures (cold core boot, backend 504, local + * AbortController timeout) used to leak as `void promise(...)` unhandled + * rejections via the `useEffect` mount handler. The new `.catch()` in + * `refreshTeamsWithLoading` plus the defensive `.catch()` on the + * `useEffect` invocation must absorb the rejection silently. + */ +import { render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useCoreState } from '../../../../providers/CoreStateProvider'; +import { CoreRpcError } from '../../../../services/coreRpcClient'; +import TeamPanel from '../TeamPanel'; + +vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() })); +vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + navigateToTeamManagement: vi.fn(), + breadcrumbs: [], + }), +})); +vi.mock('../../components/SettingsHeader', () => ({ default: () => null })); + +describe('TeamPanel — unhandled-rejection guard (#REACT-15)', () => { + let urEvents: PromiseRejectionEvent[]; + const urHandler = (e: PromiseRejectionEvent) => { + urEvents.push(e); + }; + + beforeEach(() => { + urEvents = []; + window.addEventListener('unhandledrejection', urHandler); + }); + + afterEach(() => { + window.removeEventListener('unhandledrejection', urHandler); + vi.clearAllMocks(); + }); + + test('swallows refreshTeams CoreRpcError(timeout) without unhandledrejection', async () => { + const refreshTeams = vi + .fn() + .mockRejectedValue( + new CoreRpcError('Core RPC openhuman.team_list_teams timed out after 30000ms', 'timeout') + ); + vi.mocked(useCoreState).mockReturnValue({ + snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } }, + teams: [], + refresh: vi.fn(), + refreshTeams, + } as never); + + render(); + + // Mount-effect must have fired refreshTeams and observed the rejection. + await waitFor(() => expect(refreshTeams).toHaveBeenCalled()); + // Flush microtasks so the `.catch()` chain has a chance to settle. + await new Promise(r => setTimeout(r, 20)); + + expect(urEvents).toHaveLength(0); + }); + + test('swallows transport-kind refreshTeams failure without unhandledrejection', async () => { + // Backend 504 / connect-refused shape (REACT-13 / REACT-14 family) must + // also be absorbed — the `.catch()` is unconditional, not + // kind-gated. + const refreshTeams = vi + .fn() + .mockRejectedValue(new CoreRpcError('backend request GET /teams', 'transport')); + vi.mocked(useCoreState).mockReturnValue({ + snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } }, + teams: [], + refresh: vi.fn(), + refreshTeams, + } as never); + + render(); + await waitFor(() => expect(refreshTeams).toHaveBeenCalled()); + await new Promise(r => setTimeout(r, 20)); + + expect(urEvents).toHaveLength(0); + }); +}); diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index 26da31e297..9476b48c2c 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -481,6 +481,79 @@ describe('CoreStateProvider — identity-change cache clearing', () => { expect(screen.getByTestId('token').textContent).toBe('tok1'); }); + it('setEncryptionKey (updateLocalState) swallows refresh errors after the local-state write lands (#REACT-Z #REACT-Y)', async () => { + // Regression for OPENHUMAN-REACT-Z/Y: a missing `.catch()` on the + // follow-up `refresh()` inside `updateLocalState` let an + // `app_state_snapshot` timeout bubble out as an unhandled rejection. + fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); + listTeams.mockResolvedValue([]); + vi.mocked(coreStateApi.updateCoreLocalState).mockReset(); + vi.mocked(coreStateApi.updateCoreLocalState).mockResolvedValue(undefined as never); + + let ctx: CoreStateContextValue | undefined; + render( + + { + ctx = next; + }} + /> + + ); + + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + fetchSnapshot.mockRejectedValueOnce( + new Error('Core RPC openhuman.app_state_snapshot timed out after 30000ms') + ); + + await act(async () => { + // setEncryptionKey is a thin sync wrapper around updateLocalState + // (provider line 694) — exercising it covers the new .catch() arm + // on line 579-580. + await expect(ctx!.setEncryptionKey('new-key')).resolves.toBeUndefined(); + }); + + expect(vi.mocked(coreStateApi.updateCoreLocalState)).toHaveBeenCalledWith({ + encryptionKey: 'new-key', + }); + }); + + it('storeSessionToken swallows refresh errors after the session write lands (#REACT-Z #REACT-Y)', async () => { + // Regression for OPENHUMAN-REACT-Z/Y: a missing `.catch()` on the + // post-login `refresh()` inside `storeSessionToken` let an + // `app_state_snapshot` timeout bubble out as an unhandled rejection + // immediately after sign-in. + fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); + listTeams.mockResolvedValue([]); + vi.mocked(tauriCommands.storeSession).mockReset(); + vi.mocked(tauriCommands.storeSession).mockResolvedValue(undefined as never); + vi.mocked(tauriCommands.syncMemoryClientToken).mockReset(); + vi.mocked(tauriCommands.syncMemoryClientToken).mockResolvedValue(undefined as never); + + let ctx: CoreStateContextValue | undefined; + render( + + { + ctx = next; + }} + /> + + ); + + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + fetchSnapshot.mockRejectedValueOnce( + new Error('Core RPC openhuman.app_state_snapshot timed out after 30000ms') + ); + + await act(async () => { + const token = makeJwt({ sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 }); + await expect(ctx!.storeSessionToken(token, {})).resolves.toBeUndefined(); + }); + + expect(vi.mocked(tauriCommands.storeSession)).toHaveBeenCalled(); + }); + it('setMeetAutoOrchestratorHandoff swallows refresh errors after the RPC succeeds (#1299)', async () => { fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); listTeams.mockResolvedValue([]);