diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 9a96a28c..99cab6dd 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -58,6 +58,9 @@ jobs: path: temp/testapp-${{ matrix.target }} key: integration-test-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('flutter.version') }} + - run: sudo xcode-select --switch /Applications/Xcode_16.4.app + if: ${{ matrix.target == 'ios' || matrix.target == 'ios-framework' || matrix.target == 'ipa' }} + - run: dart test --tags integration - uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e9edf0d..7c6a3ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,15 @@ ## Unreleased -### Dependencies +### Features -- Bump CLI from v2.41.1 to v2.52.0 ([#327](https://github.com/getsentry/sentry-dart-plugin/pull/327)) - - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2520) - - [diff](https://github.com/getsentry/sentry-cli/compare/2.41.1...2.52.0) +- Upload Dart symbol mapping file ([#347](https://github.com/getsentry/sentry-dart-plugin/pull/347)) + - Enables symbolication of Flutter issue titles for obfuscated builds. + - Supported: Android and iOS + - Not supported (yet): macOS, Linux and Windows. + - Generate the mapping file: Add `--extra-gen-snapshot-options=--save-obfuscation-map=` when building. Example: `flutter build apk --obfuscate --split-debug-info=build/symbols --extra-gen-snapshot-options=--save-obfuscation-map=build/mapping.json` + - Configure the plugin: Set `dart_symbol_map_path: build/mapping.json` + - Important: `dart_symbol_map_path` must point directly to the mapping file (absolute or relative path), not a directory. ## 3.1.1 diff --git a/README.md b/README.md index b9d63513..9581278a 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ sentry: build_path: ... web_build_path: ... symbols_path: ... + # Path to the Dart obfuscation map (opt-in) + # Example generation flags: + # flutter build apk --obfuscate --split-debug-info=symbols \\ + # --extra-gen-snapshot-options=--save-obfuscation-map=build/app/obfuscation.map.json + # Then set the path below to the generated obfuscation map file + dart_symbol_map_path: build/app/obfuscation.map.json commits: auto ignore_missing: true ``` @@ -95,30 +101,31 @@ ignore_missing=true ### Available Configuration Fields -| Configuration Name | Description | Default Value And Type | Required | Alternative Environment variable | -| - | - | - | - | - | -| upload_debug_symbols | Enables or disables the automatic upload of debug symbols | true (boolean) | no | - | -| upload_source_maps | Enables or disables the automatic upload of source maps | false (boolean) | no | - | -| upload_sources | Does or doesn't include the source code of native code | false (boolean) | no | - | -| legacy_web_symbolication | Uses legacy symbolication method for Flutter Web instead of Debug IDs | false (boolean) | no | - | -| project | Project's name | e.g. sentry-flutter (string) | yes | SENTRY_PROJECT | -| org | Organization's slug | e.g. sentry-sdks (string) | yes | SENTRY_ORG | -| auth_token | Auth Token | e.g. 64 random characteres (string) | yes | SENTRY_AUTH_TOKEN | -| url | URL | e.g. https://mysentry.invalid/ (string) | no | SENTRY_URL | -| url_prefix | URL prefix for JS source maps | e.g. ~/app/ (string) | no | - | -| wait_for_processing | Wait for server-side processing of uploaded files | false (boolean) | no | - | -| log_level | Configures the log level for sentry-cli | warn (string) | no | SENTRY_LOG_LEVEL | -| release | The release version for source maps, it should match the release set by the SDK | name@version from pubspec (string) | no | SENTRY_RELEASE | -| dist | The dist/build number for source maps, it should match the dist set by the SDK | the number after the '+' char from 'version' pubspec (string) | no | SENTRY_DIST | -| build_path | The build folder of debug files for upload | `build` (string) | no | - | -| web_build_path | The web build folder of debug files for upload relative to build_path | `web` (string) | no | - | -| symbols_path | The directory containing debug symbols (i.e. the `--split-debug-info=` parameter value you pass to `flutter build`) | `.` (string) | no | - | -| commits | Release commits integration | auto (string) | no | - | -| ignore_missing | Ignore missing commits previously used in the release | false (boolean) | no | - | -| bin_dir | The folder where the plugin downloads the sentry-cli binary | .dart_tool/pub/bin/sentry_dart_plugin (string) | no | - | -| bin_path | Path to the sentry-cli binary to use instead of downloading. Make sure to use the correct version. | null (string) | no | - | -| sentry_cli_cdn_url | Alternative place to download sentry-cli | https://downloads.sentry-cdn.com/sentry-cli (string) | no | SENTRYCLI_CDNURL | -| sentry_cli_version | Override the sentry-cli version that should be downloaded. | (string) | no | - | +| Configuration Name | Description | Default Value And Type | Required | Alternative Environment variable | +| ------------------------ |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------------------------------------------------------- | -------- | -------------------------------- | +| upload_debug_symbols | Enables or disables the automatic upload of debug symbols | true (boolean) | no | - | +| upload_source_maps | Enables or disables the automatic upload of source maps | false (boolean) | no | - | +| upload_sources | Does or doesn't include the source code of native code | false (boolean) | no | - | +| legacy_web_symbolication | Uses legacy symbolication method for Flutter Web instead of Debug IDs | false (boolean) | no | - | +| project | Project's name | e.g. sentry-flutter (string) | yes | SENTRY_PROJECT | +| org | Organization's slug | e.g. sentry-sdks (string) | yes | SENTRY_ORG | +| auth_token | Auth Token | e.g. 64 random characteres (string) | yes | SENTRY_AUTH_TOKEN | +| url | URL | e.g. https://mysentry.invalid/ (string) | no | SENTRY_URL | +| url_prefix | URL prefix for JS source maps | e.g. ~/app/ (string) | no | - | +| wait_for_processing | Wait for server-side processing of uploaded files | false (boolean) | no | - | +| log_level | Configures the log level for sentry-cli | warn (string) | no | SENTRY_LOG_LEVEL | +| release | The release version for source maps, it should match the release set by the SDK | name@version from pubspec (string) | no | SENTRY_RELEASE | +| dist | The dist/build number for source maps, it should match the dist set by the SDK | the number after the '+' char from 'version' pubspec (string) | no | SENTRY_DIST | +| build_path | The build folder of debug files for upload | `build` (string) | no | - | +| web_build_path | The web build folder of debug files for upload relative to build_path | `web` (string) | no | - | +| symbols_path | The directory containing debug symbols (i.e. the `--split-debug-info=` parameter value you pass to `flutter build`) | `.` (string) | no | - | +| dart_symbol_map_path | Absolute or relative path to a Dart obfuscation map file to upload. This allows symbolication of Flutter issue titles for Android and iOS. The map file is generated by adding the following arguments to your Flutter build command: `--extra-gen-snapshot-options=--save-obfuscation-map=`. | null (string) | no | - | +| commits | Release commits integration | auto (string) | no | - | +| ignore_missing | Ignore missing commits previously used in the release | false (boolean) | no | - | +| bin_dir | The folder where the plugin downloads the sentry-cli binary | .dart_tool/pub/bin/sentry_dart_plugin (string) | no | - | +| bin_path | Path to the sentry-cli binary to use instead of downloading. Make sure to use the correct version. | null (string) | no | - | +| sentry_cli_cdn_url | Alternative place to download sentry-cli | https://downloads.sentry-cdn.com/sentry-cli (string) | no | SENTRYCLI_CDNURL | +| sentry_cli_version | Override the sentry-cli version that should be downloaded. | (string) | no | - | ## Breaking Changes in v3.0.0 @@ -200,4 +207,6 @@ The `--split-debug-info` option requires setting a output directory, the directo Flutter's `build web` command requires setting the `--source-maps` parameter to generate source maps, See [Issue](https://github.com/flutter/flutter/issues/72150#issuecomment-755541599) +If you opt into uploading a Dart obfuscation map (`dart_symbol_map_path`), ensure you build with both `--obfuscate` and `--extra-gen-snapshot-options=--save-obfuscation-map=`. The map path you configure must point to the generated file. + If a previous release could not be found in the git history, please make sure you set `ignore_missing: true` in the configuration if you want to ignore such errors, See [Issue](https://github.com/getsentry/sentry-dart-plugin/issues/153) diff --git a/lib/sentry_dart_plugin.dart b/lib/sentry_dart_plugin.dart index f8c0ff5a..3e378a24 100644 --- a/lib/sentry_dart_plugin.dart +++ b/lib/sentry_dart_plugin.dart @@ -2,11 +2,13 @@ import 'dart:convert'; import 'package:file/file.dart'; import 'package:process/process.dart'; -import 'package:sentry_dart_plugin/src/utils/extensions.dart'; import 'src/configuration.dart'; +import 'src/utils/flutter_debug_files.dart'; +import 'src/symbol_maps/dart_symbol_map.dart'; import 'src/utils/injector.dart'; import 'src/utils/log.dart'; +import 'src/utils/extensions.dart'; /// Class responsible to load the configurations and upload the /// debug symbols and source maps @@ -86,7 +88,8 @@ class SentryDartPlugin { _addWait(params); final fs = injector.get(); - final debugSymbolPaths = _enumerateDebugSymbolPaths(fs); + final debugSymbolPaths = + enumerateDebugSearchRoots(fs: fs, config: _configuration); await for (final path in debugSymbolPaths) { if (await fs.directory(path).exists() || await fs.file(path).exists()) { await _executeAndLog('Failed to upload symbols', [...params, path]); @@ -97,59 +100,9 @@ class SentryDartPlugin { await _executeAndLog('Failed to upload symbols', [...params, path]); } - Log.taskCompleted(taskName); - } - - Stream _enumerateDebugSymbolPaths(FileSystem fs) async* { - final buildDir = _configuration.buildFilesFolder; - final projectRoot = fs.currentDirectory.path; - - // Android (apk, appbundle) - yield '$buildDir/app/outputs'; - yield '$buildDir/app/intermediates'; - - // Windows - for (final subdir in ['', '/x64', '/arm64']) { - yield '$buildDir/windows$subdir/runner/Release'; - } - // TODO we should delete this once we have windows symbols collected automatically. - // Related to https://github.com/getsentry/sentry-dart-plugin/issues/173 - yield 'windows/flutter/ephemeral/flutter_windows.dll.pdb'; - - // Linux - for (final subdir in ['/x64', '/arm64']) { - yield '$buildDir/linux$subdir/release/bundle'; - } - - // macOS - yield '$buildDir/macos/Build/Products/Release'; - - // macOS (macOS-framework) - yield '$buildDir/macos/framework/Release'; - - // iOS - yield '$buildDir/ios/iphoneos/Runner.app'; - if (await fs.directory('$buildDir/ios').exists()) { - final regexp = RegExp(r'^Release(-.*)?-iphoneos$'); - yield* fs - .directory('$buildDir/ios') - .list() - .where((v) => regexp.hasMatch(v.basename)) - .map((e) => e.path); - } + await _tryUploadDartSymbolMap(); - // iOS (ipa) - yield '$buildDir/ios/archive'; - - // iOS (ios-framework) - yield '$buildDir/ios/framework/Release'; - - // iOS in Fastlane - if (projectRoot == '/') { - yield 'ios/build'; - } else { - yield '$projectRoot/ios/build'; - } + Log.taskCompleted(taskName); } Future> _enumerateSymbolFiles() async { @@ -194,6 +147,22 @@ class SentryDartPlugin { return params; } + /// Upload Dart symbol map(s) if configured. + /// This is needed to symbolicate Flutter issue titles for obfuscated builds. + Future _tryUploadDartSymbolMap() async { + const taskName = 'uploading Dart symbol map(s)'; + Log.startingTask(taskName); + + try { + final fs = injector.get(); + await uploadDartSymbolMap(fs: fs, config: _configuration); + } catch (e) { + Log.error('Dart symbol map upload failed: $e'); + } finally { + Log.taskCompleted(taskName); + } + } + Future _executeNewRelease(String release) async { await _executeAndLog('Failed to create a new release', [..._releasesCliParams(), 'new', release]); diff --git a/lib/src/configuration.dart b/lib/src/configuration.dart index e0723502..6aa51932 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -73,6 +73,9 @@ class Configuration { /// The directory passed to `--split-debug-info`, defaults to '.' late String symbolsFolder; + /// Explicit path to the Dart obfuscation map file. Optional. + late String? dartSymbolMapPath; + /// The URL prefix, defaults to null late String? urlPrefix; @@ -154,6 +157,7 @@ class Configuration { final webBuildPath = configValues.webBuildPath ?? 'web'; webBuildFilesFolder = _fs.path.join(buildFilesFolder, webBuildPath); symbolsFolder = configValues.symbolsPath ?? '.'; + dartSymbolMapPath = configValues.dartSymbolMapPath; project = configValues.project; // or env. var. SENTRY_PROJECT org = configValues.org; // or env. var. SENTRY_ORG diff --git a/lib/src/configuration_values.dart b/lib/src/configuration_values.dart index 083a4aff..a0d552d1 100644 --- a/lib/src/configuration_values.dart +++ b/lib/src/configuration_values.dart @@ -20,6 +20,7 @@ class ConfigurationValues { final String? buildPath; final String? webBuildPath; final String? symbolsPath; + final String? dartSymbolMapPath; final String? commits; final bool? ignoreMissing; final String? binDir; @@ -46,6 +47,7 @@ class ConfigurationValues { this.buildPath, this.webBuildPath, this.symbolsPath, + this.dartSymbolMapPath, this.commits, this.ignoreMissing, this.binDir, @@ -99,6 +101,7 @@ class ConfigurationValues { buildPath: sentryArguments['build_path'], webBuildPath: sentryArguments['web_build_path'], symbolsPath: sentryArguments['symbols_path'], + dartSymbolMapPath: sentryArguments['dart_symbol_map_path'], commits: sentryArguments['commits'], ignoreMissing: boolFromString(sentryArguments['ignore_missing']), binDir: sentryArguments['bin_dir'], @@ -136,6 +139,7 @@ class ConfigurationValues { buildPath: configReader.getString('build_path'), webBuildPath: configReader.getString('web_build_path'), symbolsPath: configReader.getString('symbols_path'), + dartSymbolMapPath: configReader.getString('dart_symbol_map_path'), commits: configReader.getString('commits'), ignoreMissing: configReader.getBool('ignore_missing'), binDir: configReader.getString('bin_dir'), @@ -191,6 +195,9 @@ class ConfigurationValues { buildPath: args.buildPath ?? file.buildPath, webBuildPath: args.webBuildPath ?? file.webBuildPath, symbolsPath: args.symbolsPath ?? file.symbolsPath, + dartSymbolMapPath: platformEnv.dartSymbolMapPath ?? + args.dartSymbolMapPath ?? + file.dartSymbolMapPath, commits: args.commits ?? file.commits, ignoreMissing: args.ignoreMissing ?? file.ignoreMissing, binDir: args.binDir ?? file.binDir, diff --git a/lib/src/symbol_maps/dart_symbol_map.dart b/lib/src/symbol_maps/dart_symbol_map.dart new file mode 100644 index 00000000..f3caa3eb --- /dev/null +++ b/lib/src/symbol_maps/dart_symbol_map.dart @@ -0,0 +1,49 @@ +import 'package:file/file.dart'; + +import '../configuration.dart'; +import '../utils/log.dart'; +import '../utils/path.dart'; +import 'dart_symbol_map_debug_files_collector.dart'; +import 'dart_symbol_map_uploader.dart'; + +/// Single entrypoint to upload Dart obfuscation map(s) paired with +/// Flutter-relevant native debug files. This obfuscation map is used to +/// symbolicate Flutter issue titles. +/// +/// Currently only supported for Android (apk/appbundle) and iOS (ios/ipa). +/// +/// Steps: +/// - Resolves the Dart symbol map path from config +/// - Collects relevant debug files +/// - Uploads the map once per debug file via the CLI +Future uploadDartSymbolMap({ + required FileSystem fs, + required Configuration config, +}) async { + final String? resolvedMapPath = await resolveFilePath( + fs: fs, + rawPath: config.dartSymbolMapPath, + missingPathWarning: + "Skipping Dart symbol map uploads: no 'dart_symbol_map_path' provided.", + fileNotFoundWarning: + "Skipping Dart symbol map uploads: Dart symbol map file not found at '${config.dartSymbolMapPath}'.", + ); + if (resolvedMapPath == null) { + return; + } + + final Set debugFiles = + await collectDebugFilesForDartMap(fs: fs, config: config); + + if (debugFiles.isEmpty) { + Log.warn( + 'Skipping Dart symbol map uploads: no Flutter-relevant debug files found.'); + return; + } + + await DartSymbolMapUploader.addDebugIdMarkerAndUpload( + config: config, + symbolMapPath: resolvedMapPath, + debugFilePaths: debugFiles, + ); +} diff --git a/lib/src/symbol_maps/dart_symbol_map_debug_files_collector.dart b/lib/src/symbol_maps/dart_symbol_map_debug_files_collector.dart new file mode 100644 index 00000000..7ec8ffda --- /dev/null +++ b/lib/src/symbol_maps/dart_symbol_map_debug_files_collector.dart @@ -0,0 +1,102 @@ +import 'package:file/file.dart'; +import 'package:sentry_dart_plugin/src/utils/log.dart'; +// import 'package:sentry_dart_plugin/src/utils/flutter_debug_files.dart'; + +import '../configuration.dart'; + +/// Collects Flutter-relevant native debug file paths that should be paired +/// with a Dart symbol map for symbolication. +/// +/// Policy: +/// - Android: include Flutter-generated `.symbols` files (e.g., +/// `app.android-arm.symbols`, `app.android-arm64.symbols`, `app.android-x64.symbols`). +/// - Apple: include the Mach-O binary `App` inside +/// `App.framework.dSYM/Contents/Resources/DWARF/App`. +/// +/// The function returns absolute, deduplicated paths. It enumerates the +/// configured `symbolsFolder`, `buildFilesFolder`, and other Flutter +/// search roots discovered by `enumerateDebugSearchRoots`. +Future> collectDebugFilesForDartMap({ + required FileSystem fs, + required Configuration config, +}) async { + final Set foundAndroidPaths = {}; + final Set foundIosPaths = {}; + final path = fs.path; + + Future collectAndroidSymbolsUnder(String rootPath) async { + if (rootPath.isEmpty) return; + + final Directory directory = fs.directory(rootPath); + if (!await directory.exists()) return; + + await for (final FileSystemEntity entity + in directory.list(recursive: true, followLinks: false)) { + if (entity is! File) continue; + final String basename = fs.path.basename(entity.path); + if (basename.startsWith('app') && + basename.endsWith('.symbols') && + !basename.contains('darwin') && + !basename.contains('ios')) { + foundAndroidPaths.add(fs.file(entity.path).absolute.path); + } + } + } + + // Prefer scanning Android symbols under the configured symbols folder; if not + // set, fall back to the build folder. + final List androidRoots = []; + if (config.symbolsFolder.isNotEmpty) { + androidRoots.add(path.normalize(config.symbolsFolder)); + } else if (config.buildFilesFolder.isNotEmpty) { + androidRoots.add(path.normalize(config.buildFilesFolder)); + } + + for (final String root in androidRoots) { + await collectAndroidSymbolsUnder(root); + } + + if (foundAndroidPaths.isEmpty) { + Log.warn( + 'No Android symbols found in the configured symbols folder or build folder.'); + } + + // iOS: only search under two roots to simplify discovery: + // - build/ios + // - /ios/build (Fastlane) + final String buildDir = config.buildFilesFolder; + final String projectRoot = fs.currentDirectory.path; + + Future collectIosAppDsymsUnderRoot(String rootPath) async { + if (rootPath.isEmpty) return; + final Directory directory = fs.directory(rootPath); + if (!await directory.exists()) return; + + final String dsymSuffix = path.join( + 'App.framework.dSYM', + 'Contents', + 'Resources', + 'DWARF', + 'App', + ); + + await for (final FileSystemEntity entity + in directory.list(recursive: true, followLinks: false)) { + if (entity is! File) continue; + final String normalized = path.normalize(entity.path); + if (normalized.endsWith(dsymSuffix)) { + foundIosPaths.add(fs.file(normalized).absolute.path); + } + } + } + + await collectIosAppDsymsUnderRoot(path.join(path.normalize(buildDir), 'ios')); + await collectIosAppDsymsUnderRoot(path.join(projectRoot, 'ios', 'build')); + + if (foundIosPaths.isEmpty) { + Log.warn( + 'No iOS symbols found in the configured build folder or project root.'); + } + + return foundAndroidPaths.union(foundIosPaths).toSet(); +} diff --git a/lib/src/symbol_maps/dart_symbol_map_uploader.dart b/lib/src/symbol_maps/dart_symbol_map_uploader.dart new file mode 100644 index 00000000..d8f99234 --- /dev/null +++ b/lib/src/symbol_maps/dart_symbol_map_uploader.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:process/process.dart'; +import 'package:sentry_dart_plugin/src/utils/sentry_cli_args.dart'; + +import '../configuration.dart'; +import '../utils/injector.dart'; +import '../utils/log.dart'; + +/// Uploads a Dart obfuscation map paired with each provided native debug file. +/// +/// For every [debugFilePaths] entry, this emits one CLI invocation equivalent to: +/// +/// sentry-cli dart-symbol-map upload [--url ...] [--auth-token ...] +/// [--log-level ...] --org ... --project ... [--wait] +/// /path-to-map /path-to-debug-file +/// +class DartSymbolMapUploader { + /// Uploads [symbolMapPath] for each entry in [debugFilePaths]. + /// + /// Before uploading the map, we additionally fetch the debug id for the + /// debug file and prepend it to the map. This is necessary because Sentry + /// will reject maps with equivalent content. We artificially add a marker + /// to the map to avoid this. See [_prependDebugIdMarkerToMapFile] for more + /// details. When this is improved in Sentry we can remove the marker and + /// just upload the map without modifications. + /// + /// Throws [ExitError] on the first non-zero CLI exit code. + static Future addDebugIdMarkerAndUpload({ + required Configuration config, + required String symbolMapPath, + required Iterable debugFilePaths, + }) async { + final cliPath = config.cliPath; + if (cliPath == null) { + Log.warn('Skipping Dart symbol map uploads: no CLI path provided.'); + return; + } + + final ProcessManager processManager = injector.get(); + + int attempted = 0; + int succeeded = 0; + int failed = 0; + + try { + for (final String debugFilePath in debugFilePaths) { + attempted++; + + final String? debugId = await _fetchDebugId( + processManager: processManager, + cliPath: cliPath, + debugFilePath: debugFilePath, + ); + if (debugId != null && debugId.isNotEmpty) { + await _prependDebugIdMarkerToMapFile(symbolMapPath, debugId); + } else { + Log.warn( + 'Could not resolve debug id for "$debugFilePath". Proceeding without map modification.'); + } + + Log.info( + "Uploading Dart symbol map '$symbolMapPath' paired with '$debugFilePath'"); + + final args = [ + ...config.baseArgs(), + 'dart-symbol-map', + 'upload', + ...config.orgProjectArgs(), + symbolMapPath, + debugFilePath, + ]; + + final int exitCode = await _startAndForward( + processManager: processManager, + cliPath: cliPath, + args: args, + errorContext: 'Failed to upload Dart symbol map for $debugFilePath', + ); + + if (exitCode == 0) { + succeeded++; + } else { + failed++; + } + + // Propagate non-zero exit code consistently with the plugin behavior. + Log.processExitCode(exitCode); + } + } finally { + Log.info( + 'Dart symbol map upload summary: attempted=$attempted, succeeded=$succeeded, failed=$failed'); + } + } + + /// Starts the process and forwards stdout/stderr to [Log]. Returns exit code. + static Future _startAndForward({ + required ProcessManager processManager, + required String cliPath, + required List args, + required String errorContext, + }) async { + int exitCode; + try { + final Process process = await processManager.start([cliPath, ...args]); + + process.stdout.transform(utf8.decoder).listen((String data) { + final String trimmed = data.trim(); + if (trimmed.isNotEmpty) { + Log.info(trimmed); + } + }); + process.stderr.transform(utf8.decoder).listen((String data) { + final String trimmed = data.trim(); + if (trimmed.isNotEmpty) { + Log.error(trimmed); + } + }); + + exitCode = await process.exitCode; + } on Exception catch (exception) { + Log.error('$errorContext: \n$exception'); + return 1; + } + return exitCode; + } + + /// Returns the debug id for the given [debugFilePath] by invoking: + /// sentry-cli debug-files check --json /debug_file_path + /// Returns null on failure. + static Future _fetchDebugId({ + required ProcessManager processManager, + required String cliPath, + required String debugFilePath, + }) async { + try { + final Process process = await processManager.start([ + cliPath, + 'debug-files', + 'check', + '--json', + debugFilePath, + ]); + + final StringBuffer stdoutBuffer = StringBuffer(); + final StringBuffer stderrBuffer = StringBuffer(); + + process.stdout.transform(utf8.decoder).listen(stdoutBuffer.write); + process.stderr.transform(utf8.decoder).listen(stderrBuffer.write); + + final int code = await process.exitCode; + if (code != 0) { + Log.warn( + 'Failed to fetch debug id for "$debugFilePath" (exit=$code): ${stderrBuffer.toString().trim()}'); + return null; + } + + final String output = stdoutBuffer.toString().trim(); + if (output.isEmpty) { + Log.warn('Empty output when fetching debug id for "$debugFilePath"'); + return null; + } + + final dynamic decoded = jsonDecode(output); + if (decoded is! Map) { + Log.warn('Unexpected JSON when fetching debug id for "$debugFilePath"'); + return null; + } + + final variants = decoded['variants']; + if (variants is List && variants.isNotEmpty) { + final first = variants.first; + if (first is Map && first['debug_id'] is String) { + return first['debug_id'] as String; + } + } + + Log.warn('No debug id found in variants for "$debugFilePath"'); + return null; + } catch (e) { + Log.warn('Exception while fetching debug id for "$debugFilePath": $e'); + return null; + } + } + + static const _debugIdMarker = 'SENTRY_DEBUG_ID_MARKER'; + + /// Reads the Dart symbol map at [mapPath] and ensures the array starts with + /// ["SENTRY_DEBUG_ID_MARKER", debugId]. If a previous marker is present, it + /// will be replaced. + static Future _prependDebugIdMarkerToMapFile( + String mapPath, String debugId) async { + try { + final File file = File(mapPath); + if (!await file.exists()) { + Log.warn( + "Cannot modify Dart symbol map: file does not exist at '$mapPath'."); + return; + } + + final String raw = await file.readAsString(); + final dynamic decoded = jsonDecode(raw); + if (decoded is! List) { + Log.warn( + 'Cannot modify Dart symbol map: top-level JSON is not an array.'); + return; + } + + final List original = List.from(decoded); + + // If the file already has the same marker, do nothing to keep it untouched. + if (original.length >= 2 && + original[0] == _debugIdMarker && + original[1] == debugId) { + return; + } + + List tail; + if (original.isNotEmpty && original.first == _debugIdMarker) { + tail = original.length > 2 ? original.sublist(2) : []; + } else { + tail = original; + } + + final List updated = [ + _debugIdMarker, + debugId, + ...tail, + ]; + + await file.writeAsString(jsonEncode(updated)); + } catch (e) { + Log.warn('Failed to modify Dart symbol map before upload: $e'); + } + } +} diff --git a/lib/src/utils/flutter_debug_files.dart b/lib/src/utils/flutter_debug_files.dart new file mode 100644 index 00000000..f7dfc9eb --- /dev/null +++ b/lib/src/utils/flutter_debug_files.dart @@ -0,0 +1,68 @@ +import 'package:file/file.dart'; + +import '../configuration.dart'; + +/// Enumerates the search roots used to discover native debug files, matching +/// the existing behavior used by the plugin when uploading debug files. +/// +/// This preserves current directories and files probed for: +/// - Android (apk, appbundle) +/// - Windows +/// - Linux +/// - macOS (app and framework) +/// - iOS (Runner.app, Release-*-iphoneos folders, archive, framework) +/// - iOS in Fastlane (ios/build) +Stream enumerateDebugSearchRoots({ + required FileSystem fs, + required Configuration config, +}) async* { + final String buildDir = config.buildFilesFolder; + final String projectRoot = fs.currentDirectory.path; + + // Android (apk, appbundle) + yield '$buildDir/app/outputs'; + yield '$buildDir/app/intermediates'; + + // Windows + for (final subdir in ['', '/x64', '/arm64']) { + yield '$buildDir/windows$subdir/runner/Release'; + } + // TODO: Consider removing once Windows symbols are collected automatically. + // Related to https://github.com/getsentry/sentry-dart-plugin/issues/173 + yield 'windows/flutter/ephemeral/flutter_windows.dll.pdb'; + + // Linux + for (final subdir in ['/x64', '/arm64']) { + yield '$buildDir/linux$subdir/release/bundle'; + } + + // macOS + yield '$buildDir/macos/Build/Products/Release'; + + // macOS (macOS-framework) + yield '$buildDir/macos/framework/Release'; + + // iOS + yield '$buildDir/ios/iphoneos/Runner.app'; + final iosDir = fs.directory('$buildDir/ios'); + if (await iosDir.exists()) { + final regexp = RegExp(r'^Release(-.*)?-iphoneos$'); + yield* iosDir + .list() + .where((entity) => regexp.hasMatch(fs.path.basename(entity.path))) + .map((entity) => entity.path); + } + + // iOS (ipa) + yield '$buildDir/ios/archive'; + + // iOS (ios-framework) + yield '$buildDir/ios/framework/Release'; + + // iOS in Fastlane + if (projectRoot == '/') { + yield 'ios/build'; + } else { + yield '$projectRoot/ios/build'; + } +} diff --git a/lib/src/utils/path.dart b/lib/src/utils/path.dart new file mode 100644 index 00000000..5339bf00 --- /dev/null +++ b/lib/src/utils/path.dart @@ -0,0 +1,25 @@ +import 'package:file/file.dart'; + +import 'log.dart'; + +/// Resolves a provided file path to an absolute path if the file exists. +Future resolveFilePath({ + required FileSystem fs, + required String? rawPath, + required String missingPathWarning, + required String fileNotFoundWarning, +}) async { + final String? providedPath = rawPath?.trim(); + if (providedPath == null || providedPath.isEmpty) { + Log.warn(missingPathWarning); + return null; + } + + final File file = fs.file(providedPath); + if (!await file.exists()) { + Log.warn(fileNotFoundWarning); + return null; + } + + return file.absolute.path; +} diff --git a/lib/src/utils/sentry_cli_args.dart b/lib/src/utils/sentry_cli_args.dart new file mode 100644 index 00000000..ced61908 --- /dev/null +++ b/lib/src/utils/sentry_cli_args.dart @@ -0,0 +1,14 @@ +import '../configuration.dart'; + +extension SentryCliArgs on Configuration { + List baseArgs() => [ + if (url != null) ...['--url', url!], + if (authToken != null) ...['--auth-token', authToken!], + if (logLevel != null) ...['--log-level', logLevel!], + ]; + + List orgProjectArgs() => [ + if (org != null) ...['--org', org!], + if (project != null) ...['--project', project!], + ]; +} diff --git a/test/configuration_test.dart b/test/configuration_test.dart index 10fe2df0..9f2808b5 100644 --- a/test/configuration_test.dart +++ b/test/configuration_test.dart @@ -62,6 +62,7 @@ void main() { org: 'org-args-config', authToken: 'auth_token-args-config', url: 'url-args-config', + dartSymbolMapPath: 'args-dart-symbol-map.json', urlPrefix: 'url-prefix-args-config', waitForProcessing: true, logLevel: 'warning', @@ -88,6 +89,7 @@ void main() { org: 'org-file-config', authToken: 'auth_token-file-config', url: 'url-file-config', + dartSymbolMapPath: 'file-dart-symbol-map.json', urlPrefix: 'url-prefix-file-config', waitForProcessing: false, logLevel: 'debug', @@ -120,6 +122,7 @@ void main() { expect(sut.org, 'org-args-config'); expect(sut.authToken, 'auth_token-args-config'); expect(sut.url, 'url-args-config'); + expect(sut.dartSymbolMapPath, 'args-dart-symbol-map.json'); expect(sut.urlPrefix, 'url-prefix-args-config'); expect(sut.waitForProcessing, isTrue); expect(sut.logLevel, 'warning'); @@ -156,6 +159,7 @@ void main() { org: 'org-file-config', authToken: 'auth_token-file-config', url: 'url-file-config', + dartSymbolMapPath: 'file-dart-symbol-map.json', urlPrefix: 'url-prefix-file-config', waitForProcessing: true, logLevel: 'debug', @@ -189,6 +193,7 @@ void main() { expect(sut.org, 'org-file-config'); expect(sut.authToken, 'auth_token-file-config'); expect(sut.url, 'url-file-config'); + expect(sut.dartSymbolMapPath, 'file-dart-symbol-map.json'); expect(sut.urlPrefix, 'url-prefix-file-config'); expect(sut.waitForProcessing, isTrue); expect(sut.logLevel, 'debug'); @@ -226,6 +231,7 @@ void main() { expect(sut.uploadDebugSymbols, isTrue); expect(sut.uploadSourceMaps, isFalse); expect(sut.uploadSources, isFalse); + expect(sut.dartSymbolMapPath, isNull); expect(sut.commits, 'auto'); expect(sut.ignoreMissing, isFalse); expect(sut.buildFilesFolder, 'build'); diff --git a/test/configuration_values_test.dart b/test/configuration_values_test.dart index 59725141..cbf801ae 100644 --- a/test/configuration_values_test.dart +++ b/test/configuration_values_test.dart @@ -23,6 +23,7 @@ void main() { "--sentry-define=org=fixture-org", "--sentry-define=auth_token=fixture-auth_token", "--sentry-define=url=fixture-url", + "--sentry-define=dart_symbol_map_path=fixture-dart-symbol-map.json", "--sentry-define=wait_for_processing=true", "--sentry-define=log_level=fixture-log_level", "--sentry-define=release=fixture-release", @@ -47,6 +48,7 @@ void main() { expect(sut.org, 'fixture-org'); expect(sut.authToken, 'fixture-auth_token'); expect(sut.url, 'fixture-url'); + expect(sut.dartSymbolMapPath, 'fixture-dart-symbol-map.json'); expect(sut.waitForProcessing, isTrue); expect(sut.logLevel, 'fixture-log_level'); expect(sut.release, 'fixture-release'); @@ -90,6 +92,7 @@ void main() { upload_source_maps: true upload_sources: true url: fixture-url + dart_symbol_map_path: fixture-dart-symbol-map.json wait_for_processing: true log_level: fixture-log_level release: fixture-release @@ -133,6 +136,7 @@ void main() { expect(sut.org, 'o'); expect(sut.authToken, 't'); expect(sut.url, 'fixture-url'); + expect(sut.dartSymbolMapPath, 'fixture-dart-symbol-map.json'); expect(sut.waitForProcessing, isTrue); expect(sut.logLevel, 'fixture-log_level'); expect(sut.release, 'fixture-release'); @@ -155,6 +159,7 @@ void main() { upload_source_maps=true upload_sources=true url=fixture-url + dart_symbol_map_path=fixture-dart-symbol-map.json wait_for_processing=true log_level=fixture-log_level release=fixture-release @@ -197,6 +202,7 @@ void main() { expect(sut.org, 'o'); expect(sut.authToken, 't'); expect(sut.url, 'fixture-url'); + expect(sut.dartSymbolMapPath, 'fixture-dart-symbol-map.json'); expect(sut.waitForProcessing, isTrue); expect(sut.logLevel, 'fixture-log_level'); expect(sut.release, 'fixture-release'); diff --git a/test/dart_symbol_map_debug_files_collector_test.dart b/test/dart_symbol_map_debug_files_collector_test.dart new file mode 100644 index 00000000..1273486c --- /dev/null +++ b/test/dart_symbol_map_debug_files_collector_test.dart @@ -0,0 +1,151 @@ +import 'package:file/memory.dart'; +import 'package:test/test.dart'; + +import 'package:sentry_dart_plugin/src/configuration.dart'; +import 'package:sentry_dart_plugin/src/symbol_maps/dart_symbol_map_debug_files_collector.dart'; + +void main() { + group('collectDebugFilesForDartMap', () { + test('returns Android .symbols only and Apple App.framework.dSYM Mach-O', + () async { + final fs = MemoryFileSystem(style: FileSystemStyle.posix); + final projectRootDir = fs.directory('/work')..createSync(recursive: true); + fs.currentDirectory = projectRootDir; + + final buildDir = '/work/build'; + final symbolsDir = '/work/symbols'; + + // Android .symbols files + fs + .file('$symbolsDir/app.android-arm.symbols') + .createSync(recursive: true); + fs + .file('$symbolsDir/app.android-arm64.symbols') + .createSync(recursive: true); + fs + .file('$symbolsDir/app.android-x64.symbols') + .createSync(recursive: true); + + // Apple App.framework.dSYM Mach-O + final appDsymMachO = + '$buildDir/ios/iphoneos/App.framework.dSYM/Contents/Resources/DWARF/App'; + fs.file(appDsymMachO).createSync(recursive: true); + + // Noise: other .dSYM bundles should be ignored + fs + .file( + '$buildDir/ios/iphoneos/Runner.app.dSYM/Contents/Resources/DWARF/Runner') + .createSync(recursive: true); + fs + .file( + '$buildDir/macos/Build/Products/Release/FlutterMacOS.framework.dSYM/Contents/Resources/DWARF/FlutterMacOS') + .createSync(recursive: true); + + final config = Configuration() + ..buildFilesFolder = buildDir + ..symbolsFolder = symbolsDir; + + final result = await collectDebugFilesForDartMap( + fs: fs, + config: config, + ); + + expect( + result, + containsAll([ + fs.path.normalize('/work/symbols/app.android-arm.symbols'), + fs.path.normalize('/work/symbols/app.android-arm64.symbols'), + fs.path.normalize('/work/symbols/app.android-x64.symbols'), + fs.path.normalize(appDsymMachO), + ])); + + // Ensure we did not include non-App.framework dSYMs + expect(result.any((p) => p.endsWith('/Runner')), isFalse); + expect(result.any((p) => p.endsWith('/FlutterMacOS')), isFalse); + + // Ensure deduplication and absoluteness + expect(result.length, 4); + for (final p in result) { + expect(p.startsWith('/'), isTrue, + reason: 'path should be absolute: $p'); + } + }); + + test('finds App.framework.dSYM under Fastlane ios/build path', () async { + final fs = MemoryFileSystem(style: FileSystemStyle.posix); + final projectRootDir = fs.directory('/project') + ..createSync(recursive: true); + fs.currentDirectory = projectRootDir; + + final buildDir = '/project/build'; + final symbolsDir = '/project/symbols'; + + // Fastlane path + final machO = + '/project/ios/build/App.framework.dSYM/Contents/Resources/DWARF/App'; + fs.file(machO).createSync(recursive: true); + + final config = Configuration() + ..buildFilesFolder = buildDir + ..symbolsFolder = symbolsDir; + + final result = await collectDebugFilesForDartMap( + fs: fs, + config: config, + ); + + expect(result, contains(fs.path.normalize(machO))); + }); + + // macOS is not supported for Dart symbol map pairing. + + test('finds App.framework.dSYM inside iOS Xcode archive dSYMs', () async { + final fs = MemoryFileSystem(style: FileSystemStyle.posix); + final projectRootDir = fs.directory('/iosproj') + ..createSync(recursive: true); + fs.currentDirectory = projectRootDir; + + final buildDir = '/iosproj/build'; + final symbolsDir = '/iosproj/symbols'; + + // iOS archive path + final iosArchiveMachO = + '$buildDir/ios/archive/Runner.xcarchive/dSYMs/App.framework.dSYM/Contents/Resources/DWARF/App'; + fs.file(iosArchiveMachO).createSync(recursive: true); + + final config = Configuration() + ..buildFilesFolder = buildDir + ..symbolsFolder = symbolsDir; + + final result = await collectDebugFilesForDartMap( + fs: fs, + config: config, + ); + + expect(result, contains(fs.path.normalize(iosArchiveMachO))); + }); + + // macOS archive is not supported for Dart symbol map pairing. + + test('returns empty set when no roots or symbols exist', () async { + final fs = MemoryFileSystem(style: FileSystemStyle.posix); + final projectRootDir = fs.directory('/empty') + ..createSync(recursive: true); + fs.currentDirectory = projectRootDir; + + final buildDir = '/empty/build'; + final symbolsDir = '/empty/symbols'; + + final config = Configuration() + ..buildFilesFolder = buildDir + ..symbolsFolder = symbolsDir; + + final result = await collectDebugFilesForDartMap( + fs: fs, + config: config, + ); + + expect(result, isEmpty); + }); + }); +} diff --git a/test/dart_symbol_map_path_resolving_test.dart b/test/dart_symbol_map_path_resolving_test.dart new file mode 100644 index 00000000..6ab932d4 --- /dev/null +++ b/test/dart_symbol_map_path_resolving_test.dart @@ -0,0 +1,91 @@ +import 'package:file/memory.dart'; +import 'package:test/test.dart'; + +import 'package:sentry_dart_plugin/src/configuration.dart'; +import 'package:sentry_dart_plugin/src/utils/path.dart'; + +void main() { + group('resolveFilePath for dartSymbolMapPath', () { + test('returns absolute path for absolute input', () async { + final fs = MemoryFileSystem(style: FileSystemStyle.posix); + final projectRoot = fs.directory('/proj')..createSync(recursive: true); + fs.currentDirectory = projectRoot; + + final absolutePath = '/proj/maps/obfuscation.json'; + fs.file(absolutePath).createSync(recursive: true); + + final config = Configuration()..dartSymbolMapPath = absolutePath; + + final result = await resolveFilePath( + fs: fs, + rawPath: config.dartSymbolMapPath, + missingPathWarning: + "Skipping Dart symbol map uploads: no 'dart_symbol_map_path' provided.", + fileNotFoundWarning: + "Skipping Dart symbol map uploads: Dart symbol map file not found at '${config.dartSymbolMapPath}'.", + ); + expect(result, equals(absolutePath)); + }); + + test('resolves relative path to absolute when file exists', () async { + final fs = MemoryFileSystem(style: FileSystemStyle.posix); + final projectRoot = fs.directory('/root')..createSync(recursive: true); + fs.currentDirectory = projectRoot; + + final rel = 'build/app/obfuscation.map'; + final abs = '/root/build/app/obfuscation.map'; + fs.file(abs).createSync(recursive: true); + + final config = Configuration() + ..buildFilesFolder = '/root/build' + ..symbolsFolder = '/root/symbols' + ..dartSymbolMapPath = rel; + + final result = await resolveFilePath( + fs: fs, + rawPath: config.dartSymbolMapPath, + missingPathWarning: + "Skipping Dart symbol map uploads: no 'dart_symbol_map_path' provided.", + fileNotFoundWarning: + "Skipping Dart symbol map uploads: Dart symbol map file not found at '${config.dartSymbolMapPath}'.", + ); + expect(result, equals(abs)); + }); + + test('returns null and warns when path not provided', () async { + final fs = MemoryFileSystem(style: FileSystemStyle.posix); + final projectRoot = fs.directory('/x')..createSync(recursive: true); + fs.currentDirectory = projectRoot; + + final config = Configuration()..dartSymbolMapPath = null; + + final result = await resolveFilePath( + fs: fs, + rawPath: config.dartSymbolMapPath, + missingPathWarning: + "Skipping Dart symbol map uploads: no 'dart_symbol_map_path' provided.", + fileNotFoundWarning: + "Skipping Dart symbol map uploads: Dart symbol map file not found at '${config.dartSymbolMapPath}'.", + ); + expect(result, isNull); + }); + + test('returns null and warns when file does not exist', () async { + final fs = MemoryFileSystem(style: FileSystemStyle.posix); + final projectRoot = fs.directory('/p')..createSync(recursive: true); + fs.currentDirectory = projectRoot; + + final config = Configuration()..dartSymbolMapPath = 'missing.map'; + + final result = await resolveFilePath( + fs: fs, + rawPath: config.dartSymbolMapPath, + missingPathWarning: + "Skipping Dart symbol map uploads: no 'dart_symbol_map_path' provided.", + fileNotFoundWarning: + "Skipping Dart symbol map uploads: Dart symbol map file not found at '${config.dartSymbolMapPath}'.", + ); + expect(result, isNull); + }); + }); +} diff --git a/test/dart_symbol_map_uploader_test.dart b/test/dart_symbol_map_uploader_test.dart new file mode 100644 index 00000000..75bb735f --- /dev/null +++ b/test/dart_symbol_map_uploader_test.dart @@ -0,0 +1,196 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:process/process.dart'; +import 'package:test/test.dart'; + +import 'package:sentry_dart_plugin/src/configuration.dart'; +import 'package:sentry_dart_plugin/src/symbol_maps/dart_symbol_map_uploader.dart'; +import 'package:sentry_dart_plugin/src/utils/injector.dart'; +import 'package:sentry_dart_plugin/src/utils/log.dart'; + +void main() { + group('DartMapUploader.upload', () { + late MockProcessManager pm; + + setUp(() { + pm = MockProcessManager(); + injector.registerSingleton(() => pm, override: true); + }); + + test('emits one command per debug file with all flags', () async { + final config = Configuration() + ..cliPath = 'mock-cli' + ..url = 'https://example.invalid' + ..authToken = 'token' + ..logLevel = 'debug' + ..org = 'my-org' + ..project = 'my-proj'; + + final map = '/abs/path/obfuscation.map'; + final debugFiles = [ + '/a/app.android-arm.symbols', + '/b/App.framework.dSYM/Contents/Resources/DWARF/App', + ]; + + await DartSymbolMapUploader.addDebugIdMarkerAndUpload( + config: config, + symbolMapPath: map, + debugFilePaths: debugFiles, + ); + + expect(pm.commandLog.length, 4); + expect( + pm.commandLog[0], + equals( + 'mock-cli debug-files check --json ${debugFiles[0]}', + ), + ); + expect( + pm.commandLog[1], + equals( + 'mock-cli --url https://example.invalid --auth-token token --log-level debug ' + 'dart-symbol-map upload --org my-org --project my-proj ' + '$map ${debugFiles[0]}', + ), + ); + expect( + pm.commandLog[2], + equals( + 'mock-cli debug-files check --json ${debugFiles[1]}', + ), + ); + expect( + pm.commandLog[3], + equals( + 'mock-cli --url https://example.invalid --auth-token token --log-level debug ' + 'dart-symbol-map upload --org my-org --project my-proj ' + '$map ${debugFiles[1]}', + ), + ); + }); + + test('omits optional flags when not configured', () async { + final config = Configuration() + ..cliPath = 'mock-cli' + ..url = null + ..authToken = null + ..logLevel = null + ..org = null + ..project = null; + + final map = '/m/map.json'; + final debugFiles = ['/d/file.symbols']; + + await DartSymbolMapUploader.addDebugIdMarkerAndUpload( + config: config, + symbolMapPath: map, + debugFilePaths: debugFiles, + ); + + expect(pm.commandLog.length, 2); + expect( + pm.commandLog[0], + equals('mock-cli debug-files check --json ${debugFiles.single}'), + ); + expect( + pm.commandLog[1], + equals('mock-cli dart-symbol-map upload $map ${debugFiles.single}'), + ); + }); + + test('propagates non-zero exit codes via ExitError', () async { + // First debug-id check succeeds, then the first upload fails. + pm.exitCodes = [0, 1]; + + final config = Configuration() + ..cliPath = 'mock-cli' + ..org = 'o' + ..project = 'p' + ..url = null + ..authToken = null + ..logLevel = null; + + final call = DartSymbolMapUploader.addDebugIdMarkerAndUpload( + config: config, + symbolMapPath: '/map.json', + debugFilePaths: ['/debug.symbols', '/ignored.second'], + ); + + await expectLater(call, throwsA(isA())); + // Only the first pair of commands (check + upload) should have been issued + // because the first upload fails and throws. + expect(pm.commandLog.length, 2); + }); + }); +} + +class MockProcessManager implements ProcessManager { + final List commandLog = []; + List exitCodes = []; // optional per-start exit codes + + @override + bool canRun(executable, {String? workingDirectory}) => true; + + @override + bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]) => true; + + @override + Future run(List command, + {String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + covariant Encoding? stdoutEncoding = systemEncoding, + covariant Encoding? stderrEncoding = systemEncoding}) async { + return runSync(command); + } + + @override + ProcessResult runSync(List command, + {String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + covariant Encoding? stdoutEncoding = systemEncoding, + covariant Encoding? stderrEncoding = systemEncoding}) { + commandLog.add(command.join(' ')); + final int code = exitCodes.isNotEmpty ? exitCodes.removeAt(0) : 0; + return ProcessResult(-1, code, null, null); + } + + @override + Future start(List command, + {String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal}) async { + commandLog.add(command.join(' ')); + final int code = exitCodes.isNotEmpty ? exitCodes.removeAt(0) : 0; + return MockProcess(code); + } +} + +class MockProcess implements Process { + final int _exitCode; + MockProcess(this._exitCode); + + @override + Future get exitCode => Future.value(_exitCode); + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) => false; + + @override + int get pid => -1; + + @override + Stream> get stderr => const Stream>.empty(); + + @override + IOSink get stdin => throw UnimplementedError(); + + @override + Stream> get stdout => const Stream>.empty(); +} diff --git a/test/integration_test.dart b/test/integration_test.dart index 388b00c2..5a261027 100644 --- a/test/integration_test.dart +++ b/test/integration_test.dart @@ -142,6 +142,17 @@ void main() async { default: fail('Platform "$platform" missing from tests'); } + + // Ensure that when a map is present we exercise the dart-symbol-map path for supported platforms. + if (platform == 'ios' || + platform == 'ipa' || + platform == 'apk' || + platform == 'appbundle') { + final hasSummary = pluginOutput.any( + (e) => e.contains('Dart symbol map upload summary: attempted=')); + expect(hasSummary, isTrue, + reason: 'Dart symbol map upload summary not found'); + } }, timeout: Timeout(const Duration(minutes: 5))); } } @@ -177,8 +188,10 @@ Future> _flutter(List arguments, {String? cwd}) => _exec('flutter', arguments, cwd: cwd); Future> _runPlugin(Directory cwd) => _exec( - 'dart', ['run', 'sentry_dart_plugin', '--sentry-define=url=$serverUri'], - cwd: cwd.path); + 'dart', + ['run', 'sentry_dart_plugin', '--sentry-define=url=$serverUri'], + cwd: cwd.path, + ); // e.g. Flutter 3.24.4 • channel stable • https://github.com/flutter/flutter.git final _flutterVersionInfo = @@ -210,7 +223,9 @@ Future _prepareTestApp(Directory tempDir, String platform) async { if (['ipa', 'ios'].contains(platform)) '--no-codesign', if (platform == 'web') '--source-maps', if (platform != 'web') '--split-debug-info=symbols', - if (platform != 'web') '--obfuscate' + if (platform != 'web') '--obfuscate', + if (platform != 'web') + '--extra-gen-snapshot-options=--save-obfuscation-map=obfuscation.map.json', ]; // In order to not run the build on every test execution, we store a hash. @@ -250,6 +265,7 @@ sentry: log_level: debug commits: false legacy_web_symbolication: ${isWebLegacy ? true : false} + dart_symbol_map_path: obfuscation.map.json '''; await pubspecFile.writeAsString(pubspec); diff --git a/test/plugin_test.dart b/test/plugin_test.dart index 3dc8a715..29076361 100644 --- a/test/plugin_test.dart +++ b/test/plugin_test.dart @@ -217,6 +217,54 @@ void main() { }); }); + test('emits dart-symbol-map upload command', () async { + const version = '1.0.0'; + // Create Android symbols and a fake Dart symbol map + final androidSymbolsDir = fs.directory('$buildDir/app/outputs') + ..createSync(recursive: true); + fs + .file('${androidSymbolsDir.path}/app-release.symbols') + .writeAsStringSync('fake'); + final mapFile = fs.file('obfuscation.map.json') + ..writeAsStringSync('[]'); + + final config = ''' + upload_debug_symbols: true + log_level: debug + dart_symbol_map_path: ${mapFile.path} + '''; + + final commandLog = await runWith(version, config); + const release = '$name@$version'; + + final args = '$commonArgs --log-level debug'; + expect( + commandLog, + anyElement((e) { + final relStart = + '$cli $args dart-symbol-map upload $orgAndProject ${mapFile.path} '; + final absStart = + '$cli $args dart-symbol-map upload $orgAndProject ${fs.file(mapFile.path).absolute.path} '; + return (e.startsWith(relStart) || e.startsWith(absStart)) && + e.endsWith('$buildDir/app/outputs/app-release.symbols'); + }), + ); + + // Ensure other expected commands still present + expect( + commandLog, + contains( + '$cli $args debug-files upload $orgAndProject $buildDir/app/outputs')); + expect(commandLog, + contains('$cli $args releases $orgAndProject new $release')); + expect( + commandLog, + contains( + '$cli $args releases $orgAndProject set-commits $release --auto')); + expect(commandLog, + contains('$cli $args releases $orgAndProject finalize $release')); + }); + group('release', () { test('default from name and version', () async { const version = '1.0.0';