Skip to content

Commit 34fa1ed

Browse files
chargomeclaude
andcommitted
test(remix): Add source map upload e2e test app
Asserts each client chunk carries exactly one debug ID and that the shipped IDs match the uploaded artifact bundles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a366b66 commit 34fa1ed

9 files changed

Lines changed: 219 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: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 CLIENT_ASSETS_DIR = 'build/client/assets';
7+
8+
// Both injectors write this assignment, so counting it per file counts injections regardless of
9+
// which one ran. Matching only the bundler plugin's trailing `_sentryDebugIdIdentifier` would miss
10+
// the `sentry-cli` snippet, which omits it.
11+
const DEBUG_ID_ASSIGNMENT =
12+
/_sentryDebugIds\[[^\]]+\]\s*=\s*"([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})"/gi;
13+
14+
function getClientChunks(): string[] {
15+
assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`);
16+
17+
return fs
18+
.readdirSync(CLIENT_ASSETS_DIR)
19+
.filter(file => file.endsWith('.js'))
20+
.map(file => path.join(CLIENT_ASSETS_DIR, file));
21+
}
22+
23+
const chunks = getClientChunks();
24+
assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASSETS_DIR}`);
25+
26+
// 1. No chunk carries more than one debug ID.
27+
//
28+
// Two injections per chunk is the failure mode of
29+
// https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at runtime,
30+
// `applyDebugIds` flattens them to a single filename, and the last one wins. For Remix that happens
31+
// when an app keeps `npx @sentry/remix --upload-sourcemaps` in its build script alongside the Vite
32+
// plugin, so the CLI injects a second ID over the plugin's.
33+
const injectedDebugIds = new Map<string, string>();
34+
35+
for (const chunk of chunks) {
36+
const code = fs.readFileSync(chunk, 'utf-8');
37+
const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string);
38+
39+
assert.ok(
40+
ids.length <= 1,
41+
`Expected at most one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}.`,
42+
);
43+
44+
if (ids.length === 1) {
45+
injectedDebugIds.set(chunk, ids[0] as string);
46+
}
47+
}
48+
49+
console.log(`no client chunk carries more than one debug ID (${injectedDebugIds.size}/${chunks.length} carry one)\n`);
50+
51+
const requests = loadMockServerResults();
52+
const bundles = getArtifactBundles(requests);
53+
assert.ok(bundles.length > 0, 'Expected at least one uploaded artifact bundle');
54+
55+
// 2. Source maps with real content reached Sentry.
56+
//
57+
// Asserting on the upload rather than on disk, because deleting the maps after a successful upload
58+
// is the intended behaviour - the plugin defaults `filesToDeleteAfterUpload` when the app does not
59+
// configure source maps itself.
60+
const uploadedSourcemaps = getSourcemaps(bundles);
61+
assert.ok(uploadedSourcemaps.length > 0, 'Expected at least one source map in the uploaded artifact bundles');
62+
assert.ok(
63+
uploadedSourcemaps.some(entry => (entry.sourcemap.mappings?.length ?? 0) > 0),
64+
'Expected at least one uploaded source map with non-empty mappings',
65+
);
66+
console.log(`${uploadedSourcemaps.length} source map(s) uploaded with content`);
67+
68+
// 3. The debug IDs that shipped are the ones that were uploaded.
69+
//
70+
// This is what actually breaks un-minification: a chunk can carry a perfectly valid debug ID that
71+
// has no artifact bundle behind it.
72+
const debugIdPairs = getDebugIdPairs(bundles);
73+
const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase()));
74+
assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source map pair with a debug ID');
75+
76+
// Vite emits some assets without a source map, so they can never be part of an uploaded pair. Key
77+
// off the uploaded JS file names instead of the maps on disk, which are deleted after upload.
78+
const uploadedJsFiles = new Set(debugIdPairs.map(pair => path.basename(pair.jsUrl)));
79+
let crossCheckedChunks = 0;
80+
81+
for (const chunk of chunks) {
82+
if (!uploadedJsFiles.has(path.basename(chunk))) {
83+
continue;
84+
}
85+
86+
const injectedDebugId = injectedDebugIds.get(chunk);
87+
assert.ok(
88+
injectedDebugId,
89+
`Expected exactly one debug ID in ${chunk}, found none, even though its source map was uploaded.`,
90+
);
91+
92+
assert.ok(
93+
uploadedDebugIds.has(injectedDebugId.toLowerCase()),
94+
`Debug ID ${injectedDebugId} in ${chunk} was never uploaded.`,
95+
);
96+
crossCheckedChunks++;
97+
}
98+
99+
assert.ok(crossCheckedChunks > 0, 'Expected at least one uploaded chunk to cross-check debug IDs against');
100+
console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`);
101+
102+
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; 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)