Files
rea/tests/boundary/filesystem/macHopperInstallOwnership.test.ts
T
Dery Ferdika 98ec00f406 fix(setup): preserve cleanup ownership and resource reports (#6920)
* 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>
2026-10-24 15:08:10 +08:00

321 lines
10 KiB
TypeScript

import {
access,
mkdir,
mkdtemp,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
installMacHopperBundle,
systemMacHopperInstallHost,
type MacHopperBundleInstallOperations,
} from "../../../src/application/MacHopper.js";
import { SetupCleanupOwner } from "../../../src/application/SetupCleanupOwner.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
const temporaryRoot = async (): Promise<string> => {
const root = await mkdtemp(join(tmpdir(), "rea-hopper-owned-stage-test-"));
roots.push(root);
return root;
};
const bundleFiles = (
overrides: Partial<MacHopperBundleInstallOperations> = {},
captureRoot?: (root: string, mode: number) => void,
): MacHopperBundleInstallOperations => ({
async createStagingRoot(parent) {
await mkdir(parent, { recursive: true });
const root = await mkdtemp(join(parent, ".rea-hopper-stage-"));
captureRoot?.(root, (await stat(root)).mode & 0o777);
return root;
},
async copyBundle(source, stage) {
const executable = join(stage, "Contents/MacOS/hopper");
await mkdir(join(stage, "Contents/MacOS"), { recursive: true });
await writeFile(executable, source);
},
requireLauncher: async (path) => access(path).then(() => undefined),
async pathExists(path) {
try {
await access(path);
return true;
} catch (cause: unknown) {
if (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
)
return false;
throw cause;
}
},
rename,
removeStagingRoot: (path) => rm(path, { recursive: true }),
...overrides,
});
const writeBundle = async (path: string, content: string): Promise<void> => {
await mkdir(join(path, "Contents/MacOS"), { recursive: true });
await writeFile(join(path, "Contents/MacOS/hopper"), content);
};
describe("macOS Hopper staging isolation", () => {
it("rejects a staged bundle without its launcher before moving the original", async () => {
const root = await temporaryRoot();
const destination = join(root, "Applications/Hopper Disassembler.app");
await writeBundle(destination, "original installation");
let stagingRoot = "";
const result = await installMacHopperBundle(
"replacement without launcher",
destination,
true,
bundleFiles(
{
async copyBundle(_source, stage) {
await mkdir(join(stage, "Contents/MacOS"), { recursive: true });
},
},
(path) => (stagingRoot = path),
),
() => undefined,
);
expect(result).toMatchObject({
status: "failed",
reason: "launcher_missing",
diagnostic: expect.stringContaining("hopper"),
});
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("original installation");
await expect(
access(join(stagingRoot, "previous.app")),
).rejects.toMatchObject({
code: "ENOENT",
});
await expect(access(stagingRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
it("leaves pre-existing staging-looking sibling directories untouched", async () => {
const root = await temporaryRoot();
const applications = join(root, "Applications");
const destination = join(applications, "Hopper Disassembler.app");
const oldStage = `${destination}.rea-stage`;
const oldPrevious = `${oldStage}-previous`;
await Promise.all([
writeBundle(oldStage, "user stage sentinel"),
writeBundle(oldPrevious, "user backup sentinel"),
]);
let stagingRoot = "";
let stagingMode = 0;
await expect(
installMacHopperBundle(
"new bundle",
destination,
false,
bundleFiles({}, (path, mode) => {
stagingRoot = path;
stagingMode = mode;
}),
() => undefined,
),
).resolves.toEqual({ status: "installed" });
expect(
await readFile(join(oldStage, "Contents/MacOS/hopper"), "utf8"),
).toBe("user stage sentinel");
expect(
await readFile(join(oldPrevious, "Contents/MacOS/hopper"), "utf8"),
).toBe("user backup sentinel");
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("new bundle");
expect(stagingMode).toBe(0o700);
await expect(access(stagingRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
});
describe("macOS Hopper replacement ownership", () => {
it("keeps the committed replacement when owned-root cleanup fails", async () => {
const root = await temporaryRoot();
const destination = join(root, "Applications/Hopper Disassembler.app");
await writeBundle(destination, "previous installation");
let stagingRoot = "";
let failedCleanup = false;
const retained: (() => Promise<void>)[] = [];
const result = await installMacHopperBundle(
"new installation",
destination,
true,
bundleFiles(
{
removeStagingRoot: async (path) => {
if (!failedCleanup) {
failedCleanup = true;
throw new Error("simulated cleanup denial");
}
await rm(path, { recursive: true });
},
},
(path) => (stagingRoot = path),
),
(cleanup, resources) => {
expect(resources).toEqual([stagingRoot]);
retained.push(cleanup);
},
);
expect(result).toMatchObject({
status: "installed",
cleanupFailure: expect.stringContaining("simulated cleanup denial"),
});
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("new installation");
expect(
await readFile(
join(stagingRoot, "previous.app/Contents/MacOS/hopper"),
"utf8",
),
).toBe("previous installation");
expect(retained).toHaveLength(1);
await retained[0]!();
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("new installation");
await expect(access(stagingRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
it("retains a moved backup and never overwrites a later destination on rollback", async () => {
const root = await temporaryRoot();
const destination = join(root, "Applications/Hopper Disassembler.app");
await writeBundle(destination, "original installation");
let stagingRoot = "";
let backupMoved = false;
let concurrentInstallCreated = false;
const rollbackRetained: (() => Promise<void>)[] = [];
const realRename = rename;
const result = await installMacHopperBundle(
"replacement installation",
destination,
true,
bundleFiles(
{
rename: async (source, target) => {
if (source === destination && target.endsWith("/previous.app")) {
await realRename(source, target);
backupMoved = true;
return;
}
if (source.endsWith("/new.app") && target === destination) {
await writeBundle(destination, "later installation");
concurrentInstallCreated = true;
throw new Error("simulated competing install");
}
await realRename(source, target);
},
},
(path) => (stagingRoot = path),
),
(cleanup, resources) => {
expect(resources).toEqual([stagingRoot]);
rollbackRetained.push(cleanup);
},
);
expect(result).toMatchObject({
status: "failed",
diagnostic: expect.stringContaining("simulated competing install"),
cleanupFailure: expect.stringContaining(
"previous Hopper bundle remains owned",
),
});
expect(backupMoved).toBe(true);
expect(concurrentInstallCreated).toBe(true);
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("later installation");
expect(
await readFile(
join(stagingRoot, "previous.app/Contents/MacOS/hopper"),
"utf8",
),
).toBe("original installation");
expect(rollbackRetained).toHaveLength(1);
await expect(rollbackRetained[0]!()).rejects.toThrow(
"destination is occupied",
);
expect(
await readFile(
join(stagingRoot, "previous.app/Contents/MacOS/hopper"),
"utf8",
),
).toBe("original installation");
await rm(destination, { recursive: true });
await rollbackRetained[0]!();
expect(
await readFile(join(destination, "Contents/MacOS/hopper"), "utf8"),
).toBe("original installation");
await expect(access(stagingRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
});
it("reports only retained Mac cleanup roots and clears the live report after exact retries", async () => {
const home = await temporaryRoot();
const temporary = join(home, "owned-dmg");
const stage = join(home, "owned-stage");
await Promise.all([mkdir(temporary), mkdir(stage)]);
const host = systemMacHopperInstallHost(home, { HOME: home });
let allowTemporary = false;
let allowStage = false;
let temporaryCalls = 0;
host.retainCleanup?.(async () => {
temporaryCalls++;
if (!allowTemporary) throw new Error("DMG root busy");
await rm(temporary, { recursive: true });
}, [temporary]);
host.retainCleanup?.(async () => {
if (!allowStage) throw new Error("staging root busy");
await rm(stage, { recursive: true });
}, [stage]);
const cleanup = new SetupCleanupOwner([{ close: () => host.close() }]);
const failure = await cleanup.close();
expect(failure).toBeDefined();
expect(projectAnalysisError(failure!).details?.resources).toEqual([
temporary,
stage,
]);
allowTemporary = true;
expect((await cleanup.close())?.cleanupResources).toEqual([stage]);
expect(failure?.cleanupResources).toEqual([stage]);
await expect(access(temporary)).rejects.toMatchObject({ code: "ENOENT" });
allowStage = true;
expect(await cleanup.close()).toBeUndefined();
expect(failure?.cleanupIncomplete).toBe(false);
expect(temporaryCalls).toBe(2);
await expect(access(stage)).rejects.toMatchObject({ code: "ENOENT" });
});