Skip to content

Commit 1776e5c

Browse files
authored
test(e2e): Add Nuxt source maps test (#23518)
Adds a regression test for the fix of this already closed issue: #22801 The E2E test includes 3 variants: - `nuxt build` with client source maps deleted - `nuxt build` with kept `hidden` client source maps - static `nuxt generate` (that's the specific variant for the above mentioned issue) All variants were locally tested **without** the fix to confirm the "static `nuxt generate`" tests are failing in this case.
1 parent c9211d8 commit 1776e5c

14 files changed

Lines changed: 413 additions & 15 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: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import * as assert from 'assert/strict';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import {
5+
findInjectedDebugIds,
6+
findSourceMapFiles,
7+
findSourceMappingUrlComments,
8+
getArtifactBundles,
9+
getAssembleRequests,
10+
getChunkUploadPosts,
11+
getDebugIdPairs,
12+
getSourcemaps,
13+
loadMockServerResults,
14+
} from '@sentry-internal/test-utils';
15+
16+
/** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */
17+
const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true';
18+
19+
/** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */
20+
const isStaticBuild = process.env.NUXT_COMMAND === 'generate';
21+
22+
const CLIENT_OUTPUT = path.join('.output', 'public');
23+
const SERVER_OUTPUT = path.join('.output', 'server');
24+
25+
/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */
26+
const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT';
27+
const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER';
28+
29+
const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
30+
31+
function filesContaining(dir: string, needle: string): string[] {
32+
return fs
33+
.readdirSync(dir, { recursive: true, withFileTypes: true })
34+
.filter(entry => entry.isFile())
35+
.map(entry => path.join(entry.parentPath, entry.name))
36+
.filter(file => fs.readFileSync(file, 'utf8').includes(needle));
37+
}
38+
39+
console.log(
40+
`Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` +
41+
`client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`,
42+
);
43+
44+
const requests = loadMockServerResults();
45+
46+
console.log(`Captured ${requests.length} requests to mock Sentry server:\n`);
47+
for (const request of requests) {
48+
console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`);
49+
}
50+
console.log('');
51+
52+
// --- The upload reached Sentry ---
53+
54+
assert.ok(
55+
requests.some(r => r.authorization.includes('fake-auth-token')),
56+
'Expected requests with the configured auth token',
57+
);
58+
59+
assert.ok(
60+
requests.some(r => r.url?.includes('/releases/')),
61+
'Expected at least one request to releases endpoint',
62+
);
63+
64+
const chunkPosts = getChunkUploadPosts(requests);
65+
assert.ok(
66+
chunkPosts.some(r => r.bodySize > 0),
67+
'Expected at least one chunk upload POST with a non-empty body',
68+
);
69+
70+
const assembleRequests = getAssembleRequests(requests);
71+
assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request');
72+
for (const request of assembleRequests) {
73+
assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project');
74+
assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums');
75+
}
76+
77+
const bundles = getArtifactBundles(requests);
78+
assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest');
79+
console.log(`Found ${bundles.length} artifact bundle(s)\n`);
80+
81+
// --- Both bundlers uploaded ---
82+
83+
const sourcemaps = getSourcemaps(bundles);
84+
assert.ok(
85+
sourcemaps.some(map => map.sourcemap.mappings?.length),
86+
'Expected at least one uploaded sourcemap with non-empty mappings',
87+
);
88+
89+
const containsMarker = (marker: string): boolean =>
90+
sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker)));
91+
92+
// Vite builds the client and Nitro's Rollup builds the server. Counting bundles would still pass
93+
// with either plugin dropped, so each side is pinned to a marker only that side's source supplies.
94+
assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)');
95+
// Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the
96+
// only reason this marker survives into `sourcesContent`.
97+
assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)');
98+
99+
// `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry.
100+
const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter(
101+
source => source.startsWith('../') || path.isAbsolute(source),
102+
);
103+
assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`);
104+
105+
// --- Debug IDs tie the shipped bundle to the uploaded map ---
106+
107+
const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase()));
108+
assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs');
109+
110+
const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId));
111+
assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID');
112+
113+
// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting
114+
// the upload alone cannot show this.
115+
for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) {
116+
const injectedDebugIds = findInjectedDebugIds({ outputDir });
117+
assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`);
118+
119+
const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId));
120+
assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`);
121+
122+
console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`);
123+
}
124+
console.log('');
125+
126+
// --- What the build leaves behind in the client output ---
127+
128+
const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT });
129+
130+
if (keepClientSourceMaps) {
131+
assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`);
132+
console.log(` ${clientSourceMaps.length} source map(s) kept in ${CLIENT_OUTPUT}, as configured\n`);
133+
} else {
134+
// This directory is served to the internet, so a surviving `.map` hands out the original source.
135+
assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`);
136+
137+
// The maps are gone, so a surviving reference only 404s in devtools and leaks where they were.
138+
const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT });
139+
assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`);
140+
141+
// Catches source maps inlined as `data:` URIs, which the reference check above skips by design.
142+
const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER);
143+
assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`);
144+
145+
console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`);
146+
}
147+
148+
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 4 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+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "nuxt-4-sourcemaps",
3+
"description": "E2E test app asserting what the Nuxt SDK uploads to Sentry and what it leaves behind in `.output`.",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"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",
8+
"clean": "npx nuxi cleanup",
9+
"test:build": "pnpm install && pnpm build",
10+
"test:assert": "pnpm ts-node --script-mode assert-build.ts",
11+
"test:build:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:build",
12+
"test:assert:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:assert",
13+
"test:build:static": "NUXT_COMMAND=generate pnpm test:build",
14+
"test:assert:static": "NUXT_COMMAND=generate pnpm test:assert"
15+
},
16+
"dependencies": {
17+
"@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz",
18+
"nuxt": "^4.1.2"
19+
},
20+
"devDependencies": {
21+
"@playwright/test": "~1.56.0",
22+
"@sentry-internal/test-utils": "link:../../../test-utils",
23+
"@types/node": "^22.20.0",
24+
"ts-node": "10.9.1",
25+
"typescript": "~5.0.0"
26+
},
27+
"volta": {
28+
"extends": "../../package.json",
29+
"node": "22.20.0"
30+
},
31+
"sentryTest": {
32+
"variants": [
33+
{
34+
"build-command": "pnpm test:build:keep-client-sourcemaps",
35+
"assert-command": "pnpm test:assert:keep-client-sourcemaps",
36+
"label": "nuxt-4-sourcemaps (client source maps kept)"
37+
},
38+
{
39+
"build-command": "pnpm test:build:static",
40+
"assert-command": "pnpm test:assert:static",
41+
"label": "nuxt-4-sourcemaps (static / nuxt generate)"
42+
}
43+
]
44+
}
45+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as Sentry from '@sentry/nuxt';
2+
import { useRuntimeConfig } from '#imports';
3+
4+
Sentry.init({
5+
dsn: useRuntimeConfig().public.sentry.dsn,
6+
tracesSampleRate: 1.0,
7+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import * as Sentry from '@sentry/nuxt';
2+
3+
Sentry.init({
4+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
5+
tracesSampleRate: 1.0,
6+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineEventHandler } from '#imports';
2+
3+
// SOURCEMAP_MARKER_SERVER — the server counterpart of the client marker. Nitro defaults to
4+
// `sourcemapExcludeSources: true`, which would drop this from the uploaded map; the Sentry module
5+
// flips it to `false`, so finding this marker in `sourcesContent` is what proves that still works.
6+
export default defineEventHandler(() => {
7+
throw new Error('Server error from the Nuxt source map E2E app');
8+
});
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' });

0 commit comments

Comments
 (0)