From 82b914e50a119775414ccb06f52b15dabf7ae17c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 5 Mar 2026 12:04:21 +0100 Subject: [PATCH 1/5] add sourcemaps test for nextjs turbo --- .../nextjs-sourcemaps/.gitignore | 3 + .../nextjs-sourcemaps/.npmrc | 2 + .../app/client-page/page.tsx | 21 ++ .../nextjs-sourcemaps/app/layout.tsx | 7 + .../nextjs-sourcemaps/app/page.tsx | 3 + .../nextjs-sourcemaps/assert-build.ts | 43 +++ .../nextjs-sourcemaps/instrumentation.ts | 3 + .../nextjs-sourcemaps/next-env.d.ts | 6 + .../nextjs-sourcemaps/next.config.js | 18 ++ .../nextjs-sourcemaps/package.json | 28 ++ .../start-mock-sentry-server.mjs | 3 + .../nextjs-sourcemaps/tsconfig.json | 29 ++ dev-packages/test-utils/src/index.ts | 4 + .../test-utils/src/mock-sentry-server.ts | 212 ++++++++++++++ .../src/sourcemap-upload-assertions.ts | 276 ++++++++++++++++++ 15 files changed, 658 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/client-page/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/next-env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/next.config.js create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/tsconfig.json create mode 100644 dev-packages/test-utils/src/mock-sentry-server.ts create mode 100644 dev-packages/test-utils/src/sourcemap-upload-assertions.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/.gitignore new file mode 100644 index 000000000000..d0d877e6fa6b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/.gitignore @@ -0,0 +1,3 @@ +.next +.tmp_mock_uploads.json +.tmp_chunks diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/client-page/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/client-page/page.tsx new file mode 100644 index 000000000000..bfc731593558 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/client-page/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +function getGreeting(name: string): string { + return `Hello, ${name}! Welcome to the sourcemap test app.`; +} + +export default function ClientPage() { + const greeting = getGreeting('World'); + return ( +
+

{greeting}

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/page.tsx new file mode 100644 index 000000000000..8db341ed627b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Hello

; +} 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 new file mode 100644 index 000000000000..1ec474157983 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/assert-build.ts @@ -0,0 +1,43 @@ +import { + loadSourcemapUploadRecords, + assertSourcemapUploadRequests, + getArtifactBundleManifests, + assertDebugIdPairs, + assertSourcemapMappings, + assertSourcemapSources, + assertArtifactBundleAssembly, + getSourcemapUploadSummary, +} from '@sentry-internal/test-utils'; + +const requests = loadSourcemapUploadRecords(); + +console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); +for (const req of requests) { + console.log(` ${req.method} ${req.url} (${req.bodySize} bytes)`); +} +console.log(''); + +assertSourcemapUploadRequests(requests, 'fake-auth-token'); + +const manifests = getArtifactBundleManifests(requests); +console.log(`Found ${manifests.length} artifact bundle manifest(s):\n`); + +const debugIdPairs = assertDebugIdPairs(manifests); +console.log(`Found ${debugIdPairs.length} JS/sourcemap pairs with debug IDs:`); +for (const pair of debugIdPairs) { + console.log(` ${pair.debugId} ${pair.jsUrl}`); +} +console.log(''); + +assertSourcemapMappings(manifests); +assertSourcemapSources(manifests, /client-page|page\.tsx/); +assertArtifactBundleAssembly(requests, 'test-project'); + +const summary = getSourcemapUploadSummary(requests, manifests, debugIdPairs); + +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`); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/instrumentation.ts new file mode 100644 index 000000000000..34854ff0cb01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/instrumentation.ts @@ -0,0 +1,3 @@ +export async function register() { + // Minimal instrumentation - no runtime Sentry init needed for sourcemap upload testing +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/next-env.d.ts new file mode 100644 index 000000000000..1511519d3892 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import './.next/types/routes.d.ts'; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/next.config.js new file mode 100644 index 000000000000..63bb8b443a14 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/next.config.js @@ -0,0 +1,18 @@ +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = withSentryConfig(nextConfig, { + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { + name: 'test-release', + }, + sourcemaps: { + deleteSourcemapsAfterUpload: false, + }, + debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json new file mode 100644 index 000000000000..6c5ee753bb8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json @@ -0,0 +1,28 @@ +{ + "name": "nextjs-sourcemaps", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "node start-mock-sentry-server.mjs & next build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; exit $BUILD_EXIT", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "next": "16.1.6", + "react": "19.1.0", + "react-dom": "19.1.0", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^18.19.1", + "@types/react": "^19", + "@types/react-dom": "^19", + "ts-node": "10.9.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..cce37a5f9daf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..ddce4b34570e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], + "exclude": ["node_modules"], + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } +} diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 4a3dfcfaa4c8..3e9e2cf44d65 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -12,3 +12,7 @@ export { export { getPlaywrightConfig } from './playwright-config'; export { createBasicSentryServer, createTestServer } from './server'; + +export { startMockSentryServer } from './mock-sentry-server'; +export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; +export * from './sourcemap-upload-assertions'; diff --git a/dev-packages/test-utils/src/mock-sentry-server.ts b/dev-packages/test-utils/src/mock-sentry-server.ts new file mode 100644 index 000000000000..57496f0cbdb5 --- /dev/null +++ b/dev-packages/test-utils/src/mock-sentry-server.ts @@ -0,0 +1,212 @@ +import http from 'node:http'; +import fs from 'node:fs'; +import zlib from 'node:zlib'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import type { RequestRecord } from './sourcemap-upload-assertions'; + +export interface MockSentryServerOptions { + port?: number; + org?: string; + outputFile?: string; + outputDir?: string; +} + +export interface MockSentryServer { + port: number; + url: string; + close: () => void; +} + +/** + * Parse multipart form data to extract individual parts. + * sentry-cli uploads gzipped chunks as multipart/form-data. + */ +function parseMultipartParts(body: Buffer, boundary: string): { headers: string; content: Buffer }[] { + const parts: { headers: string; content: Buffer }[] = []; + const boundaryBuf = Buffer.from(`--${boundary}`); + + let start = 0; + while (start < body.length) { + const idx = body.indexOf(boundaryBuf, start); + if (idx === -1) break; + + const afterBoundary = idx + boundaryBuf.length; + if (body.slice(afterBoundary, afterBoundary + 2).toString() === '--') break; + + const headerEnd = body.indexOf('\r\n\r\n', afterBoundary); + if (headerEnd === -1) break; + + const headerStr = body.slice(afterBoundary, headerEnd).toString(); + + const nextBoundary = body.indexOf(boundaryBuf, headerEnd + 4); + const contentEnd = nextBoundary !== -1 ? nextBoundary - 2 : body.length; + const content = body.slice(headerEnd + 4, contentEnd); + + parts.push({ headers: headerStr, content }); + start = nextBoundary !== -1 ? nextBoundary : body.length; + } + + return parts; +} + +/** + * Starts a mock Sentry server that captures sourcemap upload requests. + * + * The server handles sentry-cli API endpoints (chunk-upload, artifact bundle assemble, + * releases) and writes captured request data to a JSON file and extracted bundles to a directory. + */ +export function startMockSentryServer(options: MockSentryServerOptions = {}): MockSentryServer { + const { port = 3032, org = 'test-org', outputFile = '.tmp_mock_uploads.json', outputDir = '.tmp_chunks' } = options; + + // Ensure chunks directory exists + if (fs.existsSync(outputDir)) { + fs.rmSync(outputDir, { recursive: true }); + } + fs.mkdirSync(outputDir); + + const requests: RequestRecord[] = []; + let chunkIndex = 0; + + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + + req.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + + req.on('end', () => { + const body = Buffer.concat(chunks); + const contentType = req.headers['content-type'] || ''; + const authorization = req.headers['authorization'] || ''; + + const record: RequestRecord = { + method: req.method || '', + url: req.url || '', + contentType, + authorization, + bodySize: body.length, + timestamp: new Date().toISOString(), + }; + + // For chunk upload POSTs, save and extract artifact bundles + if (req.url?.includes('chunk-upload') && req.method === 'POST' && body.length > 0) { + record.hasBody = true; + record.chunkFiles = []; + + const boundaryMatch = contentType.match(/boundary=(.+)/); + if (boundaryMatch) { + const parts = parseMultipartParts(body, boundaryMatch[1]!); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + const bundleDir = path.join(outputDir, `bundle_${chunkIndex}_${i}`); + + // Try to decompress (sentry-cli gzips chunks) + let zipBuffer: Buffer; + try { + zipBuffer = zlib.gunzipSync(part.content); + } catch { + zipBuffer = part.content; + } + + const zipFile = `${bundleDir}.zip`; + fs.writeFileSync(zipFile, zipBuffer); + + // Extract the zip to inspect contents + try { + fs.mkdirSync(bundleDir, { recursive: true }); + execSync(`unzip -q -o "${zipFile}" -d "${bundleDir}" 2>/dev/null`); + + // Read manifest.json if present + const manifestPath = path.join(bundleDir, 'manifest.json'); + if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + record.chunkFiles.push({ + bundleDir, + manifest, + fileCount: Object.keys(manifest.files || {}).length, + }); + } else { + record.chunkFiles.push({ bundleDir, note: 'no manifest.json found' }); + } + } catch (err: unknown) { + record.chunkFiles.push({ + zipFile, + note: `extraction failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + chunkIndex++; + } + } + } + + // For artifact bundle assemble, capture the request body + if (req.url?.includes('/artifactbundle/assemble/') && body.length > 0) { + try { + record.assembleBody = JSON.parse(body.toString('utf-8')); + } catch { + // ignore parse errors + } + } + + requests.push(record); + + // Write all collected requests to the output file after each request + fs.writeFileSync(outputFile, JSON.stringify(requests, null, 2)); + + // Route responses — order matters: most specific first + const url = req.url || ''; + + if (url.includes('/artifactbundle/assemble/')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ state: 'created', missingChunks: [] })); + } else if (url.includes('/chunk-upload/')) { + if (req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + url: `http://localhost:${port}/api/0/organizations/${org}/chunk-upload/`, + chunkSize: 8388608, + chunksPerRequest: 64, + maxFileSize: 2147483648, + maxRequestSize: 33554432, + concurrency: 1, + hashAlgorithm: 'sha1', + compression: ['gzip'], + accept: [ + 'debug_files', + 'release_files', + 'pdbs', + 'sources', + 'bcsymbolmaps', + 'il2cpp', + 'portablepdbs', + 'artifact_bundles', + ], + }), + ); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({})); + } + } else if (url.includes('/releases/')) { + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ version: 'test-release', dateCreated: new Date().toISOString() })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + } + }); + }); + + server.listen(port, () => { + console.log(`Mock Sentry server listening on port ${port}`); + }); + + return { + port, + url: `http://localhost:${port}`, + close: () => server.close(), + }; +} diff --git a/dev-packages/test-utils/src/sourcemap-upload-assertions.ts b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts new file mode 100644 index 000000000000..f8538fead41f --- /dev/null +++ b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts @@ -0,0 +1,276 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as assert from 'assert/strict'; + +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 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-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: string | RegExp): void { + const regex = typeof sourcePattern === 'string' ? new RegExp(sourcePattern) : sourcePattern; + let found = false; + + forEachSourcemap(manifests, ({ url, sourcemap }) => { + if (sourcemap.sources?.some(s => regex.test(s))) { + found = true; + + console.log(`Sourcemap ${url} references app sources:`); + for (const src of sourcemap.sources.filter(s => regex.test(s))) { + 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, + }; +} From bda291852ba69a523aa405df5a1b3833d904b2f9 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 5 Mar 2026 12:19:33 +0100 Subject: [PATCH 2/5] sec fixes --- dev-packages/test-utils/src/mock-sentry-server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/test-utils/src/mock-sentry-server.ts b/dev-packages/test-utils/src/mock-sentry-server.ts index 57496f0cbdb5..c1c3cafed1b8 100644 --- a/dev-packages/test-utils/src/mock-sentry-server.ts +++ b/dev-packages/test-utils/src/mock-sentry-server.ts @@ -2,7 +2,7 @@ import http from 'node:http'; import fs from 'node:fs'; import zlib from 'node:zlib'; import path from 'node:path'; -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import type { RequestRecord } from './sourcemap-upload-assertions'; export interface MockSentryServerOptions { @@ -115,7 +115,7 @@ export function startMockSentryServer(options: MockSentryServerOptions = {}): Mo // Extract the zip to inspect contents try { fs.mkdirSync(bundleDir, { recursive: true }); - execSync(`unzip -q -o "${zipFile}" -d "${bundleDir}" 2>/dev/null`); + execFileSync('unzip', ['-q', '-o', zipFile, '-d', bundleDir], { stdio: 'ignore' }); // Read manifest.json if present const manifestPath = path.join(bundleDir, 'manifest.json'); From ad50479980c90b8bd527cccb296752c61168e78c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 5 Mar 2026 12:49:33 +0100 Subject: [PATCH 3/5] lint --- .../test-utils/src/mock-sentry-server.ts | 235 ++++++++++-------- .../src/sourcemap-upload-assertions.ts | 10 +- 2 files changed, 141 insertions(+), 104 deletions(-) diff --git a/dev-packages/test-utils/src/mock-sentry-server.ts b/dev-packages/test-utils/src/mock-sentry-server.ts index c1c3cafed1b8..40770bf19187 100644 --- a/dev-packages/test-utils/src/mock-sentry-server.ts +++ b/dev-packages/test-utils/src/mock-sentry-server.ts @@ -1,9 +1,9 @@ -import http from 'node:http'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; -import zlib from 'node:zlib'; +import http from 'node:http'; import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import type { RequestRecord } from './sourcemap-upload-assertions'; +import zlib from 'node:zlib'; +import type { ChunkFileRecord, RequestRecord } from './sourcemap-upload-assertions'; export interface MockSentryServerOptions { port?: number; @@ -32,16 +32,16 @@ function parseMultipartParts(body: Buffer, boundary: string): { headers: string; if (idx === -1) break; const afterBoundary = idx + boundaryBuf.length; - if (body.slice(afterBoundary, afterBoundary + 2).toString() === '--') break; + if (body.subarray(afterBoundary, afterBoundary + 2).toString() === '--') break; const headerEnd = body.indexOf('\r\n\r\n', afterBoundary); if (headerEnd === -1) break; - const headerStr = body.slice(afterBoundary, headerEnd).toString(); + const headerStr = body.subarray(afterBoundary, headerEnd).toString(); const nextBoundary = body.indexOf(boundaryBuf, headerEnd + 4); const contentEnd = nextBoundary !== -1 ? nextBoundary - 2 : body.length; - const content = body.slice(headerEnd + 4, contentEnd); + const content = body.subarray(headerEnd + 4, contentEnd); parts.push({ headers: headerStr, content }); start = nextBoundary !== -1 ? nextBoundary : body.length; @@ -50,6 +50,130 @@ function parseMultipartParts(body: Buffer, boundary: string): { headers: string; return parts; } +/** + * Extract and inspect a single multipart chunk: decompress, unzip, read manifest. + */ +function extractChunkPart(partContent: Buffer, outputDir: string, chunkIndex: number, partIndex: number): ChunkFileRecord { + const bundleDir = path.join(outputDir, `bundle_${chunkIndex}_${partIndex}`); + + // Try to decompress (sentry-cli gzips chunks) + let zipBuffer: Buffer; + try { + zipBuffer = zlib.gunzipSync(partContent); + } catch { + zipBuffer = partContent; + } + + const zipFile = `${bundleDir}.zip`; + fs.writeFileSync(zipFile, zipBuffer); + + // Extract the zip to inspect contents + try { + fs.mkdirSync(bundleDir, { recursive: true }); + execFileSync('unzip', ['-q', '-o', zipFile, '-d', bundleDir], { stdio: 'ignore' }); + + // Read manifest.json if present + const manifestPath = path.join(bundleDir, 'manifest.json'); + if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { files?: Record }; + return { + bundleDir, + manifest: manifest as ChunkFileRecord['manifest'], + fileCount: Object.keys(manifest.files || {}).length, + }; + } + return { bundleDir, note: 'no manifest.json found' }; + } catch (err: unknown) { + return { + zipFile, + note: `extraction failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +/** + * Process a chunk upload POST request: parse multipart body, extract each part. + */ +function processChunkUpload( + record: RequestRecord, + body: Buffer, + contentType: string, + outputDir: string, + chunkIndex: number, +): number { + record.hasBody = true; + record.chunkFiles = []; + + const boundaryMatch = contentType.match(/boundary=(.+)/); + if (!boundaryMatch) { + return chunkIndex; + } + + // boundaryMatch[1] is guaranteed to exist since the regex matched + const parts = parseMultipartParts(body, boundaryMatch[1] as string); + let nextChunkIndex = chunkIndex; + for (let i = 0; i < parts.length; i++) { + // parts[i] is guaranteed to exist within the loop bounds + const part = parts[i] as { headers: string; content: Buffer }; + record.chunkFiles.push(extractChunkPart(part.content, outputDir, nextChunkIndex, i)); + nextChunkIndex++; + } + + return nextChunkIndex; +} + +/** + * Send the appropriate mock response based on the request URL. + */ +function sendResponse( + req: http.IncomingMessage, + res: http.ServerResponse, + port: number, + org: string, +): void { + const url = req.url || ''; + + if (url.includes('/artifactbundle/assemble/')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ state: 'created', missingChunks: [] })); + } else if (url.includes('/chunk-upload/')) { + if (req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + url: `http://localhost:${port}/api/0/organizations/${org}/chunk-upload/`, + chunkSize: 8388608, + chunksPerRequest: 64, + maxFileSize: 2147483648, + maxRequestSize: 33554432, + concurrency: 1, + hashAlgorithm: 'sha1', + compression: ['gzip'], + accept: [ + 'debug_files', + 'release_files', + 'pdbs', + 'sources', + 'bcsymbolmaps', + 'il2cpp', + 'portablepdbs', + 'artifact_bundles', + ], + }), + ); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({})); + } + } else if (url.includes('/releases/')) { + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ version: 'test-release', dateCreated: new Date().toISOString() })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + } +} + /** * Starts a mock Sentry server that captures sourcemap upload requests. * @@ -91,54 +215,7 @@ export function startMockSentryServer(options: MockSentryServerOptions = {}): Mo // For chunk upload POSTs, save and extract artifact bundles if (req.url?.includes('chunk-upload') && req.method === 'POST' && body.length > 0) { - record.hasBody = true; - record.chunkFiles = []; - - const boundaryMatch = contentType.match(/boundary=(.+)/); - if (boundaryMatch) { - const parts = parseMultipartParts(body, boundaryMatch[1]!); - for (let i = 0; i < parts.length; i++) { - const part = parts[i]!; - const bundleDir = path.join(outputDir, `bundle_${chunkIndex}_${i}`); - - // Try to decompress (sentry-cli gzips chunks) - let zipBuffer: Buffer; - try { - zipBuffer = zlib.gunzipSync(part.content); - } catch { - zipBuffer = part.content; - } - - const zipFile = `${bundleDir}.zip`; - fs.writeFileSync(zipFile, zipBuffer); - - // Extract the zip to inspect contents - try { - fs.mkdirSync(bundleDir, { recursive: true }); - execFileSync('unzip', ['-q', '-o', zipFile, '-d', bundleDir], { stdio: 'ignore' }); - - // Read manifest.json if present - const manifestPath = path.join(bundleDir, 'manifest.json'); - if (fs.existsSync(manifestPath)) { - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); - record.chunkFiles.push({ - bundleDir, - manifest, - fileCount: Object.keys(manifest.files || {}).length, - }); - } else { - record.chunkFiles.push({ bundleDir, note: 'no manifest.json found' }); - } - } catch (err: unknown) { - record.chunkFiles.push({ - zipFile, - note: `extraction failed: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - chunkIndex++; - } - } + chunkIndex = processChunkUpload(record, body, contentType, outputDir, chunkIndex); } // For artifact bundle assemble, capture the request body @@ -155,54 +232,12 @@ export function startMockSentryServer(options: MockSentryServerOptions = {}): Mo // Write all collected requests to the output file after each request fs.writeFileSync(outputFile, JSON.stringify(requests, null, 2)); - // Route responses — order matters: most specific first - const url = req.url || ''; - - if (url.includes('/artifactbundle/assemble/')) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ state: 'created', missingChunks: [] })); - } else if (url.includes('/chunk-upload/')) { - if (req.method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - url: `http://localhost:${port}/api/0/organizations/${org}/chunk-upload/`, - chunkSize: 8388608, - chunksPerRequest: 64, - maxFileSize: 2147483648, - maxRequestSize: 33554432, - concurrency: 1, - hashAlgorithm: 'sha1', - compression: ['gzip'], - accept: [ - 'debug_files', - 'release_files', - 'pdbs', - 'sources', - 'bcsymbolmaps', - 'il2cpp', - 'portablepdbs', - 'artifact_bundles', - ], - }), - ); - } else { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({})); - } - } else if (url.includes('/releases/')) { - res.writeHead(201, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ version: 'test-release', dateCreated: new Date().toISOString() })); - } else { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true })); - } + sendResponse(req, res, port, org); }); }); - server.listen(port, () => { - console.log(`Mock Sentry server listening on port ${port}`); - }); + // eslint-disable-next-line no-console + server.listen(port, () => console.log(`Mock Sentry server listening on port ${port}`)); return { port, diff --git a/dev-packages/test-utils/src/sourcemap-upload-assertions.ts b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts index f8538fead41f..951a9cf3c15b 100644 --- a/dev-packages/test-utils/src/sourcemap-upload-assertions.ts +++ b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts @@ -1,6 +1,6 @@ +import * as assert from 'assert/strict'; import * as fs from 'fs'; import * as path from 'path'; -import * as assert from 'assert/strict'; export interface ManifestFile { type: 'minified_source' | 'source_map'; @@ -133,7 +133,7 @@ export function assertDebugIdPairs(manifests: ArtifactBundleData[]): DebugIdPair 'Expected at least one JS/sourcemap pair with matching debug IDs in the uploaded artifact bundles', ); - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + 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}`); } @@ -207,16 +207,18 @@ export function assertSourcemapMappings(manifests: ArtifactBundleData[]): void { /** * Assert a sourcemap references source files matching a pattern. */ -export function assertSourcemapSources(manifests: ArtifactBundleData[], sourcePattern: string | RegExp): void { - const regex = typeof sourcePattern === 'string' ? new RegExp(sourcePattern) : sourcePattern; +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}`); } From 8124f7d0e206b2fbe88a296ba1bd8c08ed43b543 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 5 Mar 2026 13:03:42 +0100 Subject: [PATCH 4/5] cry --- dev-packages/test-utils/src/mock-sentry-server.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/dev-packages/test-utils/src/mock-sentry-server.ts b/dev-packages/test-utils/src/mock-sentry-server.ts index 40770bf19187..c26c5ae9d18a 100644 --- a/dev-packages/test-utils/src/mock-sentry-server.ts +++ b/dev-packages/test-utils/src/mock-sentry-server.ts @@ -53,7 +53,12 @@ function parseMultipartParts(body: Buffer, boundary: string): { headers: string; /** * Extract and inspect a single multipart chunk: decompress, unzip, read manifest. */ -function extractChunkPart(partContent: Buffer, outputDir: string, chunkIndex: number, partIndex: number): ChunkFileRecord { +function extractChunkPart( + partContent: Buffer, + outputDir: string, + chunkIndex: number, + partIndex: number, +): ChunkFileRecord { const bundleDir = path.join(outputDir, `bundle_${chunkIndex}_${partIndex}`); // Try to decompress (sentry-cli gzips chunks) @@ -125,12 +130,7 @@ function processChunkUpload( /** * Send the appropriate mock response based on the request URL. */ -function sendResponse( - req: http.IncomingMessage, - res: http.ServerResponse, - port: number, - org: string, -): void { +function sendResponse(req: http.IncomingMessage, res: http.ServerResponse, port: number, org: string): void { const url = req.url || ''; if (url.includes('/artifactbundle/assemble/')) { From 098dd05d7a810248bc609685d2746435ab63885b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 5 Mar 2026 15:04:03 +0100 Subject: [PATCH 5/5] fix(e2e): add @playwright/test to nextjs-sourcemaps test app Co-Authored-By: Claude Opus 4.6 --- .../e2e-tests/test-applications/nextjs-sourcemaps/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json index 6c5ee753bb8e..84bb06365b76 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json @@ -16,6 +16,7 @@ "typescript": "~5.0.0" }, "devDependencies": { + "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@types/node": "^18.19.1", "@types/react": "^19",