mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 16:50:43 +00:00
test: tighten assertions and harness coverage
This commit is contained in:
@@ -98,7 +98,10 @@ describe("embedded acpx plugin config", () => {
|
||||
});
|
||||
|
||||
const server = resolved.mcpServers["openclaw-plugin-tools"];
|
||||
expect(server).toBeDefined();
|
||||
expect(server).toMatchObject({
|
||||
command: process.execPath,
|
||||
args: expect.any(Array),
|
||||
});
|
||||
expect(server.command).toBe(process.execPath);
|
||||
expect(Array.isArray(server.args)).toBe(true);
|
||||
expect(server.args?.length).toBeGreaterThan(0);
|
||||
@@ -113,7 +116,10 @@ describe("embedded acpx plugin config", () => {
|
||||
});
|
||||
|
||||
const server = resolved.mcpServers["openclaw-tools"];
|
||||
expect(server).toBeDefined();
|
||||
expect(server).toMatchObject({
|
||||
command: process.execPath,
|
||||
args: expect.any(Array),
|
||||
});
|
||||
expect(server.command).toBe(process.execPath);
|
||||
expect(Array.isArray(server.args)).toBe(true);
|
||||
expect(server.args?.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("acpx package manifest", () => {
|
||||
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as AcpxPackageManifest;
|
||||
|
||||
expect(packageJson.dependencies?.acpx).toBeDefined();
|
||||
expect(packageJson.dependencies?.acpx).toEqual(expect.any(String));
|
||||
expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.13.0");
|
||||
expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.32.0");
|
||||
expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined();
|
||||
|
||||
@@ -325,10 +325,15 @@ describe("createAcpxRuntimeService", () => {
|
||||
await service.start(ctx);
|
||||
|
||||
const backend = getAcpRuntimeBackend("acpx");
|
||||
expect(backend?.runtime).toBeDefined();
|
||||
if (!backend) {
|
||||
throw new Error("expected ACPX runtime backend");
|
||||
}
|
||||
expect(backend.runtime).toMatchObject({
|
||||
ensureSession: expect.any(Function),
|
||||
});
|
||||
expect(acpxRuntimeConstructorMock).not.toHaveBeenCalled();
|
||||
|
||||
await backend?.runtime.ensureSession({
|
||||
await backend.runtime.ensureSession({
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
@@ -509,7 +514,9 @@ describe("createAcpxRuntimeService", () => {
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeTruthy();
|
||||
expect(getAcpRuntimeBackend("acpx")).toMatchObject({
|
||||
runtime: expect.any(Object),
|
||||
});
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
@@ -156,6 +156,17 @@ describe("active-memory plugin", () => {
|
||||
vi
|
||||
.mocked(api.logger.warn)
|
||||
.mock.calls.some((call: unknown[]) => String(call[0]).includes(needle));
|
||||
const expectPrependContextResult = (result: unknown) => {
|
||||
expect(result).toMatchObject({
|
||||
prependContext: expect.any(String),
|
||||
});
|
||||
};
|
||||
const requireNonEmptyString = (value: unknown, message: string): string => {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -931,7 +942,7 @@ describe("active-memory plugin", () => {
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeDefined();
|
||||
expectPrependContextResult(result);
|
||||
});
|
||||
|
||||
it("skips sessions whose conversation id is in deniedChatIds even when chat type is allowed", async () => {
|
||||
@@ -1033,7 +1044,7 @@ describe("active-memory plugin", () => {
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeDefined();
|
||||
expectPrependContextResult(result);
|
||||
});
|
||||
|
||||
it("matches per-peer direct session keys (agent:<id>:direct:<peer>)", async () => {
|
||||
@@ -1057,7 +1068,7 @@ describe("active-memory plugin", () => {
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeDefined();
|
||||
expectPrependContextResult(result);
|
||||
});
|
||||
|
||||
it("matches per-account-channel-peer direct session keys (agent:<id>:<channel>:<account>:direct:<peer>)", async () => {
|
||||
@@ -1082,7 +1093,7 @@ describe("active-memory plugin", () => {
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeDefined();
|
||||
expectPrependContextResult(result);
|
||||
});
|
||||
|
||||
it("strips :thread:<id> suffix before matching allowedChatIds (group)", async () => {
|
||||
@@ -1109,7 +1120,7 @@ describe("active-memory plugin", () => {
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeDefined();
|
||||
expectPrependContextResult(result);
|
||||
});
|
||||
|
||||
it("strips :thread:<id> suffix before matching deniedChatIds (direct)", async () => {
|
||||
@@ -1630,13 +1641,13 @@ describe("active-memory plugin", () => {
|
||||
const deprecationMessage = warnCalls
|
||||
.map(([first]) => (typeof first === "string" ? first : ""))
|
||||
.find((message) => message.includes("config.modelFallbackPolicy is deprecated"));
|
||||
expect(deprecationMessage).toBeDefined();
|
||||
const message = requireNonEmptyString(deprecationMessage, "deprecation warning missing");
|
||||
// Positive: the warning describes chain-resolution last-resort behavior.
|
||||
expect(deprecationMessage).toContain("chain-resolution");
|
||||
expect(deprecationMessage).toContain("last-resort");
|
||||
expect(message).toContain("chain-resolution");
|
||||
expect(message).toContain("last-resort");
|
||||
// Negative: the warning explicitly disclaims runtime failover, since
|
||||
// that's the wrong mental model the previous wording invited.
|
||||
expect(deprecationMessage).toMatch(/NOT a runtime failover/i);
|
||||
expect(message).toMatch(/NOT a runtime failover/i);
|
||||
});
|
||||
|
||||
it("does not use a built-in fallback model even when default-remote is configured", async () => {
|
||||
@@ -1760,9 +1771,9 @@ describe("active-memory plugin", () => {
|
||||
const debugLine = entries?.[0]?.lines.find((line) =>
|
||||
line.startsWith("🔎 Active Memory Debug:"),
|
||||
);
|
||||
expect(debugLine).toBeDefined();
|
||||
expect(debugLine).toContain("backend=qmd");
|
||||
expect(debugLine).toContain("hits=3");
|
||||
const line = requireNonEmptyString(debugLine, "active memory debug line missing");
|
||||
expect(line).toContain("backend=qmd");
|
||||
expect(line).toContain("hits=3");
|
||||
});
|
||||
|
||||
it("replaces stale structured active-memory lines on a later empty run", async () => {
|
||||
@@ -2033,6 +2044,7 @@ describe("active-memory plugin", () => {
|
||||
it("returns partial transcript text on timeout when transcripts are temporary by default", async () => {
|
||||
__testing.setMinimumTimeoutMsForTests(1);
|
||||
__testing.setSetupGraceTimeoutMsForTests(0);
|
||||
__testing.setTimeoutPartialDataGraceMsForTests(100);
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
timeoutMs: 250,
|
||||
@@ -2299,9 +2311,9 @@ describe("active-memory plugin", () => {
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.length).toBeLessThanOrEqual(128);
|
||||
expect(result).toContain("alpha beta gamma");
|
||||
const partialText = requireNonEmptyString(result, "partial assistant text missing");
|
||||
expect(partialText.length).toBeLessThanOrEqual(128);
|
||||
expect(partialText).toContain("alpha beta gamma");
|
||||
expect(readFileSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2953,9 +2965,9 @@ describe("active-memory plugin", () => {
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
const startLine = infoLines.find((line: string) => line.includes(" start timeoutMs="));
|
||||
expect(startLine).toBeTruthy();
|
||||
expect(startLine && startLine.length < 500).toBe(true);
|
||||
expect(startLine).toContain("...");
|
||||
const line = requireNonEmptyString(startLine, "active memory start log line missing");
|
||||
expect(line.length).toBeLessThan(500);
|
||||
expect(line).toContain("...");
|
||||
});
|
||||
|
||||
it("uses a canonical agent session key when only sessionId is available", async () => {
|
||||
|
||||
@@ -539,9 +539,10 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
const discovery = pluginJson.configSchema?.properties?.discovery;
|
||||
const guardrail = pluginJson.configSchema?.properties?.guardrail;
|
||||
|
||||
expect(discovery).toBeDefined();
|
||||
expect(discovery.type).toBe("object");
|
||||
expect(discovery.additionalProperties).toBe(false);
|
||||
expect(discovery).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
});
|
||||
expect(discovery.properties.enabled).toEqual({ type: "boolean" });
|
||||
expect(discovery.properties.region).toEqual({ type: "string" });
|
||||
expect(discovery.properties.providerFilter).toEqual({
|
||||
@@ -561,9 +562,10 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
minimum: 1,
|
||||
});
|
||||
|
||||
expect(guardrail).toBeDefined();
|
||||
expect(guardrail.type).toBe("object");
|
||||
expect(guardrail.additionalProperties).toBe(false);
|
||||
expect(guardrail).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
// Required fields
|
||||
expect(guardrail.required).toEqual(["guardrailIdentifier", "guardrailVersion"]);
|
||||
|
||||
@@ -48,7 +48,7 @@ function createModelRegistry(models: ProviderRuntimeModel[]) {
|
||||
}
|
||||
|
||||
describe("anthropic provider replay hooks", () => {
|
||||
it("registers the claude-cli backend", async () => {
|
||||
it("registers the claude-cli backend", () => {
|
||||
const captured = capturePluginRegistration({ register: anthropicPlugin.register });
|
||||
|
||||
expect(captured.cliBackends).toContainEqual(
|
||||
@@ -383,9 +383,11 @@ describe("anthropic provider replay hooks", () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const cliAuth = provider.auth.find((entry) => entry.id === "cli");
|
||||
|
||||
expect(cliAuth).toBeDefined();
|
||||
if (!cliAuth) {
|
||||
throw new Error("expected Anthropic CLI auth method");
|
||||
}
|
||||
|
||||
const result = await cliAuth?.run({
|
||||
const result = await cliAuth.run({
|
||||
config: {},
|
||||
} as never);
|
||||
|
||||
|
||||
@@ -88,8 +88,7 @@ describe("anthropic stream wrappers", () => {
|
||||
it("strips context-1m for Claude CLI or legacy token auth and warns", () => {
|
||||
const warn = vi.spyOn(__testing.log, "warn").mockImplementation(() => undefined);
|
||||
const headers = runWrapper("sk-ant-oat01-123");
|
||||
expect(headers?.["anthropic-beta"]).toBeDefined();
|
||||
expect(headers?.["anthropic-beta"]).toContain(OAUTH_BETA);
|
||||
expect(headers?.["anthropic-beta"]).toEqual(expect.stringContaining(OAUTH_BETA));
|
||||
expect(headers?.["anthropic-beta"]).not.toContain(CONTEXT_1M_BETA);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -97,8 +96,7 @@ describe("anthropic stream wrappers", () => {
|
||||
it("keeps context-1m for API key auth", () => {
|
||||
const warn = vi.spyOn(__testing.log, "warn").mockImplementation(() => undefined);
|
||||
const headers = runWrapper("sk-ant-api-123");
|
||||
expect(headers?.["anthropic-beta"]).toBeDefined();
|
||||
expect(headers?.["anthropic-beta"]).toContain(CONTEXT_1M_BETA);
|
||||
expect(headers?.["anthropic-beta"]).toEqual(expect.stringContaining(CONTEXT_1M_BETA));
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -165,80 +163,70 @@ describe("createAnthropicThinkingPrefillWrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAnthropicFastModeWrapper", () => {
|
||||
function runFastModeWrapper(params: {
|
||||
apiKey?: string;
|
||||
provider?: string;
|
||||
api?: string;
|
||||
baseUrl?: string;
|
||||
enabled?: boolean;
|
||||
}): Record<string, unknown> | undefined {
|
||||
return runPayloadWrapper(params, (base) =>
|
||||
createAnthropicFastModeWrapper(base, params.enabled ?? true),
|
||||
);
|
||||
}
|
||||
type ServiceTierWrapperParams = {
|
||||
apiKey?: string;
|
||||
provider?: string;
|
||||
api?: string;
|
||||
enabled?: boolean;
|
||||
serviceTier?: "auto" | "standard_only";
|
||||
};
|
||||
|
||||
it("does not inject service_tier for OAuth token", () => {
|
||||
const payload = runFastModeWrapper({ apiKey: "sk-ant-oat01-test-token" });
|
||||
const serviceTierWrapperCases: Array<{
|
||||
name: string;
|
||||
run: (params: ServiceTierWrapperParams) => Record<string, unknown> | undefined;
|
||||
}> = [
|
||||
{
|
||||
name: "fast mode",
|
||||
run: (params) =>
|
||||
runPayloadWrapper(params, (base) =>
|
||||
createAnthropicFastModeWrapper(base, params.enabled ?? true),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "explicit service tier",
|
||||
run: (params) =>
|
||||
runPayloadWrapper(params, (base) =>
|
||||
createAnthropicServiceTierWrapper(base, params.serviceTier ?? "auto"),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
describe("Anthropic service_tier payload wrappers", () => {
|
||||
it.each(serviceTierWrapperCases)("$name skips service_tier for OAuth token", ({ run }) => {
|
||||
const payload = run({ apiKey: "sk-ant-oat01-test-token" });
|
||||
expect(payload?.service_tier).toBeUndefined();
|
||||
});
|
||||
|
||||
it("injects service_tier for regular API keys", () => {
|
||||
const payload = runFastModeWrapper({ apiKey: "sk-ant-api03-test-key" });
|
||||
it.each(serviceTierWrapperCases)("$name injects service_tier for regular API keys", ({ run }) => {
|
||||
const payload = run({ apiKey: "sk-ant-api03-test-key" });
|
||||
expect(payload?.service_tier).toBe("auto");
|
||||
});
|
||||
|
||||
it("injects service_tier=standard_only when disabled for API keys", () => {
|
||||
const payload = runFastModeWrapper({ apiKey: "sk-ant-api03-test-key", enabled: false });
|
||||
it.each(serviceTierWrapperCases)(
|
||||
"$name does not inject service_tier for non-anthropic provider",
|
||||
({ run }) => {
|
||||
const payload = run({
|
||||
apiKey: "sk-ant-api03-test-key",
|
||||
provider: "openai",
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(payload?.service_tier).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("fast mode injects service_tier=standard_only when disabled for API keys", () => {
|
||||
const payload = serviceTierWrapperCases[0].run({
|
||||
apiKey: "sk-ant-api03-test-key",
|
||||
enabled: false,
|
||||
});
|
||||
expect(payload?.service_tier).toBe("standard_only");
|
||||
});
|
||||
|
||||
it("does not inject service_tier for non-anthropic provider", () => {
|
||||
const payload = runFastModeWrapper({
|
||||
apiKey: "sk-ant-api03-test-key",
|
||||
provider: "openai",
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(payload?.service_tier).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAnthropicServiceTierWrapper", () => {
|
||||
function runServiceTierWrapper(params: {
|
||||
apiKey?: string;
|
||||
provider?: string;
|
||||
api?: string;
|
||||
serviceTier?: "auto" | "standard_only";
|
||||
}): Record<string, unknown> | undefined {
|
||||
return runPayloadWrapper(params, (base) =>
|
||||
createAnthropicServiceTierWrapper(base, params.serviceTier ?? "auto"),
|
||||
);
|
||||
}
|
||||
|
||||
it("does not inject service_tier for OAuth token", () => {
|
||||
const payload = runServiceTierWrapper({ apiKey: "sk-ant-oat01-test-token" });
|
||||
expect(payload?.service_tier).toBeUndefined();
|
||||
});
|
||||
|
||||
it("injects service_tier for regular API keys", () => {
|
||||
const payload = runServiceTierWrapper({ apiKey: "sk-ant-api03-test-key" });
|
||||
expect(payload?.service_tier).toBe("auto");
|
||||
});
|
||||
|
||||
it("injects service_tier=standard_only for regular API keys", () => {
|
||||
const payload = runServiceTierWrapper({
|
||||
it("explicit service tier injects service_tier=standard_only for regular API keys", () => {
|
||||
const payload = serviceTierWrapperCases[1].run({
|
||||
apiKey: "sk-ant-api03-test-key",
|
||||
serviceTier: "standard_only",
|
||||
});
|
||||
expect(payload?.service_tier).toBe("standard_only");
|
||||
});
|
||||
|
||||
it("does not inject service_tier for non-anthropic provider", () => {
|
||||
const payload = runServiceTierWrapper({
|
||||
apiKey: "sk-ant-api03-test-key",
|
||||
provider: "openai",
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(payload?.service_tier).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -775,8 +775,10 @@ describe("gateway bonjour advertiser", () => {
|
||||
const disableLog = logger.warn.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("disabling advertiser after"),
|
||||
);
|
||||
expect(disableLog).toBeDefined();
|
||||
expect(String(disableLog?.[0])).toMatch(/restarts within \d+ minutes/);
|
||||
if (!disableLog) {
|
||||
throw new Error("expected advertiser disable warning after repeated restarts");
|
||||
}
|
||||
expect(String(disableLog[0])).toMatch(/restarts within \d+ minutes/);
|
||||
|
||||
const advertiseCallsAtDisable = advertise.mock.calls.length;
|
||||
const createServiceCallsAtDisable = createService.mock.calls.length;
|
||||
|
||||
@@ -211,7 +211,7 @@ describe("cdp.helpers", () => {
|
||||
});
|
||||
|
||||
describe("fetchBrowserJson loopback auth (bridge auth registry)", () => {
|
||||
it("falls back to per-port bridge auth when config auth is not available", async () => {
|
||||
it("falls back to per-port bridge auth when config auth is not available", () => {
|
||||
const port = 18765;
|
||||
const getBridgeAuthForPort = vi.fn((candidate: number) =>
|
||||
candidate === port ? { token: "registry-token" } : undefined,
|
||||
|
||||
@@ -455,9 +455,11 @@ describe("openCdpWebSocket option handling", () => {
|
||||
it("clamps a non-finite handshakeTimeoutMs to the default", () => {
|
||||
// Exercises the Number.isFinite false side of the handshake-timeout
|
||||
// ternary in openCdpWebSocket.
|
||||
const ws = openCdpWebSocket("ws://127.0.0.1:1/devtools/browser/X", {
|
||||
const url = "ws://127.0.0.1:1/devtools/browser/X";
|
||||
const ws = openCdpWebSocket(url, {
|
||||
handshakeTimeoutMs: Number.NaN,
|
||||
});
|
||||
expect(ws.url).toBe(url);
|
||||
// Ensure we don't leak the socket even though we never await it.
|
||||
ws.once("error", () => {});
|
||||
ws.close();
|
||||
@@ -466,9 +468,11 @@ describe("openCdpWebSocket option handling", () => {
|
||||
it("honours an explicit, finite handshakeTimeoutMs", () => {
|
||||
// Exercises the truthy side of the handshake-timeout ternary: both
|
||||
// typeof === "number" AND Number.isFinite must be true.
|
||||
const ws = openCdpWebSocket("ws://127.0.0.1:1/devtools/browser/X", {
|
||||
const url = "ws://127.0.0.1:1/devtools/browser/X";
|
||||
const ws = openCdpWebSocket(url, {
|
||||
handshakeTimeoutMs: 500,
|
||||
});
|
||||
expect(ws.url).toBe(url);
|
||||
ws.once("error", () => {});
|
||||
ws.close();
|
||||
});
|
||||
@@ -476,16 +480,20 @@ describe("openCdpWebSocket option handling", () => {
|
||||
it("omits the direct-loopback agent for non-loopback targets", () => {
|
||||
// Exercises the falsy side of `agent ? { agent } : {}` — the loopback
|
||||
// agent helper returns undefined for non-loopback hosts.
|
||||
const ws = openCdpWebSocket("ws://93.184.216.34:9222/devtools/browser/X");
|
||||
const url = "ws://93.184.216.34:9222/devtools/browser/X";
|
||||
const ws = openCdpWebSocket(url);
|
||||
expect(ws.url).toBe(url);
|
||||
ws.once("error", () => {});
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("injects custom headers when opts.headers is a non-empty object", () => {
|
||||
// Exercises the truthy side of `Object.keys(headers).length ? ... : {}`.
|
||||
const ws = openCdpWebSocket("ws://127.0.0.1:1/devtools/browser/X", {
|
||||
const url = "ws://127.0.0.1:1/devtools/browser/X";
|
||||
const ws = openCdpWebSocket(url, {
|
||||
headers: { "X-Custom": "abc" },
|
||||
});
|
||||
expect(ws.url).toBe(url);
|
||||
ws.once("error", () => {});
|
||||
ws.close();
|
||||
});
|
||||
|
||||
@@ -94,18 +94,25 @@ beforeEach(() => {
|
||||
mockState.naturalViewport = { w: 1920, h: 1080, dpr: 1 };
|
||||
});
|
||||
|
||||
function requireSentMessage(method: string) {
|
||||
const message = sentMessages.find((m) => m.method === method);
|
||||
if (!message) {
|
||||
throw new Error(`expected ${method} CDP message`);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
describe("CDP screenshot params", () => {
|
||||
it("viewport screenshot omits fromSurface and captureBeyondViewport", async () => {
|
||||
await captureScreenshot({ wsUrl: "ws://localhost:9222/devtools/page/X", format: "png" });
|
||||
|
||||
const call = sentMessages.find((m) => m.method === "Page.captureScreenshot");
|
||||
expect(call).toBeDefined();
|
||||
expect(call!.params).toMatchObject({
|
||||
const call = requireSentMessage("Page.captureScreenshot");
|
||||
expect(call.params).toMatchObject({
|
||||
format: "png",
|
||||
});
|
||||
expect(call!.params).not.toHaveProperty("fromSurface");
|
||||
expect(call!.params).not.toHaveProperty("captureBeyondViewport");
|
||||
expect(call!.params).not.toHaveProperty("clip");
|
||||
expect(call.params).not.toHaveProperty("fromSurface");
|
||||
expect(call.params).not.toHaveProperty("captureBeyondViewport");
|
||||
expect(call.params).not.toHaveProperty("clip");
|
||||
|
||||
const emulationCalls = sentMessages.filter(
|
||||
(m) => m.method === "Emulation.setDeviceMetricsOverride",
|
||||
@@ -152,10 +159,9 @@ describe("CDP screenshot params", () => {
|
||||
});
|
||||
|
||||
// Clear is called first in the finally block
|
||||
const clearCall = sentMessages.find((m) => m.method === "Emulation.clearDeviceMetricsOverride");
|
||||
expect(clearCall).toBeDefined();
|
||||
const captureCall = sentMessages.find((m) => m.method === "Page.captureScreenshot");
|
||||
expect(captureCall?.params).toMatchObject({ captureBeyondViewport: true });
|
||||
requireSentMessage("Emulation.clearDeviceMetricsOverride");
|
||||
const captureCall = requireSentMessage("Page.captureScreenshot");
|
||||
expect(captureCall.params).toMatchObject({ captureBeyondViewport: true });
|
||||
|
||||
// Viewport drifted after clear → re-apply saved dimensions
|
||||
expect(secondSetCall.params).toMatchObject({
|
||||
@@ -183,17 +189,15 @@ describe("CDP screenshot params", () => {
|
||||
// Only the expand call — no re-apply after clear
|
||||
expect(setCalls).toHaveLength(1);
|
||||
|
||||
const clearCall = sentMessages.find((m) => m.method === "Emulation.clearDeviceMetricsOverride");
|
||||
expect(clearCall).toBeDefined();
|
||||
requireSentMessage("Emulation.clearDeviceMetricsOverride");
|
||||
});
|
||||
|
||||
it("fullPage viewport dimensions never shrink below current innerWidth/Height", async () => {
|
||||
await captureScreenshot({ wsUrl: "ws://localhost:9222/devtools/page/X", fullPage: true });
|
||||
|
||||
const expandCall = sentMessages.find((m) => m.method === "Emulation.setDeviceMetricsOverride");
|
||||
expect(expandCall).toBeDefined();
|
||||
expect(Number(expandCall!.params!.width)).toBeGreaterThanOrEqual(800);
|
||||
expect(Number(expandCall!.params!.height)).toBeGreaterThanOrEqual(600);
|
||||
const expandCall = requireSentMessage("Emulation.setDeviceMetricsOverride");
|
||||
expect(Number(expandCall.params?.width)).toBeGreaterThanOrEqual(800);
|
||||
expect(Number(expandCall.params?.height)).toBeGreaterThanOrEqual(600);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("browser default executable detection", () => {
|
||||
vi.mocked(os.homedir).mockReturnValue("/Users/test");
|
||||
});
|
||||
|
||||
it("prefers default Chromium browser on macOS", async () => {
|
||||
it("prefers default Chromium browser on macOS", () => {
|
||||
mockMacDefaultBrowser("com.google.Chrome", "/Applications/Google Chrome.app");
|
||||
mockChromeExecutableExists();
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("browser default executable detection", () => {
|
||||
expect(exe?.kind).toBe("chrome");
|
||||
});
|
||||
|
||||
it("detects Edge via LaunchServices bundle ID (com.microsoft.edgemac)", async () => {
|
||||
it("detects Edge via LaunchServices bundle ID (com.microsoft.edgemac)", () => {
|
||||
const edgeExecutablePath = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge";
|
||||
// macOS LaunchServices registers Edge as "com.microsoft.edgemac", which
|
||||
// differs from the CFBundleIdentifier "com.microsoft.Edge" in the app's
|
||||
@@ -127,7 +127,7 @@ describe("browser default executable detection", () => {
|
||||
expect(exe?.kind).toBe("edge");
|
||||
});
|
||||
|
||||
it("falls back to Chrome when Edge LaunchServices lookup has no app path", async () => {
|
||||
it("falls back to Chrome when Edge LaunchServices lookup has no app path", () => {
|
||||
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
||||
const argsStr = Array.isArray(args) ? args.join(" ") : "";
|
||||
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
|
||||
@@ -150,7 +150,7 @@ describe("browser default executable detection", () => {
|
||||
expect(exe?.kind).toBe("chrome");
|
||||
});
|
||||
|
||||
it("falls back when default browser is non-Chromium on macOS", async () => {
|
||||
it("falls back when default browser is non-Chromium on macOS", () => {
|
||||
mockMacDefaultBrowser("com.apple.Safari");
|
||||
mockChromeExecutableExists();
|
||||
|
||||
|
||||
@@ -340,6 +340,7 @@ describe("chrome.ts internal", () => {
|
||||
extraArgs: [],
|
||||
} as unknown as ResolvedBrowserConfig;
|
||||
const running = await launchOpenClawChrome(resolved, profile);
|
||||
expect(running.pid).toBe(4242);
|
||||
running.proc.kill?.("SIGTERM");
|
||||
},
|
||||
});
|
||||
@@ -925,6 +926,7 @@ describe("chrome.ts internal", () => {
|
||||
extraArgs: [],
|
||||
} as unknown as ResolvedBrowserConfig;
|
||||
const running = await launchOpenClawChrome(resolved, profile);
|
||||
expect(running.pid).toBe(4242);
|
||||
running.proc.kill?.("SIGTERM");
|
||||
},
|
||||
});
|
||||
@@ -969,6 +971,7 @@ describe("chrome.ts internal", () => {
|
||||
extraArgs: [],
|
||||
} as unknown as ResolvedBrowserConfig;
|
||||
const running = await launchOpenClawChrome(resolved, profile);
|
||||
expect(running.pid).toBe(4242);
|
||||
running.proc.kill?.("SIGTERM");
|
||||
},
|
||||
});
|
||||
@@ -1106,6 +1109,8 @@ describe("chrome.ts internal", () => {
|
||||
extraArgs: [],
|
||||
} as unknown as ResolvedBrowserConfig;
|
||||
const running = await launchOpenClawChrome(resolved, profile);
|
||||
expect(spawnCount).toBe(2);
|
||||
expect(running.proc).toBe(runtimeProc);
|
||||
running.proc.kill?.("SIGTERM");
|
||||
},
|
||||
});
|
||||
@@ -1160,6 +1165,8 @@ describe("chrome.ts internal", () => {
|
||||
extraArgs: [],
|
||||
} as unknown as ResolvedBrowserConfig;
|
||||
const running = await launchOpenClawChrome(resolved, profile);
|
||||
expect(callCount).toBe(2);
|
||||
expect(running.proc).toBe(runtimeProc);
|
||||
running.proc.kill?.("SIGTERM");
|
||||
},
|
||||
});
|
||||
@@ -1213,6 +1220,7 @@ describe("chrome.ts internal", () => {
|
||||
extraArgs: [],
|
||||
} as unknown as ResolvedBrowserConfig;
|
||||
const running = await launchOpenClawChrome(resolved, profile);
|
||||
expect(running.pid).toBe(4242);
|
||||
running.proc.kill?.("SIGTERM");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,14 @@ import {
|
||||
} from "./client.js";
|
||||
|
||||
describe("browser client", () => {
|
||||
function requireSnapshotCall(calls: string[]): string {
|
||||
const call = calls.find((url) => url.includes("/snapshot?"));
|
||||
if (!call) {
|
||||
throw new Error("expected browser snapshot request");
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function stubSnapshotFetch(calls: string[]) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -85,9 +93,7 @@ describe("browser client", () => {
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, format: "ai" });
|
||||
|
||||
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
|
||||
expect(snapshotCall).toBeTruthy();
|
||||
const parsed = new URL(snapshotCall as string);
|
||||
const parsed = new URL(requireSnapshotCall(calls));
|
||||
expect(parsed.searchParams.get("labels")).toBe("1");
|
||||
expect(parsed.searchParams.get("mode")).toBe("efficient");
|
||||
});
|
||||
@@ -101,9 +107,7 @@ describe("browser client", () => {
|
||||
refs: "aria",
|
||||
});
|
||||
|
||||
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
|
||||
expect(snapshotCall).toBeTruthy();
|
||||
const parsed = new URL(snapshotCall as string);
|
||||
const parsed = new URL(requireSnapshotCall(calls));
|
||||
expect(parsed.searchParams.get("refs")).toBe("aria");
|
||||
});
|
||||
|
||||
@@ -115,9 +119,7 @@ describe("browser client", () => {
|
||||
profile: "chrome",
|
||||
});
|
||||
|
||||
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
|
||||
expect(snapshotCall).toBeTruthy();
|
||||
const parsed = new URL(snapshotCall as string);
|
||||
const parsed = new URL(requireSnapshotCall(calls));
|
||||
expect(parsed.searchParams.get("format")).toBeNull();
|
||||
expect(parsed.searchParams.get("profile")).toBe("chrome");
|
||||
});
|
||||
|
||||
@@ -13,9 +13,10 @@ describe("ensureBrowserControlAuth", () => {
|
||||
expect(result.auth.password).toBeUndefined();
|
||||
}
|
||||
|
||||
describe("trusted-proxy mode", () => {
|
||||
it("should skip auto-generation in test mode", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
it.each([
|
||||
{
|
||||
name: "trusted-proxy",
|
||||
cfg: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
@@ -25,35 +26,40 @@ describe("ensureBrowserControlAuth", () => {
|
||||
},
|
||||
trustedProxies: ["192.168.1.1"],
|
||||
},
|
||||
};
|
||||
await expectNoAutoGeneratedAuth(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("password mode", () => {
|
||||
it("should skip auto-generation in test mode", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
} satisfies OpenClawConfig,
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
cfg: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "password",
|
||||
},
|
||||
},
|
||||
};
|
||||
await expectNoAutoGeneratedAuth(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("none mode", () => {
|
||||
it("should skip auto-generation in test mode", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
} satisfies OpenClawConfig,
|
||||
},
|
||||
{
|
||||
name: "none",
|
||||
cfg: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "none",
|
||||
},
|
||||
},
|
||||
};
|
||||
await expectNoAutoGeneratedAuth(cfg);
|
||||
});
|
||||
} satisfies OpenClawConfig,
|
||||
},
|
||||
{
|
||||
name: "token",
|
||||
cfg: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
},
|
||||
])("skips auto-generation in test mode for $name mode", async ({ cfg }) => {
|
||||
await expectNoAutoGeneratedAuth(cfg);
|
||||
});
|
||||
|
||||
describe("token mode", () => {
|
||||
@@ -75,23 +81,5 @@ describe("ensureBrowserControlAuth", () => {
|
||||
expect(result.generatedToken).toBeUndefined();
|
||||
expect(result.auth.token).toBe("existing-token-123");
|
||||
});
|
||||
|
||||
it("should skip auto-generation in test environment", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await ensureBrowserControlAuth({
|
||||
cfg,
|
||||
env: { NODE_ENV: "test" },
|
||||
});
|
||||
|
||||
expect(result.generatedToken).toBeUndefined();
|
||||
expect(result.auth.token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ describeLive("browser (live): remote CDP tab persistence", () => {
|
||||
await pw.closePlaywrightBrowserConnection().catch(() => {});
|
||||
|
||||
const created = await pw.createPageViaPlaywright({ cdpUrl: CDP_URL, url: "about:blank" });
|
||||
expect(created.targetId).toEqual(expect.any(String));
|
||||
try {
|
||||
await waitFor(
|
||||
async () => {
|
||||
|
||||
@@ -331,8 +331,10 @@ describe("pw-tools-core", () => {
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(responseHandler).toBeDefined();
|
||||
responseHandler?.(resp);
|
||||
if (!responseHandler) {
|
||||
throw new Error("expected Playwright response handler");
|
||||
}
|
||||
responseHandler(resp);
|
||||
|
||||
const res = await p;
|
||||
expect(res.url).toBe("https://example.com/api/data");
|
||||
|
||||
@@ -67,6 +67,13 @@ const { resolveBrowserConfig, resolveProfile } = await import("./config.js");
|
||||
const { refreshResolvedBrowserConfigFromDisk, resolveBrowserProfileWithHotReload } =
|
||||
await import("./resolved-config-refresh.js");
|
||||
|
||||
function requireValue<T>(value: T | null | undefined, message: string): T {
|
||||
if (value == null) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
describe("server-context hot-reload profiles", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -76,7 +83,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
mockState.cachedConfig = null; // Clear simulated cache
|
||||
});
|
||||
|
||||
it("forProfile hot-reloads newly added profiles from config", async () => {
|
||||
it("forProfile hot-reloads newly added profiles from config", () => {
|
||||
// Start with only openclaw profile
|
||||
// 1. Prime the cache by calling getRuntimeConfig() first
|
||||
const cfg = getRuntimeConfig();
|
||||
@@ -117,7 +124,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
expect(profile?.cdpUrl).toBe("http://127.0.0.1:9222");
|
||||
|
||||
// 5. Verify the new profile was merged into the cached state
|
||||
expect(state.resolved.profiles.desktop).toBeDefined();
|
||||
expect(state.resolved.profiles).toHaveProperty("desktop");
|
||||
|
||||
// 6. Verify GLOBAL cache was NOT cleared - subsequent simple getRuntimeConfig() still sees STALE value
|
||||
// This confirms the fix: we read fresh config for the specific profile lookup without flushing the global cache
|
||||
@@ -125,7 +132,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
expect(stillStaleCfg.browser?.profiles?.desktop).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forProfile still throws for profiles that don't exist in fresh config", async () => {
|
||||
it("forProfile still throws for profiles that don't exist in fresh config", () => {
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const state = {
|
||||
@@ -145,7 +152,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("forProfile refreshes existing profile config after getRuntimeConfig cache updates", async () => {
|
||||
it("forProfile refreshes existing profile config after getRuntimeConfig cache updates", () => {
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const state = {
|
||||
@@ -167,7 +174,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
expect(state.resolved.profiles.openclaw?.cdpPort).toBe(19999);
|
||||
});
|
||||
|
||||
it("listProfiles refreshes config before enumerating profiles", async () => {
|
||||
it("listProfiles refreshes config before enumerating profiles", () => {
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const state = {
|
||||
@@ -188,11 +195,13 @@ describe("server-context hot-reload profiles", () => {
|
||||
expect(Object.keys(state.resolved.profiles)).toContain("desktop");
|
||||
});
|
||||
|
||||
it("marks existing runtime state for reconcile when profile invariants change", async () => {
|
||||
it("marks existing runtime state for reconcile when profile invariants change", () => {
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const openclawProfile = resolveProfile(resolved, "openclaw");
|
||||
expect(openclawProfile).toBeTruthy();
|
||||
const openclawProfile = requireValue(
|
||||
resolveProfile(resolved, "openclaw"),
|
||||
"openclaw profile missing",
|
||||
);
|
||||
const state: BrowserServerState = {
|
||||
server: null,
|
||||
port: 18791,
|
||||
@@ -201,7 +210,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
[
|
||||
"openclaw",
|
||||
{
|
||||
profile: openclawProfile!,
|
||||
profile: openclawProfile,
|
||||
running: { pid: 123 } as never,
|
||||
lastTargetId: "tab-1",
|
||||
reconcile: null,
|
||||
@@ -219,19 +228,20 @@ describe("server-context hot-reload profiles", () => {
|
||||
mode: "cached",
|
||||
});
|
||||
|
||||
const runtime = state.profiles.get("openclaw");
|
||||
expect(runtime).toBeTruthy();
|
||||
expect(runtime?.profile.cdpPort).toBe(19999);
|
||||
expect(runtime?.lastTargetId).toBeNull();
|
||||
expect(runtime?.reconcile?.reason).toContain("cdpPort");
|
||||
const runtime = requireValue(state.profiles.get("openclaw"), "openclaw runtime missing");
|
||||
expect(runtime.profile.cdpPort).toBe(19999);
|
||||
expect(runtime.lastTargetId).toBeNull();
|
||||
expect(runtime.reconcile?.reason).toContain("cdpPort");
|
||||
});
|
||||
|
||||
it("marks local managed runtime state for reconcile when profile headless changes", async () => {
|
||||
it("marks local managed runtime state for reconcile when profile headless changes", () => {
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const openclawProfile = resolveProfile(resolved, "openclaw");
|
||||
expect(openclawProfile).toBeTruthy();
|
||||
expect(openclawProfile?.headless).toBe(true);
|
||||
const openclawProfile = requireValue(
|
||||
resolveProfile(resolved, "openclaw"),
|
||||
"openclaw profile missing",
|
||||
);
|
||||
expect(openclawProfile.headless).toBe(true);
|
||||
const state: BrowserServerState = {
|
||||
server: null,
|
||||
port: 18791,
|
||||
@@ -240,7 +250,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
[
|
||||
"openclaw",
|
||||
{
|
||||
profile: openclawProfile!,
|
||||
profile: openclawProfile,
|
||||
running: { pid: 123 } as never,
|
||||
lastTargetId: "tab-1",
|
||||
reconcile: null,
|
||||
@@ -262,14 +272,13 @@ describe("server-context hot-reload profiles", () => {
|
||||
mode: "cached",
|
||||
});
|
||||
|
||||
const runtime = state.profiles.get("openclaw");
|
||||
expect(runtime).toBeTruthy();
|
||||
expect(runtime?.profile.headless).toBe(false);
|
||||
expect(runtime?.lastTargetId).toBeNull();
|
||||
expect(runtime?.reconcile?.reason).toContain("headless");
|
||||
const runtime = requireValue(state.profiles.get("openclaw"), "openclaw runtime missing");
|
||||
expect(runtime.profile.headless).toBe(false);
|
||||
expect(runtime.lastTargetId).toBeNull();
|
||||
expect(runtime.reconcile?.reason).toContain("headless");
|
||||
});
|
||||
|
||||
it("marks local managed runtime state for reconcile when profile executablePath changes", async () => {
|
||||
it("marks local managed runtime state for reconcile when profile executablePath changes", () => {
|
||||
mockState.cfgProfiles.openclaw = {
|
||||
cdpPort: 18800,
|
||||
color: "#FF4500",
|
||||
@@ -278,9 +287,11 @@ describe("server-context hot-reload profiles", () => {
|
||||
mockState.cachedConfig = null;
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const openclawProfile = resolveProfile(resolved, "openclaw");
|
||||
expect(openclawProfile).toBeTruthy();
|
||||
expect(openclawProfile?.executablePath).toBe("/usr/bin/chrome-old");
|
||||
const openclawProfile = requireValue(
|
||||
resolveProfile(resolved, "openclaw"),
|
||||
"openclaw profile missing",
|
||||
);
|
||||
expect(openclawProfile.executablePath).toBe("/usr/bin/chrome-old");
|
||||
const state: BrowserServerState = {
|
||||
server: null,
|
||||
port: 18791,
|
||||
@@ -289,7 +300,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
[
|
||||
"openclaw",
|
||||
{
|
||||
profile: openclawProfile!,
|
||||
profile: openclawProfile,
|
||||
running: { pid: 123 } as never,
|
||||
lastTargetId: "tab-1",
|
||||
reconcile: null,
|
||||
@@ -311,14 +322,13 @@ describe("server-context hot-reload profiles", () => {
|
||||
mode: "cached",
|
||||
});
|
||||
|
||||
const runtime = state.profiles.get("openclaw");
|
||||
expect(runtime).toBeTruthy();
|
||||
expect(runtime?.profile.executablePath).toBe("/usr/bin/chrome-new");
|
||||
expect(runtime?.lastTargetId).toBeNull();
|
||||
expect(runtime?.reconcile?.reason).toContain("executablePath");
|
||||
const runtime = requireValue(state.profiles.get("openclaw"), "openclaw runtime missing");
|
||||
expect(runtime.profile.executablePath).toBe("/usr/bin/chrome-new");
|
||||
expect(runtime.lastTargetId).toBeNull();
|
||||
expect(runtime.reconcile?.reason).toContain("executablePath");
|
||||
});
|
||||
|
||||
it("does not reconcile existing-session runtime when only headless changes", async () => {
|
||||
it("does not reconcile existing-session runtime when only headless changes", () => {
|
||||
mockState.cfgProfiles.remote = {
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
color: "#0066CC",
|
||||
@@ -328,11 +338,13 @@ describe("server-context hot-reload profiles", () => {
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const remoteProfile = resolveProfile(resolved, "remote");
|
||||
expect(remoteProfile).toBeTruthy();
|
||||
expect(remoteProfile?.driver).toBe("existing-session");
|
||||
expect(remoteProfile?.attachOnly).toBe(true);
|
||||
expect(remoteProfile?.headless).toBe(true);
|
||||
const remoteProfile = requireValue(
|
||||
resolveProfile(resolved, "remote"),
|
||||
"remote profile missing",
|
||||
);
|
||||
expect(remoteProfile.driver).toBe("existing-session");
|
||||
expect(remoteProfile.attachOnly).toBe(true);
|
||||
expect(remoteProfile.headless).toBe(true);
|
||||
|
||||
const state: BrowserServerState = {
|
||||
server: null,
|
||||
@@ -342,7 +354,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
[
|
||||
"remote",
|
||||
{
|
||||
profile: remoteProfile!,
|
||||
profile: remoteProfile,
|
||||
running: { pid: 456 } as never,
|
||||
lastTargetId: "tab-remote",
|
||||
reconcile: null,
|
||||
@@ -365,15 +377,14 @@ describe("server-context hot-reload profiles", () => {
|
||||
mode: "cached",
|
||||
});
|
||||
|
||||
const runtime = state.profiles.get("remote");
|
||||
expect(runtime).toBeTruthy();
|
||||
expect(runtime?.profile.driver).toBe("existing-session");
|
||||
expect(runtime?.profile.headless).toBe(false);
|
||||
expect(runtime?.lastTargetId).toBe("tab-remote");
|
||||
expect(runtime?.reconcile).toBeNull();
|
||||
const runtime = requireValue(state.profiles.get("remote"), "remote runtime missing");
|
||||
expect(runtime.profile.driver).toBe("existing-session");
|
||||
expect(runtime.profile.headless).toBe(false);
|
||||
expect(runtime.lastTargetId).toBe("tab-remote");
|
||||
expect(runtime.reconcile).toBeNull();
|
||||
});
|
||||
|
||||
it("does not reconcile remote cdp runtime when only headless changes", async () => {
|
||||
it("does not reconcile remote cdp runtime when only headless changes", () => {
|
||||
mockState.cfgProfiles.remote = {
|
||||
cdpUrl: "http://10.0.0.42:9222",
|
||||
color: "#0066CC",
|
||||
@@ -382,12 +393,14 @@ describe("server-context hot-reload profiles", () => {
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const remoteProfile = resolveProfile(resolved, "remote");
|
||||
expect(remoteProfile).toBeTruthy();
|
||||
expect(remoteProfile?.driver).toBe("openclaw");
|
||||
expect(remoteProfile?.attachOnly).toBe(false);
|
||||
expect(remoteProfile?.cdpIsLoopback).toBe(false);
|
||||
expect(remoteProfile?.headless).toBe(true);
|
||||
const remoteProfile = requireValue(
|
||||
resolveProfile(resolved, "remote"),
|
||||
"remote profile missing",
|
||||
);
|
||||
expect(remoteProfile.driver).toBe("openclaw");
|
||||
expect(remoteProfile.attachOnly).toBe(false);
|
||||
expect(remoteProfile.cdpIsLoopback).toBe(false);
|
||||
expect(remoteProfile.headless).toBe(true);
|
||||
|
||||
const state: BrowserServerState = {
|
||||
server: null,
|
||||
@@ -397,7 +410,7 @@ describe("server-context hot-reload profiles", () => {
|
||||
[
|
||||
"remote",
|
||||
{
|
||||
profile: remoteProfile!,
|
||||
profile: remoteProfile,
|
||||
running: { pid: 789 } as never,
|
||||
lastTargetId: "tab-remote-cdp",
|
||||
reconcile: null,
|
||||
@@ -419,12 +432,11 @@ describe("server-context hot-reload profiles", () => {
|
||||
mode: "cached",
|
||||
});
|
||||
|
||||
const runtime = state.profiles.get("remote");
|
||||
expect(runtime).toBeTruthy();
|
||||
expect(runtime?.profile.driver).toBe("openclaw");
|
||||
expect(runtime?.profile.cdpIsLoopback).toBe(false);
|
||||
expect(runtime?.profile.headless).toBe(false);
|
||||
expect(runtime?.lastTargetId).toBe("tab-remote-cdp");
|
||||
expect(runtime?.reconcile).toBeNull();
|
||||
const runtime = requireValue(state.profiles.get("remote"), "remote runtime missing");
|
||||
expect(runtime.profile.driver).toBe("openclaw");
|
||||
expect(runtime.profile.cdpIsLoopback).toBe(false);
|
||||
expect(runtime.profile.headless).toBe(false);
|
||||
expect(runtime.lastTargetId).toBe("tab-remote-cdp");
|
||||
expect(runtime.reconcile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,9 +17,11 @@ describe("browser manage start timeout option", () => {
|
||||
await program.parseAsync(["browser", "--timeout", "60000", "start"], { from: "user" });
|
||||
|
||||
const startCall = findBrowserManageCall("/start");
|
||||
expect(startCall).toBeDefined();
|
||||
expect(startCall?.[0]).toMatchObject({ timeout: "60000" });
|
||||
expect(startCall?.[2]).toBeUndefined();
|
||||
if (!startCall) {
|
||||
throw new Error("expected browser /start call");
|
||||
}
|
||||
expect(startCall[0]).toMatchObject({ timeout: "60000" });
|
||||
expect(startCall[2]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes headless=true for browser start --headless", async () => {
|
||||
|
||||
@@ -46,7 +46,6 @@ describe("browser state option collisions", () => {
|
||||
|
||||
const getLastRequest = () => {
|
||||
const call = mocks.callBrowserRequest.mock.calls.at(-1);
|
||||
expect(call).toBeDefined();
|
||||
if (!call) {
|
||||
throw new Error("expected browser request call");
|
||||
}
|
||||
@@ -101,9 +100,7 @@ describe("browser state option collisions", () => {
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
const call = mocks.callBrowserRequest.mock.calls.at(-1);
|
||||
expect(call).toBeDefined();
|
||||
const request = call![1] as { body?: { cookie?: { url?: string } } };
|
||||
const request = getLastRequest() as { body?: { cookie?: { url?: string } } };
|
||||
expect(request.body?.cookie?.url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
@@ -113,9 +110,7 @@ describe("browser state option collisions", () => {
|
||||
["browser", "--url", "https://inherited.example.com", "cookies", "set", "session", "abc"],
|
||||
{ from: "user" },
|
||||
);
|
||||
const call = mocks.callBrowserRequest.mock.calls.at(-1);
|
||||
expect(call).toBeDefined();
|
||||
const request = call![1] as { body?: { cookie?: { url?: string } } };
|
||||
const request = getLastRequest() as { body?: { cookie?: { url?: string } } };
|
||||
expect(request.body?.cookie?.url).toBe("https://inherited.example.com");
|
||||
});
|
||||
|
||||
|
||||
@@ -72,8 +72,10 @@ describe("registerBrowserCli lazy browser subcommands", () => {
|
||||
expect(browser?.commands.map((command) => command.name())).toContain("status");
|
||||
expect(browser?.commands.map((command) => command.name())).toContain("snapshot");
|
||||
const doctor = browser?.commands.find((command) => command.name() === "doctor");
|
||||
expect(doctor).toBeDefined();
|
||||
expect(doctor?.options.map((option) => option.long)).toContain("--deep");
|
||||
if (!doctor) {
|
||||
throw new Error("expected browser doctor command placeholder");
|
||||
}
|
||||
expect(doctor.options.map((option) => option.long)).toContain("--deep");
|
||||
expect(manageMocks.registerBrowserManageCommands).not.toHaveBeenCalled();
|
||||
expect(inspectMocks.registerBrowserInspectCommands).not.toHaveBeenCalled();
|
||||
expect(actionInputMocks.registerBrowserActionInputCommands).not.toHaveBeenCalled();
|
||||
|
||||
@@ -70,8 +70,12 @@ describe("canvas a2ui copy", () => {
|
||||
|
||||
await copyA2uiAssets({ srcDir, outDir });
|
||||
|
||||
await expect(fs.stat(path.join(outDir, "index.html"))).resolves.toBeTruthy();
|
||||
await expect(fs.stat(path.join(outDir, "a2ui.bundle.js"))).resolves.toBeTruthy();
|
||||
await expect(fs.readFile(path.join(outDir, "index.html"), "utf8")).resolves.toBe(
|
||||
"<html></html>",
|
||||
);
|
||||
await expect(fs.readFile(path.join(outDir, "a2ui.bundle.js"), "utf8")).resolves.toBe(
|
||||
"console.log(1);",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,7 +333,9 @@ describe("canvas host", () => {
|
||||
|
||||
try {
|
||||
const watcher = watcherState.watchers[watcherStart];
|
||||
expect(watcher).toBeTruthy();
|
||||
if (!watcher) {
|
||||
throw new Error("expected Canvas host watcher");
|
||||
}
|
||||
const upgraded = handler.handleUpgrade(
|
||||
{ url: CANVAS_WS_PATH } as IncomingMessage,
|
||||
{} as Duplex,
|
||||
@@ -342,12 +344,14 @@ describe("canvas host", () => {
|
||||
expect(upgraded).toBe(true);
|
||||
expect(TrackingWebSocketServerClass.latestInstance?.connectionCount).toBe(1);
|
||||
const ws = TrackingWebSocketServerClass.latestSocket;
|
||||
expect(ws).toBeTruthy();
|
||||
if (!ws) {
|
||||
throw new Error("expected Canvas host websocket");
|
||||
}
|
||||
|
||||
await fs.writeFile(index, "<html><body>v2</body></html>", "utf8");
|
||||
watcher.__emit("all", "change", index);
|
||||
await reloadSent;
|
||||
expect(ws?.sent[0]).toBe("reload");
|
||||
expect(ws.sent[0]).toBe("reload");
|
||||
} finally {
|
||||
await handler.close();
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ describe("CodexAppServerClient", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not write to stdin after the child process exits", async () => {
|
||||
it("does not write to stdin after the child process exits", () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
|
||||
|
||||
@@ -784,14 +784,18 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
|
||||
expect(manifestKeys).toEqual([...CODEX_APP_SERVER_CONFIG_KEYS].toSorted());
|
||||
for (const key of CODEX_APP_SERVER_CONFIG_KEYS) {
|
||||
expect(manifest.uiHints[`appServer.${key}`]).toBeTruthy();
|
||||
expect(manifest.uiHints[`appServer.${key}`]).toMatchObject({
|
||||
label: expect.any(String),
|
||||
});
|
||||
}
|
||||
const computerUseManifestKeys = Object.keys(
|
||||
manifest.configSchema.properties.computerUse.properties,
|
||||
).toSorted();
|
||||
expect(computerUseManifestKeys).toEqual([...CODEX_COMPUTER_USE_CONFIG_KEYS].toSorted());
|
||||
for (const key of CODEX_COMPUTER_USE_CONFIG_KEYS) {
|
||||
expect(manifest.uiHints[`computerUse.${key}`]).toBeTruthy();
|
||||
expect(manifest.uiHints[`computerUse.${key}`]).toMatchObject({
|
||||
label: expect.any(String),
|
||||
});
|
||||
}
|
||||
const codexPluginsProperties = manifest.configSchema.properties.codexPlugins;
|
||||
const codexPluginsManifestKeys = Object.keys(codexPluginsProperties.properties).toSorted();
|
||||
|
||||
@@ -911,10 +911,17 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
},
|
||||
{ signal: callController.signal },
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedSignal).toBeDefined());
|
||||
await vi.waitFor(() => {
|
||||
if (!capturedSignal) {
|
||||
throw new Error("expected dynamic tool call signal");
|
||||
}
|
||||
});
|
||||
if (!capturedSignal) {
|
||||
throw new Error("expected dynamic tool call signal");
|
||||
}
|
||||
|
||||
callController.abort(new Error("deadline"));
|
||||
expect(capturedSignal?.aborted).toBe(true);
|
||||
expect(capturedSignal.aborted).toBe(true);
|
||||
resolveTool?.(textToolResult("done"));
|
||||
|
||||
await expect(result).resolves.toEqual(expectInputText("done"));
|
||||
|
||||
@@ -57,7 +57,8 @@ describe("codex app-server session binding", () => {
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "tools-v1",
|
||||
});
|
||||
await expect(fs.stat(resolveCodexAppServerBindingPath(sessionFile))).resolves.toBeTruthy();
|
||||
const bindingStat = await fs.stat(resolveCodexAppServerBindingPath(sessionFile));
|
||||
expect(bindingStat.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips plugin app policy context with app ids as record keys", async () => {
|
||||
|
||||
@@ -73,8 +73,10 @@ function readDiagnosticsConfirmationToken(
|
||||
): string {
|
||||
const text = result.text ?? "";
|
||||
const token = new RegExp(`${escapeRegExp(commandPrefix)} confirm ([a-f0-9]{12})`).exec(text)?.[1];
|
||||
expect(token).toBeTruthy();
|
||||
return token as string;
|
||||
if (!token) {
|
||||
throw new Error(`expected ${commandPrefix} confirmation token in command output`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("codex package manifest", () => {
|
||||
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as CodexPackageManifest;
|
||||
|
||||
expect(packageJson.dependencies?.["@mariozechner/pi-coding-agent"]).toBeDefined();
|
||||
expect(packageJson.dependencies).toHaveProperty("@mariozechner/pi-coding-agent");
|
||||
expect(packageJson.dependencies?.["@openai/codex"]).toBe(
|
||||
MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION,
|
||||
);
|
||||
|
||||
@@ -31,6 +31,14 @@ function withPluginsEnabled<T>(cfg: T): T {
|
||||
} as T;
|
||||
}
|
||||
|
||||
function requireProvider<T extends { id: string }>(providers: T[], id: string): T {
|
||||
const provider = providers.find((entry) => entry.id === id);
|
||||
if (!provider) {
|
||||
throw new Error(`expected ${id} provider to be registered`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
describeLive("comfy live", () => {
|
||||
let cfg = {} as OpenClawConfig;
|
||||
let agentDir = "";
|
||||
@@ -62,9 +70,8 @@ describeLive("comfy live", () => {
|
||||
it.skipIf(!isComfyCapabilityConfigured({ cfg: cfg as never, agentDir, capability: "image" }))(
|
||||
"runs an image workflow",
|
||||
async () => {
|
||||
const provider = imageProviders.find((entry) => entry.id === "comfy");
|
||||
expect(provider).toBeDefined();
|
||||
const result = await provider!.generateImage({
|
||||
const provider = requireProvider(imageProviders, "comfy");
|
||||
const result = await provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "A tiny orange lobster icon on a clean background.",
|
||||
@@ -81,9 +88,8 @@ describeLive("comfy live", () => {
|
||||
it.skipIf(!isComfyCapabilityConfigured({ cfg: cfg as never, agentDir, capability: "video" }))(
|
||||
"runs a video workflow",
|
||||
async () => {
|
||||
const provider = videoProviders.find((entry) => entry.id === "comfy");
|
||||
expect(provider).toBeDefined();
|
||||
const result = await provider!.generateVideo({
|
||||
const provider = requireProvider(videoProviders, "comfy");
|
||||
const result = await provider.generateVideo({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "A tiny paper lobster gently waving, cinematic motion.",
|
||||
@@ -100,9 +106,8 @@ describeLive("comfy live", () => {
|
||||
it.skipIf(!isComfyCapabilityConfigured({ cfg: cfg as never, agentDir, capability: "music" }))(
|
||||
"runs a music workflow",
|
||||
async () => {
|
||||
const provider = musicProviders.find((entry) => entry.id === "comfy");
|
||||
expect(provider).toBeDefined();
|
||||
const result = await provider!.generateMusic({
|
||||
const provider = requireProvider(musicProviders, "comfy");
|
||||
const result = await provider.generateMusic({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "A gentle ambient synth loop with warm analog pads.",
|
||||
|
||||
@@ -60,6 +60,7 @@ describeLive("deepgram live", () => {
|
||||
outputFormat: "ulaw_8000",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(speech.byteLength).toBeGreaterThan(0);
|
||||
|
||||
await runRealtimeSttLiveTest({
|
||||
provider,
|
||||
|
||||
@@ -44,8 +44,7 @@ describe("DeepInfra provider config", () => {
|
||||
it("sets DeepInfra alias on the provided model ref", () => {
|
||||
const result = applyDeepInfraProviderConfig(emptyCfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
const agentModel = result.agents?.defaults?.models?.[DEEPINFRA_DEFAULT_MODEL_REF];
|
||||
expect(agentModel).toBeDefined();
|
||||
expect(agentModel?.alias).toBe("DeepInfra");
|
||||
expect(agentModel).toMatchObject({ alias: "DeepInfra" });
|
||||
});
|
||||
|
||||
it("attaches the alias to a non-default model ref when provided", () => {
|
||||
|
||||
@@ -142,18 +142,16 @@ describeLive("deepseek plugin live", () => {
|
||||
};
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const streamFn = createDeepSeekV4ThinkingWrapper(streamSimple, "high");
|
||||
expect(streamFn).toBeDefined();
|
||||
|
||||
const stream = streamFn?.(resolveDeepSeekV4LiveModel(), context, {
|
||||
const stream = streamFn(resolveDeepSeekV4LiveModel(), context, {
|
||||
apiKey: DEEPSEEK_KEY,
|
||||
maxTokens: 64,
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as Record<string, unknown>;
|
||||
},
|
||||
});
|
||||
expect(stream).toBeDefined();
|
||||
|
||||
const result = await (await stream!).result();
|
||||
const result = await (await stream).result();
|
||||
if (result.stopReason === "error") {
|
||||
throw new Error(result.errorMessage || "DeepSeek V4 replay returned error with no message");
|
||||
}
|
||||
@@ -204,18 +202,16 @@ describeLive("deepseek plugin live", () => {
|
||||
};
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const streamFn = createDeepSeekV4ThinkingWrapper(streamSimple, "high");
|
||||
expect(streamFn).toBeDefined();
|
||||
|
||||
const stream = streamFn?.(resolveDeepSeekV4LiveModel(), context, {
|
||||
const stream = streamFn(resolveDeepSeekV4LiveModel(), context, {
|
||||
apiKey: DEEPSEEK_KEY,
|
||||
maxTokens: 64,
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as Record<string, unknown>;
|
||||
},
|
||||
});
|
||||
expect(stream).toBeDefined();
|
||||
|
||||
const result = await (await stream!).result();
|
||||
const result = await (await stream).result();
|
||||
if (result.stopReason === "error") {
|
||||
throw new Error(
|
||||
result.errorMessage || "DeepSeek V4 plain replay returned error with no message",
|
||||
|
||||
@@ -119,6 +119,16 @@ function createPayloadCapturingStream(capture: PayloadCapture) {
|
||||
};
|
||||
}
|
||||
|
||||
function requireThinkingWrapper(
|
||||
wrapper: ReturnType<typeof createDeepSeekV4ThinkingWrapper>,
|
||||
label: string,
|
||||
): NonNullable<ReturnType<typeof createDeepSeekV4ThinkingWrapper>> {
|
||||
if (!wrapper) {
|
||||
throw new Error(`expected DeepSeek thinking wrapper for ${label}`);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("deepseek provider plugin", () => {
|
||||
it("registers DeepSeek with api-key auth wizard metadata", async () => {
|
||||
const provider = await registerSingleProviderPlugin(deepseekPlugin);
|
||||
@@ -225,9 +235,11 @@ describe("deepseek provider plugin", () => {
|
||||
return stream;
|
||||
};
|
||||
|
||||
const wrapThinkingOff = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "off");
|
||||
expect(wrapThinkingOff).toBeDefined();
|
||||
await wrapThinkingOff?.(
|
||||
const wrapThinkingOff = requireThinkingWrapper(
|
||||
createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "off"),
|
||||
"off",
|
||||
);
|
||||
await wrapThinkingOff(
|
||||
{
|
||||
provider: "deepseek",
|
||||
id: "deepseek-v4-pro",
|
||||
@@ -240,9 +252,11 @@ describe("deepseek provider plugin", () => {
|
||||
expect(capturedPayload).toMatchObject({ thinking: { type: "disabled" } });
|
||||
expect(capturedPayload).not.toHaveProperty("reasoning_effort");
|
||||
|
||||
const wrapThinkingXhigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "xhigh");
|
||||
expect(wrapThinkingXhigh).toBeDefined();
|
||||
await wrapThinkingXhigh?.(
|
||||
const wrapThinkingXhigh = requireThinkingWrapper(
|
||||
createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "xhigh"),
|
||||
"xhigh",
|
||||
);
|
||||
await wrapThinkingXhigh(
|
||||
{
|
||||
provider: "deepseek",
|
||||
id: "deepseek-v4-pro",
|
||||
@@ -264,9 +278,11 @@ describe("deepseek provider plugin", () => {
|
||||
const context = deepSeekReasoningToolReplayContext();
|
||||
const baseStreamFn = createPayloadCapturingStream(capture);
|
||||
|
||||
const wrapThinkingHigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high");
|
||||
expect(wrapThinkingHigh).toBeDefined();
|
||||
await wrapThinkingHigh?.(model, context, {});
|
||||
const wrapThinkingHigh = requireThinkingWrapper(
|
||||
createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high"),
|
||||
"high",
|
||||
);
|
||||
await wrapThinkingHigh(model, context, {});
|
||||
|
||||
expect(capture.payload).toMatchObject({
|
||||
thinking: { type: "enabled" },
|
||||
@@ -301,9 +317,11 @@ describe("deepseek provider plugin", () => {
|
||||
);
|
||||
const baseStreamFn = createPayloadCapturingStream(capture);
|
||||
|
||||
const wrapThinkingHigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high");
|
||||
expect(wrapThinkingHigh).toBeDefined();
|
||||
await wrapThinkingHigh?.(model, context, {});
|
||||
const wrapThinkingHigh = requireThinkingWrapper(
|
||||
createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high"),
|
||||
"high",
|
||||
);
|
||||
await wrapThinkingHigh(model, context, {});
|
||||
|
||||
expect((capture.payload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
@@ -338,9 +356,11 @@ describe("deepseek provider plugin", () => {
|
||||
} as Context;
|
||||
const baseStreamFn = createPayloadCapturingStream(capture);
|
||||
|
||||
const wrapThinkingHigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high");
|
||||
expect(wrapThinkingHigh).toBeDefined();
|
||||
await wrapThinkingHigh?.(model, context, {});
|
||||
const wrapThinkingHigh = requireThinkingWrapper(
|
||||
createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high"),
|
||||
"high",
|
||||
);
|
||||
await wrapThinkingHigh(model, context, {});
|
||||
|
||||
expect((capture.payload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
@@ -355,12 +375,11 @@ describe("deepseek provider plugin", () => {
|
||||
const context = deepSeekReasoningToolReplayContext();
|
||||
const baseStreamFn = createPayloadCapturingStream(capture);
|
||||
|
||||
const wrapThinkingNone = createDeepSeekV4ThinkingWrapper(
|
||||
baseStreamFn as never,
|
||||
"none" as never,
|
||||
const wrapThinkingNone = requireThinkingWrapper(
|
||||
createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "none" as never),
|
||||
"none",
|
||||
);
|
||||
expect(wrapThinkingNone).toBeDefined();
|
||||
await wrapThinkingNone?.(model, context, {});
|
||||
await wrapThinkingNone(model, context, {});
|
||||
|
||||
expect(capture.payload).toMatchObject({ thinking: { type: "disabled" } });
|
||||
expect(capture.payload).not.toHaveProperty("reasoning_effort");
|
||||
|
||||
@@ -131,7 +131,9 @@ describe("PlaywrightDiffScreenshotter", () => {
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0]?.pdf).toHaveBeenCalledTimes(1);
|
||||
const pdfCall = pages[0]?.pdf.mock.calls[0]?.[0] as Record<string, unknown> | undefined;
|
||||
expect(pdfCall).toBeDefined();
|
||||
if (!pdfCall) {
|
||||
throw new Error("expected PDF render call");
|
||||
}
|
||||
expect(pdfCall).not.toHaveProperty("pageRanges");
|
||||
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
|
||||
await expect(fs.readFile(pdfPath, "utf8")).resolves.toContain("%PDF-1.7");
|
||||
|
||||
@@ -11,6 +11,6 @@ describe("diffs package manifest", () => {
|
||||
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as DiffsPackageManifest;
|
||||
|
||||
expect(packageJson.dependencies?.["@pierre/diffs"]).toBeDefined();
|
||||
expect(packageJson.dependencies).toHaveProperty("@pierre/diffs");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,9 @@ describe("diffs tool", () => {
|
||||
|
||||
const text = readTextContent(result, 0);
|
||||
expect(text).toContain("http://127.0.0.1:18789/plugins/diffs/view/");
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toBeDefined();
|
||||
expect(readDetails(result).viewerUrl).toEqual(
|
||||
expect.stringContaining("http://127.0.0.1:18789/plugins/diffs/view/"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured viewerBaseUrl when tool input omits baseUrl", async () => {
|
||||
@@ -92,16 +94,15 @@ describe("diffs tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose reserved format in the tool schema", async () => {
|
||||
it("does not expose reserved format in the tool schema", () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
});
|
||||
|
||||
const parameters = tool.parameters as { properties?: Record<string, unknown> };
|
||||
expect(parameters.properties).toBeDefined();
|
||||
expect(parameters.properties).not.toHaveProperty("format");
|
||||
const properties = readParametersProperties(tool.parameters);
|
||||
expect(properties).not.toHaveProperty("format");
|
||||
});
|
||||
|
||||
it("returns an image artifact in image mode", async () => {
|
||||
@@ -132,16 +133,17 @@ describe("diffs tool", () => {
|
||||
expect(readTextContent(result, 0)).toContain("Diff PNG generated at:");
|
||||
expect(readTextContent(result, 0)).toContain("Use the `message` tool");
|
||||
expect(result?.content).toHaveLength(1);
|
||||
expect((result?.details as Record<string, unknown>).filePath).toBeDefined();
|
||||
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
|
||||
expect((result?.details as Record<string, unknown>).format).toBe("png");
|
||||
expect((result?.details as Record<string, unknown>).fileQuality).toBe("standard");
|
||||
expect((result?.details as Record<string, unknown>).imageQuality).toBe("standard");
|
||||
expect((result?.details as Record<string, unknown>).fileScale).toBe(2);
|
||||
expect((result?.details as Record<string, unknown>).imageScale).toBe(2);
|
||||
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(960);
|
||||
expect((result?.details as Record<string, unknown>).imageMaxWidth).toBe(960);
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
|
||||
const details = readDetails(result);
|
||||
expect(requireString(details.filePath, "filePath")).toMatch(/preview\.png$/);
|
||||
expect(requireString(details.imagePath, "imagePath")).toMatch(/preview\.png$/);
|
||||
expect(details.format).toBe("png");
|
||||
expect(details.fileQuality).toBe("standard");
|
||||
expect(details.imageQuality).toBe("standard");
|
||||
expect(details.fileScale).toBe(2);
|
||||
expect(details.imageScale).toBe(2);
|
||||
expect(details.fileMaxWidth).toBe(960);
|
||||
expect(details.imageMaxWidth).toBe(960);
|
||||
expect(details.viewerUrl).toBeUndefined();
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -206,8 +208,8 @@ describe("diffs tool", () => {
|
||||
mode: "file",
|
||||
ttlSeconds: 1,
|
||||
});
|
||||
const filePath = (result?.details as Record<string, unknown>).filePath as string;
|
||||
await expect(fs.stat(filePath)).resolves.toBeDefined();
|
||||
const filePath = requireString(readDetails(result).filePath, "filePath");
|
||||
await fs.access(filePath);
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 2_000));
|
||||
await store.cleanupExpired();
|
||||
@@ -564,6 +566,32 @@ function createPdfScreenshotter(
|
||||
return { screenshotHtml };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readDetails(result: unknown): Record<string, unknown> {
|
||||
const details = (result as { details?: unknown } | null | undefined)?.details;
|
||||
if (!isRecord(details)) {
|
||||
throw new Error("expected diffs tool result details");
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
function readParametersProperties(parameters: unknown): Record<string, unknown> {
|
||||
if (isRecord(parameters) && isRecord(parameters.properties)) {
|
||||
return parameters.properties;
|
||||
}
|
||||
throw new Error("expected diffs tool parameter properties");
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readTextContent(result: unknown, index: number): string {
|
||||
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
|
||||
?.content;
|
||||
|
||||
@@ -18,26 +18,63 @@ afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("resolveDiscordAccount allowFrom precedence", () => {
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const resolved = resolveDiscordAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: { token: "token-work", name: "Work" },
|
||||
const defaultAccountOmissionCases = [
|
||||
{
|
||||
name: "resolveDiscordAccount",
|
||||
assert: () => {
|
||||
const resolved = resolveDiscordAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: { token: "token-work", name: "Work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.token).toBe("token-work");
|
||||
});
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.token).toBe("token-work");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "createDiscordActionGate",
|
||||
assert: () => {
|
||||
const gate = createDiscordActionGate({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
actions: { reactions: false },
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
token: "token-work",
|
||||
actions: { reactions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(gate("reactions")).toBe(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("Discord defaultAccount omission contract", () => {
|
||||
it.each(defaultAccountOmissionCases)(
|
||||
"$name uses configured defaultAccount when accountId is omitted",
|
||||
({ assert }) => {
|
||||
assert();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("resolveDiscordAccount allowFrom precedence", () => {
|
||||
it("prefers accounts.default.allowFrom over top-level for default account", () => {
|
||||
const resolved = resolveDiscordAccount({
|
||||
cfg: {
|
||||
@@ -93,29 +130,6 @@ describe("resolveDiscordAccount allowFrom precedence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDiscordActionGate", () => {
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const gate = createDiscordActionGate({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
actions: { reactions: false },
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
token: "token-work",
|
||||
actions: { reactions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(gate("reactions")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordMaxLinesPerMessage", () => {
|
||||
it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => {
|
||||
const resolved = resolveDiscordMaxLinesPerMessage({
|
||||
|
||||
@@ -11,8 +11,10 @@ const fetchChannelPermissionsDiscordMock = vi.fn();
|
||||
|
||||
function readDiscordGuilds(cfg: OpenClawConfig) {
|
||||
const guilds = cfg.channels?.discord?.guilds;
|
||||
expect(guilds).toBeDefined();
|
||||
return guilds ?? {};
|
||||
if (!guilds) {
|
||||
throw new Error("expected discord guilds config");
|
||||
}
|
||||
return guilds;
|
||||
}
|
||||
|
||||
describe("discord audit", () => {
|
||||
|
||||
@@ -26,7 +26,9 @@ describe("discord channel message adapter", () => {
|
||||
|
||||
it("backs declared durable-final capabilities with outbound send proofs", async () => {
|
||||
const adapter = discordPlugin.message;
|
||||
expect(adapter).toBeDefined();
|
||||
if (!adapter) {
|
||||
throw new Error("Expected discord plugin to expose a channel message adapter");
|
||||
}
|
||||
|
||||
const proveText = async () => {
|
||||
resetDiscordOutboundMocks(hoisted);
|
||||
|
||||
@@ -135,10 +135,15 @@ describe("discord component registry", () => {
|
||||
});
|
||||
const confirm = result.entries.find((entry) => entry.label === "Confirm");
|
||||
const cancel = result.entries.find((entry) => entry.label === "Cancel");
|
||||
expect(confirm?.consumptionGroupId).toBeTruthy();
|
||||
expect(cancel?.consumptionGroupId).toBe(confirm?.consumptionGroupId);
|
||||
if (!confirm?.consumptionGroupId) {
|
||||
throw new Error("expected confirm entry to carry a consumption group id");
|
||||
}
|
||||
if (!cancel) {
|
||||
throw new Error("expected cancel entry");
|
||||
}
|
||||
expect(cancel.consumptionGroupId).toBe(confirm.consumptionGroupId);
|
||||
expect(confirm?.consumptionGroupEntryIds).toEqual(
|
||||
expect.arrayContaining([confirm?.id, cancel?.id]),
|
||||
expect.arrayContaining([confirm.id, cancel.id]),
|
||||
);
|
||||
|
||||
registerDiscordComponentEntries({
|
||||
|
||||
@@ -8,13 +8,19 @@ import {
|
||||
scanDiscordNumericIdEntries,
|
||||
} from "./doctor.js";
|
||||
|
||||
function getDiscordCompatibilityNormalizer(): NonNullable<
|
||||
typeof discordDoctor.normalizeCompatibilityConfig
|
||||
> {
|
||||
const normalize = discordDoctor.normalizeCompatibilityConfig;
|
||||
if (!normalize) {
|
||||
throw new Error("Expected discord doctor to expose normalizeCompatibilityConfig");
|
||||
}
|
||||
return normalize;
|
||||
}
|
||||
|
||||
describe("discord doctor", () => {
|
||||
it("normalizes legacy discord streaming aliases for runtime config", () => {
|
||||
const normalize = discordDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
const normalize = getDiscordCompatibilityNormalizer();
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
@@ -76,11 +82,7 @@ describe("discord doctor", () => {
|
||||
});
|
||||
|
||||
it("moves account voice.tts.edge into providers.microsoft", () => {
|
||||
const normalize = discordDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
const normalize = getDiscordCompatibilityNormalizer();
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
@@ -117,11 +119,7 @@ describe("discord doctor", () => {
|
||||
});
|
||||
|
||||
it("moves legacy guild channel allow toggles into enabled", () => {
|
||||
const normalize = discordDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
const normalize = getDiscordCompatibilityNormalizer();
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
@@ -169,11 +167,7 @@ describe("discord doctor", () => {
|
||||
});
|
||||
|
||||
it("moves legacy guild channel agentId into a top-level route binding", () => {
|
||||
const normalize = discordDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
const normalize = getDiscordCompatibilityNormalizer();
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
@@ -213,11 +207,7 @@ describe("discord doctor", () => {
|
||||
});
|
||||
|
||||
it("moves account-scoped guild channel agentId into an account-scoped route binding", () => {
|
||||
const normalize = discordDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
const normalize = getDiscordCompatibilityNormalizer();
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
@@ -263,11 +253,7 @@ describe("discord doctor", () => {
|
||||
});
|
||||
|
||||
it("removes legacy guild channel agentId when a matching route binding already exists", () => {
|
||||
const normalize = discordDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
const normalize = getDiscordCompatibilityNormalizer();
|
||||
|
||||
const existingBinding = {
|
||||
agentId: "video",
|
||||
|
||||
@@ -544,7 +544,7 @@ describe("RequestClient", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes message multipart uploads with payload_json", async () => {
|
||||
it("serializes message multipart uploads with payload_json", () => {
|
||||
const headers = new Headers();
|
||||
const body = serializeRequestBody(
|
||||
{
|
||||
|
||||
@@ -253,6 +253,20 @@ describe("discord native /think autocomplete", () => {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function requireThinkLevelCommand() {
|
||||
const command = findCommandByNativeName("think", "discord", {
|
||||
includeBundledChannelFallback: false,
|
||||
});
|
||||
if (!command) {
|
||||
throw new Error("expected Discord /think command");
|
||||
}
|
||||
const levelArg = command.args?.find((entry) => entry.name === "level");
|
||||
if (!levelArg) {
|
||||
throw new Error("expected Discord /think level arg");
|
||||
}
|
||||
return { command, levelArg };
|
||||
}
|
||||
|
||||
it("uses the session override context for /think choices", async () => {
|
||||
const cfg = createConfig();
|
||||
const interaction = {
|
||||
@@ -269,15 +283,7 @@ describe("discord native /think autocomplete", () => {
|
||||
respond: (choices: Array<{ name: string; value: string }>) => Promise<void>;
|
||||
};
|
||||
|
||||
const command = findCommandByNativeName("think", "discord", {
|
||||
includeBundledChannelFallback: false,
|
||||
});
|
||||
expect(command).toBeTruthy();
|
||||
const levelArg = command?.args?.find((entry) => entry.name === "level");
|
||||
expect(levelArg).toBeTruthy();
|
||||
if (!command || !levelArg) {
|
||||
return;
|
||||
}
|
||||
const { command, levelArg } = requireThinkLevelCommand();
|
||||
|
||||
const context = await resolveDiscordNativeChoiceContext({
|
||||
interaction,
|
||||
@@ -346,15 +352,7 @@ describe("discord native /think autocomplete", () => {
|
||||
accountId: "default",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
const command = findCommandByNativeName("think", "discord", {
|
||||
includeBundledChannelFallback: false,
|
||||
});
|
||||
const levelArg = command?.args?.find((entry) => entry.name === "level");
|
||||
expect(command).toBeTruthy();
|
||||
expect(levelArg).toBeTruthy();
|
||||
if (!command || !levelArg) {
|
||||
return;
|
||||
}
|
||||
const { command, levelArg } = requireThinkLevelCommand();
|
||||
|
||||
const choices = resolveCommandArgChoices({
|
||||
command,
|
||||
@@ -401,15 +399,7 @@ describe("discord native /think autocomplete", () => {
|
||||
expect(context).toBeNull();
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const command = findCommandByNativeName("think", "discord", {
|
||||
includeBundledChannelFallback: false,
|
||||
});
|
||||
const levelArg = command?.args?.find((entry) => entry.name === "level");
|
||||
expect(command).toBeTruthy();
|
||||
expect(levelArg).toBeTruthy();
|
||||
if (!command || !levelArg) {
|
||||
return;
|
||||
}
|
||||
const { command, levelArg } = requireThinkLevelCommand();
|
||||
const choices = resolveCommandArgChoices({
|
||||
command,
|
||||
arg: levelArg,
|
||||
|
||||
@@ -315,8 +315,10 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(resolveWait).toBeDefined();
|
||||
resolveWait?.();
|
||||
if (!resolveWait) {
|
||||
throw new Error("expected lifecycle wait resolver");
|
||||
}
|
||||
resolveWait();
|
||||
await expect(lifecyclePromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ function createGatewayInfoBody(overrides?: {
|
||||
}
|
||||
|
||||
function resolveGatewayInfoFetch(resolve: ((value: Response) => void) | undefined): void {
|
||||
expect(resolve).toBeDefined();
|
||||
resolve!({
|
||||
if (!resolve) {
|
||||
throw new Error("expected pending gateway info fetch resolver");
|
||||
}
|
||||
resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => createGatewayInfoBody(),
|
||||
@@ -449,7 +451,7 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses proxy agent for gateway WebSocket when configured", async () => {
|
||||
it("uses proxy agent for gateway WebSocket when configured", () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
@@ -473,7 +475,7 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the default gateway plugin when proxy is invalid", async () => {
|
||||
it("falls back to the default gateway plugin when proxy is invalid", () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
@@ -535,7 +537,7 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the default gateway plugin when proxy is remote", async () => {
|
||||
it("falls back to the default gateway plugin when proxy is remote", () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("resolveDiscordRestFetch", () => {
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to global fetch when proxy URL is invalid", async () => {
|
||||
it("falls back to global fetch when proxy URL is invalid", () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
resolveDiscordThreadStarter,
|
||||
} from "./threading.js";
|
||||
|
||||
type ResolvedThreadStarter = NonNullable<Awaited<ReturnType<typeof resolveDiscordThreadStarter>>>;
|
||||
|
||||
type ThreadStarterRestMessage = {
|
||||
content?: string | null;
|
||||
embeds?: Array<{ title?: string | null; description?: string | null }>;
|
||||
@@ -65,6 +67,15 @@ function createStarterMessage(overrides: ThreadStarterRestMessage = {}): ThreadS
|
||||
};
|
||||
}
|
||||
|
||||
function requireThreadStarter(
|
||||
result: Awaited<ReturnType<typeof resolveDiscordThreadStarter>>,
|
||||
): ResolvedThreadStarter {
|
||||
if (!result) {
|
||||
throw new Error("expected resolved Discord thread starter");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolveStarter(params: {
|
||||
message: ThreadStarterRestMessage;
|
||||
parentId?: string;
|
||||
@@ -152,10 +163,10 @@ describe("resolveDiscordThreadStarter", () => {
|
||||
resolveTimestampMs: () => 456,
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.text).toContain("forwarded task content");
|
||||
expect(result!.author).toBe("Bob");
|
||||
expect(result!.timestamp).toBe(456);
|
||||
const starter = requireThreadStarter(result);
|
||||
expect(starter.text).toContain("forwarded task content");
|
||||
expect(starter.author).toBe("Bob");
|
||||
expect(starter.timestamp).toBe(456);
|
||||
});
|
||||
|
||||
it("prefers content over forwarded message snapshots", async () => {
|
||||
@@ -167,8 +178,7 @@ describe("resolveDiscordThreadStarter", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.text).toBe("direct content");
|
||||
expect(requireThreadStarter(result).text).toBe("direct content");
|
||||
});
|
||||
|
||||
it("joins multiple forwarded message snapshots", async () => {
|
||||
@@ -182,9 +192,9 @@ describe("resolveDiscordThreadStarter", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.text).toContain("first forwarded message");
|
||||
expect(result!.text).toContain("second forwarded message");
|
||||
const starter = requireThreadStarter(result);
|
||||
expect(starter.text).toContain("first forwarded message");
|
||||
expect(starter.text).toContain("second forwarded message");
|
||||
});
|
||||
|
||||
it("preserves forwarded attachment placeholders in thread starter context", async () => {
|
||||
@@ -206,9 +216,9 @@ describe("resolveDiscordThreadStarter", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.text).toContain("[Forwarded message]");
|
||||
expect(result!.text).toContain("<media:image> (1 image)");
|
||||
const starter = requireThreadStarter(result);
|
||||
expect(starter.text).toContain("[Forwarded message]");
|
||||
expect(starter.text).toContain("<media:image> (1 image)");
|
||||
});
|
||||
|
||||
it("preserves forwarded sticker placeholders in thread starter context", async () => {
|
||||
@@ -229,9 +239,9 @@ describe("resolveDiscordThreadStarter", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.text).toContain("[Forwarded message]");
|
||||
expect(result!.text).toContain("<media:sticker> (1 sticker)");
|
||||
const starter = requireThreadStarter(result);
|
||||
expect(starter.text).toContain("[Forwarded message]");
|
||||
expect(starter.text).toContain("<media:sticker> (1 sticker)");
|
||||
});
|
||||
|
||||
it("uses the thread id as the message channel id for forum parents", async () => {
|
||||
@@ -241,7 +251,7 @@ describe("resolveDiscordThreadStarter", () => {
|
||||
parentType: ChannelType.GuildForum,
|
||||
});
|
||||
|
||||
expect(result?.text).toBe("starter content");
|
||||
expect(requireThreadStarter(result).text).toBe("starter content");
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/channels/thread-1/messages/thread-1"),
|
||||
);
|
||||
|
||||
@@ -8,8 +8,10 @@ import { createDiscordRequestClient, DISCORD_REST_TIMEOUT_MS } from "./proxy-req
|
||||
describe("createDiscordRequestClient", () => {
|
||||
it("preserves the REST client's abort signal for proxied fetch calls", async () => {
|
||||
const fetchSpy = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
expect(init?.signal).toBeDefined();
|
||||
expect(init!.signal!.aborted).toBe(false);
|
||||
if (!(init?.signal instanceof AbortSignal)) {
|
||||
throw new Error("Expected proxied fetch init to include an AbortSignal");
|
||||
}
|
||||
expect(init.signal.aborted).toBe(false);
|
||||
return createJsonResponse([]);
|
||||
});
|
||||
|
||||
@@ -67,8 +69,10 @@ describe("createDiscordRequestClient", () => {
|
||||
|
||||
await client.get("/channels/123/messages");
|
||||
|
||||
expect(receivedSignal).toBeDefined();
|
||||
expect(receivedSignal!.aborted).toBe(false);
|
||||
if (!receivedSignal) {
|
||||
throw new Error("Expected proxied fetch to receive the REST timeout signal");
|
||||
}
|
||||
expect(receivedSignal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("exports a reasonable timeout constant", () => {
|
||||
|
||||
@@ -234,13 +234,15 @@ describe("Discord security audit findings", () => {
|
||||
if (testCase.expectNoNameBasedFinding) {
|
||||
expect(nameBasedFinding).toBeUndefined();
|
||||
} else {
|
||||
expect(nameBasedFinding).toBeDefined();
|
||||
expect(nameBasedFinding?.severity).toBe(testCase.expectNameBasedSeverity);
|
||||
if (!nameBasedFinding) {
|
||||
throw new Error(`expected name-based finding for ${testCase.name}`);
|
||||
}
|
||||
expect(nameBasedFinding.severity).toBe(testCase.expectNameBasedSeverity);
|
||||
for (const snippet of testCase.detailIncludes ?? []) {
|
||||
expect(nameBasedFinding?.detail).toContain(snippet);
|
||||
expect(nameBasedFinding.detail).toContain(snippet);
|
||||
}
|
||||
for (const snippet of testCase.detailExcludes ?? []) {
|
||||
expect(nameBasedFinding?.detail).not.toContain(snippet);
|
||||
expect(nameBasedFinding.detail).not.toContain(snippet);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,28 @@ vi.mock("./send.shared.js", () => ({
|
||||
|
||||
const { readMessagesDiscord, searchMessagesDiscord } = await import("./send.messages.js");
|
||||
|
||||
const restErrorCases: Array<{
|
||||
name: string;
|
||||
invoke: () => Promise<unknown>;
|
||||
}> = [
|
||||
{
|
||||
name: "readMessagesDiscord",
|
||||
invoke: () => readMessagesDiscord("C1", {}, { cfg: {} as never }),
|
||||
},
|
||||
{
|
||||
name: "searchMessagesDiscord",
|
||||
invoke: () => searchMessagesDiscord({ guildId: "G1", content: "test" }, { cfg: {} as never }),
|
||||
},
|
||||
];
|
||||
|
||||
describe("Discord message REST error handling", () => {
|
||||
it.each(restErrorCases)("$name propagates REST errors", async ({ invoke }) => {
|
||||
restMock.get.mockRejectedValueOnce(new Error("Discord API error"));
|
||||
|
||||
await expect(invoke()).rejects.toThrow("Discord API error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMessagesDiscord", () => {
|
||||
it("returns messages from the REST client", async () => {
|
||||
const messages = [{ id: "1", content: "hello" }];
|
||||
@@ -20,14 +42,6 @@ describe("readMessagesDiscord", () => {
|
||||
expect(result).toEqual(messages);
|
||||
expect(restMock.get).toHaveBeenCalledWith(expect.stringContaining("C1"), { limit: 5 });
|
||||
});
|
||||
|
||||
it("propagates REST errors", async () => {
|
||||
restMock.get.mockRejectedValueOnce(new Error("Discord API error"));
|
||||
|
||||
await expect(readMessagesDiscord("C1", {}, { cfg: {} as never })).rejects.toThrow(
|
||||
"Discord API error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchMessagesDiscord", () => {
|
||||
@@ -42,12 +56,4 @@ describe("searchMessagesDiscord", () => {
|
||||
|
||||
expect(result).toEqual(results);
|
||||
});
|
||||
|
||||
it("propagates REST errors", async () => {
|
||||
restMock.get.mockRejectedValueOnce(new Error("Discord API error"));
|
||||
|
||||
await expect(
|
||||
searchMessagesDiscord({ guildId: "G1", content: "test" }, { cfg: {} as never }),
|
||||
).rejects.toThrow("Discord API error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,9 +263,29 @@ describe("DiscordVoiceManager", () => {
|
||||
]);
|
||||
};
|
||||
|
||||
const getSessionEntry = (
|
||||
manager: InstanceType<typeof managerModule.DiscordVoiceManager>,
|
||||
guildId = "g1",
|
||||
) => {
|
||||
const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get(guildId);
|
||||
if (!entry) {
|
||||
throw new Error(`expected Discord voice session for guild ${guildId}`);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const getLastAudioPlayer = () => {
|
||||
const player = createAudioPlayerMock.mock.results.at(-1)?.value as
|
||||
| { state: { status: string } }
|
||||
| undefined;
|
||||
if (!player) {
|
||||
throw new Error("expected Discord voice audio player to be created");
|
||||
}
|
||||
return player;
|
||||
};
|
||||
|
||||
const emitDecryptFailure = (manager: InstanceType<typeof managerModule.DiscordVoiceManager>) => {
|
||||
const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get("g1");
|
||||
expect(entry).toBeDefined();
|
||||
const entry = getSessionEntry(manager);
|
||||
(
|
||||
manager as unknown as { handleReceiveError: (e: unknown, err: unknown) => void }
|
||||
).handleReceiveError(
|
||||
@@ -424,10 +444,8 @@ describe("DiscordVoiceManager", () => {
|
||||
|
||||
await manager.join({ guildId: "g1", channelId: "1001" });
|
||||
|
||||
const player = createAudioPlayerMock.mock.results.at(-1)?.value;
|
||||
const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get("g1");
|
||||
expect(entry).toBeDefined();
|
||||
expect(player).toBeDefined();
|
||||
const player = getLastAudioPlayer();
|
||||
const entry = getSessionEntry(manager);
|
||||
player.state.status = "playing";
|
||||
|
||||
await (
|
||||
@@ -567,29 +585,24 @@ describe("DiscordVoiceManager", () => {
|
||||
|
||||
await manager.join({ guildId: "g1", channelId: "1001" });
|
||||
|
||||
const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get(
|
||||
"g1",
|
||||
) as
|
||||
| {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
capture: {
|
||||
activeSpeakers: Set<string>;
|
||||
activeCaptureStreams: Map<
|
||||
string,
|
||||
{ generation: number; stream: { destroy: () => void } }
|
||||
>;
|
||||
captureFinalizeTimers: Map<string, unknown>;
|
||||
captureGenerations: Map<string, number>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(entry).toBeDefined();
|
||||
const entry = getSessionEntry(manager) as {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
capture: {
|
||||
activeSpeakers: Set<string>;
|
||||
activeCaptureStreams: Map<
|
||||
string,
|
||||
{ generation: number; stream: { destroy: () => void } }
|
||||
>;
|
||||
captureFinalizeTimers: Map<string, unknown>;
|
||||
captureGenerations: Map<string, number>;
|
||||
};
|
||||
};
|
||||
|
||||
const firstStream = { destroy: vi.fn() };
|
||||
entry?.capture.activeSpeakers.add("u1");
|
||||
entry?.capture.captureGenerations.set("u1", 1);
|
||||
entry?.capture.activeCaptureStreams.set("u1", { generation: 1, stream: firstStream });
|
||||
entry.capture.activeSpeakers.add("u1");
|
||||
entry.capture.captureGenerations.set("u1", 1);
|
||||
entry.capture.activeCaptureStreams.set("u1", { generation: 1, stream: firstStream });
|
||||
|
||||
(
|
||||
manager as unknown as {
|
||||
|
||||
@@ -73,6 +73,7 @@ describeLive("elevenlabs plugin live", () => {
|
||||
outputFormat: "ulaw_8000",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(speech.byteLength).toBeGreaterThan(0);
|
||||
|
||||
await runRealtimeSttLiveTest({
|
||||
provider,
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("elevenLabsMediaUnderstandingProvider", () => {
|
||||
expect(elevenLabsMediaUnderstandingProvider.id).toBe("elevenlabs");
|
||||
expect(elevenLabsMediaUnderstandingProvider.capabilities).toEqual(["audio"]);
|
||||
expect(elevenLabsMediaUnderstandingProvider.defaultModels?.audio).toBe("scribe_v2");
|
||||
expect(elevenLabsMediaUnderstandingProvider.transcribeAudio).toBeDefined();
|
||||
expect(elevenLabsMediaUnderstandingProvider.transcribeAudio).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("posts multipart audio to ElevenLabs speech-to-text", async () => {
|
||||
|
||||
@@ -12,14 +12,16 @@ import {
|
||||
|
||||
function expectFalJsonPost(params: { call: number; url: string; body: Record<string, unknown> }) {
|
||||
const request = fetchWithSsrFGuardMock.mock.calls[params.call - 1]?.[0];
|
||||
expect(request).toBeTruthy();
|
||||
expect(request?.url).toBe(params.url);
|
||||
expect(request?.auditContext).toBe("fal-image-generate");
|
||||
expect(request?.init?.method).toBe("POST");
|
||||
const headers = new Headers(request?.init?.headers);
|
||||
if (!request) {
|
||||
throw new Error(`expected fal fetch request #${params.call}`);
|
||||
}
|
||||
expect(request.url).toBe(params.url);
|
||||
expect(request.auditContext).toBe("fal-image-generate");
|
||||
expect(request.init?.method).toBe("POST");
|
||||
const headers = new Headers(request.init?.headers);
|
||||
expect(headers.get("authorization")).toBe("Key fal-test-key");
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(JSON.parse(String(request?.init?.body))).toEqual(params.body);
|
||||
expect(JSON.parse(String(request.init?.body))).toEqual(params.body);
|
||||
}
|
||||
|
||||
describe("fal image-generation provider", () => {
|
||||
|
||||
@@ -13,7 +13,11 @@ describe("feishu setup entry", () => {
|
||||
it("declares the setup entry without importing Feishu runtime dependencies", async () => {
|
||||
const { default: setupEntry } = await import("./setup-entry.js");
|
||||
|
||||
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
|
||||
expect(typeof setupEntry.loadSetupPlugin).toBe("function");
|
||||
expect(setupEntry).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "bundled-channel-setup-entry",
|
||||
loadSetupPlugin: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,16 @@ type HttpInstanceLike = {
|
||||
post: (url: string, body?: unknown, options?: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function requireHttpInstance(value: unknown): HttpInstanceLike {
|
||||
if (isRecord(value) && typeof value.get === "function" && typeof value.post === "function") {
|
||||
return {
|
||||
get: value.get as HttpInstanceLike["get"],
|
||||
post: value.post as HttpInstanceLike["post"],
|
||||
};
|
||||
}
|
||||
throw new Error("expected Feishu HTTP instance");
|
||||
}
|
||||
|
||||
function readCallOptions(
|
||||
mock: { mock: { calls: unknown[][] } },
|
||||
index = -1,
|
||||
@@ -222,25 +232,12 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe("createFeishuClient HTTP timeout", () => {
|
||||
const getLastClientHttpInstance = (): HttpInstanceLike | undefined => {
|
||||
const httpInstance = readCallOptions(clientCtorMock).httpInstance;
|
||||
if (
|
||||
isRecord(httpInstance) &&
|
||||
typeof httpInstance.get === "function" &&
|
||||
typeof httpInstance.post === "function"
|
||||
) {
|
||||
return {
|
||||
get: httpInstance.get as HttpInstanceLike["get"],
|
||||
post: httpInstance.post as HttpInstanceLike["post"],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const readLastClientHttpInstance = (): HttpInstanceLike =>
|
||||
requireHttpInstance(readCallOptions(clientCtorMock).httpInstance);
|
||||
|
||||
const expectGetCallTimeout = async (timeout: number) => {
|
||||
const httpInstance = getLastClientHttpInstance();
|
||||
expect(httpInstance).toBeDefined();
|
||||
await httpInstance?.get("https://example.com/api");
|
||||
const httpInstance = readLastClientHttpInstance();
|
||||
await httpInstance.get("https://example.com/api");
|
||||
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
|
||||
"https://example.com/api",
|
||||
expect.objectContaining({ timeout }),
|
||||
@@ -250,16 +247,18 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
it("passes a custom httpInstance with default timeout to Lark.Client", () => {
|
||||
createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret
|
||||
|
||||
expect(readCallOptions(clientCtorMock).httpInstance).toBeDefined();
|
||||
expect(readLastClientHttpInstance()).toMatchObject({
|
||||
get: expect.any(Function),
|
||||
post: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("injects default timeout into HTTP request options", async () => {
|
||||
createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret
|
||||
|
||||
const httpInstance = getLastClientHttpInstance();
|
||||
const httpInstance = readLastClientHttpInstance();
|
||||
|
||||
expect(httpInstance).toBeDefined();
|
||||
await httpInstance?.post(
|
||||
await httpInstance.post(
|
||||
"https://example.com/api",
|
||||
{ data: 1 },
|
||||
{ headers: { "X-Custom": "yes" } },
|
||||
@@ -275,10 +274,9 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
it("allows explicit timeout override per-request", async () => {
|
||||
createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret
|
||||
|
||||
const httpInstance = getLastClientHttpInstance();
|
||||
const httpInstance = readLastClientHttpInstance();
|
||||
|
||||
expect(httpInstance).toBeDefined();
|
||||
await httpInstance?.get("https://example.com/api", { timeout: 5_000 });
|
||||
await httpInstance.get("https://example.com/api", { timeout: 5_000 });
|
||||
|
||||
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
|
||||
"https://example.com/api",
|
||||
@@ -362,9 +360,8 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
});
|
||||
|
||||
expect(clientCtorMock.mock.calls.length).toBe(2);
|
||||
const httpInstance = getLastClientHttpInstance();
|
||||
expect(httpInstance).toBeDefined();
|
||||
await httpInstance?.get("https://example.com/api");
|
||||
const httpInstance = readLastClientHttpInstance();
|
||||
await httpInstance.get("https://example.com/api");
|
||||
|
||||
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
|
||||
"https://example.com/api",
|
||||
|
||||
@@ -61,7 +61,6 @@ describe("createFeishuCommentReplyDispatcher", () => {
|
||||
|
||||
function latestReplyDispatcherOptions() {
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0];
|
||||
expect(options).toBeDefined();
|
||||
if (!options) {
|
||||
throw new Error("expected reply dispatcher options");
|
||||
}
|
||||
|
||||
@@ -163,7 +163,10 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
});
|
||||
registerFeishuDocTools(harness.api);
|
||||
const tool = harness.resolveTool("feishu_doc", context);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) {
|
||||
throw new Error("expected Feishu doc tool");
|
||||
}
|
||||
expect(tool.execute).toEqual(expect.any(Function));
|
||||
return tool;
|
||||
}
|
||||
|
||||
@@ -206,8 +209,8 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
expect(blockDescendantCreateMock).toHaveBeenCalledTimes(1);
|
||||
const call = blockDescendantCreateMock.mock.calls[0]?.[0];
|
||||
expect(call?.data.children_id).toEqual(["h1", "t1", "h2"]);
|
||||
expect(call?.data.descendants).toBeDefined();
|
||||
expect(call?.data.descendants.length).toBeGreaterThanOrEqual(3);
|
||||
expect(call?.data.descendants).toEqual(expect.arrayContaining(blocks));
|
||||
expect(call?.data.descendants).toHaveLength(3);
|
||||
|
||||
expect(result.details.blocks_added).toBe(3);
|
||||
});
|
||||
|
||||
@@ -566,8 +566,10 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
);
|
||||
expectMediaTimeoutClientConfigured();
|
||||
expect(result.buffer).toEqual(Buffer.from("image-data"));
|
||||
expect(capturedPath).toBeDefined();
|
||||
expectPathIsolatedToTmpRoot(capturedPath as string, imageKey);
|
||||
if (!capturedPath) {
|
||||
throw new Error("expected Feishu image temp path");
|
||||
}
|
||||
expectPathIsolatedToTmpRoot(capturedPath, imageKey);
|
||||
});
|
||||
|
||||
it("uses isolated temp paths for message resource downloads", async () => {
|
||||
@@ -589,8 +591,10 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
});
|
||||
|
||||
expect(result.buffer).toEqual(Buffer.from("resource-data"));
|
||||
expect(capturedPath).toBeDefined();
|
||||
expectPathIsolatedToTmpRoot(capturedPath as string, fileKey);
|
||||
if (!capturedPath) {
|
||||
throw new Error("expected Feishu resource temp path");
|
||||
}
|
||||
expectPathIsolatedToTmpRoot(capturedPath, fileKey);
|
||||
});
|
||||
|
||||
it("rejects invalid image keys before calling feishu api", async () => {
|
||||
|
||||
@@ -188,7 +188,6 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
throw new Error("feishuOutbound.chunker missing");
|
||||
}
|
||||
|
||||
expect(() => chunker("hello world", 5)).not.toThrow();
|
||||
expect(chunker("hello world", 5)).toEqual(["hello", "world"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables block streaming by default to prevent silent reply drops", async () => {
|
||||
it("disables block streaming by default to prevent silent reply drops", () => {
|
||||
const result = createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
@@ -334,7 +334,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps core block streaming disabled when Feishu blockStreaming is explicitly false", async () => {
|
||||
it("keeps core block streaming disabled when Feishu blockStreaming is explicitly false", () => {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
appId: "app_id",
|
||||
@@ -910,7 +910,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
expect(reasoningUpdate).not.toMatch(/> _.*_/);
|
||||
|
||||
const combinedUpdate = updateCalls.find((c) => c.includes("Thinking") && c.includes("---"));
|
||||
expect(combinedUpdate).toBeDefined();
|
||||
if (!combinedUpdate) {
|
||||
throw new Error("expected combined reasoning and final-answer streaming update");
|
||||
}
|
||||
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
||||
|
||||
@@ -509,21 +509,36 @@ describe("resolveFeishuCardTemplate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStructuredCard", () => {
|
||||
it("uses schema-2.0 width config instead of legacy wide screen mode", () => {
|
||||
const card = buildStructuredCard("hello") as {
|
||||
config: {
|
||||
width_mode?: string;
|
||||
enable_forward?: boolean;
|
||||
wide_screen_mode?: boolean;
|
||||
};
|
||||
function expectSchema2WidthConfig(card: unknown) {
|
||||
const typedCard = card as {
|
||||
config: {
|
||||
width_mode?: string;
|
||||
enable_forward?: boolean;
|
||||
wide_screen_mode?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
expect(card.config.width_mode).toBe("fill");
|
||||
expect(card.config.enable_forward).toBeUndefined();
|
||||
expect(card.config.wide_screen_mode).toBeUndefined();
|
||||
expect(typedCard.config.width_mode).toBe("fill");
|
||||
expect(typedCard.config.enable_forward).toBeUndefined();
|
||||
expect(typedCard.config.wide_screen_mode).toBeUndefined();
|
||||
}
|
||||
|
||||
describe("Feishu card schema config", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "structured card",
|
||||
build: () => buildStructuredCard("hello"),
|
||||
},
|
||||
{
|
||||
name: "markdown card",
|
||||
build: () => buildMarkdownCard("hello"),
|
||||
},
|
||||
])("$name uses schema-2.0 width config instead of legacy wide screen mode", ({ build }) => {
|
||||
expectSchema2WidthConfig(build());
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStructuredCard", () => {
|
||||
it("falls back to blue when the header template is unsupported", () => {
|
||||
const card = buildStructuredCard("hello", {
|
||||
header: {
|
||||
@@ -542,19 +557,3 @@ describe("buildStructuredCard", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMarkdownCard", () => {
|
||||
it("uses schema-2.0 width config instead of legacy wide screen mode", () => {
|
||||
const card = buildMarkdownCard("hello") as {
|
||||
config: {
|
||||
width_mode?: string;
|
||||
enable_forward?: boolean;
|
||||
wide_screen_mode?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
expect(card.config.width_mode).toBe("fill");
|
||||
expect(card.config.enable_forward).toBeUndefined();
|
||||
expect(card.config.wide_screen_mode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,21 +101,24 @@ describe("feishu setup wizard", () => {
|
||||
) as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runSetupWizardConfigure({
|
||||
configure: feishuConfigure,
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: { source: "env", id: "FEISHU_APP_ID", provider: "default" },
|
||||
appSecret: { source: "env", id: "FEISHU_APP_SECRET", provider: "default" },
|
||||
},
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: feishuConfigure,
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: { source: "env", id: "FEISHU_APP_ID", provider: "default" },
|
||||
appSecret: { source: "env", id: "FEISHU_APP_SECRET", provider: "default" },
|
||||
},
|
||||
} as never,
|
||||
prompter,
|
||||
runtime: createNonExitingRuntimeEnv(),
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
},
|
||||
} as never,
|
||||
prompter,
|
||||
runtime: createNonExitingRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(result.cfg.channels?.feishu).toMatchObject({
|
||||
appId: "cli_from_prompt",
|
||||
appSecret: "secret_from_prompt",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,10 @@ describe("handleFileFetch — fs errors", () => {
|
||||
const r = await handleFileFetch({ path: tmpRoot });
|
||||
expect(r).toMatchObject({ ok: false, code: "IS_DIRECTORY" });
|
||||
// canonical path is reported back so the caller can re-check policy
|
||||
expect(r.ok ? null : r.canonicalPath).toBeTruthy();
|
||||
if (r.ok) {
|
||||
throw new Error("expected directory fetch to fail");
|
||||
}
|
||||
expect(r.canonicalPath).toBe(tmpRoot);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("firecrawl tools", () => {
|
||||
expect(authHeader).toBe("Bearer firecrawl-test-key");
|
||||
});
|
||||
|
||||
it("blocks private and non-http scrape targets before Firecrawl requests", async () => {
|
||||
it("blocks private and non-http scrape targets before Firecrawl requests", () => {
|
||||
expect(() =>
|
||||
firecrawlClientTesting.assertFirecrawlScrapeTargetAllowed("https://example.com/page"),
|
||||
).not.toThrow();
|
||||
|
||||
@@ -318,7 +318,7 @@ describe("github-copilot token", () => {
|
||||
({ deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } = await import("./token.js"));
|
||||
});
|
||||
|
||||
it("derives baseUrl from token", async () => {
|
||||
it("derives baseUrl from token", () => {
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe(
|
||||
"https://api.example.com",
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ function requireStreamFn(streamFn: ReturnType<typeof wrapCopilotProviderStream>)
|
||||
}
|
||||
|
||||
describe("wrapCopilotAnthropicStream", () => {
|
||||
it("adds Copilot headers and Anthropic cache markers for Claude payloads", async () => {
|
||||
it("adds Copilot headers and Anthropic cache markers for Claude payloads", () => {
|
||||
const payloads: Array<{
|
||||
messages: Array<Record<string, unknown>>;
|
||||
}> = [];
|
||||
|
||||
@@ -20,6 +20,17 @@ const convertMessagesForTest = convertMessages as unknown as (
|
||||
context: Context,
|
||||
) => ReturnType<typeof convertMessages>;
|
||||
|
||||
function requireRecordProperty(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> {
|
||||
const value = record[key];
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected object property ${key}`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("google-shared convertTools", () => {
|
||||
it("preserves parameters when type is missing", () => {
|
||||
const tools = [
|
||||
@@ -41,7 +52,9 @@ describe("google-shared convertTools", () => {
|
||||
);
|
||||
|
||||
expect(params.type).toBeUndefined();
|
||||
expect(params.properties).toBeDefined();
|
||||
expect(params.properties).toEqual({
|
||||
action: { type: "string" },
|
||||
});
|
||||
expect(params.required).toEqual(["action"]);
|
||||
});
|
||||
|
||||
@@ -290,7 +303,9 @@ describe("google-shared convertMessages", () => {
|
||||
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
|
||||
);
|
||||
const toolResponse = asRecord(toolResponsePart);
|
||||
expect(toolResponse.functionResponse).toBeTruthy();
|
||||
expect(requireRecordProperty(toolResponse, "functionResponse")).toMatchObject({
|
||||
name: "myTool",
|
||||
});
|
||||
expect(contents[3].role).toBe("user");
|
||||
});
|
||||
|
||||
@@ -320,7 +335,9 @@ describe("google-shared convertMessages", () => {
|
||||
(part) => typeof part === "object" && part !== null && "functionCall" in part,
|
||||
);
|
||||
const toolCall = asRecord(toolCallPart);
|
||||
expect(toolCall.functionCall).toBeTruthy();
|
||||
expect(requireRecordProperty(toolCall, "functionCall")).toMatchObject({
|
||||
name: "myTool",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips tool call and response ids for google-gemini-cli", () => {
|
||||
|
||||
@@ -190,8 +190,10 @@ describe("google provider plugin hooks", () => {
|
||||
name: "Google Provider",
|
||||
});
|
||||
const provider = requireRegisteredProvider(providers, "google");
|
||||
expect(provider.resolveThinkingProfile).toBeDefined();
|
||||
const resolveThinkingProfile = provider.resolveThinkingProfile!;
|
||||
if (!provider.resolveThinkingProfile) {
|
||||
throw new Error("expected Google provider thinking profile resolver");
|
||||
}
|
||||
const resolveThinkingProfile = provider.resolveThinkingProfile;
|
||||
const gemini3Profile = resolveThinkingProfile({
|
||||
provider: "google",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
@@ -246,9 +248,11 @@ describe("google provider plugin hooks", () => {
|
||||
onClearAudio() {},
|
||||
});
|
||||
|
||||
expect(bridge).toBeDefined();
|
||||
expect(() => bridge?.sendAudio(Buffer.alloc(160))).not.toThrow();
|
||||
expect(() => bridge?.setMediaTimestamp(20)).not.toThrow();
|
||||
expect(() => bridge?.sendUserMessage?.("hello")).not.toThrow();
|
||||
if (!bridge) {
|
||||
throw new Error("expected Google realtime bridge");
|
||||
}
|
||||
expect(() => bridge.sendAudio(Buffer.alloc(160))).not.toThrow();
|
||||
expect(() => bridge.setMediaTimestamp(20)).not.toThrow();
|
||||
expect(() => bridge.sendUserMessage?.("hello")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("google model id helpers", () => {
|
||||
|
||||
it("keeps bare Gemini 3.1 Pro as an alias for Google's preview-suffixed API id", () => {
|
||||
expect(normalizeGoogleModelId("gemini-3-pro")).toBe("gemini-3.1-pro-preview");
|
||||
expect(normalizeGoogleModelId("gemini-3-pro-preview")).toBe("gemini-3.1-pro-preview");
|
||||
expect(normalizeGoogleModelId("gemini-3.1-pro")).toBe("gemini-3.1-pro-preview");
|
||||
expect(normalizeGoogleModelId("gemini-3.1-pro-preview")).toBe("gemini-3.1-pro-preview");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]);
|
||||
|
||||
export function normalizeGoogleModelId(id: string): string {
|
||||
if (id === "gemini-3-pro") {
|
||||
if (id === "gemini-3-pro" || id === "gemini-3-pro-preview") {
|
||||
return "gemini-3.1-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3-flash") {
|
||||
|
||||
@@ -450,7 +450,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
setOAuthCredentialsFsForTest();
|
||||
});
|
||||
|
||||
it("returns null when gemini binary is not in PATH", async () => {
|
||||
it("returns null when gemini binary is not in PATH", () => {
|
||||
process.env.PATH = "/nonexistent";
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
@@ -458,7 +458,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts credentials from oauth2.js in known path", async () => {
|
||||
it("extracts credentials from oauth2.js in known path", () => {
|
||||
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
|
||||
|
||||
clearCredentialsCache();
|
||||
@@ -467,7 +467,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
expectFakeCliCredentials(result);
|
||||
});
|
||||
|
||||
it("extracts credentials when PATH entry is an npm global shim", async () => {
|
||||
it("extracts credentials when PATH entry is an npm global shim", () => {
|
||||
installNpmShimLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
|
||||
|
||||
clearCredentialsCache();
|
||||
@@ -476,7 +476,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
expectFakeCliCredentials(result);
|
||||
});
|
||||
|
||||
it("extracts credentials from bundled npm installs", async () => {
|
||||
it("extracts credentials from bundled npm installs", () => {
|
||||
installBundledNpmLayout({
|
||||
bundleContent: `
|
||||
const OAUTH_CLIENT_ID = "${FAKE_CLIENT_ID}";
|
||||
@@ -490,7 +490,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
expectFakeCliCredentials(result);
|
||||
});
|
||||
|
||||
it("extracts credentials from Homebrew libexec installs", async () => {
|
||||
it("extracts credentials from Homebrew libexec installs", () => {
|
||||
installHomebrewLibexecLayout({ oauth2Content: FAKE_OAUTH2_CONTENT });
|
||||
|
||||
clearCredentialsCache();
|
||||
@@ -499,21 +499,21 @@ describe("extractGeminiCliCredentials", () => {
|
||||
expectFakeCliCredentials(result);
|
||||
});
|
||||
|
||||
it("returns null when oauth2.js cannot be found", async () => {
|
||||
it("returns null when oauth2.js cannot be found", () => {
|
||||
installGeminiLayout({ oauth2Exists: false, readdir: [] });
|
||||
|
||||
clearCredentialsCache();
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when oauth2.js lacks credentials", async () => {
|
||||
it("returns null when oauth2.js lacks credentials", () => {
|
||||
installGeminiLayout({ oauth2Exists: true, oauth2Content: "// no credentials here" });
|
||||
|
||||
clearCredentialsCache();
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("caches credentials after first extraction", async () => {
|
||||
it("caches credentials after first extraction", () => {
|
||||
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
|
||||
|
||||
clearCredentialsCache();
|
||||
@@ -529,7 +529,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
expect(mockReadFileSync.mock.calls.length).toBe(readCount);
|
||||
});
|
||||
|
||||
it("skips unrelated oauth2.js files when gemini resolves inside a Windows nvm root", async () => {
|
||||
it("skips unrelated oauth2.js files when gemini resolves inside a Windows nvm root", () => {
|
||||
const { unrelatedOauth2Path } = installWindowsNvmLayoutWithUnrelatedOauth({
|
||||
oauth2Content: FAKE_OAUTH2_CONTENT,
|
||||
unrelatedOauth2Content: "// unrelated oauth file",
|
||||
@@ -657,6 +657,23 @@ describe("loginGeminiCliOAuth", () => {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
function requireString(value: string | null | undefined, label: string): string {
|
||||
if (!value) {
|
||||
throw new Error(`Expected ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireRecordedRequest(
|
||||
request: RecordedFetchRequest | undefined,
|
||||
label: string,
|
||||
): RecordedFetchRequest {
|
||||
if (!request) {
|
||||
throw new Error(`Expected ${label} request`);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
type LoginGeminiCliOAuthFn = (options: {
|
||||
isRemote: boolean;
|
||||
openUrl: () => Promise<void>;
|
||||
@@ -757,8 +774,10 @@ describe("loginGeminiCliOAuth", () => {
|
||||
`gl-node/${process.versions.node}`,
|
||||
);
|
||||
|
||||
const clientMetadata = getHeaderValue(firstHeaders, "Client-Metadata");
|
||||
expect(clientMetadata).toBeDefined();
|
||||
const clientMetadata = requireString(
|
||||
getHeaderValue(firstHeaders, "Client-Metadata"),
|
||||
"Client-Metadata",
|
||||
);
|
||||
expect(parseJsonString(clientMetadata, "Client-Metadata")).toEqual(
|
||||
EXPECTED_LOAD_CODE_ASSIST_METADATA,
|
||||
);
|
||||
@@ -784,13 +803,16 @@ describe("loginGeminiCliOAuth", () => {
|
||||
const { loginGeminiCliOAuth } = await import("./oauth.js");
|
||||
const { authUrl } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
|
||||
|
||||
const authState = new URL(authUrl).searchParams.get("state");
|
||||
expect(authState).toBeTruthy();
|
||||
const authState = requireString(new URL(authUrl).searchParams.get("state"), "OAuth state");
|
||||
|
||||
const tokenRequest = requests.find((request) => request.url === TOKEN_URL);
|
||||
expect(tokenRequest).toBeDefined();
|
||||
const codeVerifier = getFormField(tokenRequest?.init?.body, "code_verifier");
|
||||
expect(codeVerifier).toBeTruthy();
|
||||
const tokenRequest = requireRecordedRequest(
|
||||
requests.find((request) => request.url === TOKEN_URL),
|
||||
"token",
|
||||
);
|
||||
const codeVerifier = requireString(
|
||||
getFormField(tokenRequest.init?.body, "code_verifier"),
|
||||
"PKCE code verifier",
|
||||
);
|
||||
expect(codeVerifier).not.toBe(authState);
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("googlechat message actions", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("describes send and reaction actions only when enabled accounts exist", async () => {
|
||||
it("describes send and reaction actions only when enabled accounts exist", () => {
|
||||
listEnabledGoogleChatAccounts.mockReturnValueOnce([]);
|
||||
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull();
|
||||
|
||||
|
||||
@@ -455,7 +455,9 @@ describe("googlechatPlugin outbound resolveTarget", () => {
|
||||
if (result.ok) {
|
||||
throw new Error("Expected invalid target to fail");
|
||||
}
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error.message).toBe(
|
||||
"Google Chat target is required (<spaces/{space}|users/{user}>)",
|
||||
);
|
||||
});
|
||||
|
||||
it("errors when no target is provided", () => {
|
||||
@@ -467,7 +469,9 @@ describe("googlechatPlugin outbound resolveTarget", () => {
|
||||
if (result.ok) {
|
||||
throw new Error("Expected missing target to fail");
|
||||
}
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error.message).toBe(
|
||||
"Google Chat target is required (<spaces/{space}|users/{user}>)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -378,7 +378,7 @@ describe("googlechat google auth runtime", () => {
|
||||
expect(second.interceptors.response.add).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("normalizes Google auth request headers before upstream interceptors run", async () => {
|
||||
it("normalizes Google auth request headers before upstream interceptors run", () => {
|
||||
const config = {
|
||||
headers: { "x-test": "1" },
|
||||
url: new URL("https://www.googleapis.com/oauth2/v1/certs"),
|
||||
|
||||
@@ -44,11 +44,53 @@ function readRequestBody(request: GuardRequest): string {
|
||||
return body;
|
||||
}
|
||||
|
||||
const guardedSuccessReleaseCases = [
|
||||
{
|
||||
name: "listInworldVoices",
|
||||
run: async () => {
|
||||
const { release } = queueGuardedResponse(
|
||||
new Response(JSON.stringify({ voices: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
await listInworldVoices({ apiKey: "test-key" });
|
||||
return release;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inworldTTS",
|
||||
run: async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
const { release } = queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
return release;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Inworld guarded dispatcher lifecycle", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(guardedSuccessReleaseCases)(
|
||||
"$name releases the guarded dispatcher after success",
|
||||
async ({ run }) => {
|
||||
const release = await run();
|
||||
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("listInworldVoices", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
@@ -148,16 +190,6 @@ describe("listInworldVoices", () => {
|
||||
|
||||
expect(lastGuardRequest().url).toBe("https://api.inworld.ai/voices/v1/voices?languages=EN_US");
|
||||
});
|
||||
|
||||
it("releases the guarded dispatcher after success", async () => {
|
||||
const { release } = queueGuardedResponse(
|
||||
new Response(JSON.stringify({ voices: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
await listInworldVoices({ apiKey: "test-key" });
|
||||
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inworldTTS", () => {
|
||||
@@ -297,17 +329,6 @@ describe("inworldTTS", () => {
|
||||
expect(buffer).toEqual(Buffer.from("audio"));
|
||||
});
|
||||
|
||||
it("releases the guarded dispatcher after success", async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
const { release } = queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases the guarded dispatcher after failure", async () => {
|
||||
const { release } = queueGuardedResponse(new Response("fail", { status: 500 }));
|
||||
|
||||
|
||||
@@ -420,10 +420,12 @@ describe("irc setup", () => {
|
||||
prompter,
|
||||
accountId: "work",
|
||||
});
|
||||
expect(updated).toBeDefined();
|
||||
if (!updated) {
|
||||
throw new Error("expected IRC allowFrom setup to return updated config");
|
||||
}
|
||||
|
||||
expect(updated?.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
|
||||
expect(updated?.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
|
||||
expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
|
||||
expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
||||
|
||||
@@ -19,6 +19,14 @@ import {
|
||||
const emptyCfg: OpenClawConfig = {};
|
||||
const KILOCODE_MODEL_IDS = ["kilo/auto"];
|
||||
|
||||
function requireKilocodeProvider(cfg: OpenClawConfig) {
|
||||
const provider = cfg.models?.providers?.kilocode;
|
||||
if (!provider) {
|
||||
throw new Error("expected Kilocode provider config");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
describe("Kilo Gateway provider config", () => {
|
||||
describe("constants", () => {
|
||||
it("KILOCODE_BASE_URL points to kilo openrouter endpoint", () => {
|
||||
@@ -50,10 +58,9 @@ describe("Kilo Gateway provider config", () => {
|
||||
describe("applyKilocodeProviderConfig", () => {
|
||||
it("registers kilocode provider with correct baseUrl and api", () => {
|
||||
const result = applyKilocodeProviderConfig(emptyCfg);
|
||||
const provider = result.models?.providers?.kilocode;
|
||||
expect(provider).toBeDefined();
|
||||
expect(provider?.baseUrl).toBe(KILOCODE_BASE_URL);
|
||||
expect(provider?.api).toBe("openai-completions");
|
||||
const provider = requireKilocodeProvider(result);
|
||||
expect(provider.baseUrl).toBe(KILOCODE_BASE_URL);
|
||||
expect(provider.api).toBe("openai-completions");
|
||||
});
|
||||
|
||||
it("includes the default model in the provider model list", () => {
|
||||
@@ -95,8 +102,7 @@ describe("Kilo Gateway provider config", () => {
|
||||
it("sets Kilo Gateway alias in agent default models", () => {
|
||||
const result = applyKilocodeProviderConfig(emptyCfg);
|
||||
const agentModel = result.agents?.defaults?.models?.[KILOCODE_DEFAULT_MODEL_REF];
|
||||
expect(agentModel).toBeDefined();
|
||||
expect(agentModel?.alias).toBe("Kilo Gateway");
|
||||
expect(agentModel).toMatchObject({ alias: "Kilo Gateway" });
|
||||
});
|
||||
|
||||
it("preserves existing alias if already set", () => {
|
||||
@@ -133,9 +139,8 @@ describe("Kilo Gateway provider config", () => {
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(
|
||||
KILOCODE_DEFAULT_MODEL_REF,
|
||||
);
|
||||
const provider = result.models?.providers?.kilocode;
|
||||
expect(provider).toBeDefined();
|
||||
expect(provider?.baseUrl).toBe(KILOCODE_BASE_URL);
|
||||
const provider = requireKilocodeProvider(result);
|
||||
expect(provider.baseUrl).toBe(KILOCODE_BASE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,17 @@ type MockKilocodeFetch = ((
|
||||
mock: { calls: unknown[][] };
|
||||
};
|
||||
|
||||
function requireModelById(
|
||||
models: Awaited<ReturnType<typeof discoverKilocodeModels>>,
|
||||
id: string,
|
||||
): Awaited<ReturnType<typeof discoverKilocodeModels>>[number] {
|
||||
const model = models.find((candidate) => candidate.id === id);
|
||||
if (!model) {
|
||||
throw new Error(`expected Kilocode model ${id}`);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function makeGatewayModel(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
@@ -115,14 +126,13 @@ describe("discoverKilocodeModels", () => {
|
||||
|
||||
it("static catalog has correct defaults for kilo/auto", async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
const auto = models.find((m) => m.id === "kilo/auto");
|
||||
expect(auto).toBeDefined();
|
||||
expect(auto?.name).toBe("Kilo Auto");
|
||||
expect(auto?.reasoning).toBe(true);
|
||||
expect(auto?.input).toEqual(["text", "image"]);
|
||||
expect(auto?.contextWindow).toBe(1000000);
|
||||
expect(auto?.maxTokens).toBe(128000);
|
||||
expect(auto?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
const auto = requireModelById(models, "kilo/auto");
|
||||
expect(auto.name).toBe("Kilo Auto");
|
||||
expect(auto.reasoning).toBe(true);
|
||||
expect(auto.input).toEqual(["text", "image"]);
|
||||
expect(auto.contextWindow).toBe(1000000);
|
||||
expect(auto.maxTokens).toBe(128000);
|
||||
expect(auto.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,14 +168,13 @@ describe("discoverKilocodeModels (fetch path)", () => {
|
||||
|
||||
expect(models.length).toBe(2);
|
||||
|
||||
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
|
||||
expect(sonnet).toBeDefined();
|
||||
expect(sonnet?.cost.input).toBeCloseTo(3.0);
|
||||
expect(sonnet?.cost.output).toBeCloseTo(15.0);
|
||||
expect(sonnet?.cost.cacheRead).toBeCloseTo(0.3);
|
||||
expect(sonnet?.cost.cacheWrite).toBeCloseTo(3.75);
|
||||
expect(sonnet?.input).toEqual(["text", "image"]);
|
||||
expect(sonnet?.reasoning).toBe(true);
|
||||
const sonnet = requireModelById(models, "anthropic/claude-sonnet-4");
|
||||
expect(sonnet.cost.input).toBeCloseTo(3.0);
|
||||
expect(sonnet.cost.output).toBeCloseTo(15.0);
|
||||
expect(sonnet.cost.cacheRead).toBeCloseTo(0.3);
|
||||
expect(sonnet.cost.cacheWrite).toBeCloseTo(3.75);
|
||||
expect(sonnet.input).toEqual(["text", "image"]);
|
||||
expect(sonnet.reasoning).toBe(true);
|
||||
expect(sonnet?.contextWindow).toBe(200000);
|
||||
expect(sonnet?.maxTokens).toBe(8192);
|
||||
});
|
||||
@@ -244,10 +253,9 @@ describe("discoverKilocodeModels (fetch path)", () => {
|
||||
});
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
const auto = models.find((m) => m.id === "kilo/auto");
|
||||
expect(auto).toBeDefined();
|
||||
expect(auto?.name).toBe("Kilo: Auto");
|
||||
expect(auto?.cost.input).toBeCloseTo(5.0);
|
||||
const auto = requireModelById(models, "kilo/auto");
|
||||
expect(auto.name).toBe("Kilo: Auto");
|
||||
expect(auto.cost.input).toBeCloseTo(5.0);
|
||||
expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,7 +327,7 @@ describe("buildLineMessageContext", () => {
|
||||
expect(context!.route.matchedBy).toBe("binding.peer");
|
||||
});
|
||||
|
||||
it("normalizes LINE ACP binding conversation ids through the plugin bindings surface", async () => {
|
||||
it("normalizes LINE ACP binding conversation ids through the plugin bindings surface", () => {
|
||||
const compiled = lineBindingsAdapter.compileConfiguredBinding({
|
||||
conversationId: "line:user:U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
@@ -346,7 +346,7 @@ describe("buildLineMessageContext", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes canonical LINE targets through the plugin bindings surface", async () => {
|
||||
it("normalizes canonical LINE targets through the plugin bindings surface", () => {
|
||||
const compiled = lineBindingsAdapter.compileConfiguredBinding({
|
||||
conversationId: "line:U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
|
||||
@@ -31,6 +31,16 @@ let getLineRuntimeState: typeof import("./monitor.js").getLineRuntimeState;
|
||||
let clearLineRuntimeStateForTests: typeof import("./monitor.js").clearLineRuntimeStateForTests;
|
||||
let innerLineWebhookHandlerMock: ReturnType<typeof vi.fn<LineNodeWebhookHandler>>;
|
||||
|
||||
function requireRegisteredRoute(): { handler: LineNodeWebhookHandler } {
|
||||
const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
|
||||
| { handler: LineNodeWebhookHandler }
|
||||
| undefined;
|
||||
if (!route) {
|
||||
throw new Error("expected registered LINE webhook route");
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
vi.mock("./bot.js", () => ({
|
||||
createLineBot: createLineBotMock,
|
||||
}));
|
||||
@@ -305,10 +315,7 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
|
||||
| { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
|
||||
| undefined;
|
||||
expect(route).toBeDefined();
|
||||
const route = requireRegisteredRoute();
|
||||
|
||||
const payload = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const signature = crypto.createHmac("SHA256", "second-secret").update(payload).digest("base64");
|
||||
@@ -318,7 +325,7 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
}) as unknown as IncomingMessage;
|
||||
const res = createRouteResponse();
|
||||
|
||||
await route!.handler(req, res);
|
||||
await route.handler(req, res);
|
||||
|
||||
const firstBot = createLineBotMock.mock.results[0]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
@@ -350,10 +357,7 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
|
||||
| { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
|
||||
| undefined;
|
||||
expect(route).toBeDefined();
|
||||
const route = requireRegisteredRoute();
|
||||
|
||||
const payload = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const signature = crypto.createHmac("SHA256", "shared-secret").update(payload).digest("base64");
|
||||
@@ -363,7 +367,7 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
}) as unknown as IncomingMessage;
|
||||
const res = createRouteResponse();
|
||||
|
||||
await route!.handler(req, res);
|
||||
await route.handler(req, res);
|
||||
|
||||
const firstBot = createLineBotMock.mock.results[0]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
@@ -391,10 +395,7 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
|
||||
| { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
|
||||
| undefined;
|
||||
expect(route).toBeDefined();
|
||||
const route = requireRegisteredRoute();
|
||||
const createHeldPostRequest = () => {
|
||||
const req = Object.assign(new EventEmitter(), {
|
||||
destroyed: false,
|
||||
@@ -420,12 +421,12 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
};
|
||||
|
||||
const firstRequests = Array.from({ length: limit }, () =>
|
||||
route!.handler(createHeldPostRequest(), createRouteResponse()),
|
||||
route.handler(createHeldPostRequest(), createRouteResponse()),
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const overflowResponse = createRouteResponse();
|
||||
await route!.handler(createSignedPostRequest(), overflowResponse);
|
||||
await route.handler(createSignedPostRequest(), overflowResponse);
|
||||
|
||||
const bot = createLineBotMock.mock.results[0]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -4,6 +4,18 @@ import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transfor
|
||||
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
type TestFlexMessage = {
|
||||
altText?: string;
|
||||
contents?: { footer?: { contents?: unknown[] }; body?: { contents?: unknown[] } };
|
||||
};
|
||||
|
||||
function requireFlexMessage(value: unknown, label: string): TestFlexMessage {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error(`expected flex message for ${label}`);
|
||||
}
|
||||
return value as TestFlexMessage;
|
||||
}
|
||||
|
||||
describe("hasLineDirectives", () => {
|
||||
it("matches expected detection across directive patterns", () => {
|
||||
const cases: Array<{ text: string; expected: boolean }> = [
|
||||
@@ -249,22 +261,18 @@ describe("parseLineDirectives", () => {
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as {
|
||||
altText?: string;
|
||||
contents?: { footer?: { contents?: unknown[] }; body?: { contents?: unknown[] } };
|
||||
};
|
||||
expect(flexMessage, testCase.name).toBeDefined();
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.name);
|
||||
if (testCase.expectedAltText !== undefined) {
|
||||
expect(flexMessage?.altText, testCase.name).toBe(testCase.expectedAltText);
|
||||
expect(flexMessage.altText, testCase.name).toBe(testCase.expectedAltText);
|
||||
}
|
||||
if (testCase.expectedText !== undefined) {
|
||||
expect(result.text, testCase.name).toBe(testCase.expectedText);
|
||||
}
|
||||
if (testCase.expectFooter) {
|
||||
expect(flexMessage?.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0);
|
||||
expect(flexMessage.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0);
|
||||
}
|
||||
if ("expectBodyContents" in testCase && testCase.expectBodyContents) {
|
||||
expect(flexMessage?.contents?.body?.contents, testCase.name).toBeDefined();
|
||||
expect(flexMessage.contents?.body?.contents, testCase.name).toEqual(expect.any(Array));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -285,9 +293,8 @@ describe("parseLineDirectives", () => {
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe(testCase.altText);
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.text);
|
||||
expect(flexMessage.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -307,9 +314,8 @@ describe("parseLineDirectives", () => {
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe(testCase.altText);
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.text);
|
||||
expect(flexMessage.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -329,9 +335,8 @@ describe("parseLineDirectives", () => {
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe(testCase.altText);
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.text);
|
||||
expect(flexMessage.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -351,10 +356,9 @@ describe("parseLineDirectives", () => {
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, testCase.text);
|
||||
if (testCase.contains) {
|
||||
expect(flexMessage?.altText).toContain(testCase.contains);
|
||||
expect(flexMessage.altText).toContain(testCase.contains);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -183,7 +183,7 @@ describe("line setup wizard", () => {
|
||||
expect(result.cfg.channels?.line?.channelSecret).toBe("line-secret");
|
||||
});
|
||||
|
||||
it("reads the named-account DM policy instead of the channel root", async () => {
|
||||
it("reads the named-account DM policy instead of the channel root", () => {
|
||||
expect(
|
||||
lineSetupWizard.dmPolicy?.getCurrent(
|
||||
{
|
||||
@@ -205,14 +205,14 @@ describe("line setup wizard", () => {
|
||||
).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", async () => {
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(lineSetupWizard.dmPolicy?.resolveConfigKeys?.({} as OpenClawConfig, "work")).toEqual({
|
||||
policyKey: "channels.line.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.line.accounts.work.allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted DM policy account context", async () => {
|
||||
it("uses configured defaultAccount for omitted DM policy account context", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
@@ -246,7 +246,7 @@ describe("line setup wizard", () => {
|
||||
expect(workAccount?.dmPolicy).toBe("open");
|
||||
});
|
||||
|
||||
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', async () => {
|
||||
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => {
|
||||
const next = lineSetupWizard.dmPolicy?.setPolicy(
|
||||
{
|
||||
channels: {
|
||||
|
||||
@@ -71,11 +71,17 @@ async function invokeWebhook(params: {
|
||||
headers?: Record<string, string>;
|
||||
onEvents?: ReturnType<typeof vi.fn>;
|
||||
autoSign?: boolean;
|
||||
runtime?: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
exit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}) {
|
||||
const onEventsMock = params.onEvents ?? vi.fn(async () => {});
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents: onEventsMock as never,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
|
||||
const headers = { ...params.headers };
|
||||
@@ -97,6 +103,99 @@ async function invokeWebhook(params: {
|
||||
return { res, onEvents: onEventsMock };
|
||||
}
|
||||
|
||||
const parseResponseBody = (body: unknown) => {
|
||||
if (typeof body !== "string") {
|
||||
return body;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
};
|
||||
|
||||
type WebhookPostResult = {
|
||||
body: unknown;
|
||||
contentType?: string;
|
||||
dispatched: ReturnType<typeof vi.fn>;
|
||||
runtimeError: ReturnType<typeof vi.fn>;
|
||||
status: number | undefined;
|
||||
};
|
||||
|
||||
type WebhookPostInvoker = (params: {
|
||||
failWith?: Error;
|
||||
rawBody: string;
|
||||
signed: boolean;
|
||||
}) => Promise<WebhookPostResult>;
|
||||
|
||||
async function invokeNodePostContract(params: {
|
||||
failWith?: Error;
|
||||
rawBody: string;
|
||||
signed: boolean;
|
||||
}) {
|
||||
const dispatched = vi.fn(async () => {
|
||||
if (params.failWith) {
|
||||
throw params.failWith;
|
||||
}
|
||||
});
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const handler = createLineNodeWebhookHandler({
|
||||
channelSecret: SECRET,
|
||||
bot: { handleWebhook: dispatched },
|
||||
runtime,
|
||||
readBody: async () => params.rawBody,
|
||||
});
|
||||
const { res, headers } = createRes();
|
||||
await handler(
|
||||
{
|
||||
method: "POST",
|
||||
headers: params.signed ? { "x-line-signature": sign(params.rawBody, SECRET) } : {},
|
||||
} as unknown as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
return {
|
||||
body: parseResponseBody(res.body),
|
||||
contentType: headers["content-type"],
|
||||
dispatched,
|
||||
runtimeError: runtime.error,
|
||||
status: res.statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeMiddlewarePostContract(params: {
|
||||
failWith?: Error;
|
||||
rawBody: string;
|
||||
signed: boolean;
|
||||
}) {
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const onEvents = vi.fn(async () => {
|
||||
if (params.failWith) {
|
||||
throw params.failWith;
|
||||
}
|
||||
});
|
||||
const { res, onEvents: dispatched } = await invokeWebhook({
|
||||
body: params.rawBody,
|
||||
headers: params.signed ? undefined : {},
|
||||
autoSign: params.signed,
|
||||
onEvents,
|
||||
runtime,
|
||||
});
|
||||
return {
|
||||
body: res.json.mock.calls.at(-1)?.[0],
|
||||
dispatched,
|
||||
runtimeError: runtime.error,
|
||||
status: res.status.mock.calls.at(-1)?.[0],
|
||||
};
|
||||
}
|
||||
|
||||
const sharedWebhookPostContractCases = [
|
||||
{ name: "node handler", invoke: invokeNodePostContract },
|
||||
{ name: "middleware", invoke: invokeMiddlewarePostContract },
|
||||
] satisfies Array<{
|
||||
name: string;
|
||||
invoke: WebhookPostInvoker;
|
||||
}>;
|
||||
|
||||
async function expectSignedRawBodyWins(params: { rawBody: string | Buffer; signedUserId: string }) {
|
||||
const onEvents = vi.fn(async () => {});
|
||||
const reqBody = {
|
||||
@@ -126,6 +225,66 @@ async function expectSignedRawBodyWins(params: { rawBody: string | Buffer; signe
|
||||
expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user");
|
||||
}
|
||||
|
||||
describe("LINE webhook shared POST contract", () => {
|
||||
it.each(sharedWebhookPostContractCases)(
|
||||
"$name rejects verification-shaped requests without a signature",
|
||||
async ({ invoke }) => {
|
||||
const result = await invoke({ rawBody: JSON.stringify({ events: [] }), signed: false });
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body).toEqual({ error: "Missing X-Line-Signature header" });
|
||||
if (result.contentType) {
|
||||
expect(result.contentType).toBe("application/json");
|
||||
}
|
||||
expect(result.dispatched).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(sharedWebhookPostContractCases)(
|
||||
"$name accepts signed verification-shaped requests without dispatching events",
|
||||
async ({ invoke }) => {
|
||||
const result = await invoke({ rawBody: JSON.stringify({ events: [] }), signed: true });
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({ status: "ok" });
|
||||
if (result.contentType) {
|
||||
expect(result.contentType).toBe("application/json");
|
||||
}
|
||||
expect(result.dispatched).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(sharedWebhookPostContractCases)(
|
||||
"$name rejects missing signature when events are non-empty",
|
||||
async ({ invoke }) => {
|
||||
const result = await invoke({
|
||||
rawBody: JSON.stringify({ events: [{ type: "message" }] }),
|
||||
signed: false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body).toEqual({ error: "Missing X-Line-Signature header" });
|
||||
expect(result.dispatched).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(sharedWebhookPostContractCases)(
|
||||
"$name returns 500 when event processing fails and does not acknowledge with 200",
|
||||
async ({ invoke }) => {
|
||||
const result = await invoke({
|
||||
failWith: new Error("transient failure"),
|
||||
rawBody: JSON.stringify({ events: [{ type: "message" }] }),
|
||||
signed: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.body).toEqual({ error: "Internal server error" });
|
||||
expect(result.dispatched).toHaveBeenCalledTimes(1);
|
||||
expect(result.runtimeError).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("createLineNodeWebhookHandler", () => {
|
||||
it("returns 200 for GET", async () => {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
@@ -161,32 +320,6 @@ describe("createLineNodeWebhookHandler", () => {
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects verification-shaped requests without a signature", async () => {
|
||||
const rawBody = JSON.stringify({ events: [] });
|
||||
const { bot, handler } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res, headers } = createRes();
|
||||
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(headers["content-type"]).toBe("application/json");
|
||||
expect(res.body).toBe(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts signed verification-shaped requests without dispatching events", async () => {
|
||||
const rawBody = JSON.stringify({ events: [] });
|
||||
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res, headers } = createRes();
|
||||
await runSignedPost({ handler, rawBody, secret, res });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(headers["content-type"]).toBe("application/json");
|
||||
expect(res.body).toBe(JSON.stringify({ status: "ok" }));
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 405 for non-GET/HEAD/POST methods", async () => {
|
||||
const { bot, handler } = createPostWebhookTestHarness(JSON.stringify({ events: [] }));
|
||||
|
||||
@@ -198,17 +331,6 @@ describe("createLineNodeWebhookHandler", () => {
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing signature when events are non-empty", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const { bot, handler } = createPostWebhookTestHarness(rawBody);
|
||||
|
||||
const { res } = createRes();
|
||||
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unsigned POST requests before reading the body", async () => {
|
||||
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
@@ -316,31 +438,6 @@ describe("createLineNodeWebhookHandler", () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const { secret } = createPostWebhookTestHarness(rawBody);
|
||||
const failingBot = {
|
||||
handleWebhook: vi.fn(async () => {
|
||||
throw new Error("transient failure");
|
||||
}),
|
||||
};
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const failingHandler = createLineNodeWebhookHandler({
|
||||
channelSecret: secret,
|
||||
bot: failingBot,
|
||||
runtime,
|
||||
readBody: async () => rawBody,
|
||||
});
|
||||
|
||||
const { res } = createRes();
|
||||
await runSignedPost({ handler: failingHandler, rawBody, secret, res });
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toBe(JSON.stringify({ error: "Internal server error" }));
|
||||
expect(failingBot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON payload even when signature is valid", async () => {
|
||||
const rawBody = "not json";
|
||||
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
||||
@@ -391,26 +488,6 @@ describe("createLineWebhookMiddleware", () => {
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects verification-shaped requests without a signature", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: JSON.stringify({ events: [] }),
|
||||
headers: {},
|
||||
autoSign: false,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts signed verification-shaped requests without dispatching events", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: JSON.stringify({ events: [] }),
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ status: "ok" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects oversized signed payloads before JSON parsing", async () => {
|
||||
const largeBody = JSON.stringify({ events: [], payload: "x".repeat(70 * 1024) });
|
||||
const { res, onEvents } = await invokeWebhook({ body: largeBody });
|
||||
@@ -419,17 +496,6 @@ describe("createLineWebhookMiddleware", () => {
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing signature when events are non-empty", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: JSON.stringify({ events: [{ type: "message" }] }),
|
||||
headers: {},
|
||||
autoSign: false,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects signed requests when raw body is missing", async () => {
|
||||
const { res, onEvents } = await invokeWebhook({
|
||||
body: { events: [{ type: "message" }] },
|
||||
@@ -484,30 +550,4 @@ describe("createLineWebhookMiddleware", () => {
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" });
|
||||
expect(onEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
|
||||
const onEvents = vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const middleware = createLineWebhookMiddleware({
|
||||
channelSecret: SECRET,
|
||||
onEvents,
|
||||
runtime,
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: { "x-line-signature": sign(rawBody, SECRET) },
|
||||
body: rawBody,
|
||||
} as any;
|
||||
const res = createMiddlewareRes();
|
||||
|
||||
await middleware(req, res, {} as any);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.status).not.toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,8 +74,10 @@ describe("lmstudio-models", () => {
|
||||
contextLength: number,
|
||||
) => {
|
||||
const loadCall = findModelLoadCall(fetchMock);
|
||||
expect(loadCall).toBeDefined();
|
||||
const loadInit = loadCall?.[1] as RequestInit;
|
||||
if (!loadCall) {
|
||||
throw new Error("expected LM Studio model load request");
|
||||
}
|
||||
const loadInit = loadCall[1] as RequestInit;
|
||||
const loadBody = parseJsonRequestBody(loadInit) as { context_length: number };
|
||||
expect(loadBody.context_length).toBe(contextLength);
|
||||
};
|
||||
@@ -361,8 +363,10 @@ describe("lmstudio-models", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const loadCall = findModelLoadCall(fetchMock);
|
||||
expect(loadCall).toBeDefined();
|
||||
expect(loadCall?.[1]).toMatchObject({
|
||||
if (!loadCall) {
|
||||
throw new Error("expected LM Studio model load request");
|
||||
}
|
||||
expect(loadCall[1]).toMatchObject({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Proxy-Auth": "required",
|
||||
|
||||
@@ -336,7 +336,7 @@ describe("lobster plugin tool", () => {
|
||||
).rejects.toThrow(/must stay within/);
|
||||
});
|
||||
|
||||
it("can be gated off in sandboxed contexts", async () => {
|
||||
it("can be gated off in sandboxed contexts", () => {
|
||||
const api = fakeApi();
|
||||
const factoryTool = (ctx: OpenClawPluginToolContext) => {
|
||||
if (ctx.sandboxed) {
|
||||
|
||||
@@ -51,11 +51,13 @@ describe("matrix plugin", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(typeof registrar).toBe("function");
|
||||
if (!registrar) {
|
||||
throw new Error("expected Matrix CLI registrar to be registered");
|
||||
}
|
||||
expect(cliMocks.registerMatrixCli).not.toHaveBeenCalled();
|
||||
|
||||
const program = { command: vi.fn() };
|
||||
const result = registrar?.({ program } as never);
|
||||
const result = registrar({ program } as never);
|
||||
|
||||
await result;
|
||||
expect(cliMocks.registerMatrixCli).toHaveBeenCalledWith({ program });
|
||||
|
||||
@@ -95,9 +95,11 @@ describe("matrixMessageActions", () => {
|
||||
expect(discovery.mediaSourceParams).toEqual({
|
||||
"set-profile": ["avatarUrl", "avatarPath"],
|
||||
});
|
||||
expect(properties.displayName).toBeDefined();
|
||||
expect(properties.avatarUrl).toBeDefined();
|
||||
expect(properties.avatarPath).toBeDefined();
|
||||
expect(properties).toMatchObject({
|
||||
displayName: expect.any(Object),
|
||||
avatarUrl: expect.any(Object),
|
||||
avatarPath: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it("hides self-profile updates for non-owner discovery", () => {
|
||||
|
||||
@@ -7,6 +7,25 @@ import { resolveMatrixConfigForAccount } from "./matrix/client/config.js";
|
||||
import { installMatrixTestRuntime } from "./test-runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function requireMatrixDirectory() {
|
||||
const directory = matrixPlugin.directory;
|
||||
if (!directory?.listPeers || !directory.listGroups) {
|
||||
throw new Error("expected Matrix directory listPeers/listGroups");
|
||||
}
|
||||
return {
|
||||
listPeers: directory.listPeers,
|
||||
listGroups: directory.listGroups,
|
||||
};
|
||||
}
|
||||
|
||||
function requireMatrixReplyToModeResolver() {
|
||||
const resolveReplyToMode = matrixPlugin.threading?.resolveReplyToMode;
|
||||
if (!resolveReplyToMode) {
|
||||
throw new Error("expected Matrix replyToMode resolver");
|
||||
}
|
||||
return resolveReplyToMode;
|
||||
}
|
||||
|
||||
describe("matrix directory", () => {
|
||||
const runtimeEnv: RuntimeEnv = createRuntimeEnv();
|
||||
|
||||
@@ -28,12 +47,10 @@ describe("matrix directory", () => {
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.directory).toBeTruthy();
|
||||
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
|
||||
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
|
||||
const directory = requireMatrixDirectory();
|
||||
|
||||
await expect(
|
||||
matrixPlugin.directory!.listPeers!({
|
||||
directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
@@ -50,7 +67,7 @@ describe("matrix directory", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
matrixPlugin.directory!.listGroups!({
|
||||
directory.listGroups({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
@@ -79,16 +96,16 @@ describe("matrix directory", () => {
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy();
|
||||
const resolveReplyToMode = requireMatrixReplyToModeResolver();
|
||||
expect(
|
||||
matrixPlugin.threading?.resolveReplyToMode?.({
|
||||
resolveReplyToMode({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
matrixPlugin.threading?.resolveReplyToMode?.({
|
||||
resolveReplyToMode({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
|
||||
@@ -44,11 +44,13 @@ describe("matrix channel message adapter", () => {
|
||||
|
||||
it("backs declared durable-final capabilities with runtime outbound proofs", async () => {
|
||||
const adapter = matrixPlugin.message;
|
||||
expect(adapter).toBeDefined();
|
||||
if (adapter?.send?.text === undefined || adapter.send.media === undefined) {
|
||||
throw new Error("expected matrix text and media message adapter");
|
||||
}
|
||||
|
||||
const proveText = async () => {
|
||||
mocks.sendMessageMatrix.mockClear();
|
||||
const result = await adapter!.send!.text!({
|
||||
const result = await adapter.send.text({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "hello",
|
||||
@@ -65,7 +67,7 @@ describe("matrix channel message adapter", () => {
|
||||
|
||||
const proveMedia = async () => {
|
||||
mocks.sendMessageMatrix.mockClear();
|
||||
const result = await adapter!.send!.media!({
|
||||
const result = await adapter.send.media({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "caption",
|
||||
|
||||
@@ -35,13 +35,18 @@ describe("matrix doctor", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function normalizeMatrixDmConfig(dm: Record<string, unknown>) {
|
||||
function runMatrixCompatibilityNormalize(
|
||||
params: Parameters<NonNullable<typeof matrixDoctor.normalizeCompatibilityConfig>>[0],
|
||||
) {
|
||||
const normalize = matrixDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
throw new Error("expected Matrix doctor compatibility normalizer");
|
||||
}
|
||||
return normalize({
|
||||
return normalize(params);
|
||||
}
|
||||
|
||||
function normalizeMatrixDmConfig(dm: Record<string, unknown>) {
|
||||
return runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -155,13 +160,7 @@ describe("matrix doctor", () => {
|
||||
});
|
||||
|
||||
it("normalizes legacy Matrix room allow aliases to enabled", () => {
|
||||
const normalize = matrixDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -213,13 +212,7 @@ describe("matrix doctor", () => {
|
||||
});
|
||||
|
||||
it("normalizes legacy Matrix private-network aliases", () => {
|
||||
const normalize = matrixDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -261,13 +254,7 @@ describe("matrix doctor", () => {
|
||||
});
|
||||
|
||||
it("migrates legacy channels.matrix.dm.policy 'trusted' with allowFrom to 'allowlist'", () => {
|
||||
const normalize = matrixDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -331,13 +318,7 @@ describe("matrix doctor", () => {
|
||||
});
|
||||
|
||||
it("migrates legacy per-account channels.matrix.accounts.<id>.dm.policy 'trusted'", () => {
|
||||
const normalize = matrixDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -383,13 +364,7 @@ describe("matrix doctor", () => {
|
||||
});
|
||||
|
||||
it("leaves modern dm.policy values untouched", () => {
|
||||
const normalize = matrixDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("FileBackedMatrixSyncStore", () => {
|
||||
type: "com.openclaw.test",
|
||||
},
|
||||
]);
|
||||
expect(savedSync?.roomsData.join?.["!room:example.org"]).toBeTruthy();
|
||||
expect(savedSync?.roomsData.join?.["!room:example.org"]).toEqual(expect.any(Object));
|
||||
expect(secondStore.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ describe("matrix credentials storage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates legacy matrix credential files on read", async () => {
|
||||
it("migrates legacy matrix credential files on read", () => {
|
||||
const { legacyPath, currentPath } = setupLegacyCredentialsFile({
|
||||
cfg: {
|
||||
channels: {
|
||||
|
||||
@@ -59,8 +59,10 @@ async function triggerInvite(
|
||||
inviteEvent: unknown = {},
|
||||
) {
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", inviteEvent);
|
||||
if (!inviteHandler) {
|
||||
throw new Error("expected Matrix invite handler");
|
||||
}
|
||||
await inviteHandler("!room:example.org", inviteEvent);
|
||||
}
|
||||
|
||||
describe("registerMatrixAutoJoin", () => {
|
||||
@@ -83,7 +85,7 @@ describe("registerMatrixAutoJoin", () => {
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
|
||||
it("does not auto-join invites by default", async () => {
|
||||
it("does not auto-join invites by default", () => {
|
||||
const { getInviteHandler, joinRoom } = registerAutoJoinHarness({});
|
||||
|
||||
expect(getInviteHandler()).toBeNull();
|
||||
@@ -144,8 +146,10 @@ describe("registerMatrixAutoJoin", () => {
|
||||
resolveRoom.mockRejectedValue(new Error("temporary homeserver failure"));
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await expect(inviteHandler!("!room:example.org", {})).resolves.toBeUndefined();
|
||||
if (!inviteHandler) {
|
||||
throw new Error("expected Matrix invite handler");
|
||||
}
|
||||
await expect(inviteHandler("!room:example.org", {})).resolves.toBeUndefined();
|
||||
|
||||
expect(joinRoom).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
|
||||
@@ -282,7 +282,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidates direct-room membership cache on room member events", async () => {
|
||||
it("invalidates direct-room membership cache on room member events", () => {
|
||||
const { invalidateRoom, roomEventListener } = createHarness();
|
||||
|
||||
roomEventListener("!room:example.org", {
|
||||
@@ -299,7 +299,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
expect(invalidateRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
|
||||
it("remembers invite provenance on room invites", async () => {
|
||||
it("remembers invite provenance on room invites", () => {
|
||||
const { invalidateRoom, rememberInvite, roomInviteListener } = createHarness();
|
||||
if (!roomInviteListener) {
|
||||
throw new Error("room.invite listener was not registered");
|
||||
@@ -321,7 +321,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
expect(rememberInvite).toHaveBeenCalledWith("!room:example.org", "@alice:example.org");
|
||||
});
|
||||
|
||||
it("ignores lifecycle-only invite events emitted with self sender ids", async () => {
|
||||
it("ignores lifecycle-only invite events emitted with self sender ids", () => {
|
||||
const { invalidateRoom, rememberInvite, roomInviteListener } = createHarness();
|
||||
if (!roomInviteListener) {
|
||||
throw new Error("room.invite listener was not registered");
|
||||
@@ -342,7 +342,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
expect(rememberInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remembers invite provenance even when Matrix omits the direct invite hint", async () => {
|
||||
it("remembers invite provenance even when Matrix omits the direct invite hint", () => {
|
||||
const { invalidateRoom, rememberInvite, roomInviteListener } = createHarness();
|
||||
if (!roomInviteListener) {
|
||||
throw new Error("room.invite listener was not registered");
|
||||
@@ -363,7 +363,7 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
expect(rememberInvite).toHaveBeenCalledWith("!room:example.org", "@alice:example.org");
|
||||
});
|
||||
|
||||
it("does not synthesize invite provenance from room joins", async () => {
|
||||
it("does not synthesize invite provenance from room joins", () => {
|
||||
const { invalidateRoom, rememberInvite, roomJoinListener } = createHarness();
|
||||
if (!roomJoinListener) {
|
||||
throw new Error("room.join listener was not registered");
|
||||
|
||||
@@ -3124,7 +3124,7 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
|
||||
// The draft stream should have received "Block two", not empty string.
|
||||
const sentBody = sendSingleTextMessageMatrixMock.mock.calls[0]?.[1];
|
||||
expect(sentBody).toBeTruthy();
|
||||
expect(sentBody).toBe("Block two");
|
||||
await finish();
|
||||
});
|
||||
|
||||
|
||||
@@ -31,9 +31,11 @@ describe("matrix reply context", () => {
|
||||
body: longBody,
|
||||
},
|
||||
} as MatrixRawEvent);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.length).toBeLessThanOrEqual(500);
|
||||
expect(result!.endsWith("...")).toBe(true);
|
||||
if (result === undefined) {
|
||||
throw new Error("expected truncated reply context");
|
||||
}
|
||||
expect(result.length).toBeLessThanOrEqual(500);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles media-only reply events", () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("resolveMatrixRoomConfig", () => {
|
||||
aliases: [],
|
||||
});
|
||||
expect(result.matchSource).toBe("direct");
|
||||
expect(result.config).toBeDefined();
|
||||
expect(result.config).toMatchObject({ enabled: true });
|
||||
});
|
||||
|
||||
it('returns matchSource="direct" for alias match', () => {
|
||||
@@ -52,7 +52,7 @@ describe("resolveMatrixRoomConfig", () => {
|
||||
aliases: ["#alias:example.org"],
|
||||
});
|
||||
expect(result.matchSource).toBe("direct");
|
||||
expect(result.config).toBeDefined();
|
||||
expect(result.config).toMatchObject({ enabled: true });
|
||||
});
|
||||
|
||||
it('returns matchSource="wildcard" for wildcard match', () => {
|
||||
@@ -62,7 +62,7 @@ describe("resolveMatrixRoomConfig", () => {
|
||||
aliases: [],
|
||||
});
|
||||
expect(result.matchSource).toBe("wildcard");
|
||||
expect(result.config).toBeDefined();
|
||||
expect(result.config).toMatchObject({ enabled: true });
|
||||
});
|
||||
|
||||
it("returns undefined matchSource when no match", () => {
|
||||
|
||||
@@ -600,8 +600,11 @@ describe("MatrixClient request hardening", () => {
|
||||
});
|
||||
|
||||
const store = lastCreateClientOpts?.store as { flush: () => Promise<void> } | undefined;
|
||||
expect(store).toBeTruthy();
|
||||
const flushSpy = vi.spyOn(store!, "flush").mockResolvedValue();
|
||||
if (!store) {
|
||||
throw new Error("expected Matrix sync store");
|
||||
}
|
||||
expect(store.flush).toEqual(expect.any(Function));
|
||||
const flushSpy = vi.spyOn(store, "flush").mockResolvedValue();
|
||||
|
||||
await client.stopAndPersist();
|
||||
|
||||
@@ -1733,7 +1736,13 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
expect(status.verified).toBe(true);
|
||||
expect(status.backup).toBeDefined();
|
||||
expect(status.backup).toMatchObject({
|
||||
serverVersion: null,
|
||||
activeVersion: null,
|
||||
trusted: null,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
});
|
||||
expect(status.serverDeviceKnown).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user