Skip to content

Commit 01bccc6

Browse files
committed
test: add Bun.spawn spy and expanded isolated tests for upgrade coverage
- Expand test/isolated/brew-upgrade.test.ts: add executeUpgradePackageManager (npm/pnpm/bun/yarn args, error handling), detectLegacyInstallationMethod via isInstalledWith (npm detected, yarn detected, unknown, spawn error, auto-save and fast-path reuse), and unknown method default case - Add Bun.spawn spy tests in upgrade command tests: curl full upgrade path (runSetupOnNewBinary with --install), setup failure on non-zero exit, NIGHTLY_TAG download URL verification, --force flag bypassing version check, and migrateToStandaloneForNightly for npm->standalone nightly migration
1 parent 6edc751 commit 01bccc6

File tree

2 files changed

+538
-41
lines changed

2 files changed

+538
-41
lines changed

test/commands/cli/upgrade.test.ts

Lines changed: 268 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1010
import { mkdirSync, rmSync } from "node:fs";
11+
import { unlink } from "node:fs/promises";
12+
import { homedir } from "node:os";
1113
import { join } from "node:path";
1214
import { run } from "@stricli/core";
1315
import { app } from "../../../src/app.js";
@@ -136,6 +138,19 @@ function mockGitHubVersion(version: string): void {
136138
});
137139
}
138140

141+
/**
142+
* Mock fetch for the nightly version.json endpoint.
143+
*/
144+
function mockNightlyVersion(version: string): void {
145+
mockFetch(
146+
async () =>
147+
new Response(JSON.stringify({ version }), {
148+
status: 200,
149+
headers: { "content-type": "application/json" },
150+
})
151+
);
152+
}
153+
139154
describe("sentry cli upgrade", () => {
140155
let testDir: string;
141156

@@ -318,19 +333,6 @@ describe("sentry cli upgrade — nightly channel", () => {
318333
rmSync(testDir, { recursive: true, force: true });
319334
});
320335

321-
/**
322-
* Mock fetch for the nightly version.json endpoint.
323-
*/
324-
function mockNightlyVersion(version: string): void {
325-
mockFetch(
326-
async () =>
327-
new Response(JSON.stringify({ version }), {
328-
status: 200,
329-
headers: { "content-type": "application/json" },
330-
})
331-
);
332-
}
333-
334336
describe("resolveChannelAndVersion", () => {
335337
test("'nightly' positional sets channel to nightly", async () => {
336338
mockNightlyVersion(CLI_VERSION);
@@ -449,3 +451,256 @@ describe("sentry cli upgrade — nightly channel", () => {
449451
});
450452
});
451453
});
454+
455+
// ---------------------------------------------------------------------------
456+
// Download + setup paths (Option B: Bun.spawn spy)
457+
//
458+
// These tests cover runSetupOnNewBinary and the full executeUpgrade flow by:
459+
// 1. Mocking fetch to return a fake binary payload for downloadBinaryToTemp
460+
// 2. Replacing Bun.spawn with a spy that resolves immediately with exit 0
461+
//
462+
// Bun.spawn is writable on the global Bun object, so it can be temporarily
463+
// replaced without mock.module.
464+
// ---------------------------------------------------------------------------
465+
466+
describe("sentry cli upgrade — curl full upgrade path (Bun.spawn spy)", () => {
467+
useTestConfigDir("test-upgrade-spawn-");
468+
469+
let testDir: string;
470+
let originalSpawn: typeof Bun.spawn;
471+
let spawnedArgs: string[][];
472+
473+
/** Default install paths (default curl dir) */
474+
const defaultBinDir = join(homedir(), ".sentry", "bin");
475+
476+
beforeEach(() => {
477+
testDir = join(
478+
"/tmp",
479+
`upgrade-spawn-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
480+
);
481+
mkdirSync(testDir, { recursive: true });
482+
mkdirSync(defaultBinDir, { recursive: true });
483+
484+
originalFetch = globalThis.fetch;
485+
originalSpawn = Bun.spawn;
486+
spawnedArgs = [];
487+
488+
// Replace Bun.spawn with a spy that immediately resolves with exit 0
489+
Bun.spawn = ((cmd: string[], _opts: unknown) => {
490+
spawnedArgs.push(cmd);
491+
return { exited: Promise.resolve(0) };
492+
}) as typeof Bun.spawn;
493+
});
494+
495+
afterEach(async () => {
496+
globalThis.fetch = originalFetch;
497+
Bun.spawn = originalSpawn;
498+
rmSync(testDir, { recursive: true, force: true });
499+
500+
// Clean up any temp binary files written to the default curl install path
501+
const binName = process.platform === "win32" ? "sentry.exe" : "sentry";
502+
for (const suffix of ["", ".download", ".old", ".lock"]) {
503+
try {
504+
await unlink(join(defaultBinDir, `${binName}${suffix}`));
505+
} catch {
506+
// Ignore
507+
}
508+
}
509+
});
510+
511+
/**
512+
* Mock fetch to serve both the GitHub latest-release version endpoint and a
513+
* minimal valid gzipped binary for downloadBinaryToTemp.
514+
*/
515+
function mockBinaryDownloadWithVersion(version: string): void {
516+
const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); // ELF magic
517+
const gzipped = Bun.gzipSync(fakeContent);
518+
mockFetch(async (url) => {
519+
const urlStr = String(url);
520+
if (urlStr.includes("releases/latest")) {
521+
return new Response(JSON.stringify({ tag_name: version }), {
522+
status: 200,
523+
headers: { "content-type": "application/json" },
524+
});
525+
}
526+
// Binary download (.gz or raw)
527+
return new Response(gzipped, { status: 200 });
528+
});
529+
}
530+
531+
test("runs setup on downloaded binary after curl upgrade", async () => {
532+
mockBinaryDownloadWithVersion("99.99.99");
533+
534+
const { context, output } = createMockContext({ homeDir: testDir });
535+
536+
await run(app, ["cli", "upgrade", "--method", "curl"], context);
537+
538+
const combined = output.join("");
539+
expect(combined).toContain("Upgrading to 99.99.99...");
540+
expect(combined).toContain("Successfully upgraded to 99.99.99.");
541+
542+
// Verify Bun.spawn was called with the downloaded binary + setup args
543+
expect(spawnedArgs.length).toBeGreaterThan(0);
544+
const setupCall = spawnedArgs.find((args) => args.includes("setup"));
545+
expect(setupCall).toBeDefined();
546+
expect(setupCall).toContain("cli");
547+
expect(setupCall).toContain("setup");
548+
expect(setupCall).toContain("--method");
549+
expect(setupCall).toContain("curl");
550+
expect(setupCall).toContain("--install");
551+
});
552+
553+
test("reports setup failure when Bun.spawn exits non-zero", async () => {
554+
// Use a unified mock that handles both the version endpoint and binary download
555+
const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]);
556+
const gzipped = Bun.gzipSync(fakeContent);
557+
mockFetch(async (url) => {
558+
const urlStr = String(url);
559+
if (urlStr.includes("releases/latest")) {
560+
return new Response(JSON.stringify({ tag_name: "99.99.99" }), {
561+
status: 200,
562+
headers: { "content-type": "application/json" },
563+
});
564+
}
565+
// Binary download (both .gz and raw URLs)
566+
return new Response(gzipped, { status: 200 });
567+
});
568+
569+
Bun.spawn = ((_cmd: string[], _opts: unknown) => ({
570+
exited: Promise.resolve(1),
571+
})) as typeof Bun.spawn;
572+
573+
const { context, errors } = createMockContext({ homeDir: testDir });
574+
575+
await run(app, ["cli", "upgrade", "--method", "curl"], context);
576+
577+
expect(errors.join("")).toContain("Setup failed with exit code 1");
578+
});
579+
580+
test("uses NIGHTLY_TAG download URL for nightly channel without versionArg", async () => {
581+
const capturedUrls: string[] = [];
582+
const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]);
583+
const gzipped = Bun.gzipSync(fakeContent);
584+
585+
// Single unified mock: captures all URLs, serves version.json and binary download
586+
mockFetch(async (url) => {
587+
const urlStr = String(url);
588+
capturedUrls.push(urlStr);
589+
if (urlStr.includes("version.json")) {
590+
return new Response(
591+
JSON.stringify({ version: "0.99.0-dev.1234567890" }),
592+
{ status: 200, headers: { "content-type": "application/json" } }
593+
);
594+
}
595+
// Binary download — return gzipped content for all other URLs
596+
return new Response(gzipped, { status: 200 });
597+
});
598+
599+
// Set nightly channel; "nightly" positional triggers NIGHTLY_TAG for download
600+
setReleaseChannel("nightly");
601+
602+
const { context } = createMockContext({ homeDir: testDir });
603+
604+
await run(app, ["cli", "upgrade", "--method", "curl", "nightly"], context);
605+
606+
// Download URL should use the rolling "nightly" tag
607+
const downloadUrl = capturedUrls.find((u) => u.includes("/download/"));
608+
expect(downloadUrl).toBeDefined();
609+
expect(downloadUrl).toContain("/download/nightly/");
610+
});
611+
612+
test("--force bypasses 'already up to date' and proceeds to download", async () => {
613+
mockBinaryDownloadWithVersion(CLI_VERSION); // Same version — would normally short-circuit
614+
615+
const { context, output } = createMockContext({ homeDir: testDir });
616+
617+
await run(app, ["cli", "upgrade", "--method", "curl", "--force"], context);
618+
619+
const combined = output.join("");
620+
// With --force, should NOT show "Already up to date"
621+
expect(combined).not.toContain("Already up to date.");
622+
// Should proceed to upgrade and succeed
623+
expect(combined).toContain(`Upgrading to ${CLI_VERSION}...`);
624+
expect(combined).toContain(`Successfully upgraded to ${CLI_VERSION}.`);
625+
});
626+
});
627+
628+
describe("sentry cli upgrade — migrateToStandaloneForNightly (Bun.spawn spy)", () => {
629+
useTestConfigDir("test-upgrade-migrate-");
630+
631+
let testDir: string;
632+
let originalSpawn: typeof Bun.spawn;
633+
634+
const defaultBinDir = join(homedir(), ".sentry", "bin");
635+
636+
beforeEach(() => {
637+
testDir = join(
638+
"/tmp",
639+
`upgrade-migrate-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
640+
);
641+
mkdirSync(testDir, { recursive: true });
642+
mkdirSync(defaultBinDir, { recursive: true });
643+
644+
originalFetch = globalThis.fetch;
645+
originalSpawn = Bun.spawn;
646+
647+
Bun.spawn = ((_cmd: string[], _opts: unknown) => ({
648+
exited: Promise.resolve(0),
649+
})) as typeof Bun.spawn;
650+
});
651+
652+
afterEach(async () => {
653+
globalThis.fetch = originalFetch;
654+
Bun.spawn = originalSpawn;
655+
rmSync(testDir, { recursive: true, force: true });
656+
657+
const binName = process.platform === "win32" ? "sentry.exe" : "sentry";
658+
for (const suffix of ["", ".download", ".old", ".lock"]) {
659+
try {
660+
await unlink(join(defaultBinDir, `${binName}${suffix}`));
661+
} catch {
662+
// Ignore
663+
}
664+
}
665+
});
666+
667+
test("migrates npm install to standalone binary for nightly channel", async () => {
668+
const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]);
669+
const gzipped = Bun.gzipSync(fakeContent);
670+
671+
mockFetch(async (url) => {
672+
const urlStr = String(url);
673+
// nightly version.json
674+
if (urlStr.includes("version.json")) {
675+
return new Response(
676+
JSON.stringify({ version: "0.99.0-dev.1234567890" }),
677+
{ status: 200, headers: { "content-type": "application/json" } }
678+
);
679+
}
680+
// binary download (.gz)
681+
if (urlStr.includes(".gz")) {
682+
return new Response(gzipped, { status: 200 });
683+
}
684+
return new Response("Not Found", { status: 404 });
685+
});
686+
687+
// Switch to nightly and use npm method → triggers migration
688+
setReleaseChannel("nightly");
689+
690+
const { context, output } = createMockContext({ homeDir: testDir });
691+
692+
await run(app, ["cli", "upgrade", "--method", "npm", "nightly"], context);
693+
694+
const combined = output.join("");
695+
expect(combined).toContain(
696+
"Nightly builds are only available as standalone binaries."
697+
);
698+
expect(combined).toContain("Migrating to standalone installation...");
699+
expect(combined).toContain("Successfully installed nightly");
700+
// Warns about old npm install
701+
expect(combined).toContain(
702+
"npm-installed sentry may still appear earlier in PATH"
703+
);
704+
expect(combined).toContain("npm uninstall -g sentry");
705+
});
706+
});

0 commit comments

Comments
 (0)