Files
rea/src/application/LinuxHopper.test.ts
T
morluto ec56c1da04 fix(setup): preserve registration identity and recovery ownership
Keep layered environment choices, current byte-exact recovery snapshots, and package-manager failure details. Report pinned package drift without claiming runner resolution. Retain failed Linux installer cleanup for host-owned retry while preserving the install outcome.

Reviewed fixes include #2632, #2769, #2783, #2959, #2979, #2983, #2995, #3009, and #2559.

Validation: 98 focused setup/registration tests passed; 45 Linux installer tests passed. Real Linux package installation remains unverified.
2026-10-14 08:25:53 +08:00

386 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
formatCommandForDisplay,
installLinuxHopper,
linuxHopperLauncherDigestSupported,
linuxPackageManagerCommands,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
supportsReaHost,
LinuxHopperTemporaryCleanupOwner,
type LinuxDistribution,
type LinuxHopperDownload,
type LinuxHopperInstallHost,
type LinuxHopperLauncherStatus,
type LinuxPackageFamily,
type LinuxPackageInstallFailure,
type LinuxPackageInstallOutcome,
} from "./LinuxHopper.js";
describe("Linux package-manager command display", () => {
it("preserves argument boundaries for paths with spaces and quotes", () => {
expect(
formatCommandForDisplay("pkexec", [
"pacman",
"-U",
"/tmp/rea test/hopper.arch",
"apostrophe'path",
]),
).toBe('pkexec pacman -U "/tmp/rea test/hopper.arch" "apostrophe\'path"');
});
});
class RecordingLinuxHost implements LinuxHopperInstallHost {
distributionValue: LinuxDistribution | undefined = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
};
archive = new Uint8Array([1, 2, 3]);
archiveOk = true;
installSucceeds = true;
cleanupFails = false;
packageFailure: LinuxPackageInstallFailure = {
command: "pacman -U --noconfirm /tmp/rea test/hopper.arch",
exitCode: 1,
stdout: "",
stderr: "error: failed to prepare transaction",
outputTruncated: false,
};
launcherStatusValue: LinuxHopperLauncherStatus = "ready";
downloads: string[] = [];
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
cleaned: string[] = [];
distribution = (): Promise<LinuxDistribution | undefined> =>
Promise.resolve(this.distributionValue);
download = (url: string): Promise<LinuxHopperDownload> => {
this.downloads.push(url);
return Promise.resolve({ ok: this.archiveOk, bytes: this.archive });
};
createTemporaryDirectory = (): Promise<string> =>
Promise.resolve("/tmp/rea test");
writeArchive = (): Promise<void> => Promise.resolve();
installPackage = (
family: LinuxPackageFamily,
archive: string,
): Promise<LinuxPackageInstallOutcome> => {
this.installed.push({ family, archive });
const outcome: LinuxPackageInstallOutcome = this.installSucceeds
? { status: "installed" }
: { status: "failed", failure: this.packageFailure };
return Promise.resolve(outcome);
};
launcherStatus = (): Promise<LinuxHopperLauncherStatus> =>
Promise.resolve(this.launcherStatusValue);
cleanup = (path: string): Promise<void> => {
this.cleaned.push(path);
return this.cleanupFails
? Promise.reject(new Error("temporary path is busy"))
: Promise.resolve();
};
}
describe("Linux Hopper host classification", () => {
it.each([
['ID=ubuntu\nVERSION_ID="24.04"\n', "deb"],
["ID=fedora\nVERSION_ID=41\n", "rpm"],
['ID=nobara\nVERSION_ID="44"\nID_LIKE="rhel centos fedora"\n', "rpm"],
["ID=arch\n", "arch"],
["ID=cachyos\nID_LIKE=arch\n", "arch"],
['ID=omarchy\nID_LIKE=arch\nVERSION_ID="4.0.4"\n', "arch"],
] as const)(
"accepts an explicitly supported distribution",
(document, family) => {
expect(parseLinuxDistribution(document)).toMatchObject({
packageFamily: family,
hopperSetupSupported: true,
});
},
);
it("detects unresolved or loader-reported shared libraries", () => {
expect(
linuxSharedLibrariesAvailable(
"libQt6Network.so.6 => not found\nlibc.so.6 => /lib/libc.so.6",
),
).toBe(false);
expect(
linuxSharedLibrariesAvailable(
"error while loading shared libraries: libQt6Gui.so.6",
),
).toBe(false);
expect(
linuxSharedLibrariesAvailable(
"libQt6Network.so.6 => /usr/lib/libQt6Network.so.6",
),
).toBe(true);
});
it.each([
'ID=ubuntu\nVERSION_ID="22.04"\n',
"ID=fedora\nVERSION_ID=40\n",
'ID=nobara\nVERSION_ID="43"\nID_LIKE="rhel centos fedora"\n',
'ID=derivative\nVERSION_ID="44"\nID_LIKE="rhel centos fedora"\n',
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
"ID=manjaro\nID_LIKE=arch\n",
"ID=garuda\nID_LIKE=arch\n",
"ID=omarchy\n",
"ID=omarchy\nID_LIKE=debian\n",
])(
"rejects vendor/version combinations without a tested Hopper installer",
(document) => {
expect(parseLinuxDistribution(document).hopperSetupSupported).toBe(false);
},
);
it.each([
'ID=kali\nVERSION_ID="2026.3"\nID_LIKE=debian\n',
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
'ID=linuxmint\nVERSION_ID="22.1"\nID_LIKE="ubuntu debian"\n',
"ID=manjaro\nID_LIKE=arch\n",
"ID=derivative\nID_LIKE='debian'\n",
"ID='debian'\nVERSION_ID='13'\n",
])(
"hosts REA on a distribution Hopper has no tested package for: %s",
(document) => {
const distribution = parseLinuxDistribution(document);
expect(distribution.hopperSetupSupported).toBe(false);
expect(supportsReaHost(distribution)).toBe(true);
},
);
it("withholds host support from a distribution with no package family", () => {
expect(supportsReaHost(parseLinuxDistribution("ID=nixos\n"))).toBe(false);
expect(supportsReaHost(undefined)).toBe(false);
});
});
describe("Linux Hopper installation", () => {
it.each([
[
"Debian and Arch",
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
],
["RPM", "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13"],
])("accepts the pinned %s launcher build", (_, digest) => {
expect(linuxHopperLauncherDigestSupported(digest)).toBe(true);
});
it("rejects an unverified launcher build", () => {
expect(linuxHopperLauncherDigestSupported("0".repeat(64))).toBe(false);
});
it.each([
["deb", "apt-get", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]],
[
"rpm",
"dnf",
[
"xorg-x11-server-Xvfb",
"xorg-x11-xauth",
"python3",
"libX11",
"libXtst",
],
],
[
"arch",
"pacman",
["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"],
],
] as const)(
"selects the %s native package manager and dependencies",
(family, executable, packages) => {
expect(
linuxPackageManagerCommands(family, "/tmp/Hopper package", true),
).toContainEqual(
expect.objectContaining({
executable,
args: expect.arrayContaining([...packages]),
}),
);
expect(
linuxPackageManagerCommands(family, "/tmp/Hopper package", false),
).toContainEqual(
expect.objectContaining({
executable: "pkexec",
args: expect.arrayContaining([executable, "/tmp/Hopper package"]),
}),
);
},
);
});
describe("Linux Hopper package installation", () => {
it.each(["deb", "rpm", "arch"] as const)(
"downloads, verifies, installs, reads back, and cleans a %s package",
async (family) => {
const host = new RecordingLinuxHost();
host.distributionValue = {
id: family,
packageFamily: family,
hopperSetupSupported: true,
};
const result = await installFixture(host);
expect(result).toEqual({
status: "installed",
launcherPath: "/opt/hopper/bin/Hopper",
});
expect(host.installed).toEqual([
{ family, archive: `/tmp/rea test/hopper.${family}` },
]);
expect(host.cleaned).toEqual(["/tmp/rea test"]);
},
);
});
describe("Linux Hopper installation failure classification", () => {
it.each([
["unsupported host", "unsupported_host"],
["package request failure", "download"],
["checksum mismatch", "integrity"],
["package manager failure", "authorization_or_package_manager"],
["missing launcher", "launcher_missing"],
["missing runtime", "runtime_dependencies"],
["unsupported build", "unsupported_hopper_build"],
] as const)("classifies %s", async (scenario, reason) => {
const host = new RecordingLinuxHost();
if (scenario === "unsupported host")
host.distributionValue = {
id: "debian",
packageFamily: "deb",
hopperSetupSupported: false,
};
if (scenario === "package request failure") host.archiveOk = false;
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
if (scenario === "package manager failure") host.installSucceeds = false;
if (scenario === "missing launcher") host.launcherStatusValue = "missing";
if (scenario === "missing runtime")
host.launcherStatusValue = "runtime_dependencies";
if (scenario === "unsupported build")
host.launcherStatusValue = "unsupported_hopper_build";
expect(await installFixture(host)).toEqual({
status: "failed",
reason,
...(scenario === "package manager failure"
? { packageFailure: host.packageFailure }
: {}),
});
if (host.installed.length > 0)
expect(host.cleaned).toEqual(["/tmp/rea test"]);
});
});
describe("Linux Hopper cleanup and failure details", () => {
it("preserves the failing package-manager command and its output", async () => {
const host = new RecordingLinuxHost();
host.installSucceeds = false;
host.packageFailure = {
command: "pkexec pacman -U --noconfirm /tmp/rea test/hopper.arch",
exitCode: 1,
stdout: "loading packages...",
stderr:
"\u001b[31merror: unresolvable package conflicts detected\u001b[0m",
outputTruncated: false,
};
expect(await installFixture(host)).toEqual({
status: "failed",
reason: "authorization_or_package_manager",
packageFailure: host.packageFailure,
});
expect(host.cleaned).toEqual(["/tmp/rea test"]);
});
it("preserves a successful install result when temporary cleanup fails", async () => {
const host = new RecordingLinuxHost();
host.cleanupFails = true;
expect(await installFixture(host)).toEqual({
status: "installed",
launcherPath: "/opt/hopper/bin/Hopper",
cleanupFailure: {
path: "/tmp/rea test",
reason: "temporary path is busy",
},
});
});
it("preserves an install failure when temporary cleanup also fails", async () => {
const host = new RecordingLinuxHost();
host.installSucceeds = false;
host.cleanupFails = true;
expect(await installFixture(host)).toMatchObject({
status: "failed",
reason: "authorization_or_package_manager",
packageFailure: host.packageFailure,
cleanupFailure: {
path: "/tmp/rea test",
reason: "temporary path is busy",
},
});
});
});
describe("Linux Hopper cancellation", () => {
it("cancels before network or mutation", async () => {
const host = new RecordingLinuxHost();
const controller = new AbortController();
controller.abort();
expect(
await installLinuxHopper(host, { signal: controller.signal }),
).toEqual({
status: "failed",
reason: "cancelled",
});
expect(host.downloads).toEqual([]);
expect(host.installed).toEqual([]);
});
});
describe("Linux Hopper temporary cleanup ownership", () => {
it("retains a failed cleanup for a later successful retry", async () => {
const owner = new LinuxHopperTemporaryCleanupOwner();
owner.retain({ path: "/tmp/rea-hopper", reason: "busy" });
let attempts = 0;
expect(
await owner.retry(async () => {
attempts += 1;
throw new Error("still busy");
}),
).toContain("/tmp/rea-hopper");
expect(
await owner.retry(async () => {
attempts += 1;
}),
).toBeUndefined();
expect(attempts).toBe(2);
});
});
function releasesFor(
bytes: Uint8Array,
): Record<
LinuxPackageFamily,
{ filename: string; file_length: string; file_hash: string }
> {
const release = (extension: string) => ({
filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`,
file_length: String(bytes.byteLength),
file_hash: createHash("sha1").update(bytes).digest("hex"),
});
return {
deb: release("deb"),
rpm: release("rpm"),
arch: release("pkg.tar.xz"),
};
}
const installFixture = (host: RecordingLinuxHost) =>
installLinuxHopper(host, {
releases: releasesFor(new Uint8Array([1, 2, 3])),
});