mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
98ec00f406
* fix(setup): report host-owned cleanup resources alongside the primary's
SetupHost.close documents that it reports the resources a setup host still
owns, but its contract returned only a string. runSetup therefore built the
ProviderCleanupError from the primary failure's cleanupResources alone, so a
resource the host failed to release reached the projection only inside a
free-text reason while the error asks the caller to review the reported local
resources.
Return a SetupCloseReport instead. closeSetupResources absorbs the structured
resources of any cause that reports them, through
ArtifactReaderFailure.cleanupObservation, and leaves opaque failures
message-only so no resource name is invented. runSetup then unions the host's
resources with the primary's before building the typed cleanup error.
Two boundary tests read the close result directly: the skill-cleanup case now
asserts the retained backup path in the report reason rather than in a bare
string, and the shared ownership helper takes an opaque close result because it
only ever awaits undefined from either host it is given.
Validation: RED then green in src/application/Setup.recovery.test.ts, where
the new case previously received only the primary resource; 116 tests across
the seven Setup and cleanup-ownership suites pass; tsc --noEmit over the
changed set, its setup callers, and both boundary tests exits 0; oxlint
reports 0 errors; oxfmt --check is clean; verify:module-boundaries passes.
The two failures in src/setupEnvironment.test.ts reproduce identically on
pristine bded9c628 with this change stashed.
* fix(setup): preserve cleanup ownership through host reports
* fix(setup): bind finalization reports to cleanup owners
* fix(setup): retain live resources in returned cleanup views
* fix(setup): preserve completed cleanup and exact installer resources
* fix(setup): retain delegated cleanup across authority changes
* fix(setup): compose callback and source cleanup authorities
* fix(setup): retain producer cleanup failure reasons
* fix(cli): render structured setup cleanup resources
* fix(setup): separate failure evidence from cleanup ownership
* fix(setup): normalize close failures and unify lease binding
* fix(setup): preserve direct lease resource attribution
---------
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
264 lines
8.2 KiB
TypeScript
264 lines
8.2 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
installMacHopper,
|
|
type MacHopperBundleVerification,
|
|
type MacHopperBundleInstallResult,
|
|
type MacHopperInstallHost,
|
|
} from "./MacHopper.js";
|
|
|
|
class FakeMacHopperHost implements MacHopperInstallHost {
|
|
readonly archive = new TextEncoder().encode("hopper-dmg");
|
|
archiveOk = true;
|
|
verification: MacHopperBundleVerification = "verified";
|
|
existing = false;
|
|
mountSucceeds = true;
|
|
copySucceeds = true;
|
|
mountFailure: Error | undefined;
|
|
verificationFailure: Error | undefined;
|
|
badHash = false;
|
|
unmounts = 0;
|
|
cleanups = 0;
|
|
installs = 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 = () =>
|
|
this.mountFailure === undefined
|
|
? Promise.resolve(this.mountSucceeds)
|
|
: Promise.reject(this.mountFailure);
|
|
unmount = () => {
|
|
this.unmounts += 1;
|
|
return Promise.resolve();
|
|
};
|
|
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
|
|
validBundle = () =>
|
|
this.verificationFailure === undefined
|
|
? Promise.resolve(this.verification)
|
|
: Promise.reject(this.verificationFailure);
|
|
destinationExists = () => Promise.resolve(this.existing);
|
|
installBundle = (
|
|
_source: string,
|
|
_destination: string,
|
|
replaceExisting: boolean,
|
|
) => {
|
|
this.installs += 1;
|
|
this.replaces = replaceExisting;
|
|
return Promise.resolve<MacHopperBundleInstallResult>(
|
|
this.copySucceeds
|
|
? { status: "installed" }
|
|
: {
|
|
status: "failed",
|
|
reason: "copy",
|
|
diagnostic: "ditto: destination is read-only",
|
|
},
|
|
);
|
|
};
|
|
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("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("preserves installation when macOS cannot open the installed app", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.openApplication = () =>
|
|
Promise.reject(new Error("LaunchServices refused"));
|
|
|
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
|
status: "installed",
|
|
launcherPath:
|
|
"/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
|
openFailure: "LaunchServices refused",
|
|
});
|
|
expect(host.installs).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.verification = "invalid_signature";
|
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
|
status: "failed",
|
|
reason: "bundle",
|
|
});
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("preserves a thrown mount-stage diagnostic and cleans the temporary image", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.mountFailure = new Error("hdiutil attach: resource busy");
|
|
|
|
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
|
status: "failed",
|
|
reason: "mount",
|
|
diagnostic: "hdiutil attach: resource busy",
|
|
});
|
|
expect(host.unmounts).toBe(0);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("preserves the copy diagnostic from the owned install operation", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.copySucceeds = false;
|
|
|
|
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
|
status: "failed",
|
|
reason: "copy",
|
|
diagnostic: "ditto: destination is read-only",
|
|
});
|
|
expect(host.installs).toBe(1);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("reports a publisher mismatch without copying or launching the bundle", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.verification = "unverified_publisher";
|
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
|
status: "failed",
|
|
reason: "publisher_verification_failed",
|
|
});
|
|
expect(host.installs).toBe(0);
|
|
expect(host.opens).toBe(0);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
|
|
it("preserves a thrown signature-check diagnostic as a bundle failure", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.verificationFailure = new Error("codesign: helper unavailable");
|
|
|
|
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
|
status: "failed",
|
|
reason: "bundle",
|
|
diagnostic: "codesign: helper unavailable",
|
|
});
|
|
expect(host.installs).toBe(0);
|
|
expect(host.unmounts).toBe(1);
|
|
expect(host.cleanups).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("reports an unresolved destination probe without claiming an existing installation", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
host.destinationExists = async () => {
|
|
throw Object.assign(new Error("destination inspection denied"), {
|
|
code: "EACCES",
|
|
});
|
|
};
|
|
expect(await installMacHopper({ replaceExisting: true }, host)).toMatchObject(
|
|
{
|
|
status: "failed",
|
|
reason: "destination_inspection",
|
|
diagnostic: "destination inspection denied",
|
|
},
|
|
);
|
|
expect(host.installs).toBe(0);
|
|
expect(host.unmounts).toBe(0);
|
|
});
|
|
|
|
it("retains the exact DMG temporary root with its retry capability", async () => {
|
|
const host = new FakeMacHopperHost();
|
|
const retained: {
|
|
close: () => Promise<void>;
|
|
resources: readonly string[];
|
|
}[] = [];
|
|
let allowCleanup = false;
|
|
const result = await installMacHopper(
|
|
{},
|
|
{
|
|
...host,
|
|
cleanup: async () => {
|
|
if (!allowCleanup) throw new Error("DMG root busy");
|
|
},
|
|
retainCleanup: (close, resources) => {
|
|
retained.push({ close, resources });
|
|
},
|
|
},
|
|
);
|
|
expect(result).toMatchObject({
|
|
status: "installed",
|
|
cleanupFailure: expect.stringContaining("DMG root busy"),
|
|
});
|
|
expect(retained).toHaveLength(1);
|
|
expect(retained[0]?.resources).toEqual(["/tmp/rea-hopper-test"]);
|
|
allowCleanup = true;
|
|
await expect(retained[0]!.close()).resolves.toBeUndefined();
|
|
});
|