chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import * as generator from "../../../src/lib/cli-helper/config-generator/index.ts";
|
||||
|
||||
describe("config-generator", () => {
|
||||
describe("validateBaseUrl", () => {
|
||||
it("accepts http URLs", async () => {
|
||||
const mod = await import("../../../src/lib/cli-helper/config-generator/index.ts");
|
||||
assert.strictEqual(mod.validateBaseUrl("http://localhost:20128"), true);
|
||||
});
|
||||
|
||||
it("accepts https URLs", async () => {
|
||||
const mod = await import("../../../src/lib/cli-helper/config-generator/index.ts");
|
||||
assert.strictEqual(mod.validateBaseUrl("https://example.com"), true);
|
||||
});
|
||||
|
||||
it("rejects non-URL strings", async () => {
|
||||
const mod = await import("../../../src/lib/cli-helper/config-generator/index.ts");
|
||||
assert.strictEqual(mod.validateBaseUrl("not-a-url"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSafeCatalogUrl (SSRF guard, CodeQL #326)", () => {
|
||||
it("allows the loopback OmniRoute target (the legitimate default) and returns a URL", async () => {
|
||||
const { assertSafeCatalogUrl } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
// The catalog source IS the user's own OmniRoute — localhost must stay allowed.
|
||||
assert.doesNotThrow(() => assertSafeCatalogUrl("http://localhost:20128/v1/models"));
|
||||
assert.doesNotThrow(() => assertSafeCatalogUrl("http://127.0.0.1:20128/v1/models"));
|
||||
// Returns the validated, re-parsed URL (taint-severed value the caller fetches).
|
||||
const safe = assertSafeCatalogUrl("http://localhost:20128/v1/models");
|
||||
assert.ok(safe instanceof URL);
|
||||
assert.equal(safe.href, "http://localhost:20128/v1/models");
|
||||
});
|
||||
|
||||
it("allows a public OmniRoute Cloud target", async () => {
|
||||
const { assertSafeCatalogUrl } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
assert.doesNotThrow(() => assertSafeCatalogUrl("https://api.omniroute.online/v1/models"));
|
||||
});
|
||||
|
||||
it("blocks the cloud-metadata SSRF→IAM pivot (169.254.169.254)", async () => {
|
||||
const { assertSafeCatalogUrl } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
assert.throws(() => assertSafeCatalogUrl("http://169.254.169.254/v1/models"));
|
||||
assert.throws(() =>
|
||||
assertSafeCatalogUrl("http://metadata.google.internal/v1/models")
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks non-http(s) protocols and embedded credentials", async () => {
|
||||
const { assertSafeCatalogUrl } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
assert.throws(() => assertSafeCatalogUrl("file:///etc/passwd"));
|
||||
assert.throws(() => assertSafeCatalogUrl("http://user:pass@example.com/v1/models"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateConfig", () => {
|
||||
it("returns error for invalid baseUrl", async () => {
|
||||
const result = await generator.generateConfig("claude", {
|
||||
baseUrl: "invalid",
|
||||
apiKey: "sk-xxx",
|
||||
});
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(result.error?.includes("Invalid baseUrl"));
|
||||
});
|
||||
|
||||
it("returns error for empty apiKey", async () => {
|
||||
const result = await generator.generateConfig("claude", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "",
|
||||
});
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(result.error?.includes("API key"));
|
||||
});
|
||||
|
||||
it("returns success for valid claude config", async () => {
|
||||
// This may fail if the claude generator has issues - just ensure error handling works
|
||||
const result = await generator.generateConfig("claude", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
// Either success or error (if generator missing), but check structure is correct
|
||||
assert.ok("success" in result);
|
||||
assert.ok("configPath" in result);
|
||||
});
|
||||
|
||||
it("returns success for valid hermes config", async () => {
|
||||
const result = await generator.generateConfig("hermes", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(result.configPath.endsWith(".hermes/config.yaml"));
|
||||
assert.ok(String(result.content || "").includes("providers:"));
|
||||
assert.ok(String(result.content || "").includes("omniroute"));
|
||||
});
|
||||
|
||||
it("returns error for unknown tool", async () => {
|
||||
const result = await generator.generateConfig("unknown-tool-xyz", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-xxx",
|
||||
});
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(result.error?.includes("Unknown tool"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateAllConfigs", () => {
|
||||
it("returns array of GenerateResult for all tools", async () => {
|
||||
const results = await generator.generateAllConfigs({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-xxx",
|
||||
});
|
||||
assert.ok(Array.isArray(results));
|
||||
assert.strictEqual(results.length, 7); // claude, codex, opencode, cline, kilocode, continue, hermes
|
||||
});
|
||||
});
|
||||
|
||||
describe("hermes-agent (rich multi-role)", () => {
|
||||
it("exports HERMES_AGENT_ROLES with expected roles", async () => {
|
||||
const hermesAgent =
|
||||
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
|
||||
assert.ok(Array.isArray(hermesAgent.HERMES_AGENT_ROLES));
|
||||
const ids = hermesAgent.HERMES_AGENT_ROLES.map((r: any) => r.id);
|
||||
assert.ok(ids.includes("default"));
|
||||
assert.ok(ids.includes("delegation"));
|
||||
assert.ok(ids.includes("vision"));
|
||||
assert.ok(ids.includes("approval"));
|
||||
});
|
||||
|
||||
it("getCurrentHermesAgentRoles returns an object", async () => {
|
||||
const hermesAgent =
|
||||
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
|
||||
const roles = await hermesAgent.getCurrentHermesAgentRoles();
|
||||
assert.ok(typeof roles === "object" && roles !== null);
|
||||
});
|
||||
|
||||
it("generateHermesAgentConfig returns yaml string for valid payload", async () => {
|
||||
const hermesAgent =
|
||||
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
|
||||
const result = await hermesAgent.generateHermesAgentConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test-omniroute",
|
||||
selections: [
|
||||
{ role: "default", model: "gpt-4o" },
|
||||
{ role: "delegation", model: "claude-3-5-sonnet" },
|
||||
{ role: "vision", model: "gpt-4o" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(!result.error);
|
||||
assert.ok(typeof result.yaml === "string");
|
||||
assert.ok(result.yaml.length > 50);
|
||||
assert.ok(result.yaml.includes("provider: omniroute"));
|
||||
});
|
||||
|
||||
it("generateHermesAgentConfig includes auxiliary section for non-default roles", async () => {
|
||||
const hermesAgent =
|
||||
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
|
||||
const result = await hermesAgent.generateHermesAgentConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
selections: [
|
||||
{ role: "compression", model: "test-model" },
|
||||
{ role: "skills_hub", model: "test-model-2" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(result.yaml.includes("auxiliary:"));
|
||||
assert.ok(result.yaml.includes("compression:"));
|
||||
});
|
||||
|
||||
it("generateHermesAgentConfig returns error when baseUrl is missing", async () => {
|
||||
const hermesAgent =
|
||||
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
|
||||
const result = await hermesAgent.generateHermesAgentConfig({
|
||||
baseUrl: "",
|
||||
selections: [{ role: "default", model: "x" }],
|
||||
} as any);
|
||||
|
||||
assert.ok(result.error);
|
||||
assert.ok(result.error.includes("baseUrl"));
|
||||
});
|
||||
|
||||
it("generateHermesAgentConfig correctly structures delegation and auxiliary roles", async () => {
|
||||
const hermesAgent =
|
||||
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
|
||||
const result = await hermesAgent.generateHermesAgentConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
selections: [
|
||||
{ role: "default", model: "model-default" },
|
||||
{ role: "delegation", model: "model-delegation" },
|
||||
{ role: "approval", model: "model-approval" },
|
||||
],
|
||||
});
|
||||
|
||||
const yaml = result.yaml;
|
||||
assert.ok(yaml.includes("model:"));
|
||||
assert.ok(yaml.includes("default: model-default"));
|
||||
assert.ok(yaml.includes("delegation:"));
|
||||
assert.ok(yaml.includes("auxiliary:"));
|
||||
assert.ok(yaml.includes("approval:"));
|
||||
});
|
||||
|
||||
it("generateHermesAgentConfig performs non-destructive merge (preserves other keys)", async () => {
|
||||
// This test mainly verifies the function doesn't blow away unrelated config
|
||||
const hermesAgent =
|
||||
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
|
||||
const result = await hermesAgent.generateHermesAgentConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
selections: [{ role: "default", model: "new-model" }],
|
||||
});
|
||||
|
||||
// Should still contain providers block and the new model
|
||||
assert.ok(result.yaml.includes("providers:"));
|
||||
assert.ok(result.yaml.includes("new-model"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("opencode (context-aware)", () => {
|
||||
/**
|
||||
* The catalog is the single source of truth for context windows —
|
||||
* we never fabricate a default. Tests below pin this contract.
|
||||
*/
|
||||
function makeCatalogResponse(models: unknown[]): unknown {
|
||||
return { object: "list", data: models };
|
||||
}
|
||||
|
||||
const SAMPLE_CATALOG: unknown[] = [
|
||||
{ id: "ds/deepseek-v4-flash", owned_by: "deepseek", context_length: 1_000_000, max_input_tokens: 1_000_000 },
|
||||
{ id: "llama3", owned_by: "llama", max_context_window_tokens: 8192 },
|
||||
{ id: "MASTER", owned_by: "combo", context_length: 131072, max_input_tokens: 131072 },
|
||||
{ id: "Opencode FREE Omni", owned_by: "combo", context_length: 200000, max_input_tokens: 160000 },
|
||||
// Combo whose targets have no known context — generator must NOT
|
||||
// fabricate a default. The model is emitted without limit.context.
|
||||
{ id: "NO_CTX_COMBO", owned_by: "combo" },
|
||||
];
|
||||
|
||||
function stubFetchOnce(body: unknown, status = 200) {
|
||||
const original = globalThis.fetch;
|
||||
let calls = 0;
|
||||
// @ts-ignore — globalThis.fetch signature is compatible for our purposes
|
||||
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||
calls += 1;
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
return {
|
||||
calls: () => calls,
|
||||
restore: () => {
|
||||
globalThis.fetch = original;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("emits limit.context from the catalog (no hardcoded fallback)", async () => {
|
||||
const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG));
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
const out = await generateOpencodeConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
const cfg = JSON.parse(out);
|
||||
const models = cfg.provider.omniroute.models;
|
||||
assert.strictEqual(models["ds/deepseek-v4-flash"].limit.context, 1_000_000);
|
||||
assert.strictEqual(models["MASTER"].limit.context, 131072);
|
||||
// Combo with min-of-targets 200K: must reflect the catalog's value,
|
||||
// not a hardcoded 128K.
|
||||
assert.strictEqual(models["Opencode FREE Omni"].limit.context, 200000);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT fabricate a default context when the catalog has no entry", async () => {
|
||||
const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG));
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
const out = await generateOpencodeConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
const cfg = JSON.parse(out);
|
||||
// NO_CTX_COMBO has no context_length in the catalog — generator
|
||||
// must NOT default to 128K (or any other value). The entry is
|
||||
// emitted without limit.context so OpenCode's own heuristic
|
||||
// applies and the user can fix the upstream.
|
||||
const noCtx = cfg.provider.omniroute.models["NO_CTX_COMBO"];
|
||||
assert.strictEqual(
|
||||
noCtx.limit?.context,
|
||||
undefined,
|
||||
`NO_CTX_COMBO should not have a fabricated limit.context (got ${noCtx.limit?.context})`
|
||||
);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers max_context_window_tokens when context_length is absent", async () => {
|
||||
const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG));
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
const out = await generateOpencodeConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
const cfg = JSON.parse(out);
|
||||
assert.strictEqual(cfg.provider.omniroute.models.llama3.limit.context, 8192);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("THROWS when the catalog fetch fails (no silent stale config)", async () => {
|
||||
// When the catalog fetch fails, the generator MUST throw rather than
|
||||
// emit a config with fabricated values. The CLI catches the error
|
||||
// and surfaces it to the user; the user's existing opencode.json is
|
||||
// left untouched.
|
||||
const original = globalThis.fetch;
|
||||
// @ts-ignore
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
let threw = false;
|
||||
try {
|
||||
await generateOpencodeConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
assert.ok(
|
||||
/catalog|fetch|ECONNREFUSED/i.test(String(e?.message ?? e)),
|
||||
`Expected fetch error, got: ${String(e?.message ?? e)}`
|
||||
);
|
||||
}
|
||||
assert.ok(threw, "generator must throw when catalog fetch fails");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("writes a top-level model prefixed with provider id when options.model is supplied", async () => {
|
||||
const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG));
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
const out = await generateOpencodeConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "MASTER",
|
||||
});
|
||||
const cfg = JSON.parse(out);
|
||||
assert.strictEqual(cfg.model, "omniroute/MASTER");
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-pulls the Opencode FREE Omni combo context (the user-reported case)", async () => {
|
||||
// Regression guard: the catalog's min-of-targets for combos must be
|
||||
// reflected verbatim. No hardcoded 128K, no fallback that overrides
|
||||
// the catalog's actual value.
|
||||
const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG));
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
const out = await generateOpencodeConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
const cfg = JSON.parse(out);
|
||||
assert.strictEqual(
|
||||
cfg.provider.omniroute.models["Opencode FREE Omni"].limit.context,
|
||||
200000,
|
||||
"Opencode FREE Omni must have context=200000 from the catalog, not 128000"
|
||||
);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Regression test for #3628: Hermes Agent config must honour the HERMES_HOME env var.
|
||||
*
|
||||
* Hard Rule #18 — TDD gate: this file was written BEFORE the fix and was red,
|
||||
* then turned green once getHermesHome() was introduced and all callsites
|
||||
* were routed through it.
|
||||
*/
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Keep the original value so we can restore it in teardown.
|
||||
const _originalHermesHome = process.env.HERMES_HOME;
|
||||
|
||||
describe("getHermesHome (#3628 — HERMES_HOME env var)", () => {
|
||||
// ── helper: dynamic import so we always get a fresh module evaluation
|
||||
// Note: Node ESM caches modules. We test the exported helper directly.
|
||||
|
||||
after(() => {
|
||||
// Restore the env var regardless of test outcome.
|
||||
if (_originalHermesHome === undefined) {
|
||||
delete process.env.HERMES_HOME;
|
||||
} else {
|
||||
process.env.HERMES_HOME = _originalHermesHome;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to ~/.hermes when HERMES_HOME is not set", async () => {
|
||||
delete process.env.HERMES_HOME;
|
||||
|
||||
const { getHermesHome } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/hermesHome.ts"
|
||||
);
|
||||
|
||||
const result = getHermesHome();
|
||||
assert.strictEqual(result, path.join(os.homedir(), ".hermes"));
|
||||
});
|
||||
|
||||
it("returns HERMES_HOME when the env var is set", async () => {
|
||||
process.env.HERMES_HOME = "/custom/hermes";
|
||||
|
||||
const { getHermesHome } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/hermesHome.ts"
|
||||
);
|
||||
|
||||
const result = getHermesHome();
|
||||
assert.ok(
|
||||
result.startsWith("/custom/hermes"),
|
||||
`Expected path to start with /custom/hermes, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
it("hermes-agent CONFIG_PATH uses HERMES_HOME when set", async () => {
|
||||
process.env.HERMES_HOME = "/custom/hermes";
|
||||
|
||||
const { getHermesConfigPath } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/hermesHome.ts"
|
||||
);
|
||||
|
||||
const configPath = getHermesConfigPath();
|
||||
assert.ok(
|
||||
configPath.startsWith("/custom/hermes"),
|
||||
`Expected config path to start with /custom/hermes, got: ${configPath}`
|
||||
);
|
||||
assert.ok(
|
||||
configPath.endsWith("config.yaml"),
|
||||
`Expected config path to end with config.yaml, got: ${configPath}`
|
||||
);
|
||||
});
|
||||
|
||||
it("TOOL_CONFIG_PATHS['hermes-agent'] reflects HERMES_HOME (lazy evaluation)", async () => {
|
||||
process.env.HERMES_HOME = "/custom/hermes";
|
||||
|
||||
// Force a fresh dynamic import of index.ts to test lazy evaluation.
|
||||
// Because Node caches ESM modules by URL, we test the helper which is
|
||||
// what index.ts should delegate to.
|
||||
const { getHermesConfigPath } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/hermesHome.ts"
|
||||
);
|
||||
|
||||
// The path returned by the generator index must equal what getHermesConfigPath() returns.
|
||||
const expected = getHermesConfigPath();
|
||||
assert.ok(
|
||||
expected.startsWith("/custom/hermes"),
|
||||
`Expected ${expected} to start with /custom/hermes`
|
||||
);
|
||||
});
|
||||
|
||||
it("getCliConfigPaths('hermes-agent') uses HERMES_HOME (cliRuntime integration)", async () => {
|
||||
process.env.HERMES_HOME = "/custom/hermes";
|
||||
|
||||
const { getCliConfigPaths } = await import(
|
||||
"../../../src/shared/services/cliRuntime.ts"
|
||||
);
|
||||
|
||||
const paths = getCliConfigPaths("hermes-agent");
|
||||
assert.ok(paths !== null, "getCliConfigPaths('hermes-agent') returned null");
|
||||
const configPath = (paths as Record<string, string>)["config"];
|
||||
assert.ok(
|
||||
configPath.startsWith("/custom/hermes"),
|
||||
`Expected hermes-agent config path to start with /custom/hermes, got: ${configPath}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import * as toolDetector from "../../../src/lib/cli-helper/tool-detector.ts";
|
||||
|
||||
describe("tool-detector", () => {
|
||||
before(() => {
|
||||
// Install mock exec implementation for deterministic testing
|
||||
// @ts-expect-error - internal test hook
|
||||
toolDetector.__setExecFileImpl(async (cmd) => {
|
||||
if (cmd === "opencode") {
|
||||
return { stdout: "v1.0.0\n" };
|
||||
}
|
||||
if (cmd === "hermes") {
|
||||
return { stdout: "v0.75.3\n" };
|
||||
}
|
||||
if (cmd === "openclaw") {
|
||||
return { stdout: "v0.3.1\n" };
|
||||
}
|
||||
if (cmd === "which") {
|
||||
return { stdout: "/usr/local/bin/opencode\n" };
|
||||
}
|
||||
throw new Error("Command not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectTool", () => {
|
||||
it("returns null for unknown tool id", async () => {
|
||||
const result = await toolDetector.detectTool("unknown-tool-xyz");
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it("returns DetectedTool object for installed tool", async () => {
|
||||
const result = await toolDetector.detectTool("opencode");
|
||||
assert.ok(result !== null);
|
||||
assert.strictEqual(result!.id, "opencode");
|
||||
assert.strictEqual(result!.name, "OpenCode");
|
||||
assert.strictEqual(result!.installed, true);
|
||||
assert.strictEqual(result!.version, "1.0.0");
|
||||
assert.ok(result!.configPath.includes(".config/opencode"));
|
||||
assert.strictEqual(typeof result!.configured, "boolean");
|
||||
});
|
||||
|
||||
it("returns DetectedTool object for Hermes with the Hermes config path", async () => {
|
||||
const result = await toolDetector.detectTool("hermes");
|
||||
assert.ok(result !== null);
|
||||
assert.strictEqual(result!.id, "hermes");
|
||||
assert.strictEqual(result!.name, "Hermes");
|
||||
assert.strictEqual(result!.installed, true);
|
||||
assert.strictEqual(result!.version, "0.75.3");
|
||||
assert.ok(result!.configPath.includes(".hermes/config.yaml"));
|
||||
assert.strictEqual(typeof result!.configured, "boolean");
|
||||
});
|
||||
|
||||
// Regression test for #2833 — openclaw was missing from TOOLS array
|
||||
it("returns DetectedTool object for openclaw with the openclaw config path", async () => {
|
||||
const result = await toolDetector.detectTool("openclaw");
|
||||
assert.ok(result !== null, "detectTool('openclaw') must not return null");
|
||||
assert.strictEqual(result!.id, "openclaw");
|
||||
assert.strictEqual(result!.name, "OpenClaw");
|
||||
assert.strictEqual(result!.installed, true);
|
||||
assert.strictEqual(result!.version, "0.3.1");
|
||||
assert.ok(
|
||||
result!.configPath.includes(".openclaw/openclaw.json"),
|
||||
`expected configPath to include '.openclaw/openclaw.json', got: ${result!.configPath}`,
|
||||
);
|
||||
assert.strictEqual(typeof result!.configured, "boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectAllTools", () => {
|
||||
it("returns array (may be empty if tools not installed)", async () => {
|
||||
const tools = await toolDetector.detectAllTools();
|
||||
assert.ok(Array.isArray(tools));
|
||||
// All items must pass shape check
|
||||
for (const t of tools) {
|
||||
assert.ok(t.id);
|
||||
assert.ok(t.name);
|
||||
assert.strictEqual(typeof t.installed, "boolean");
|
||||
assert.ok("configPath" in t);
|
||||
assert.ok("configured" in t);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression test for #2833 — openclaw must appear in detectAllTools()
|
||||
it("includes openclaw in the detected tools list", async () => {
|
||||
const tools = await toolDetector.detectAllTools();
|
||||
const openclaw = tools.find((t) => t.id === "openclaw");
|
||||
assert.ok(openclaw !== undefined, "detectAllTools() must include an entry with id='openclaw'");
|
||||
assert.strictEqual(openclaw!.name, "OpenClaw");
|
||||
assert.ok(
|
||||
openclaw!.configPath.includes(".openclaw/openclaw.json"),
|
||||
`expected configPath to include '.openclaw/openclaw.json', got: ${openclaw!.configPath}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user