|
| 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!'); |
0 commit comments