Skip to content

Commit b9cd12a

Browse files
authored
test(e2e): Add nuxt-5-sourcemaps test (#25268)
This is a copy of `nuxt-4-sourcemaps` for Nuxt 5 (Nitro 3), set up like `nuxt-5`: it uses the nightly builds and runs as an optional app. It differs from the Nuxt 4 test in two places: - **No intermediate client build:** Nuxt 5 writes the client build straight into `.output/public`. So the check that every client debug ID was uploaded runs only when the maps are kept. - **Existing Nuxt 5 bugs:** three assertions are skipped with TODOs, one for each bug: - The server maps have no `sourcesContent`. - Files with glob characters in the path are not uploaded. - The server maps do not reach the `.vue` files. Merge after this is merged: #25267 and #25287 (CI fix)
1 parent e0e5d65 commit b9cd12a

12 files changed

Lines changed: 479 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Nuxt dev/build outputs
2+
.output
3+
.data
4+
.nuxt
5+
.nitro
6+
.cache
7+
dist
8+
9+
# Node dependencies
10+
node_modules
11+
12+
# Mock Sentry server artifacts
13+
.tmp_mock_uploads.json
14+
.tmp_chunks
15+
.tmp_build_stdout
16+
.tmp_build_stderr
17+
18+
# Logs
19+
logs
20+
*.log
21+
22+
# Misc
23+
.DS_Store
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<template>
2+
<div>
3+
<h1>Nuxt source map E2E</h1>
4+
<NuxtPage />
5+
</div>
6+
</template>
7+
8+
<script setup lang="ts">
9+
// SOURCEMAP_MARKER_CLIENT — a comment, so it is stripped from the emitted bundle but survives in
10+
// `sourcesContent`. `assert-build.ts` uses it to tell "the real source was uploaded" apart from
11+
// "the real source is still sitting in `.output/public`".
12+
</script>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<template>
2+
<button id="throw-error" @click="throwClientError">Throw client error</button>
3+
</template>
4+
5+
<script setup lang="ts">
6+
function throwClientError(): void {
7+
throw new Error('Client error from the Nuxt source map E2E app');
8+
}
9+
</script>
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
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+
findSourceMapFiles,
10+
findSourceMappingUrlComments,
11+
getArtifactBundles,
12+
getAssembleRequests,
13+
getChunkUploadPosts,
14+
getDebugIdPairs,
15+
getSourcemaps,
16+
loadMockServerResults,
17+
startNodeServer,
18+
} from '@sentry-internal/test-utils';
19+
20+
/** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */
21+
const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true';
22+
23+
/** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */
24+
const isStaticBuild = process.env.NUXT_COMMAND === 'generate';
25+
26+
/** Nuxt 5 writes the client build here directly, so Sentry deletes the maps in Vite's only output directory. */
27+
const CLIENT_OUTPUT = path.join('.output', 'public');
28+
const SERVER_OUTPUT = path.join('.output', 'server');
29+
30+
/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */
31+
const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT';
32+
const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER';
33+
34+
const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
35+
36+
function filesContaining(dir: string, needle: string): string[] {
37+
return fs
38+
.readdirSync(dir, { recursive: true, withFileTypes: true })
39+
.filter(entry => entry.isFile())
40+
.map(entry => path.join(entry.parentPath, entry.name))
41+
.filter(file => fs.readFileSync(file, 'utf8').includes(needle));
42+
}
43+
44+
/**
45+
* Rolldown emits no source map for a chunk without mappable source, such as Vue's export helper.
46+
* Such a chunk still gets a debug ID, but there is no map to upload for it.
47+
*/
48+
function debugIdsOfChunksWithoutSourceMap(dir: string): Set<string> {
49+
const debugIds = new Set<string>();
50+
51+
for (const file of filesContaining(dir, 'sentry-dbid-')) {
52+
if (file.endsWith('.map') || fs.existsSync(`${file}.map`)) {
53+
continue;
54+
}
55+
56+
for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) {
57+
debugIds.add((match[1] as string).toLowerCase());
58+
}
59+
}
60+
61+
return debugIds;
62+
}
63+
64+
// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob
65+
// characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally.
66+
function debugIdsOfFilesWithGlobCharacters(dir: string): Set<string> {
67+
const debugIds = new Set<string>();
68+
69+
for (const file of filesContaining(dir, 'sentry-dbid-')) {
70+
if (!/[[\]{}()*?]/.test(path.relative(dir, file))) {
71+
continue;
72+
}
73+
74+
for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) {
75+
debugIds.add((match[1] as string).toLowerCase());
76+
}
77+
}
78+
79+
return debugIds;
80+
}
81+
82+
console.log(
83+
`Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` +
84+
`client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`,
85+
);
86+
87+
const requests = loadMockServerResults();
88+
89+
console.log(`Captured ${requests.length} requests to mock Sentry server:\n`);
90+
for (const request of requests) {
91+
console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`);
92+
}
93+
console.log('');
94+
95+
// --- The upload reached Sentry ---
96+
97+
assert.ok(
98+
requests.some(r => r.authorization.includes('fake-auth-token')),
99+
'Expected requests with the configured auth token',
100+
);
101+
102+
// Sentry rejects a release that names no project with a 400, and the mock server accepts anything,
103+
// so assert on the body rather than on the call alone.
104+
const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST');
105+
assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release');
106+
for (const request of releaseCreates) {
107+
const body = request.jsonBody as { version?: string; projects?: string[] } | undefined;
108+
assert.equal(
109+
body?.version,
110+
'test-release',
111+
`Expected the release create body to carry the version, got ${JSON.stringify(body)}`,
112+
);
113+
assert.deepEqual(
114+
body?.projects,
115+
['test-project'],
116+
`Expected the release create body to name the project, got ${JSON.stringify(body)}`,
117+
);
118+
}
119+
120+
assert.ok(
121+
requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'),
122+
'Expected a PUT to finalize the release',
123+
);
124+
125+
assert.ok(
126+
requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'),
127+
'Expected a GET for the chunk-upload options',
128+
);
129+
130+
const chunkPosts = getChunkUploadPosts(requests);
131+
assert.ok(
132+
chunkPosts.some(r => r.bodySize > 0),
133+
'Expected at least one chunk upload POST with a non-empty body',
134+
);
135+
136+
const assembleRequests = getAssembleRequests(requests);
137+
assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request');
138+
for (const request of assembleRequests) {
139+
assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project');
140+
assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release');
141+
assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums');
142+
for (const chunk of request.assembleBody?.chunks ?? []) {
143+
assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`);
144+
}
145+
}
146+
147+
const bundles = getArtifactBundles(requests);
148+
assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest');
149+
console.log(`Found ${bundles.length} artifact bundle(s)\n`);
150+
151+
// --- Both bundlers uploaded ---
152+
153+
const sourcemaps = getSourcemaps(bundles);
154+
assert.ok(
155+
sourcemaps.some(map => map.sourcemap.mappings?.length),
156+
'Expected at least one uploaded sourcemap with non-empty mappings',
157+
);
158+
159+
const containsMarker = (marker: string): boolean =>
160+
sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker)));
161+
162+
// Vite builds the client and Nitro builds the server. Counting bundles would still pass with either
163+
// plugin dropped, so each side is pinned to a marker only that side's source supplies.
164+
assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)');
165+
// TODO: Nitro 3 builds the server with Rolldown, and the uploaded server maps carry no `sourcesContent`: the
166+
// module's `sourcemapExcludeSources: false` does not reach that build. Assert the server marker when that is fixed:
167+
// assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)');
168+
169+
// `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry.
170+
const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter(
171+
source => source.startsWith('../') || path.isAbsolute(source),
172+
);
173+
assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`);
174+
175+
// --- Debug IDs tie the shipped bundle to the uploaded map ---
176+
177+
const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase()));
178+
assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs');
179+
180+
const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId));
181+
assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID');
182+
183+
// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting
184+
// the upload alone cannot show this.
185+
// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk
186+
// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the
187+
// maps are kept. The upload is the same in all variants.
188+
const outputDirsWithSourceMaps = [
189+
...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []),
190+
...(isStaticBuild ? [] : [SERVER_OUTPUT]),
191+
];
192+
const debugIdsWithoutSourceMap = new Set(
193+
outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]),
194+
);
195+
const debugIdsSkippedByUploader = isStaticBuild ? new Set<string>() : debugIdsOfFilesWithGlobCharacters(SERVER_OUTPUT);
196+
197+
for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) {
198+
const injectedDebugIds = findInjectedDebugIds({ outputDir });
199+
assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`);
200+
// The exemption for chunks without a source map must not let a build without any uploaded map pass.
201+
assert.ok(
202+
injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)),
203+
`Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`,
204+
);
205+
206+
if (outputDirsWithSourceMaps.includes(outputDir)) {
207+
const unuploaded = injectedDebugIds.filter(
208+
debugId =>
209+
!uploadedDebugIds.has(debugId) &&
210+
!debugIdsWithoutSourceMap.has(debugId) &&
211+
!debugIdsSkippedByUploader.has(debugId),
212+
);
213+
assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`);
214+
}
215+
216+
console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`);
217+
218+
// Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own.
219+
// Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519
220+
const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir });
221+
assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`);
222+
223+
// A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on
224+
// Vite's map even if Nitro uploaded nothing.
225+
const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps);
226+
assert.deepEqual(
227+
filesWithForeignSourcemaps,
228+
[],
229+
`Expected the source map uploaded for each file in ${outputDir} to be generated for that file`,
230+
);
231+
}
232+
233+
// If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one.
234+
const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps);
235+
assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map');
236+
237+
// TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output
238+
// (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach
239+
// `.vue` files. Assert this when the server maps reach the original sources:
240+
// const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT }));
241+
// const serverSources = sourcemaps
242+
// .filter(map => map.debugId && serverDebugIds.has(map.debugId))
243+
// .flatMap(map => getSourcemapSources(map.sourcemap));
244+
// assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue')));
245+
console.log('');
246+
247+
// --- What the build leaves behind in the client output ---
248+
249+
const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT });
250+
251+
if (keepClientSourceMaps) {
252+
assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`);
253+
console.log(` ${clientSourceMaps.length} source map(s) kept in ${CLIENT_OUTPUT}, as configured\n`);
254+
} else {
255+
// This directory is served to the internet, so a surviving `.map` hands out the original source.
256+
assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`);
257+
258+
// The maps are gone, so a surviving reference only 404s in devtools and leaks where they were.
259+
const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT });
260+
assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`);
261+
262+
// Catches source maps inlined as `data:` URIs, which the reference check above skips by design.
263+
const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER);
264+
assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`);
265+
266+
console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`);
267+
}
268+
269+
// --- What the built server answers for the client source maps ---
270+
271+
// `nuxt generate` emits no server, and a static host serves only the files that exist.
272+
if (!isStaticBuild) {
273+
const clientChunkUrls = fs
274+
.readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true })
275+
.filter(entry => entry.isFile() && entry.name.endsWith('.js'))
276+
.map(
277+
entry => `/${path.relative(CLIENT_OUTPUT, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')}`,
278+
);
279+
assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`);
280+
281+
const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 });
282+
283+
try {
284+
// Without this, a server that serves no public files at all would pass the 404 checks.
285+
const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status;
286+
assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`);
287+
288+
// Nitro serves public files from a list it writes into the server bundle. A map deleted after that
289+
// stays on the list, and Nitro answers its request with a 500 instead of a 404.
290+
const responses = await Promise.all(
291+
clientChunkUrls.map(async chunkUrl => {
292+
const mapUrl = `${chunkUrl}.map`;
293+
const expectedStatus = fs.existsSync(path.join(CLIENT_OUTPUT, mapUrl)) ? 200 : 404;
294+
const { status } = await fetch(`${server.baseUrl}${mapUrl}`);
295+
return { mapUrl, status, expectedStatus };
296+
}),
297+
);
298+
const unexpectedStatuses = responses
299+
.filter(({ status, expectedStatus }) => status !== expectedStatus)
300+
.map(({ mapUrl, status, expectedStatus }) => `${mapUrl} → ${status}, expected ${expectedStatus}`);
301+
assert.deepEqual(
302+
unexpectedStatuses,
303+
[],
304+
'Expected the server to answer each client source map URL with 200 if the map is in the output, else 404',
305+
);
306+
307+
console.log(` The server answers all ${clientChunkUrls.length} client source map URL(s) as expected\n`);
308+
} finally {
309+
await server.close();
310+
}
311+
}
312+
313+
console.log('All sourcemap assertions passed!');
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Nuxt defaults `sourcemap.client` to `false`, which the SDK respects as a deliberate opt-out, so
2+
// an app that never mentions `sourcemap`, uploads nothing client-side. `'hidden'` is what the SDK's
3+
// own warning tells users to set, which makes it the setup worth regression-testing.
4+
const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true';
5+
6+
export default defineNuxtConfig({
7+
compatibilityDate: '2025-06-06',
8+
imports: { autoImport: false },
9+
10+
sourcemap: { client: 'hidden' },
11+
12+
modules: ['@sentry/nuxt/module'],
13+
14+
runtimeConfig: {
15+
public: {
16+
sentry: {
17+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
18+
},
19+
},
20+
},
21+
22+
sentry: {
23+
sentryUrl: 'http://localhost:3032',
24+
authToken: 'fake-auth-token',
25+
org: 'test-org',
26+
project: 'test-project',
27+
release: { name: 'test-release' },
28+
// Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should
29+
// upload the maps and leave the emitted files alone.
30+
sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] },
31+
debug: true,
32+
},
33+
});

0 commit comments

Comments
 (0)