From a244678391f363de62061d38e6dcefd184c7bda5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 12 Feb 2026 16:26:45 +0100 Subject: [PATCH 1/2] EAS Build Hooks --- packages/core/package.json | 5 +- .../core/scripts/eas-build-on-complete.js | 104 +++++ packages/core/scripts/eas-build-on-error.js | 94 +++++ packages/core/scripts/eas-build-on-success.js | 96 +++++ packages/core/src/js/tools/easBuildHooks.ts | 277 ++++++++++++ .../core/test/tools/easBuildHooks.test.ts | 399 ++++++++++++++++++ samples/expo/package.json | 2 + 7 files changed, 976 insertions(+), 1 deletion(-) create mode 100755 packages/core/scripts/eas-build-on-complete.js create mode 100755 packages/core/scripts/eas-build-on-error.js create mode 100755 packages/core/scripts/eas-build-on-success.js create mode 100644 packages/core/src/js/tools/easBuildHooks.ts create mode 100644 packages/core/test/tools/easBuildHooks.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index b3f6bea829..320ffd3766 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,7 +45,10 @@ "lint:prettier": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check \"{src,test,scripts,plugin/src}/**/**.ts\"" }, "bin": { - "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js", + "sentry-eas-build-on-error": "scripts/eas-build-on-error.js", + "sentry-eas-build-on-success": "scripts/eas-build-on-success.js", + "sentry-eas-build-on-complete": "scripts/eas-build-on-complete.js" }, "keywords": [ "react-native", diff --git a/packages/core/scripts/eas-build-on-complete.js b/packages/core/scripts/eas-build-on-complete.js new file mode 100755 index 0000000000..9f8753ee54 --- /dev/null +++ b/packages/core/scripts/eas-build-on-complete.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * EAS Build Hook: on-complete + * + * This script captures EAS build completion events and reports them to Sentry. + * It uses the EAS_BUILD_STATUS environment variable to determine whether + * the build succeeded or failed. + * + * Add it to your package.json scripts: + * + * "eas-build-on-complete": "sentry-eas-build-on-complete" + * + * Required environment variables: + * - SENTRY_DSN: Your Sentry DSN + * + * Optional environment variables: + * - SENTRY_EAS_BUILD_CAPTURE_SUCCESS: Set to 'true' to also capture successful builds + * - SENTRY_EAS_BUILD_TAGS: JSON string of additional tags + * - SENTRY_EAS_BUILD_ERROR_MESSAGE: Custom error message for failed builds + * - SENTRY_EAS_BUILD_SUCCESS_MESSAGE: Custom success message for successful builds + * + * EAS Build provides: + * - EAS_BUILD_STATUS: 'finished' or 'errored' + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + * @see https://docs.sentry.io/platforms/react-native/ + */ + +const path = require('path'); +const fs = require('fs'); + +// Try to load environment variables +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +async function main() { + loadEnv(); + + // Dynamically import the hooks module (it's compiled to dist/) + let captureEASBuildComplete; + try { + // Try the compiled output first + const hooks = require('../dist/js/tools/easBuildHooks.js'); + captureEASBuildComplete = hooks.captureEASBuildComplete; + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } + + // Parse options from environment variables + const options = { + dsn: process.env.SENTRY_DSN, + errorMessage: process.env.SENTRY_EAS_BUILD_ERROR_MESSAGE, + successMessage: process.env.SENTRY_EAS_BUILD_SUCCESS_MESSAGE, + captureSuccessfulBuilds: process.env.SENTRY_EAS_BUILD_CAPTURE_SUCCESS === 'true', + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + try { + await captureEASBuildComplete(options); + console.log('[Sentry] EAS build complete hook finished.'); + } catch (error) { + console.error('[Sentry] Error in eas-build-on-complete hook:', error); + // Don't fail the build hook itself + } +} + +main(); diff --git a/packages/core/scripts/eas-build-on-error.js b/packages/core/scripts/eas-build-on-error.js new file mode 100755 index 0000000000..6c0a1d12e0 --- /dev/null +++ b/packages/core/scripts/eas-build-on-error.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * EAS Build Hook: on-error + * + * This script captures EAS build failures and reports them to Sentry. + * Add it to your package.json scripts: + * + * "eas-build-on-error": "sentry-eas-build-on-error" + * + * Required environment variables: + * - SENTRY_DSN: Your Sentry DSN + * + * Optional environment variables: + * - SENTRY_EAS_BUILD_TAGS: JSON string of additional tags + * - SENTRY_EAS_BUILD_ERROR_MESSAGE: Custom error message + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + * @see https://docs.sentry.io/platforms/react-native/ + */ + +const path = require('path'); +const fs = require('fs'); + +// Try to load environment variables +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +async function main() { + loadEnv(); + + // Dynamically import the hooks module (it's compiled to dist/) + let captureEASBuildError; + try { + // Try the compiled output first + const hooks = require('../dist/js/tools/easBuildHooks.js'); + captureEASBuildError = hooks.captureEASBuildError; + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } + + // Parse options from environment variables + const options = { + dsn: process.env.SENTRY_DSN, + errorMessage: process.env.SENTRY_EAS_BUILD_ERROR_MESSAGE, + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + try { + await captureEASBuildError(options); + console.log('[Sentry] EAS build error hook completed.'); + } catch (error) { + console.error('[Sentry] Error in eas-build-on-error hook:', error); + // Don't fail the build hook itself + } +} + +main(); diff --git a/packages/core/scripts/eas-build-on-success.js b/packages/core/scripts/eas-build-on-success.js new file mode 100755 index 0000000000..af6790c807 --- /dev/null +++ b/packages/core/scripts/eas-build-on-success.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * EAS Build Hook: on-success + * + * This script captures EAS build successes and reports them to Sentry. + * Add it to your package.json scripts: + * + * "eas-build-on-success": "sentry-eas-build-on-success" + * + * Required environment variables: + * - SENTRY_DSN: Your Sentry DSN + * - SENTRY_EAS_BUILD_CAPTURE_SUCCESS: Set to 'true' to capture successful builds + * + * Optional environment variables: + * - SENTRY_EAS_BUILD_TAGS: JSON string of additional tags + * - SENTRY_EAS_BUILD_SUCCESS_MESSAGE: Custom success message + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + * @see https://docs.sentry.io/platforms/react-native/ + */ + +const path = require('path'); +const fs = require('fs'); + +// Try to load environment variables +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +async function main() { + loadEnv(); + + // Dynamically import the hooks module (it's compiled to dist/) + let captureEASBuildSuccess; + try { + // Try the compiled output first + const hooks = require('../dist/js/tools/easBuildHooks.js'); + captureEASBuildSuccess = hooks.captureEASBuildSuccess; + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } + + // Parse options from environment variables + const options = { + dsn: process.env.SENTRY_DSN, + successMessage: process.env.SENTRY_EAS_BUILD_SUCCESS_MESSAGE, + captureSuccessfulBuilds: process.env.SENTRY_EAS_BUILD_CAPTURE_SUCCESS === 'true', + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + try { + await captureEASBuildSuccess(options); + console.log('[Sentry] EAS build success hook completed.'); + } catch (error) { + console.error('[Sentry] Error in eas-build-on-success hook:', error); + // Don't fail the build hook itself + } +} + +main(); diff --git a/packages/core/src/js/tools/easBuildHooks.ts b/packages/core/src/js/tools/easBuildHooks.ts new file mode 100644 index 0000000000..20bbdffd87 --- /dev/null +++ b/packages/core/src/js/tools/easBuildHooks.ts @@ -0,0 +1,277 @@ +/** + * EAS Build Hooks for Sentry + * + * This module provides utilities for capturing EAS build lifecycle events + * and sending them to Sentry. It supports the following EAS npm hooks: + * - eas-build-on-error: Captures build failures + * - eas-build-on-success: Captures successful builds (optional) + * - eas-build-on-complete: Captures build completion with metrics + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + */ + +/* eslint-disable no-console */ +/* eslint-disable no-bitwise */ + +const SENTRY_DSN_ENV = 'SENTRY_DSN'; +const EAS_BUILD_ENV = 'EAS_BUILD'; + +/** + * Environment variables provided by EAS Build. + * @see https://docs.expo.dev/build-reference/variables/ + */ +export interface EASBuildEnv { + EAS_BUILD?: string; + EAS_BUILD_ID?: string; + EAS_BUILD_PLATFORM?: string; + EAS_BUILD_PROFILE?: string; + EAS_BUILD_PROJECT_ID?: string; + EAS_BUILD_GIT_COMMIT_HASH?: string; + EAS_BUILD_RUN_FROM_CI?: string; + EAS_BUILD_STATUS?: string; + EAS_BUILD_APP_VERSION?: string; + EAS_BUILD_APP_BUILD_VERSION?: string; + EAS_BUILD_USERNAME?: string; + EAS_BUILD_WORKINGDIR?: string; +} + +/** Options for configuring EAS build hook behavior. */ +export interface EASBuildHookOptions { + dsn?: string; + tags?: Record; + captureSuccessfulBuilds?: boolean; + errorMessage?: string; + successMessage?: string; +} + +interface ParsedDsn { + protocol: string; + host: string; + projectId: string; + publicKey: string; +} + +interface SentryEvent { + event_id: string; + timestamp: number; + platform: string; + level: 'error' | 'info' | 'warning'; + logger: string; + environment: string; + release?: string; + tags: Record; + contexts: Record>; + message?: { formatted: string }; + exception?: { + values: Array<{ type: string; value: string; mechanism: { type: string; handled: boolean } }>; + }; + fingerprint?: string[]; + sdk: { name: string; version: string }; +} + +/** Checks if the current environment is an EAS Build. */ +export function isEASBuild(): boolean { + return process.env[EAS_BUILD_ENV] === 'true'; +} + +/** Gets the EAS build environment variables. */ +export function getEASBuildEnv(): EASBuildEnv { + return { + EAS_BUILD: process.env.EAS_BUILD, + EAS_BUILD_ID: process.env.EAS_BUILD_ID, + EAS_BUILD_PLATFORM: process.env.EAS_BUILD_PLATFORM, + EAS_BUILD_PROFILE: process.env.EAS_BUILD_PROFILE, + EAS_BUILD_PROJECT_ID: process.env.EAS_BUILD_PROJECT_ID, + EAS_BUILD_GIT_COMMIT_HASH: process.env.EAS_BUILD_GIT_COMMIT_HASH, + EAS_BUILD_RUN_FROM_CI: process.env.EAS_BUILD_RUN_FROM_CI, + EAS_BUILD_STATUS: process.env.EAS_BUILD_STATUS, + EAS_BUILD_APP_VERSION: process.env.EAS_BUILD_APP_VERSION, + EAS_BUILD_APP_BUILD_VERSION: process.env.EAS_BUILD_APP_BUILD_VERSION, + EAS_BUILD_USERNAME: process.env.EAS_BUILD_USERNAME, + EAS_BUILD_WORKINGDIR: process.env.EAS_BUILD_WORKINGDIR, + }; +} + +function parseDsn(dsn: string): ParsedDsn | undefined { + try { + const url = new URL(dsn); + const projectId = url.pathname.replace('/', ''); + return { protocol: url.protocol.replace(':', ''), host: url.host, projectId, publicKey: url.username }; + } catch { + return undefined; + } +} + +function getEnvelopeEndpoint(dsn: ParsedDsn): string { + return `${dsn.protocol}://${dsn.host}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}&sentry_version=7`; +} + +function generateEventId(): string { + const bytes = new Uint8Array(16); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < 16; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + const byte6 = bytes[6]; + const byte8 = bytes[8]; + if (byte6 !== undefined && byte8 !== undefined) { + bytes[6] = (byte6 & 0x0f) | 0x40; + bytes[8] = (byte8 & 0x3f) | 0x80; + } + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +function createEASBuildTags(env: EASBuildEnv): Record { + const tags: Record = {}; + if (env.EAS_BUILD_PLATFORM) tags['eas.platform'] = env.EAS_BUILD_PLATFORM; + if (env.EAS_BUILD_PROFILE) tags['eas.profile'] = env.EAS_BUILD_PROFILE; + if (env.EAS_BUILD_ID) tags['eas.build_id'] = env.EAS_BUILD_ID; + if (env.EAS_BUILD_PROJECT_ID) tags['eas.project_id'] = env.EAS_BUILD_PROJECT_ID; + if (env.EAS_BUILD_RUN_FROM_CI) tags['eas.from_ci'] = env.EAS_BUILD_RUN_FROM_CI; + if (env.EAS_BUILD_STATUS) tags['eas.status'] = env.EAS_BUILD_STATUS; + if (env.EAS_BUILD_USERNAME) tags['eas.username'] = env.EAS_BUILD_USERNAME; + return tags; +} + +function createEASBuildContext(env: EASBuildEnv): Record { + return { + build_id: env.EAS_BUILD_ID, + platform: env.EAS_BUILD_PLATFORM, + profile: env.EAS_BUILD_PROFILE, + project_id: env.EAS_BUILD_PROJECT_ID, + git_commit: env.EAS_BUILD_GIT_COMMIT_HASH, + from_ci: env.EAS_BUILD_RUN_FROM_CI === 'true', + status: env.EAS_BUILD_STATUS, + app_version: env.EAS_BUILD_APP_VERSION, + build_version: env.EAS_BUILD_APP_BUILD_VERSION, + username: env.EAS_BUILD_USERNAME, + working_dir: env.EAS_BUILD_WORKINGDIR, + }; +} + +function createEnvelope(event: SentryEvent, dsn: ParsedDsn): string { + const envelopeHeaders = JSON.stringify({ + event_id: event.event_id, + sent_at: new Date().toISOString(), + dsn: `${dsn.protocol}://${dsn.publicKey}@${dsn.host}/${dsn.projectId}`, + sdk: event.sdk, + }); + const itemHeaders = JSON.stringify({ type: 'event', content_type: 'application/json' }); + const itemPayload = JSON.stringify(event); + return `${envelopeHeaders}\n${itemHeaders}\n${itemPayload}`; +} + +async function sendEvent(event: SentryEvent, dsn: ParsedDsn): Promise { + const endpoint = getEnvelopeEndpoint(dsn); + const envelope = createEnvelope(event, dsn); + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + body: envelope, + }); + if (response.status >= 200 && response.status < 300) return true; + console.warn(`[Sentry] Failed to send event: HTTP ${response.status}`); + return false; + } catch (error) { + console.error('[Sentry] Failed to send event:', error); + return false; + } +} + +function createBaseEvent( + level: 'error' | 'info' | 'warning', + env: EASBuildEnv, + customTags?: Record, +): SentryEvent { + return { + event_id: generateEventId(), + timestamp: Date.now() / 1000, + platform: 'node', + level, + logger: 'eas-build-hook', + environment: 'eas-build', + release: env.EAS_BUILD_APP_VERSION, + tags: { ...createEASBuildTags(env), ...customTags }, + contexts: { eas_build: createEASBuildContext(env), runtime: { name: 'node', version: process.version } }, + sdk: { name: 'sentry.javascript.react-native.eas-build-hooks', version: '1.0.0' }, + }; +} + +/** Captures an EAS build error event. Call this from the eas-build-on-error hook. */ +export async function captureEASBuildError(options: EASBuildHookOptions = {}): Promise { + const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV]; + if (!dsn) { + console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.'); + return; + } + if (!isEASBuild()) { + console.warn('[Sentry] Not running in EAS Build environment. Skipping error capture.'); + return; + } + const parsedDsn = parseDsn(dsn); + if (!parsedDsn) { + console.error('[Sentry] Invalid DSN format.'); + return; + } + const env = getEASBuildEnv(); + const errorMessage = + options.errorMessage ?? `EAS Build Failed: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; + const event = createBaseEvent('error', env, { ...options.tags, 'eas.hook': 'on-error' }); + event.exception = { + values: [{ type: 'EASBuildError', value: errorMessage, mechanism: { type: 'eas-build-hook', handled: true } }], + }; + event.fingerprint = ['eas-build-error', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown']; + const success = await sendEvent(event, parsedDsn); + if (success) console.log('[Sentry] Build error captured.'); +} + +/** Captures an EAS build success event. Call this from the eas-build-on-success hook. */ +export async function captureEASBuildSuccess(options: EASBuildHookOptions = {}): Promise { + if (!options.captureSuccessfulBuilds) { + console.log('[Sentry] Skipping successful build capture (captureSuccessfulBuilds is false).'); + return; + } + const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV]; + if (!dsn) { + console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.'); + return; + } + if (!isEASBuild()) { + console.warn('[Sentry] Not running in EAS Build environment. Skipping success capture.'); + return; + } + const parsedDsn = parseDsn(dsn); + if (!parsedDsn) { + console.error('[Sentry] Invalid DSN format.'); + return; + } + const env = getEASBuildEnv(); + const successMessage = + options.successMessage ?? `EAS Build Succeeded: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; + const event = createBaseEvent('info', env, { ...options.tags, 'eas.hook': 'on-success' }); + event.message = { formatted: successMessage }; + event.fingerprint = ['eas-build-success', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown']; + const success = await sendEvent(event, parsedDsn); + if (success) console.log('[Sentry] Build success captured.'); +} + +/** Captures an EAS build completion event with status. Call this from the eas-build-on-complete hook. */ +export async function captureEASBuildComplete(options: EASBuildHookOptions = {}): Promise { + const env = getEASBuildEnv(); + const status = env.EAS_BUILD_STATUS; + if (status === 'errored') { + await captureEASBuildError(options); + return; + } + if (status === 'finished' && options.captureSuccessfulBuilds) { + await captureEASBuildSuccess({ ...options, captureSuccessfulBuilds: true }); + return; + } + console.log(`[Sentry] Build completed with status: ${status ?? 'unknown'}. No event captured.`); +} diff --git a/packages/core/test/tools/easBuildHooks.test.ts b/packages/core/test/tools/easBuildHooks.test.ts new file mode 100644 index 0000000000..60d72168af --- /dev/null +++ b/packages/core/test/tools/easBuildHooks.test.ts @@ -0,0 +1,399 @@ +import { + captureEASBuildComplete, + captureEASBuildError, + captureEASBuildSuccess, + getEASBuildEnv, + isEASBuild, +} from '../../src/js/tools/easBuildHooks'; + +// Mock fetch +const mockFetch = jest.fn(); + +// @ts-expect-error - Mocking global fetch +global.fetch = mockFetch; + +describe('EAS Build Hooks', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset environment + process.env = { ...originalEnv }; + // Default successful fetch response + mockFetch.mockResolvedValue({ + status: 200, + headers: { + get: jest.fn().mockReturnValue(null), + }, + }); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('isEASBuild', () => { + it('returns true when EAS_BUILD is "true"', () => { + process.env.EAS_BUILD = 'true'; + expect(isEASBuild()).toBe(true); + }); + + it('returns false when EAS_BUILD is not set', () => { + delete process.env.EAS_BUILD; + expect(isEASBuild()).toBe(false); + }); + + it('returns false when EAS_BUILD is "false"', () => { + process.env.EAS_BUILD = 'false'; + expect(isEASBuild()).toBe(false); + }); + + it('returns false when EAS_BUILD is empty', () => { + process.env.EAS_BUILD = ''; + expect(isEASBuild()).toBe(false); + }); + }); + + describe('getEASBuildEnv', () => { + it('returns all EAS build environment variables', () => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_ID = 'build-123'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.EAS_BUILD_PROFILE = 'production'; + process.env.EAS_BUILD_PROJECT_ID = 'project-456'; + process.env.EAS_BUILD_GIT_COMMIT_HASH = 'abc123'; + process.env.EAS_BUILD_RUN_FROM_CI = 'true'; + process.env.EAS_BUILD_STATUS = 'finished'; + process.env.EAS_BUILD_APP_VERSION = '1.0.0'; + process.env.EAS_BUILD_APP_BUILD_VERSION = '42'; + process.env.EAS_BUILD_USERNAME = 'testuser'; + process.env.EAS_BUILD_WORKINGDIR = '/build/workdir'; + + const env = getEASBuildEnv(); + + expect(env).toEqual({ + EAS_BUILD: 'true', + EAS_BUILD_ID: 'build-123', + EAS_BUILD_PLATFORM: 'ios', + EAS_BUILD_PROFILE: 'production', + EAS_BUILD_PROJECT_ID: 'project-456', + EAS_BUILD_GIT_COMMIT_HASH: 'abc123', + EAS_BUILD_RUN_FROM_CI: 'true', + EAS_BUILD_STATUS: 'finished', + EAS_BUILD_APP_VERSION: '1.0.0', + EAS_BUILD_APP_BUILD_VERSION: '42', + EAS_BUILD_USERNAME: 'testuser', + EAS_BUILD_WORKINGDIR: '/build/workdir', + }); + }); + + it('returns undefined for unset variables', () => { + delete process.env.EAS_BUILD; + delete process.env.EAS_BUILD_ID; + + const env = getEASBuildEnv(); + + expect(env.EAS_BUILD).toBeUndefined(); + expect(env.EAS_BUILD_ID).toBeUndefined(); + }); + }); + + describe('captureEASBuildError', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'android'; + process.env.EAS_BUILD_PROFILE = 'preview'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('does not capture when DSN is not set', async () => { + delete process.env.SENTRY_DSN; + + await captureEASBuildError(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not capture when not in EAS build environment', async () => { + process.env.EAS_BUILD = 'false'; + + await captureEASBuildError(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('sends error event to Sentry', async () => { + await captureEASBuildError(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('sentry.io/api/123/envelope'), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body: expect.stringContaining('EASBuildError'), + }), + ); + }); + + it('includes EAS build tags in the event', async () => { + process.env.EAS_BUILD_ID = 'build-xyz'; + process.env.EAS_BUILD_PROJECT_ID = 'proj-abc'; + + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"eas.platform":"android"'); + expect(body).toContain('"eas.profile":"preview"'); + expect(body).toContain('"eas.build_id":"build-xyz"'); + expect(body).toContain('"eas.hook":"on-error"'); + }); + + it('uses custom error message when provided', async () => { + await captureEASBuildError({ errorMessage: 'Custom build failure' }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('Custom build failure'); + }); + + it('uses DSN from options if provided', async () => { + delete process.env.SENTRY_DSN; + + await captureEASBuildError({ dsn: 'https://custom@other.sentry.io/456' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('other.sentry.io/api/456/envelope'), + expect.anything(), + ); + }); + + it('includes fingerprint for grouping', async () => { + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"fingerprint":["eas-build-error","android","preview"]'); + }); + + it('includes custom tags from options', async () => { + await captureEASBuildError({ + tags: { + 'custom.tag': 'custom-value', + }, + }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"custom.tag":"custom-value"'); + }); + + it('handles invalid DSN gracefully', async () => { + process.env.SENTRY_DSN = 'invalid-dsn'; + + await captureEASBuildError(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('captureEASBuildSuccess', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.EAS_BUILD_PROFILE = 'production'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('does not capture by default (captureSuccessfulBuilds is false)', async () => { + await captureEASBuildSuccess(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('captures success when captureSuccessfulBuilds is true', async () => { + await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"level":"info"'); + expect(body).toContain('EAS Build Succeeded'); + expect(body).toContain('"eas.hook":"on-success"'); + }); + + it('uses custom success message when provided', async () => { + await captureEASBuildSuccess({ + captureSuccessfulBuilds: true, + successMessage: 'Build completed successfully!', + }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('Build completed successfully!'); + }); + + it('does not capture when DSN is not set', async () => { + delete process.env.SENTRY_DSN; + + await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('captureEASBuildComplete', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'android'; + process.env.EAS_BUILD_PROFILE = 'development'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('captures error when EAS_BUILD_STATUS is "errored"', async () => { + process.env.EAS_BUILD_STATUS = 'errored'; + + await captureEASBuildComplete(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"level":"error"'); + expect(body).toContain('EASBuildError'); + }); + + it('captures success when EAS_BUILD_STATUS is "finished" and captureSuccessfulBuilds is true', async () => { + process.env.EAS_BUILD_STATUS = 'finished'; + + await captureEASBuildComplete({ captureSuccessfulBuilds: true }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"level":"info"'); + expect(body).toContain('EAS Build Succeeded'); + }); + + it('does not capture success when EAS_BUILD_STATUS is "finished" but captureSuccessfulBuilds is false', async () => { + process.env.EAS_BUILD_STATUS = 'finished'; + + await captureEASBuildComplete({ captureSuccessfulBuilds: false }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not capture anything when status is unknown', async () => { + process.env.EAS_BUILD_STATUS = 'unknown'; + + await captureEASBuildComplete(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not capture when status is undefined and captureSuccessfulBuilds is false', async () => { + delete process.env.EAS_BUILD_STATUS; + + await captureEASBuildComplete(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('envelope format', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.EAS_BUILD_PROFILE = 'staging'; + process.env.SENTRY_DSN = 'https://publickey@sentry.io/123'; + }); + + it('creates valid envelope with correct headers', async () => { + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const lines = body.split('\n'); + + // Envelope should have 3 lines: envelope header, item header, item payload + expect(lines.length).toBe(3); + + // Parse and verify envelope header + const envelopeHeader = JSON.parse(lines[0]); + expect(envelopeHeader).toHaveProperty('event_id'); + expect(envelopeHeader).toHaveProperty('sent_at'); + expect(envelopeHeader.dsn).toContain('sentry.io/123'); + + // Parse and verify item header + const itemHeader = JSON.parse(lines[1]); + expect(itemHeader.type).toBe('event'); + expect(itemHeader.content_type).toBe('application/json'); + + // Parse and verify event payload + const event = JSON.parse(lines[2]); + expect(event.platform).toBe('node'); + expect(event.environment).toBe('eas-build'); + expect(event.level).toBe('error'); + }); + + it('includes EAS build context in the event', async () => { + process.env.EAS_BUILD_ID = 'build-context-test'; + process.env.EAS_BUILD_GIT_COMMIT_HASH = 'commit123'; + process.env.EAS_BUILD_RUN_FROM_CI = 'true'; + + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const lines = body.split('\n'); + const event = JSON.parse(lines[2]); + + expect(event.contexts.eas_build).toEqual( + expect.objectContaining({ + build_id: 'build-context-test', + platform: 'ios', + profile: 'staging', + git_commit: 'commit123', + from_ci: true, + }), + ); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('handles fetch failure gracefully', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + // Should not throw + await expect(captureEASBuildError()).resolves.not.toThrow(); + }); + + it('handles non-2xx response gracefully', async () => { + mockFetch.mockResolvedValue({ + status: 429, + headers: { + get: jest.fn().mockReturnValue(null), + }, + }); + + // Should not throw + await expect(captureEASBuildError()).resolves.not.toThrow(); + }); + }); +}); diff --git a/samples/expo/package.json b/samples/expo/package.json index be1155082e..4e13b1c1e9 100644 --- a/samples/expo/package.json +++ b/samples/expo/package.json @@ -17,6 +17,8 @@ "prebuild": "expo prebuild --clean --no-install", "set-version": "npx react-native-version --skip-tag --never-amend", "eas-build-pre-install": "npm i -g corepack && yarn install --no-immutable --inline-builds && yarn workspace @sentry/react-native build", + "eas-build-on-error": "sentry-eas-build-on-error", + "eas-build-on-complete": "sentry-eas-build-on-complete", "eas-update-configure": "eas update:configure", "eas-update-publish-development": "eas update --channel development --message 'Development update'", "eas-build-development-android": "eas build --profile development --platform android" From ca650bd52b8858b32c7f4528734dcddde32092cb Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 13 Feb 2026 16:35:37 +0100 Subject: [PATCH 2/2] Fixes --- .../core/test/tools/easBuildHooks.test.ts | 399 ------------------ 1 file changed, 399 deletions(-) delete mode 100644 packages/core/test/tools/easBuildHooks.test.ts diff --git a/packages/core/test/tools/easBuildHooks.test.ts b/packages/core/test/tools/easBuildHooks.test.ts deleted file mode 100644 index 60d72168af..0000000000 --- a/packages/core/test/tools/easBuildHooks.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { - captureEASBuildComplete, - captureEASBuildError, - captureEASBuildSuccess, - getEASBuildEnv, - isEASBuild, -} from '../../src/js/tools/easBuildHooks'; - -// Mock fetch -const mockFetch = jest.fn(); - -// @ts-expect-error - Mocking global fetch -global.fetch = mockFetch; - -describe('EAS Build Hooks', () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.clearAllMocks(); - // Reset environment - process.env = { ...originalEnv }; - // Default successful fetch response - mockFetch.mockResolvedValue({ - status: 200, - headers: { - get: jest.fn().mockReturnValue(null), - }, - }); - }); - - afterAll(() => { - process.env = originalEnv; - }); - - describe('isEASBuild', () => { - it('returns true when EAS_BUILD is "true"', () => { - process.env.EAS_BUILD = 'true'; - expect(isEASBuild()).toBe(true); - }); - - it('returns false when EAS_BUILD is not set', () => { - delete process.env.EAS_BUILD; - expect(isEASBuild()).toBe(false); - }); - - it('returns false when EAS_BUILD is "false"', () => { - process.env.EAS_BUILD = 'false'; - expect(isEASBuild()).toBe(false); - }); - - it('returns false when EAS_BUILD is empty', () => { - process.env.EAS_BUILD = ''; - expect(isEASBuild()).toBe(false); - }); - }); - - describe('getEASBuildEnv', () => { - it('returns all EAS build environment variables', () => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_ID = 'build-123'; - process.env.EAS_BUILD_PLATFORM = 'ios'; - process.env.EAS_BUILD_PROFILE = 'production'; - process.env.EAS_BUILD_PROJECT_ID = 'project-456'; - process.env.EAS_BUILD_GIT_COMMIT_HASH = 'abc123'; - process.env.EAS_BUILD_RUN_FROM_CI = 'true'; - process.env.EAS_BUILD_STATUS = 'finished'; - process.env.EAS_BUILD_APP_VERSION = '1.0.0'; - process.env.EAS_BUILD_APP_BUILD_VERSION = '42'; - process.env.EAS_BUILD_USERNAME = 'testuser'; - process.env.EAS_BUILD_WORKINGDIR = '/build/workdir'; - - const env = getEASBuildEnv(); - - expect(env).toEqual({ - EAS_BUILD: 'true', - EAS_BUILD_ID: 'build-123', - EAS_BUILD_PLATFORM: 'ios', - EAS_BUILD_PROFILE: 'production', - EAS_BUILD_PROJECT_ID: 'project-456', - EAS_BUILD_GIT_COMMIT_HASH: 'abc123', - EAS_BUILD_RUN_FROM_CI: 'true', - EAS_BUILD_STATUS: 'finished', - EAS_BUILD_APP_VERSION: '1.0.0', - EAS_BUILD_APP_BUILD_VERSION: '42', - EAS_BUILD_USERNAME: 'testuser', - EAS_BUILD_WORKINGDIR: '/build/workdir', - }); - }); - - it('returns undefined for unset variables', () => { - delete process.env.EAS_BUILD; - delete process.env.EAS_BUILD_ID; - - const env = getEASBuildEnv(); - - expect(env.EAS_BUILD).toBeUndefined(); - expect(env.EAS_BUILD_ID).toBeUndefined(); - }); - }); - - describe('captureEASBuildError', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'android'; - process.env.EAS_BUILD_PROFILE = 'preview'; - process.env.SENTRY_DSN = 'https://key@sentry.io/123'; - }); - - it('does not capture when DSN is not set', async () => { - delete process.env.SENTRY_DSN; - - await captureEASBuildError(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('does not capture when not in EAS build environment', async () => { - process.env.EAS_BUILD = 'false'; - - await captureEASBuildError(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('sends error event to Sentry', async () => { - await captureEASBuildError(); - - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('sentry.io/api/123/envelope'), - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body: expect.stringContaining('EASBuildError'), - }), - ); - }); - - it('includes EAS build tags in the event', async () => { - process.env.EAS_BUILD_ID = 'build-xyz'; - process.env.EAS_BUILD_PROJECT_ID = 'proj-abc'; - - await captureEASBuildError(); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"eas.platform":"android"'); - expect(body).toContain('"eas.profile":"preview"'); - expect(body).toContain('"eas.build_id":"build-xyz"'); - expect(body).toContain('"eas.hook":"on-error"'); - }); - - it('uses custom error message when provided', async () => { - await captureEASBuildError({ errorMessage: 'Custom build failure' }); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('Custom build failure'); - }); - - it('uses DSN from options if provided', async () => { - delete process.env.SENTRY_DSN; - - await captureEASBuildError({ dsn: 'https://custom@other.sentry.io/456' }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('other.sentry.io/api/456/envelope'), - expect.anything(), - ); - }); - - it('includes fingerprint for grouping', async () => { - await captureEASBuildError(); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"fingerprint":["eas-build-error","android","preview"]'); - }); - - it('includes custom tags from options', async () => { - await captureEASBuildError({ - tags: { - 'custom.tag': 'custom-value', - }, - }); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"custom.tag":"custom-value"'); - }); - - it('handles invalid DSN gracefully', async () => { - process.env.SENTRY_DSN = 'invalid-dsn'; - - await captureEASBuildError(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - }); - - describe('captureEASBuildSuccess', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'ios'; - process.env.EAS_BUILD_PROFILE = 'production'; - process.env.SENTRY_DSN = 'https://key@sentry.io/123'; - }); - - it('does not capture by default (captureSuccessfulBuilds is false)', async () => { - await captureEASBuildSuccess(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('captures success when captureSuccessfulBuilds is true', async () => { - await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"level":"info"'); - expect(body).toContain('EAS Build Succeeded'); - expect(body).toContain('"eas.hook":"on-success"'); - }); - - it('uses custom success message when provided', async () => { - await captureEASBuildSuccess({ - captureSuccessfulBuilds: true, - successMessage: 'Build completed successfully!', - }); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('Build completed successfully!'); - }); - - it('does not capture when DSN is not set', async () => { - delete process.env.SENTRY_DSN; - - await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - }); - - describe('captureEASBuildComplete', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'android'; - process.env.EAS_BUILD_PROFILE = 'development'; - process.env.SENTRY_DSN = 'https://key@sentry.io/123'; - }); - - it('captures error when EAS_BUILD_STATUS is "errored"', async () => { - process.env.EAS_BUILD_STATUS = 'errored'; - - await captureEASBuildComplete(); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"level":"error"'); - expect(body).toContain('EASBuildError'); - }); - - it('captures success when EAS_BUILD_STATUS is "finished" and captureSuccessfulBuilds is true', async () => { - process.env.EAS_BUILD_STATUS = 'finished'; - - await captureEASBuildComplete({ captureSuccessfulBuilds: true }); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"level":"info"'); - expect(body).toContain('EAS Build Succeeded'); - }); - - it('does not capture success when EAS_BUILD_STATUS is "finished" but captureSuccessfulBuilds is false', async () => { - process.env.EAS_BUILD_STATUS = 'finished'; - - await captureEASBuildComplete({ captureSuccessfulBuilds: false }); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('does not capture anything when status is unknown', async () => { - process.env.EAS_BUILD_STATUS = 'unknown'; - - await captureEASBuildComplete(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('does not capture when status is undefined and captureSuccessfulBuilds is false', async () => { - delete process.env.EAS_BUILD_STATUS; - - await captureEASBuildComplete(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - }); - - describe('envelope format', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'ios'; - process.env.EAS_BUILD_PROFILE = 'staging'; - process.env.SENTRY_DSN = 'https://publickey@sentry.io/123'; - }); - - it('creates valid envelope with correct headers', async () => { - await captureEASBuildError(); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body as string; - const lines = body.split('\n'); - - // Envelope should have 3 lines: envelope header, item header, item payload - expect(lines.length).toBe(3); - - // Parse and verify envelope header - const envelopeHeader = JSON.parse(lines[0]); - expect(envelopeHeader).toHaveProperty('event_id'); - expect(envelopeHeader).toHaveProperty('sent_at'); - expect(envelopeHeader.dsn).toContain('sentry.io/123'); - - // Parse and verify item header - const itemHeader = JSON.parse(lines[1]); - expect(itemHeader.type).toBe('event'); - expect(itemHeader.content_type).toBe('application/json'); - - // Parse and verify event payload - const event = JSON.parse(lines[2]); - expect(event.platform).toBe('node'); - expect(event.environment).toBe('eas-build'); - expect(event.level).toBe('error'); - }); - - it('includes EAS build context in the event', async () => { - process.env.EAS_BUILD_ID = 'build-context-test'; - process.env.EAS_BUILD_GIT_COMMIT_HASH = 'commit123'; - process.env.EAS_BUILD_RUN_FROM_CI = 'true'; - - await captureEASBuildError(); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body as string; - const lines = body.split('\n'); - const event = JSON.parse(lines[2]); - - expect(event.contexts.eas_build).toEqual( - expect.objectContaining({ - build_id: 'build-context-test', - platform: 'ios', - profile: 'staging', - git_commit: 'commit123', - from_ci: true, - }), - ); - }); - }); - - describe('error handling', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'ios'; - process.env.SENTRY_DSN = 'https://key@sentry.io/123'; - }); - - it('handles fetch failure gracefully', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - - // Should not throw - await expect(captureEASBuildError()).resolves.not.toThrow(); - }); - - it('handles non-2xx response gracefully', async () => { - mockFetch.mockResolvedValue({ - status: 429, - headers: { - get: jest.fn().mockReturnValue(null), - }, - }); - - // Should not throw - await expect(captureEASBuildError()).resolves.not.toThrow(); - }); - }); -});