Skip to content

Commit 61decb7

Browse files
chargomeclaude
andauthored
test(remix): Add source map upload e2e test app (#24464)
Asserts each client chunk carries exactly one debug ID and that the shipped IDs match the uploaded artifact bundles. Stacked on #24463. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ed586c1 commit 61decb7

9 files changed

Lines changed: 245 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/node_modules
2+
/build
3+
.tmp_mock_uploads.json
4+
.tmp_chunks
5+
.tmp_build_stdout
6+
.tmp_build_stderr
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { RemixBrowser } from '@remix-run/react';
2+
import { StrictMode, startTransition } from 'react';
3+
import { hydrateRoot } from 'react-dom/client';
4+
5+
startTransition(() => {
6+
hydrateRoot(
7+
document,
8+
<StrictMode>
9+
<RemixBrowser />
10+
</StrictMode>,
11+
);
12+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
2+
3+
export default function App() {
4+
return (
5+
<html lang="en">
6+
<head>
7+
<meta charSet="utf-8" />
8+
<meta name="viewport" content="width=device-width, initial-scale=1" />
9+
<Meta />
10+
<Links />
11+
</head>
12+
<body>
13+
<Outlet />
14+
<ScrollRestoration />
15+
<Scripts />
16+
</body>
17+
</html>
18+
);
19+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Index() {
2+
return <h1>Sourcemaps test app</h1>;
3+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import * as assert from 'assert/strict';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import { getArtifactBundles, getDebugIdPairs, getSourcemaps, loadMockServerResults } from '@sentry-internal/test-utils';
5+
6+
const BUILD_DIR = 'build';
7+
const CLIENT_ASSETS_DIR = 'build/client/assets';
8+
9+
// Both injectors write this assignment, so counting it per file counts injections regardless of
10+
// which one ran. Matching only the bundler plugin's trailing `_sentryDebugIdIdentifier` would miss
11+
// the `sentry-cli` snippet, which omits it.
12+
const DEBUG_ID_ASSIGNMENT =
13+
/_sentryDebugIds\[[^\]]+\]\s*=\s*"([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})"/gi;
14+
15+
function getClientChunks(): string[] {
16+
assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`);
17+
18+
return fs
19+
.readdirSync(CLIENT_ASSETS_DIR)
20+
.filter(file => file.endsWith('.js'))
21+
.map(file => path.join(CLIENT_ASSETS_DIR, file));
22+
}
23+
24+
const chunks = getClientChunks();
25+
assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASSETS_DIR}`);
26+
27+
// 1. No chunk carries more than one debug ID.
28+
//
29+
// Two injections per chunk is the failure mode of
30+
// https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at runtime,
31+
// `applyDebugIds` flattens them to a single filename, and the last one wins. For Remix that happens
32+
// when an app keeps `npx @sentry/remix --upload-sourcemaps` in its build script alongside the Vite
33+
// plugin, so the CLI injects a second ID over the plugin's.
34+
const injectedDebugIds = new Map<string, string>();
35+
36+
for (const chunk of chunks) {
37+
const code = fs.readFileSync(chunk, 'utf-8');
38+
const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string);
39+
40+
assert.ok(
41+
ids.length <= 1,
42+
`Expected at most one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}.`,
43+
);
44+
45+
if (ids.length === 1) {
46+
injectedDebugIds.set(chunk, ids[0] as string);
47+
}
48+
}
49+
50+
console.log(`no client chunk carries more than one debug ID (${injectedDebugIds.size}/${chunks.length} carry one)\n`);
51+
52+
const requests = loadMockServerResults();
53+
const bundles = getArtifactBundles(requests);
54+
assert.ok(bundles.length > 0, 'Expected at least one uploaded artifact bundle');
55+
56+
// 2. Source maps with real content reached Sentry.
57+
//
58+
// Asserting on the upload rather than on disk, because deleting the maps after a successful upload
59+
// is the intended behaviour - the plugin defaults `filesToDeleteAfterUpload` when the app does not
60+
// configure source maps itself.
61+
const uploadedSourcemaps = getSourcemaps(bundles);
62+
assert.ok(uploadedSourcemaps.length > 0, 'Expected at least one source map in the uploaded artifact bundles');
63+
assert.ok(
64+
uploadedSourcemaps.some(entry => (entry.sourcemap.mappings?.length ?? 0) > 0),
65+
'Expected at least one uploaded source map with non-empty mappings',
66+
);
67+
console.log(`${uploadedSourcemaps.length} source map(s) uploaded with content`);
68+
69+
// 3. The debug IDs that shipped are the ones that were uploaded.
70+
//
71+
// This is what actually breaks un-minification: a chunk can carry a perfectly valid debug ID that
72+
// has no artifact bundle behind it.
73+
const debugIdPairs = getDebugIdPairs(bundles);
74+
const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase()));
75+
assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source map pair with a debug ID');
76+
77+
// The uploaded artifacts are named after the debug ID (`~/<debugId>-<n>.js`), not after the chunk
78+
// they came from, so the two file name sets never line up. Cross-check the IDs themselves: every
79+
// debug ID that shipped has to have an artifact bundle behind it.
80+
let crossCheckedChunks = 0;
81+
82+
for (const [chunk, injectedDebugId] of injectedDebugIds) {
83+
assert.ok(
84+
uploadedDebugIds.has(injectedDebugId.toLowerCase()),
85+
`Debug ID ${injectedDebugId} in ${chunk} was never uploaded.\n` +
86+
`Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}`,
87+
);
88+
crossCheckedChunks++;
89+
}
90+
91+
assert.ok(
92+
crossCheckedChunks > 0,
93+
'Expected at least one chunk carrying a debug ID to cross-check against the upload.\n' +
94+
`Client chunks: ${JSON.stringify(chunks.map(chunk => path.basename(chunk)))}\n` +
95+
`Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}\n` +
96+
`Uploaded JS urls: ${JSON.stringify(debugIdPairs.map(pair => pair.jsUrl))}`,
97+
);
98+
console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`);
99+
100+
// 4. No source map survived the build.
101+
//
102+
// The plugin defaults `filesToDeleteAfterUpload` when the app configures no source map setting, so
103+
// a leftover `.map` means one of the builds was never cleaned up. Remix runs a client and an SSR
104+
// pass with different `outDir`s, while the deletion glob is held in a promise that settles once -
105+
// this is what catches the second pass being left behind.
106+
function findSourceMaps(dir: string): string[] {
107+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
108+
const entryPath = path.join(dir, entry.name);
109+
110+
if (entry.isDirectory()) {
111+
return findSourceMaps(entryPath);
112+
}
113+
114+
return entry.name.endsWith('.map') ? [entryPath] : [];
115+
});
116+
}
117+
118+
const leftoverSourceMaps = findSourceMaps(BUILD_DIR);
119+
assert.deepEqual(
120+
leftoverSourceMaps,
121+
[],
122+
`Expected every source map to be deleted after upload, found ${leftoverSourceMaps.length}:\n${leftoverSourceMaps.join(
123+
'\n',
124+
)}`,
125+
);
126+
console.log('no source maps left in the build output\n');
127+
128+
console.log('All remix source map assertions passed!');
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"private": true,
3+
"sideEffects": false,
4+
"type": "module",
5+
"scripts": {
6+
"build": "node start-mock-sentry-server.mjs & remix vite:build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; cat .tmp_build_stdout .tmp_build_stderr; exit $BUILD_EXIT",
7+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
8+
"test:build": "pnpm install && pnpm build",
9+
"test:assert": "pnpm tsx assert-build.ts"
10+
},
11+
"dependencies": {
12+
"@sentry/remix": "file:../../packed/sentry-remix-packed.tgz",
13+
"@remix-run/node": "2.17.4",
14+
"@remix-run/react": "2.17.4",
15+
"@remix-run/serve": "2.17.4",
16+
"isbot": "^3.6.8",
17+
"react": "^18.2.0",
18+
"react-dom": "^18.2.0"
19+
},
20+
"devDependencies": {
21+
"@remix-run/dev": "2.17.4",
22+
"@sentry-internal/test-utils": "link:../../../test-utils",
23+
"@types/node": "^20",
24+
"@types/react": "^18.2.64",
25+
"@types/react-dom": "^18.2.34",
26+
"tsx": "^4.23.0",
27+
"typescript": "^5.1.6",
28+
"vite": "^5.4.11"
29+
},
30+
"volta": {
31+
"extends": "../../package.json"
32+
}
33+
}
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({ port: 3033 });
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"include": ["./app/**/*.ts", "./app/**/*.tsx", "./*.ts"],
3+
"exclude": ["node_modules", "build"],
4+
"compilerOptions": {
5+
"lib": ["DOM", "DOM.Iterable", "ES2022"],
6+
"types": ["node", "vite/client"],
7+
"isolatedModules": true,
8+
"esModuleInterop": true,
9+
"jsx": "react-jsx",
10+
"moduleResolution": "bundler",
11+
"module": "ES2022",
12+
"resolveJsonModule": true,
13+
"target": "ES2022",
14+
"strict": true,
15+
"allowJs": true,
16+
"skipLibCheck": true,
17+
"forceConsistentCasingInFileNames": true,
18+
"noEmit": true
19+
}
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { vitePlugin as remix } from '@remix-run/dev';
2+
import { sentryRemixVitePlugin } from '@sentry/remix/vite';
3+
import { defineConfig } from 'vite';
4+
5+
export default defineConfig({
6+
plugins: [
7+
remix({
8+
ignoredRouteFiles: ['**/.*'],
9+
}),
10+
sentryRemixVitePlugin({
11+
authToken: 'fake-auth-token',
12+
org: 'test-org',
13+
project: 'test-project',
14+
sentryUrl: 'http://localhost:3033',
15+
release: {
16+
name: 'test-release',
17+
},
18+
debug: true,
19+
}),
20+
],
21+
});

0 commit comments

Comments
 (0)