From 1d9d4141c671d16e572756396317900e58dc4649 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:55:05 +0200 Subject: [PATCH 1/2] test(tanstackstart-react): Add source map E2E app Build a TanStack Start app with Nitro against a mock Sentry server and check the upload: the source maps carry the client and server source, their source paths are relative to the project root, every debug ID has an uploaded source map, and no source map stays in the build output. The app is optional in CI because Nitro 3 is in beta. --- .../tanstackstart-react-sourcemaps/.gitignore | 11 ++ .../assert-build.ts | 183 ++++++++++++++++++ .../package.json | 37 ++++ .../src/router.tsx | 4 + .../src/routes/__root.tsx | 20 ++ .../src/routes/index.tsx | 21 ++ .../start-mock-sentry-server.mjs | 3 + .../tsconfig.json | 16 ++ .../vite.config.ts | 21 ++ 9 files changed, 316 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore new file mode 100644 index 000000000000..4187ae735082 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore @@ -0,0 +1,11 @@ +node_modules +.output +.nitro +.tanstack +src/routeTree.gen.ts + +# Mock Sentry server artifacts +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..df57d3303f0b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts @@ -0,0 +1,183 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + findDebugIdsWithConflictingSourcemaps, + findFilesWithForeignSourcemaps, + findFilesWithMultipleDebugIds, + findInjectedDebugIds, + getArtifactBundles, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemapSources, + getSourcemaps, + loadMockServerResults, + startNodeServer, +} from '@sentry-internal/test-utils'; + +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.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 DEBUG_ID_PATTERN = /sentry-dbid-([\da-f-]{36})/gi; + +function readDebugIds(file: string): string[] { + return [...fs.readFileSync(file, 'utf8').matchAll(DEBUG_ID_PATTERN)].map(match => (match[1] as string).toLowerCase()); +} + +function emittedJsFiles(dir: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && /\.m?js$/.test(entry.name) && !entry.parentPath.includes('node_modules')) + .map(entry => path.join(entry.parentPath, entry.name)); +} + +/** In debug mode, the Sentry plugin logs each file it deletes after the upload. */ +const deletedFiles = new Set( + [...fs.readFileSync('.tmp_build_stdout', 'utf8').matchAll(/Deleting asset after upload: (.+)$/gm)].map(match => + path.relative(fs.realpathSync('.'), match[1] as string), + ), +); + +/** A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to upload. */ +function debugIdsOfChunksWithoutSourceMap(dir: string): Set { + return new Set( + emittedJsFiles(dir) + .filter(file => !fs.existsSync(`${file}.map`) && !deletedFiles.has(`${file}.map`)) + .flatMap(readDebugIds), + ); +} + +function sourceMapsIn(dir: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.map')) + .map(entry => path.join(entry.parentPath, entry.name)); +} + +// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob +// characters, like Nitro's `_libs/@tanstack/react-router+[...].mjs`. Remove this when the uploader matches paths literally. +function debugIdsOfFilesWithGlobCharacters(dir: string): Set { + return new Set( + emittedJsFiles(dir) + .filter(file => /[[\]{}()*?]/.test(path.relative(dir, file))) + .flatMap(readDebugIds), + ); +} + +const requests = loadMockServerResults(); + +// --- The upload reached Sentry --- + +assert.ok( + getChunkUploadPosts(requests).some(request => request.bodySize > 0), + 'Expected at least one chunk upload with a non-empty body', +); + +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))); + +// Vite builds the client, and Nitro builds the server again from Vite's SSR output. Each side is pinned to a marker +// only that side's source supplies. +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded source map carrying the client source'); +assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded source map carrying the server source'); + +// --- Source paths match the files in the project --- + +const unresolvedSources = [...new Set(sourcemaps.flatMap(map => getSourcemapSources(map.sourcemap)))].filter( + source => source.startsWith('../') || path.isAbsolute(source) || /\?ts[rs]-/.test(source), +); +assert.deepEqual(unresolvedSources, [], 'Expected every uploaded source path to be relative to the project root'); + +// --- Server source maps reach the original source --- + +// Nitro bundles the SSR output again. Its maps must lead through that output to the file the server function is in. +const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); +const serverFunctionSources = sourcemaps + .filter(map => map.debugId && serverDebugIds.has(map.debugId)) + .filter(map => map.sourcemap.sourcesContent?.some(source => source?.includes(SERVER_MARKER))) + .flatMap(map => getSourcemapSources(map.sourcemap)); +assert.ok( + serverFunctionSources.includes('src/routes/index.tsx'), + `Expected a server source map to resolve to src/routes/index.tsx, got: ${serverFunctionSources.join(', ')}`, +); + +// --- Debug IDs tie the shipped bundles to the uploaded maps --- + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); + +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 exemptDebugIds = new Set([ + ...debugIdsOfChunksWithoutSourceMap(outputDir), + ...debugIdsOfFilesWithGlobCharacters(outputDir), + ]); + 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`); + + // A file of Nitro's output can carry the debug ID of the SSR chunk it was built from. + assert.deepEqual( + findFilesWithMultipleDebugIds({ outputDir }), + [], + `Expected at most one debug ID per file in ${outputDir}`, + ); + assert.deepEqual( + findFilesWithForeignSourcemaps({ outputDir }, sourcemaps), + [], + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + ); + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); +} + +assert.deepEqual( + findDebugIdsWithConflictingSourcemaps(sourcemaps), + [], + 'Expected every uploaded debug ID to resolve to exactly one source map', +); + +// --- Sentry deletes the source maps it enabled after the upload --- + +for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { + assert.deepEqual(sourceMapsIn(outputDir), [], `Expected no source maps in ${outputDir}`); +} + +const clientChunkUrls = emittedJsFiles(CLIENT_OUTPUT).map( + file => `/${path.relative(CLIENT_OUTPUT, file).split(path.sep).join('/')}`, +); +assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); + +const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); + +try { + // Without this, a server that serves no public files at all would pass the 404 checks. + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); + + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that + // stays on the list, and Nitro answers its request with a 500 instead of a 404. + const responses = await Promise.all( + clientChunkUrls.map(async chunkUrl => ({ + mapUrl: `${chunkUrl}.map`, + status: (await fetch(`${server.baseUrl}${chunkUrl}.map`)).status, + })), + ); + assert.deepEqual( + responses.filter(({ status }) => status !== 404).map(({ mapUrl, status }) => `${mapUrl} → ${status}`), + [], + 'Expected the server to answer each client source map URL with 404', + ); +} finally { + await server.close(); +} + +console.log('All source map assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json new file mode 100644 index 000000000000..e03631c63231 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json @@ -0,0 +1,37 @@ +{ + "name": "tanstackstart-react-sourcemaps", + "description": "E2E test app asserting what the TanStack Start SDK uploads to Sentry when Nitro builds the server.", + "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 .output", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts" + }, + "dependencies": { + "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", + "@tanstack/react-router": "1.170.44", + "@tanstack/react-start": "1.169.1", + "nitro": "latest", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^22.20.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react-swc": "^4.2.0", + "ts-node": "10.9.1", + "typescript": "^5.9.0", + "vite": "^8.3.0" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx new file mode 100644 index 000000000000..741d1aade5d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/router.tsx @@ -0,0 +1,4 @@ +import { createRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; + +export const getRouter = () => createRouter({ routeTree }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx new file mode 100644 index 000000000000..09432c62c40d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/__root.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'; + +export const Route = createRootRoute({ + component: RootComponent, +}); + +function RootComponent(): ReactNode { + return ( + + + + + + + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx new file mode 100644 index 000000000000..51012573f928 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/src/routes/index.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; + +// SOURCEMAP_MARKER_CLIENT — a comment, so it is stripped from the emitted bundles but survives in `sourcesContent`. +// `assert-build.ts` uses the markers to check that the original source reached the uploaded maps. +const throwServerError = createServerFn().handler(async () => { + // SOURCEMAP_MARKER_SERVER + throw new Error('Server function error from the TanStack Start source map E2E app'); +}); + +export const Route = createFileRoute('/')({ + component: Home, +}); + +function Home() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..1680a6360165 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org' }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..02faa5762420 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", ".output"] +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts new file mode 100644 index 000000000000..b43b2441b453 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts @@ -0,0 +1,21 @@ +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react-swc'; +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + tanstackStart(), + nitro(), + viteReact(), + sentryTanstackStart({ + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + debug: true, + }), + ], +}); From 9917d320f3bbd9e0ffe7e5b56186cf95e8c55c11 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:57:54 +0200 Subject: [PATCH 2/2] test(tanstackstart-react): Check source map markers per output side Both markers are in the same route file, which client and server source maps embed completely, so one uploaded map could pass both checks. Check each marker only in the source maps of the files in its output directory, matched by debug ID. --- .../assert-build.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts index df57d3303f0b..2aa44e66f20e 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts @@ -13,6 +13,7 @@ import { getSourcemaps, loadMockServerResults, startNodeServer, + type SourcemapEntry, } from '@sentry-internal/test-utils'; const CLIENT_OUTPUT = path.join('.output', 'public'); @@ -81,13 +82,22 @@ 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))); -// Vite builds the client, and Nitro builds the server again from Vite's SSR output. Each side is pinned to a marker -// only that side's source supplies. -assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded source map carrying the client source'); -assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded source map carrying the server source'); +/** The uploaded source maps of the files in an output directory, matched by debug ID. */ +function sourceMapsOf(outputDir: string): SourcemapEntry[] { + const debugIds = new Set(findInjectedDebugIds({ outputDir })); + return sourcemaps.filter(map => map.debugId && debugIds.has(map.debugId)); +} + +const clientSourceMaps = sourceMapsOf(CLIENT_OUTPUT); +const serverSourceMaps = sourceMapsOf(SERVER_OUTPUT); +const containsMarker = (maps: SourcemapEntry[], marker: string): boolean => + maps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +// Both markers are in the same route file, which client and server source maps embed completely. So each marker only +// proves the upload of its side if the check only looks at the source maps of that side. +assert.ok(containsMarker(clientSourceMaps, CLIENT_MARKER), 'Expected a client source map carrying the client source'); +assert.ok(containsMarker(serverSourceMaps, SERVER_MARKER), 'Expected a server source map carrying the server source'); // --- Source paths match the files in the project --- @@ -99,9 +109,7 @@ assert.deepEqual(unresolvedSources, [], 'Expected every uploaded source path to // --- Server source maps reach the original source --- // Nitro bundles the SSR output again. Its maps must lead through that output to the file the server function is in. -const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); -const serverFunctionSources = sourcemaps - .filter(map => map.debugId && serverDebugIds.has(map.debugId)) +const serverFunctionSources = serverSourceMaps .filter(map => map.sourcemap.sourcesContent?.some(source => source?.includes(SERVER_MARKER))) .flatMap(map => getSourcemapSources(map.sourcemap)); assert.ok(