test: simplify talk config and path env coverage

This commit is contained in:
Peter Steinberger
2026-03-13 18:06:53 +00:00
parent 8f4e77e72f
commit bec76be592
2 changed files with 187 additions and 153 deletions

View File

@@ -20,6 +20,26 @@ import { withServer } from "./test-with-server.js";
installGatewayTestHooks({ scope: "suite" });
type GatewaySocket = Parameters<Parameters<typeof withServer>[0]>[0];
type SecretRef = { source?: string; provider?: string; id?: string };
type TalkConfigPayload = {
config?: {
talk?: {
provider?: string;
providers?: {
elevenlabs?: { voiceId?: string; apiKey?: string | SecretRef };
};
resolved?: {
provider?: string;
config?: { voiceId?: string; apiKey?: string | SecretRef };
};
apiKey?: string | SecretRef;
voiceId?: string;
silenceTimeoutMs?: number;
};
session?: { mainKey?: string };
ui?: { seamColor?: string };
};
};
const TALK_CONFIG_DEVICE_PATH = path.join(
os.tmpdir(),
`openclaw-talk-config-device-${process.pid}.json`,
@@ -67,6 +87,37 @@ async function writeTalkConfig(config: {
await writeConfigFile({ talk: config });
}
async function fetchTalkConfig(
ws: GatewaySocket,
params?: { includeSecrets?: boolean } | Record<string, unknown>,
) {
return rpcReq<TalkConfigPayload>(ws, "talk.config", params ?? {});
}
function expectElevenLabsTalkConfig(
talk: TalkConfigPayload["config"] extends { talk?: infer T } ? T : never,
expected: {
voiceId?: string;
apiKey?: string | SecretRef;
silenceTimeoutMs?: number;
},
) {
expect(talk?.provider).toBe("elevenlabs");
expect(talk?.providers?.elevenlabs?.voiceId).toBe(expected.voiceId);
expect(talk?.resolved?.provider).toBe("elevenlabs");
expect(talk?.resolved?.config?.voiceId).toBe(expected.voiceId);
expect(talk?.voiceId).toBe(expected.voiceId);
if ("apiKey" in expected) {
expect(talk?.providers?.elevenlabs?.apiKey).toEqual(expected.apiKey);
expect(talk?.resolved?.config?.apiKey).toEqual(expected.apiKey);
expect(talk?.apiKey).toEqual(expected.apiKey);
}
if ("silenceTimeoutMs" in expected) {
expect(talk?.silenceTimeoutMs).toBe(expected.silenceTimeoutMs);
}
}
describe("gateway talk.config", () => {
it("returns redacted talk config for read scope", async () => {
const { writeConfigFile } = await import("../config/config.js");
@@ -86,35 +137,26 @@ describe("gateway talk.config", () => {
await withServer(async (ws) => {
await connectOperator(ws, ["operator.read"]);
const res = await rpcReq<{
config?: {
talk?: {
provider?: string;
providers?: {
elevenlabs?: { voiceId?: string; apiKey?: string };
};
resolved?: {
provider?: string;
config?: { voiceId?: string; apiKey?: string };
};
apiKey?: string;
voiceId?: string;
silenceTimeoutMs?: number;
};
};
}>(ws, "talk.config", {});
const res = await fetchTalkConfig(ws);
expect(res.ok).toBe(true);
expect(res.payload?.config?.talk?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123");
expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toBe(
"__OPENCLAW_REDACTED__",
);
expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.resolved?.config?.voiceId).toBe("voice-123");
expect(res.payload?.config?.talk?.resolved?.config?.apiKey).toBe("__OPENCLAW_REDACTED__");
expect(res.payload?.config?.talk?.voiceId).toBe("voice-123");
expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__");
expect(res.payload?.config?.talk?.silenceTimeoutMs).toBe(1500);
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
voiceId: "voice-123",
apiKey: "__OPENCLAW_REDACTED__",
silenceTimeoutMs: 1500,
});
expect(res.payload?.config?.session?.mainKey).toBe("main-test");
expect(res.payload?.config?.ui?.seamColor).toBe("#112233");
});
});
it("rejects invalid talk.config params", async () => {
await writeTalkConfig({ apiKey: "secret-key-abc" }); // pragma: allowlist secret
await withServer(async (ws) => {
await connectOperator(ws, ["operator.read"]);
const res = await fetchTalkConfig(ws, { includeSecrets: "yes" });
expect(res.ok).toBe(false);
expect(res.error?.message).toContain("invalid talk.config params");
});
});
@@ -123,22 +165,25 @@ describe("gateway talk.config", () => {
await withServer(async (ws) => {
await connectOperator(ws, ["operator.read"]);
const res = await rpcReq(ws, "talk.config", { includeSecrets: true });
const res = await fetchTalkConfig(ws, { includeSecrets: true });
expect(res.ok).toBe(false);
expect(res.error?.message).toContain("missing scope: operator.talk.secrets");
});
});
it("returns secrets for operator.talk.secrets scope", async () => {
it.each([
["operator.talk.secrets", ["operator.read", "operator.write", "operator.talk.secrets"]],
["operator.admin", ["operator.read", "operator.admin"]],
] as const)("returns secrets for %s scope", async (_label, scopes) => {
await writeTalkConfig({ apiKey: "secret-key-abc" }); // pragma: allowlist secret
await withServer(async (ws) => {
await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]);
const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", {
includeSecrets: true,
});
await connectOperator(ws, [...scopes]);
const res = await fetchTalkConfig(ws, { includeSecrets: true });
expect(res.ok).toBe(true);
expect(res.payload?.config?.talk?.apiKey).toBe("secret-key-abc");
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
apiKey: "secret-key-abc",
});
});
});
@@ -154,44 +199,15 @@ describe("gateway talk.config", () => {
await withEnvAsync({ ELEVENLABS_API_KEY: "env-elevenlabs-key" }, async () => {
await withServer(async (ws) => {
await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]);
const res = await rpcReq<{
config?: {
talk?: {
apiKey?: { source?: string; provider?: string; id?: string };
providers?: {
elevenlabs?: {
apiKey?: { source?: string; provider?: string; id?: string };
};
};
resolved?: {
provider?: string;
config?: {
apiKey?: { source?: string; provider?: string; id?: string };
};
};
};
};
}>(ws, "talk.config", {
includeSecrets: true,
});
const res = await fetchTalkConfig(ws, { includeSecrets: true });
expect(res.ok).toBe(true);
expect(validateTalkConfigResult(res.payload)).toBe(true);
expect(res.payload?.config?.talk?.apiKey).toEqual({
const secretRef = {
source: "env",
provider: "default",
id: "ELEVENLABS_API_KEY",
});
expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toEqual({
source: "env",
provider: "default",
id: "ELEVENLABS_API_KEY",
});
expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.resolved?.config?.apiKey).toEqual({
source: "env",
provider: "default",
id: "ELEVENLABS_API_KEY",
});
} satisfies SecretRef;
expectElevenLabsTalkConfig(res.payload?.config?.talk, { apiKey: secretRef });
});
});
});
@@ -212,27 +228,11 @@ describe("gateway talk.config", () => {
await withServer(async (ws) => {
await connectOperator(ws, ["operator.read"]);
const res = await rpcReq<{
config?: {
talk?: {
provider?: string;
providers?: {
elevenlabs?: { voiceId?: string };
};
resolved?: {
provider?: string;
config?: { voiceId?: string };
};
voiceId?: string;
};
};
}>(ws, "talk.config", {});
const res = await fetchTalkConfig(ws);
expect(res.ok).toBe(true);
expect(res.payload?.config?.talk?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-normalized");
expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs");
expect(res.payload?.config?.talk?.resolved?.config?.voiceId).toBe("voice-normalized");
expect(res.payload?.config?.talk?.voiceId).toBe("voice-normalized");
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
voiceId: "voice-normalized",
});
});
});
});

View File

@@ -72,26 +72,39 @@ describe("ensureOpenClawCliOnPath", () => {
}
});
it("prepends the bundled app bin dir when a sibling openclaw exists", () => {
const tmp = abs("/tmp/openclaw-path/case-bundled");
function setupAppCliRoot(name: string) {
const tmp = abs(`/tmp/openclaw-path/${name}`);
const appBinDir = path.join(tmp, "AppBin");
const cliPath = path.join(appBinDir, "openclaw");
const appCli = path.join(appBinDir, "openclaw");
setDir(tmp);
setDir(appBinDir);
setExe(cliPath);
setExe(appCli);
return { tmp, appBinDir, appCli };
}
function bootstrapPath(params: {
execPath: string;
cwd: string;
homeDir: string;
platform: NodeJS.Platform;
allowProjectLocalBin?: boolean;
}) {
ensureOpenClawCliOnPath(params);
return (process.env.PATH ?? "").split(path.delimiter);
}
it("prepends the bundled app bin dir when a sibling openclaw exists", () => {
const { tmp, appBinDir, appCli } = setupAppCliRoot("case-bundled");
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
ensureOpenClawCliOnPath({
execPath: cliPath,
const updated = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
const updated = process.env.PATH ?? "";
expect(updated.split(path.delimiter)[0]).toBe(appBinDir);
expect(updated[0]).toBe(appBinDir);
});
it("is idempotent", () => {
@@ -107,13 +120,7 @@ describe("ensureOpenClawCliOnPath", () => {
});
it("prepends mise shims when available", () => {
const tmp = abs("/tmp/openclaw-path/case-mise");
const appBinDir = path.join(tmp, "AppBin");
const appCli = path.join(appBinDir, "openclaw");
setDir(tmp);
setDir(appBinDir);
setExe(appCli);
const { tmp, appBinDir, appCli } = setupAppCliRoot("case-mise");
const miseDataDir = path.join(tmp, "mise");
const shimsDir = path.join(miseDataDir, "shims");
setDir(miseDataDir);
@@ -123,62 +130,92 @@ describe("ensureOpenClawCliOnPath", () => {
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
ensureOpenClawCliOnPath({
const updated = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
const updated = process.env.PATH ?? "";
const parts = updated.split(path.delimiter);
const appBinIndex = parts.indexOf(appBinDir);
const shimsIndex = parts.indexOf(shimsDir);
const appBinIndex = updated.indexOf(appBinDir);
const shimsIndex = updated.indexOf(shimsDir);
expect(appBinIndex).toBeGreaterThanOrEqual(0);
expect(shimsIndex).toBeGreaterThan(appBinIndex);
});
it("only appends project-local node_modules/.bin when explicitly enabled", () => {
const tmp = abs("/tmp/openclaw-path/case-project-local");
const appBinDir = path.join(tmp, "AppBin");
const appCli = path.join(appBinDir, "openclaw");
setDir(tmp);
setDir(appBinDir);
setExe(appCli);
const localBinDir = path.join(tmp, "node_modules", ".bin");
const localCli = path.join(localBinDir, "openclaw");
setDir(path.join(tmp, "node_modules"));
setDir(localBinDir);
setExe(localCli);
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
ensureOpenClawCliOnPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
const withoutOptIn = (process.env.PATH ?? "").split(path.delimiter);
expect(withoutOptIn.includes(localBinDir)).toBe(false);
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
ensureOpenClawCliOnPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
it.each([
{
name: "explicit option",
envValue: undefined,
allowProjectLocalBin: true,
},
{
name: "truthy env",
envValue: "1",
allowProjectLocalBin: undefined,
},
])(
"only appends project-local node_modules/.bin when enabled via $name",
({ envValue, allowProjectLocalBin }) => {
const { tmp, appCli } = setupAppCliRoot("case-project-local");
const localBinDir = path.join(tmp, "node_modules", ".bin");
const localCli = path.join(localBinDir, "openclaw");
setDir(path.join(tmp, "node_modules"));
setDir(localBinDir);
setExe(localCli);
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
delete process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN;
const withoutOptIn = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
expect(withoutOptIn.includes(localBinDir)).toBe(false);
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
if (envValue === undefined) {
delete process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN;
} else {
process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN = envValue;
}
const withOptIn = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
...(allowProjectLocalBin === undefined ? {} : { allowProjectLocalBin }),
});
const usrBinIndex = withOptIn.indexOf("/usr/bin");
const localIndex = withOptIn.indexOf(localBinDir);
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
expect(localIndex).toBeGreaterThan(usrBinIndex);
},
);
it("prepends XDG_BIN_HOME ahead of other user bin fallbacks", () => {
const { tmp, appCli } = setupAppCliRoot("case-xdg-bin-home");
const xdgBinHome = path.join(tmp, "xdg-bin");
const localBin = path.join(tmp, ".local", "bin");
setDir(xdgBinHome);
setDir(path.join(tmp, ".local"));
setDir(localBin);
process.env.PATH = "/usr/bin";
process.env.XDG_BIN_HOME = xdgBinHome;
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
const updated = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "linux",
});
const withOptIn = (process.env.PATH ?? "").split(path.delimiter);
const usrBinIndex = withOptIn.indexOf("/usr/bin");
const localIndex = withOptIn.indexOf(localBinDir);
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
expect(localIndex).toBeGreaterThan(usrBinIndex);
expect(updated.indexOf(xdgBinHome)).toBeLessThan(updated.indexOf(localBin));
});
it("prepends Linuxbrew dirs when present", () => {
@@ -200,15 +237,12 @@ describe("ensureOpenClawCliOnPath", () => {
delete process.env.HOMEBREW_BREW_FILE;
delete process.env.XDG_BIN_HOME;
ensureOpenClawCliOnPath({
const parts = bootstrapPath({
execPath: path.join(execDir, "node"),
cwd: tmp,
homeDir: tmp,
platform: "linux",
});
const updated = process.env.PATH ?? "";
const parts = updated.split(path.delimiter);
expect(parts[0]).toBe(linuxbrewBin);
expect(parts[1]).toBe(linuxbrewSbin);
});