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..84bb06365b76 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json @@ -0,0 +1,29 @@ +{ + "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": { + "@playwright/test": "~1.56.0", + "@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..c26c5ae9d18a --- /dev/null +++ b/dev-packages/test-utils/src/mock-sentry-server.ts @@ -0,0 +1,247 @@ +import { execFileSync } from 'node:child_process'; +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'; + +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.subarray(afterBoundary, afterBoundary + 2).toString() === '--') break; + + const headerEnd = body.indexOf('\r\n\r\n', afterBoundary); + if (headerEnd === -1) break; + + 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.subarray(headerEnd + 4, contentEnd); + + parts.push({ headers: headerStr, content }); + start = nextBoundary !== -1 ? nextBoundary : body.length; + } + + 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. + * + * 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) { + chunkIndex = processChunkUpload(record, body, contentType, outputDir, 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)); + + sendResponse(req, res, port, org); + }); + }); + + // eslint-disable-next-line no-console + 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..951a9cf3c15b --- /dev/null +++ b/dev-packages/test-utils/src/sourcemap-upload-assertions.ts @@ -0,0 +1,278 @@ +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, + }; +}