Skip to content

Commit d4dd524

Browse files
authored
Partially implement javascript source map upload command (#9447)
* Implement partial source map upload command * lint * move test fixture data * improve coverage * refactor * format * fix non-determinism in test
1 parent eec6c5c commit d4dd524

File tree

7 files changed

+314
-0
lines changed

7 files changed

+314
-0
lines changed

src/archiveFile.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as fs from "fs";
2+
import { expect } from "chai";
3+
import { archiveFile } from "./archiveFile";
4+
import { FIXTURE_DIR } from "./test/fixtures/config-imports";
5+
6+
describe("archiveFile", () => {
7+
it("should archive files", async () => {
8+
const outputPath = await archiveFile(`${FIXTURE_DIR}/firebase.json`);
9+
10+
expect(outputPath).to.match(/\.zip$/);
11+
expect(fs.existsSync(outputPath)).to.be.true;
12+
const archiveStats = fs.statSync(outputPath);
13+
const origStats = fs.statSync(`${FIXTURE_DIR}/firebase.json`);
14+
expect(archiveStats.size).to.be.greaterThan(0);
15+
expect(archiveStats.size).not.to.equal(origStats.size);
16+
});
17+
});

src/archiveFile.ts

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

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");

src/test/fixtures/mapping-files/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.

src/test/fixtures/mapping-files/subdir/subdir_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.

0 commit comments

Comments
 (0)