Skip to content

Commit cb03500

Browse files
feat(crashlytics): Add unit tests for Crashlytics helpers (#8951)
* feat(crashlytics): Add unit tests for Crashlytics helpers Adds comprehensive unit tests for the two helper modules in `src/crashlytics`: - `buildToolsJarHelper.ts`: Tests cover JAR download/caching logic and the command execution wrapper. Mocks are used for filesystem operations, child processes, and environment variables. - `listTopIssues.ts`: Tests cover successful API calls and error handling for failed or invalid API requests. HTTP requests are mocked using `nock`. A minor refactoring was done in `buildToolsJarHelper.ts` to facilitate testing by using `fs-extra` for file deletion, which was easier to mock than the direct `node:fs` import. * format; --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 75ccad5 commit cb03500

File tree

3 files changed

+216
-2
lines changed

3 files changed

+216
-2
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import * as chai from "chai";
2+
import * as sinon from "sinon";
3+
import * as fs from "fs-extra";
4+
import * as os from "os";
5+
import * as path from "path";
6+
import * as chaiAsPromised from "chai-as-promised";
7+
import * as spawn from "cross-spawn";
8+
import * as downloadUtils from "../downloadUtils";
9+
import { FirebaseError } from "../error";
10+
11+
chai.use(chaiAsPromised);
12+
const expect = chai.expect;
13+
14+
// Note: we have to use require() here because our test needs to stub out dependencies
15+
// that are imported at the module-level in buildToolsJarHelper.ts.
16+
/* eslint-disable @typescript-eslint/no-var-requires */
17+
18+
describe("buildToolsJarHelper", () => {
19+
let buildToolsJarHelper: any;
20+
let sandbox: sinon.SinonSandbox;
21+
22+
const jarVersion = "3.0.3";
23+
const fakeHomeDir = "/fake/home";
24+
const cacheDir = path.join(fakeHomeDir, ".cache", "firebase", "crashlytics", "buildtools");
25+
const jarPath = path.join(cacheDir, `crashlytics-buildtools-${jarVersion}.jar`);
26+
27+
let downloadStub: sinon.SinonStub;
28+
let existsSyncStub: sinon.SinonStub;
29+
let rmSyncStub: sinon.SinonStub;
30+
let mkdirSyncStub: sinon.SinonStub;
31+
let copySyncStub: sinon.SinonStub;
32+
33+
beforeEach(() => {
34+
sandbox = sinon.createSandbox();
35+
sandbox.stub(os, "homedir").returns(fakeHomeDir);
36+
37+
downloadStub = sandbox.stub(downloadUtils, "downloadToTmp").resolves("/tmp/tmp.jar");
38+
existsSyncStub = sandbox.stub(fs, "existsSync");
39+
rmSyncStub = sandbox.stub(fs, "rmSync");
40+
mkdirSyncStub = sandbox.stub(fs, "mkdirSync");
41+
copySyncStub = sandbox.stub(fs, "copySync");
42+
43+
// Clear the module cache to ensure we get a fresh import with our stubs
44+
delete require.cache[require.resolve("./buildToolsJarHelper")];
45+
buildToolsJarHelper = require("./buildToolsJarHelper");
46+
});
47+
48+
afterEach(() => {
49+
sandbox.restore();
50+
delete process.env.CRASHLYTICS_LOCAL_JAR;
51+
});
52+
53+
describe("fetchBuildtoolsJar", () => {
54+
it("should use the local jar override if provided", async () => {
55+
process.env.CRASHLYTICS_LOCAL_JAR = "/local/path/to/jar.jar";
56+
const result = await buildToolsJarHelper.fetchBuildtoolsJar();
57+
expect(result).to.equal("/local/path/to/jar.jar");
58+
expect(downloadStub).to.not.have.been.called;
59+
});
60+
61+
it("should return the path to the cached jar if it exists", async () => {
62+
existsSyncStub.withArgs(jarPath).returns(true);
63+
64+
const result = await buildToolsJarHelper.fetchBuildtoolsJar();
65+
66+
expect(result).to.equal(jarPath);
67+
expect(downloadStub).to.not.have.been.called;
68+
});
69+
70+
it("should download the jar if it does not exist in the cache and the cache dir doesn't exist", async () => {
71+
existsSyncStub.withArgs(jarPath).returns(false);
72+
existsSyncStub.withArgs(cacheDir).returns(false);
73+
74+
const result = await buildToolsJarHelper.fetchBuildtoolsJar();
75+
76+
expect(downloadStub).to.have.been.calledOnce;
77+
expect(mkdirSyncStub).to.have.been.calledWith(cacheDir, { recursive: true });
78+
expect(copySyncStub).to.have.been.calledWith("/tmp/tmp.jar", jarPath);
79+
expect(rmSyncStub).to.not.have.been.called;
80+
expect(result).to.equal(jarPath);
81+
});
82+
83+
it("should delete old cache and download the new jar if cache dir exists but jar file doesn't", async () => {
84+
existsSyncStub.withArgs(jarPath).returns(false);
85+
existsSyncStub.withArgs(cacheDir).returns(true);
86+
87+
const result = await buildToolsJarHelper.fetchBuildtoolsJar();
88+
89+
expect(rmSyncStub).to.have.been.calledWith(cacheDir, { recursive: true, force: true });
90+
expect(downloadStub).to.have.been.calledOnce;
91+
expect(mkdirSyncStub).to.have.been.calledWith(cacheDir, { recursive: true });
92+
expect(copySyncStub).to.have.been.calledWith("/tmp/tmp.jar", jarPath);
93+
expect(result).to.equal(jarPath);
94+
});
95+
});
96+
97+
describe("runBuildtoolsCommand", () => {
98+
let spawnSyncStub: sinon.SinonStub;
99+
100+
beforeEach(() => {
101+
spawnSyncStub = sandbox.stub(spawn, "sync");
102+
});
103+
104+
it("should call spawn.sync with the correct arguments", () => {
105+
spawnSyncStub.returns({ status: 0 });
106+
const jarFile = "my.jar";
107+
const args = ["arg1", "arg2"];
108+
109+
buildToolsJarHelper.runBuildtoolsCommand(jarFile, args, false);
110+
111+
expect(spawnSyncStub).to.have.been.calledWith(
112+
"java",
113+
["-jar", jarFile, ...args, "-clientName", "firebase-cli;crashlytics-buildtools"],
114+
{ stdio: "pipe" },
115+
);
116+
});
117+
118+
it("should use 'inherit' for stdio when debug is true", () => {
119+
spawnSyncStub.returns({ status: 0 });
120+
const jarFile = "my.jar";
121+
const args = ["arg1", "arg2"];
122+
123+
buildToolsJarHelper.runBuildtoolsCommand(jarFile, args, true);
124+
125+
expect(spawnSyncStub).to.have.been.calledWith(
126+
"java",
127+
["-jar", jarFile, ...args, "-clientName", "firebase-cli;crashlytics-buildtools"],
128+
{ stdio: "inherit" },
129+
);
130+
});
131+
132+
it("should throw a FirebaseError on command failure", () => {
133+
spawnSyncStub.returns({ status: 1, stdout: "error output" });
134+
const jarFile = "my.jar";
135+
const args = ["arg1", "arg2"];
136+
137+
expect(() => buildToolsJarHelper.runBuildtoolsCommand(jarFile, args, false)).to.throw(
138+
FirebaseError,
139+
/java command failed/,
140+
);
141+
});
142+
143+
it("should not throw an error on command success", () => {
144+
spawnSyncStub.returns({ status: 0 });
145+
const jarFile = "my.jar";
146+
const args = ["arg1", "arg2"];
147+
148+
expect(() => buildToolsJarHelper.runBuildtoolsCommand(jarFile, args, false)).to.not.throw();
149+
});
150+
});
151+
});

src/crashlytics/buildToolsJarHelper.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import * as spawn from "cross-spawn";
66
import * as downloadUtils from "../downloadUtils";
77
import { FirebaseError } from "../error";
88
import { logger } from "../logger";
9-
import { rmSync } from "node:fs";
109
import * as utils from "../utils";
1110

1211
const JAR_CACHE_DIR =
@@ -39,7 +38,7 @@ export async function fetchBuildtoolsJar(): Promise<string> {
3938
logger.debug(
4039
`Deleting Jar cache at ${JAR_CACHE_DIR} because the CLI was run with a newer Jar version`,
4140
);
42-
rmSync(JAR_CACHE_DIR, { recursive: true });
41+
fs.rmSync(JAR_CACHE_DIR, { recursive: true, force: true });
4342
}
4443
utils.logBullet("Downloading crashlytics-buildtools.jar to " + jarPath);
4544
utils.logBullet(

src/crashlytics/listTopIssues.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as chai from "chai";
2+
import * as nock from "nock";
3+
import * as chaiAsPromised from "chai-as-promised";
4+
5+
import { listTopIssues } from "./listTopIssues";
6+
import { FirebaseError } from "../error";
7+
import { crashlyticsApiOrigin } from "../api";
8+
9+
chai.use(chaiAsPromised);
10+
const expect = chai.expect;
11+
12+
describe("listTopIssues", () => {
13+
const projectId = "my-project";
14+
const appId = "1:1234567890:android:abcdef1234567890";
15+
const requestProjectId = "1234567890";
16+
17+
afterEach(() => {
18+
nock.cleanAll();
19+
});
20+
21+
it("should resolve with the response body on success", async () => {
22+
const issueType = "FATAL";
23+
const issueCount = 10;
24+
const mockResponse = { issues: [{ id: "1" }] };
25+
26+
nock(crashlyticsApiOrigin())
27+
.get(`/v1alpha/projects/${requestProjectId}/apps/${appId}/reports/topIssues`)
28+
.query({
29+
page_size: `${issueCount}`,
30+
"filter.issue.error_types": issueType,
31+
})
32+
.reply(200, mockResponse);
33+
34+
const result = await listTopIssues(projectId, appId, issueType, issueCount);
35+
36+
expect(result).to.deep.equal(mockResponse);
37+
expect(nock.isDone()).to.be.true;
38+
});
39+
40+
it("should throw a FirebaseError if the API call fails", async () => {
41+
const issueType = "FATAL";
42+
const issueCount = 10;
43+
44+
nock(crashlyticsApiOrigin())
45+
.get(`/v1alpha/projects/${requestProjectId}/apps/${appId}/reports/topIssues`)
46+
.reply(500, { error: "Internal Server Error" });
47+
48+
await expect(listTopIssues(projectId, appId, issueType, issueCount)).to.be.rejectedWith(
49+
FirebaseError,
50+
/Failed to fetch the top issues/,
51+
);
52+
});
53+
54+
it("should throw a FirebaseError if the appId is invalid", async () => {
55+
const invalidAppId = "invalid-app-id";
56+
const issueType = "FATAL";
57+
const issueCount = 10;
58+
59+
await expect(listTopIssues(projectId, invalidAppId, issueType, issueCount)).to.be.rejectedWith(
60+
FirebaseError,
61+
"Unable to get the projectId from the AppId.",
62+
);
63+
});
64+
});

0 commit comments

Comments
 (0)