Files
2026-07-13 13:36:53 +08:00

372 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { join } from "path";
import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";
// installer.ts transitively imports modules that pull in `electron` at value
// scope (askpass.ts, sudoCreds.ts). Loading the real package in a plain
// Node/vitest environment fails ("Electron failed to install correctly"),
// which breaks every test that dynamically imports the installer module.
// Provide a minimal stub so the import resolves.
vi.mock("electron", () => ({
BrowserWindow: class {
static getAllWindows(): unknown[] {
return [];
}
},
ipcMain: {
on: (): void => {},
handle: (): void => {},
removeHandler: (): void => {},
removeAllListeners: (): void => {},
},
}));
// We test the extracted pure functions by importing them.
// Some functions depend on HERMES_HOME — we mock the module-level constants.
const TEST_DIR = join(tmpdir(), `hermes-test-${Date.now()}`);
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true });
});
// ─── readLogs (test the logic, not the import) ─────────
describe("readLogs logic", () => {
it("returns last N lines from a log file", () => {
const logDir = join(TEST_DIR, "logs");
mkdirSync(logDir, { recursive: true });
const lines = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`);
writeFileSync(join(logDir, "agent.log"), lines.join("\n"));
const content = readFileSync(join(logDir, "agent.log"), "utf-8");
const allLines = content.split("\n");
const tail = allLines.slice(-10).join("\n");
expect(tail).toContain("line 50");
expect(tail).toContain("line 41");
expect(tail).not.toContain("line 30");
});
it("sanitizes log file names", () => {
const allowed = ["agent.log", "errors.log", "gateway.log"];
// Simulating the sanitization logic from readLogs
const sanitize = (f: string): string =>
allowed.includes(f) ? f : "agent.log";
expect(sanitize("agent.log")).toBe("agent.log");
expect(sanitize("errors.log")).toBe("errors.log");
expect(sanitize("gateway.log")).toBe("gateway.log");
expect(sanitize("../../etc/passwd")).toBe("agent.log");
expect(sanitize("malicious.log")).toBe("agent.log");
expect(sanitize("")).toBe("agent.log");
});
});
// ─── MCP server YAML parsing ───────────────────────────
describe("MCP server YAML parsing", () => {
// Simulate the regex-based parsing from listMcpServers
function parseMcpBlock(
content: string,
): Array<{ name: string; type: string; enabled: boolean }> {
const match = content.match(/^mcp_servers:\s*\n((?:[ \t]+.+\n)*)/m);
if (!match) return [];
const block = match[1];
const servers: Array<{ name: string; type: string; enabled: boolean }> = [];
const nameRe = /^[ ]{2}(\w[\w-]*):\s*$/gm;
let m: RegExpExecArray | null;
while ((m = nameRe.exec(block)) !== null) {
const name = m[1];
const start = m.index + m[0].length;
// Find next line at exactly 2-space indent (next server name)
const nextMatch = /\n {2}\w/g;
nextMatch.lastIndex = start;
const next = nextMatch.exec(block);
const serverBlock = block.slice(start, next ? next.index : undefined);
const hasUrl = /url:/.test(serverBlock);
const enabledMatch = serverBlock.match(/enabled:\s*(true|false)/i);
const enabled =
enabledMatch === null || enabledMatch[1].toLowerCase() === "true";
servers.push({ name, type: hasUrl ? "http" : "stdio", enabled });
}
return servers;
}
it("parses stdio MCP servers", () => {
const yaml = `mcp_servers:
github:
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
`;
const servers = parseMcpBlock(yaml);
expect(servers).toHaveLength(1);
expect(servers[0]).toEqual({
name: "github",
type: "stdio",
enabled: true,
});
});
it("parses HTTP MCP servers", () => {
const yaml = `mcp_servers:
notion:
url: "https://mcp.notion.com/mcp"
headers:
Authorization: "Bearer token"
`;
const servers = parseMcpBlock(yaml);
expect(servers).toHaveLength(1);
expect(servers[0]).toEqual({ name: "notion", type: "http", enabled: true });
});
it("detects disabled servers", () => {
const yaml = `mcp_servers:
github:
command: npx
enabled: false
`;
const servers = parseMcpBlock(yaml);
expect(servers[0].enabled).toBe(false);
});
it("parses multiple servers", () => {
const yaml = `mcp_servers:
github:
command: npx
notion:
url: "https://example.com"
enabled: false
`;
const servers = parseMcpBlock(yaml);
expect(servers).toHaveLength(2);
expect(servers[0].name).toBe("github");
expect(servers[1].name).toBe("notion");
expect(servers[1].enabled).toBe(false);
});
it("returns empty for missing mcp_servers section", () => {
const yaml = `memory:\n provider: honcho\n`;
expect(parseMcpBlock(yaml)).toEqual([]);
});
it("returns empty for empty mcp_servers", () => {
const yaml = `mcp_servers:\n`;
expect(parseMcpBlock(yaml)).toEqual([]);
});
});
// ─── Memory provider discovery logic ────────────────────
describe("Memory provider discovery", () => {
it("creates provider list from directory scan", () => {
// Simulate plugins/memory/ structure
const pluginsDir = join(TEST_DIR, "plugins", "memory");
mkdirSync(join(pluginsDir, "honcho"), { recursive: true });
mkdirSync(join(pluginsDir, "mem0"), { recursive: true });
mkdirSync(join(pluginsDir, "holographic"), { recursive: true });
mkdirSync(join(pluginsDir, "__pycache__"), { recursive: true });
// Create __init__.py for installed providers
writeFileSync(join(pluginsDir, "honcho", "__init__.py"), "");
writeFileSync(join(pluginsDir, "mem0", "__init__.py"), "");
writeFileSync(join(pluginsDir, "holographic", "__init__.py"), "");
// Simulate the scanning logic
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { readdirSync } = require("fs");
const dirs = readdirSync(pluginsDir, { withFileTypes: true });
const providers = dirs
.filter(
(d: { isDirectory(): boolean; name: string }) =>
d.isDirectory() && !d.name.startsWith("_"),
)
.map((d: { name: string }) => ({
name: d.name,
installed: existsSync(join(pluginsDir, d.name, "__init__.py")),
}));
expect(providers).toHaveLength(3);
expect(providers.map((p: { name: string }) => p.name).sort()).toEqual([
"holographic",
"honcho",
"mem0",
]);
expect(providers.every((p: { installed: boolean }) => p.installed)).toBe(
true,
);
});
it("reads active provider from config.yaml", () => {
const configPath = join(TEST_DIR, "config.yaml");
writeFileSync(
configPath,
`memory:
memory_enabled: true
provider: honcho
nudge_interval: 10
`,
);
const content = readFileSync(configPath, "utf-8");
const match = content.match(/^\s*provider:\s*["']?(\w+)["']?\s*$/m);
expect(match?.[1]).toBe("honcho");
});
it("returns empty string when no provider configured", () => {
const configPath = join(TEST_DIR, "config.yaml");
writeFileSync(
configPath,
`memory:
memory_enabled: true
nudge_interval: 10
`,
);
const content = readFileSync(configPath, "utf-8");
const match = content.match(/^\s*provider:\s*["']?(\w+)["']?\s*$/m);
expect(match).toBeNull();
});
});
// ─── OAuth credential discovery ─────────────────
//
// The previous installer-side `hasHermesAuthCredential` accepted a bare
// `active_provider` and empty `providers: { name: {} }` entries as
// configured. That looser check could mask onboarding failures where a
// credential record existed but contained no token. The replacement,
// `hasOAuthCredentials` (in config.ts, profile-aware), only counts an
// entry that has at least one of access_token / refresh_token / api_key.
describe("OAuth credential discovery", () => {
async function importConfigWithHome(
home: string,
): Promise<typeof import("../src/main/config")> {
vi.resetModules();
process.env.HERMES_HOME = home;
return await import("../src/main/config");
}
afterEach(() => {
delete process.env.HERMES_HOME;
vi.resetModules();
});
it("detects OAuth credentials stored in auth.json credential_pool", async () => {
writeFileSync(
join(TEST_DIR, "auth.json"),
JSON.stringify({
credential_pool: {
"openai-codex": [{ access_token: "sk-test-token" }],
},
}),
);
const { hasOAuthCredentials } = await importConfigWithHome(TEST_DIR);
expect(hasOAuthCredentials("openai-codex")).toBe(true);
expect(hasOAuthCredentials("anthropic")).toBe(false);
});
it("requires actual token fields, not just a provider entry", async () => {
writeFileSync(
join(TEST_DIR, "auth.json"),
JSON.stringify({
active_provider: "openrouter",
providers: {
anthropic: {}, // empty — no token, so not "configured"
openai: { access_token: "tok-openai" },
claude: { refresh_token: "ref-claude" },
mistral: { api_key: "key-mistral" },
},
}),
);
const { hasOAuthCredentials } = await importConfigWithHome(TEST_DIR);
// Empty providers entries and a bare active_provider no longer count
// — the previous behavior reported them as configured even with no
// actual credentials, which masked real onboarding errors.
expect(hasOAuthCredentials("anthropic")).toBe(false);
expect(hasOAuthCredentials("openrouter")).toBe(false);
// Any of access_token / refresh_token / api_key qualifies.
expect(hasOAuthCredentials("openai")).toBe(true);
expect(hasOAuthCredentials("claude")).toBe(true);
expect(hasOAuthCredentials("mistral")).toBe(true);
});
it("returns false when auth.json is missing or malformed", async () => {
const missing = await importConfigWithHome(TEST_DIR);
expect(missing.hasOAuthCredentials("openai-codex")).toBe(false);
writeFileSync(join(TEST_DIR, "auth.json"), "{not-json");
const malformed = await importConfigWithHome(TEST_DIR);
expect(malformed.hasOAuthCredentials("openai-codex")).toBe(false);
});
});
// ─── OpenClaw detector ─────────────────────────────────
describe("checkOpenClawExists", () => {
it("ignores an empty .openclaw directory (self-created stub)", async () => {
// Mirrors the real-world case where hermes-desktop's own Claw3D settings
// helper has mkdirSync'd ~/.openclaw/claw3d/ without writing any files.
mkdirSync(join(TEST_DIR, ".openclaw", "claw3d"), { recursive: true });
const { checkOpenClawExists } = await import("../src/main/installer");
expect(checkOpenClawExists(TEST_DIR)).toEqual({ found: false, path: null });
});
it("detects a populated .openclaw directory", async () => {
const dir = join(TEST_DIR, ".openclaw");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "openclaw.json"), "{}");
const { checkOpenClawExists } = await import("../src/main/installer");
expect(checkOpenClawExists(TEST_DIR)).toEqual({ found: true, path: dir });
});
it("detects files nested under subdirectories", async () => {
const dir = join(TEST_DIR, ".openclaw");
mkdirSync(join(dir, "skills", "my-skill"), { recursive: true });
writeFileSync(join(dir, "skills", "my-skill", "SKILL.md"), "hi");
const { checkOpenClawExists } = await import("../src/main/installer");
expect(checkOpenClawExists(TEST_DIR)).toEqual({ found: true, path: dir });
});
it("returns not-found when no candidate directory exists", async () => {
const { checkOpenClawExists } = await import("../src/main/installer");
expect(checkOpenClawExists(TEST_DIR)).toEqual({ found: false, path: null });
});
it("falls back to legacy .clawdbot and .moldbot names", async () => {
const dir = join(TEST_DIR, ".clawdbot");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "clawdbot.json"), "{}");
const { checkOpenClawExists } = await import("../src/main/installer");
expect(checkOpenClawExists(TEST_DIR)).toEqual({ found: true, path: dir });
});
});
// ─── Backward compatibility checks ─────────────────────
describe("Backward compatibility", () => {
it("getEnhancedPath logic includes standard paths", () => {
// Simulate getEnhancedPath extra paths
const extra = ["/usr/local/bin", "/opt/homebrew/bin", "/opt/homebrew/sbin"];
const result = [...extra, process.env.PATH || ""].join(":");
expect(result).toContain("/usr/local/bin");
expect(result).toContain("/opt/homebrew/bin");
});
it("findNpm candidate list includes standard locations", () => {
const candidates = ["/usr/local/bin/npm", "/opt/homebrew/bin/npm"];
// At least these standard paths should be checked
expect(candidates.length).toBeGreaterThanOrEqual(2);
expect(candidates[0]).toContain("npm");
});
});