-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add crashlytics:sourcemap:upload command behind experiment
#10565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tonybaroneee
wants to merge
39
commits into
main
Choose a base branch
from
crashlytics2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,478
−0
Open
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 4946af1
Add app version defaults to Crashlytics mapping file upload (#9525)
andrewbrook 6806e57
Register stored Crashlytics source map file with telemetry admin API …
tonybaroneee 85aa573
Add resource name and `allow_missing` param to source map registratio…
tonybaroneee 86910fe
Update source map upload command options (#10000)
tonybaroneee 87bc9ba
Handle mismatched source map file names (#10013)
tonybaroneee b1a4c37
Fix Crashlytics unit tests (#10172)
andrewbrook 98cf3e2
Update crashlytics:sourcemap:upload to align with API (#10176)
andrewbrook 6a65117
add rate limiting and retry logic to source map uploads (#10191)
andrewbrook 4d04a90
Misc. bug fixes to Crashlytics mapping upload (#10441)
andrewbrook bfec42b
Merge branch 'main' into crashlytics2
tonybaroneee a1bb36a
Merge branch 'main' into crashlytics2
tonybaroneee 18a2197
Merge branch 'main' into crashlytics2
tonybaroneee 58c285f
Merge branch 'main' into crashlytics2
tonybaroneee 261c510
Only allow directories to be passed to crashlytics sourcemap upload (…
tonybaroneee 433f024
Merge branch 'main' into crashlytics2
tonybaroneee 586a05a
Merge branch 'main' into crashlytics2
tonybaroneee 4ed637a
Update firebase telemetry admin url
tonybaroneee ae69234
Add experiment for crashlytics source map upload cmd
tonybaroneee e5cf473
Merge branch 'main' into crashlytics2
tonybaroneee a3af6df
Replace `ignore` and `ignoreStrings` for readdirRecursive
tonybaroneee 7a1bd0a
Add MurmurHash3 hashing function util
tonybaroneee 370f265
Add `retryDelay` to sourcemap upload command options
tonybaroneee 5703f6a
Use for.. of.. instead of forEach
tonybaroneee c82f09b
Added logic to cleanup tmp zip files from disk
tonybaroneee 52c6b80
Merge branch 'main' into crashlytics2
tonybaroneee 0e65b63
Update getLinkedSourceMapPath to use async file operations
tonybaroneee c432982
Use named capturing group in `sourceMappingURL` regex for clarity
tonybaroneee a931c9a
Optimize `pipeAsync` to register listeners before finalize
tonybaroneee 3f04575
Split sourcemap utility functions into separate file
tonybaroneee 8d6ebfd
Lint fixes
tonybaroneee a639fa9
Add specs for sourcemap uploads
tonybaroneee 06dc36e
Small fixes from GCA review
tonybaroneee b0d3416
Merge branch 'main' into crashlytics2
tonybaroneee 3d4b42a
Merge branch 'main' into crashlytics2
joehan e324075
Close fds in archiveFile
tonybaroneee 0d73b96
Merge branch 'crashlytics2' of github.com:firebase/firebase-tools int…
tonybaroneee 429dc6b
Merge branch 'main' into crashlytics2
tonybaroneee c4058de
Cite source for murmur3 hash algo
tonybaroneee File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> { | ||
| 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); | ||
| }); | ||
| } | ||
|
tonybaroneee marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.