Skip to content
Open
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,148 @@
import * as assert from 'assert/strict';
import * as fs from 'fs';
import * as path from 'path';
import {
findInjectedDebugIds,
findSourceMapFiles,
findSourceMappingUrlComments,
getArtifactBundles,
getAssembleRequests,
getChunkUploadPosts,
getDebugIdPairs,
getSourcemaps,
loadMockServerResults,
} 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';

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));
}

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',
);

assert.ok(
requests.some(r => r.url?.includes('/releases/')),
'Expected at least one request to releases endpoint',
);

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.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums');
}

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's Rollup 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)');
// Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the
// only reason this marker survives into `sourcesContent`.
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.
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}`);

const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.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), all uploaded`);
}
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`);
}

console.log('All sourcemap assertions passed!');
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Nuxt 4 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,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "nuxt-4-sourcemaps",
"description": "E2E test app asserting what the Nuxt SDK uploads to Sentry and what it leaves behind in `.output`.",
"private": true,
"type": "module",
"scripts": {
"build": "node start-mock-sentry-server.mjs & nuxt ${NUXT_COMMAND:-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 nuxi cleanup",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm ts-node --script-mode assert-build.ts",
"test:build:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:build",
"test:assert:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:assert",
"test:build:static": "NUXT_COMMAND=generate pnpm test:build",
"test:assert:static": "NUXT_COMMAND=generate pnpm test:assert"
},
"dependencies": {
"@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz",
"nuxt": "^4.1.2"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^22.20.0",
"ts-node": "10.9.1",
"typescript": "~5.0.0"
},
"volta": {
"extends": "../../package.json",
"node": "22.20.0"
},
"sentryTest": {
"variants": [
{
"build-command": "pnpm test:build:keep-client-sourcemaps",
"assert-command": "pnpm test:assert:keep-client-sourcemaps",
"label": "nuxt-4-sourcemaps (client source maps kept)"
},
{
"build-command": "pnpm test:build:static",
"assert-command": "pnpm test:assert:static",
"label": "nuxt-4-sourcemaps (static / nuxt generate)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as Sentry from '@sentry/nuxt';
import { useRuntimeConfig } from '#imports';

Sentry.init({
dsn: useRuntimeConfig().public.sentry.dsn,
tracesSampleRate: 1.0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as Sentry from '@sentry/nuxt';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
tracesSampleRate: 1.0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineEventHandler } from '#imports';

// SOURCEMAP_MARKER_SERVER — the server counterpart of the client marker. Nitro defaults to
// `sourcemapExcludeSources: true`, which would drop this from the uploaded map; the Sentry module
// flips it to `false`, so finding this marker in `sourcesContent` is what proves that still works.
export default defineEventHandler(() => {
throw new Error('Server error from the Nuxt source map E2E app');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { startMockSentryServer } from '@sentry-internal/test-utils';

startMockSentryServer({ org: 'test-org' });
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true
},
"include": ["**/*.ts", "**/*.vue"],
"exclude": ["node_modules", ".output", ".nuxt"]
}
Loading
Loading