Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions mockdata/mock_mapping.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 96 additions & 0 deletions src/commands/crashlytics-sourcemap-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as chai from "chai";
import * as sinon from "sinon";

import { command } from "./crashlytics-sourcemap-upload";
import * as gcs from "../gcp/storage";
import * as projectUtils from "../projectUtils";
import * as getProjectNumber from "../getProjectNumber";
import { FirebaseError } from "../error";

const expect = chai.expect;

const PROJECT_ID = "test-project";
const PROJECT_NUMBER = "12345";
const BUCKET_NAME = "test-bucket";
const DIR_PATH = "mockdata";
const FILE_PATH = "mockdata/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>;

beforeEach(() => {
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",
});
});

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()(FILE_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()(FILE_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 () => {
expect(command.runner()("invalid/path", { app: "test-app" })).to.be.rejectedWith(
FirebaseError,
"provide a valid file path or directory",
);
});

it("should upload a single mapping file", async () => {
await command.runner()(FILE_PATH, { app: "test-app" });
expect(gcsMock.uploadObject).to.be.calledOnce;
expect(gcsMock.uploadObject).to.be.calledWith(sinon.match.any, BUCKET_NAME);
expect(gcsMock.uploadObject.firstCall.args[0].file).to.match(
/test-app-default-mockdata-mock_mapping\.js\.map\.zip/,
);
});

it("should find and upload mapping files in a directory", async () => {
await command.runner()(DIR_PATH, { app: "test-app" });
expect(gcsMock.uploadObject).to.be.calledOnce;
expect(gcsMock.uploadObject.firstCall.args[0].file).to.match(
/test-app-default-mockdata-mock_mapping\.js\.map\.zip/,
);
});
});
178 changes: 178 additions & 0 deletions src/commands/crashlytics-sourcemap-upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import * as archiver from "archiver";
import * as fs from "fs";
import * as path from "path";
import * as tmp from "tmp";
import { statSync } from "fs-extra";
import { readdirRecursive } from "../fsAsync";
import { Command } from "../command";
import { FirebaseError } from "../error";
import { logLabeledBullet, logLabeledWarning } from "../utils";
import { needProjectId } from "../projectUtils";
import * as gcs from "../gcp/storage";
import { getProjectNumber } from "../getProjectNumber";
import { Options } from "../options";

interface CommandOptions extends Options {
app?: string;
bucketLocation?: string;
appVersion?: string;
}

export const command = new Command("crashlytics:sourcemap:upload <mappingFiles>")
.description("upload javascript source maps to de-minify stack traces")
.option("--app <appID>", "the app id of your Firebase app")
.option(
"--bucket-location <bucketLocation>",
'the location of the Google Cloud Storage bucket (default: "US-CENTRAL1"',
)
.option(
"--app-version <appVersion>",
"the version of your Firebase app (defaults to Git commit hash, if available)",
)
.action(async (mappingFiles: string, options: CommandOptions) => {
checkGoogleAppID(options);

// App version
const appVersion = getAppVersion();

// Get project identifiers
const projectId = needProjectId(options);
const projectNumber = await getProjectNumber(options);

// Upsert default GCS bucket
const bucketName = await upsertBucket(projectId, projectNumber, options);

// Find and upload mapping files
const rootDir = options.projectRoot ?? process.cwd();
const filePath = path.relative(rootDir, mappingFiles);
let fstat: fs.Stats;
try {
fstat = statSync(filePath);
} catch (e) {
throw new FirebaseError(
"provide a valid file path or directory to mapping file(s), e.g. app/build/outputs/app.js.map or app/build/outputs",
);
}
if (fstat.isFile()) {
await uploadMap(mappingFiles, bucketName, appVersion, options);
} else if (fstat.isDirectory()) {
logLabeledBullet("crashlytics", "Looking for mapping files in your directory...");
const files = (
await readdirRecursive({ path: filePath, ignore: ["node_modules", ".git"] })
).filter((f) => f.name.endsWith(".js.map"));
for (const file of files) {
await uploadMap(file.name, bucketName, appVersion, options);
}
} else {
throw new FirebaseError(
"provide a valid file path or directory to mapping file(s), e.g. app/build/outputs/app.js.map or app/build/outputs",
);
}

// TODO: notify Firebase Telemetry service of the new mapping file
});

function checkGoogleAppID(options: CommandOptions): void {
if (!options.app) {
throw new FirebaseError(
"set --app <appId> to a valid Firebase application id, e.g. 1:00000000:android:0000000",
);
}
}

function getAppVersion(): string {
// TODO: implement app version lookup
return "default";
}

async function upsertBucket(
projectId: string,
projectNumber: string,
options: CommandOptions,
): Promise<string> {
let loc: string = "US-CENTRAL1";
if (options.bucketLocation) {
loc = (options.bucketLocation as string).toUpperCase();
} else {
logLabeledBullet(
"crashlytics",
"No Google Cloud Storage bucket location specified. Defaulting to US-CENTRAL1.",
);
}

const baseName = `firebasecrashlytics-sourcemaps-${projectNumber}-${loc.toLowerCase()}`;
return await gcs.upsertBucket({
product: "crashlytics",
createMessage: `Creating Cloud Storage bucket in ${loc} to store Crashlytics source maps at ${baseName}...`,
projectId,
req: {
baseName,
purposeLabel: `crashlytics-sourcemaps-${loc.toLowerCase()}`,
location: loc,
lifecycle: {
rule: [
{
action: {
type: "Delete",
},
condition: {
age: 30,
},
},
],
},
},
});
}

async function uploadMap(
mappingFile: string,
bucketName: string,
appVersion: string,
options: CommandOptions,
) {
logLabeledBullet("crashlytics", `Found mapping file ${mappingFile}...`);
try {
const tmpArchive = await createArchive(mappingFile, options);
const gcsFile = `${options.app}-${appVersion}-${normalizeFileName(mappingFile)}.zip`;

const { bucket, object } = await gcs.uploadObject(
{
file: gcsFile,
stream: fs.createReadStream(tmpArchive),
},
bucketName,
);
logLabeledBullet("crashlytics", `Uploaded to gs://${bucket}/${object}`);
} catch (e) {
logLabeledWarning("crashlytics", `Failed to upload mapping file ${mappingFile}:\n${e}`);
}
}

async function createArchive(mappingFile: string, options: CommandOptions): Promise<string> {
const tmpName = normalizeFileName(mappingFile);
const tmpFile = tmp.fileSync({ prefix: `${tmpName}-`, postfix: ".zip" }).name;
const fileStream = fs.createWriteStream(tmpFile, {
flags: "w",
encoding: "binary",
});
const archive = archiver("zip");
const rootDir = options.projectRoot ?? process.cwd();
const name = path.relative(rootDir, mappingFile);
archive.file(name, { name: "mapping.js.map" });
await pipeAsync(archive, fileStream);
return tmpFile;
}

async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise<void> {
from.pipe(to);
await from.finalize();
return new Promise((resolve, reject) => {
to.on("finish", resolve);
to.on("error", reject);
});
}

function normalizeFileName(fileName: string): string {
return fileName.replaceAll(/\//g, "-");
}
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export function load(client: any): any {
client.crashlytics.mappingfile = {};
client.crashlytics.mappingfile.generateid = loadCommand("crashlytics-mappingfile-generateid");
client.crashlytics.mappingfile.upload = loadCommand("crashlytics-mappingfile-upload");
client.crashlytics.sourcemap = {};
client.crashlytics.sourcemap.upload = loadCommand("crashlytics-sourcemap-upload");
client.database = {};
client.database.get = loadCommand("database-get");
client.database.import = loadCommand("database-import");
Expand Down
Loading