diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 3722af2a3..5fbf97471 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -35,6 +35,21 @@ steps: exit "$$PREP_STATUS" fi make build REFRESH_JS_BUILD=1 + # Vite emits `.map` files into `dist/` for Sentry symbolication. + # Zip them (preserving the `assets/` layout the host uploads under), + # then strip them from `dist/` BEFORE tarring: the source maps must + # never ship inside the app bundles, and some downstream steps (e.g. + # the Android publish) copy `dist/` directly rather than through the + # map-stripping Makefile targets. Stripping here makes `dist.tar.gz` + # map-free for every consumer. + echo "--- :sentry: Packaging source maps" + # Include both the minified `.js` and their `.map` files: Sentry's + # Debug ID symbolication needs the minified source alongside the map + # (uploading only maps yields `js_no_source`/`missing_source`). Both + # carry the same injected Debug ID. + ( cd dist && zip -r -q ../GutenbergKitSourceMaps.zip . -i '*.js' '*.map' ) + buildkite-agent artifact upload GutenbergKitSourceMaps.zip + find dist -name '*.map' -delete tar -czf dist.tar.gz dist/ buildkite-agent artifact upload dist.tar.gz plugins: &plugins @@ -149,6 +164,9 @@ steps: command: | buildkite-agent artifact download '*.xcframework.zip' . buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . + # Source maps are optional: `publish_to_s3` skips the upload when the + # zip is absent, so don't fail the step if the artifact isn't found. + buildkite-agent artifact download GutenbergKitSourceMaps.zip . || true install_gems bundle exec fastlane publish_to_s3 version:$BUILDKITE_COMMIT plugins: *plugins diff --git a/.buildkite/publish-pr-xcframework.sh b/.buildkite/publish-pr-xcframework.sh index a25b03543..428922db6 100755 --- a/.buildkite/publish-pr-xcframework.sh +++ b/.buildkite/publish-pr-xcframework.sh @@ -21,6 +21,11 @@ echo '--- :arrow_down: Downloading XCFramework artifacts' buildkite-agent artifact download '*.xcframework.zip' . --step "build-xcframework" buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . --step "build-xcframework" +# Source maps are optional: `publish_to_s3` skips the upload when the zip is +# absent, so tolerate a missing artifact (e.g. a build that emitted no maps). +echo '--- :arrow_down: Downloading source maps' +buildkite-agent artifact download GutenbergKitSourceMaps.zip . --step "build-react" || true + echo '--- :rubygems: Setting up Gems' install_gems diff --git a/.buildkite/release.sh b/.buildkite/release.sh index 9e65687e2..077b193cd 100755 --- a/.buildkite/release.sh +++ b/.buildkite/release.sh @@ -16,6 +16,11 @@ echo '--- :arrow_down: Downloading XCFramework artifacts' buildkite-agent artifact download '*.xcframework.zip' . --step "build-xcframework" buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . --step "build-xcframework" +# Source maps are optional: `publish_to_s3` skips the upload when the zip is +# absent, so tolerate a missing artifact (e.g. a build that emitted no maps). +echo '--- :arrow_down: Downloading source maps' +buildkite-agent artifact download GutenbergKitSourceMaps.zip . --step "build-react" || true + echo '--- :rubygems: Setting up Gems' install_gems diff --git a/Makefile b/Makefile index 3edeeebac..a82b51a9a 100644 --- a/Makefile +++ b/Makefile @@ -132,6 +132,7 @@ build: prep-translations ## Build the project for all platforms (iOS, Android, w $(MAKE) _RECURSIVE_INVOKE=1 npm-dependencies && \ echo "--- :node: Building Gutenberg" && \ npm run build && \ + $(MAKE) inject-sourcemap-debug-ids && \ echo "--- :open_file_folder: Copying Build Products into place" && \ $(MAKE) copy-dist-ios && \ $(MAKE) copy-dist-android; \ @@ -139,17 +140,42 @@ build: prep-translations ## Build the project for all platforms (iOS, Android, w echo "--- :white_check_mark: Skipping JS build (dist already exists). Use REFRESH_JS_BUILD=1 to force refresh."; \ fi +# Inject Sentry Debug IDs into the built JS and its source maps. This runs +# BETWEEN `npm run build` and the `copy-dist-*` targets so the `//# debugId=` +# comment lands in `dist/` and is copied into the shipped bundles, while the +# matching `debug_id` is written into the `.map` files that get uploaded to +# Sentry. Debug IDs let Sentry match maps to events regardless of the (unstable, +# per-install) on-device file paths — the only reliable approach for GutenbergKit's +# WebView, where path-based matching fails. +# +# `@sentry/cli sourcemaps inject` only rewrites files with adjacent maps; it needs +# no auth token (uploading, which does, stays host-side). The injected UUIDs are +# deterministic (content-hashed), so builds remain reproducible. +.PHONY: inject-sourcemap-debug-ids +inject-sourcemap-debug-ids: + @echo "--- :sentry: Injecting source map Debug IDs" + @npx sentry-cli sourcemaps inject dist + +# The `find ... -delete` after each copy strips source maps from the native +# bundles. Maps are emitted into `dist/` (see `vite.config.js`) so build tooling +# can deliver them to Sentry, but they must NOT ship inside the app: they'd bloat +# the binary and expose de-minified source. `dist/` itself keeps its maps for the +# upload step. Stripping here (rather than only in CI) covers every consumer of +# these targets — local `make build`, CI, and `bin/release.sh` (which commits the +# iOS bundle via `git add .`). .PHONY: copy-dist-ios copy-dist-ios: @rm -rf ./ios/Sources/GutenbergKitResources/Gutenberg/ @mkdir -p ./ios/Sources/GutenbergKitResources/Gutenberg @cp -r ./dist/. ./ios/Sources/GutenbergKitResources/Gutenberg/ + @find ./ios/Sources/GutenbergKitResources/Gutenberg -name '*.map' -delete @touch ./ios/Sources/GutenbergKitResources/Gutenberg/.gitkeep .PHONY: copy-dist-android copy-dist-android: @rm -rf ./android/Gutenberg/src/main/assets/ @cp -r ./dist/. ./android/Gutenberg/src/main/assets + @find ./android/Gutenberg/src/main/assets -name '*.map' -delete .PHONY: build-swift-package build-swift-package: build ## Build the Swift package for iOS diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 1b9ef3d35..5b135b285 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -48,6 +48,25 @@ lane :publish_to_s3 do |options| auto_prefix: false, if_exists: :replace ) + + # Publish the JS source maps alongside the XCFramework so host apps can fetch + # them (by GutenbergKit version) and upload them to Sentry for exception + # symbolication. The maps are NOT bundled inside the XCFramework — SwiftPM + # binary-target consumers don't surface sibling files to the host build — so + # they ship as a separate object under the same versioned key prefix. + # Optional: older builds (or a build that emitted no maps) simply skip this. + sourcemaps = sourcemaps_file_path + if sourcemaps + upload_to_s3( + bucket: 'a8c-apps-public-artifacts', + key: File.join('gutenbergkit', version, 'GutenbergKitSourceMaps.zip'), + file: sourcemaps, + auto_prefix: false, + if_exists: :replace + ) + else + UI.important('No GutenbergKitSourceMaps.zip found; skipping source-map upload.') + end end lane :publish_pr_xcframework do @@ -224,6 +243,15 @@ def xcframework_file_path ) end +# Locate the source-map bundle produced by the `build-react` CI step. Returns +# nil when absent (rather than failing) so `publish_to_s3` can treat source-map +# publishing as optional — a build that emitted no maps still publishes the +# XCFramework. +def sourcemaps_file_path + path = File.join(PROJECT_ROOT, 'GutenbergKitSourceMaps.zip') + File.exist?(path) ? path : nil +end + def require_single_glob_match!(pattern, not_found_message) matches = Dir.glob(pattern) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 91041f7b4..5faf740db 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -102,6 +102,9 @@ public struct GutenbergJSException { public let tags: [String: String] public let isHandled: Bool public let handledBy: String + /// Source-map Debug IDs for the files in the stack trace, used to symbolicate + /// the exception in Sentry regardless of the on-device file paths. + public let debugImages: [DebugImage] public struct StacktraceLine { public let filename: String? @@ -120,6 +123,22 @@ public struct GutenbergJSException { } } + /// Pairs a minified file with the Debug ID that identifies its source map. + public struct DebugImage { + public let codeFile: String + public let debugID: String + + init?(from dict: [AnyHashable: Any]) { + guard let codeFile = dict["code_file"] as? String, + let debugID = dict["debug_id"] as? String + else { + return nil + } + self.codeFile = codeFile + self.debugID = debugID + } + } + init?(from dict: [AnyHashable: Any]) { guard let type = dict["type"] as? String, let message = dict["message"] as? String, @@ -139,6 +158,9 @@ public struct GutenbergJSException { self.tags = tags self.isHandled = isHandled self.handledBy = handledBy + // Optional: absent in payloads from builds without Debug IDs. + let rawDebugImages = dict["debug_images"] as? [[AnyHashable: Any]] ?? [] + self.debugImages = rawDebugImages.compactMap { DebugImage(from: $0) } } } diff --git a/ios/Tests/GutenbergKitTests/GutenbergJSExceptionTests.swift b/ios/Tests/GutenbergKitTests/GutenbergJSExceptionTests.swift new file mode 100644 index 000000000..acd976d61 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/GutenbergJSExceptionTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +@Suite +struct GutenbergJSExceptionTests { + + private func baseException( + debugImages: [[String: Any]]? = nil + ) -> [String: Any] { + var dict: [String: Any] = [ + "type": "TypeError", + "message": "boom", + "stacktrace": [ + ["function": "fn", "filename": "~/assets/editor-abc.js", "lineno": 22, "colno": 247] + ], + "context": [:], + "tags": [:], + "isHandled": false, + "handledBy": "window.error" + ] + if let debugImages { + dict["debug_images"] = debugImages + } + return dict + } + + @Test("Decodes debug images when present") + func decodesDebugImages() throws { + let dict = baseException(debugImages: [ + ["code_file": "~/assets/editor-abc.js", "debug_id": "11111111-1111-1111-1111-111111111111"] + ]) + + let exception = try #require(GutenbergJSException(from: dict)) + + #expect(exception.debugImages.count == 1) + #expect(exception.debugImages.first?.codeFile == "~/assets/editor-abc.js") + #expect(exception.debugImages.first?.debugID == "11111111-1111-1111-1111-111111111111") + } + + @Test("Defaults to no debug images when the field is absent") + func defaultsToEmptyWhenAbsent() throws { + let exception = try #require(GutenbergJSException(from: baseException())) + #expect(exception.debugImages.isEmpty) + } + + @Test("Skips malformed debug images") + func skipsMalformedDebugImages() throws { + let dict = baseException(debugImages: [ + ["code_file": "~/assets/editor-abc.js"], // missing debug_id + ["code_file": "~/assets/index-def.js", "debug_id": "22222222-2222-2222-2222-222222222222"] + ]) + + let exception = try #require(GutenbergJSException(from: dict)) + + #expect(exception.debugImages.count == 1) + #expect(exception.debugImages.first?.debugID == "22222222-2222-2222-2222-222222222222") + } +} diff --git a/package-lock.json b/package-lock.json index 60d45275c..d784a2701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@sentry/cli": "^3.6.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -6495,6 +6496,189 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry/cli": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.6.0.tgz", + "integrity": "sha512-79XC8o59G/i3VqmnoQD74a/QD/422eQQlUqiPd3sEvcYCxnaZialRVAsxuNEFd6sx4aVGpqt775MMDT9cV/lMg==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "undici": "^6.22.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "3.6.0", + "@sentry/cli-linux-arm": "3.6.0", + "@sentry/cli-linux-arm64": "3.6.0", + "@sentry/cli-linux-i686": "3.6.0", + "@sentry/cli-linux-x64": "3.6.0", + "@sentry/cli-win32-arm64": "3.6.0", + "@sentry/cli-win32-i686": "3.6.0", + "@sentry/cli-win32-x64": "3.6.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.6.0.tgz", + "integrity": "sha512-C2SWHKaEP8NoYkLDr5jwrzklTwlJkzPIx7lu2LZrwBuHG/sNhWdili0ED2mD86b6Q90hlcMtuBm5IHUwcZ6FTQ==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.6.0.tgz", + "integrity": "sha512-S9xsDZTvybOGbrqqZ7DvF7JCNKp4cakDWJ4LdvQX+z82cHQSoLkYOXkA3EafDfWV9BGIRMIXitMMiSsV2PMU4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.6.0.tgz", + "integrity": "sha512-Rc+DB8vuTDpwqY2BxIPnooYk2ZDYQytF+n4nfi6pZZsJtr3SFFe+3wIWVmCVqxiHkaISb33+iJIDxOOqhkSNbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.6.0.tgz", + "integrity": "sha512-PQ7+ctNmWtHgmbKa+rJheHU7D9GHJXafgWYfVW6gt7R0Ag9LxiDVYLGjrL4G/i7AGFNgudXFaLkGKNX7HUjc+g==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.6.0.tgz", + "integrity": "sha512-c+7xNg5BAaPE8N2Q6pg3Q/kt97JSaskuQIjRxHaFuDbCkJvww4VozY6mW5NUMJaW48rEs3mahWKamTLqJsO3wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.6.0.tgz", + "integrity": "sha512-zhZ7YyGreHSKZ92Mwb9h4cEyL0I/eND7W6XIUXUW0BCCmxFOMc71vlQpUw8gijHIsFDbv8c8a6VOSkeRuwbSwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.6.0.tgz", + "integrity": "sha512-JaxzVDdyetrPBp8NHh2yNAYdDk79ROXqfAfjQwG5z6V764MMMrf2WrhQ7EwoKXOPtBLm/drbOcYgaxHuDZKGRw==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.6.0.tgz", + "integrity": "sha512-LW0078VlxaUeVMMLA15A1zhkvZ5vby/lwthtBXPoBSDdTcgbTE4D4gQXM9vEapV28SvCO3fVIek3pbtrkaeDMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -18998,6 +19182,13 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/ps-man": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/ps-man/-/ps-man-1.1.8.tgz", diff --git a/package.json b/package.json index 999194ac2..3cbe8a56a 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@sentry/cli": "^3.6.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/src/utils/exception-parser.js b/src/utils/exception-parser.js index b5b96389d..0a810fc74 100644 --- a/src/utils/exception-parser.js +++ b/src/utils/exception-parser.js @@ -85,9 +85,62 @@ const parseException = ( originalException ) => { return exception; }; +// Build a map of `filename -> debugId` from the `window._sentryDebugIds` global. +// `sentry-cli sourcemaps inject` adds a snippet to each built chunk that captures +// its own `(new Error).stack` as a key and stores the chunk's Debug ID as the +// value. The top frame of that stack identifies the chunk's file, so parsing each +// key yields which Debug ID belongs to which file. +const getDebugIdsByFilename = () => { + const debugIdMap = + typeof window !== 'undefined' ? window._sentryDebugIds : undefined; + if ( ! debugIdMap ) { + return {}; + } + + const result = {}; + for ( const stack of Object.keys( debugIdMap ) ) { + for ( const line of stack.split( '\n' ) ) { + let frame; + for ( const parser of stackParsers ) { + frame = parser( line ); + if ( frame ) { + break; + } + } + // The first parseable frame's file is the chunk the snippet ran in. + if ( frame?.filename ) { + result[ frame.filename ] = debugIdMap[ stack ]; + break; + } + } + } + return result; +}; + +// Produce the Sentry `debug_meta` images for the files referenced by this +// exception's stack, so Sentry can match the uploaded source maps by Debug ID +// (independent of the unstable on-device file paths). +const getDebugImages = ( stacktrace ) => { + const debugIdsByFilename = getDebugIdsByFilename(); + const seen = new Set(); + const images = []; + + for ( const frame of stacktrace ) { + const filename = frame.filename; + const debugId = filename && debugIdsByFilename[ filename ]; + if ( debugId && ! seen.has( filename ) ) { + seen.add( filename ); + images.push( { code_file: filename, debug_id: debugId } ); + } + } + return images; +}; + export default ( exception, { context, tags } = {} ) => { + const parsed = parseException( exception ); return { - ...parseException( exception ), + ...parsed, + debug_images: getDebugImages( parsed.stacktrace ), context: { ...context, }, diff --git a/src/utils/exception-parser.test.js b/src/utils/exception-parser.test.js new file mode 100644 index 000000000..3695540dc --- /dev/null +++ b/src/utils/exception-parser.test.js @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { describe, it, expect, afterEach } from 'vitest'; + +/** + * Internal dependencies + */ +import parseException from './exception-parser'; + +describe( 'parseException debug images', () => { + afterEach( () => { + delete window._sentryDebugIds; + } ); + + it( 'emits a debug image for each stack file that has a Debug ID', () => { + // `sentry-cli sourcemaps inject` records `(new Error).stack -> debugId`; + // the stack's top frame identifies the chunk the snippet ran in. + window._sentryDebugIds = { + 'Error\n at https://example.com/assets/editor-abc.js:1:1': + '11111111-1111-1111-1111-111111111111', + 'Error\n at https://example.com/assets/index-def.js:1:1': + '22222222-2222-2222-2222-222222222222', + }; + + const exception = { + name: 'TypeError', + message: 'boom', + stack: [ + 'TypeError: boom', + ' at fn (https://example.com/assets/editor-abc.js:22:247)', + ' at go (https://example.com/assets/index-def.js:3:9)', + ].join( '\n' ), + }; + + const { debug_images: debugImages } = parseException( exception ); + + expect( debugImages ).toEqual( + expect.arrayContaining( [ + { + code_file: 'https://example.com/assets/editor-abc.js', + debug_id: '11111111-1111-1111-1111-111111111111', + }, + { + code_file: 'https://example.com/assets/index-def.js', + debug_id: '22222222-2222-2222-2222-222222222222', + }, + ] ) + ); + expect( debugImages ).toHaveLength( 2 ); + } ); + + it( 'deduplicates files that appear in multiple frames', () => { + window._sentryDebugIds = { + 'Error\n at https://example.com/assets/editor-abc.js:1:1': + '11111111-1111-1111-1111-111111111111', + }; + + const exception = { + name: 'Error', + message: 'boom', + stack: [ + 'Error: boom', + ' at a (https://example.com/assets/editor-abc.js:22:247)', + ' at b (https://example.com/assets/editor-abc.js:30:5)', + ].join( '\n' ), + }; + + const { debug_images: debugImages } = parseException( exception ); + + expect( debugImages ).toEqual( [ + { + code_file: 'https://example.com/assets/editor-abc.js', + debug_id: '11111111-1111-1111-1111-111111111111', + }, + ] ); + } ); + + it( 'returns an empty list when no Debug IDs are present', () => { + const exception = { + name: 'Error', + message: 'boom', + stack: 'Error: boom\n at a (https://example.com/assets/editor-abc.js:1:1)', + }; + + const { debug_images: debugImages } = parseException( exception ); + + expect( debugImages ).toEqual( [] ); + } ); +} ); diff --git a/vite.config.js b/vite.config.js index c855406fc..6c70d7e99 100644 --- a/vite.config.js +++ b/vite.config.js @@ -24,6 +24,13 @@ export default defineConfig( { build: { outDir: '../dist', target: 'esnext', + // Emit source maps so build tooling can upload them to Sentry for + // exception symbolication. `'hidden'` writes the `.map` files but omits + // the `//# sourceMappingURL=` comment from the shipped JS: the maps are + // stripped from the native bundles (see the Makefile) so a comment + // pointing at them would dangle. Dev-server debugging is unaffected — + // `build.sourcemap` applies only to production builds, not `vite dev`. + sourcemap: 'hidden', }, resolve: { alias: {