fix(setup): preserve Linux Hopper HTTP failure status (#7183)

Preserve observed failed-response HTTP status through Linux Hopper setup diagnostics without changing download rejection, retry, or installation policy. Independently validated on latest main through red/green regressions, actual TLS sockets, focused checks and CI. Closes #7182.
This commit is contained in:
Wenyu Chiou authored and GitHub committed 2026-10-26 20:45:06 +08:00
1 parent 0211a07ec5
commit 1a91630b88
4 files changed
+171 -11

No files matched your search

@@ -0,0 +1,132 @@
import { afterEach, expect, it, vi } from "vitest";
import { createDoctorHostFixture } from "../../src/application/Doctor.fixture.js";
import { downloadHopperPackage } from "../../src/application/HopperDownload.js";
import {
systemLinuxHopperInstallHost,
type LinuxHopperInstallHost,
} from "../../src/application/LinuxHopper.js";
import { systemSetupHost } from "../../src/application/SetupHost.js";
import { ok } from "../../src/domain/result.js";
vi.mock("../../src/application/LinuxHopper.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../src/application/LinuxHopper.js")
>()),
systemLinuxHopperInstallHost: vi.fn(),
}));
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const url =
"https://www.hopperapp.com/downloader/public/Hopper-fixture-Linux-demo.deb";
const generic =
"REA could not download or unpack the official Hopper package. Check network and filesystem access, then retry setup.";
const installHost = (): LinuxHopperInstallHost => {
const unexpected = vi.fn((): never => {
throw new Error("Unexpected installation effect");
});
return {
distribution: async () => ({
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
}),
release: async () =>
ok({
version: "fixture",
filename: url,
file_length: "1",
file_hash: "0".repeat(40),
main_exe_hash: "0".repeat(64),
signature: "AA==",
public: "1",
beta: "0",
}),
download: downloadHopperPackage,
createTemporaryDirectory: unexpected,
writeArchive: unexpected,
installPackage: unexpected,
verifyArchive: unexpected,
retainVerifiedArchive: unexpected,
launcherStatus: unexpected,
cleanup: unexpected,
};
};
const setupFailure = async () => {
vi.mocked(systemLinuxHopperInstallHost).mockReturnValue(installHost());
const host = systemSetupHost(
createDoctorHostFixture({ platform: "linux" }),
{},
);
try {
return await host.installHopper(false);
} finally {
expect(await host.close?.()).toBeUndefined();
}
};
it.each([404, 503])(
"preserves HTTP %i from download through Linux setup diagnostics",
async (status) => {
const cancel = vi.fn();
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockImplementation(
async () => new Response(new ReadableStream({ cancel }), { status }),
);
vi.stubGlobal("fetch", fetch);
expect(await downloadHopperPackage(url)).toEqual({
ok: false,
bytes: new Uint8Array(),
httpStatus: status,
});
expect(await setupFailure()).toEqual({
status: "failed",
code: "download_failed",
remediation: `download: Package download returned HTTP ${status}. ${generic}`,
});
expect(fetch).toHaveBeenCalledTimes(2);
expect(cancel).toHaveBeenCalledTimes(2);
},
);
it.each([undefined, null, 0, 200, 600, 503.5, "503", Number.NaN])(
"keeps generic diagnostics when the failed response status is %s",
async (status) => {
vi.stubGlobal(
"fetch",
vi.fn<typeof globalThis.fetch>().mockImplementation(async () => {
const response = new Response(null, { status: 503 });
Object.defineProperty(response, "status", { value: status });
return response;
}),
);
expect(await downloadHopperPackage(url)).toEqual({
ok: false,
bytes: new Uint8Array(),
});
expect(await setupFailure()).toEqual({
status: "failed",
code: "download_failed",
remediation: generic,
});
},
);
it("keeps a successful download unchanged", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response("package")),
);
expect(await downloadHopperPackage(url)).toEqual({
ok: true,
bytes: Buffer.from("package"),
});
});