Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e564667
Partially implement javascript source map upload command (#9447)
andrewbrook Nov 20, 2025
4946af1
Add app version defaults to Crashlytics mapping file upload (#9525)
andrewbrook Nov 26, 2025
6806e57
Register stored Crashlytics source map file with telemetry admin API …
tonybaroneee Feb 3, 2026
85aa573
Add resource name and `allow_missing` param to source map registratio…
tonybaroneee Feb 10, 2026
86910fe
Update source map upload command options (#10000)
tonybaroneee Mar 2, 2026
87bc9ba
Handle mismatched source map file names (#10013)
tonybaroneee Mar 4, 2026
b1a4c37
Fix Crashlytics unit tests (#10172)
andrewbrook Mar 25, 2026
98cf3e2
Update crashlytics:sourcemap:upload to align with API (#10176)
andrewbrook Mar 25, 2026
6a65117
add rate limiting and retry logic to source map uploads (#10191)
andrewbrook Mar 31, 2026
4d04a90
Misc. bug fixes to Crashlytics mapping upload (#10441)
andrewbrook May 4, 2026
bfec42b
Merge branch 'main' into crashlytics2
tonybaroneee May 6, 2026
a1bb36a
Merge branch 'main' into crashlytics2
tonybaroneee May 7, 2026
18a2197
Merge branch 'main' into crashlytics2
tonybaroneee May 8, 2026
58c285f
Merge branch 'main' into crashlytics2
tonybaroneee May 11, 2026
261c510
Only allow directories to be passed to crashlytics sourcemap upload (…
tonybaroneee May 12, 2026
433f024
Merge branch 'main' into crashlytics2
tonybaroneee May 13, 2026
586a05a
Merge branch 'main' into crashlytics2
tonybaroneee May 26, 2026
4ed637a
Update firebase telemetry admin url
tonybaroneee May 27, 2026
ae69234
Add experiment for crashlytics source map upload cmd
tonybaroneee May 27, 2026
e5cf473
Merge branch 'main' into crashlytics2
tonybaroneee May 27, 2026
a3af6df
Replace `ignore` and `ignoreStrings` for readdirRecursive
tonybaroneee May 28, 2026
7a1bd0a
Add MurmurHash3 hashing function util
tonybaroneee May 28, 2026
370f265
Add `retryDelay` to sourcemap upload command options
tonybaroneee May 28, 2026
5703f6a
Use for.. of.. instead of forEach
tonybaroneee May 28, 2026
c82f09b
Added logic to cleanup tmp zip files from disk
tonybaroneee May 28, 2026
52c6b80
Merge branch 'main' into crashlytics2
tonybaroneee May 28, 2026
0e65b63
Update getLinkedSourceMapPath to use async file operations
tonybaroneee May 28, 2026
c432982
Use named capturing group in `sourceMappingURL` regex for clarity
tonybaroneee May 28, 2026
a931c9a
Optimize `pipeAsync` to register listeners before finalize
tonybaroneee May 28, 2026
3f04575
Split sourcemap utility functions into separate file
tonybaroneee May 28, 2026
8d6ebfd
Lint fixes
tonybaroneee May 28, 2026
a639fa9
Add specs for sourcemap uploads
tonybaroneee May 28, 2026
06dc36e
Small fixes from GCA review
tonybaroneee May 28, 2026
b0d3416
Merge branch 'main' into crashlytics2
tonybaroneee May 28, 2026
3d4b42a
Merge branch 'main' into crashlytics2
joehan May 29, 2026
e324075
Close fds in archiveFile
tonybaroneee Jun 1, 2026
0d73b96
Merge branch 'crashlytics2' of github.com:firebase/firebase-tools int…
tonybaroneee Jun 1, 2026
429dc6b
Merge branch 'main' into crashlytics2
tonybaroneee Jun 1, 2026
c4058de
Cite source for murmur3 hash algo
tonybaroneee Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/archiveFile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as fs from "fs";
import { expect } from "chai";
import { archiveFile } from "./archiveFile";
import { FIXTURE_DIR } from "./test/fixtures/config-imports";

describe("archiveFile", () => {
it("should archive files", async () => {
const outputPath = await archiveFile(`${FIXTURE_DIR}/firebase.json`);

expect(outputPath).to.match(/\.zip$/);
expect(fs.existsSync(outputPath)).to.be.true;
const archiveStats = fs.statSync(outputPath);
const origStats = fs.statSync(`${FIXTURE_DIR}/firebase.json`);
expect(archiveStats.size).to.be.greaterThan(0);
expect(archiveStats.size).not.to.equal(origStats.size);
});
});
35 changes: 35 additions & 0 deletions src/archiveFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as archiver from "archiver";
import * as fs from "fs";
import * as path from "path";
import * as tmp from "tmp";

export interface ArchiveOptions {
/** Optionally override the name of the file being archived */
archivedFileName?: string;
}

/** Archives (zips) a file and returns a path to the tmp output file. */
export async function archiveFile(filePath: string, options?: ArchiveOptions): Promise<string> {
Comment thread
tonybaroneee marked this conversation as resolved.
const tmpFileObj = tmp.fileSync({ postfix: ".zip" });
const tmpFile = tmpFileObj.name;
fs.closeSync(tmpFileObj.fd);
const fileStream = fs.createWriteStream(tmpFile, {
flags: "w",
encoding: "binary",
});
const archive = archiver("zip");
const name = options?.archivedFileName ?? path.basename(filePath);
archive.file(filePath, { name });
await pipeAsync(archive, fileStream);
return tmpFile;
}

async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise<void> {
return new Promise((resolve, reject) => {
to.on("finish", resolve);
to.on("error", reject);
from.on("error", reject);
from.pipe(to);
from.finalize().catch(reject);
});
}
Comment thread
tonybaroneee marked this conversation as resolved.
334 changes: 334 additions & 0 deletions src/commands/crashlytics-sourcemap-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
import * as chai from "chai";
import * as sinon from "sinon";
import * as fs from "fs";

import { command } from "./crashlytics-sourcemap-upload";
import * as gcs from "../gcp/storage";
import { SourceMap } from "../crashlytics/sourcemap";
import * as projectUtils from "../projectUtils";
import * as getProjectNumber from "../getProjectNumber";
import { FirebaseError } from "../error";
import * as childProcess from "child_process";
import * as utils from "../utils";
import { Client } from "../apiv2";

const expect = chai.expect;

const PROJECT_ID = "test-project";
const PROJECT_NUMBER = "12345";
const BUCKET_NAME = "test-bucket";
const DIR_PATH = "src/test/fixtures/mapping-files";
const DIR_WITH_JS_PATH = "src/test/fixtures/mapping-files-with-js";
const FILE_PATH = "src/test/fixtures/mapping-files/mock_mapping.js.map";

describe("crashlytics:sourcemap:upload", () => {
let sandbox: sinon.SinonSandbox;
let gcsMock: sinon.SinonStubbedInstance<typeof gcs>;
let projectUtilsMock: sinon.SinonStubbedInstance<typeof projectUtils>;
let getProjectNumberMock: sinon.SinonStubbedInstance<typeof getProjectNumber>;
let execSyncStub: sinon.SinonStub;
let commandExistsSyncStub: sinon.SinonStub;
let clientPatchStub: sinon.SinonStub;
let logLabeledWarningStub: sinon.SinonStub;
let logLabeledBulletStub: sinon.SinonStub;

beforeEach(() => {
(command as unknown as { befores: unknown[] }).befores = []; // Bypass pre-action hooks for unit testing action

sandbox = sinon.createSandbox();
gcsMock = sandbox.stub(gcs);
projectUtilsMock = sandbox.stub(projectUtils);
getProjectNumberMock = sandbox.stub(getProjectNumber);

projectUtilsMock.needProjectId.returns(PROJECT_ID);
getProjectNumberMock.getProjectNumber.resolves(PROJECT_NUMBER);
gcsMock.upsertBucket.resolves(BUCKET_NAME);
gcsMock.uploadObject.resolves({
bucket: BUCKET_NAME,
object: "test-object",
generation: "1",
});
execSyncStub = sandbox.stub(childProcess, "execSync");
commandExistsSyncStub = sandbox.stub(utils, "commandExistsSync");
logLabeledWarningStub = sandbox.stub(utils, "logLabeledWarning");
logLabeledBulletStub = sandbox.stub(utils, "logLabeledBullet");
// Default to git working
commandExistsSyncStub.withArgs("git").returns(true);
execSyncStub.withArgs("git rev-parse HEAD").returns(Buffer.from("a".repeat(40)));
clientPatchStub = sandbox.stub(Client.prototype, "patch").resolves({
status: 200,
response: {} as unknown as import("node-fetch").Response,
body: {},
});
});

afterEach(() => {
sandbox.restore();
});

it("should throw an error if no app ID is provided", async () => {
await expect(command.runner()("filename", {})).to.be.rejectedWith(
FirebaseError,
"set --app <appId> to a valid Firebase application id",
);
});

it("should create the default cloud storage bucket", async () => {
await command.runner()(DIR_PATH, {
app: "test-app",
});
expect(gcsMock.upsertBucket).to.be.calledOnce;
const args = gcsMock.upsertBucket.firstCall.args;
expect(args[0].req.baseName).to.equal("firebasecrashlytics-sourcemaps-12345-us-central1");
expect(args[0].req.location).to.equal("US-CENTRAL1");
});

it("should create a custom cloud storage bucket", async () => {
const options = {
app: "test-app",
bucketLocation: "a-different-LoCaTiOn",
};
await command.runner()(DIR_PATH, options);
expect(gcsMock.upsertBucket).to.be.calledOnce;
const args = gcsMock.upsertBucket.firstCall.args;
expect(args[0].req.baseName).to.equal(
"firebasecrashlytics-sourcemaps-12345-a-different-location",
);
expect(args[0].req.location).to.equal("A-DIFFERENT-LOCATION");
});

it("should throw an error if the mapping file path is invalid", async () => {
await expect(
command.runner()("invalid/path", {
app: "test-app",
}),
).to.be.rejectedWith(FirebaseError, "provide a valid directory to mapping file(s)");
});

it("should throw an error if the mapping file path is not a directory", async () => {
await expect(
command.runner()(FILE_PATH, {
app: "test-app",
}),
).to.be.rejectedWith(FirebaseError, "provide a valid directory to mapping file(s)");
});

it("should find and upload mapping files in a directory", async () => {
await command.runner()(DIR_PATH, { app: "test-app" });
expect(gcsMock.uploadObject).to.be.calledTwice;
const uploadedFiles = gcsMock.uploadObject
.getCalls()
.map((call) => call.args[0].file)
.sort();
expect(uploadedFiles[0]).to.match(
/test-app-.*-src-test-fixtures-mapping-files-mock_mapping\.js\.map\.zip/,
);
expect(uploadedFiles[1]).to.match(
/test-app-.*-src-test-fixtures-mapping-files-subdir-subdir_mock_mapping\.js\.map\.zip/,
);
});

it("should find and upload mapping files in the current directory if no path is provided", async () => {
const originalCwd = process.cwd();
try {
process.chdir("src/test");
await command.runner()(undefined, { app: "test-app" });
const uploadedFiles = gcsMock.uploadObject
.getCalls()
.map((call) => call.args[0].file)
.sort();
expect(uploadedFiles[0]).to.match(
/test-app-.*-fixtures-mapping-files-mock_mapping\.js\.map\.zip/,
);
expect(uploadedFiles[1]).to.match(
/test-app-.*-fixtures-mapping-files-subdir-subdir_mock_mapping\.js\.map\.zip/,
);
expect(uploadedFiles[2]).to.match(/test-app-.*-fixtures-mapping-files-with-js-main\.js\.zip/);
expect(uploadedFiles[3]).to.match(
/test-app-.*-fixtures-mapping-files-with-js-other\.js\.map\.zip/,
);
} finally {
process.chdir(originalCwd);
}
});

it("should find obfuscated mapping files linked by sourceMappingURL in a directory", async () => {
await command.runner()(DIR_WITH_JS_PATH, {
app: "test-app",
});
expect(gcsMock.uploadObject).to.be.calledTwice;
const uploadedFiles = gcsMock.uploadObject
.getCalls()
.map((call) => call.args[0].file)
.sort();

// The zip name is based on the obfuscated path, so the first one is the "main.js.map" pretending to be the name
expect(uploadedFiles[0]).to.match(
/test-app-.*-src-test-fixtures-mapping-files-with-js-main\.js\.zip/,
);
expect(uploadedFiles[1]).to.match(
/test-app-.*-src-test-fixtures-mapping-files-with-js-other\.js\.map\.zip/,
);

expect(clientPatchStub).to.be.calledTwice;
const apiPayloads = clientPatchStub
.getCalls()
.map((call) => (call.args[1] as SourceMap).obfuscatedFilePath)
.sort();

expect(apiPayloads[0]).to.equal("/src/test/fixtures/mapping-files-with-js/main.js");
expect(apiPayloads[1]).to.equal("/src/test/fixtures/mapping-files-with-js/other.js.map");
});

it("should use the provided app version", async () => {
await command.runner()(DIR_PATH, {
app: "test-app",
appVersion: "1.0.0",
});
const uploadedFiles = gcsMock.uploadObject
.getCalls()
.map((call) => call.args[0].file)
.sort();
expect(uploadedFiles[0]).to.eq(
"test-app-1.0.0-src-test-fixtures-mapping-files-mock_mapping.js.map.zip",
);
});

it("should fall back to the git commit for app version", async () => {
await command.runner()(DIR_PATH, {
app: "test-app",
});
const uploadedFiles = gcsMock.uploadObject
.getCalls()
.map((call) => call.args[0].file)
.sort();
expect(uploadedFiles[0]).to.match(
/test-app-a{40}-src-test-fixtures-mapping-files-mock_mapping.js.map.zip/,
);
});

it("should fall back to the package version for app version", async () => {
commandExistsSyncStub.withArgs("git").returns(true);
execSyncStub.withArgs("git rev-parse HEAD").throws(new Error("git failed"));
commandExistsSyncStub.withArgs("npm").returns(true);
execSyncStub.withArgs("npm pkg get version").returns(Buffer.from("1.2.3"));

await command.runner()(DIR_PATH, {
app: "test-app",
});
const uploadedFiles = gcsMock.uploadObject
.getCalls()
.map((call) => call.args[0].file)
.sort();
expect(uploadedFiles[0]).to.eq(
"test-app-1.2.3-src-test-fixtures-mapping-files-mock_mapping.js.map.zip",
);
});

it("should fall back to the 'unset' for app version", async () => {
commandExistsSyncStub.withArgs("git").returns(false);
commandExistsSyncStub.withArgs("npm").returns(false);

await command.runner()(DIR_PATH, {
app: "test-app",
});
const uploadedFiles = gcsMock.uploadObject
.getCalls()
.map((call) => call.args[0].file)
.sort();
expect(uploadedFiles[0]).to.eq(
"test-app-unset-src-test-fixtures-mapping-files-mock_mapping.js.map.zip",
);
});

it("should register the source map after upload", async () => {
await command.runner()(DIR_PATH, {
app: "test-app",
});
expect(clientPatchStub).to.be.calledTwice;
const payloads = clientPatchStub
.getCalls()
.map((call) => call.args[1] as SourceMap)
.sort((a, b) => a.obfuscatedFilePath.localeCompare(b.obfuscatedFilePath));
expect(payloads[0].name).to.match(
/projects\/test-project\/locations\/global\/mappingFiles\/2906062618/,
);
expect(payloads[0]).to.deep.equal({
name: "projects/test-project/locations/global/mappingFiles/2906062618",
appId: "test-app",
version: "a".repeat(40),
obfuscatedFilePath: "/src/test/fixtures/mapping-files/mock_mapping.js.map",
fileUri: `gs://${BUCKET_NAME}/test-object`,
});
expect(
(clientPatchStub.firstCall.args[2] as { queryParams: Record<string, string> }).queryParams,
).to.deep.equal({ allowMissing: "true" });
});

it("should warn if registration fails", async () => {
clientPatchStub.rejects(new Error("Registration failed"));
await command.runner()(DIR_PATH, {
app: "test-app",
retryDelay: 10,
});
expect(clientPatchStub.callCount).to.equal(4);
expect(logLabeledWarningStub).to.be.calledTwice;
expect(logLabeledWarningStub).to.be.calledWith(
"crashlytics",
sinon.match(/Failed to upload mapping file/),
);
});

it("should log failed files", async () => {
clientPatchStub.rejects(new Error("Registration failed"));
await command.runner()(DIR_PATH, { app: "test-app", retryDelay: 10 });

// Should verify that logLabeledBullet is called with the specific failed files
expect(logLabeledBulletStub).to.be.calledWith(
"crashlytics",
sinon.match(/Could not upload the following files:/),
);
expect(logLabeledBulletStub).to.be.calledWith(
"crashlytics",
sinon.match(/subdir_mock_mapping\.js\.map/),
);
expect(logLabeledBulletStub).to.be.calledWith(
"crashlytics",
sinon.match(/mock_mapping\.js\.map/),
);
});

it("should clean up temporary zip files from disk after success path upload", async () => {
const rmSyncSpy = sandbox.spy(fs, "rmSync");

await command.runner()(DIR_PATH, {
app: "test-app",
});

expect(rmSyncSpy.callCount).to.equal(2);

for (const call of rmSyncSpy.getCalls()) {
const filePath = call.args[0] as string;
expect(filePath).to.include(".zip");
expect(fs.existsSync(filePath)).to.be.false;
}
});

it("should clean up temporary zip files from disk even if upload fails and retries", async () => {
const rmSyncSpy = sandbox.spy(fs, "rmSync");
clientPatchStub.rejects(new Error("Registration failed"));

await command.runner()(DIR_PATH, {
app: "test-app",
retryDelay: 10,
});

expect(rmSyncSpy.callCount).to.equal(4);

for (const call of rmSyncSpy.getCalls()) {
const filePath = call.args[0] as string;
expect(filePath).to.include(".zip");
expect(fs.existsSync(filePath)).to.be.false;
}
});
});
Loading
Loading