fix(doctor): retain host uncertainty and resolved Java selection

This commit is contained in:
morluto committed 2026-10-26 02:33:31 +08:00
1 parent 82fe820060
commit 37a7c2d652
5 files changed
+105 -43

No files matched your search

+44 -16
View File
@@ -495,6 +495,37 @@ describe("doctor runtime check", () => {
);
});
it.each([undefined, "", "not-a-version", "14garbage"])(
"retains unknown host support for macOS probe value %s",
async (version) => {
const result = await runDoctor(
undefined,
host({ macosVersion: () => Promise.resolve(version) }),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "host",
ok: false,
classification: "unknown",
}),
);
},
);
it.skipIf(process.platform !== "darwin")(
"reads the macOS version without PATH utilities",
async () => {
const system = systemDoctorHost({
environment: { PATH: "/nonexistent" },
});
try {
expect(await system.macosVersion()).toMatch(/^\d+(?:\.\d+)*$/u);
} finally {
await system.close?.();
}
},
);
describe("doctor", () => {
it("returns exact recovery for every failed diagnostic", async () => {
const result = await runDoctor(
@@ -518,22 +549,19 @@ describe("doctor", () => {
});
});
it.each(["11.7", undefined])(
"rejects unsupported macOS version %s",
async (version) => {
const result = await runDoctor(
undefined,
host({ macosVersion: () => Promise.resolve(version) }),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "host",
ok: false,
classification: "unsupported_host",
}),
);
},
);
it.each(["11.7"])("rejects unsupported macOS version %s", async (version) => {
const result = await runDoctor(
undefined,
host({ macosVersion: () => Promise.resolve(version) }),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "host",
ok: false,
classification: "unsupported_host",
}),
);
});
it("admits only the experimental Windows x64 host boundary", async () => {
const windows = await runDoctor(
+3 -1
View File
@@ -764,7 +764,9 @@ const readMacosVersion = async (
options: { readonly env: NodeJS.ProcessEnv },
): Promise<string | undefined> => {
try {
return (await run("sw_vers", ["-productVersion"], options)).stdout.trim();
return (
await run("/usr/bin/sw_vers", ["-productVersion"], options)
).stdout.trim();
} catch (cause: unknown) {
// best-effort cleanup: optional OS version probing; failure means unknown.
void cause;
+18 -4
View File
@@ -129,8 +129,25 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
host.platform === "darwin" ? await host.macosVersion() : undefined;
const linuxDistribution =
host.platform === "linux" ? await host.linuxDistribution() : undefined;
const macosMajor =
macosVersion !== undefined && /^\d+(?:\.\d+)*$/u.test(macosVersion)
? Number(macosVersion.split(".")[0])
: undefined;
if (host.platform === "darwin" && macosMajor === undefined)
return {
name: "host",
ok: false,
classification: "unknown",
detail:
macosVersion === undefined
? "The macOS version could not be determined."
: `The macOS version probe returned an unrecognized value: ${JSON.stringify(macosVersion)}.`,
remediation: "Check /usr/bin/sw_vers -productVersion and rerun doctor.",
};
const supported =
(host.platform === "darwin" && parseMajor(macosVersion) >= 12) ||
(host.platform === "darwin" &&
macosMajor !== undefined &&
macosMajor >= 12) ||
(host.platform === "linux" && supportsReaHost(linuxDistribution)) ||
(host.platform === "win32" && host.architecture === "x64");
const detail =
@@ -359,8 +376,5 @@ const unsupportedHopperRemediation = (configured: boolean): string =>
? "Check HOPPER_LAUNCHER_PATH and its publisher verification evidence. Run rea setup to install and retain an authenticated official Hopper package for offline use."
: "Run rea setup to install and retain an authenticated official Hopper package for offline use.";
const parseMajor = (version: string | undefined): number =>
Number.parseInt(version?.split(".")[0] ?? "0", 10);
const providerCheckName = (providerId: string, name: string): boolean =>
name === providerId || name.startsWith(`${providerId}-`);
+11 -7
View File
@@ -1714,7 +1714,7 @@ it("explains the macOS keg-only JDK case without probing an unselected installat
},
}),
);
expect(probed).toEqual(["java"]);
expect(probed).toEqual(["/usr/bin/java"]);
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
status: "failed",
code: "runtime_missing",
@@ -1813,8 +1813,8 @@ it.each([
scenario === "explicit home"
? ["/selected-jdk/bin/java"]
: scenario === "PATH succeeds"
? ["java"]
: ["java", keg],
? ["/usr/bin/java"]
: ["/usr/bin/java", keg],
);
expect(inspection.status).toBe(
scenario === "PATH succeeds" || scenario === "keg succeeds"
@@ -1825,19 +1825,21 @@ it.each([
expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({
java_discovery_failures: expect.arrayContaining([
{
command: "java",
command: "/usr/bin/java",
code: "runtime_missing",
detail: expect.any(String),
},
]),
});
}
if (scenario === "both fail") expect(inspection.javaCommand).toBe("java");
if (scenario === "both fail")
expect(inspection.javaCommand).toBe("/usr/bin/java");
},
);
it.each([
["darwin", undefined, "/selected-bin/java", "java"],
["darwin", undefined, "/selected-bin/java", "/selected-bin/java"],
["darwin", undefined, "/linked/../jdk/java", "/linked/../jdk/java"],
["darwin", "/selected-jdk", undefined, "/selected-jdk/bin/java"],
["linux", undefined, undefined, "java"],
["win32", undefined, undefined, "java.exe"],
@@ -1849,7 +1851,9 @@ it.each([
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: { PATH: "/selected-bin" },
environment: {
PATH: `:relative-bin:${pathJava === undefined ? "/selected-bin" : pathJava.slice(0, -"/java".length)}`,
},
installDir: INSTALL,
platform,
architecture: "arm64",
+29 -15
View File
@@ -11,6 +11,7 @@ import { ok, type Result } from "../domain/result.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { readGhidraExecutableHeader } from "./GhidraExecutableHeader.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import {
readGhidraInstallationProperties,
type GhidraInstallationFileOptions,
@@ -524,24 +525,37 @@ const installationCoordinates = async (
),
)
.filter((candidate) => host.executable(candidate));
let javaCommand = (() => {
const selectedJava: {
command: string;
source: "configured" | "path" | "homebrew" | "default";
} = (() => {
if (options.javaHome !== undefined)
return path.join(
options.javaHome,
"bin",
platform === "win32" ? "java.exe" : "java",
);
return {
source: "configured",
command: path.join(
options.javaHome,
"bin",
platform === "win32" ? "java.exe" : "java",
),
};
if (platform !== "darwin")
return platform === "win32" ? "java.exe" : "java";
return {
source: "default",
command: platform === "win32" ? "java.exe" : "java",
};
// Check the caller's PATH, never a file named java in the working directory.
const javaOnPath = options.environment.PATH?.split(":").some(
(directory) =>
path.isAbsolute(directory) &&
host.executable(path.join(directory, "java")),
);
if (javaOnPath) return "java";
return homebrewJavaCandidates()[0] ?? "java";
const javaOnPath = options.environment.PATH?.split(":")
.filter((directory) => path.isAbsolute(directory))
.map((directory) => absoluteCallerPath(`${directory}/java`, { platform }))
.find((candidate) => host.executable(candidate));
if (javaOnPath !== undefined)
return { source: "path", command: javaOnPath };
const homebrew = homebrewJavaCandidates()[0];
return homebrew === undefined
? { source: "default", command: "/usr/bin/java" }
: { source: "homebrew", command: homebrew };
})();
let javaCommand = selectedJava.command;
// Java is a prerequisite of a selected installation. Without one, or with a
// relative JAVA_HOME, configuration validation refuses Ghidra before Java can
// matter, so callers such as setup and doctor must not execute a host JVM.
@@ -574,7 +588,7 @@ const installationCoordinates = async (
if (
platform === "darwin" &&
options.javaHome === undefined &&
javaCommand === "java" &&
(selectedJava.source === "path" || selectedJava.source === "default") &&
javaProbe !== undefined &&
!javaProbe.ok &&
!(javaProbe.error instanceof AnalysisError) &&