Files
rea/tests/boundary/filesystem/setupLinuxCleanupOwnership.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

133 lines
4.8 KiB
TypeScript

import { mkdir, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import {
installLinuxHopper,
systemLinuxHopperInstallHost,
} from "../../../src/application/LinuxHopper.js";
import { systemSetupHost } from "../../../src/application/SetupHost.js";
import { runSetup } from "../../../src/application/Setup.js";
import { createDoctorHostFixture } from "../../../src/application/Doctor.fixture.js";
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { ConfigurationError } from "../../../src/domain/configurationErrors.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
vi.mock("../../../src/application/LinuxHopper.js", async (load) => {
const actual =
await load<typeof import("../../../src/application/LinuxHopper.js")>();
return {
...actual,
installLinuxHopper: vi.fn(actual.installLinuxHopper),
systemLinuxHopperInstallHost: vi.fn(actual.systemLinuxHopperInstallHost),
};
});
afterEach(() => vi.resetAllMocks());
it.each(["settled", "late"] as const)(
"retains and releases the actual temporary directory through setup's Linux cleanup task: %s",
async (timing) => {
const home = await createTestTempDirectory("rea-setup-linux-cleanup-");
const temporary = join(home, "owned-installer-staging");
await mkdir(temporary);
let allowCleanup = false;
const actual = await vi.importActual<
typeof import("../../../src/application/LinuxHopper.js")
>("../../../src/application/LinuxHopper.js");
const child = actual.systemLinuxHopperInstallHost(
{ HOME: home },
async () => undefined,
);
vi.mocked(systemLinuxHopperInstallHost).mockReturnValue({
...child,
cleanup: async (path) => {
expect(path).toBe(temporary);
if (!allowCleanup)
throw new Error(
"Controlled owned temporary directory cleanup failure",
);
await rm(path, { recursive: true });
},
});
// Only the installer outcome is simulated; no package/download action runs.
let completeInstall: (() => void) | undefined;
const installGate = new Promise<void>((resolve) => {
completeInstall = resolve;
});
vi.mocked(installLinuxHopper).mockImplementation(async () => {
if (timing === "late") await installGate;
return {
status: "failed",
reason: "cleanup_failed",
cleanupFailure: {
path: temporary,
reason: "Controlled cleanup failure",
},
};
});
const closeDoctor = vi.fn(async () => undefined);
const host = systemSetupHost(
createDoctorHostFixture({
platform: "linux",
homeDirectory: home,
close: closeDoctor,
}),
{ HOME: home },
);
try {
const installing = host.installHopper(false);
const closing = timing === "late" ? host.close?.() : undefined;
completeInstall?.();
expect(await installing).toMatchObject({ status: "failed" });
if (closing !== undefined)
expect((await closing)?.cleanupResources).toEqual([temporary]);
const primary = new ConfigurationError("Controlled state failure", {
settings: [{ setting: "fixture", constraint: "controlled" }],
});
const failure = await runSetup(
{ approved: true, structured: true, installHopper: false },
{
...host,
initialSetupState: async () => {
throw primary;
},
},
).then(
() => undefined,
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(AnalysisError);
if (!(failure instanceof AnalysisError))
throw new Error("Expected typed cleanup failure");
expect(projectAnalysisError(failure)).toMatchObject({
code: "cleanup_incomplete",
details: { resources: [temporary] },
});
expect((await stat(temporary)).isDirectory()).toBe(true);
allowCleanup = true;
expect(await host.close?.()).toBeUndefined();
expect(failure.cleanupIncomplete).toBe(false);
expect(closeDoctor).toHaveBeenCalledTimes(1);
expect(projectAnalysisError(failure)).toMatchObject(
projectAnalysisError(primary),
);
expect(projectAnalysisError(failure).details).not.toHaveProperty(
"cleanup",
);
expect(projectAnalysisError(failure).details).not.toHaveProperty(
"resources",
);
expect(projectAnalysisError(failure).details).toHaveProperty(
"cleanup_history",
);
await expect(stat(temporary)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
completeInstall?.();
allowCleanup = true;
await host.close?.();
}
},
);