diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts index aeb76ee19e32..ad96fabd3f14 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts @@ -1,97 +1,23 @@ -import * as assert from 'node:assert/strict'; -import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - findInjectedDebugIds, - findSourceMapFiles, - getArtifactBundles, - getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, - getSourcemaps, - loadMockServerResults, -} from '@sentry-internal/test-utils'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; const isStatic = process.env.E2E_ASTRO_OUTPUT === 'static'; const BUILD_OUTPUT = 'dist'; const OUTPUT_DIRS = isStatic ? [BUILD_OUTPUT] : [path.join(BUILD_OUTPUT, 'client'), path.join(BUILD_OUTPUT, 'server')]; -/** All 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'; -const PRERENDER_MARKER = 'SOURCEMAP_MARKER_PRERENDER'; - -/** Written by the build, see `recordChunksWithoutSourceMap` in `astro.config.mjs`. */ -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( - 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'); -if (!isStatic) { - assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source'); -} -// Astro 7 writes the prerender build to a hidden `.prerender` folder, which a plain `dist/**/*` glob skips -assert.ok(containsMarker(PRERENDER_MARKER), 'Expected an uploaded sourcemap carrying the prerendered page source'); - -const buildOutput = ['.tmp_build_stdout', '.tmp_build_stderr'] - .filter(file => fs.existsSync(file)) - .map(file => fs.readFileSync(file, 'utf8')) - .join('\n'); -assert.ok( - !buildOutput.includes("Didn't find any matching sources for debug ID upload"), - 'Expected every Vite build to find sources for the debug ID upload', -); - -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 OUTPUT_DIRS) { - 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}`); +assertSourceMapUpload({ + outputDirs: OUTPUT_DIRS, + // The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. + strippedDirs: [BUILD_OUTPUT], + markers: { + 'client source': 'SOURCEMAP_MARKER_CLIENT', + ...(isStatic ? {} : { 'server source': 'SOURCEMAP_MARKER_SERVER' }), + // Astro 7 writes the prerender build to a hidden `.prerender` folder, which a plain `dist/**/*` glob skips + 'prerendered page source': 'SOURCEMAP_MARKER_PRERENDER', + }, + // Written by the build, see `recordChunksWithoutSourceMap` in `astro.config.mjs`. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', +}); console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts index 8a9ee339b1e6..782b54789526 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -2,19 +2,15 @@ import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; import { + assertSourceMapUpload, findDebugIdsWithConflictingSourcemaps, findFilesWithForeignSourcemaps, findFilesWithMultipleDebugIds, findInjectedDebugIds, findSourceMapFiles, findSourceMappingUrlComments, - getArtifactBundles, getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, getSourcemapSources, - getSourcemaps, - loadMockServerResults, startNodeServer, } from '@sentry-internal/test-utils'; @@ -86,20 +82,22 @@ console.log( `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, ); -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(''); - -// --- The upload reached Sentry --- +const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ + outputDirs: isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT], + // The client output is checked below, because the "kept" variant expects its maps to stay and some presets + // write the server into it. + strippedDirs: [], + // Vite builds the client and Nitro's Rollup builds the server. Counting bundles would still pass + // with either plugin dropped, so each side is pinned to a marker only that side's source supplies. + // Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the + // only reason the server marker survives into `sourcesContent`. + markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, + // Nitro copies Vite's client output to `CLIENT_OUTPUT`, and Sentry deletes no maps in Vite's own directory. + debugIdsWithoutSourceMap: debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_OUTPUT), +}); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', -); +// --- Nuxt-specific checks on the upload --- // Sentry rejects a release that names no project with a 400, and the mock server accepts anything, // so assert on the body rather than on the call alone. @@ -129,79 +127,23 @@ assert.ok( 'Expected a GET for the chunk-upload options', ); -const chunkPosts = getChunkUploadPosts(requests); -assert.ok( - chunkPosts.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'); +for (const request of getAssembleRequests(requests)) { assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); for (const chunk of request.assembleBody?.chunks ?? []) { assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); } } -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); -console.log(`Found ${bundles.length} artifact bundle(s)\n`); - -// --- Both bundlers uploaded --- - -const sourcemaps = getSourcemaps(bundles); -assert.ok( - sourcemaps.some(map => map.sourcemap.mappings?.length), - 'Expected at least one uploaded sourcemap with non-empty mappings', -); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -// Vite builds the client and Nitro's Rollup builds the server. Counting bundles would still pass -// with either plugin dropped, so each side is pinned to a marker only that side's source supplies. -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); -// Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the -// only reason this marker survives into `sourcesContent`. -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); - // `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( source => source.startsWith('../') || path.isAbsolute(source), ); assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); -// --- Debug IDs tie the shipped bundle to the uploaded map --- - -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'); - const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); -// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting -// the upload alone cannot show this. -const debugIdsWithoutSourceMap = debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_OUTPUT); - for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { - const injectedDebugIds = findInjectedDebugIds({ outputDir }); - assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - // The exemption for chunks without a source map must not let a build without any uploaded map pass. - assert.ok( - injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), - `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, - ); - - 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`); - - console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`); - // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts index 0a86f401f470..8c64d688098f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts @@ -2,18 +2,15 @@ import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; import { + assertSourceMapUpload, + findDebugIdsInFilesWithGlobCharacters, findDebugIdsWithConflictingSourcemaps, findFilesWithForeignSourcemaps, findFilesWithMultipleDebugIds, findInjectedDebugIds, findSourceMapFiles, findSourceMappingUrlComments, - getArtifactBundles, getAssembleRequests, - getChunkUploadPosts, - getDebugIdPairs, - getSourcemaps, - loadMockServerResults, startNodeServer, } from '@sentry-internal/test-utils'; @@ -61,43 +58,39 @@ function debugIdsOfChunksWithoutSourceMap(dir: string): Set { return debugIds; } -// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob -// characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally. -function debugIdsOfFilesWithGlobCharacters(dir: string): Set { - const debugIds = new Set(); - - for (const file of filesContaining(dir, 'sentry-dbid-')) { - if (!/[[\]{}()*?]/.test(path.relative(dir, file))) { - continue; - } - - for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { - debugIds.add((match[1] as string).toLowerCase()); - } - } - - return debugIds; -} - console.log( `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, ); -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(''); - -// --- The upload reached Sentry --- - -assert.ok( - requests.some(r => r.authorization.includes('fake-auth-token')), - 'Expected requests with the configured auth token', +// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk +// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the +// maps are kept. The upload is the same in all variants. +const outputDirsWithSourceMaps = [ + ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), + ...(isStaticBuild ? [] : [SERVER_OUTPUT]), +]; +const debugIdsWithoutSourceMap = new Set( + outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), ); +const debugIdsSkippedByUploader = isStaticBuild + ? [] + : findDebugIdsInFilesWithGlobCharacters({ outputDir: SERVER_OUTPUT }); + +const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ + outputDirs: outputDirsWithSourceMaps, + // The client output is checked below, because the "kept" variant expects its maps to stay. + strippedDirs: [], + // Vite builds the client and Nitro builds the server. Counting bundles would still pass with either + // plugin dropped, so each side is pinned to a marker only that side's source supplies. + // Nitro 3 deletes `sourcesContent` from the server maps unless `experimental.sourcemapMinify` is `false`. The module + // sets it to `false`, which is the only reason the server marker survives into `sourcesContent`. + markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, + debugIdsWithoutSourceMap: [...debugIdsWithoutSourceMap, ...debugIdsSkippedByUploader], +}); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); + +// --- Nuxt-specific checks on the upload --- // Sentry rejects a release that names no project with a 400, and the mock server accepts anything, // so assert on the body rather than on the call alone. @@ -127,92 +120,34 @@ assert.ok( 'Expected a GET for the chunk-upload options', ); -const chunkPosts = getChunkUploadPosts(requests); -assert.ok( - chunkPosts.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'); +for (const request of getAssembleRequests(requests)) { assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); for (const chunk of request.assembleBody?.chunks ?? []) { assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); } } -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); -console.log(`Found ${bundles.length} artifact bundle(s)\n`); - -// --- Both bundlers uploaded --- - -const sourcemaps = getSourcemaps(bundles); -assert.ok( - sourcemaps.some(map => map.sourcemap.mappings?.length), - 'Expected at least one uploaded sourcemap with non-empty mappings', -); - -const containsMarker = (marker: string): boolean => - sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); - -// Vite builds the client and Nitro builds the server. Counting bundles would still pass with either -// plugin dropped, so each side is pinned to a marker only that side's source supplies. -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); -// Nitro 3 deletes `sourcesContent` from the server maps unless `experimental.sourcemapMinify` is `false`. The module -// sets it to `false`, which is the only reason this marker survives into `sourcesContent`. -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); - // `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( source => source.startsWith('../') || path.isAbsolute(source), ); assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); -// --- Debug IDs tie the shipped bundle to the uploaded map --- - -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'); - const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); -// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting -// the upload alone cannot show this. -// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk -// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the -// maps are kept. The upload is the same in all variants. -const outputDirsWithSourceMaps = [ - ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), - ...(isStaticBuild ? [] : [SERVER_OUTPUT]), -]; -const debugIdsWithoutSourceMap = new Set( - outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), -); -const debugIdsSkippedByUploader = isStaticBuild ? new Set() : debugIdsOfFilesWithGlobCharacters(SERVER_OUTPUT); +// --- Every output directory ships at least one uploaded debug ID --- +// The shared assertions cover the directories that keep their maps. A directory whose maps are deleted +// must still ship a debug ID with an uploaded map, or a build that uploads nothing would pass. for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { const injectedDebugIds = findInjectedDebugIds({ outputDir }); assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - // The exemption for chunks without a source map must not let a build without any uploaded map pass. assert.ok( injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, ); - if (outputDirsWithSourceMaps.includes(outputDir)) { - const unuploaded = injectedDebugIds.filter( - debugId => - !uploadedDebugIds.has(debugId) && - !debugIdsWithoutSourceMap.has(debugId) && - !debugIdsSkippedByUploader.has(debugId), - ); - assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); - } - console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx index 9a290b88c9a4..1f65a0f9be7b 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT import { RemixBrowser } from '@remix-run/react'; import { StrictMode, startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts new file mode 100644 index 000000000000..02c2ba99b548 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/lib/greeting.server.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_SERVER +export function getServerGreeting(): string { + return 'Hello from the server'; +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx index ea184ec80a18..f2c03a8c2682 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx @@ -1,3 +1,12 @@ +import { json } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; +import { getServerGreeting } from '../lib/greeting.server'; + +export function loader() { + return json({ greeting: getServerGreeting() }); +} + export default function Index() { - return

Sourcemaps test app

; + const { greeting } = useLoaderData(); + return

Sourcemaps test app: {greeting}

; } diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts index 7f4ae319867b..64ee74111b0f 100644 --- a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts @@ -1,10 +1,12 @@ import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; -import { getArtifactBundles, getDebugIdPairs, getSourcemaps, loadMockServerResults } from '@sentry-internal/test-utils'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; const BUILD_DIR = 'build'; -const CLIENT_ASSETS_DIR = 'build/client/assets'; +const CLIENT_OUTPUT = path.join(BUILD_DIR, 'client'); +const SERVER_OUTPUT = path.join(BUILD_DIR, 'server'); +const CLIENT_ASSETS_DIR = path.join(CLIENT_OUTPUT, 'assets'); // Both injectors write this assignment, so counting it per file counts injections regardless of // which one ran. Matching only the bundler plugin's trailing `_sentryDebugIdIdentifier` would miss @@ -12,117 +14,38 @@ const CLIENT_ASSETS_DIR = 'build/client/assets'; const DEBUG_ID_ASSIGNMENT = /_sentryDebugIds\[[^\]]+\]\s*=\s*"([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})"/gi; -function getClientChunks(): string[] { - assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`); +assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`); - return fs - .readdirSync(CLIENT_ASSETS_DIR) - .filter(file => file.endsWith('.js')) - .map(file => path.join(CLIENT_ASSETS_DIR, file)); -} - -const chunks = getClientChunks(); +const chunks = fs + .readdirSync(CLIENT_ASSETS_DIR) + .filter(file => file.endsWith('.js')) + .map(file => path.join(CLIENT_ASSETS_DIR, file)); assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASSETS_DIR}`); -// 1. No chunk carries more than one debug ID. +// No chunk carries more than one debug ID. // // Two injections per chunk is the failure mode of // https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at runtime, // `applyDebugIds` flattens them to a single filename, and the last one wins. For Remix that happens // when an app keeps `npx @sentry/remix --upload-sourcemaps` in its build script alongside the Vite // plugin, so the CLI injects a second ID over the plugin's. -const injectedDebugIds = new Map(); - for (const chunk of chunks) { - const code = fs.readFileSync(chunk, 'utf-8'); - const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); + const ids = [...fs.readFileSync(chunk, 'utf-8').matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); assert.ok( ids.length <= 1, `Expected at most one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}.`, ); - - if (ids.length === 1) { - injectedDebugIds.set(chunk, ids[0] as string); - } } - -console.log(`no client chunk carries more than one debug ID (${injectedDebugIds.size}/${chunks.length} carry one)\n`); - -const requests = loadMockServerResults(); -const bundles = getArtifactBundles(requests); -assert.ok(bundles.length > 0, 'Expected at least one uploaded artifact bundle'); - -// 2. Source maps with real content reached Sentry. -// -// Asserting on the upload rather than on disk, because deleting the maps after a successful upload -// is the intended behaviour - the plugin defaults `filesToDeleteAfterUpload` when the app does not -// configure source maps itself. -const uploadedSourcemaps = getSourcemaps(bundles); -assert.ok(uploadedSourcemaps.length > 0, 'Expected at least one source map in the uploaded artifact bundles'); -assert.ok( - uploadedSourcemaps.some(entry => (entry.sourcemap.mappings?.length ?? 0) > 0), - 'Expected at least one uploaded source map with non-empty mappings', -); -console.log(`${uploadedSourcemaps.length} source map(s) uploaded with content`); - -// 3. The debug IDs that shipped are the ones that were uploaded. -// -// This is what actually breaks un-minification: a chunk can carry a perfectly valid debug ID that -// has no artifact bundle behind it. -const debugIdPairs = getDebugIdPairs(bundles); -const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase())); -assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source map pair with a debug ID'); - -// The uploaded artifacts are named after the debug ID (`~/-.js`), not after the chunk -// they came from, so the two file name sets never line up. Cross-check the IDs themselves: every -// debug ID that shipped has to have an artifact bundle behind it. -let crossCheckedChunks = 0; - -for (const [chunk, injectedDebugId] of injectedDebugIds) { - assert.ok( - uploadedDebugIds.has(injectedDebugId.toLowerCase()), - `Debug ID ${injectedDebugId} in ${chunk} was never uploaded.\n` + - `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}`, - ); - crossCheckedChunks++; -} - -assert.ok( - crossCheckedChunks > 0, - 'Expected at least one chunk carrying a debug ID to cross-check against the upload.\n' + - `Client chunks: ${JSON.stringify(chunks.map(chunk => path.basename(chunk)))}\n` + - `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}\n` + - `Uploaded JS urls: ${JSON.stringify(debugIdPairs.map(pair => pair.jsUrl))}`, -); -console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`); - -// 4. No source map survived the build. -// -// The plugin defaults `filesToDeleteAfterUpload` when the app configures no source map setting, so -// a leftover `.map` means one of the builds was never cleaned up. Remix runs a client and an SSR -// pass with different `outDir`s, while the deletion glob is held in a promise that settles once - -// this is what catches the second pass being left behind. -function findSourceMaps(dir: string): string[] { - return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { - const entryPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - return findSourceMaps(entryPath); - } - - return entry.name.endsWith('.map') ? [entryPath] : []; - }); -} - -const leftoverSourceMaps = findSourceMaps(BUILD_DIR); -assert.deepEqual( - leftoverSourceMaps, - [], - `Expected every source map to be deleted after upload, found ${leftoverSourceMaps.length}:\n${leftoverSourceMaps.join( - '\n', - )}`, -); -console.log('no source maps left in the build output\n'); - -console.log('All remix source map assertions passed!'); +console.log(`no client chunk carries more than one debug ID (${chunks.length} chunks)\n`); + +// The plugin defaults `filesToDeleteAfterUpload` when the app configures no source map setting. Remix +// runs a client and an SSR pass with different `outDir`s, while the deletion glob is held in a promise +// that settles once, so a leftover `.map` means the second pass was left behind. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + strippedDirs: [BUILD_DIR], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts new file mode 100644 index 000000000000..9cf7afcbe4ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/assert-build.ts @@ -0,0 +1,24 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { assertSourceMapUpload, findDebugIdsInFilesWithGlobCharacters } from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// TODO: The client maps stay in `.output/public`, because the SDK's deletion glob `./**/*.map` skips dot +// folders. Add `CLIENT_OUTPUT` to `strippedDirs` once the SDK deletes them. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + strippedDirs: [SERVER_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, + debugIdsWithoutSourceMap: [ + // Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.ts`. + ...(fs.existsSync('.tmp_debug_ids_without_source_map') + ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) + : []), + ...findDebugIdsInFilesWithGlobCharacters({ outputDir: SERVER_OUTPUT }), + ], +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json index 376bb2ef38fc..a8428951c236 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -4,12 +4,12 @@ "//": "Sentry is initialized from server/plugins/sentry.ts, a Nitro plugin. Build-time instrumentation means there is no `--import` preload and nothing to copy into .output.", "scripts": { "clean": "pnpx rimraf node_modules pnpm-lock.yaml .nitro .output", - "build": "vite build", + "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", "preview": "HOST=localhost PORT=3030 vite preview", "start": "HOST=localhost PORT=3030 node .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "node assert-build.ts && pnpm test:prod" }, "type": "module", "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx index 11087fbb5918..17e38cf08b72 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT // @refresh reload import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx index 276935366318..78f28e36a56c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-server.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_SERVER // @refresh reload import { StartServer, createHandler } from '@solidjs/start/server'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..cce37a5f9daf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer(); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts index d3ffd97997a9..74811201d2cf 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts @@ -2,6 +2,27 @@ import { sentrySolidStart } from '@sentry/solidstart/vite'; import { solidStart } from '@solidjs/start/config'; import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; +import * as fs from 'node:fs'; + +// 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' as const, + enforce: 'post' as const, + writeBundle(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || 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: [ @@ -10,12 +31,15 @@ export default defineConfig({ middleware: './src/middleware.ts', }), sentrySolidStart({ - org: process.env.E2E_TEST_SENTRY_ORG_SLUG, - project: process.env.E2E_TEST_SENTRY_PROJECT, - authToken: process.env.E2E_TEST_AUTH_TOKEN, + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, debug: true, }), // `serverDir` defaults to `false`, which skips plugin scanning entirely. nitro({ serverDir: './server' }), + recordChunksWithoutSourceMap(), ], }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts index 71061cf25d96..336cfbac60a7 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts @@ -2,7 +2,17 @@ import { withSentry } from '@sentry/solidstart'; import { defineConfig } from '@solidjs/start/config'; export default defineConfig( - withSentry({ - middleware: './src/middleware.ts', - }), + withSentry( + { + middleware: './src/middleware.ts', + }, + { + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + debug: true, + }, + ), ); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/assert-build.ts b/dev-packages/e2e-tests/test-applications/solidstart/assert-build.ts new file mode 100644 index 000000000000..fd40cfc4de2a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/assert-build.ts @@ -0,0 +1,16 @@ +import * as path from 'node:path'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// Vinxi writes the server output after the SDK deleted the maps, so `.output/server` keeps Nitro's own maps, +// which carry no mappings. They are not served, so only the client output must be free of maps. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + strippedDirs: [CLIENT_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 5567434a3c0e..eb837f408d61 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -3,13 +3,13 @@ "version": "0.0.0", "scripts": { "clean": "pnpx rimraf node_modules pnpm-lock.yaml .vinxi .output", - "build": "vinxi build", + "build": "node start-mock-sentry-server.mjs & vinxi 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", "dev": "vinxi dev", "preview": "HOST=localhost PORT=3030 vinxi start", "start:import": "HOST=localhost PORT=3030 node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev", + "test:assert": "node assert-build.ts && pnpm test:prod && pnpm test:dev", "test:dev": "TEST_ENV=development playwright test db" }, "type": "module", diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx index 11087fbb5918..17e38cf08b72 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT // @refresh reload import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx index 276935366318..78f28e36a56c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-server.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_SERVER // @refresh reload import { StartServer, createHandler } from '@solidjs/start/server'; diff --git a/dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..d4bca7c2c259 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/start-mock-sentry-server.mjs @@ -0,0 +1,4 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +// Not a dot-folder: the SDK deletes `.*/**/*.map`, which would remove the maps the server extracts here. +startMockSentryServer({ outputDir: '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 index 697c4437532d..7f70c46b9941 100644 --- 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 @@ -1,74 +1,29 @@ 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'; +import { assertSourceMapUpload, findSourceMapFiles } 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'; +const { requests, sourcemaps } = assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT, SERVER_OUTPUT], + // The server output keeps `sourceMappingURL` comments that point at the deleted maps. Nothing serves + // it, so only the client output must be free of them. + strippedDirs: [CLIENT_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT', 'server source': 'SOURCEMAP_MARKER_SERVER' }, + // Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.js`. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', +}); -/** 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', -); +// 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.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 @@ -76,25 +31,4 @@ const wrappedSources = sourcemaps .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/tanstackstart-react/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/assert-build.ts new file mode 100644 index 000000000000..422a947f537a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/assert-build.ts @@ -0,0 +1,16 @@ +import * as path from 'node:path'; +import { assertSourceMapUpload } from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// The SDK enables them for the client build only: the server build in `.output/server` ships debug IDs but +// no source maps, so nothing of it reaches Sentry. Cover the server here once the SDK uploads its maps. +assertSourceMapUpload({ + outputDirs: [CLIENT_OUTPUT], + markers: { 'client source': 'SOURCEMAP_MARKER_CLIENT' }, + // Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.ts`. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', +}); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index ed739895d0d9..d2b3c156b829 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "type": "module", "scripts": { - "build": "vite build && cp instrument.server.mjs .output/server", + "build": "node start-mock-sentry-server.mjs & (vite build && cp instrument.server.mjs .output/server) > .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", "dev": "vite dev", "start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test": "playwright test", @@ -17,7 +17,7 @@ "test:build:hoisted": "pnpm install --config.node-linker=hoisted && pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", - "test:assert": "pnpm test:assert:proxy", + "test:assert": "node assert-build.ts && pnpm test:assert:proxy", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx index fc489cd7bdeb..780672f38299 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/client.tsx @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT // Imported first so Sentry.init() runs before any other import's side effects. import './instrument.client'; import './early-side-effect'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts index b10a3bc1e37b..f7376fa305e2 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts @@ -1,3 +1,4 @@ +// SOURCEMAP_MARKER_SERVER import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; import handler, { createServerEntry } from '@tanstack/react-start/server-entry'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..cce37a5f9daf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer(); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 2385d8aa5e93..f78b8666d702 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -1,10 +1,31 @@ import { defineConfig } from 'vite'; +import * as fs from 'node:fs'; import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; import { sentryTanstackStart } from '@sentry/tanstackstart-react/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' as const, + enforce: 'post' as const, + writeBundle(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || 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`); + } + } + }, + }; +} + const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off'; const useManagedTunnelRoute = tunnelRouteMode !== 'off'; const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; @@ -50,11 +71,14 @@ export default defineConfig({ // react's vite plugin must come after start's vite plugin viteReact(), sentryTanstackStart({ - org: process.env.E2E_TEST_SENTRY_ORG_SLUG, - project: process.env.E2E_TEST_SENTRY_PROJECT, - authToken: process.env.E2E_TEST_AUTH_TOKEN, + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, debug: true, tunnelRoute, }), + recordChunksWithoutSourceMap(), ], }); diff --git a/dev-packages/test-utils/src/build-output.ts b/dev-packages/test-utils/src/build-output.ts index f42e5dd1bdb0..f174069321ce 100644 --- a/dev-packages/test-utils/src/build-output.ts +++ b/dev-packages/test-utils/src/build-output.ts @@ -37,6 +37,11 @@ function* walkFiles(outputDir: string, extensions?: string[]): Generator if (extensions && !extensions.includes(path.extname(entry.name))) { continue; } + // Dependencies a server build traces into its output are not the app's bundles. Some ship debug + // IDs of their own, for example the `sentry` package. + if (path.relative(outputDir, entry.parentPath).split(path.sep).includes('node_modules')) { + continue; + } yield path.join(entry.parentPath, entry.name); } } @@ -160,6 +165,28 @@ export function findInjectedDebugIds({ outputDir, extensions = JS_EXTENSIONS }: return [...debugIds]; } +/** + * Returns the debug IDs of emitted files whose path has glob characters, such as Nitro 3's + * `_libs/_sentry/node+[...].mjs`. + * + * TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips these files. + * Remove this when the uploader matches paths literally. + */ +export function findDebugIdsInFilesWithGlobCharacters({ + outputDir, + extensions = JS_EXTENSIONS, +}: OutputScanOptions): string[] { + const debugIds = new Set(); + + for (const file of walkFiles(outputDir, extensions)) { + if (/[[\]{}()*?]/.test(path.relative(outputDir, file))) { + readDebugIds(file).forEach(debugId => debugIds.add(debugId)); + } + } + + return [...debugIds]; +} + /** * Returns ` → ` for each file with more than one debug ID. * The uploader reads the first ID, but the SDK can report any of them. diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index bb70d9382f9e..6adabe0f44fb 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -23,6 +23,7 @@ export { findSourceMapFiles, findSourceMappingUrlComments, findInjectedDebugIds, + findDebugIdsInFilesWithGlobCharacters, findFilesWithMultipleDebugIds, findFilesWithForeignSourcemaps, bundleReferencesModule, @@ -44,6 +45,8 @@ export type { NodeServerOptions, NodeServer } from './node-server'; export { startMockSentryServer } from './mock-sentry-server'; export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; export * from './sourcemap-upload-utils'; +export { assertSourceMapUpload } from './sourcemap-upload-assertions'; +export type { SourceMapUploadAssertionOptions, SourceMapUploadAssertionResult } from './sourcemap-upload-assertions'; export { CDPClient } from './cdp-client'; export type { CDPClientOptions } from './cdp-client'; diff --git a/dev-packages/test-utils/src/sourcemap-upload-assertions.ts b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts new file mode 100644 index 000000000000..8dc16efeb4ee --- /dev/null +++ b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts @@ -0,0 +1,186 @@ +/* oxlint-disable no-console -- the log is the context for a failed assertion */ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import { findInjectedDebugIds, findSourceMapFiles, findSourceMappingUrlComments } from './build-output'; +import type { ArtifactBundleData, RequestRecord, SourcemapEntry } from './sourcemap-upload-utils'; +import { + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, +} from './sourcemap-upload-utils'; + +export interface SourceMapUploadAssertionOptions { + /** Directories with emitted JavaScript. Every debug ID injected there must have an uploaded source map. */ + outputDirs: string[]; + /** Directories that must hold no `.map` file and no `sourceMappingURL` comment. Defaults to `outputDirs`. */ + strippedDirs?: string[]; + /** + * Strings that an uploaded source map must carry in its `sourcesContent`, keyed by what they prove, for example + * `{ 'client source': 'SOURCEMAP_MARKER_CLIENT' }`. Put the marker in a comment, so the bundler strips it from + * the code but keeps it in the map. + */ + markers: Record; + /** Defaults to `fake-auth-token`. */ + authToken?: string; + /** Defaults to `test-project`. */ + project?: string; + /** Defaults to `test-release`. */ + release?: string; + /** + * Debug IDs of chunks without a source map, for which no upload is expected. Either the IDs themselves or a + * file with one ID per line. Defaults to `.tmp_debug_ids_without_source_map`, which a build plugin writes + * while the maps still exist, before the upload deletes them. A missing file means no exemptions. + */ + debugIdsWithoutSourceMap?: Iterable | string; + /** Build logs that must not carry the uploader's "no matching sources" warning. Missing files are skipped. */ + buildLogFiles?: string[]; + /** Defaults to the requests the mock Sentry server recorded. */ + requests?: RequestRecord[]; +} + +export interface SourceMapUploadAssertionResult { + requests: RequestRecord[]; + bundles: ArtifactBundleData[]; + sourcemaps: SourcemapEntry[]; + /** Lowercased debug IDs that have an uploaded JS and source map pair. */ + uploadedDebugIds: Set; +} + +function readDebugIdList(source: Iterable | string | undefined): Set { + if (source !== undefined && typeof source !== 'string') { + return new Set([...source].map(debugId => debugId.toLowerCase())); + } + + const file = source ?? '.tmp_debug_ids_without_source_map'; + if (!fs.existsSync(file)) { + return new Set(); + } + + return new Set( + fs + .readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .map(debugId => debugId.toLowerCase()), + ); +} + +/** + * Asserts that a build uploaded its source maps to the mock Sentry server and stripped them from its output. + * + * This is the contract every framework SDK keeps when it manages source maps for the user: the maps reach + * Sentry with the debug IDs the shipped bundles carry, the shipped bundles reference no map, and no map + * is left where it would be served. + * + * Returns what it read, so an app can add assertions of its own on top. + */ +export function assertSourceMapUpload(options: SourceMapUploadAssertionOptions): SourceMapUploadAssertionResult { + const { + outputDirs, + strippedDirs = outputDirs, + markers, + authToken = 'fake-auth-token', + project = 'test-project', + release = 'test-release', + buildLogFiles = ['.tmp_build_stdout', '.tmp_build_stderr'], + } = options; + + const requests = options.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(''); + + // --- The upload reached Sentry --- + + assert.ok( + requests.some(r => r.authorization.includes(authToken)), + 'Expected requests with the configured auth token', + ); + + 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(project), `Expected assemble request for ${project}`); + assert.equal(request.assembleBody?.version, 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'); + + // --- The uploaded maps carry the app's source --- + + const sourcemaps = getSourcemaps(bundles); + assert.ok( + sourcemaps.some(map => map.sourcemap.mappings?.length), + 'Expected at least one uploaded source map with non-empty mappings', + ); + + for (const [proves, marker] of Object.entries(markers)) { + assert.ok( + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))), + `Expected an uploaded source map carrying the ${proves} (marker ${marker})`, + ); + } + + const buildOutput = buildLogFiles + .filter(file => fs.existsSync(file)) + .map(file => fs.readFileSync(file, 'utf8')) + .join('\n'); + assert.ok( + !buildOutput.includes("Didn't find any matching sources for debug ID upload"), + 'Expected every build to find sources for the debug ID upload', + ); + + // --- Debug IDs tie the shipped bundles to the uploaded maps --- + + const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); + assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/source map pair with matching debug IDs'); + + const exemptDebugIds = readDebugIdList(options.debugIdsWithoutSourceMap); + + for (const outputDir of outputDirs) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + // The exemption for chunks without a source map must not let a build without any uploaded map pass. + assert.ok( + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), + `Expected at least one debug ID in ${outputDir} to have an uploaded source map`, + ); + + const unuploaded = injectedDebugIds.filter( + debugId => !uploadedDebugIds.has(debugId) && !exemptDebugIds.has(debugId), + ); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded source map`); + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`); + } + + // --- Nothing is left where it would be served --- + + for (const strippedDir of strippedDirs) { + // A surviving map hands out the original source to anyone who requests it. + assert.deepEqual(findSourceMapFiles({ outputDir: strippedDir }), [], `Expected no source maps in ${strippedDir}`); + // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. + assert.deepEqual( + findSourceMappingUrlComments({ outputDir: strippedDir }), + [], + `Expected no sourceMappingURL comments in ${strippedDir}`, + ); + + console.log(` ${strippedDir}: free of source maps and sourceMappingURL comments`); + } + console.log(''); + + return { requests, bundles, sourcemaps, uploadedDebugIds }; +}