Skip to content

Commit 5bb82b5

Browse files
committed
test(brew): add coverage for Homebrew install method
- UpgradeError unsupported_operation: default message - fetchLatestVersion/versionExists: brew routes to GitHub (same as curl) - detectInstallationMethod: detects brew from /Cellar/ in execPath - executeUpgrade(brew): success, non-zero exit, spawn error, correct args - upgrade command: errors on brew + explicit version, check mode works
1 parent 11655dd commit 5bb82b5

File tree

3 files changed

+221
-0
lines changed

3 files changed

+221
-0
lines changed

test/commands/cli/upgrade.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,39 @@ describe("sentry cli upgrade", () => {
216216
});
217217
});
218218

219+
describe("brew method", () => {
220+
test("errors immediately when specific version requested with brew", async () => {
221+
// No fetch mock needed — error is thrown before any network call
222+
const { context, output, errors } = createMockContext({
223+
homeDir: testDir,
224+
});
225+
226+
await run(app, ["cli", "upgrade", "--method", "brew", "1.2.3"], context);
227+
228+
const allOutput = [...output, ...errors].join("");
229+
expect(allOutput).toContain(
230+
"Homebrew does not support installing a specific version"
231+
);
232+
});
233+
234+
test("check mode works for brew method", async () => {
235+
mockGitHubVersion("99.99.99");
236+
237+
const { context, output } = createMockContext({ homeDir: testDir });
238+
239+
await run(
240+
app,
241+
["cli", "upgrade", "--check", "--method", "brew"],
242+
context
243+
);
244+
245+
const combined = output.join("");
246+
expect(combined).toContain("Installation method: brew");
247+
expect(combined).toContain("Latest version: 99.99.99");
248+
expect(combined).toContain("Run 'sentry cli upgrade' to update.");
249+
});
250+
});
251+
219252
describe("version validation", () => {
220253
test("reports error for non-existent version", async () => {
221254
// Mock: latest is 99.99.99, but 0.0.1 doesn't exist

test/isolated/brew-upgrade.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Isolated tests for Homebrew upgrade execution.
3+
*
4+
* Uses mock.module() to stub node:child_process/spawn, which leaks module
5+
* state — kept isolated so it doesn't affect other test files.
6+
*/
7+
8+
import { describe, expect, mock, test } from "bun:test";
9+
import {
10+
execFile,
11+
execFileSync,
12+
execSync,
13+
fork,
14+
spawnSync,
15+
} from "node:child_process";
16+
import { EventEmitter } from "node:events";
17+
import { UpgradeError } from "../../src/lib/errors.js";
18+
19+
/**
20+
* Build a minimal fake ChildProcess EventEmitter that emits 'close'
21+
* with the given exit code after a microtask tick.
22+
*/
23+
function fakeProcess(exitCode: number): EventEmitter {
24+
const emitter = new EventEmitter();
25+
// Emit close asynchronously so the Promise can attach listeners first
26+
queueMicrotask(() => emitter.emit("close", exitCode));
27+
return emitter;
28+
}
29+
30+
// Mock node:child_process before importing the module under test.
31+
// Bun hoists mock.module() calls, so this runs before any imports below.
32+
// Pass through real non-spawn exports so transitive deps are unaffected.
33+
let spawnImpl: (cmd: string, args: string[], opts: object) => EventEmitter =
34+
() => fakeProcess(0);
35+
36+
mock.module("node:child_process", () => ({
37+
execFile,
38+
execFileSync,
39+
execSync,
40+
fork,
41+
spawnSync,
42+
spawn: (cmd: string, args: string[], opts: object) =>
43+
spawnImpl(cmd, args, opts),
44+
}));
45+
46+
// Import after mock is registered
47+
const { executeUpgrade } = await import("../../src/lib/upgrade.js");
48+
49+
describe("executeUpgrade (brew)", () => {
50+
test("returns null on successful brew upgrade", async () => {
51+
spawnImpl = () => fakeProcess(0);
52+
53+
const result = await executeUpgrade("brew", "1.0.0");
54+
expect(result).toBeNull();
55+
});
56+
57+
test("throws UpgradeError with execution_failed reason on non-zero exit", async () => {
58+
spawnImpl = () => fakeProcess(1);
59+
60+
await expect(executeUpgrade("brew", "1.0.0")).rejects.toThrow(UpgradeError);
61+
62+
try {
63+
await executeUpgrade("brew", "1.0.0");
64+
expect.unreachable("should have thrown");
65+
} catch (err) {
66+
expect(err).toBeInstanceOf(UpgradeError);
67+
expect((err as UpgradeError).reason).toBe("execution_failed");
68+
expect((err as UpgradeError).message).toContain("exit code 1");
69+
}
70+
});
71+
72+
test("throws UpgradeError with execution_failed reason on spawn error", async () => {
73+
spawnImpl = () => {
74+
const emitter = new EventEmitter();
75+
queueMicrotask(() => emitter.emit("error", new Error("brew not found")));
76+
return emitter;
77+
};
78+
79+
try {
80+
await executeUpgrade("brew", "1.0.0");
81+
expect.unreachable("should have thrown");
82+
} catch (err) {
83+
expect(err).toBeInstanceOf(UpgradeError);
84+
expect((err as UpgradeError).reason).toBe("execution_failed");
85+
expect((err as UpgradeError).message).toContain("brew not found");
86+
}
87+
});
88+
89+
test("invokes brew with correct arguments", async () => {
90+
let capturedCmd = "";
91+
let capturedArgs: string[] = [];
92+
93+
spawnImpl = (cmd, args) => {
94+
capturedCmd = cmd;
95+
capturedArgs = args;
96+
return fakeProcess(0);
97+
};
98+
99+
await executeUpgrade("brew", "1.0.0");
100+
101+
expect(capturedCmd).toBe("brew");
102+
expect(capturedArgs).toEqual(["upgrade", "getsentry/tools/sentry"]);
103+
});
104+
});

test/lib/upgrade.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { clearInstallInfo } from "../../src/lib/db/install-info.js";
1919
import { UpgradeError } from "../../src/lib/errors.js";
2020
import {
21+
detectInstallationMethod,
2122
executeUpgrade,
2223
fetchLatestFromGitHub,
2324
fetchLatestFromNpm,
@@ -250,6 +251,14 @@ describe("UpgradeError", () => {
250251
expect(error.message).toBe("The specified version was not found.");
251252
});
252253

254+
test("creates error with default message for unsupported_operation", () => {
255+
const error = new UpgradeError("unsupported_operation");
256+
expect(error.reason).toBe("unsupported_operation");
257+
expect(error.message).toBe(
258+
"This operation is not supported for this installation method."
259+
);
260+
});
261+
253262
test("allows custom message", () => {
254263
const error = new UpgradeError("network_error", "Custom error message");
255264
expect(error.reason).toBe("network_error");
@@ -323,6 +332,19 @@ describe("fetchLatestVersion", () => {
323332
expect(version).toBe("2.0.0");
324333
});
325334

335+
test("uses GitHub for brew method", async () => {
336+
mockFetch(
337+
async () =>
338+
new Response(JSON.stringify({ tag_name: "v2.0.0" }), {
339+
status: 200,
340+
headers: { "Content-Type": "application/json" },
341+
})
342+
);
343+
344+
const version = await fetchLatestVersion("brew");
345+
expect(version).toBe("2.0.0");
346+
});
347+
326348
test("uses npm for unknown method", async () => {
327349
mockFetch(
328350
async () =>
@@ -380,6 +402,20 @@ describe("versionExists", () => {
380402
expect(exists).toBe(true);
381403
});
382404

405+
test("checks GitHub for brew method - version exists", async () => {
406+
mockFetch(async () => new Response(null, { status: 200 }));
407+
408+
const exists = await versionExists("brew", "1.0.0");
409+
expect(exists).toBe(true);
410+
});
411+
412+
test("checks GitHub for brew method - version does not exist", async () => {
413+
mockFetch(async () => new Response(null, { status: 404 }));
414+
415+
const exists = await versionExists("brew", "99.99.99");
416+
expect(exists).toBe(false);
417+
});
418+
383419
test("checks npm for yarn method", async () => {
384420
mockFetch(async () => new Response(null, { status: 200 }));
385421

@@ -431,6 +467,54 @@ describe("executeUpgrade", () => {
431467
});
432468
});
433469

470+
describe("Homebrew detection (detectInstallationMethod)", () => {
471+
let originalExecPath: string;
472+
473+
beforeEach(() => {
474+
originalExecPath = process.execPath;
475+
});
476+
477+
afterEach(() => {
478+
Object.defineProperty(process, "execPath", {
479+
value: originalExecPath,
480+
configurable: true,
481+
});
482+
clearInstallInfo();
483+
});
484+
485+
test("detects brew when execPath resolves through /Cellar/", async () => {
486+
Object.defineProperty(process, "execPath", {
487+
value: "/opt/homebrew/Cellar/sentry/1.2.3/bin/sentry",
488+
configurable: true,
489+
});
490+
491+
const method = await detectInstallationMethod();
492+
expect(method).toBe("brew");
493+
});
494+
495+
test("detects brew for Intel Homebrew path (/usr/local/Cellar/)", async () => {
496+
Object.defineProperty(process, "execPath", {
497+
value: "/usr/local/Cellar/sentry/1.2.3/bin/sentry",
498+
configurable: true,
499+
});
500+
501+
const method = await detectInstallationMethod();
502+
expect(method).toBe("brew");
503+
});
504+
505+
test("does not detect brew for non-Homebrew paths", async () => {
506+
// A plain curl-installed binary should not be detected as brew
507+
Object.defineProperty(process, "execPath", {
508+
value: "/home/user/.sentry/bin/sentry",
509+
configurable: true,
510+
});
511+
512+
// Will fall through to package manager checks and return "unknown" in test env
513+
const method = await detectInstallationMethod();
514+
expect(method).not.toBe("brew");
515+
});
516+
});
517+
434518
describe("getBinaryDownloadUrl", () => {
435519
test("builds correct URL for current platform", () => {
436520
const url = getBinaryDownloadUrl("1.0.0");

0 commit comments

Comments
 (0)