Skip to content

Commit 8613745

Browse files
committed
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.
1 parent a58eeb9 commit 8613745

9 files changed

Lines changed: 316 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
node_modules
2+
.output
3+
.nitro
4+
.tanstack
5+
src/routeTree.gen.ts
6+
7+
# Mock Sentry server artifacts
8+
.tmp_mock_uploads.json
9+
.tmp_chunks
10+
.tmp_build_stdout
11+
.tmp_build_stderr
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import * as assert from 'assert/strict';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import {
5+
findDebugIdsWithConflictingSourcemaps,
6+
findFilesWithForeignSourcemaps,
7+
findFilesWithMultipleDebugIds,
8+
findInjectedDebugIds,
9+
getArtifactBundles,
10+
getChunkUploadPosts,
11+
getDebugIdPairs,
12+
getSourcemapSources,
13+
getSourcemaps,
14+
loadMockServerResults,
15+
startNodeServer,
16+
} from '@sentry-internal/test-utils';
17+
18+
const CLIENT_OUTPUT = path.join('.output', 'public');
19+
const SERVER_OUTPUT = path.join('.output', 'server');
20+
21+
/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */
22+
const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT';
23+
const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER';
24+
25+
const DEBUG_ID_PATTERN = /sentry-dbid-([\da-f-]{36})/gi;
26+
27+
function readDebugIds(file: string): string[] {
28+
return [...fs.readFileSync(file, 'utf8').matchAll(DEBUG_ID_PATTERN)].map(match => (match[1] as string).toLowerCase());
29+
}
30+
31+
function emittedJsFiles(dir: string): string[] {
32+
return fs
33+
.readdirSync(dir, { recursive: true, withFileTypes: true })
34+
.filter(entry => entry.isFile() && /\.m?js$/.test(entry.name) && !entry.parentPath.includes('node_modules'))
35+
.map(entry => path.join(entry.parentPath, entry.name));
36+
}
37+
38+
/** In debug mode, the Sentry plugin logs each file it deletes after the upload. */
39+
const deletedFiles = new Set(
40+
[...fs.readFileSync('.tmp_build_stdout', 'utf8').matchAll(/Deleting asset after upload: (.+)$/gm)].map(match =>
41+
path.relative(fs.realpathSync('.'), match[1] as string),
42+
),
43+
);
44+
45+
/** A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to upload. */
46+
function debugIdsOfChunksWithoutSourceMap(dir: string): Set<string> {
47+
return new Set(
48+
emittedJsFiles(dir)
49+
.filter(file => !fs.existsSync(`${file}.map`) && !deletedFiles.has(`${file}.map`))
50+
.flatMap(readDebugIds),
51+
);
52+
}
53+
54+
function sourceMapsIn(dir: string): string[] {
55+
return fs
56+
.readdirSync(dir, { recursive: true, withFileTypes: true })
57+
.filter(entry => entry.isFile() && entry.name.endsWith('.map'))
58+
.map(entry => path.join(entry.parentPath, entry.name));
59+
}
60+
61+
// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob
62+
// characters, like Nitro's `_libs/@tanstack/react-router+[...].mjs`. Remove this when the uploader matches paths literally.
63+
function debugIdsOfFilesWithGlobCharacters(dir: string): Set<string> {
64+
return new Set(
65+
emittedJsFiles(dir)
66+
.filter(file => /[[\]{}()*?]/.test(path.relative(dir, file)))
67+
.flatMap(readDebugIds),
68+
);
69+
}
70+
71+
const requests = loadMockServerResults();
72+
73+
// --- The upload reached Sentry ---
74+
75+
assert.ok(
76+
getChunkUploadPosts(requests).some(request => request.bodySize > 0),
77+
'Expected at least one chunk upload with a non-empty body',
78+
);
79+
80+
const bundles = getArtifactBundles(requests);
81+
assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest');
82+
83+
const sourcemaps = getSourcemaps(bundles);
84+
const containsMarker = (marker: string): boolean =>
85+
sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker)));
86+
87+
// Vite builds the client, and Nitro builds the server again from Vite's SSR output. Each side is pinned to a marker
88+
// only that side's source supplies.
89+
assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded source map carrying the client source');
90+
assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded source map carrying the server source');
91+
92+
// --- Source paths match the files in the project ---
93+
94+
const unresolvedSources = [...new Set(sourcemaps.flatMap(map => getSourcemapSources(map.sourcemap)))].filter(
95+
source => source.startsWith('../') || path.isAbsolute(source) || /\?ts[rs]-/.test(source),
96+
);
97+
assert.deepEqual(unresolvedSources, [], 'Expected every uploaded source path to be relative to the project root');
98+
99+
// --- Server source maps reach the original source ---
100+
101+
// Nitro bundles the SSR output again. Its maps must lead through that output to the file the server function is in.
102+
const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT }));
103+
const serverFunctionSources = sourcemaps
104+
.filter(map => map.debugId && serverDebugIds.has(map.debugId))
105+
.filter(map => map.sourcemap.sourcesContent?.some(source => source?.includes(SERVER_MARKER)))
106+
.flatMap(map => getSourcemapSources(map.sourcemap));
107+
assert.ok(
108+
serverFunctionSources.includes('src/routes/index.tsx'),
109+
`Expected a server source map to resolve to src/routes/index.tsx, got: ${serverFunctionSources.join(', ')}`,
110+
);
111+
112+
// --- Debug IDs tie the shipped bundles to the uploaded maps ---
113+
114+
const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase()));
115+
116+
for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) {
117+
const injectedDebugIds = findInjectedDebugIds({ outputDir });
118+
assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`);
119+
120+
const exemptDebugIds = new Set([
121+
...debugIdsOfChunksWithoutSourceMap(outputDir),
122+
...debugIdsOfFilesWithGlobCharacters(outputDir),
123+
]);
124+
const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId) && !exemptDebugIds.has(debugId));
125+
assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded source map`);
126+
127+
// A file of Nitro's output can carry the debug ID of the SSR chunk it was built from.
128+
assert.deepEqual(
129+
findFilesWithMultipleDebugIds({ outputDir }),
130+
[],
131+
`Expected at most one debug ID per file in ${outputDir}`,
132+
);
133+
assert.deepEqual(
134+
findFilesWithForeignSourcemaps({ outputDir }, sourcemaps),
135+
[],
136+
`Expected the source map uploaded for each file in ${outputDir} to be generated for that file`,
137+
);
138+
139+
console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`);
140+
}
141+
142+
assert.deepEqual(
143+
findDebugIdsWithConflictingSourcemaps(sourcemaps),
144+
[],
145+
'Expected every uploaded debug ID to resolve to exactly one source map',
146+
);
147+
148+
// --- Sentry deletes the source maps it enabled after the upload ---
149+
150+
for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) {
151+
assert.deepEqual(sourceMapsIn(outputDir), [], `Expected no source maps in ${outputDir}`);
152+
}
153+
154+
const clientChunkUrls = emittedJsFiles(CLIENT_OUTPUT).map(
155+
file => `/${path.relative(CLIENT_OUTPUT, file).split(path.sep).join('/')}`,
156+
);
157+
assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`);
158+
159+
const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 });
160+
161+
try {
162+
// Without this, a server that serves no public files at all would pass the 404 checks.
163+
const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status;
164+
assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`);
165+
166+
// Nitro serves public files from a list it writes into the server bundle. A map deleted after that
167+
// stays on the list, and Nitro answers its request with a 500 instead of a 404.
168+
const responses = await Promise.all(
169+
clientChunkUrls.map(async chunkUrl => ({
170+
mapUrl: `${chunkUrl}.map`,
171+
status: (await fetch(`${server.baseUrl}${chunkUrl}.map`)).status,
172+
})),
173+
);
174+
assert.deepEqual(
175+
responses.filter(({ status }) => status !== 404).map(({ mapUrl, status }) => `${mapUrl} → ${status}`),
176+
[],
177+
'Expected the server to answer each client source map URL with 404',
178+
);
179+
} finally {
180+
await server.close();
181+
}
182+
183+
console.log('All source map assertions passed!');
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "tanstackstart-react-sourcemaps",
3+
"description": "E2E test app asserting what the TanStack Start SDK uploads to Sentry when Nitro builds the server.",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"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",
8+
"clean": "npx rimraf node_modules pnpm-lock.yaml .output",
9+
"test:build": "pnpm install && pnpm build",
10+
"test:assert": "pnpm ts-node --script-mode assert-build.ts"
11+
},
12+
"dependencies": {
13+
"@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz",
14+
"@tanstack/react-router": "1.170.44",
15+
"@tanstack/react-start": "1.169.1",
16+
"nitro": "latest",
17+
"react": "^19.2.0",
18+
"react-dom": "^19.2.0"
19+
},
20+
"devDependencies": {
21+
"@playwright/test": "~1.63.0",
22+
"@sentry-internal/test-utils": "link:../../../test-utils",
23+
"@types/node": "^22.20.0",
24+
"@types/react": "^19.2.0",
25+
"@types/react-dom": "^19.2.0",
26+
"@vitejs/plugin-react-swc": "^4.2.0",
27+
"ts-node": "10.9.1",
28+
"typescript": "^5.9.0",
29+
"vite": "^8.3.0"
30+
},
31+
"volta": {
32+
"extends": "../../package.json"
33+
},
34+
"sentryTest": {
35+
"optional": true
36+
}
37+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createRouter } from '@tanstack/react-router';
2+
import { routeTree } from './routeTree.gen';
3+
4+
export const getRouter = () => createRouter({ routeTree });
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { ReactNode } from 'react';
2+
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router';
3+
4+
export const Route = createRootRoute({
5+
component: RootComponent,
6+
});
7+
8+
function RootComponent(): ReactNode {
9+
return (
10+
<html>
11+
<head>
12+
<HeadContent />
13+
</head>
14+
<body>
15+
<Outlet />
16+
<Scripts />
17+
</body>
18+
</html>
19+
);
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createFileRoute } from '@tanstack/react-router';
2+
import { createServerFn } from '@tanstack/react-start';
3+
4+
// SOURCEMAP_MARKER_CLIENT — a comment, so it is stripped from the emitted bundles but survives in `sourcesContent`.
5+
// `assert-build.ts` uses the markers to check that the original source reached the uploaded maps.
6+
const throwServerError = createServerFn().handler(async () => {
7+
// SOURCEMAP_MARKER_SERVER
8+
throw new Error('Server function error from the TanStack Start source map E2E app');
9+
});
10+
11+
export const Route = createFileRoute('/')({
12+
component: Home,
13+
});
14+
15+
function Home() {
16+
return (
17+
<button type="button" onClick={() => throwServerError()}>
18+
Throw server function error
19+
</button>
20+
);
21+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { startMockSentryServer } from '@sentry-internal/test-utils';
2+
3+
startMockSentryServer({ org: 'test-org' });
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
5+
"module": "ESNext",
6+
"moduleResolution": "bundler",
7+
"jsx": "react-jsx",
8+
"strict": true,
9+
"skipLibCheck": true,
10+
"esModuleInterop": true,
11+
"resolveJsonModule": true,
12+
"noEmit": true
13+
},
14+
"include": ["**/*.ts", "**/*.tsx"],
15+
"exclude": ["node_modules", ".output"]
16+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite';
2+
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
3+
import viteReact from '@vitejs/plugin-react-swc';
4+
import { nitro } from 'nitro/vite';
5+
import { defineConfig } from 'vite';
6+
7+
export default defineConfig({
8+
plugins: [
9+
tanstackStart(),
10+
nitro(),
11+
viteReact(),
12+
sentryTanstackStart({
13+
sentryUrl: 'http://localhost:3032',
14+
authToken: 'fake-auth-token',
15+
org: 'test-org',
16+
project: 'test-project',
17+
release: { name: 'test-release' },
18+
debug: true,
19+
}),
20+
],
21+
});

0 commit comments

Comments
 (0)