Files
supabase/apps/studio/lib/sentry-client-options.test.ts
T
Jordi Enric fb22534439 fix: share sentry crash policy and enable www reporting (#50232)
## Problem

The website initializes Sentry only on the server and edge runtimes,
leaving browser crashes unreported. Its crash-reporting setup also needs
the same consent and third-party filtering policy that docs and Studio
otherwise maintain separately.

## Fix

Add www browser initialization and tagged crash capture for both Next.js
routers, with accessible fallback focus. Move the shared
consent/platform and third-party filtering into common/sentry, reuse it
from all three apps, and remove the duplicated docs/www helpers and
tests. Preserve each app's initialization and Studio's additional noise
filtering, sampling, and sanitization.

Include the source-map upload token in www's build cache inputs, and
trigger the shared/www and Studio test workflows when the shared policy
changes.

## How to test

- Run `pnpm --filter www test ../../packages/common/sentry.test.ts
lib/sentry-capture.test.tsx`: all 22 shared-policy and real-SDK capture
tests passed locally.
- Run `pnpm --filter studio exec vitest run
lib/sentry-client-options.test.ts`: all 42 Studio options and
policy-parity tests passed locally.
- The www capture tests exercise the actual initializer and both router
handlers with an in-memory transport, verify crash tags and fallback
focus, and enforce consent. Removing initialization, capture calls,
boundary tags, or consent gating was verified to fail these tests.
- On a www preview with its DSN configured, accept telemetry consent and
trigger temporary render errors in both routers. Verify they reach the
www Sentry project with the boundary tag and readable stack traces.

Formatting passes. Full local app typechecks encounter existing
dependency/generated-file drift, with no diagnostics in changed files.
Three unchanged TanStack mock call-count tests fail locally and
reproduce against the pre-refactor implementation. Live Sentry ingestion
and source-map uploads remain deployment checks.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Accessibility**
- Error pages now automatically move focus to a clearly labeled error
message, helping screen-reader and keyboard users understand when a page
fails.

- **Reliability**
- Browser error reporting now captures application crashes more
consistently across supported page types and navigation transitions.
- Reporting respects consent and platform availability while filtering
unrelated third-party failures.

- **Testing**
- Expanded automated coverage for error capture, reporting rules,
consent handling, and accessible error-page behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-14 09:28:08 +02:00

546 lines
18 KiB
TypeScript

import type { Event as SentryEvent, StackFrame } from '@sentry/react'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import {
buildSentryClientOptions,
isBrowserWalletExtensionError,
isCancellationRejection,
isChallengeExpiredError,
isUserAbortedOperation,
} from './sentry-client-options'
describe('Sentry beforeSend filtering functions', () => {
describe('isBrowserWalletExtensionError', () => {
it('returns true for Gate.io wallet extension error (gt-window-provider.js)', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'en.shouldSetTallyForCurrentProvider is not a function',
stacktrace: {
frames: [
{ filename: 'app:///_next/static/chunks/main.js' } as StackFrame,
{ filename: 'app:///gt-window-provider.js' } as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('returns true for Gate.io BTC wallet extension error (gt-window-provider-btc.js)', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'f.shouldSetTallyForCurrentProvider is not a function',
stacktrace: {
frames: [{ filename: 'app:///gt-window-provider-btc.js' } as StackFrame],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('returns true for wallet-provider in abs_path', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'Error',
value: 'wallet error',
stacktrace: {
frames: [
{ abs_path: 'chrome-extension://abc123/wallet-provider.js' } as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('returns false for regular application errors', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'Error',
value: 'Regular error',
stacktrace: {
frames: [
{ filename: 'app:///_next/static/chunks/main.js' } as StackFrame,
{ filename: 'app:///_next/static/chunks/pages/index.js' } as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(false)
})
it('returns false for empty event', () => {
const event: SentryEvent = {}
expect(isBrowserWalletExtensionError(event)).toBe(false)
})
it('returns false when exception values are empty', () => {
const event: SentryEvent = {
exception: {
values: [],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(false)
})
it('returns false when stacktrace frames are undefined', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'Error',
value: 'Error without stacktrace',
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(false)
})
})
describe('isUserAbortedOperation', () => {
it('returns true for "operation was aborted" error', () => {
const error = new Error('The operation was aborted.')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true for "signal is aborted" error', () => {
const error = new Error('signal is aborted without reason')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true for "manually canceled" error', () => {
const error = new Error('operation is manually canceled')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true for "AbortError" message', () => {
const error = new Error('AbortError: The operation was aborted')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true when message is in event.message (no error object)', () => {
const error = null
const event: SentryEvent = {
message: '[CRITICAL][sign in via EP] Failed: The operation was aborted.',
}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true for event message with "signal is aborted"', () => {
const event: SentryEvent = {
message: '[CRITICAL][sign in via EP] Failed: signal is aborted without reason',
}
expect(isUserAbortedOperation(undefined, event)).toBe(true)
})
it('returns false for regular errors', () => {
const error = new Error('Something went wrong')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(false)
})
it('returns false for empty inputs', () => {
expect(isUserAbortedOperation(null, {})).toBe(false)
expect(isUserAbortedOperation(undefined, {})).toBe(false)
})
it('handles non-Error objects gracefully', () => {
const error = { message: 'The operation was aborted.' }
const event: SentryEvent = {}
// Non-Error objects should not match since we check instanceof Error
expect(isUserAbortedOperation(error, event)).toBe(false)
})
})
describe('isCancellationRejection', () => {
it('returns true for cancellation type in extra.__serialized__', () => {
const event: SentryEvent = {
extra: {
__serialized__: {
msg: 'operation is manually canceled',
type: 'cancelation',
},
},
}
expect(isCancellationRejection(event)).toBe(true)
})
it('returns false when type is not cancelation', () => {
const event: SentryEvent = {
extra: {
__serialized__: {
msg: 'some error',
type: 'error',
},
},
}
expect(isCancellationRejection(event)).toBe(false)
})
it('returns false when __serialized__ is undefined', () => {
const event: SentryEvent = {
extra: {},
}
expect(isCancellationRejection(event)).toBe(false)
})
it('returns false when extra is undefined', () => {
const event: SentryEvent = {}
expect(isCancellationRejection(event)).toBe(false)
})
it('returns false when __serialized__ has no type property', () => {
const event: SentryEvent = {
extra: {
__serialized__: {
msg: 'some message',
},
},
}
expect(isCancellationRejection(event)).toBe(false)
})
})
describe('isChallengeExpiredError', () => {
it('returns true for challenge-expired error message', () => {
const error = new Error('Non-Error promise rejection captured with value: challenge-expired')
const event: SentryEvent = {}
expect(isChallengeExpiredError(error, event)).toBe(true)
})
it('returns true when challenge-expired is in event.message', () => {
const event: SentryEvent = {
message: 'challenge-expired',
}
expect(isChallengeExpiredError(null, event)).toBe(true)
})
it('returns false for regular errors', () => {
const error = new Error('Something went wrong')
const event: SentryEvent = {}
expect(isChallengeExpiredError(error, event)).toBe(false)
})
it('returns false for empty inputs', () => {
expect(isChallengeExpiredError(null, {})).toBe(false)
expect(isChallengeExpiredError(undefined, {})).toBe(false)
})
it('returns false for similar but different messages', () => {
const error = new Error('challenge expired') // No hyphen
const event: SentryEvent = {}
expect(isChallengeExpiredError(error, event)).toBe(false)
})
})
describe('integration scenarios', () => {
it('correctly identifies SUPABASE-APP-353 pattern (cancellation rejection)', () => {
// Based on actual Sentry issue SUPABASE-APP-353
const event: SentryEvent = {
exception: {
values: [
{
type: 'UnhandledRejection',
value: 'Object captured as promise rejection with keys: msg, type',
},
],
},
extra: {
__serialized__: {
msg: 'operation is manually canceled',
type: 'cancelation',
},
},
}
expect(isCancellationRejection(event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-AFC pattern (wallet extension)', () => {
// Based on actual Sentry issue SUPABASE-APP-AFC
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'f.shouldSetTallyForCurrentProvider is not a function',
stacktrace: {
frames: [
{
filename:
'node_modules/.pnpm/@sentry+browser@10.27.0/node_modules/@sentry/browser/src/helpers.ts',
function: 'n',
} as StackFrame,
{
filename: 'app:///gt-window-provider-btc.js',
function: 'GateWindowProvider.internalListener',
} as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-92A pattern (wallet extension)', () => {
// Based on actual Sentry issue SUPABASE-APP-92A
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'en.shouldSetTallyForCurrentProvider is not a function',
stacktrace: {
frames: [
{
filename:
'node_modules/.pnpm/@sentry+browser@10.27.0/node_modules/@sentry/browser/src/helpers.ts',
function: 'n',
} as StackFrame,
{
filename: 'app:///gt-window-provider.js',
function: 'GateWindowProvider.internalListener',
} as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-BG6 pattern (user aborted)', () => {
// Based on actual Sentry issue SUPABASE-APP-BG6
const error = new Error('The operation was aborted.')
const event: SentryEvent = {
message: '[CRITICAL][sign in via EP] Failed: The operation was aborted.',
}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-BG7 pattern (signal aborted)', () => {
// Based on actual Sentry issue SUPABASE-APP-BG7
const error = new Error('signal is aborted without reason')
const event: SentryEvent = {
message: '[CRITICAL][sign in via EP] Failed: signal is aborted without reason',
}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-ACC pattern (challenge expired)', () => {
// Based on actual Sentry issue SUPABASE-APP-ACC
const error = new Error('Non-Error promise rejection captured with value: challenge-expired')
const event: SentryEvent = {
exception: {
values: [
{
type: 'UnhandledRejection',
value: 'Non-Error promise rejection captured with value: challenge-expired',
},
],
},
}
expect(isChallengeExpiredError(error, event)).toBe(true)
})
it('does not filter legitimate errors', () => {
const error = new Error('Cannot read property "foo" of undefined')
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'Cannot read property "foo" of undefined',
stacktrace: {
frames: [{ filename: 'app:///_next/static/chunks/pages/index.js' } as StackFrame],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(false)
expect(isUserAbortedOperation(error, event)).toBe(false)
expect(isCancellationRejection(event)).toBe(false)
expect(isChallengeExpiredError(error, event)).toBe(false)
})
})
})
describe('buildSentryClientOptions', () => {
// Representative subset of Sentry's default integrations. `integrations`
// is the function form: Sentry.init calls it with the defaults and installs
// whatever it returns, so dropping these here would disable session
// envelopes (BrowserSession) and window.onerror capture (GlobalHandlers).
const fakeDefaultIntegrations = [{ name: 'BrowserSession' }, { name: 'GlobalHandlers' }]
const getIntegrationNames = (options: ReturnType<typeof buildSentryClientOptions>) => {
const integrations = options.integrations
if (typeof integrations !== 'function') {
throw new Error('expected the function form of integrations')
}
return integrations(fakeDefaultIntegrations).map((integration) => integration.name)
}
it('preserves the default integrations passed in by Sentry.init', () => {
for (const includeThirdPartyErrorFilter of [true, false]) {
const names = getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter }))
// browserSessionIntegration is what sends the session envelope on every
// page load; globalHandlers is window.onerror / unhandledrejection.
expect(names).toContain('BrowserSession')
expect(names).toContain('GlobalHandlers')
}
})
it('sets the release only when one is provided', () => {
const withRelease = buildSentryClientOptions({
includeThirdPartyErrorFilter: false,
release: 'abc123',
})
expect(withRelease.release).toBe('abc123')
// The key must be ABSENT when no release is passed: on the Next build a
// `release: undefined` entry would override the release injected into
// @sentry/nextjs's init by withSentryConfig (options are spread last).
const withoutRelease = buildSentryClientOptions({ includeThirdPartyErrorFilter: true })
expect('release' in withoutRelease).toBe(false)
})
it('includes the third-party error filter only when the build annotates frames', () => {
// Next build: withSentryConfig injects the applicationKey metadata.
expect(
getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter: true }))
).toContain('ThirdPartyErrorsFilter')
// TanStack/Vite build: no bundler metadata — including the integration
// would tag every event third_party_code=true and beforeSend would drop
// them all.
expect(
getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter: false }))
).not.toContain('ThirdPartyErrorsFilter')
})
it('appends build-specific extra integrations', () => {
const options = buildSentryClientOptions({
includeThirdPartyErrorFilter: false,
extraIntegrations: [{ name: 'FakeRouterTracing' }],
})
expect(getIntegrationNames(options)).toContain('FakeRouterTracing')
})
it('builds the same shared options for both builds (parity)', () => {
const nextOptions = buildSentryClientOptions({ includeThirdPartyErrorFilter: true })
const tanstackOptions = buildSentryClientOptions({ includeThirdPartyErrorFilter: false })
// Everything except the integrations array must be identical between the
// two runtimes.
const { integrations: _next, ...nextRest } = nextOptions
const { integrations: _tanstack, ...tanstackRest } = tanstackOptions
expect(Object.keys(nextRest)).toEqual(Object.keys(tanstackRest))
expect(nextRest.tracesSampleRate).toBe(tanstackRest.tracesSampleRate)
expect(nextRest.allowUrls).toEqual(tanstackRest.allowUrls)
expect(nextRest.ignoreErrors).toEqual(tanstackRest.ignoreErrors)
})
})
describe('which errors Studio sends to Sentry', () => {
let beforeSend: NonNullable<ReturnType<typeof buildSentryClientOptions>['beforeSend']>
let restoreConsent: () => void
beforeAll(async () => {
vi.stubEnv('NEXT_PUBLIC_IS_PLATFORM', 'true')
vi.resetModules()
const { consentState } = await import('common')
const previousConsent = consentState.hasConsented
consentState.hasConsented = true
restoreConsent = () => {
consentState.hasConsented = previousConsent
}
const { buildSentryClientOptions } = await import('./sentry-client-options')
const options = buildSentryClientOptions({ includeThirdPartyErrorFilter: true })
if (!options.beforeSend) throw new Error('Missing Sentry beforeSend')
beforeSend = options.beforeSend
})
afterAll(() => {
restoreConsent?.()
vi.unstubAllEnvs()
vi.resetModules()
})
it('drops errors from outside the app', async () => {
expect(await beforeSend({ type: undefined, tags: { third_party_code: true } }, {})).toBeNull()
})
it.each([true, 'true'])(
'sends page crashes even when the code location is missing: %s',
async (tag) => {
const event: Parameters<typeof beforeSend>[0] = {
type: undefined,
tags: { third_party_code: true, globalErrorBoundary: tag },
exception: { values: [{ value: 'Page crashed' }] },
}
expect(await beforeSend(event, {})).toBe(event)
}
)
it('drops errors with no code location when they did not crash the page', async () => {
expect(
await beforeSend({ type: undefined, exception: { values: [{ value: 'No stack' }] } }, {})
).toBeNull()
})
})