fix(doctor): require an executable Hopper launcher

This commit is contained in:
morluto 2026-07-12 08:55:36 +08:00
parent a67054f815
commit aee9cb8e4e
2 changed files with 28 additions and 4 deletions

View File

@ -26,6 +26,7 @@ export interface DoctorHost {
readonly configuredHopperPath?: string;
macosVersion(): Promise<string | undefined>;
readable(path: string): Promise<boolean>;
executable(path: string): Promise<boolean>;
brewHopperPath(): Promise<string | undefined>;
manualHopperPaths(): Promise<readonly string[]>;
}
@ -72,7 +73,7 @@ export const runDoctor = async (
].filter((value): value is string => value !== undefined);
let hopperPath: string | undefined;
for (const candidate of new Set(candidates))
if (await host.readable(candidate)) {
if (await host.executable(candidate)) {
hopperPath = candidate;
break;
}
@ -124,6 +125,14 @@ export const systemDoctorHost = (): DoctorHost => ({
return false;
}
},
async executable(path) {
try {
await access(path, constants.X_OK);
return true;
} catch {
return false;
}
},
async brewHopperPath() {
return probeHomebrew(async (command) => {
try {

View File

@ -6,6 +6,7 @@ const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
nodeVersion: "22.1.0",
macosVersion: () => Promise.resolve("12.0"),
readable: (path) => Promise.resolve(path.includes("Hopper")),
executable: (path) => Promise.resolve(path.includes("Hopper")),
brewHopperPath: () => Promise.resolve(undefined),
manualHopperPaths: () => Promise.resolve([]),
...overrides,
@ -29,7 +30,7 @@ describe("doctor", () => {
undefined,
host({
configuredHopperPath: "/manual/Hopper",
readable: (path) => Promise.resolve(path === "/manual/Hopper"),
executable: (path) => Promise.resolve(path === "/manual/Hopper"),
}),
);
expect(result.hopperPath).toBe("/manual/Hopper");
@ -41,7 +42,7 @@ describe("doctor", () => {
const result = await runDoctor(
undefined,
host({
readable: (candidate) => Promise.resolve(candidate === path),
executable: (candidate) => Promise.resolve(candidate === path),
brewHopperPath: () => Promise.resolve(path),
}),
);
@ -53,10 +54,24 @@ describe("doctor", () => {
const result = await runDoctor(
undefined,
host({
readable: (candidate) => Promise.resolve(candidate === path),
executable: (candidate) => Promise.resolve(candidate === path),
manualHopperPaths: () => Promise.resolve([path]),
}),
);
expect(result.hopperPath).toBe(path);
});
it("rejects a readable Hopper launcher without execute permission", async () => {
const path = "/manual/Hopper";
const result = await runDoctor(
undefined,
host({
configuredHopperPath: path,
readable: () => Promise.resolve(true),
executable: () => Promise.resolve(false),
}),
);
expect(result.hopperPath).toBeUndefined();
expect(result.checks.find(({ name }) => name === "hopper")?.ok).toBe(false);
});
});