171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
runSetup,
|
|
type ClientConfigurationResult,
|
|
type SetupClient,
|
|
type SetupHost,
|
|
type SetupOptions,
|
|
} from "../src/application/Setup.js";
|
|
import type { DoctorCheck } from "../src/application/Doctor.js";
|
|
import type { LinuxDistribution } from "../src/application/LinuxHopper.js";
|
|
|
|
class FakeSetupHost implements SetupHost {
|
|
readonly platform: NodeJS.Platform;
|
|
readonly nodeVersion = "25.1.0";
|
|
version: string | undefined = "14.5";
|
|
distribution: LinuxDistribution | undefined;
|
|
hopper: string | undefined;
|
|
hopperInstallSucceeds = true;
|
|
skill: "installed" | "unchanged" | "failed" = "installed";
|
|
clients: readonly SetupClient[] = [];
|
|
clientResults = new Map<string, ClientConfigurationResult>();
|
|
hopperInstalls = 0;
|
|
configurations = 0;
|
|
|
|
constructor(platform: NodeJS.Platform = "darwin") {
|
|
this.platform = platform;
|
|
}
|
|
|
|
macosVersion = (): Promise<string | undefined> =>
|
|
Promise.resolve(this.version);
|
|
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
|
|
Promise.resolve(this.distribution);
|
|
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
|
|
installHopper = (): Promise<string | undefined> => {
|
|
this.hopperInstalls += 1;
|
|
if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper";
|
|
return Promise.resolve(this.hopper);
|
|
};
|
|
detectedClients = (): Promise<readonly SetupClient[]> =>
|
|
Promise.resolve(this.clients);
|
|
configureClient = (
|
|
client: SetupClient,
|
|
): Promise<ClientConfigurationResult> => {
|
|
this.configurations += 1;
|
|
return Promise.resolve(
|
|
this.clientResults.get(client.name) ?? { status: "configured" },
|
|
);
|
|
};
|
|
installSkill = (): Promise<"installed" | "unchanged" | "failed"> =>
|
|
Promise.resolve(this.skill);
|
|
doctor = (): Promise<{
|
|
healthy: boolean;
|
|
hopperPath?: string;
|
|
checks: readonly DoctorCheck[];
|
|
}> =>
|
|
Promise.resolve({
|
|
healthy: this.hopper !== undefined,
|
|
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
|
|
checks: [],
|
|
});
|
|
}
|
|
|
|
const options = (approved: boolean, installHopper = false): SetupOptions => ({
|
|
approved,
|
|
installHopper,
|
|
structured: true,
|
|
});
|
|
|
|
describe("setup workflow", () => {
|
|
it("returns a complete plan without mutation", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
|
const result = await runSetup(options(false), host);
|
|
expect(result.status).toBe("needs_confirmation");
|
|
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
|
|
"install_hopper",
|
|
"configure_client",
|
|
"install_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configurations).toBe(0);
|
|
});
|
|
|
|
it("applies an accepted interactive plan including Hopper", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(true),
|
|
);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.appliedActions).toEqual([
|
|
"installed_hopper",
|
|
"installed_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(1);
|
|
});
|
|
|
|
it("declines an interactive plan without mutation", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(false),
|
|
);
|
|
expect(result.status).toBe("planned");
|
|
expect(result.appliedActions).toEqual([]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
});
|
|
|
|
it("requires the Hopper flag for unattended setup", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(options(true), host);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(result.appliedActions).toEqual(["installed_skill"]);
|
|
expect(result.remediation).toContain("--install-hopper");
|
|
});
|
|
|
|
it("installs Hopper when unattended authorization is explicit", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(options(true, true), host);
|
|
expect(result.appliedActions).toEqual([
|
|
"installed_hopper",
|
|
"installed_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(1);
|
|
});
|
|
|
|
it("reuses existing Hopper and configures detected clients", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{ name: "claude", configPath: "/claude.json" },
|
|
{ name: "cursor", configPath: "/cursor.json" },
|
|
];
|
|
const result = await runSetup(options(true), host);
|
|
expect(result.status).toBe("ready");
|
|
expect(result.appliedActions).toEqual([
|
|
"configured_claude",
|
|
"configured_cursor",
|
|
"installed_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
});
|
|
|
|
it("reports a failed Hopper installation without configuring clients", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopperInstallSucceeds = false;
|
|
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
|
const result = await runSetup(options(true, true), host);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toContain("failed");
|
|
expect(host.configurations).toBe(0);
|
|
});
|
|
|
|
it("rejects unsupported hosts before mutation", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "debian",
|
|
versionId: "13",
|
|
packageFamily: "deb",
|
|
supported: false,
|
|
};
|
|
const result = await runSetup(options(true, true), host);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
});
|
|
});
|