Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist

# Node dependencies
node_modules

# Mock Sentry server artifacts
.tmp_mock_uploads.json
.tmp_chunks
.tmp_build_stdout
.tmp_build_stderr

# Logs
logs
*.log

# Misc
.DS_Store
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<template>
<div>
<h1>Nuxt source map E2E</h1>
<NuxtPage />
</div>
</template>

<script setup lang="ts">
// SOURCEMAP_MARKER_CLIENT — a comment, so it is stripped from the emitted bundle but survives in
// `sourcesContent`. `assert-build.ts` uses it to tell "the real source was uploaded" apart from
// "the real source is still sitting in `.output/public`".
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<button id="throw-error" @click="throwClientError">Throw client error</button>
</template>

<script setup lang="ts">
function throwClientError(): void {
throw new Error('Client error from the Nuxt source map E2E app');
}
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
import * as assert from 'assert/strict';
import * as fs from 'fs';
import * as path from 'path';
import {
findDebugIdsWithConflictingSourcemaps,
findFilesWithForeignSourcemaps,
findFilesWithMultipleDebugIds,
findInjectedDebugIds,
findSourceMapFiles,
findSourceMappingUrlComments,
getArtifactBundles,
getAssembleRequests,
getChunkUploadPosts,
getDebugIdPairs,
getSourcemaps,
loadMockServerResults,
startNodeServer,
} from '@sentry-internal/test-utils';

/** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */
const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true';

/** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */
const isStaticBuild = process.env.NUXT_COMMAND === 'generate';

/** Nuxt 5 writes the client build here directly, so Sentry deletes the maps in Vite's only output directory. */
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 UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;

function filesContaining(dir: string, needle: string): string[] {
return fs
.readdirSync(dir, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile())
.map(entry => path.join(entry.parentPath, entry.name))
.filter(file => fs.readFileSync(file, 'utf8').includes(needle));
}

/**
* Rolldown emits no source map for a chunk without mappable source, such as Vue's export helper.
* Such a chunk still gets a debug ID, but there is no map to upload for it.
*/
function debugIdsOfChunksWithoutSourceMap(dir: string): Set<string> {
const debugIds = new Set<string>();

for (const file of filesContaining(dir, 'sentry-dbid-')) {
if (file.endsWith('.map') || fs.existsSync(`${file}.map`)) {
continue;
}

for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) {
debugIds.add((match[1] as string).toLowerCase());
}
}

return debugIds;
}

// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there any issues tracking these?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet, I created them now as sub-issues of this one: #25319

I'll link them in the TODOs.

// characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally.
function debugIdsOfFilesWithGlobCharacters(dir: string): Set<string> {
const debugIds = new Set<string>();

for (const file of filesContaining(dir, 'sentry-dbid-')) {
if (!/[[\]{}()*?]/.test(path.relative(dir, file))) {
continue;
}

for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) {
debugIds.add((match[1] as string).toLowerCase());
}
}

return debugIds;
}

console.log(
`Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` +
`client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`,
);

const requests = loadMockServerResults();

console.log(`Captured ${requests.length} requests to mock Sentry server:\n`);
for (const request of requests) {
console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`);
}
console.log('');

// --- The upload reached Sentry ---

assert.ok(
requests.some(r => r.authorization.includes('fake-auth-token')),
'Expected requests with the configured auth token',
);

// Sentry rejects a release that names no project with a 400, and the mock server accepts anything,
// so assert on the body rather than on the call alone.
const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST');
assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release');
for (const request of releaseCreates) {
const body = request.jsonBody as { version?: string; projects?: string[] } | undefined;
assert.equal(
body?.version,
'test-release',
`Expected the release create body to carry the version, got ${JSON.stringify(body)}`,
);
assert.deepEqual(
body?.projects,
['test-project'],
`Expected the release create body to name the project, got ${JSON.stringify(body)}`,
);
}

assert.ok(
requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'),
'Expected a PUT to finalize the release',
);

assert.ok(
requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'),
'Expected a GET for the chunk-upload options',
);

const chunkPosts = getChunkUploadPosts(requests);
assert.ok(
chunkPosts.some(r => r.bodySize > 0),
'Expected at least one chunk upload POST with a non-empty body',
);

const assembleRequests = getAssembleRequests(requests);
assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request');
for (const request of assembleRequests) {
assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project');
assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release');
assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums');
for (const chunk of request.assembleBody?.chunks ?? []) {
assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`);
}
}

const bundles = getArtifactBundles(requests);
assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest');
console.log(`Found ${bundles.length} artifact bundle(s)\n`);

// --- Both bundlers uploaded ---

const sourcemaps = getSourcemaps(bundles);
assert.ok(
sourcemaps.some(map => map.sourcemap.mappings?.length),
'Expected at least one uploaded sourcemap with non-empty mappings',
);

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. Counting bundles would still pass with either
// plugin dropped, so each side is pinned to a marker only that side's source supplies.
assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)');
// TODO: Nitro 3 builds the server with Rolldown, and the uploaded server maps carry no `sourcesContent`: the
// module's `sourcemapExcludeSources: false` does not reach that build. Assert the server marker when that is fixed:
// assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)');

// `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry.
const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter(
source => source.startsWith('../') || path.isAbsolute(source),
);
assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`);

// --- Debug IDs tie the shipped bundle to the uploaded map ---

const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase()));
assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs');

const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId));
assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID');

// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting
// the upload alone cannot show this.
// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk
// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the
// maps are kept. The upload is the same in all variants.
const outputDirsWithSourceMaps = [
...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []),
...(isStaticBuild ? [] : [SERVER_OUTPUT]),
];
const debugIdsWithoutSourceMap = new Set(
outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]),
);
const debugIdsSkippedByUploader = isStaticBuild ? new Set<string>() : debugIdsOfFilesWithGlobCharacters(SERVER_OUTPUT);

for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) {
const injectedDebugIds = findInjectedDebugIds({ outputDir });
assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`);
// The exemption for chunks without a source map must not let a build without any uploaded map pass.
assert.ok(
injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)),
`Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`,
);

if (outputDirsWithSourceMaps.includes(outputDir)) {
const unuploaded = injectedDebugIds.filter(
debugId =>
!uploadedDebugIds.has(debugId) &&
!debugIdsWithoutSourceMap.has(debugId) &&
!debugIdsSkippedByUploader.has(debugId),
);
assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`);
}

console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`);

// Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own.
// Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519
const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir });
assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`);

// A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on
// Vite's map even if Nitro uploaded nothing.
const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps);
assert.deepEqual(
filesWithForeignSourcemaps,
[],
`Expected the source map uploaded for each file in ${outputDir} to be generated for that file`,
);
}

// If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one.
const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps);
assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map');

// TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output
// (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach
// `.vue` files. Assert this when the server maps reach the original sources:
// const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT }));
// const serverSources = sourcemaps
// .filter(map => map.debugId && serverDebugIds.has(map.debugId))
// .flatMap(map => getSourcemapSources(map.sourcemap));
// assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue')));
console.log('');

// --- What the build leaves behind in the client output ---

const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT });

if (keepClientSourceMaps) {
assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`);
console.log(` ${clientSourceMaps.length} source map(s) kept in ${CLIENT_OUTPUT}, as configured\n`);
} else {
// This directory is served to the internet, so a surviving `.map` hands out the original source.
assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`);

// The maps are gone, so a surviving reference only 404s in devtools and leaks where they were.
const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT });
assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`);

// Catches source maps inlined as `data:` URIs, which the reference check above skips by design.
const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER);
assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`);

console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`);
}

// --- What the built server answers for the client source maps ---

// `nuxt generate` emits no server, and a static host serves only the files that exist.
if (!isStaticBuild) {
const clientChunkUrls = fs
.readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile() && entry.name.endsWith('.js'))
.map(
entry => `/${path.relative(CLIENT_OUTPUT, path.join(entry.parentPath, entry.name)).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 => {
const mapUrl = `${chunkUrl}.map`;
const expectedStatus = fs.existsSync(path.join(CLIENT_OUTPUT, mapUrl)) ? 200 : 404;
const { status } = await fetch(`${server.baseUrl}${mapUrl}`);
return { mapUrl, status, expectedStatus };
}),
);
const unexpectedStatuses = responses
.filter(({ status, expectedStatus }) => status !== expectedStatus)
.map(({ mapUrl, status, expectedStatus }) => `${mapUrl} → ${status}, expected ${expectedStatus}`);
assert.deepEqual(
unexpectedStatuses,
[],
'Expected the server to answer each client source map URL with 200 if the map is in the output, else 404',
);

console.log(` The server answers all ${clientChunkUrls.length} client source map URL(s) as expected\n`);
} finally {
await server.close();
}
}

console.log('All sourcemap assertions passed!');
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Nuxt defaults `sourcemap.client` to `false`, which the SDK respects as a deliberate opt-out, so
// an app that never mentions `sourcemap`, uploads nothing client-side. `'hidden'` is what the SDK's
// own warning tells users to set, which makes it the setup worth regression-testing.
const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true';

export default defineNuxtConfig({
compatibilityDate: '2025-06-06',
imports: { autoImport: false },

sourcemap: { client: 'hidden' },

modules: ['@sentry/nuxt/module'],

runtimeConfig: {
public: {
sentry: {
dsn: 'https://public@dsn.ingest.sentry.io/1337',
},
},
},

sentry: {
sentryUrl: 'http://localhost:3032',
authToken: 'fake-auth-token',
org: 'test-org',
project: 'test-project',
release: { name: 'test-release' },
// Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should
// upload the maps and leave the emitted files alone.
sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] },
debug: true,
},
});
Loading
Loading