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..2aa44e66f20e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts @@ -0,0 +1,191 @@ +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, + type SourcemapEntry, +} 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); + +/** 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 --- + +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 serverFunctionSources = serverSourceMaps + .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, + }), + ], +});