From d476ef2e5e5c2b5da02b7cf5079bab9763f3cc9c Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 8 Sep 2026 15:03:21 +0200 Subject: [PATCH] react-native: skip iOS source map upload on debug builds, warn instead of fail --- .../scripts/ios-sourcemap-upload.sh | 109 +++++-- .../scripts/iosSourcemapUploadScript.spec.ts | 305 ++++++++++++++++++ 2 files changed, 385 insertions(+), 29 deletions(-) create mode 100644 packages/react-native/tests/scripts/iosSourcemapUploadScript.spec.ts diff --git a/packages/react-native/scripts/ios-sourcemap-upload.sh b/packages/react-native/scripts/ios-sourcemap-upload.sh index 3ae49083..f57d67c6 100755 --- a/packages/react-native/scripts/ios-sourcemap-upload.sh +++ b/packages/react-native/scripts/ios-sourcemap-upload.sh @@ -1,67 +1,118 @@ #!/bin/bash -# Script responsible for preprocessing source maps with debugid and uploading it to Backtrace via backtrace-js. +# Adds the Backtrace debug id to a React Native source map and uploads it to Backtrace via backtrace-js. +# # Usage: ./ios-sourcemap-upload.sh # Parameters: # (Required) Path to the source map file. -# (Required) Path to generated backtrace debug id. +# (Required) Path to the debug id file written by the Backtrace metro serializer. # (Required) Path to the .backtracejsrc configuration file. -# (Required) Path to the react-native project directory +# (Required) Path to the react-native project directory. +# Environment: +# DEBUG_ID_PATH Overrides . +# NODE_BINARY Node executable, defaults to `node` on PATH. Inside an Xcode build phase, ios/.xcode.env and +# ios/.xcode.env.local are sourced first, like React Native's own bundle phase. # -# Adjusting metro configuration is required in order to correctly use debug_id available in the debug_id_file_path. - +# Inside an Xcode build phase (CONFIGURATION is set) the script never fails the build over a missing input: +# builds that produce no debug id (SKIP_BUNDLING, or any Debug configuration, which bundles in dev mode) are +# skipped, and a missing source map, debug id, configuration file, backtrace-js or node is reported as an Xcode +# warning. Outside Xcode the same conditions are errors. A failed upload fails in both modes. +# +# The Backtrace serializer must be set as customSerializer in metro.config.js for the debug id file to exist. set -e set -x -if [ -z "$1" ]; then - echo "Error: Missing path to the source map file." +in_xcode_build_phase=false +if [ -n "$CONFIGURATION" ]; then + in_xcode_build_phase=true +fi + +fail() { + if [ "$in_xcode_build_phase" = true ]; then + echo "warning: Backtrace: $1" >&2 + exit 0 + fi + echo "Error: Backtrace: $1" >&2 exit 1 +} + +if [ -z "$1" ]; then + fail "Missing path to the source map file." fi -source_map_file_path="$1" +if [ -z "$3" ]; then + fail "Missing path to the .backtracejsrc file." +fi -# Check if the file exists -if [ ! -f "$source_map_file_path" ]; then - echo "Error: File '$source_map_file_path' does not exist." - exit 1 +if [ -z "$4" ]; then + fail "Missing path to the project directory." fi +source_map_file_path="$1" debug_id_file_path=${DEBUG_ID_PATH:-${2:-}} +backtrace_configuration_path="$3" +project_directory_path="$4" + +if [ -z "$debug_id_file_path" ]; then + fail "Missing path to the debug id file." +fi + +if [ "$in_xcode_build_phase" = true ]; then + if [ -n "$SKIP_BUNDLING" ]; then + echo "Backtrace: SKIP_BUNDLING is set, nothing was bundled, skipping source map upload." + exit 0 + fi + + # Debug configurations bundle in dev mode, and the Backtrace serializer writes no debug id for dev bundles. + case "$CONFIGURATION" in + *Debug*) + echo "Backtrace: Debug configuration produces no debug id, skipping source map upload." + exit 0 + ;; + esac +fi + +if [ ! -f "$source_map_file_path" ]; then + fail "Source map file '$source_map_file_path' does not exist. Check that SOURCEMAP_FILE is exported before the React Native bundle step." +fi if [ ! -f "$debug_id_file_path" ]; then - echo "Error: File '$debug_id_file_path' does not exist." - exit 1 + fail "Debug id file '$debug_id_file_path' does not exist. Check if customSerializer has been set to the Backtrace serializer in metro.config.js." fi -if [ -z "$3" ]; then - echo "Error: Missing path to the .backtracejsrc file." - exit 1 +if [ ! -f "$backtrace_configuration_path" ]; then + fail "Configuration file '$backtrace_configuration_path' does not exist." fi -if [ -z "$4" ]; then - echo "Error: Missing path to the project directory." - exit 1 +backtrace_js_path="${project_directory_path}/node_modules/.bin/backtrace-js" + +if [ ! -f "$backtrace_js_path" ]; then + fail "backtrace-js not found at '$backtrace_js_path'. Install @backtrace/javascript-cli in the project." fi -project_directory_path="$4" +if [ -z "$NODE_BINARY" ] && [ -n "$PODS_ROOT" ]; then + if [ -f "$PODS_ROOT/../.xcode.env" ]; then + source "$PODS_ROOT/../.xcode.env" + fi + if [ -f "$PODS_ROOT/../.xcode.env.local" ]; then + source "$PODS_ROOT/../.xcode.env.local" + fi +fi -backtrace_configuration_path="$3" +NODE_BINARY="${NODE_BINARY:-node}" -# check and assign NODE_BINARY env -source "$REACT_NATIVE_PATH/scripts/node-binary.sh" -debug_id=$(<"$debug_id_file_path") +if ! type "$NODE_BINARY" >/dev/null 2>&1; then + fail "Cannot find the '$NODE_BINARY' binary. Set NODE_BINARY in ios/.xcode.env or in the build phase." +fi +debug_id=$(<"$debug_id_file_path") script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" "$NODE_BINARY" "$script_dir/addDebugIdToSourceMap.js" \ "$source_map_file_path" \ "$debug_id" -# path to react-native module dir relative from this script -backtrace_js_path="${project_directory_path}/node_modules/.bin/backtrace-js" - -# run backtrace-js on bundle "$NODE_BINARY" "$backtrace_js_path" upload \ -p "$source_map_file_path" \ --config "$backtrace_configuration_path" diff --git a/packages/react-native/tests/scripts/iosSourcemapUploadScript.spec.ts b/packages/react-native/tests/scripts/iosSourcemapUploadScript.spec.ts new file mode 100644 index 00000000..4c653104 --- /dev/null +++ b/packages/react-native/tests/scripts/iosSourcemapUploadScript.spec.ts @@ -0,0 +1,305 @@ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const SCRIPT = path.resolve(__dirname, '../../scripts/ios-sourcemap-upload.sh'); +const NODE_DIR = path.dirname(process.execPath); + +interface Fixture { + root: string; + project: string; + sourceMap: string; + debugId: string; + config: string; + uploadLog: string; + binDir: string; +} + +interface RunOptions { + args?: string[]; + env?: Record; + nodeOnPath?: boolean; +} + +function resolveTool(name: string) { + return spawnSync('/bin/sh', ['-c', `command -v ${name}`], { encoding: 'utf8' }).stdout.trim(); +} + +function createFixture(): Fixture { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bt-ios-sourcemap-')); + const project = path.join(root, 'project'); + const uploadLog = path.join(root, 'upload.log'); + const binDir = path.join(root, 'bin'); + + fs.mkdirSync(path.join(project, 'node_modules', '.bin'), { recursive: true }); + fs.mkdirSync(path.join(project, 'build'), { recursive: true }); + fs.mkdirSync(path.join(project, 'ios', 'Pods'), { recursive: true }); + fs.mkdirSync(binDir); + fs.symlinkSync(resolveTool('dirname'), path.join(binDir, 'dirname')); + + fs.writeFileSync( + path.join(project, 'node_modules', '.bin', 'backtrace-js'), + `require('fs').writeFileSync(${JSON.stringify(uploadLog)}, JSON.stringify(process.argv.slice(2)));\n` + + `process.exit(Number(process.env.FAKE_UPLOAD_EXIT_CODE ?? 0));\n`, + ); + + const sourceMap = path.join(project, 'main.jsbundle.map'); + const debugId = path.join(project, 'build', '.backtrace-sourcemap-id'); + const config = path.join(project, '.backtracejsrc'); + fs.writeFileSync(sourceMap, JSON.stringify({ version: 3, sources: ['a.js'], names: [], mappings: 'AAAA' })); + fs.writeFileSync(debugId, 'test-debug-id\n'); + fs.writeFileSync(config, '{}'); + + return { root, project, sourceMap, debugId, config, uploadLog, binDir }; +} + +function run(fixture: Fixture, { args, env, nodeOnPath = true }: RunOptions = {}) { + const pathEntries = nodeOnPath ? [NODE_DIR, fixture.binDir] : [fixture.binDir]; + const defaultArgs = [fixture.sourceMap, fixture.debugId, fixture.config, fixture.project]; + return spawnSync('/bin/bash', [SCRIPT, ...(args ?? defaultArgs)], { + env: { PATH: pathEntries.join(':'), ...env }, + encoding: 'utf8', + }); +} + +function uploadCall(fixture: Fixture): string[] | undefined { + return fs.existsSync(fixture.uploadLog) ? JSON.parse(fs.readFileSync(fixture.uploadLog, 'utf8')) : undefined; +} + +function sourceMapDebugId(fixture: Fixture): string | undefined { + return JSON.parse(fs.readFileSync(fixture.sourceMap, 'utf8')).debugId; +} + +function writeXcodeEnv(fixture: Fixture, name: string, nodeBinary: string) { + fs.writeFileSync(path.join(fixture.project, 'ios', name), `export NODE_BINARY="${nodeBinary}"\n`); +} + +const describeOnUnix = process.platform === 'win32' ? describe.skip : describe; + +describeOnUnix('ios-sourcemap-upload.sh', () => { + let fixture: Fixture; + + beforeEach(() => { + fixture = createFixture(); + }); + + afterEach(() => { + fs.rmSync(fixture.root, { recursive: true, force: true }); + }); + + describe('outside Xcode', () => { + it('adds the debug id to the source map and uploads it', () => { + const result = run(fixture); + + expect(result.status).toBe(0); + expect(sourceMapDebugId(fixture)).toBe('test-debug-id'); + expect(uploadCall(fixture)).toEqual(['upload', '-p', fixture.sourceMap, '--config', fixture.config]); + }); + + it('uses DEBUG_ID_PATH over the debug id argument', () => { + const override = path.join(fixture.root, 'override-id'); + fs.writeFileSync(override, 'override-id'); + + const result = run(fixture, { env: { DEBUG_ID_PATH: override } }); + + expect(result.status).toBe(0); + expect(sourceMapDebugId(fixture)).toBe('override-id'); + }); + + it('fails when the source map does not exist', () => { + fs.rmSync(fixture.sourceMap); + + const result = run(fixture); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Error: Backtrace: Source map file'); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('fails when the debug id file does not exist', () => { + fs.rmSync(fixture.debugId); + + const result = run(fixture); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Error: Backtrace: Debug id file'); + expect(result.stderr).toContain('metro.config.js'); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('fails when arguments are missing', () => { + const result = run(fixture, { args: [fixture.sourceMap] }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Error: Backtrace: Missing path'); + }); + + it('fails when node cannot be found', () => { + const result = run(fixture, { nodeOnPath: false }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Error: Backtrace: Cannot find the 'node' binary"); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('fails when the upload fails', () => { + const result = run(fixture, { env: { FAKE_UPLOAD_EXIT_CODE: '3' } }); + + expect(result.status).toBe(3); + }); + }); + + describe('in an Xcode build phase', () => { + const release = { CONFIGURATION: 'Release', PLATFORM_NAME: 'iphoneos' }; + + it('uploads on a Release build', () => { + const result = run(fixture, { env: release }); + + expect(result.status).toBe(0); + expect(sourceMapDebugId(fixture)).toBe('test-debug-id'); + expect(uploadCall(fixture)).toEqual(['upload', '-p', fixture.sourceMap, '--config', fixture.config]); + }); + + it('uploads on a custom non-Debug configuration', () => { + const result = run(fixture, { env: { ...release, CONFIGURATION: 'Staging' } }); + + expect(result.status).toBe(0); + expect(uploadCall(fixture)).toBeDefined(); + }); + + it('skips a Debug simulator build that bundled nothing', () => { + fs.rmSync(fixture.sourceMap); + fs.rmSync(fixture.debugId); + + const result = run(fixture, { env: { CONFIGURATION: 'Debug', PLATFORM_NAME: 'iphonesimulator' } }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Debug configuration produces no debug id'); + expect(result.stderr).not.toContain('warning:'); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('skips a Debug device build even though it bundled', () => { + const result = run(fixture, { env: { CONFIGURATION: 'Debug', PLATFORM_NAME: 'iphoneos' } }); + + expect(result.status).toBe(0); + expect(sourceMapDebugId(fixture)).toBeUndefined(); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('skips configurations that contain Debug', () => { + const result = run(fixture, { env: { ...release, CONFIGURATION: 'Debug-Staging' } }); + + expect(result.status).toBe(0); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('skips when SKIP_BUNDLING is set', () => { + fs.rmSync(fixture.sourceMap); + fs.rmSync(fixture.debugId); + + const result = run(fixture, { env: { ...release, SKIP_BUNDLING: '1' } }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('SKIP_BUNDLING is set'); + expect(result.stderr).not.toContain('warning:'); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('warns instead of failing when the source map is missing on a Release build', () => { + fs.rmSync(fixture.sourceMap); + + const result = run(fixture, { env: release }); + + expect(result.status).toBe(0); + expect(result.stderr).toMatch(/^warning: Backtrace: Source map file .* SOURCEMAP_FILE/m); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('warns instead of failing when the debug id file is missing on a Release build', () => { + fs.rmSync(fixture.debugId); + + const result = run(fixture, { env: release }); + + expect(result.status).toBe(0); + expect(result.stderr).toMatch(/^warning: Backtrace: Debug id file .* metro\.config\.js/m); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('warns instead of failing when the configuration file is missing', () => { + fs.rmSync(fixture.config); + + const result = run(fixture, { env: release }); + + expect(result.status).toBe(0); + expect(result.stderr).toMatch(/^warning: Backtrace: Configuration file/m); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('warns instead of failing when backtrace-js is not installed', () => { + fs.rmSync(path.join(fixture.project, 'node_modules', '.bin', 'backtrace-js')); + + const result = run(fixture, { env: release }); + + expect(result.status).toBe(0); + expect(result.stderr).toMatch(/^warning: Backtrace: backtrace-js not found/m); + expect(sourceMapDebugId(fixture)).toBeUndefined(); + }); + + it('takes NODE_BINARY from ios/.xcode.env when node is not on PATH', () => { + writeXcodeEnv(fixture, '.xcode.env', process.execPath); + + const result = run(fixture, { + env: { ...release, PODS_ROOT: path.join(fixture.project, 'ios', 'Pods') }, + nodeOnPath: false, + }); + + expect(result.status).toBe(0); + expect(uploadCall(fixture)).toBeDefined(); + }); + + it('lets ios/.xcode.env.local override ios/.xcode.env', () => { + writeXcodeEnv(fixture, '.xcode.env', '/nonexistent/node'); + writeXcodeEnv(fixture, '.xcode.env.local', process.execPath); + + const result = run(fixture, { + env: { ...release, PODS_ROOT: path.join(fixture.project, 'ios', 'Pods') }, + nodeOnPath: false, + }); + + expect(result.status).toBe(0); + expect(uploadCall(fixture)).toBeDefined(); + }); + + it('prefers an explicit NODE_BINARY over ios/.xcode.env', () => { + writeXcodeEnv(fixture, '.xcode.env', '/nonexistent/node'); + + const result = run(fixture, { + env: { + ...release, + PODS_ROOT: path.join(fixture.project, 'ios', 'Pods'), + NODE_BINARY: process.execPath, + }, + nodeOnPath: false, + }); + + expect(result.status).toBe(0); + expect(uploadCall(fixture)).toBeDefined(); + }); + + it('warns instead of failing when node cannot be found', () => { + const result = run(fixture, { env: release, nodeOnPath: false }); + + expect(result.status).toBe(0); + expect(result.stderr).toMatch(/^warning: Backtrace: Cannot find the 'node' binary/m); + expect(uploadCall(fixture)).toBeUndefined(); + }); + + it('still fails the build when the upload fails', () => { + const result = run(fixture, { env: { ...release, FAKE_UPLOAD_EXIT_CODE: '3' } }); + + expect(result.status).toBe(3); + }); + }); +});