Skip to content

Commit 73447d9

Browse files
committed
Implement partial source map upload command
1 parent 659c558 commit 73447d9

File tree

4 files changed

+262
-0
lines changed

4 files changed

+262
-0
lines changed

mockdata/mock_mapping.js.map

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as chai from "chai";
2+
import * as sinon from "sinon";
3+
4+
import { command } from "./crashlytics-sourcemap-upload";
5+
import * as gcs from "../gcp/storage";
6+
import * as projectUtils from "../projectUtils";
7+
import * as getProjectNumber from "../getProjectNumber";
8+
import { FirebaseError } from "../error";
9+
10+
const expect = chai.expect;
11+
12+
const PROJECT_ID = "test-project";
13+
const PROJECT_NUMBER = "12345";
14+
const BUCKET_NAME = "test-bucket";
15+
const DIR_PATH = "mockdata";
16+
const FILE_PATH = "mockdata/mock_mapping.js.map";
17+
18+
describe("crashlytics:sourcemap:upload", () => {
19+
let sandbox: sinon.SinonSandbox;
20+
let gcsMock: sinon.SinonStubbedInstance<typeof gcs>;
21+
let projectUtilsMock: sinon.SinonStubbedInstance<typeof projectUtils>;
22+
let getProjectNumberMock: sinon.SinonStubbedInstance<typeof getProjectNumber>;
23+
24+
beforeEach(() => {
25+
sandbox = sinon.createSandbox();
26+
gcsMock = sandbox.stub(gcs);
27+
projectUtilsMock = sandbox.stub(projectUtils);
28+
getProjectNumberMock = sandbox.stub(getProjectNumber);
29+
30+
projectUtilsMock.needProjectId.returns(PROJECT_ID);
31+
getProjectNumberMock.getProjectNumber.resolves(PROJECT_NUMBER);
32+
gcsMock.upsertBucket.resolves(BUCKET_NAME);
33+
gcsMock.uploadObject.resolves({
34+
bucket: BUCKET_NAME,
35+
object: "test-object",
36+
generation: "1",
37+
});
38+
});
39+
40+
afterEach(() => {
41+
sandbox.restore();
42+
});
43+
44+
it("should throw an error if no app ID is provided", async () => {
45+
await expect(command.runner()('filename', {})).to.be.rejectedWith(
46+
FirebaseError,
47+
"set --app <appId> to a valid Firebase application id"
48+
);
49+
});
50+
51+
it("should create the default cloud storage bucket", async () => {
52+
await command.runner()(FILE_PATH, { app: "test-app" });
53+
expect(gcsMock.upsertBucket).to.be.calledOnce;
54+
const args = gcsMock.upsertBucket.firstCall.args;
55+
expect(args[0].req.baseName).to.equal("firebasecrashlytics-sourcemaps-12345-us-central1");
56+
expect(args[0].req.location).to.equal("US-CENTRAL1");
57+
});
58+
59+
it("should create a custom cloud storage bucket", async () => {
60+
const options = {
61+
app: "test-app",
62+
bucketLocation: "a-different-LoCaTiOn",
63+
};
64+
await command.runner()(FILE_PATH, options);
65+
expect(gcsMock.upsertBucket).to.be.calledOnce;
66+
const args = gcsMock.upsertBucket.firstCall.args;
67+
expect(args[0].req.baseName).to.equal("firebasecrashlytics-sourcemaps-12345-a-different-location");
68+
expect(args[0].req.location).to.equal("A-DIFFERENT-LOCATION");
69+
});
70+
71+
it("should throw an error if the mapping file path is invalid", async () => {
72+
expect(
73+
command.runner()("invalid/path", { app: "test-app" })
74+
).to.be.rejectedWith(FirebaseError, "provide a valid file path or directory");
75+
});
76+
77+
it("should upload a single mapping file", async () => {
78+
await command.runner()(FILE_PATH, { app: "test-app" });
79+
expect(gcsMock.uploadObject).to.be.calledOnce;
80+
expect(gcsMock.uploadObject).to.be.calledWith(
81+
sinon.match.any,
82+
BUCKET_NAME,
83+
);
84+
expect(gcsMock.uploadObject.firstCall.args[0].file)
85+
.to.match(/test-app-default-mockdata-mock_mapping\.js\.map\.zip/);
86+
});
87+
88+
it("should find and upload mapping files in a directory", async () => {
89+
await command.runner()(DIR_PATH, { app: "test-app" });
90+
expect(gcsMock.uploadObject).to.be.calledOnce;
91+
expect(gcsMock.uploadObject.firstCall.args[0].file)
92+
.to.match(/test-app-default-mockdata-mock_mapping\.js\.map\.zip/);
93+
});
94+
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import * as archiver from "archiver";
2+
import * as fs from "fs";
3+
import * as path from "path";
4+
import * as tmp from "tmp";
5+
import { statSync } from "fs-extra";
6+
import { readdirRecursive } from "../fsAsync";
7+
import { Command } from "../command";
8+
import { FirebaseError } from "../error";
9+
import { logLabeledBullet, logLabeledWarning } from "../utils";
10+
import { needProjectId } from "../projectUtils";
11+
import * as gcs from "../gcp/storage";
12+
import { getProjectNumber } from "../getProjectNumber";
13+
import { Options } from "../options";
14+
15+
interface CommandOptions extends Options {
16+
app?: string;
17+
bucketLocation?: string;
18+
appVersion?: string;
19+
}
20+
21+
export const command = new Command("crashlytics:sourcemap:upload <mappingFiles>")
22+
.description("upload javascript source maps to de-minify stack traces")
23+
.option("--app <appID>", "the app id of your Firebase app")
24+
.option("--bucket-location <bucketLocation>", "the location of the Google Cloud Storage bucket (default: \"US-CENTRAL1\"")
25+
.option("--app-version <appVersion>", "the version of your Firebase app (defaults to Git commit hash, if available)")
26+
.action(async (mappingFiles: string, options: CommandOptions) => {
27+
const app = getGoogleAppID(options);
28+
const debug = !!options.debug;
29+
30+
// App version
31+
const appVersion = getAppVersion(options);
32+
33+
// Get project identifiers
34+
const projectId = needProjectId(options);
35+
const projectNumber = await getProjectNumber(options);
36+
37+
// Upsert default GCS bucket
38+
const bucketName = await upsertBucket(projectId, projectNumber, options);
39+
40+
// Find and upload mapping files
41+
const rootDir = options.projectRoot ?? process.cwd();
42+
const filePath = path.relative(rootDir, mappingFiles);
43+
let fstat: fs.Stats;
44+
try {
45+
fstat = statSync(filePath)
46+
} catch (e) {
47+
throw new FirebaseError(
48+
"provide a valid file path or directory to mapping file(s), e.g. app/build/outputs/app.js.map or app/build/outputs",
49+
);
50+
}
51+
if (fstat.isFile()) {
52+
await uploadMap(mappingFiles, bucketName, appVersion, options);
53+
} else if (fstat.isDirectory()) {
54+
logLabeledBullet("crashlytics", "Looking for mapping files in your directory...");
55+
const files = (await readdirRecursive({ path: filePath, ignore: ["node_modules", ".git"] }))
56+
.filter(f => f.name.endsWith('.js.map'));
57+
for (const file of files) {
58+
await uploadMap(file.name, bucketName, appVersion, options);
59+
}
60+
} else {
61+
throw new FirebaseError(
62+
"provide a valid file path or directory to mapping file(s), e.g. app/build/outputs/app.js.map or app/build/outputs",
63+
);
64+
}
65+
66+
// TODO: notify Firebase Telemetry service of the new mapping file
67+
});
68+
69+
function getGoogleAppID(options: CommandOptions): string {
70+
if (!options.app) {
71+
throw new FirebaseError(
72+
"set --app <appId> to a valid Firebase application id, e.g. 1:00000000:android:0000000",
73+
);
74+
}
75+
return options.app;
76+
}
77+
78+
function getAppVersion(options: CommandOptions): string {
79+
// TODO: implement app version lookup
80+
return "default";
81+
}
82+
83+
async function upsertBucket(projectId: string, projectNumber: string, options: CommandOptions): Promise<string> {
84+
let loc: string = 'US-CENTRAL1';
85+
if (options.bucketLocation) {
86+
loc = (options.bucketLocation as string).toUpperCase();
87+
} else {
88+
logLabeledBullet("crashlytics", "No Google Cloud Storage bucket location specified. Defaulting to US-CENTRAL1.");
89+
}
90+
91+
const baseName = `firebasecrashlytics-sourcemaps-${projectNumber}-${loc.toLowerCase()}`;
92+
return await gcs.upsertBucket({
93+
product: "crashlytics",
94+
createMessage: `Creating Cloud Storage bucket in ${loc} to store Crashlytics source maps at ${baseName}...`,
95+
projectId,
96+
req: {
97+
baseName,
98+
purposeLabel: `crashlytics-sourcemaps-${loc.toLowerCase()}`,
99+
location: loc,
100+
lifecycle: {
101+
rule: [
102+
{
103+
action: {
104+
type: "Delete",
105+
},
106+
condition: {
107+
age: 30,
108+
},
109+
},
110+
],
111+
},
112+
},
113+
});
114+
}
115+
116+
async function uploadMap(mappingFile: string, bucketName: string, appVersion: string, options: CommandOptions) {
117+
logLabeledBullet("crashlytics", `Found mapping file ${mappingFile}...`);
118+
try {
119+
const tmpArchive = await createArchive(mappingFile, options);
120+
const gcsFile = `${options.app}-${appVersion}-${normalizeFileName(mappingFile)}.zip`
121+
122+
const { bucket, object } = await gcs.uploadObject(
123+
{
124+
file: gcsFile,
125+
stream: fs.createReadStream(tmpArchive),
126+
},
127+
bucketName,
128+
);
129+
logLabeledBullet("crashlytics", `Uploaded to gs://${bucket}/${object}`);
130+
} catch (e) {
131+
logLabeledWarning("crashlytics", `Failed to upload mapping file ${mappingFile}:\n${e}`);
132+
}
133+
}
134+
135+
async function createArchive(mappingFile: string, options: CommandOptions): Promise<string> {
136+
const tmpName = normalizeFileName(mappingFile);
137+
const tmpFile = tmp.fileSync({ prefix: `${tmpName}-`, postfix: ".zip" }).name;
138+
const fileStream = fs.createWriteStream(tmpFile, {
139+
flags: "w",
140+
encoding: "binary",
141+
});
142+
const archive = archiver("zip");
143+
const rootDir = options.projectRoot ?? process.cwd();
144+
const name = path.relative(rootDir, mappingFile);
145+
archive.file(name, {name: 'mapping.js.map'});
146+
await pipeAsync(archive, fileStream);
147+
return tmpFile;
148+
}
149+
150+
async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise<void> {
151+
from.pipe(to);
152+
await from.finalize();
153+
return new Promise((resolve, reject) => {
154+
to.on("finish", resolve);
155+
to.on("error", reject);
156+
});
157+
}
158+
159+
function normalizeFileName(fileName: string): string {
160+
return fileName.replaceAll(/\//g, '-');
161+
}

src/commands/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export function load(client: any): any {
5252
client.crashlytics.mappingfile = {};
5353
client.crashlytics.mappingfile.generateid = loadCommand("crashlytics-mappingfile-generateid");
5454
client.crashlytics.mappingfile.upload = loadCommand("crashlytics-mappingfile-upload");
55+
client.crashlytics.sourcemap = {};
56+
client.crashlytics.sourcemap.upload = loadCommand("crashlytics-sourcemap-upload");
5557
client.database = {};
5658
client.database.get = loadCommand("database-get");
5759
client.database.import = loadCommand("database-import");

0 commit comments

Comments
 (0)