Implements and adds tests for installer

This commit is contained in:
thatben 2025-04-08 15:13:24 +02:00
parent 10e1a33cc4
commit 574d4febe2
No known key found for this signature in database
GPG Key ID: 62C543548433D43E
11 changed files with 566 additions and 34 deletions

View File

@ -18,6 +18,18 @@ export const mockFsService = {
getAvailableRoots: vi.fn(),
pathJoin: vi.fn(),
isDir: vi.fn(),
isFile: vi.fn(),
readDir: vi.fn(),
makeDir: vi.fn(),
};
export const mockShellService = {
run: vi.fn(),
};
export const mockOsService = {
isWindows: vi.fn(),
isDarwin: vi.fn(),
isLinux: vi.fn(),
getWorkingDir: vi.fn(),
};

View File

@ -135,14 +135,6 @@ async function performInstall(config) {
try {
if (platform === "win32") {
try {
try {
await runCommand("curl --version");
} catch (error) {
throw new Error(
"curl is not available. Please install curl or update your Windows version.",
);
}
await runCommand(
"curl -LO --ssl-no-revoke https://get.codex.storage/install.cmd",
);

View File

@ -1,9 +1,130 @@
export class Installer {
constructor(configService) {
constructor(configService, shellService, osService, fsService) {
this.config = configService.get();
this.configService = configService;
this.shell = shellService;
this.os = osService;
this.fs = fsService;
}
isCodexInstalled = async () => {
return false;
}
}
try {
await this.getCodexVersion();
return true;
} catch (error) {
return false;
}
};
getCodexVersion = async () => {
if (this.config.codexExe.length < 1)
throw new Error("Codex not installed.");
const version = await this.shell.run(`"${this.config.codexExe}" --version`);
if (version.length < 1) throw new Error("Version info not found.");
return version;
};
installCodex = async (processCallbacks) => {
if (!(await this.arePrerequisitesCorrect(processCallbacks))) return;
processCallbacks.installStarts();
if (this.os.isWindows()) {
await this.installCodexWindows(processCallbacks);
} else {
await this.installCodexUnix(processCallbacks);
}
if (!(await this.isCodexInstalled()))
throw new Error("Codex installation failed.");
processCallbacks.installSuccessful();
};
arePrerequisitesCorrect = async (processCallbacks) => {
if (await this.isCodexInstalled()) {
processCallbacks.warn("Codex is already installed.");
return false;
}
if (this.config.codexInstallPath.length < 1) {
processCallbacks.warn("Install path not set.");
return false;
}
if (!(await this.isCurlAvailable())) {
processCallbacks.warn("Curl is not available.");
return false;
}
return true;
};
isCurlAvailable = async () => {
const curlVersion = await this.shell.run("curl --version");
return curlVersion.length > 0;
};
installCodexWindows = async (processCallbacks) => {
await this.shell.run(
"curl -LO --ssl-no-revoke https://get.codex.storage/install.cmd",
);
processCallbacks.downloadSuccessful();
await this.shell.run(
`set "INSTALL_DIR=${this.config.codexInstallPath}" && ` +
`"${this.os.getWorkingDir()}\\install.cmd"`,
);
await this.saveCodexInstallPath("codex.exe");
await this.shell.run("del /f install.cmd");
};
installCodexUnix = async (processCallbacks) => {
await this.ensureUnixDependencies();
await this.shell.run(
"curl -# --connect-timeout 10 --max-time 60 -L https://get.codex.storage/install.sh -o install.sh && chmod +x install.sh",
);
processCallbacks.downloadSuccessful();
if (this.os.isDarwin()) {
await this.runInstallerDarwin();
} else {
await this.runInstallerLinux();
}
await this.saveCodexInstallPath("codex");
await this.shell.run("rm -f install.sh");
};
runInstallerDarwin = async () => {
const timeoutCommand = `perl -e '
eval {
local $SIG{ALRM} = sub { die "timeout\\n" };
alarm(120);
system("INSTALL_DIR=\\"${this.config.codexInstallPath}\\" bash install.sh");
alarm(0);
};
die if $@;
'`;
await this.shell.run(timeoutCommand);
};
runInstallerLinux = async () => {
await this.shell.run(
`INSTALL_DIR="${this.config.codexInstallPath}" timeout 120 bash install.sh`,
);
};
ensureUnixDependencies = async (processCallbacks) => {
const libgompCheck = await this.shell.run("ldconfig -p | grep libgomp");
if (libgompCheck.length < 1) {
processCallbacks.warn("libgomp not found.");
return false;
}
return true;
};
saveCodexInstallPath = async (codexExe) => {
this.config.codexExe = this.fs.pathJoin([
this.config.codexInstallPath,
codexExe,
]);
if (!this.fs.isFile(this.config.codexExe))
throw new Error("Codex executable not found.");
await this.configService.saveConfig();
};
}

View File

@ -0,0 +1,372 @@
import { describe, beforeEach, it, expect, vi } from "vitest";
import {
mockShellService,
mockOsService,
mockFsService,
} from "../__mocks__/service.mocks.js";
import { mockConfigService } from "../__mocks__/service.mocks.js";
import { Installer } from "./installer.js";
describe("Installer", () => {
const config = {
codexInstallPath: "/install-codex",
};
const workingDir = "/working-dir";
const processCallbacks = {
installStarts: vi.fn(),
downloadSuccessful: vi.fn(),
installSuccessful: vi.fn(),
warn: vi.fn(),
};
let installer;
beforeEach(() => {
vi.resetAllMocks();
mockConfigService.get.mockReturnValue(config);
mockOsService.getWorkingDir.mockReturnValue(workingDir);
installer = new Installer(
mockConfigService,
mockShellService,
mockOsService,
mockFsService,
);
});
describe("getCodexVersion", () => {
it("throws when codex exe is not set", async () => {
config.codexExe = "";
await expect(installer.getCodexVersion()).rejects.toThrow(
"Codex not installed.",
);
});
it("throws when version info is not found", async () => {
config.codexExe = "codex.exe";
mockShellService.run.mockResolvedValueOnce("");
await expect(installer.getCodexVersion()).rejects.toThrow(
"Version info not found.",
);
});
it("returns version info", async () => {
const versionInfo = "versionInfo";
config.codexExe = "codex.exe";
mockShellService.run.mockResolvedValueOnce(versionInfo);
const version = await installer.getCodexVersion();
expect(version).toBe(versionInfo);
});
});
describe("isCodexInstalled", () => {
it("return true when getCodexVersion succeeds", async () => {
installer.getCodexVersion = vi.fn();
expect(await installer.isCodexInstalled()).toBe(true);
});
it("returns false when getCodexVersion fails", async () => {
installer.getCodexVersion = vi.fn(() => {
throw new Error("Codex not installed.");
});
expect(await installer.isCodexInstalled()).toBe(false);
});
});
describe("installCodex", () => {
beforeEach(() => {
installer.arePrerequisitesCorrect = vi.fn();
installer.installCodexWindows = vi.fn();
installer.installCodexUnix = vi.fn();
installer.isCodexInstalled = vi.fn();
});
it("returns early when prerequisites are not correct", async () => {
installer.arePrerequisitesCorrect.mockResolvedValue(false);
await installer.installCodex(processCallbacks);
expect(processCallbacks.installStarts).not.toHaveBeenCalled();
expect(processCallbacks.installSuccessful).not.toHaveBeenCalled();
expect(processCallbacks.downloadSuccessful).not.toHaveBeenCalled();
expect(installer.isCodexInstalled).not.toHaveBeenCalled();
expect(installer.installCodexWindows).not.toHaveBeenCalled();
expect(installer.installCodexUnix).not.toHaveBeenCalled();
});
describe("prerequisites OK", () => {
beforeEach(() => {
installer.arePrerequisitesCorrect.mockResolvedValue(true);
installer.isCodexInstalled.mockResolvedValue(true);
});
it("calls installStarts when prerequisites are correct", async () => {
await installer.installCodex(processCallbacks);
expect(processCallbacks.installStarts).toHaveBeenCalled();
});
it("calls installCodexWindows when OS is Windows", async () => {
mockOsService.isWindows.mockReturnValue(true);
await installer.installCodex(processCallbacks);
expect(installer.installCodexWindows).toHaveBeenCalledWith(
processCallbacks,
);
});
it("calls installCodexUnix when OS is not Windows", async () => {
mockOsService.isWindows.mockReturnValue(false);
await installer.installCodex(processCallbacks);
expect(installer.installCodexUnix).toHaveBeenCalledWith(
processCallbacks,
);
});
it("throws when codex is not installed after installation", async () => {
installer.isCodexInstalled.mockResolvedValue(false);
await expect(installer.installCodex(processCallbacks)).rejects.toThrow(
"Codex installation failed.",
);
});
it("calls installSuccessful when installation is successful", async () => {
await installer.installCodex(processCallbacks);
expect(processCallbacks.installSuccessful).toHaveBeenCalled();
});
});
});
describe("arePrerequisitesCorrect", () => {
beforeEach(() => {
installer.isCodexInstalled = vi.fn();
installer.isCurlAvailable = vi.fn();
config.codexInstallPath = "/install-codex";
});
it("returns false when codex is already installed", async () => {
installer.isCodexInstalled.mockResolvedValue(true);
expect(await installer.arePrerequisitesCorrect(processCallbacks)).toBe(
false,
);
expect(processCallbacks.warn).toHaveBeenCalledWith(
"Codex is already installed.",
);
});
it("returns false when install path is not set", async () => {
config.codexInstallPath = "";
expect(await installer.arePrerequisitesCorrect(processCallbacks)).toBe(
false,
);
expect(processCallbacks.warn).toHaveBeenCalledWith(
"Install path not set.",
);
});
it("returns false when curl is not available", async () => {
installer.isCodexInstalled.mockResolvedValue(false);
installer.isCurlAvailable.mockResolvedValue(false);
expect(await installer.arePrerequisitesCorrect(processCallbacks)).toBe(
false,
);
expect(processCallbacks.warn).toHaveBeenCalledWith(
"Curl is not available.",
);
});
it("returns true when all prerequisites are correct", async () => {
installer.isCodexInstalled.mockResolvedValue(false);
installer.isCurlAvailable.mockResolvedValue(true);
const result = await installer.arePrerequisitesCorrect(processCallbacks);
expect(result).toBe(true);
});
});
describe("isCurlAvailable", () => {
it("returns true when curl version is found", async () => {
mockShellService.run.mockResolvedValueOnce("curl version");
const result = await installer.isCurlAvailable();
expect(mockShellService.run).toHaveBeenCalledWith("curl --version");
expect(result).toBe(true);
});
it("returns false when curl version is not found", async () => {
mockShellService.run.mockResolvedValueOnce("");
const result = await installer.isCurlAvailable();
expect(mockShellService.run).toHaveBeenCalledWith("curl --version");
expect(result).toBe(false);
});
});
describe("install functions", () => {
beforeEach(() => {
installer.saveCodexInstallPath = vi.fn();
});
describe("installCodexWindows", () => {
it("runs the curl command to download the installer", async () => {
await installer.installCodexWindows(processCallbacks);
expect(mockShellService.run).toHaveBeenCalledWith(
"curl -LO --ssl-no-revoke https://get.codex.storage/install.cmd",
);
});
it("calls downloadSuccessful", async () => {
await installer.installCodexWindows(processCallbacks);
expect(processCallbacks.downloadSuccessful).toHaveBeenCalled();
});
it("runs installer script", async () => {
await installer.installCodexWindows(processCallbacks);
expect(mockShellService.run).toHaveBeenCalledWith(
`set "INSTALL_DIR=${config.codexInstallPath}" && "${workingDir}\\install.cmd"`,
);
});
it("saves the codex install path", async () => {
await installer.installCodexWindows(processCallbacks);
expect(installer.saveCodexInstallPath).toHaveBeenCalledWith(
"codex.exe",
);
});
it("deletes the installer script", async () => {
await installer.installCodexWindows(processCallbacks);
expect(mockShellService.run).toHaveBeenCalledWith("del /f install.cmd");
});
});
describe("installCodexUnix", () => {
beforeEach(() => {
installer.ensureUnixDependencies = vi.fn();
installer.runInstallerDarwin = vi.fn();
installer.runInstallerLinux = vi.fn();
});
it("ensures Unix dependencies", async () => {
await installer.installCodexUnix(processCallbacks);
expect(installer.ensureUnixDependencies).toHaveBeenCalled();
});
it("runs the curl command to download the installer", async () => {
await installer.installCodexUnix(processCallbacks);
expect(mockShellService.run).toHaveBeenCalledWith(
"curl -# --connect-timeout 10 --max-time 60 -L https://get.codex.storage/install.sh -o install.sh && chmod +x install.sh",
);
});
it("calls downloadSuccessful", async () => {
await installer.installCodexUnix(processCallbacks);
expect(processCallbacks.downloadSuccessful).toHaveBeenCalled();
});
it("runs installer for darwin ", async () => {
mockOsService.isDarwin.mockReturnValue(true);
await installer.installCodexUnix(processCallbacks);
expect(installer.runInstallerDarwin).toHaveBeenCalled();
});
it("runs installer for linux", async () => {
mockOsService.isDarwin.mockReturnValue(false);
await installer.installCodexUnix(processCallbacks);
expect(installer.runInstallerLinux).toHaveBeenCalled();
});
it("saves the codex install path", async () => {
await installer.installCodexUnix(processCallbacks);
expect(installer.saveCodexInstallPath).toHaveBeenCalledWith("codex");
});
it("deletes the installer script", async () => {
await installer.installCodexUnix(processCallbacks);
expect(mockShellService.run).toHaveBeenCalledWith("rm -f install.sh");
});
});
describe("runInstallerDarwin", () => {
it("runs the installer script for darwin with custom timeout command", async () => {
const timeoutCommand = `perl -e '
eval {
local $SIG{ALRM} = sub { die "timeout\\n" };
alarm(120);
system("INSTALL_DIR=\\"${config.codexInstallPath}\\" bash install.sh");
alarm(0);
};
die if $@;
'`;
await installer.runInstallerDarwin();
expect(mockShellService.run).toHaveBeenCalledWith(timeoutCommand);
});
});
describe("runInstallerLinux", () => {
it("runs the installer script using unix timeout command", async () => {
await installer.runInstallerLinux();
expect(mockShellService.run).toHaveBeenCalledWith(
`INSTALL_DIR="${config.codexInstallPath}" timeout 120 bash install.sh`,
);
});
});
});
describe("ensureUnixDependencies", () => {
it("returns true when libgomp is installed", async () => {
mockShellService.run.mockResolvedValueOnce("yes");
expect(await installer.ensureUnixDependencies(processCallbacks)).toBe(
true,
);
expect(mockShellService.run).toHaveBeenCalledWith(
"ldconfig -p | grep libgomp",
);
});
it("returns false when libgomp is not found", async () => {
mockShellService.run.mockResolvedValue("");
expect(await installer.ensureUnixDependencies(processCallbacks)).toBe(
false,
);
expect(mockShellService.run).toHaveBeenCalledWith(
"ldconfig -p | grep libgomp",
);
});
it("it calls warn in processCallbacks when libgomp is not found", async () => {
mockShellService.run.mockResolvedValue("");
await installer.ensureUnixDependencies(processCallbacks);
expect(processCallbacks.warn).toHaveBeenCalledWith("libgomp not found.");
});
});
describe("saveCodexInstallPath", () => {
const codexExe = "_codex_.exe";
const pathJointResult = "/path-to-codex/_codex_.exe";
beforeEach(() => {
mockFsService.pathJoin.mockReturnValue(pathJointResult);
});
it("combines the install path with the exe", async () => {
mockFsService.isFile.mockReturnValue(true);
await installer.saveCodexInstallPath(codexExe);
expect(mockFsService.pathJoin).toHaveBeenCalledWith([
config.codexInstallPath,
codexExe,
]);
});
it("sets the codex exe path", async () => {
mockFsService.isFile.mockReturnValue(true);
await installer.saveCodexInstallPath(codexExe);
expect(config.codexExe).toBe(pathJointResult);
});
it("throws when file does not exist", async () => {
mockFsService.isFile.mockReturnValue(false);
await expect(installer.saveCodexInstallPath(codexExe)).rejects.toThrow(
"Codex executable not found.",
);
});
it("saves the config", async () => {
mockFsService.isFile.mockReturnValue(true);
await installer.saveCodexInstallPath(codexExe);
expect(mockConfigService.saveConfig).toHaveBeenCalled();
});
});
});

View File

@ -112,7 +112,7 @@ export async function main() {
new MenuLoop(),
installMenu,
configMenu,
new DataDirMover(fsService, uiService)
new DataDirMover(fsService, uiService),
);
await mainMenu.show();

View File

@ -31,6 +31,14 @@ export class FsService {
}
};
isFile = (path) => {
try {
return fs.lstatSync(path).isFile();
} catch {
return false;
}
};
readDir = (dir) => {
return fs.readdirSync(dir);
};

23
src/services/osService.js Normal file
View File

@ -0,0 +1,23 @@
import os from "os";
export class OsService {
constructor() {
this.platform = os.platform();
}
isWindows = () => {
return this.platform === "win32";
};
isDarwin = () => {
return this.platform === "darwin";
};
isLinux = () => {
return this.platform === "linux";
};
getWorkingDir = () => {
return process.cwd();
};
}

View File

@ -0,0 +1,18 @@
import { exec } from "child_process";
import { promisify } from "util";
export class ShellService {
constructor() {
this.execAsync = promisify(exec);
}
async run(command) {
try {
const { stdout, stderr } = await this.execAsync(command);
return stdout;
} catch (error) {
console.error("Error:", error.message);
throw error;
}
}
}

View File

@ -9,7 +9,7 @@ export class InstallMenu {
show = async () => {
await this.ui.askMultipleChoice("Configure your Codex installation", [
{
label: "Install path: " + this.config.codexPath,
label: "Install path: " + this.config.codexInstallPath,
action: this.selectInstallPath,
},
{
@ -28,8 +28,8 @@ export class InstallMenu {
};
selectInstallPath = async () => {
this.config.codexPath = await this.pathSelector.show(
this.config.codexPath,
this.config.codexInstallPath = await this.pathSelector.show(
this.config.codexInstallPath,
false,
);
this.configService.saveConfig();

View File

@ -6,7 +6,7 @@ import { mockPathSelector } from "../__mocks__/utils.mocks.js";
describe("InstallMenu", () => {
const config = {
codexPath: "/codex",
codexInstallPath: "/codex",
};
let installMenu;
@ -27,7 +27,7 @@ describe("InstallMenu", () => {
"Configure your Codex installation",
[
{
label: "Install path: " + config.codexPath,
label: "Install path: " + config.codexInstallPath,
action: installMenu.selectInstallPath,
},
{
@ -47,14 +47,14 @@ describe("InstallMenu", () => {
});
it("allows selecting the install path", async () => {
const originalPath = config.codexPath;
const originalPath = config.codexInstallPath;
const newPath = "/new/path";
mockPathSelector.show.mockResolvedValue(newPath);
await installMenu.selectInstallPath();
expect(mockPathSelector.show).toHaveBeenCalledWith(originalPath, false);
expect(config.codexPath).toBe(newPath);
expect(config.codexInstallPath).toBe(newPath);
expect(mockConfigService.saveConfig).toHaveBeenCalled();
});

View File

@ -1,14 +0,0 @@
import { exec } from "child_process";
import { promisify } from "util";
export const execAsync = promisify(exec);
export async function runCommand(command) {
try {
const { stdout, stderr } = await execAsync(command);
return stdout;
} catch (error) {
console.error("Error:", error.message);
throw error;
}
}