Skip to content

Commit ec73d27

Browse files
test(e2e): Assert TanStack Start Nitro server maps compose to TypeScript
Sentry unminifies after upload, so this checks the running Nitro bundle's debug IDs and uploaded sources rather than Playwright error events. Co-Authored-By: Cursor <cursoragent@cursor.com>
1 parent 77b0edd commit ec73d27

15 files changed

Lines changed: 339 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Logs
2+
logs
3+
*.log
4+
5+
node_modules
6+
dist
7+
dist-ssr
8+
*.local
9+
10+
# TanStack Start / Nitro
11+
.output
12+
.nitro
13+
.tanstack
14+
.src_dir
15+
16+
# Mock Sentry server artifacts
17+
.tmp_mock_uploads.json
18+
.tmp_chunks
19+
.tmp_build_stdout
20+
.tmp_build_stderr
21+
22+
# Generated
23+
src/routeTree.gen.ts
24+
25+
# Editor
26+
.DS_Store
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import * as assert from 'assert/strict';
2+
import * as path from 'path';
3+
import {
4+
findInjectedDebugIds,
5+
getArtifactBundles,
6+
getAssembleRequests,
7+
getChunkUploadPosts,
8+
getDebugIdPairs,
9+
getSourcemapSources,
10+
getSourcemaps,
11+
loadMockServerResults,
12+
} from '@sentry-internal/test-utils';
13+
14+
const SERVER_OUTPUT = path.join('.output', 'server');
15+
const SERVER_SOURCE = 'server-error.ts';
16+
const NITRO_INTERMEDIATE = '.nitro/vite/services/ssr';
17+
18+
const requests = loadMockServerResults();
19+
20+
console.log(`Captured ${requests.length} requests to mock Sentry server:\n`);
21+
for (const request of requests) {
22+
console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`);
23+
}
24+
console.log('');
25+
26+
assert.ok(
27+
requests.some(r => r.authorization.includes('fake-auth-token')),
28+
'Expected requests with the configured auth token',
29+
);
30+
31+
const chunkPosts = getChunkUploadPosts(requests);
32+
assert.ok(
33+
chunkPosts.some(r => r.bodySize > 0),
34+
'Expected at least one chunk upload POST with a non-empty body',
35+
);
36+
37+
const assembleRequests = getAssembleRequests(requests);
38+
assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request');
39+
for (const request of assembleRequests) {
40+
assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project');
41+
assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release');
42+
}
43+
44+
const bundles = getArtifactBundles(requests);
45+
assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest');
46+
console.log(`Found ${bundles.length} artifact bundle(s)\n`);
47+
48+
const sourcemaps = getSourcemaps(bundles);
49+
assert.ok(sourcemaps.length > 0, 'Expected at least one uploaded source map');
50+
51+
const debugIdPairs = getDebugIdPairs(bundles);
52+
const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase()));
53+
assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs');
54+
55+
// The running Nitro bundle is what Node executes. Its debug IDs must match uploaded maps, otherwise
56+
// Sentry cannot apply those maps to server-function frames.
57+
const injectedServerDebugIds = findInjectedDebugIds({ outputDir: SERVER_OUTPUT });
58+
assert.ok(injectedServerDebugIds.length > 0, `Expected debug IDs to be injected into ${SERVER_OUTPUT}`);
59+
60+
const unuploadedServerDebugIds = injectedServerDebugIds.filter(debugId => !uploadedDebugIds.has(debugId));
61+
assert.deepEqual(
62+
unuploadedServerDebugIds,
63+
[],
64+
`Expected every debug ID in ${SERVER_OUTPUT} to have an uploaded sourcemap`,
65+
);
66+
67+
console.log(` ${SERVER_OUTPUT}: ${injectedServerDebugIds.length} injected debug ID(s), all uploaded`);
68+
69+
const serverDebugIdSet = new Set(injectedServerDebugIds);
70+
const serverPairs = debugIdPairs.filter(pair => serverDebugIdSet.has(pair.debugId.toLowerCase()));
71+
const serverMapUrls = new Set(serverPairs.map(pair => pair.mapUrl));
72+
const serverSourcemaps = sourcemaps.filter(entry => serverMapUrls.has(entry.url));
73+
74+
assert.ok(
75+
serverSourcemaps.length > 0,
76+
'Expected at least one uploaded sourcemap paired with a debug ID from the Nitro server output',
77+
);
78+
79+
function sourcesOf(entry: (typeof sourcemaps)[number]): string[] {
80+
return getSourcemapSources(entry.sourcemap);
81+
}
82+
83+
const composedServerMaps = serverSourcemaps.filter(entry => {
84+
const sources = sourcesOf(entry);
85+
return (
86+
(entry.sourcemap.mappings?.length ?? 0) > 0 &&
87+
sources.some(source => source.includes(SERVER_SOURCE)) &&
88+
sources.every(
89+
source => !source.includes(NITRO_INTERMEDIATE) && !source.includes('?tss-') && !source.startsWith('../'),
90+
)
91+
);
92+
});
93+
94+
assert.ok(
95+
composedServerMaps.length > 0,
96+
`Expected a Nitro server sourcemap whose sources include ${SERVER_SOURCE} with non-empty mappings, rewritten paths, and no ${NITRO_INTERMEDIATE} intermediates. ` +
97+
`Server map sources: ${JSON.stringify(serverSourcemaps.flatMap(entry => sourcesOf(entry)))}`,
98+
);
99+
100+
console.log(` ${composedServerMaps.length} Nitro server map(s) compose to ${SERVER_SOURCE} with rewritten sources\n`);
101+
102+
console.log('All TanStack Start Nitro sourcemap assertions passed!');
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "tanstackstart-react-sourcemaps",
3+
"description": "E2E test app asserting that TanStack Start + Nitro server source maps compose back to original TypeScript.",
4+
"private": true,
5+
"version": "0.0.1",
6+
"type": "module",
7+
"scripts": {
8+
"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",
9+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
10+
"test:build": "pnpm install && pnpm build",
11+
"test:assert": "pnpm tsx assert-build.ts"
12+
},
13+
"dependencies": {
14+
"@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz",
15+
"@tanstack/react-router": "1.170.18",
16+
"@tanstack/react-start": "1.168.35",
17+
"nitro": "latest || *",
18+
"react": "^19.2.0",
19+
"react-dom": "^19.2.0"
20+
},
21+
"devDependencies": {
22+
"@sentry-internal/test-utils": "link:../../../test-utils",
23+
"@types/node": "^24.10.0",
24+
"@types/react": "^19.2.0",
25+
"@types/react-dom": "^19.2.0",
26+
"@vitejs/plugin-react-swc": "^3.5.0",
27+
"tsx": "^4.23.0",
28+
"typescript": "^5.9.0",
29+
"vite": "7.3.2",
30+
"vite-tsconfig-paths": "^5.1.4"
31+
},
32+
"volta": {
33+
"extends": "../../package.json"
34+
}
35+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import './instrument.client';
2+
import { StartClient } from '@tanstack/react-start/client';
3+
import { StrictMode, startTransition } from 'react';
4+
import { hydrateRoot } from 'react-dom/client';
5+
6+
startTransition(() => {
7+
hydrateRoot(
8+
document,
9+
<StrictMode>
10+
<StartClient />
11+
</StrictMode>,
12+
);
13+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as Sentry from '@sentry/tanstackstart-react';
2+
3+
Sentry.init({
4+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
5+
tracesSampleRate: 1.0,
6+
release: 'e2e-test',
7+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createRouter } from '@tanstack/react-router';
2+
import { routeTree } from './routeTree.gen';
3+
4+
export const getRouter = () => {
5+
return createRouter({
6+
routeTree,
7+
scrollRestoration: true,
8+
});
9+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { HeadContent, Outlet, Scripts, createRootRoute } from '@tanstack/react-router';
2+
import type { ReactNode } from 'react';
3+
4+
export const Route = createRootRoute({
5+
head: () => ({
6+
meta: [
7+
{ charSet: 'utf-8' },
8+
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
9+
{ title: 'TanStack Start sourcemap E2E' },
10+
],
11+
}),
12+
component: RootComponent,
13+
});
14+
15+
function RootComponent() {
16+
return (
17+
<RootDocument>
18+
<Outlet />
19+
</RootDocument>
20+
);
21+
}
22+
23+
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
24+
return (
25+
<html>
26+
<head>
27+
<HeadContent />
28+
</head>
29+
<body>
30+
{children}
31+
<Scripts />
32+
</body>
33+
</html>
34+
);
35+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createFileRoute } from '@tanstack/react-router';
2+
import { createServerFn } from '@tanstack/react-start';
3+
import { throwServerSourcemapError } from '../server-error';
4+
5+
const throwServerError = createServerFn({ method: 'POST' }).handler(() => {
6+
throwServerSourcemapError();
7+
});
8+
9+
export const Route = createFileRoute('/')({
10+
component: Home,
11+
});
12+
13+
function Home() {
14+
return (
15+
<button
16+
type="button"
17+
onClick={() => {
18+
void throwServerError();
19+
}}
20+
>
21+
Break server function
22+
</button>
23+
);
24+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/** SOURCEMAP_MARKER_SERVER */
2+
export function throwServerSourcemapError(): never {
3+
throw new Error('Server sourcemap e2e');
4+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { wrapFetchWithSentry } from '@sentry/tanstackstart-react';
2+
import handler, { createServerEntry } from '@tanstack/react-start/server-entry';
3+
import type { ServerEntry } from '@tanstack/react-start/server-entry';
4+
5+
const requestHandler: ServerEntry = wrapFetchWithSentry({
6+
fetch(request: Request) {
7+
return handler.fetch(request);
8+
},
9+
});
10+
11+
export default createServerEntry(requestHandler);

0 commit comments

Comments
 (0)