diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts index 1ec474157983..6bd6b5f92568 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts @@ -1,15 +1,14 @@ +import * as assert from 'assert/strict'; import { - loadSourcemapUploadRecords, - assertSourcemapUploadRequests, - getArtifactBundleManifests, - assertDebugIdPairs, - assertSourcemapMappings, - assertSourcemapSources, - assertArtifactBundleAssembly, - getSourcemapUploadSummary, + loadMockServerResults, + getArtifactBundles, + getDebugIdPairs, + getSourcemaps, + getChunkUploadPosts, + getAssembleRequests, } from '@sentry-internal/test-utils'; -const requests = loadSourcemapUploadRecords(); +const requests = loadMockServerResults(); console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); for (const req of requests) { @@ -17,27 +16,59 @@ for (const req of requests) { } console.log(''); -assertSourcemapUploadRequests(requests, 'fake-auth-token'); +// Auth token is present +const authenticated = requests.filter(r => r.authorization.includes('fake-auth-token')); +assert.ok(authenticated.length > 0, 'Expected requests with the configured auth token'); -const manifests = getArtifactBundleManifests(requests); -console.log(`Found ${manifests.length} artifact bundle manifest(s):\n`); +// Chunk uploads happened +const chunkPosts = getChunkUploadPosts(requests); +assert.ok(chunkPosts.length > 0, 'Expected at least one chunk upload POST'); +assert.ok( + chunkPosts.some(r => r.bodySize > 0), + 'Expected at least one chunk upload with a non-empty body', +); -const debugIdPairs = assertDebugIdPairs(manifests); -console.log(`Found ${debugIdPairs.length} JS/sourcemap pairs with debug IDs:`); +// Release endpoint was called +assert.ok( + requests.some(r => r.url?.includes('/releases/')), + 'Expected at least one request to releases endpoint', +); + +// Artifact bundles have manifests +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`); + +// Debug ID pairs exist and are valid UUIDs +const debugIdPairs = getDebugIdPairs(bundles); +assert.ok(debugIdPairs.length > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); + +const uuidRegex = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; for (const pair of debugIdPairs) { + assert.match(pair.debugId, uuidRegex, `Invalid debug ID: ${pair.debugId}`); console.log(` ${pair.debugId} ${pair.jsUrl}`); } console.log(''); -assertSourcemapMappings(manifests); -assertSourcemapSources(manifests, /client-page|page\.tsx/); -assertArtifactBundleAssembly(requests, 'test-project'); +// Sourcemaps have real content +const sourcemaps = getSourcemaps(bundles); +assert.ok( + sourcemaps.some(s => s.sourcemap.mappings && s.sourcemap.mappings.length > 0), + 'Expected at least one sourcemap with non-empty mappings', +); -const summary = getSourcemapUploadSummary(requests, manifests, debugIdPairs); +// At least one sourcemap references app source files +assert.ok( + sourcemaps.some(s => s.sourcemap.sources?.some(src => /client-page|page\.tsx/.test(src))), + 'Expected at least one sourcemap referencing app source files', +); + +// Assemble requests reference the correct project +const assembleReqs = getAssembleRequests(requests); +assert.ok(assembleReqs.length > 0, 'Expected at least one assemble request'); +for (const req of assembleReqs) { + assert.ok(req.assembleBody?.projects?.includes('test-project'), 'Expected assemble request to include test-project'); + assert.ok((req.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); +} -console.log('\nAll sourcemap upload assertions passed!'); -console.log(` - ${summary.totalRequests} total requests captured`); -console.log(` - ${summary.chunkUploadPosts} chunk upload POST requests`); -console.log(` - ${summary.artifactBundles} artifact bundles with manifests`); -console.log(` - ${summary.debugIdPairs} JS/sourcemap pairs with debug IDs`); -console.log(` - ${summary.assembleRequests} artifact bundle assemble requests`); +console.log('All sourcemap upload assertions passed!'); diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 3e9e2cf44d65..749cbdbdd663 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -15,4 +15,4 @@ export { createBasicSentryServer, createTestServer } from './server'; export { startMockSentryServer } from './mock-sentry-server'; export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; -export * from './sourcemap-upload-assertions'; +export * from './sourcemap-upload-utils'; diff --git a/dev-packages/test-utils/src/mock-sentry-server.ts b/dev-packages/test-utils/src/mock-sentry-server.ts index c26c5ae9d18a..93ae312905e8 100644 --- a/dev-packages/test-utils/src/mock-sentry-server.ts +++ b/dev-packages/test-utils/src/mock-sentry-server.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import zlib from 'node:zlib'; -import type { ChunkFileRecord, RequestRecord } from './sourcemap-upload-assertions'; +import type { ChunkFileRecord, RequestRecord } from './sourcemap-upload-utils'; export interface MockSentryServerOptions { port?: number; diff --git a/dev-packages/test-utils/src/sourcemap-upload-assertions.ts b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts deleted file mode 100644 index 951a9cf3c15b..000000000000 --- a/dev-packages/test-utils/src/sourcemap-upload-assertions.ts +++ /dev/null @@ -1,278 +0,0 @@ -import * as assert from 'assert/strict'; -import * as fs from 'fs'; -import * as path from 'path'; - -export interface ManifestFile { - type: 'minified_source' | 'source_map'; - url: string; - headers?: Record; -} - -export interface Manifest { - files: Record; - debug_id?: string; - org?: string; - project?: string; - release?: string; -} - -export interface ChunkFileRecord { - bundleDir?: string; - zipFile?: string; - manifest?: Manifest; - fileCount?: number; - note?: string; -} - -export interface RequestRecord { - method: string; - url: string; - contentType: string; - authorization: string; - bodySize: number; - timestamp: string; - hasBody?: boolean; - chunkFiles?: ChunkFileRecord[]; - assembleBody?: { - checksum: string; - chunks: string[]; - projects: string[]; - }; -} - -export interface DebugIdPair { - jsUrl: string; - mapUrl: string; - debugId: string; - bundleDir: string; -} - -export interface ArtifactBundleData { - bundleDir: string; - manifest: Manifest; -} - -/** - * Load parsed request records from the JSON output file written by the mock Sentry server. - */ -export function loadSourcemapUploadRecords(outputFile = '.tmp_mock_uploads.json'): RequestRecord[] { - assert.ok(fs.existsSync(outputFile), `Expected ${outputFile} to exist. Did the mock server run?`); - return JSON.parse(fs.readFileSync(outputFile, 'utf-8')); -} - -/** - * Assert basic upload mechanics: auth token, chunk uploads with body, releases. - */ -export function assertSourcemapUploadRequests(requests: RequestRecord[], authToken: string): void { - assert.ok(requests.length > 0, 'Expected at least one request to the mock Sentry server'); - - const authenticatedRequests = requests.filter(r => r.authorization.includes(authToken)); - assert.ok(authenticatedRequests.length > 0, 'Expected at least one request with the configured auth token'); - - const chunkUploadPosts = requests.filter(r => r.url?.includes('chunk-upload') && r.method === 'POST'); - assert.ok(chunkUploadPosts.length > 0, 'Expected at least one POST to chunk-upload endpoint'); - - const uploadsWithBody = chunkUploadPosts.filter(r => r.bodySize > 0); - assert.ok(uploadsWithBody.length > 0, 'Expected at least one chunk upload with a non-empty body'); - - const releaseRequests = requests.filter(r => r.url?.includes('/releases/')); - assert.ok(releaseRequests.length > 0, 'Expected at least one request to releases endpoint'); -} - -/** - * Extract all artifact bundle manifests from chunk upload records. - */ -export function getArtifactBundleManifests(requests: RequestRecord[]): ArtifactBundleData[] { - const allManifests: ArtifactBundleData[] = []; - const chunkUploadPosts = requests.filter(r => r.url?.includes('chunk-upload') && r.method === 'POST'); - - for (const req of chunkUploadPosts) { - for (const chunk of req.chunkFiles ?? []) { - if (chunk.manifest && chunk.bundleDir) { - allManifests.push({ bundleDir: chunk.bundleDir, manifest: chunk.manifest }); - } - } - } - - assert.ok(allManifests.length > 0, 'Expected at least one artifact bundle with a manifest.json'); - return allManifests; -} - -/** - * Assert debug ID pairs exist and are valid UUIDs, returns them. - */ -export function assertDebugIdPairs(manifests: ArtifactBundleData[]): DebugIdPair[] { - const debugIdPairs: DebugIdPair[] = []; - - for (const { bundleDir, manifest } of manifests) { - const files = manifest.files; - const fileEntries = Object.entries(files); - - for (const [, entry] of fileEntries) { - if (entry.type !== 'minified_source') continue; - - const debugId = entry.headers?.['debug-id']; - const sourcemapRef = entry.headers?.['sourcemap']; - if (!debugId || !sourcemapRef) continue; - - const mapEntry = fileEntries.find(([, e]) => e.type === 'source_map' && e.headers?.['debug-id'] === debugId); - - if (mapEntry) { - debugIdPairs.push({ - jsUrl: entry.url, - mapUrl: mapEntry[1].url, - debugId, - bundleDir, - }); - } - } - } - - assert.ok( - debugIdPairs.length > 0, - 'Expected at least one JS/sourcemap pair with matching debug IDs in the uploaded artifact bundles', - ); - - const uuidRegex = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; - for (const pair of debugIdPairs) { - assert.match(pair.debugId, uuidRegex, `Expected debug ID to be a valid UUID, got: ${pair.debugId}`); - } - - return debugIdPairs; -} - -interface ParsedSourcemap { - version?: number; - sources?: string[]; - mappings?: string; -} - -interface SourcemapEntry { - url: string; - bundleDir: string; - sourcemap: ParsedSourcemap; -} - -/** - * Iterate over all source_map entries in the manifests, reading and parsing each sourcemap file. - * Skips entries that don't exist on disk or fail to parse. - * Return `true` from the callback to stop iteration early. - */ -function forEachSourcemap(manifests: ArtifactBundleData[], callback: (entry: SourcemapEntry) => boolean | void): void { - for (const { bundleDir, manifest } of manifests) { - for (const [filePath, entry] of Object.entries(manifest.files)) { - if (entry.type !== 'source_map') continue; - - const fullPath = path.join(bundleDir, filePath); - if (!fs.existsSync(fullPath)) continue; - - let content: string; - try { - content = fs.readFileSync(fullPath, 'utf-8'); - } catch { - continue; - } - - let sourcemap: ParsedSourcemap; - try { - sourcemap = JSON.parse(content); - } catch { - continue; - } - - if (callback({ url: entry.url, bundleDir, sourcemap }) === true) { - return; - } - } - } -} - -/** - * Assert at least one sourcemap has non-empty mappings. - */ -export function assertSourcemapMappings(manifests: ArtifactBundleData[]): void { - let foundRealMappings = false; - - forEachSourcemap(manifests, ({ sourcemap }) => { - if (sourcemap.mappings && sourcemap.mappings.length > 0) { - foundRealMappings = true; - return true; - } - return false; - }); - - assert.ok(foundRealMappings, 'Expected at least one sourcemap with non-empty mappings'); -} - -/** - * Assert a sourcemap references source files matching a pattern. - */ -export function assertSourcemapSources(manifests: ArtifactBundleData[], sourcePattern: RegExp): void { - const regex = sourcePattern; - let found = false; - - forEachSourcemap(manifests, ({ url, sourcemap }) => { - if (sourcemap.sources?.some(s => regex.test(s))) { - found = true; - - // eslint-disable-next-line no-console - console.log(`Sourcemap ${url} references app sources:`); - for (const src of sourcemap.sources.filter(s => regex.test(s))) { - // eslint-disable-next-line no-console - console.log(` - ${src}`); - } - - assert.equal(sourcemap.version, 3, `Expected sourcemap version 3, got ${sourcemap.version}`); - assert.ok( - sourcemap.mappings && sourcemap.mappings.length > 0, - 'Expected sourcemap for app source to have non-empty mappings', - ); - } - }); - - assert.ok(found, `Expected at least one sourcemap to reference sources matching ${sourcePattern}`); -} - -/** - * Assert assemble requests reference the expected project. - */ -export function assertArtifactBundleAssembly(requests: RequestRecord[], project: string): void { - const assembleRequests = requests.filter(r => r.url?.includes('/artifactbundle/assemble/') && r.assembleBody); - assert.ok(assembleRequests.length > 0, 'Expected at least one artifact bundle assemble request'); - - for (const req of assembleRequests) { - assert.ok( - req.assembleBody?.projects?.includes(project), - `Expected assemble request to include project "${project}". Got: ${req.assembleBody?.projects}`, - ); - assert.ok( - (req.assembleBody?.chunks?.length ?? 0) > 0, - 'Expected assemble request to have at least one chunk checksum', - ); - } -} - -export interface SourcemapUploadSummary { - totalRequests: number; - chunkUploadPosts: number; - artifactBundles: number; - debugIdPairs: number; - assembleRequests: number; -} - -/** - * Compute summary counts from captured requests, manifests, and debug ID pairs. - */ -export function getSourcemapUploadSummary( - requests: RequestRecord[], - manifests: ArtifactBundleData[], - debugIdPairs: DebugIdPair[], -): SourcemapUploadSummary { - return { - totalRequests: requests.length, - chunkUploadPosts: requests.filter(r => r.url?.includes('chunk-upload') && r.method === 'POST').length, - artifactBundles: manifests.length, - debugIdPairs: debugIdPairs.length, - assembleRequests: requests.filter(r => r.url?.includes('/artifactbundle/assemble/') && r.assembleBody).length, - }; -} diff --git a/dev-packages/test-utils/src/sourcemap-upload-utils.ts b/dev-packages/test-utils/src/sourcemap-upload-utils.ts new file mode 100644 index 000000000000..96a621b5f44c --- /dev/null +++ b/dev-packages/test-utils/src/sourcemap-upload-utils.ts @@ -0,0 +1,173 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface ManifestFile { + type: 'minified_source' | 'source_map'; + url: string; + headers?: Record; +} + +export interface Manifest { + files: Record; + debug_id?: string; + org?: string; + project?: string; + release?: string; +} + +export interface ChunkFileRecord { + bundleDir?: string; + zipFile?: string; + manifest?: Manifest; + fileCount?: number; + note?: string; +} + +export interface RequestRecord { + method: string; + url: string; + contentType: string; + authorization: string; + bodySize: number; + timestamp: string; + hasBody?: boolean; + chunkFiles?: ChunkFileRecord[]; + assembleBody?: { + checksum: string; + chunks: string[]; + projects: string[]; + }; +} + +export interface DebugIdPair { + jsUrl: string; + mapUrl: string; + debugId: string; + bundleDir: string; +} + +export interface ArtifactBundleData { + bundleDir: string; + manifest: Manifest; +} + +export interface ParsedSourcemap { + [key: string]: unknown; + version?: number; + sources?: string[]; + mappings?: string; +} + +export interface SourcemapEntry { + url: string; + bundleDir: string; + sourcemap: ParsedSourcemap; +} + +/** + * Load parsed request records from the JSON output file written by the mock Sentry server. + */ +export function loadMockServerResults(outputFile = '.tmp_mock_uploads.json'): RequestRecord[] { + if (!fs.existsSync(outputFile)) { + throw new Error(`Expected ${outputFile} to exist. Did the mock server run?`); + } + return JSON.parse(fs.readFileSync(outputFile, 'utf-8')); +} + +/** + * Extract all artifact bundle manifests from chunk upload records. + */ +export function getArtifactBundles(requests: RequestRecord[]): ArtifactBundleData[] { + const bundles: ArtifactBundleData[] = []; + const chunkUploadPosts = requests.filter(r => r.url?.includes('chunk-upload') && r.method === 'POST'); + + for (const req of chunkUploadPosts) { + for (const chunk of req.chunkFiles ?? []) { + if (chunk.manifest && chunk.bundleDir) { + bundles.push({ bundleDir: chunk.bundleDir, manifest: chunk.manifest }); + } + } + } + + return bundles; +} + +/** + * Extract debug ID pairs (JS file + sourcemap with matching debug-id) from artifact bundles. + */ +export function getDebugIdPairs(bundles: ArtifactBundleData[]): DebugIdPair[] { + const pairs: DebugIdPair[] = []; + + for (const { bundleDir, manifest } of bundles) { + const fileEntries = Object.entries(manifest.files); + + for (const [, entry] of fileEntries) { + if (entry.type !== 'minified_source') continue; + + const debugId = entry.headers?.['debug-id']; + const sourcemapRef = entry.headers?.['sourcemap']; + if (!debugId || !sourcemapRef) continue; + + const mapEntry = fileEntries.find(([, e]) => e.type === 'source_map' && e.headers?.['debug-id'] === debugId); + + if (mapEntry) { + pairs.push({ + jsUrl: entry.url, + mapUrl: mapEntry[1].url, + debugId, + bundleDir, + }); + } + } + } + + return pairs; +} + +/** + * Read and parse all sourcemap files from artifact bundles. + */ +export function getSourcemaps(bundles: ArtifactBundleData[]): SourcemapEntry[] { + const sourcemaps: SourcemapEntry[] = []; + + for (const { bundleDir, manifest } of bundles) { + for (const [filePath, entry] of Object.entries(manifest.files)) { + if (entry.type !== 'source_map') continue; + + const fullPath = path.join(bundleDir, filePath); + if (!fs.existsSync(fullPath)) continue; + + let content: string; + try { + content = fs.readFileSync(fullPath, 'utf-8'); + } catch { + continue; + } + + let sourcemap: ParsedSourcemap; + try { + sourcemap = JSON.parse(content); + } catch { + continue; + } + + sourcemaps.push({ url: entry.url, bundleDir, sourcemap }); + } + } + + return sourcemaps; +} + +/** + * Get chunk upload POST requests. + */ +export function getChunkUploadPosts(requests: RequestRecord[]): RequestRecord[] { + return requests.filter(r => r.url?.includes('chunk-upload') && r.method === 'POST'); +} + +/** + * Get artifact bundle assemble requests. + */ +export function getAssembleRequests(requests: RequestRecord[]): RequestRecord[] { + return requests.filter(r => r.url?.includes('/artifactbundle/assemble/') && r.assembleBody); +}