diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/.gitignore new file mode 100644 index 000000000000..4096cd11d279 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +node_modules +/build +/.svelte-kit + +# Mock Sentry server artifacts +.tmp_* +/tmp_mock_chunks diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..697c4437532d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts @@ -0,0 +1,100 @@ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + findInjectedDebugIds, + findSourceMapFiles, + findSourceMappingUrlComments, + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, +} from '@sentry-internal/test-utils'; + +const BUILD_OUTPUT = 'build'; +const CLIENT_OUTPUT = path.join(BUILD_OUTPUT, 'client'); +const SERVER_OUTPUT = path.join(BUILD_OUTPUT, 'server'); + +/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; + +/** Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.js`. */ +const debugIdsWithoutSourceMap = new Set( + fs.existsSync('.tmp_debug_ids_without_source_map') + ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) + : [], +); + +const requests = loadMockServerResults(); + +console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); +for (const request of requests) { + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); +} +console.log(''); + +assert.ok( + requests.some(r => r.authorization.includes('fake-auth-token')), + 'Expected requests with the configured auth token', +); + +assert.ok( + requests.some(r => r.url?.includes('/releases') && r.method === 'POST'), + 'Expected a POST to create the release', +); + +assert.ok( + getChunkUploadPosts(requests).some(r => r.bodySize > 0), + 'Expected at least one chunk upload POST with a non-empty body', +); + +const assembleRequests = getAssembleRequests(requests); +assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); +for (const request of assembleRequests) { + assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); + assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); +} + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); + +const sourcemaps = getSourcemaps(bundles); + +const containsMarker = (marker: string): boolean => + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source'); +assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source'); + +// The auto-instrument plugin loads wrapped modules under this suffix. It must be stripped before +// upload, or the uploaded sources don't match the frames Sentry receives. +const wrappedSources = sourcemaps + .flatMap(map => map.sourcemap.sources ?? []) + .filter(source => source.includes('?sentry-auto-wrap')); +assert.deepEqual(wrappedSources, [], 'Expected no uploaded source to carry the auto-wrap query suffix'); + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); + +for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + + const unuploaded = injectedDebugIds.filter( + debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), + ); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); +} + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); +assert.deepEqual( + findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }), + [], + `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`, +); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json new file mode 100644 index 000000000000..93da7029d773 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json @@ -0,0 +1,30 @@ +{ + "name": "sveltekit-3-sourcemaps", + "description": "E2E test app asserting what the SvelteKit SDK uploads to Sentry and what it leaves behind in `build`.", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "node assert-build.ts" + }, + "dependencies": { + "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" + }, + "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sveltejs/adapter-node": "^6.0.0", + "@sveltejs/kit": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@types/node": "^22.20.0", + "svelte": "^5.57.1", + "typescript": "^6.0.0", + "vite": "^8.0.12" + }, + "volta": { + "node": "22.20.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.d.ts new file mode 100644 index 000000000000..ede601ab93e2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.html b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.html new file mode 100644 index 000000000000..023060c2d49d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.client.ts new file mode 100644 index 000000000000..f3bbf5d7b179 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.client.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/sveltekit'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +export const handleError = Sentry.handleErrorWithSentry(); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.server.ts new file mode 100644 index 000000000000..0865342abcf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.server.ts @@ -0,0 +1,5 @@ +import * as Sentry from '@sentry/sveltekit'; + +export const handleError = Sentry.handleErrorWithSentry(); + +export const handle = Sentry.sentryHandle(); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/instrumentation.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/instrumentation.server.ts new file mode 100644 index 000000000000..372d3cceff73 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/instrumentation.server.ts @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/sveltekit'; + +// SvelteKit 3 loads this file before the app, which is where `Sentry.init` belongs. +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/+page.svelte new file mode 100644 index 000000000000..dec5cf04b490 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/+page.svelte @@ -0,0 +1,8 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.server.ts new file mode 100644 index 000000000000..131d44a2f2a0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.server.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_SERVER +export const load = async () => { + throw new Error('Server load error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.svelte new file mode 100644 index 000000000000..089965d0419f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.svelte @@ -0,0 +1 @@ +

Server error

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..7d2ade5cb226 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,4 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +// Not a hidden dir, which the SDK's default `./.*/**/*.map` cleanup would empty after the upload +startMockSentryServer({ org: 'test-org', outputDir: 'tmp_mock_chunks' }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/static/favicon.png b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/static/favicon.png new file mode 100644 index 000000000000..825b9e65af7c Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/static/favicon.png differ diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..fb327998a0ba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "$app/tsconfig", + "include": ["src"], + "compilerOptions": { + "allowJs": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/vite.config.js new file mode 100644 index 000000000000..c826e74cce72 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/vite.config.js @@ -0,0 +1,44 @@ +import adapter from '@sveltejs/adapter-node'; +import { sentrySvelteKit } from '@sentry/sveltekit/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; +import * as fs from 'node:fs'; +import { defineConfig } from 'vite'; + +// Rolldown emits no map for chunks without mappable source (e.g. Vite's preload helper), so their debug +// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build', + enforce: 'post', + writeBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1].toLowerCase()}\n`); + } + } + }, + }; +} + +export default defineConfig({ + plugins: [ + sentrySvelteKit({ + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + debug: true, + }), + sveltekit({ + preprocess: vitePreprocess(), + adapter: adapter(), + }), + recordChunksWithoutSourceMap(), + ], +}); diff --git a/packages/sveltekit/src/vite/sourceMaps.ts b/packages/sveltekit/src/vite/sourceMaps.ts index 4f8c50e1a14c..7cfa82ed6f6d 100644 --- a/packages/sveltekit/src/vite/sourceMaps.ts +++ b/packages/sveltekit/src/vite/sourceMaps.ts @@ -111,10 +111,14 @@ export async function makeCustomSentryVitePlugins( let isSSRBuild = true; + // Recorded before `sourceMapSettingsPlugin` overrides `build.sourcemap` + let sourcemapSettingAlreadySet = false; + const sourceMapSettingsPlugin: Plugin = { name: 'sentry-sveltekit-update-source-map-setting-plugin', apply: 'build', // only apply this plugin at build time config: async (config: UserConfig) => { + sourcemapSettingAlreadySet = typeof config.build?.sourcemap !== 'undefined'; return { ...config, build: { @@ -125,18 +129,9 @@ export async function makeCustomSentryVitePlugins( }, }; - // Whether `build.sourcemap` is already set when this `config` hook runs. Read here rather than in - // `configResolved`, where it is always set - but note that nothing here may *await* the SvelteKit - // config from a `config` hook, see the note in `kitConfig.ts`. - let sourcemapSettingAlreadySet = false; - const filesToDeleteAfterUploadConfigPlugin: Plugin = { name: 'sentry-sveltekit-files-to-delete-after-upload-setting-plugin', apply: 'build', // only apply this plugin at build time - config: (config: UserConfig) => { - sourcemapSettingAlreadySet = typeof config.build?.sourcemap !== 'undefined'; - return config; - }, configResolved: async () => { // Resolved here (not in `closeBundle`) so the adapter is invoked before the build writes its // output - see the note on the adapter output dir in `sentrySvelteKit()`. @@ -165,6 +160,86 @@ export async function makeCustomSentryVitePlugins( }, }; + let sourceMapsUploaded = false; + + // We need to start uploading source maps later than in the original plugin + // because SvelteKit is invoking the adapter after its builds. + // This means that we need to wait until the adapter is done before we start uploading. + const uploadSourceMaps = async (): Promise => { + sourceMapsUploaded = true; + + const outDir = path.resolve(process.cwd(), await getAdapterOutputDir()); + // eslint-disable-next-line no-console + debug && console.log('[Source Maps Plugin] Looking up source maps in', outDir); + + const jsFiles = getFiles(outDir).filter(file => file.endsWith('.js')); + // eslint-disable-next-line no-console + debug && console.log('[Source Maps Plugin] Flattening source maps'); + + // @ts-expect-error - we're using dynamic import here and TS complains about that. It works though. + const sorcery = await import('sorcery'); + + for (const file of jsFiles) { + try { + await (sorcery as Sorcery).load(file).then(async chain => { + if (!chain) { + // We end up here, if we don't have a source map for the file. + // This is fine, as we're not interested in files w/o source maps. + return; + } + // This flattens the source map + await chain.apply(); + // Write it back to the original file + await chain.write(); + }); + } catch (e) { + // Sometimes sorcery fails to flatten the source map. While this isn't ideal, it seems to be mostly + // happening in Kit-internal files which is fine as they're not in-app. + // This mostly happens when sorcery tries to resolve a source map while flattening that doesn't exist. + const isKnownError = e instanceof Error && e.message.includes('ENOENT: no such file or directory, open'); + if (debug && !isKnownError) { + // eslint-disable-next-line no-console + console.error('[Source Maps Plugin] error while flattening', file, e); + } + } + + // We need to remove the query string from the source map files that our auto-instrument plugin added + // to proxy the load functions during building. + const mapFile = `${file}.map`; + try { + const mapContent = (await fs.promises.readFile(mapFile, 'utf-8')).toString(); + const cleanedMapContent = mapContent.replace( + // oxlint-disable-next-line sdk/no-regexp-constructor -- no user input + escaped anyway + new RegExp(escapeStringForRegex(WRAPPED_MODULE_SUFFIX), 'gm'), + '', + ); + await fs.promises.writeFile(mapFile, cleanedMapContent); + } catch { + // Map file doesn't exist, nothing to clean + } + } + + try { + // Call the original plugin's writeBundle to upload source maps, manage releases, and delete files. + // We pass in the `outDir` we determined earlier as output options. + if (typeof originalWriteBundle === 'function') { + // @ts-expect-error - calling writeBundle with only outputOptions, without the full bundle arg + await originalWriteBundle({ dir: outDir }); + } + } catch { + // eslint-disable-next-line no-console + console.warn('[Source Maps Plugin] Failed to upload source maps!'); + // eslint-disable-next-line no-console + console.log( + '[Source Maps Plugin] Please make sure you specified a valid Sentry auth token, as well as your org and project slugs.', + ); + // eslint-disable-next-line no-console + console.log( + '[Source Maps Plugin] Further information: https://github.com/getsentry/sentry-javascript/blob/develop/packages/sveltekit/README.md#uploading-source-maps', + ); + } + }; + const customDebugIdUploadPlugin: Plugin = { name: 'sentry-sveltekit-debug-id-upload-plugin', apply: 'build', // only apply this plugin at build time @@ -179,84 +254,24 @@ export async function makeCustomSentryVitePlugins( } }, - // We need to start uploading source maps later than in the original plugin - // because SvelteKit is invoking the adapter at closeBundle. - // This means that we need to wait until the adapter is done before we start uploading. + // SvelteKit 2 invokes the adapter in `closeBundle` of the SSR build. closeBundle: async () => { if (!isSSRBuild) { return; } + await uploadSourceMaps(); + }, - const outDir = path.resolve(process.cwd(), await getAdapterOutputDir()); - // eslint-disable-next-line no-console - debug && console.log('[Source Maps Plugin] Looking up source maps in', outDir); - - const jsFiles = getFiles(outDir).filter(file => file.endsWith('.js')); - // eslint-disable-next-line no-console - debug && console.log('[Source Maps Plugin] Flattening source maps'); - - // @ts-expect-error - we're using dynamic import here and TS complains about that. It works though. - const sorcery = await import('sorcery'); - - for (const file of jsFiles) { - try { - await (sorcery as Sorcery).load(file).then(async chain => { - if (!chain) { - // We end up here, if we don't have a source map for the file. - // This is fine, as we're not interested in files w/o source maps. - return; - } - // This flattens the source map - await chain.apply(); - // Write it back to the original file - await chain.write(); - }); - } catch (e) { - // Sometimes sorcery fails to flatten the source map. While this isn't ideal, it seems to be mostly - // happening in Kit-internal files which is fine as they're not in-app. - // This mostly happens when sorcery tries to resolve a source map while flattening that doesn't exist. - const isKnownError = e instanceof Error && e.message.includes('ENOENT: no such file or directory, open'); - if (debug && !isKnownError) { - // eslint-disable-next-line no-console - console.error('[Source Maps Plugin] error while flattening', file, e); - } - } - - // We need to remove the query string from the source map files that our auto-instrument plugin added - // to proxy the load functions during building. - const mapFile = `${file}.map`; - try { - const mapContent = (await fs.promises.readFile(mapFile, 'utf-8')).toString(); - const cleanedMapContent = mapContent.replace( - // oxlint-disable-next-line sdk/no-regexp-constructor -- no user input + escaped anyway - new RegExp(escapeStringForRegex(WRAPPED_MODULE_SUFFIX), 'gm'), - '', - ); - await fs.promises.writeFile(mapFile, cleanedMapContent); - } catch { - // Map file doesn't exist, nothing to clean - } - } - - try { - // Call the original plugin's writeBundle to upload source maps, manage releases, and delete files. - // We pass in the `outDir` we determined earlier as output options. - if (typeof originalWriteBundle === 'function') { - // @ts-expect-error - calling writeBundle with only outputOptions, without the full bundle arg - await originalWriteBundle({ dir: outDir }); + // SvelteKit 3 invokes the adapter in its post `buildApp` hook, which runs before ours (`enforce: 'post'`). + // With SvelteKit 2, nothing is built yet when this runs, so `closeBundle` uploads instead. + buildApp: { + order: 'post', + handler: async builder => { + if (sourceMapsUploaded || !Object.values(builder.environments).some(environment => environment.isBuilt)) { + return; } - } catch { - // eslint-disable-next-line no-console - console.warn('[Source Maps Plugin] Failed to upload source maps!'); - // eslint-disable-next-line no-console - console.log( - '[Source Maps Plugin] Please make sure you specified a valid Sentry auth token, as well as your org and project slugs.', - ); - // eslint-disable-next-line no-console - console.log( - '[Source Maps Plugin] Further information: https://github.com/getsentry/sentry-javascript/blob/develop/packages/sveltekit/README.md#uploading-source-maps', - ); - } + await uploadSourceMaps(); + }, }, }; diff --git a/packages/sveltekit/test/vite/injectGlobalValues.test.ts b/packages/sveltekit/test/vite/injectGlobalValues.test.ts index 10b81dadf3fc..4e4226e5368b 100644 --- a/packages/sveltekit/test/vite/injectGlobalValues.test.ts +++ b/packages/sveltekit/test/vite/injectGlobalValues.test.ts @@ -145,6 +145,9 @@ describe('adapter output dir resolution', () => { ], }); + const sourceMapSettingPlugin = plugins.find( + plugin => plugin.name === 'sentry-sveltekit-update-source-map-setting-plugin', + )!; const filesToDeletePlugin = plugins.find( plugin => plugin.name === 'sentry-sveltekit-files-to-delete-after-upload-setting-plugin', )!; @@ -153,7 +156,7 @@ describe('adapter output dir resolution', () => { // This takes the branch that leaves `filesToDeleteAfterUpload` untouched - the adapter still // has to be resolved by `configResolved`, not later in `closeBundle` // @ts-expect-error these hooks exist and are callable - filesToDeletePlugin.config({ build: { sourcemap: true } }); + await sourceMapSettingPlugin.config({ build: { sourcemap: true } }); // @ts-expect-error these hooks exist and are callable await filesToDeletePlugin.configResolved(); diff --git a/packages/sveltekit/test/vite/sourceMaps.test.ts b/packages/sveltekit/test/vite/sourceMaps.test.ts index d12ee0fdf8f5..d4f2d3c06db8 100644 --- a/packages/sveltekit/test/vite/sourceMaps.test.ts +++ b/packages/sveltekit/test/vite/sourceMaps.test.ts @@ -188,6 +188,61 @@ describe('makeCustomSentryVitePlugins()', () => { await plugin.closeBundle(); expect(mockedMainPlugin.writeBundle).not.toHaveBeenCalled(); }); + + it('uploads source maps in the post `buildApp` hook when there is no separate SSR build (SvelteKit 3)', async () => { + const plugin = await getSentryViteSubPlugin('sentry-sveltekit-debug-id-upload-plugin'); + + // @ts-expect-error this function exists! + plugin.configResolved({ build: { ssr: false } }); + // @ts-expect-error this function exists! + await plugin.closeBundle(); + expect(mockedMainPlugin.writeBundle).not.toHaveBeenCalled(); + + expect(plugin?.buildApp).toEqual({ order: 'post', handler: expect.any(Function) }); + // @ts-expect-error this function exists! + await plugin.buildApp.handler({ environments: { ssr: { isBuilt: true }, client: { isBuilt: true } } }); + expect(mockedMainPlugin.writeBundle).toHaveBeenCalledTimes(1); + }); + + it("doesn't upload source maps in the post `buildApp` hook before any environment was built (SvelteKit 2 on Vite 7+)", async () => { + const plugin = await getSentryViteSubPlugin('sentry-sveltekit-debug-id-upload-plugin'); + + // @ts-expect-error this function exists! + await plugin.buildApp.handler({ environments: { ssr: { isBuilt: false } } }); + expect(mockedMainPlugin.writeBundle).not.toHaveBeenCalled(); + + // @ts-expect-error this function exists! + plugin.configResolved({ build: { ssr: true } }); + // @ts-expect-error this function exists! + await plugin.closeBundle(); + expect(mockedMainPlugin.writeBundle).toHaveBeenCalledTimes(1); + }); + + it('uploads source maps only once when both `closeBundle` and `buildApp` run (SvelteKit 2 on Vite 7+)', async () => { + const plugin = await getSentryViteSubPlugin('sentry-sveltekit-debug-id-upload-plugin'); + + // @ts-expect-error this function exists! + plugin.configResolved({ build: { ssr: true } }); + // @ts-expect-error this function exists! + await plugin.closeBundle(); + // @ts-expect-error this function exists! + await plugin.buildApp.handler({ environments: { ssr: { isBuilt: true } } }); + + expect(mockedMainPlugin.writeBundle).toHaveBeenCalledTimes(1); + }); + + it('uploads source maps on every SSR rebuild in watch mode', async () => { + const plugin = await getSentryViteSubPlugin('sentry-sveltekit-debug-id-upload-plugin'); + + // @ts-expect-error this function exists! + plugin.configResolved({ build: { ssr: true } }); + // @ts-expect-error this function exists! + await plugin.closeBundle(); + // @ts-expect-error this function exists! + await plugin.closeBundle(); + + expect(mockedMainPlugin.writeBundle).toHaveBeenCalledTimes(2); + }); }); it('catches errors while uploading source maps', async () => { @@ -200,13 +255,10 @@ describe('makeCustomSentryVitePlugins()', () => { const plugin = await getSentryViteSubPlugin('sentry-sveltekit-debug-id-upload-plugin'); - // @ts-expect-error this function exists! - expect(plugin.closeBundle).not.toThrow(); - // @ts-expect-error this function exists! plugin.configResolved({ build: { ssr: true } }); // @ts-expect-error this function exists! - await plugin.closeBundle(); + await expect(plugin.closeBundle()).resolves.toBeUndefined(); expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to upload source maps')); expect(consoleLogSpy).toHaveBeenCalled(); @@ -348,11 +400,7 @@ describe('deleteFilesAfterUpload', () => { plugin => plugin.name === 'sentry-sveltekit-files-to-delete-after-upload-setting-plugin', )!; - // `config` records whether the user set `build.sourcemap`; `configResolved` resolves the - // setting (it can't be awaited from `config` - see the note in `kitConfig.ts`) - // @ts-expect-error these functions exist! - filesToDeleteAfterUploadSettingPlugin.config(viteConfig); - // @ts-expect-error these functions exist! + // @ts-expect-error this function exists! await filesToDeleteAfterUploadSettingPlugin.configResolved(); await expect(mergedOptions.sourcemaps.filesToDeleteAfterUpload).resolves.toEqual([ @@ -430,11 +478,7 @@ describe('deleteFilesAfterUpload', () => { plugin => plugin.name === 'sentry-sveltekit-files-to-delete-after-upload-setting-plugin', )!; - // `config` records whether the user set `build.sourcemap`; `configResolved` resolves the - // setting (it can't be awaited from `config` - see the note in `kitConfig.ts`) - // @ts-expect-error these functions exist! - filesToDeleteAfterUploadSettingPlugin.config(viteConfig); - // @ts-expect-error these functions exist! + // @ts-expect-error this function exists! await filesToDeleteAfterUploadSettingPlugin.configResolved(); await expect(mergedOptions.sourcemaps.filesToDeleteAfterUpload).resolves.toEqual(