import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; import { installMacHopper, macHopperInstallDisclosure, type MacHopperInstallHost, } from "../src/application/MacHopper.js"; class FakeMacHopperHost implements MacHopperInstallHost { readonly archive = new TextEncoder().encode("hopper-dmg"); archiveOk = true; valid = true; existing = false; mountSucceeds = true; copySucceeds = true; launcherSucceeds = true; badHash = false; unmounts = 0; cleanups = 0; opens = 0; replaces = false; download = (url: string) => Promise.resolve( url.includes("files-api") ? { ok: true, bytes: new TextEncoder().encode( JSON.stringify({ "OS X": { filename: "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg", file_length: String(this.archive.byteLength), file_hash: this.badHash ? "0".repeat(40) : createHash("sha1").update(this.archive).digest("hex"), }, }), ), } : { ok: this.archiveOk, bytes: this.archive }, ); createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test"); createMountDirectory = () => Promise.resolve(); writePackage = () => Promise.resolve(); mount = () => Promise.resolve(this.mountSucceeds); unmount = () => { this.unmounts += 1; return Promise.resolve(); }; appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app"); validBundle = () => Promise.resolve(this.valid); destinationExists = () => Promise.resolve(this.existing); installBundle = ( _source: string, _destination: string, _stage: string, replaceExisting: boolean, ) => { this.replaces = replaceExisting; return Promise.resolve(this.copySucceeds); }; launcherReady = () => Promise.resolve(this.launcherSucceeds); openApplication = () => { this.opens += 1; return Promise.resolve(); }; cleanup = () => { this.cleanups += 1; return Promise.resolve(); }; destination = () => "/home/user/Applications/Hopper Disassembler.app"; } describe("macOS Hopper installation", () => { it("discloses network and command boundaries before installation", () => { expect(macHopperInstallDisclosure).toMatchObject({ networkOrigins: [ "https://www.hopperapp.com/include/files-api.php?request=releases&public=true", "https://www.hopperapp.com:443/downloader/public/", ], }); expect(macHopperInstallDisclosure.commands).toContain( "hdiutil attach -readonly -nobrowse -mountpoint ", ); }); it("installs, verifies, opens, and cleans the official package", async () => { const host = new FakeMacHopperHost(); await expect(installMacHopper({}, host)).resolves.toEqual({ status: "installed", launcherPath: "/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper", }); expect(host.opens).toBe(1); expect(host.unmounts).toBe(1); expect(host.cleanups).toBe(1); }); it("rejects an integrity mismatch before mounting", async () => { const host = new FakeMacHopperHost(); host.badHash = true; await expect(installMacHopper({}, host)).resolves.toEqual({ status: "failed", reason: "integrity", }); expect(host.unmounts).toBe(0); expect(host.cleanups).toBe(0); }); it("refuses to overwrite an existing application", async () => { const host = new FakeMacHopperHost(); host.existing = true; await expect(installMacHopper({}, host)).resolves.toEqual({ status: "failed", reason: "destination_exists", }); }); it("replaces the managed application when explicitly requested", async () => { const host = new FakeMacHopperHost(); host.existing = true; await expect( installMacHopper({ replaceExisting: true }, host), ).resolves.toMatchObject({ status: "installed" }); expect(host.replaces).toBe(true); }); it("cleans and detaches after bundle validation fails", async () => { const host = new FakeMacHopperHost(); host.valid = false; await expect(installMacHopper({}, host)).resolves.toEqual({ status: "failed", reason: "bundle", }); expect(host.unmounts).toBe(1); expect(host.cleanups).toBe(1); }); });