diff --git a/src/commands/crashlytics-sourcemap-upload.spec.ts b/src/commands/crashlytics-sourcemap-upload.spec.ts index 9e0eded332c..ba6dd3cf2fc 100644 --- a/src/commands/crashlytics-sourcemap-upload.spec.ts +++ b/src/commands/crashlytics-sourcemap-upload.spec.ts @@ -1,6 +1,8 @@ import * as chai from "chai"; import * as sinon from "sinon"; import * as fs from "fs"; +import * as path from "path"; +import * as tmp from "tmp"; import { command } from "./crashlytics-sourcemap-upload"; import * as gcs from "../gcp/storage"; @@ -180,6 +182,73 @@ describe("crashlytics:sourcemap:upload", () => { expect(apiPayloads[1]).to.equal("/src/test/fixtures/mapping-files-with-js/other.js.map"); }); + it("should find and upload hidden mapping files without sourceMappingURL in a directory", async () => { + const tmpDir = tmp.dirSync({ unsafeCleanup: true }); + const jsFile = path.join(tmpDir.name, "index.js"); + const mapFile = path.join(tmpDir.name, "index.js.map"); + + // No sourceMappingURL comment in JS file + fs.writeFileSync(jsFile, "console.log('hidden');"); + fs.writeFileSync(mapFile, JSON.stringify({ version: 3, file: "index.js", sources: [] })); + + try { + await command.runner()(tmpDir.name, { + app: "test-app", + }); + + expect(gcsMock.uploadObject).to.be.calledOnce; + const uploadedFiles = gcsMock.uploadObject.getCalls().map((call) => call.args[0].file); + expect(uploadedFiles[0]).to.match(/test-app-.*-index\.js\.zip/); + + expect(clientPatchStub).to.be.calledOnce; + const apiPayload = clientPatchStub.firstCall.args[1] as SourceMap; + expect(apiPayload.obfuscatedFilePath).to.match(/index\.js$/); + expect(apiPayload.obfuscatedFilePath).to.not.include(".js.map"); + } finally { + tmpDir.removeCallback(); + } + }); + + it("should support mixed directories with traditional, hidden, and unlinked mapping files", async () => { + const tmpDir = tmp.dirSync({ unsafeCleanup: true }); + + // 1. Traditional sourcemap + const tradJs = path.join(tmpDir.name, "trad.js"); + const tradMap = path.join(tmpDir.name, "trad.js.map"); + fs.writeFileSync(tradJs, "console.log('trad');\n//# sourceMappingURL=trad.js.map"); + fs.writeFileSync(tradMap, JSON.stringify({ version: 3, sources: [] })); + + // 2. Hidden sourcemap + const hiddenJs = path.join(tmpDir.name, "hidden.js"); + const hiddenMap = path.join(tmpDir.name, "hidden.js.map"); + fs.writeFileSync(hiddenJs, "console.log('hidden');"); + fs.writeFileSync(hiddenMap, JSON.stringify({ version: 3, file: "hidden.js", sources: [] })); + + // 3. Standalone unlinked map file + const unlinkedMap = path.join(tmpDir.name, "unlinked.js.map"); + fs.writeFileSync(unlinkedMap, JSON.stringify({ version: 3, sources: [] })); + + try { + await command.runner()(tmpDir.name, { + app: "test-app", + }); + + expect(gcsMock.uploadObject).to.be.calledThrice; + expect(clientPatchStub).to.be.calledThrice; + + const apiPayloads = clientPatchStub + .getCalls() + .map((call) => (call.args[1] as SourceMap).obfuscatedFilePath) + .sort(); + + expect(apiPayloads.some((p) => p.endsWith("hidden.js"))).to.be.true; + expect(apiPayloads.some((p) => p.endsWith("trad.js"))).to.be.true; + expect(apiPayloads.some((p) => p.endsWith("unlinked.js.map"))).to.be.true; + } finally { + tmpDir.removeCallback(); + } + }); + it("should use the provided app version", async () => { await command.runner()(DIR_PATH, { app: "test-app", diff --git a/src/crashlytics/sourcemap.spec.ts b/src/crashlytics/sourcemap.spec.ts index ac878d48e0a..04b297dcec7 100644 --- a/src/crashlytics/sourcemap.spec.ts +++ b/src/crashlytics/sourcemap.spec.ts @@ -10,6 +10,8 @@ import { getAppVersion, normalizeFileName, getLinkedSourceMapPath, + extractSourceMapFileField, + findHiddenSourceMapTarget, findSourceMapMappings, CommandOptions, uploadMap, @@ -145,6 +147,170 @@ describe("crashlytics:sourcemap helpers", () => { }); }); + describe("extractSourceMapFileField", () => { + it("should return undefined for empty files", async () => { + const tmpFile = tmp.fileSync({ postfix: ".js.map" }); + try { + const result = await extractSourceMapFileField(tmpFile.name); + expect(result).to.be.undefined; + } finally { + tmpFile.removeCallback(); + } + }); + + it("should return undefined when no file property is present in the source map", async () => { + const tmpFile = tmp.fileSync({ postfix: ".js.map" }); + fs.writeFileSync( + tmpFile.name, + JSON.stringify({ version: 3, sources: ["app.ts"], mappings: "" }), + ); + try { + const result = await extractSourceMapFileField(tmpFile.name); + expect(result).to.be.undefined; + } finally { + tmpFile.removeCallback(); + } + }); + + it("should extract file property from standard json", async () => { + const tmpFile = tmp.fileSync({ postfix: ".js.map" }); + fs.writeFileSync( + tmpFile.name, + JSON.stringify({ version: 3, file: "main.bundle.js", sources: ["main.ts"], mappings: "" }), + ); + try { + const result = await extractSourceMapFileField(tmpFile.name); + expect(result).to.equal("main.bundle.js"); + } finally { + tmpFile.removeCallback(); + } + }); + + it("should extract file property with whitespace around colon", async () => { + const tmpFile = tmp.fileSync({ postfix: ".js.map" }); + fs.writeFileSync( + tmpFile.name, + '{\n "version": 3,\n "file" : "output.js" ,\n "sources": []\n}', + ); + try { + const result = await extractSourceMapFileField(tmpFile.name); + expect(result).to.equal("output.js"); + } finally { + tmpFile.removeCallback(); + } + }); + + it("should handle escaped characters in file property", async () => { + const tmpFile = tmp.fileSync({ postfix: ".js.map" }); + fs.writeFileSync(tmpFile.name, '{"version":3,"file":"dist\\/assets\\/main.js","sources":[]}'); + try { + const result = await extractSourceMapFileField(tmpFile.name); + expect(result).to.equal("dist/assets/main.js"); + } finally { + tmpFile.removeCallback(); + } + }); + + it("should return undefined if file property is empty string", async () => { + const tmpFile = tmp.fileSync({ postfix: ".js.map" }); + fs.writeFileSync(tmpFile.name, '{"version":3,"file":" ","sources":[]}'); + try { + const result = await extractSourceMapFileField(tmpFile.name); + expect(result).to.be.undefined; + } finally { + tmpFile.removeCallback(); + } + }); + + it("should return undefined for non-existent files", async () => { + const result = await extractSourceMapFileField("/non/existent/path/file.js.map"); + expect(result).to.be.undefined; + }); + }); + + describe("findHiddenSourceMapTarget", () => { + it("should find target JS file by conventional naming when candidate is in jsFilesSet", async () => { + const mapPath = "/dist/assets/index-B_3419df.js.map"; + const jsPath = "/dist/assets/index-B_3419df.js"; + const jsFilesSet = new Set([path.resolve(jsPath)]); + + const result = await findHiddenSourceMapTarget(mapPath, jsFilesSet); + expect(result).to.equal(path.resolve(jsPath)); + }); + + it("should find target JS file by conventional naming on filesystem when jsFilesSet is omitted", async () => { + const tmpJs = tmp.fileSync({ postfix: ".js" }); + const mapPath = `${tmpJs.name}.map`; + fs.writeFileSync(tmpJs.name, "console.log('hello');"); + + try { + const result = await findHiddenSourceMapTarget(mapPath); + expect(result).to.equal(path.resolve(tmpJs.name)); + } finally { + tmpJs.removeCallback(); + } + }); + + it("should find target JS file from source map file property relative to map directory", async () => { + const tmpDir = tmp.dirSync(); + const jsFile = path.join(tmpDir.name, "main.js"); + const mapFile = path.join(tmpDir.name, "custom_name.js.map"); + + fs.writeFileSync(jsFile, "console.log('main');"); + fs.writeFileSync(mapFile, JSON.stringify({ version: 3, file: "main.js" })); + + try { + const jsFilesSet = new Set([path.resolve(jsFile)]); + const result = await findHiddenSourceMapTarget(mapFile, jsFilesSet); + expect(result).to.equal(path.resolve(jsFile)); + } finally { + try { + fs.unlinkSync(jsFile); + fs.unlinkSync(mapFile); + tmpDir.removeCallback(); + } catch { + // ignore + } + } + }); + + it("should find target JS file from source map file property by matching basename", async () => { + const tmpDir = tmp.dirSync(); + const jsFile = path.join(tmpDir.name, "app.js"); + const mapFile = path.join(tmpDir.name, "app-hash123.js.map"); + + fs.writeFileSync(jsFile, "console.log('app');"); + fs.writeFileSync(mapFile, JSON.stringify({ version: 3, file: "dist/app.js" })); + + try { + const jsFilesSet = new Set([path.resolve(jsFile)]); + const result = await findHiddenSourceMapTarget(mapFile, jsFilesSet); + expect(result).to.equal(path.resolve(jsFile)); + } finally { + try { + fs.unlinkSync(jsFile); + fs.unlinkSync(mapFile); + tmpDir.removeCallback(); + } catch { + // ignore + } + } + }); + + it("should return undefined when conventional JS file does not exist and no file property exists", async () => { + const tmpMap = tmp.fileSync({ postfix: ".js.map" }); + fs.writeFileSync(tmpMap.name, "{}"); + + try { + const jsFilesSet = new Set(); + const result = await findHiddenSourceMapTarget(tmpMap.name, jsFilesSet); + expect(result).to.be.undefined; + } finally { + tmpMap.removeCallback(); + } + }); + }); + describe("findSourceMapMappings", () => { it("should construct mappings correctly for linked files", async () => { const tmpJs = tmp.fileSync({ postfix: ".js" }); @@ -194,6 +360,265 @@ describe("crashlytics:sourcemap helpers", () => { tmpMap.removeCallback(); } }); + + it("should construct mappings correctly for hidden sourcemaps without sourceMappingURL comments", async () => { + const tmpDir = tmp.dirSync(); + const jsFile = path.join(tmpDir.name, "bundle.js"); + const mapFile = path.join(tmpDir.name, "bundle.js.map"); + + // JS file has NO sourceMappingURL comment + fs.writeFileSync(jsFile, "console.log('hidden sourcemap');"); + fs.writeFileSync( + mapFile, + JSON.stringify({ version: 3, sources: ["src/bundle.ts"], mappings: "" }), + ); + + try { + const files = [{ name: jsFile }, { name: mapFile }]; + const results = await findSourceMapMappings(files, tmpDir.name); + + expect(results).to.have.lengthOf(1); + expect(results[0]).to.deep.equal({ + mapFilePath: mapFile, + obfuscatedFilePath: "bundle.js", + }); + } finally { + try { + fs.unlinkSync(jsFile); + fs.unlinkSync(mapFile); + tmpDir.removeCallback(); + } catch { + // ignore + } + } + }); + + it("should construct mappings for Vite-style bundled JS and hidden map files", async () => { + const tmpDir = tmp.dirSync(); + const assetsDir = path.join(tmpDir.name, "assets"); + fs.mkdirSync(assetsDir, { recursive: true }); + + const indexJs = path.join(assetsDir, "index-D7h28k.js"); + const indexMap = path.join(assetsDir, "index-D7h28k.js.map"); + const vendorJs = path.join(assetsDir, "vendor-CzP23r.js"); + const vendorMap = path.join(assetsDir, "vendor-CzP23r.js.map"); + + fs.writeFileSync(indexJs, "console.log('index');"); + fs.writeFileSync( + indexMap, + JSON.stringify({ version: 3, file: "index-D7h28k.js", sources: [] }), + ); + fs.writeFileSync(vendorJs, "console.log('vendor');"); + fs.writeFileSync( + vendorMap, + JSON.stringify({ version: 3, file: "vendor-CzP23r.js", sources: [] }), + ); + + try { + const files = [ + { name: indexJs }, + { name: indexMap }, + { name: vendorJs }, + { name: vendorMap }, + ]; + const results = await findSourceMapMappings(files, tmpDir.name); + + expect(results).to.have.lengthOf(2); + const sorted = results.sort((a, b) => + a.obfuscatedFilePath.localeCompare(b.obfuscatedFilePath), + ); + expect(sorted[0]).to.deep.equal({ + mapFilePath: indexMap, + obfuscatedFilePath: path.join("assets", "index-D7h28k.js"), + }); + expect(sorted[1]).to.deep.equal({ + mapFilePath: vendorMap, + obfuscatedFilePath: path.join("assets", "vendor-CzP23r.js"), + }); + } finally { + try { + fs.rmSync(tmpDir.name, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); + + it("should construct mappings for Angular-style bundled JS and hidden map files", async () => { + const tmpDir = tmp.dirSync(); + const browserDir = path.join(tmpDir.name, "browser"); + fs.mkdirSync(browserDir, { recursive: true }); + + const mainJs = path.join(browserDir, "main-5J77SZZZ.js"); + const mainMap = path.join(browserDir, "main-5J77SZZZ.js.map"); + const polyfillsJs = path.join(browserDir, "polyfills-FF234AAA.js"); + const polyfillsMap = path.join(browserDir, "polyfills-FF234AAA.js.map"); + + fs.writeFileSync(mainJs, "console.log('angular main');"); + fs.writeFileSync( + mainMap, + JSON.stringify({ version: 3, file: "main-5J77SZZZ.js", sources: [] }), + ); + fs.writeFileSync(polyfillsJs, "console.log('angular polyfills');"); + fs.writeFileSync( + polyfillsMap, + JSON.stringify({ version: 3, file: "polyfills-FF234AAA.js", sources: [] }), + ); + + try { + const files = [ + { name: mainJs }, + { name: mainMap }, + { name: polyfillsJs }, + { name: polyfillsMap }, + ]; + const results = await findSourceMapMappings(files, tmpDir.name); + + expect(results).to.have.lengthOf(2); + const sorted = results.sort((a, b) => + a.obfuscatedFilePath.localeCompare(b.obfuscatedFilePath), + ); + expect(sorted[0]).to.deep.equal({ + mapFilePath: mainMap, + obfuscatedFilePath: path.join("browser", "main-5J77SZZZ.js"), + }); + expect(sorted[1]).to.deep.equal({ + mapFilePath: polyfillsMap, + obfuscatedFilePath: path.join("browser", "polyfills-FF234AAA.js"), + }); + } finally { + try { + fs.rmSync(tmpDir.name, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); + + it("should construct mappings for hidden sourcemaps where map file name differs but file property matches", async () => { + const tmpDir = tmp.dirSync(); + const jsFile = path.join(tmpDir.name, "app.js"); + const mapFile = path.join(tmpDir.name, "custom-sourcemap-name.js.map"); + + fs.writeFileSync(jsFile, "console.log('app');"); + fs.writeFileSync(mapFile, JSON.stringify({ version: 3, file: "app.js", sources: [] })); + + try { + const files = [{ name: jsFile }, { name: mapFile }]; + const results = await findSourceMapMappings(files, tmpDir.name); + + expect(results).to.have.lengthOf(1); + expect(results[0]).to.deep.equal({ + mapFilePath: mapFile, + obfuscatedFilePath: "app.js", + }); + } finally { + try { + fs.unlinkSync(jsFile); + fs.unlinkSync(mapFile); + tmpDir.removeCallback(); + } catch { + // ignore + } + } + }); + + it("should handle mixed directories with traditional, hidden, and unlinked sourcemap files", async () => { + const tmpDir = tmp.dirSync(); + + // 1. Traditional sourcemap (has sourceMappingURL comment) + const tradJs = path.join(tmpDir.name, "traditional.js"); + const tradMap = path.join(tmpDir.name, "traditional-hash.js.map"); + fs.writeFileSync( + tradJs, + "console.log('trad');\n//# sourceMappingURL=traditional-hash.js.map", + ); + fs.writeFileSync(tradMap, JSON.stringify({ version: 3, sources: [] })); + + // 2. Hidden sourcemap (no comment) + const hiddenJs = path.join(tmpDir.name, "hidden.js"); + const hiddenMap = path.join(tmpDir.name, "hidden.js.map"); + fs.writeFileSync(hiddenJs, "console.log('hidden');"); + fs.writeFileSync(hiddenMap, JSON.stringify({ version: 3, file: "hidden.js", sources: [] })); + + // 3. Unlinked standalone sourcemap (no JS file) + const unlinkedMap = path.join(tmpDir.name, "standalone.js.map"); + fs.writeFileSync(unlinkedMap, JSON.stringify({ version: 3, sources: [] })); + + try { + const files = [ + { name: tradJs }, + { name: tradMap }, + { name: hiddenJs }, + { name: hiddenMap }, + { name: unlinkedMap }, + ]; + const results = await findSourceMapMappings(files, tmpDir.name); + + expect(results).to.have.lengthOf(3); + const sorted = results.sort((a, b) => + a.obfuscatedFilePath.localeCompare(b.obfuscatedFilePath), + ); + expect(sorted[0]).to.deep.equal({ + mapFilePath: hiddenMap, + obfuscatedFilePath: "hidden.js", + }); + expect(sorted[1]).to.deep.equal({ + mapFilePath: unlinkedMap, + obfuscatedFilePath: "standalone.js.map", + }); + expect(sorted[2]).to.deep.equal({ + mapFilePath: tradMap, + obfuscatedFilePath: "traditional.js", + }); + } finally { + try { + fs.rmSync(tmpDir.name, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); + + it("should not re-assign an already-linked JS file from Pass 1 to a hidden map file in Pass 2", async () => { + const tmpDir = tmp.dirSync(); + + // main.js explicitly links to main-hash.js.map + const mainJs = path.join(tmpDir.name, "main.js"); + const mainHashMap = path.join(tmpDir.name, "main-hash.js.map"); + // Another map file that also has conventional name main.js.map + const mainMap = path.join(tmpDir.name, "main.js.map"); + + fs.writeFileSync(mainJs, "console.log('main');\n//# sourceMappingURL=main-hash.js.map"); + fs.writeFileSync(mainHashMap, JSON.stringify({ version: 3, sources: [] })); + fs.writeFileSync(mainMap, JSON.stringify({ version: 3, sources: [] })); + + try { + const files = [{ name: mainJs }, { name: mainHashMap }, { name: mainMap }]; + const results = await findSourceMapMappings(files, tmpDir.name); + + expect(results).to.have.lengthOf(2); + const sorted = results.sort((a, b) => + a.obfuscatedFilePath.localeCompare(b.obfuscatedFilePath), + ); + // main-hash.js.map was linked to main.js in Pass 1 + expect(sorted[0]).to.deep.equal({ + mapFilePath: mainHashMap, + obfuscatedFilePath: "main.js", + }); + // main.js.map falls back to itself because main.js is already taken + expect(sorted[1]).to.deep.equal({ + mapFilePath: mainMap, + obfuscatedFilePath: "main.js.map", + }); + } finally { + try { + fs.rmSync(tmpDir.name, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); }); describe("uploadMap", () => { diff --git a/src/crashlytics/sourcemap.ts b/src/crashlytics/sourcemap.ts index d0aaabc06e8..834495c4ea4 100644 --- a/src/crashlytics/sourcemap.ts +++ b/src/crashlytics/sourcemap.ts @@ -152,6 +152,8 @@ export async function upsertBucket( /** * Scans files to discover mapping files and links them to source assets. + * Supports both traditional sourcemap comments (//# sourceMappingURL=...) and + * hidden sourcemaps (generated without sourceMappingURL comments). */ export async function findSourceMapMappings( files: { name: string }[], @@ -161,12 +163,17 @@ export async function findSourceMapMappings( const mapFiles = files.filter((f) => f.name.endsWith(".js.map")); const mappings: SourceMapMapping[] = []; - const mapFilePathsSet = new Set(mapFiles.map((f) => f.name)); - // Set to track map files that were linked from a JS file (via `sourceMappingURL` comment) - const mapFilesLinkedInJsComment = new Set(); + const mapFilePathsSet = new Set(mapFiles.map((f) => path.resolve(f.name))); + const jsFilesSet = new Set(jsFiles.map((f) => path.resolve(f.name))); + + // Track map files and JS files that have been successfully linked + const linkedMapFilePaths = new Set(); + const linkedJsFilePaths = new Set(); const limit = pLimit(CONCURRENCY); - const results = await Promise.all( + + // Pass 1: Traditional sourcemaps - check JS files for sourceMappingURL comments + const traditionalResults = await Promise.all( jsFiles.map((jsFile) => limit(async () => { const mapFilePath = await getLinkedSourceMapPath(jsFile.name); @@ -175,29 +182,143 @@ export async function findSourceMapMappings( ), ); - for (const { jsFile, mapFilePath } of results) { - if (mapFilePath && mapFilePathsSet.has(mapFilePath)) { - mappings.push({ - mapFilePath, - obfuscatedFilePath: path.relative(rootDir, path.resolve(jsFile.name)), - }); - mapFilesLinkedInJsComment.add(mapFilePath); + for (const { jsFile, mapFilePath } of traditionalResults) { + if (mapFilePath) { + const resolvedMapPath = path.resolve(mapFilePath); + const resolvedJsPath = path.resolve(jsFile.name); + if (mapFilePathsSet.has(resolvedMapPath) && !linkedMapFilePaths.has(resolvedMapPath)) { + mappings.push({ + mapFilePath, + obfuscatedFilePath: path.relative(rootDir, resolvedJsPath), + }); + linkedMapFilePaths.add(resolvedMapPath); + linkedJsFilePaths.add(resolvedJsPath); + } } } - // Add map files that were not linked from any JS file - for (const mapFile of mapFiles) { - if (!mapFilesLinkedInJsComment.has(mapFile.name)) { + // Pass 2: Hidden sourcemaps - for map files not linked in Pass 1, resolve matching JS files + const unlinkedMapFiles = mapFiles.filter((f) => !linkedMapFilePaths.has(path.resolve(f.name))); + const availableJsFilesSet = new Set( + [...jsFilesSet].filter((jsPath) => !linkedJsFilePaths.has(jsPath)), + ); + + const hiddenResults = await Promise.all( + unlinkedMapFiles.map((mapFile) => + limit(async () => { + const resolvedMapPath = path.resolve(mapFile.name); + const targetJsPath = await findHiddenSourceMapTarget(mapFile.name, availableJsFilesSet); + return { mapFile, targetJsPath, resolvedMapPath }; + }), + ), + ); + + for (const { mapFile, targetJsPath, resolvedMapPath } of hiddenResults) { + if (targetJsPath && !linkedJsFilePaths.has(path.resolve(targetJsPath))) { + const resolvedJsPath = path.resolve(targetJsPath); + mappings.push({ + mapFilePath: mapFile.name, + obfuscatedFilePath: path.relative(rootDir, resolvedJsPath), + }); + linkedMapFilePaths.add(resolvedMapPath); + linkedJsFilePaths.add(resolvedJsPath); + } else if (!linkedMapFilePaths.has(resolvedMapPath)) { + // Pass 3: Unlinked map files without matching JS file map to themselves (fallback) mappings.push({ mapFilePath: mapFile.name, - obfuscatedFilePath: path.relative(rootDir, path.resolve(mapFile.name)), + obfuscatedFilePath: path.relative(rootDir, resolvedMapPath), }); + linkedMapFilePaths.add(resolvedMapPath); } } return mappings; } +/** + * Extracts the optional 'file' field from the header of a source map file. + * Reads the initial chunk of the file to quickly parse the property without loading massive mapping tables into memory. + */ +export async function extractSourceMapFileField(mapFilePath: string): Promise { + let fileHandle: fs.promises.FileHandle | undefined; + try { + const stat = await fs.promises.stat(mapFilePath); + const size = stat.size; + if (size === 0) { + return undefined; + } + const bufferSize = Math.min(size, 16384); + fileHandle = await fs.promises.open(mapFilePath, "r"); + const buffer = Buffer.alloc(bufferSize); + const { bytesRead } = await fileHandle.read(buffer, 0, bufferSize, 0); + const header = buffer.toString("utf-8", 0, bytesRead); + const regex = /"file"\s*:\s*"(?(?:[^"\\]|\\.)*)"/; + const match = regex.exec(header); + const file = match?.groups?.file; + if (file) { + try { + const decoded = JSON.parse(`"${file}"`) as string; + const trimmed = decoded.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } catch { + const trimmed = file.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + } + } catch (e) { + logger.debug( + `Error reading source map file property from ${mapFilePath}: ${e instanceof Error ? e.message : String(e)}`, + ); + } finally { + if (fileHandle) { + try { + await fileHandle.close(); + } catch (e) { + // Ignore close error + } + } + } + return undefined; +} + +/** + * Finds the corresponding JS target file for a hidden source map. + * Checks by filename convention (e.g. foo.js.map -> foo.js) and the source map 'file' property. + */ +export async function findHiddenSourceMapTarget( + mapFilePath: string, + jsFilesSet?: Set, +): Promise { + const resolvedMapPath = path.resolve(mapFilePath); + const mapDir = path.dirname(resolvedMapPath); + + // Strategy 1: Check by convention (e.g., stripping '.map' suffix from 'foo.js.map' -> 'foo.js') + if (resolvedMapPath.endsWith(".map")) { + const candidateJsPath = resolvedMapPath.slice(0, -4); + if (jsFilesSet ? jsFilesSet.has(candidateJsPath) : fs.existsSync(candidateJsPath)) { + return candidateJsPath; + } + } + + // Strategy 2: Check the 'file' property inside the source map JSON + const fileField = await extractSourceMapFileField(resolvedMapPath); + if (fileField) { + // Check relative to the source map directory + const candidateJsPath = path.resolve(mapDir, fileField); + if (jsFilesSet ? jsFilesSet.has(candidateJsPath) : fs.existsSync(candidateJsPath)) { + return candidateJsPath; + } + + // Check if any known JS file matches the fileField basename in the same directory + const baseCandidate = path.resolve(mapDir, path.basename(fileField)); + if (jsFilesSet ? jsFilesSet.has(baseCandidate) : fs.existsSync(baseCandidate)) { + return baseCandidate; + } + } + + return undefined; +} + /** * Extracts sourceMappingURL value from the last 4KB tail of a JS file. */