mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
test(providers, channels, sessions): remove low-value tests (batch d047) (#159120)
* test(kilocode): deslop s308 tests * test(bonjour): deslop s314 tests * test(raft): deslop s315 tests * test(byteplus): deslop s301 tests * test(alibaba): deslop s311 tests * test(session-url): deslop s309 tests * test(imap): deslop s305 tests * test(memory-host-sdk): deslop s302 tests * test(talk-voice): deslop s318 tests * test(voyage): deslop s304 tests * test(voyage): retain terminal fixture error path
This commit is contained in:
parent
bfff4f4cbd
commit
915e37b7dd
21 changed files with 783 additions and 2865 deletions
|
|
@ -17,27 +17,15 @@ import {
|
|||
} from "openclaw/plugin-sdk/provider-http-test-mocks";
|
||||
import {
|
||||
expectDashscopeVideoTaskPoll,
|
||||
expectExplicitVideoGenerationCapabilities,
|
||||
expectSuccessfulDashscopeVideoResult,
|
||||
mockSuccessfulDashscopeVideoTask,
|
||||
} from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
// Alibaba tests cover video generation provider plugin behavior.
|
||||
import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import {
|
||||
DASHSCOPE_WAN_VIDEO_MODELS,
|
||||
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
resolveApiKeyForProviderMock,
|
||||
postJsonRequestMock,
|
||||
fetchWithTimeoutMock,
|
||||
fetchWithTimeoutGuardedMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
sanitizeConfiguredModelProviderRequestMock,
|
||||
} = getProviderHttpMocks();
|
||||
const { resolveApiKeyForProviderMock, postJsonRequestMock, fetchWithTimeoutMock } =
|
||||
getProviderHttpMocks();
|
||||
|
||||
let alibabaVideoGenerationProvider: typeof import("./video-generation-provider.js").alibabaVideoGenerationProvider;
|
||||
|
||||
|
|
@ -104,38 +92,25 @@ describe("alibaba video generation provider", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitVideoGenerationCapabilities(alibabaVideoGenerationProvider);
|
||||
expect(alibabaVideoGenerationProvider).toMatchObject({
|
||||
id: "alibaba",
|
||||
label: "Alibaba Model Studio",
|
||||
defaultModel: DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
|
||||
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
|
||||
});
|
||||
});
|
||||
it("advertises Wan video generation with a config-only Standard API key", () => {
|
||||
clearAlibabaAuthEnvironment();
|
||||
|
||||
it.each(["sk-ws-alibaba-standard-key", "sk-alibaba-legacy-standard-key"])(
|
||||
"advertises Wan video generation with config-only Standard API key %s",
|
||||
(apiKey) => {
|
||||
clearAlibabaAuthEnvironment();
|
||||
|
||||
expect(
|
||||
alibabaVideoGenerationProvider.isConfigured?.({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
alibaba: {
|
||||
apiKey,
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com",
|
||||
models: [],
|
||||
},
|
||||
expect(
|
||||
alibabaVideoGenerationProvider.isConfigured?.({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
alibaba: {
|
||||
apiKey: "sk-alibaba-legacy-standard-key",
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not use Qwen Coding Plan credentials for Alibaba video discovery", () => {
|
||||
clearAlibabaAuthEnvironment();
|
||||
|
|
@ -157,29 +132,6 @@ describe("alibaba video generation provider", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["", "oauth:alibaba", "custom-local", "secretref-managed"])(
|
||||
"does not advertise a non-secret Alibaba credential marker %j",
|
||||
(apiKey) => {
|
||||
clearAlibabaAuthEnvironment();
|
||||
|
||||
expect(
|
||||
alibabaVideoGenerationProvider.isConfigured?.({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
alibaba: {
|
||||
apiKey,
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("tracks whether an allowed Alibaba API-key SecretRef resolves", () => {
|
||||
clearAlibabaAuthEnvironment();
|
||||
vi.stubEnv("ALIBABA_QA_CONFIG_KEY", "resolved-alibaba-config-key");
|
||||
|
|
@ -303,8 +255,7 @@ describe("alibaba video generation provider", () => {
|
|||
it("submits async Wan generation, polls task status, and downloads the resulting video", async () => {
|
||||
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
|
||||
|
||||
const provider = alibabaVideoGenerationProvider;
|
||||
const result = await provider.generateVideo({
|
||||
const result = await alibabaVideoGenerationProvider.generateVideo({
|
||||
provider: "alibaba",
|
||||
model: "wan2.6-r2v-flash",
|
||||
prompt: "animate this shot",
|
||||
|
|
@ -332,103 +283,4 @@ describe("alibaba video generation provider", () => {
|
|||
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock);
|
||||
expectSuccessfulDashscopeVideoResult(result);
|
||||
});
|
||||
|
||||
it("applies configured request policy to DashScope video requests", async () => {
|
||||
const requestPolicy = {
|
||||
allowPrivateNetwork: true,
|
||||
headers: { "X-DashScope-Route": "alibaba-policy" },
|
||||
};
|
||||
const dispatcherPolicy = { mode: "env-proxy" as const };
|
||||
resolveProviderHttpRequestConfigMock.mockImplementationOnce((params) => {
|
||||
const headers = new Headers(params.defaultHeaders);
|
||||
for (const [key, value] of Object.entries(params.request?.headers ?? {})) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
return {
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
|
||||
allowPrivateNetwork: params.request?.allowPrivateNetwork === true,
|
||||
headers,
|
||||
dispatcherPolicy,
|
||||
};
|
||||
});
|
||||
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
|
||||
|
||||
const provider = alibabaVideoGenerationProvider;
|
||||
await provider.generateVideo({
|
||||
provider: "alibaba",
|
||||
model: "wan2.6-t2v",
|
||||
prompt: "animate this shot",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
alibaba: {
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com",
|
||||
models: [],
|
||||
request: requestPolicy,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith(requestPolicy);
|
||||
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "alibaba",
|
||||
capability: "video",
|
||||
transport: "http",
|
||||
request: requestPolicy,
|
||||
}),
|
||||
);
|
||||
const request = requireFirstPostJsonRequest(
|
||||
postJsonRequestMock,
|
||||
"DashScope request with request policy",
|
||||
);
|
||||
expect(request.allowPrivateNetwork).toBe(true);
|
||||
expect(request.dispatcherPolicy).toBe(dispatcherPolicy);
|
||||
expect(request.headers).toBeInstanceOf(Headers);
|
||||
expect((request.headers as Headers).get("x-dashscope-route")).toBe("alibaba-policy");
|
||||
expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://dashscope-intl.aliyuncs.com/api/v1/tasks/task-1",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
expect.any(Number),
|
||||
fetch,
|
||||
{
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
dispatcherPolicy,
|
||||
},
|
||||
);
|
||||
expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://example.com/out.mp4",
|
||||
{ method: "GET" },
|
||||
expect.any(Number),
|
||||
fetch,
|
||||
{
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
dispatcherPolicy,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("fails fast when reference inputs are local buffers instead of remote URLs", async () => {
|
||||
const provider = alibabaVideoGenerationProvider;
|
||||
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "alibaba",
|
||||
model: "wan2.6-i2v",
|
||||
prompt: "animate this local frame",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("png-bytes"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Alibaba Wan video generation currently requires remote http(s) URLs for reference images/videos.",
|
||||
);
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,14 +37,7 @@ describe("bonjour plugin entry", () => {
|
|||
let discoveryService:
|
||||
| Parameters<ReturnType<typeof createTestPluginApi>["registerGatewayDiscoveryService"]>[0]
|
||||
| undefined;
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
const api = createTestPluginApi({
|
||||
logger,
|
||||
registerGatewayDiscoveryService(service) {
|
||||
discoveryService = service;
|
||||
},
|
||||
|
|
@ -66,36 +59,26 @@ describe("bonjour plugin entry", () => {
|
|||
const stop = vi.fn();
|
||||
mocks.startGatewayBonjourAdvertiser.mockResolvedValueOnce({ stop });
|
||||
|
||||
const advertisedOptions = {
|
||||
gatewayPort: 3210,
|
||||
gatewayTlsEnabled: true,
|
||||
gatewayTlsFingerprintSha256: "abc123",
|
||||
gatewayDirectReachable: true,
|
||||
sshPort: 22,
|
||||
tailnetDns: "dev.tailnet.ts.net",
|
||||
cliPath: "/usr/local/bin/openclaw",
|
||||
minimal: false,
|
||||
};
|
||||
await expect(
|
||||
discoveryService.advertise({
|
||||
machineDisplayName: "Dev Box",
|
||||
gatewayPort: 3210,
|
||||
gatewayTlsEnabled: true,
|
||||
gatewayTlsFingerprintSha256: "abc123",
|
||||
gatewayDirectReachable: true,
|
||||
sshPort: 22,
|
||||
tailnetDns: "dev.tailnet.ts.net",
|
||||
cliPath: "/usr/local/bin/openclaw",
|
||||
minimal: false,
|
||||
}),
|
||||
discoveryService.advertise({ ...advertisedOptions, machineDisplayName: "Dev Box" }),
|
||||
).resolves.toEqual({ stop });
|
||||
|
||||
expect(mocks.advertiserModuleLoaded).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runtimeModuleLoaded).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.startGatewayBonjourAdvertiser).toHaveBeenCalledWith(
|
||||
{ ...advertisedOptions, instanceName: "Dev Box (OpenClaw)" },
|
||||
{
|
||||
instanceName: "Dev Box (OpenClaw)",
|
||||
gatewayPort: 3210,
|
||||
gatewayTlsEnabled: true,
|
||||
gatewayTlsFingerprintSha256: "abc123",
|
||||
gatewayDirectReachable: true,
|
||||
sshPort: 22,
|
||||
tailnetDns: "dev.tailnet.ts.net",
|
||||
cliPath: "/usr/local/bin/openclaw",
|
||||
minimal: false,
|
||||
},
|
||||
{
|
||||
logger,
|
||||
logger: api.logger,
|
||||
registerUncaughtExceptionHandler: mocks.registerUncaughtExceptionHandler,
|
||||
registerUnhandledRejectionHandler: mocks.registerUnhandledRejectionHandler,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createService: vi.fn(),
|
||||
|
|
@ -22,19 +22,10 @@ const {
|
|||
registerUnhandledRejectionHandler,
|
||||
logger,
|
||||
} = mocks;
|
||||
const dnsLabelEncoder = new TextEncoder();
|
||||
|
||||
const stringOrFallback = (value: unknown, fallback: string) =>
|
||||
typeof value === "string" && value.trim() ? value : fallback;
|
||||
|
||||
function expectDnsLabelByteLength(value: string, expected: number) {
|
||||
expect(dnsLabelEncoder.encode(value).byteLength).toBe(expected);
|
||||
}
|
||||
|
||||
function expectDnsLabelWithinLimit(value: string) {
|
||||
expect(dnsLabelEncoder.encode(value).byteLength).toBeLessThanOrEqual(63);
|
||||
}
|
||||
|
||||
function warnMessages(): string[] {
|
||||
return logger.warn.mock.calls.map(([message]) => String(message));
|
||||
}
|
||||
|
|
@ -43,16 +34,7 @@ function expectWarnContaining(fragment: string) {
|
|||
expect(warnMessages().join("\n")).toContain(fragment);
|
||||
}
|
||||
|
||||
function mockCall(mock: ReturnType<typeof vi.fn>, index = 0): unknown[] {
|
||||
const call = mock.mock.calls.at(index);
|
||||
if (!call) {
|
||||
throw new Error(`Expected mock call ${index}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function enableAdvertiserUnitMode(hostname = "test-host") {
|
||||
// Allow advertiser to run in unit tests.
|
||||
vi.stubEnv("VITEST", undefined);
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
vi.spyOn(os, "hostname").mockReturnValue(hostname);
|
||||
|
|
@ -63,49 +45,31 @@ function mockCiaoService(params?: {
|
|||
advertise?: ReturnType<typeof vi.fn>;
|
||||
serviceState?: string;
|
||||
stateRef?: { value: string };
|
||||
on?: ReturnType<typeof vi.fn>;
|
||||
listenerMap?: Map<string, (value: unknown) => void>;
|
||||
responder?: Record<string, unknown>;
|
||||
}) {
|
||||
const advertise = params?.advertise ?? vi.fn().mockResolvedValue(undefined);
|
||||
const destroy = vi.fn().mockResolvedValue(undefined);
|
||||
const on =
|
||||
params?.on ??
|
||||
vi.fn((event: string, listener: (value: unknown) => void) => {
|
||||
params?.listenerMap?.set(event, listener);
|
||||
});
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
const service = {
|
||||
advertise,
|
||||
destroy,
|
||||
on,
|
||||
getFQDN: () =>
|
||||
`${stringOrFallback(options.type, "service")}.${stringOrFallback(options.domain, "local")}.`,
|
||||
getHostname: () => stringOrFallback(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
Object.defineProperty(service, "serviceState", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => params?.stateRef?.value ?? params?.serviceState ?? "announced",
|
||||
set: (value: string) => {
|
||||
if (params?.stateRef) {
|
||||
params.stateRef.value = value;
|
||||
}
|
||||
},
|
||||
});
|
||||
return service;
|
||||
const on = vi.fn((event: string, listener: (value: unknown) => void) => {
|
||||
params?.listenerMap?.set(event, listener);
|
||||
});
|
||||
createService.mockImplementation((options: Record<string, unknown>) => ({
|
||||
advertise,
|
||||
destroy,
|
||||
on,
|
||||
getFQDN: () =>
|
||||
`${stringOrFallback(options.type, "service")}.${stringOrFallback(options.domain, "local")}.`,
|
||||
getHostname: () => stringOrFallback(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
get serviceState() {
|
||||
return params?.stateRef?.value ?? params?.serviceState ?? "announced";
|
||||
},
|
||||
}));
|
||||
getResponder.mockReturnValue(params?.responder ?? { createService, shutdown });
|
||||
return { destroy };
|
||||
}
|
||||
|
||||
vi.mock("@homebridge/ciao", () => {
|
||||
return {
|
||||
Protocol: { TCP: "tcp" },
|
||||
getResponder,
|
||||
};
|
||||
});
|
||||
vi.mock("@homebridge/ciao", () => ({ getResponder }));
|
||||
|
||||
const { startGatewayBonjourAdvertiser } = await import("./advertiser.js");
|
||||
|
||||
|
|
@ -114,16 +78,15 @@ afterAll(() => {
|
|||
vi.resetModules();
|
||||
});
|
||||
|
||||
type StartGatewayBonjourAdvertiser = typeof startGatewayBonjourAdvertiser;
|
||||
|
||||
const startAdvertiser = (
|
||||
opts: Parameters<StartGatewayBonjourAdvertiser>[0],
|
||||
): ReturnType<StartGatewayBonjourAdvertiser> =>
|
||||
startGatewayBonjourAdvertiser(opts, {
|
||||
logger,
|
||||
registerUncaughtExceptionHandler: (handler) => registerUncaughtExceptionHandler(handler),
|
||||
registerUnhandledRejectionHandler: (handler) => registerUnhandledRejectionHandler(handler),
|
||||
});
|
||||
const startAdvertiser = (opts: Partial<Parameters<typeof startGatewayBonjourAdvertiser>[0]> = {}) =>
|
||||
startGatewayBonjourAdvertiser(
|
||||
{ gatewayPort: 18789, sshPort: 2222, ...opts },
|
||||
{
|
||||
logger,
|
||||
registerUncaughtExceptionHandler,
|
||||
registerUnhandledRejectionHandler,
|
||||
},
|
||||
);
|
||||
|
||||
describe("gateway bonjour advertiser", () => {
|
||||
type ServiceCall = {
|
||||
|
|
@ -133,23 +96,19 @@ describe("gateway bonjour advertiser", () => {
|
|||
txt?: unknown;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
enableAdvertiserUnitMode();
|
||||
mockCiaoService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
createService.mockClear();
|
||||
getResponder.mockReset();
|
||||
shutdown.mockClear();
|
||||
registerUncaughtExceptionHandler.mockClear();
|
||||
registerUnhandledRejectionHandler.mockClear();
|
||||
logger.info.mockClear();
|
||||
logger.warn.mockClear();
|
||||
logger.debug.mockClear();
|
||||
vi.resetAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not block on advertise and publishes expected txt keys", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
let resolveAdvertise = () => {};
|
||||
const advertise = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
|
|
@ -160,8 +119,6 @@ describe("gateway bonjour advertiser", () => {
|
|||
const { destroy } = mockCiaoService({ advertise });
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
gatewayDirectReachable: true,
|
||||
tailnetDns: "host.tailnet.ts.net",
|
||||
cliPath: "/opt/homebrew/bin/openclaw",
|
||||
|
|
@ -169,26 +126,24 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
expect(createService).toHaveBeenCalledTimes(1);
|
||||
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
|
||||
expect(gatewayCall?.[0]?.type).toBe("openclaw-gw");
|
||||
const gatewayType = stringOrFallback(gatewayCall?.[0]?.type, "");
|
||||
expect(gatewayType.length).toBeLessThanOrEqual(15);
|
||||
expect(gatewayCall?.[0]?.port).toBe(18789);
|
||||
expect(gatewayCall?.[0]?.domain).toBe("local");
|
||||
expect(gatewayCall?.[0]?.hostname).toBe("test-host");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayDirectReachable).toBe("1");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.tailnetDns).toBe(
|
||||
"host.tailnet.ts.net",
|
||||
expect(createService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "openclaw-gw",
|
||||
port: 18789,
|
||||
domain: "local",
|
||||
hostname: "test-host",
|
||||
txt: expect.objectContaining({
|
||||
lanHost: "test-host.local",
|
||||
gatewayPort: "18789",
|
||||
gatewayDirectReachable: "1",
|
||||
sshPort: "2222",
|
||||
tailnetDns: "host.tailnet.ts.net",
|
||||
cliPath: "/opt/homebrew/bin/openclaw",
|
||||
transport: "gateway",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
|
||||
"/opt/homebrew/bin/openclaw",
|
||||
);
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.transport).toBe("gateway");
|
||||
|
||||
// We don't await `advertise()`, but it should still be called for each service.
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
resolveAdvertise();
|
||||
await Promise.resolve();
|
||||
|
|
@ -199,13 +154,7 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("omits cliPath and sshPort in minimal mode", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
cliPath: "/opt/homebrew/bin/openclaw",
|
||||
tailnetDns: "host.tailnet.ts.net",
|
||||
minimal: true,
|
||||
|
|
@ -220,107 +169,61 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("honors truthy OPENCLAW_DISABLE_BONJOUR values", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
vi.stubEnv("OPENCLAW_DISABLE_BONJOUR", "true");
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
expect(createService).not.toHaveBeenCalled();
|
||||
await expect(started.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-disables Bonjour in detected containers", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => String(filePath) === "/.dockerenv");
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
expect(createService).not.toHaveBeenCalled();
|
||||
await expect(started.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-disables Bonjour on Fly Machines without Docker sentinel files", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
vi.stubEnv("FLY_MACHINE_ID", "3d8d5459a03038");
|
||||
vi.stubEnv("FLY_APP_NAME", "openclaw-clawcks-test");
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n");
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
expect(createService).not.toHaveBeenCalled();
|
||||
await expect(started.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors explicit Bonjour opt-in inside detected containers", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
vi.stubEnv("OPENCLAW_DISABLE_BONJOUR", "0");
|
||||
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => String(filePath) === "/.dockerenv");
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
expect(createService).toHaveBeenCalledTimes(1);
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
it("attaches conflict listeners for services", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
const onCalls: Array<{ event: string }> = [];
|
||||
|
||||
const on = vi.fn((event: string) => {
|
||||
onCalls.push({ event });
|
||||
});
|
||||
mockCiaoService({ on });
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
|
||||
// 1 service × 2 listeners
|
||||
expect(onCalls.map((c) => c.event)).toEqual(["name-change", "hostname-change"]);
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
it("cleans up ciao process handlers after shutdown", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
const order: string[] = [];
|
||||
shutdown.mockImplementation(async () => {
|
||||
order.push("shutdown");
|
||||
});
|
||||
mockCiaoService();
|
||||
|
||||
const cleanupException = vi.fn(() => {
|
||||
order.push("cleanup-exception");
|
||||
});
|
||||
const cleanupRejection = vi.fn(() => {
|
||||
order.push("cleanup-rejection");
|
||||
});
|
||||
registerUncaughtExceptionHandler.mockImplementation(() => cleanupException);
|
||||
registerUnhandledRejectionHandler.mockImplementation(() => cleanupRejection);
|
||||
registerUncaughtExceptionHandler.mockReturnValue(cleanupException);
|
||||
registerUnhandledRejectionHandler.mockReturnValue(cleanupRejection);
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
await started.stop();
|
||||
|
||||
|
|
@ -332,16 +235,9 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("handles ciao netmask assertions at the bonjour caller", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
const started = await startAdvertiser();
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
|
||||
const exceptionHandler = mockCall(registerUncaughtExceptionHandler).at(0) as
|
||||
const exceptionHandler = registerUncaughtExceptionHandler.mock.calls[0]?.[0] as
|
||||
| ((reason: unknown) => boolean)
|
||||
| undefined;
|
||||
expect(exceptionHandler).toBeTypeOf("function");
|
||||
|
|
@ -362,21 +258,15 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("logs advertise failures without starting a competing retry loop", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const advertise = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
mockCiaoService({ advertise, serviceState: "unannounced" });
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
// initial advertise attempt happens immediately
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
|
||||
// allow promise rejection handler to run
|
||||
await Promise.resolve();
|
||||
expectWarnContaining("advertise failed");
|
||||
|
||||
|
|
@ -391,17 +281,12 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("handles advertise throwing synchronously", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
const advertise = vi.fn(() => {
|
||||
throw new Error("sync-fail");
|
||||
});
|
||||
mockCiaoService({ advertise, serviceState: "unannounced" });
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
expectWarnContaining("advertise threw");
|
||||
|
|
@ -410,68 +295,40 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("suppresses ciao self-probe retry console noise while advertising", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
const baseConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
mockCiaoService();
|
||||
const started = await startAdvertiser();
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
const baseConsoleLog = vi.fn();
|
||||
console.log = baseConsoleLog as typeof console.log;
|
||||
console.log(
|
||||
"[test._openclaw-gw._tcp.local.] failed probing with reason: Error: Can't probe for a service which is announced already. Received announcing for service test._openclaw-gw._tcp.local.. Trying again in 2 seconds!",
|
||||
);
|
||||
console.log("ordinary console line");
|
||||
|
||||
try {
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
expect(baseConsoleLog).toHaveBeenCalledTimes(1);
|
||||
expect(baseConsoleLog).toHaveBeenCalledWith("ordinary console line");
|
||||
|
||||
console.log(
|
||||
"[test._openclaw-gw._tcp.local.] failed probing with reason: Error: Can't probe for a service which is announced already. Received announcing for service test._openclaw-gw._tcp.local.. Trying again in 2 seconds!",
|
||||
);
|
||||
console.log("ordinary console line");
|
||||
|
||||
expect(baseConsoleLog).toHaveBeenCalledTimes(1);
|
||||
expect(baseConsoleLog).toHaveBeenCalledWith("ordinary console line");
|
||||
|
||||
await started.stop();
|
||||
} finally {
|
||||
console.log = originalConsoleLog;
|
||||
}
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
it("suppresses transient ciao ENODEV MDNS socket warnings while advertising", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
const baseConsoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
mockCiaoService();
|
||||
const started = await startAdvertiser();
|
||||
|
||||
const originalConsoleWarn = console.warn;
|
||||
const baseConsoleWarn = vi.fn();
|
||||
console.warn = baseConsoleWarn as typeof console.warn;
|
||||
// A Docker bridge disappears between ciao's interface polls; the send to
|
||||
// the removed interface fails with ENODEV, which ciao does not silence.
|
||||
console.warn(
|
||||
"Encountered MDNS socket error on socket 'br-abcdef123456': Error: send ENODEV 224.0.0.251:5353\n at ...",
|
||||
);
|
||||
console.warn("ordinary warning line");
|
||||
|
||||
try {
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
expect(baseConsoleWarn).toHaveBeenCalledTimes(1);
|
||||
expect(baseConsoleWarn).toHaveBeenCalledWith("ordinary warning line");
|
||||
|
||||
// A Docker bridge disappears between ciao's interface polls; the send to
|
||||
// the removed interface fails with ENODEV, which ciao does not silence.
|
||||
console.warn(
|
||||
"Encountered MDNS socket error on socket 'br-abcdef123456': Error: send ENODEV 224.0.0.251:5353\n at ...",
|
||||
);
|
||||
console.warn("ordinary warning line");
|
||||
|
||||
expect(baseConsoleWarn).toHaveBeenCalledTimes(1);
|
||||
expect(baseConsoleWarn).toHaveBeenCalledWith("ordinary warning line");
|
||||
|
||||
await started.stop();
|
||||
} finally {
|
||||
console.warn = originalConsoleWarn;
|
||||
}
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
it("does not monkey-patch responder methods during shutdown", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
const responder = {
|
||||
createService,
|
||||
shutdown,
|
||||
|
|
@ -480,18 +337,10 @@ describe("gateway bonjour advertiser", () => {
|
|||
probe: vi.fn(),
|
||||
republishService: vi.fn(),
|
||||
};
|
||||
const originalMethods = {
|
||||
advertiseService: responder.advertiseService,
|
||||
announce: responder.announce,
|
||||
probe: responder.probe,
|
||||
republishService: responder.republishService,
|
||||
};
|
||||
const originalMethods = { ...responder };
|
||||
mockCiaoService({ responder });
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
await started.stop();
|
||||
|
||||
expect(responder.advertiseService).toBe(originalMethods.advertiseService);
|
||||
|
|
@ -501,32 +350,18 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("does not clobber console.log if another wrapper replaced it before shutdown", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
const baseConsoleLog = vi.fn();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const replacementConsoleLog = vi.fn();
|
||||
console.log = baseConsoleLog as typeof console.log;
|
||||
|
||||
try {
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
console.log = replacementConsoleLog as typeof console.log;
|
||||
await started.stop();
|
||||
console.log = replacementConsoleLog as typeof console.log;
|
||||
await started.stop();
|
||||
|
||||
expect(console.log).toBe(replacementConsoleLog);
|
||||
} finally {
|
||||
console.log = originalConsoleLog;
|
||||
}
|
||||
expect(console.log).toBe(replacementConsoleLog);
|
||||
});
|
||||
|
||||
it("never overlaps ciao lifecycle states or conflict handling with another advertise call", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const stateRef = { value: "unannounced" };
|
||||
|
|
@ -534,10 +369,7 @@ describe("gateway bonjour advertiser", () => {
|
|||
const listenerMap = new Map<string, (value: unknown) => void>();
|
||||
const { destroy } = mockCiaoService({ advertise, stateRef, listenerMap });
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
expect(createService).toHaveBeenCalledTimes(1);
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -564,18 +396,13 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("makes advertiser shutdown idempotent", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
const cleanupException = vi.fn();
|
||||
const cleanupRejection = vi.fn();
|
||||
const { destroy } = mockCiaoService();
|
||||
registerUncaughtExceptionHandler.mockImplementation(() => cleanupException);
|
||||
registerUnhandledRejectionHandler.mockImplementation(() => cleanupRejection);
|
||||
registerUncaughtExceptionHandler.mockReturnValue(cleanupException);
|
||||
registerUnhandledRejectionHandler.mockReturnValue(cleanupRejection);
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
await Promise.all([started.stop(), started.stop()]);
|
||||
|
||||
|
|
@ -586,18 +413,10 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("normalizes hostnames with domains for service names", async () => {
|
||||
// Allow advertiser to run in unit tests.
|
||||
vi.stubEnv("VITEST", undefined);
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
|
||||
vi.stubEnv("OPENCLAW_MDNS_HOSTNAME", undefined);
|
||||
vi.spyOn(os, "hostname").mockReturnValue("Mac.localdomain");
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
expect(gatewayCall?.[0]?.name).toBe("Mac (OpenClaw)");
|
||||
|
|
@ -609,18 +428,10 @@ describe("gateway bonjour advertiser", () => {
|
|||
});
|
||||
|
||||
it("falls back to openclaw when system hostname is invalid for DNS", async () => {
|
||||
// Allow advertiser to run in unit tests.
|
||||
vi.stubEnv("VITEST", undefined);
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
vi.stubEnv("OPENCLAW_MDNS_HOSTNAME", undefined);
|
||||
vi.spyOn(os, "hostname").mockReturnValue("My_Lobster Host");
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
expect(gatewayCall?.[0]?.hostname).toBe("openclaw");
|
||||
|
|
@ -633,20 +444,15 @@ describe("gateway bonjour advertiser", () => {
|
|||
const reportedHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf";
|
||||
enableAdvertiserUnitMode(reportedHostname);
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
const serviceName = gatewayCall?.[0]?.name as string;
|
||||
const hostname = gatewayCall?.[0]?.hostname as string;
|
||||
|
||||
expectDnsLabelByteLength(`${reportedHostname} (OpenClaw)`, 64);
|
||||
expect(Buffer.byteLength(`${reportedHostname} (OpenClaw)`)).toBe(64);
|
||||
expect(hostname).toBe(reportedHostname);
|
||||
expectDnsLabelWithinLimit(serviceName);
|
||||
expect(Buffer.byteLength(serviceName)).toBeLessThanOrEqual(63);
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
|
@ -655,22 +461,17 @@ describe("gateway bonjour advertiser", () => {
|
|||
const longHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf-abcdefghij";
|
||||
enableAdvertiserUnitMode(longHostname);
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
const serviceName = gatewayCall?.[0]?.name as string;
|
||||
const hostname = gatewayCall?.[0]?.hostname as string;
|
||||
|
||||
expectDnsLabelByteLength(longHostname, 64);
|
||||
expectDnsLabelByteLength(hostname, 63);
|
||||
expect(Buffer.byteLength(longHostname)).toBe(64);
|
||||
expect(Buffer.byteLength(hostname)).toBe(63);
|
||||
expect(hostname).toBe(longHostname.slice(0, -1));
|
||||
expect(hostname).not.toMatch(/-$/);
|
||||
expectDnsLabelWithinLimit(serviceName);
|
||||
expect(Buffer.byteLength(serviceName)).toBeLessThanOrEqual(63);
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
|
@ -680,40 +481,14 @@ describe("gateway bonjour advertiser", () => {
|
|||
const cjkHostname = "你".repeat(21);
|
||||
enableAdvertiserUnitMode(cjkHostname);
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
const started = await startAdvertiser();
|
||||
|
||||
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
const serviceName = gatewayCall?.[0]?.name as string;
|
||||
|
||||
expectDnsLabelWithinLimit(serviceName);
|
||||
expect(Buffer.byteLength(serviceName)).toBeLessThanOrEqual(63);
|
||||
expect(serviceName).not.toMatch(/\uFFFD$/);
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
it("uses system hostname when OPENCLAW_MDNS_HOSTNAME is unset", async () => {
|
||||
// Allow advertiser to run in unit tests.
|
||||
vi.stubEnv("VITEST", undefined);
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
vi.stubEnv("OPENCLAW_MDNS_HOSTNAME", undefined);
|
||||
vi.spyOn(os, "hostname").mockReturnValue("Lobster");
|
||||
|
||||
mockCiaoService();
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
|
||||
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
expect(gatewayCall?.[0]?.hostname).toBe("Lobster");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Lobster.local");
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,42 +3,17 @@ import { describe, expect, it } from "vitest";
|
|||
const { classifyCiaoProcessError } = await import("./ciao.js");
|
||||
|
||||
describe("bonjour-ciao", () => {
|
||||
it("classifies ciao netmask assertions separately from side effects", () => {
|
||||
expect(
|
||||
classifyCiaoProcessError(
|
||||
Object.assign(
|
||||
new Error(
|
||||
"IP address version must match. Netmask cannot have a version different from the address!",
|
||||
),
|
||||
{ name: "AssertionError" },
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
kind: "netmask-assertion",
|
||||
formatted:
|
||||
"AssertionError: IP address version must match. Netmask cannot have a version different from the address!",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies networkInterfaces SystemError failures (restricted sandboxes)", () => {
|
||||
const err = Object.assign(
|
||||
new Error("A system error occurred: uv_interface_addresses returned Unknown system error 1"),
|
||||
{ name: "SystemError" },
|
||||
);
|
||||
expect(classifyCiaoProcessError(err)).toEqual({
|
||||
kind: "interface-enumeration-failure",
|
||||
formatted:
|
||||
"SystemError: A system error occurred: uv_interface_addresses returned Unknown system error 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses networkInterfaces failures wrapped in cause chains", () => {
|
||||
const inner = Object.assign(
|
||||
new Error("A system error occurred: uv_interface_addresses returned Unknown system error 1"),
|
||||
{ name: "SystemError" },
|
||||
);
|
||||
const wrapper = new Error("ciao NetworkManager init failed", { cause: inner });
|
||||
expect(classifyCiaoProcessError(wrapper)).not.toBe(null);
|
||||
expect(classifyCiaoProcessError(wrapper)).toEqual({
|
||||
kind: "interface-enumeration-failure",
|
||||
formatted:
|
||||
"SystemError: A system error occurred: uv_interface_addresses returned Unknown system error 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unrelated rejections visible", () => {
|
||||
|
|
|
|||
|
|
@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest";
|
|||
import { formatBonjourError } from "./errors.js";
|
||||
|
||||
describe("formatBonjourError", () => {
|
||||
it("formats named errors with their type prefix", () => {
|
||||
const err = new Error("timed out");
|
||||
err.name = "AbortError";
|
||||
expect(formatBonjourError(err)).toBe("AbortError: timed out");
|
||||
});
|
||||
|
||||
it("avoids duplicating named errors with blank messages", () => {
|
||||
const err = new Error("");
|
||||
err.name = "AbortError";
|
||||
expect(formatBonjourError(err)).toBe("AbortError");
|
||||
});
|
||||
|
||||
it("treats whitespace-only messages as blank", () => {
|
||||
const named = new Error(" ");
|
||||
named.name = "AbortError";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
// Byteplus tests cover video generation provider plugin behavior.
|
||||
import {
|
||||
getProviderHttpMocks,
|
||||
installProviderHttpMockCleanup,
|
||||
} from "openclaw/plugin-sdk/provider-http-test-mocks";
|
||||
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { streamedJsonResponse } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { VideoGenerationRequest } from "openclaw/plugin-sdk/video-generation";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
|
||||
|
||||
|
|
@ -15,29 +14,37 @@ beforeAll(async () => {
|
|||
({ buildBytePlusVideoGenerationProvider } = await import("./video-generation-provider.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_123" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
});
|
||||
installProviderHttpMockCleanup();
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function mockSuccessfulBytePlusTask(params?: { model?: string }) {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
id: "task_123",
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
function generateVideo(request: Partial<VideoGenerationRequest> = {}) {
|
||||
return buildBytePlusVideoGenerationProvider().generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "A lantern floats upward into the night sky",
|
||||
cfg: {},
|
||||
...request,
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulBytePlusTask(params?: { download?: Response }) {
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task_123",
|
||||
status: "succeeded",
|
||||
content: {
|
||||
video_url: "https://example.com/byteplus.mp4",
|
||||
},
|
||||
model: params?.model ?? "seedance-1-0-pro-250528",
|
||||
content: { video_url: "https://example.com/byteplus.mp4" },
|
||||
model: "seedance-1-0-pro-250528",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response("webm-bytes", { headers: { "content-type": "video/webm" } }),
|
||||
params?.download ?? new Response("webm-bytes", { headers: { "content-type": "video/webm" } }),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -76,29 +83,18 @@ function streamedVideoResponse(bytes: string): Response {
|
|||
);
|
||||
}
|
||||
|
||||
// Builds a JSON body larger than the shared 16 MiB readProviderJsonResponse cap so the
|
||||
// bounded reader cancels the stream mid-flight; if the cap were removed the reader would
|
||||
// buffer the whole advertised payload before parsing. Tracks how many bytes were pulled
|
||||
// and whether the stream was canceled so callers can assert the body was not fully read.
|
||||
function makeOversizedJsonStream(): {
|
||||
body: ReadableStream<Uint8Array>;
|
||||
maxBytes: number;
|
||||
totalBytes: number;
|
||||
state: { bytesPulled: number; canceled: boolean };
|
||||
} {
|
||||
const maxBytes = 16 * 1024 * 1024; // matches PROVIDER_JSON_RESPONSE_MAX_BYTES.
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
// Advertise twice the shared 16 MiB JSON cap to prove reads stop and cancel mid-stream.
|
||||
function makeOversizedJsonStream() {
|
||||
const maxBytes = 16 * 1024 * 1024;
|
||||
const chunk = new Uint8Array(1024 * 1024);
|
||||
const totalBytes = maxBytes * 2;
|
||||
const state = { bytesPulled: 0, canceled: false };
|
||||
let pulled = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (pulled >= TOTAL_CHUNKS) {
|
||||
if (state.bytesPulled >= totalBytes) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
pulled += 1;
|
||||
state.bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
|
|
@ -106,203 +102,58 @@ function makeOversizedJsonStream(): {
|
|||
state.canceled = true;
|
||||
},
|
||||
});
|
||||
return { body, maxBytes, totalBytes: TOTAL_CHUNKS * ONE_MIB, state };
|
||||
return { body, maxBytes, totalBytes, state };
|
||||
}
|
||||
|
||||
describe("byteplus video generation provider", () => {
|
||||
it("declares explicit mode capabilities", () => {
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
expectExplicitVideoGenerationCapabilities(provider);
|
||||
expect(provider.defaultModel).toBe("seedance-1-0-pro-250528");
|
||||
expect(provider.models).toEqual(["seedance-1-0-pro-250528", "seedance-1-5-pro-251215"]);
|
||||
});
|
||||
|
||||
it("creates a content-generation task, polls, and downloads the video", async () => {
|
||||
mockSuccessfulBytePlusTask();
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "A lantern floats upward into the night sky",
|
||||
cfg: {},
|
||||
});
|
||||
const result = await generateVideo();
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledTimes(1);
|
||||
const request = requireBytePlusPostRequest();
|
||||
expect(request.url).toBe(
|
||||
expect(requireBytePlusPostRequest().url).toBe(
|
||||
"https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks",
|
||||
);
|
||||
expect(result.videos).toHaveLength(1);
|
||||
const [video] = result.videos;
|
||||
if (!video) {
|
||||
throw new Error("Expected generated BytePlus video");
|
||||
}
|
||||
expect(video.fileName).toBe("video-1.webm");
|
||||
const metadata = result.metadata as Record<string, unknown>;
|
||||
expect(metadata.taskId).toBe("task_123");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "JSON error", contentType: "application/json", body: '{"error":"denied"}' },
|
||||
{ name: "problem JSON", contentType: "application/problem+json", body: '{"title":"denied"}' },
|
||||
{ name: "HTML", contentType: "text/html; charset=utf-8", body: "<html>sign in</html>" },
|
||||
{ name: "empty video", contentType: "video/mp4", body: "" },
|
||||
])("rejects a successful $name response as generated video", async ({ contentType, body }) => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task-invalid-download" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-invalid-download",
|
||||
status: "succeeded",
|
||||
content: { video_url: "https://example.com/invalid.mp4" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(body, { headers: { "content-type": contentType } }));
|
||||
|
||||
await expect(
|
||||
buildBytePlusVideoGenerationProvider().generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "invalid download",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("BytePlus generated video download: malformed video response");
|
||||
expect(result.videos[0]?.fileName).toBe("video-1.webm");
|
||||
expect(result.metadata).toMatchObject({ taskId: "task_123" });
|
||||
});
|
||||
|
||||
it("cancels the unread response body when a generated-video MIME type is rejected", async () => {
|
||||
const canceled = vi.fn();
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task-open-response" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-open-response",
|
||||
status: "succeeded",
|
||||
content: { video_url: "https://example.com/invalid.mp4" },
|
||||
mockSuccessfulBytePlusTask({
|
||||
download: new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}'));
|
||||
},
|
||||
cancel: canceled,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}'));
|
||||
},
|
||||
cancel: canceled,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
});
|
||||
|
||||
await expect(
|
||||
buildBytePlusVideoGenerationProvider().generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "open invalid response",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("BytePlus generated video download: malformed video response");
|
||||
await expect(generateVideo()).rejects.toThrow(
|
||||
"BytePlus generated video download: malformed video response",
|
||||
);
|
||||
expect(canceled).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases a rejected download body without awaiting a debug-capture tee branch", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task-captured-response" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
// The debug proxy clones every captured response, so the caller-facing body is one
|
||||
// branch of a live tee. Cancelling such a branch settles only once both branches
|
||||
// cancel, so awaiting it here would hang the download instead of surfacing the error.
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}'));
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
const captureClone = response.clone();
|
||||
const captureReader = captureClone.body?.getReader();
|
||||
await captureReader?.read();
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task-captured-response",
|
||||
status: "succeeded",
|
||||
content: { video_url: "https://example.com/invalid.mp4" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(response);
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await expect(
|
||||
Promise.race([
|
||||
buildBytePlusVideoGenerationProvider().generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "captured invalid response",
|
||||
cfg: {},
|
||||
}),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error("BytePlus download waited for a captured response clone"));
|
||||
}, 500);
|
||||
}),
|
||||
]),
|
||||
).rejects.toThrow("BytePlus generated video download: malformed video response");
|
||||
} finally {
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
await captureReader?.cancel().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects generated video downloads that exceed the configured media cap", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_too_large" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task_too_large",
|
||||
status: "succeeded",
|
||||
content: {
|
||||
video_url: "https://example.com/too-large.mp4",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
|
||||
mockSuccessfulBytePlusTask({ download: streamedVideoResponse("too-large") });
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "short video",
|
||||
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
|
||||
}),
|
||||
generateVideo({ cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } } }),
|
||||
).rejects.toThrow("BytePlus generated video download exceeds 1 bytes");
|
||||
});
|
||||
|
||||
it("shares one wall-clock deadline across download headers and body", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_slow_download" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task_slow_download",
|
||||
id: "task_123",
|
||||
status: "succeeded",
|
||||
content: { video_url: "https://example.com/slow.mp4" },
|
||||
}),
|
||||
|
|
@ -322,32 +173,19 @@ describe("byteplus video generation provider", () => {
|
|||
);
|
||||
});
|
||||
|
||||
const result = buildBytePlusVideoGenerationProvider().generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "slow download",
|
||||
timeoutMs: 100,
|
||||
cfg: {},
|
||||
});
|
||||
const assertion = expect(result).rejects.toThrow(
|
||||
const assertion = expect(generateVideo({ timeoutMs: 100 })).rejects.toThrow(
|
||||
"BytePlus generated video download timed out after 100ms",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("keeps the unified model for image requests and lowercases resolution", async () => {
|
||||
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
mockSuccessfulBytePlusTask();
|
||||
await generateVideo({
|
||||
prompt: "Animate this still image",
|
||||
resolution: "720P",
|
||||
inputImages: [{ url: "https://example.com/first-frame.png" }],
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(requireBytePlusPostBody()).toEqual({
|
||||
|
|
@ -365,165 +203,72 @@ describe("byteplus video generation provider", () => {
|
|||
});
|
||||
|
||||
it("maps declared providerOptions into the request body", async () => {
|
||||
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
|
||||
mockSuccessfulBytePlusTask();
|
||||
await generateVideo({ providerOptions: { seed: 42, draft: true, camera_fixed: false } });
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
expect(requireBytePlusPostBody()).toMatchObject({
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "A cinematic lobster montage",
|
||||
providerOptions: {
|
||||
seed: 42,
|
||||
draft: true,
|
||||
camera_fixed: false,
|
||||
},
|
||||
cfg: {},
|
||||
seed: 42,
|
||||
resolution: "480p",
|
||||
camera_fixed: false,
|
||||
});
|
||||
|
||||
const body = requireBytePlusPostBody();
|
||||
expect(body.model).toBe("seedance-1-0-pro-250528");
|
||||
expect(body.seed).toBe(42);
|
||||
expect(body.resolution).toBe("480p");
|
||||
expect(body.camera_fixed).toBe(false);
|
||||
});
|
||||
|
||||
it("drops malformed seed values before creating videos", async () => {
|
||||
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "A cinematic lobster montage",
|
||||
providerOptions: {
|
||||
seed: 1.5,
|
||||
},
|
||||
cfg: {},
|
||||
});
|
||||
mockSuccessfulBytePlusTask();
|
||||
await generateVideo({ providerOptions: { seed: 1.5 } });
|
||||
|
||||
expect(requireBytePlusPostBody()).not.toHaveProperty("seed");
|
||||
});
|
||||
|
||||
it("drops out-of-range duration values before creating videos", async () => {
|
||||
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "A cinematic lobster montage",
|
||||
durationSeconds: 99,
|
||||
cfg: {},
|
||||
});
|
||||
mockSuccessfulBytePlusTask();
|
||||
await generateVideo({ durationSeconds: 99 });
|
||||
|
||||
expect(requireBytePlusPostBody()).not.toHaveProperty("duration");
|
||||
});
|
||||
|
||||
it("drops malformed response duration metadata", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
id: "task_123",
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task_123",
|
||||
status: "succeeded",
|
||||
content: {
|
||||
video_url: "https://example.com/byteplus.mp4",
|
||||
},
|
||||
content: { video_url: "https://example.com/byteplus.mp4" },
|
||||
duration: 1.5,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(streamedVideoResponse("mp4-bytes"));
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "A lantern floats upward into the night sky",
|
||||
cfg: {},
|
||||
});
|
||||
const result = await generateVideo();
|
||||
|
||||
expect(result.metadata).toMatchObject({ duration: undefined });
|
||||
});
|
||||
|
||||
it("reports malformed create JSON with a provider-owned error", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("{ not valid json"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
release,
|
||||
});
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "bad create response",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("BytePlus video generation failed: malformed JSON response");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects status responses missing a task status", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_missing_status" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task_missing_status",
|
||||
content: {
|
||||
video_url: "https://example.com/byteplus.mp4",
|
||||
},
|
||||
id: "task_123",
|
||||
content: { video_url: "https://example.com/byteplus.mp4" },
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "missing status",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("BytePlus video status response missing task status");
|
||||
await expect(generateVideo()).rejects.toThrow(
|
||||
"BytePlus video status response missing task status",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed completed content", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_malformed_content" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
id: "task_malformed_content",
|
||||
id: "task_123",
|
||||
status: "succeeded",
|
||||
content: ["https://example.com/byteplus.mp4"],
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "malformed content",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("BytePlus video generation completed with malformed content");
|
||||
await expect(generateVideo()).rejects.toThrow(
|
||||
"BytePlus video generation completed with malformed content",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds the submit task JSON body and cancels an oversized stream", async () => {
|
||||
|
|
@ -537,29 +282,15 @@ describe("byteplus video generation provider", () => {
|
|||
release,
|
||||
});
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "oversized submit response",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
await expect(generateVideo()).rejects.toThrow(
|
||||
`BytePlus video generation failed: JSON response exceeds ${stream.maxBytes} bytes`,
|
||||
);
|
||||
expect(stream.state.canceled).toBe(true);
|
||||
// Only the bounded prefix is pulled, never the full advertised stream.
|
||||
expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
|
||||
// The submit request must still be released even though the body overflowed.
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bounds the poll status JSON body and cancels an oversized stream", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_oversized_poll" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
const stream = makeOversizedJsonStream();
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
new Response(stream.body, {
|
||||
|
|
@ -568,15 +299,7 @@ describe("byteplus video generation provider", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "oversized poll response",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
await expect(generateVideo()).rejects.toThrow(
|
||||
`BytePlus video status request failed: JSON response exceeds ${stream.maxBytes} bytes`,
|
||||
);
|
||||
expect(stream.state.canceled).toBe(true);
|
||||
|
|
|
|||
|
|
@ -1,54 +1,12 @@
|
|||
import type { ParsedMail } from "mailparser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { simpleParser } from "mailparser";
|
||||
import { expect, it } from "vitest";
|
||||
import { renderImapPrompt } from "./prompt.js";
|
||||
|
||||
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
|
||||
// on the ES2024 String.prototype.isWellFormed() runtime API.
|
||||
const UNPAIRED_SURROGATE_RE =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||
|
||||
function mail(text: string): ParsedMail {
|
||||
return {
|
||||
text,
|
||||
subject: "test",
|
||||
from: { text: "sender@example.com" },
|
||||
attachments: [],
|
||||
} as unknown as ParsedMail;
|
||||
}
|
||||
|
||||
describe("renderImapPrompt", () => {
|
||||
it("snippet cut does not split a surrogate pair", () => {
|
||||
const body = `${"x".repeat(239)}🙂tail`;
|
||||
const prompt = renderImapPrompt(mail(body), { includeBody: true, maxBytes: 20_000 }, false);
|
||||
expect(UNPAIRED_SURROGATE_RE.test(prompt)).toBe(false);
|
||||
const snippetLine = prompt.split("\n").find((line) => line.startsWith("Snippet: "));
|
||||
expect(snippetLine).toBeDefined();
|
||||
// The 240th UTF-16 code unit lands on the emoji's high surrogate; the
|
||||
// safe cut backs off before it rather than stranding a lone surrogate.
|
||||
expect(snippetLine?.endsWith("x")).toBe(true);
|
||||
expect(snippetLine?.includes("🙂")).toBe(false);
|
||||
});
|
||||
|
||||
it("byte cut cannot introduce replacement characters or split a code point", () => {
|
||||
const body = `${"A".repeat(100)}${"🙂".repeat(50)}`;
|
||||
// Emoji are 4 UTF-8 bytes each; sweep maxBytes across a range so the cut
|
||||
// lands at every interior byte offset (1, 2, and 3 bytes into an emoji).
|
||||
for (let maxBytes = 400; maxBytes <= 420; maxBytes++) {
|
||||
const result = renderImapPrompt(mail(body), { includeBody: true, maxBytes }, false);
|
||||
expect(result).toContain("[truncated: email content exceeded the configured byte limit]");
|
||||
expect(Buffer.byteLength(result)).toBeLessThanOrEqual(maxBytes);
|
||||
expect(UNPAIRED_SURROGATE_RE.test(result)).toBe(false);
|
||||
expect(result.includes("<22>")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("sourceTruncated appends the marker even under the byte limit", () => {
|
||||
const body = "hello world";
|
||||
const result = renderImapPrompt(mail(body), { includeBody: true, maxBytes: 20_000 }, true);
|
||||
expect(result.endsWith("[truncated: email content exceeded the configured byte limit]")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result).toContain(body);
|
||||
expect(UNPAIRED_SURROGATE_RE.test(result)).toBe(false);
|
||||
});
|
||||
it("sourceTruncated appends the marker even under the byte limit", async () => {
|
||||
const mail = await simpleParser("From: sender@example.com\r\nSubject: test\r\n\r\nhello world");
|
||||
const result = renderImapPrompt(mail, { includeBody: true, maxBytes: 20_000 }, true);
|
||||
expect(result.endsWith("[truncated: email content exceeded the configured byte limit]")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result).toContain("hello world");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { authenticate } from "mailauth";
|
||||
import { simpleParser } from "mailparser";
|
||||
import type { IdentifierAuthentication } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveImapConfig } from "./config.js";
|
||||
import { createImapAuthResult } from "./imap-test-support.js";
|
||||
import { renderImapPrompt } from "./prompt.js";
|
||||
import { evaluateImapSender } from "./sender-gate.js";
|
||||
|
||||
function account(overrides: Record<string, unknown> = {}) {
|
||||
|
|
@ -22,8 +20,8 @@ function account(overrides: Record<string, unknown> = {}) {
|
|||
}).accounts.inbox!;
|
||||
}
|
||||
|
||||
async function message(headers: string[], body = "Hello from a trusted sender") {
|
||||
const raw = Buffer.from([...headers, "", body].join("\r\n"));
|
||||
async function message(headers: string[]) {
|
||||
const raw = Buffer.from([...headers, "", "Hello from a trusted sender"].join("\r\n"));
|
||||
return { raw, mail: await simpleParser(raw), internalDate: new Date() };
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +29,6 @@ describe("IMAP sender admission", () => {
|
|||
it.each([
|
||||
["trusted@EXAMPLE.com", ["trusted@example.COM"], true],
|
||||
["person@example.com", ["@EXAMPLE.com"], true],
|
||||
["trusted@evil.example", ["trusted@example.com"], false],
|
||||
["Trusted@example.com", ["trusted@example.com"], false],
|
||||
])("matches sender %s against the actual addr-spec", async (sender, entries, accepted) => {
|
||||
const mail = await message([`From: ${sender}`, "To: reader+secret-token@example.com"]);
|
||||
|
|
@ -54,7 +51,7 @@ describe("IMAP sender admission", () => {
|
|||
|
||||
it("rejects a spoofed display name and ignores Reply-To", async () => {
|
||||
const mail = await message([
|
||||
'From: "trusted@example.com" <attacker@evil.example>',
|
||||
'From: "trusted@example.com" <trusted@evil.example>',
|
||||
"Reply-To: trusted@example.com",
|
||||
"To: reader@example.com",
|
||||
]);
|
||||
|
|
@ -68,11 +65,8 @@ describe("IMAP sender admission", () => {
|
|||
expect(authenticator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["From: trusted@example.com, attacker@evil.example"],
|
||||
["From: attacker@evil.example", "From: trusted@example.com"],
|
||||
])("rejects multi-From messages before authentication", async (...headers) => {
|
||||
const mail = await message(headers);
|
||||
it("rejects duplicate From headers before authentication", async () => {
|
||||
const mail = await message(["From: attacker@evil.example", "From: trusted@example.com"]);
|
||||
const authenticator = vi.fn(async () => createImapAuthResult("pass"));
|
||||
const verdict = await evaluateImapSender({ ...mail, account: account(), authenticator });
|
||||
expect(authenticator).not.toHaveBeenCalled();
|
||||
|
|
@ -101,30 +95,25 @@ describe("IMAP sender admission", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["neutral", "temperror", "none"] as const)(
|
||||
it.each(["temperror", "none"] as const)(
|
||||
"never dispatches on DMARC %s at the default verified threshold",
|
||||
async (result) => {
|
||||
const mail = await message([
|
||||
"From: trusted@example.com",
|
||||
"To: reader+wrong-token@example.com",
|
||||
]);
|
||||
const authentication =
|
||||
result === "neutral"
|
||||
? createImapAuthResult(result)
|
||||
: await authenticate(mail.raw, {
|
||||
disableArc: true,
|
||||
disableBimi: true,
|
||||
resolver: async () => {
|
||||
if (result === "temperror") {
|
||||
throw new Error("fixture DNS timeout");
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
const authentication = await authenticate(mail.raw, {
|
||||
disableArc: true,
|
||||
disableBimi: true,
|
||||
resolver: async () => {
|
||||
if (result === "temperror") {
|
||||
throw new Error("fixture DNS timeout");
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
expect(authentication.dmarc).toMatchObject({ status: { result } });
|
||||
if (result !== "neutral") {
|
||||
expect(authentication.dmarc).not.toHaveProperty("alignment");
|
||||
}
|
||||
expect(authentication.dmarc).not.toHaveProperty("alignment");
|
||||
const configured = account({
|
||||
addressTokens: [{ token: "expected-token", senders: ["trusted@example.com"] }],
|
||||
});
|
||||
|
|
@ -158,32 +147,24 @@ describe("IMAP sender admission", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["mutable", true],
|
||||
["unverified", true],
|
||||
["asserted", false],
|
||||
["verified", false],
|
||||
] satisfies [IdentifierAuthentication, boolean][])(
|
||||
"admits verified mail and applies the %s floor to unproven mail",
|
||||
async (min, acceptsUnproven) => {
|
||||
const mail = await message(["From: trusted@example.com"]);
|
||||
const configured = account({ senderAuth: { min } });
|
||||
for (const result of ["pass", "none", "temperror"] as const) {
|
||||
await expect(
|
||||
evaluateImapSender({
|
||||
...mail,
|
||||
account: configured,
|
||||
authenticator: async () => createImapAuthResult(result),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
accepted: result === "pass" || acceptsUnproven,
|
||||
strength: result === "pass" ? "verified" : "unverified",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
it("admits verified and unproven mail at an explicit unverified floor", async () => {
|
||||
const mail = await message(["From: trusted@example.com"]);
|
||||
const configured = account({ senderAuth: { min: "unverified" } });
|
||||
for (const result of ["pass", "none", "temperror"] as const) {
|
||||
await expect(
|
||||
evaluateImapSender({
|
||||
...mail,
|
||||
account: configured,
|
||||
authenticator: async () => createImapAuthResult(result),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
accepted: true,
|
||||
strength: result === "pass" ? "verified" : "unverified",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only configured Authentication-Results authorities", async () => {
|
||||
it("rejects untrusted Authentication-Results authorities", async () => {
|
||||
const configured = account({
|
||||
senderAuth: {
|
||||
min: "asserted",
|
||||
|
|
@ -203,17 +184,6 @@ describe("IMAP sender admission", () => {
|
|||
strength: "unverified",
|
||||
reason: "unverified-authentication",
|
||||
});
|
||||
const trusted = await message([
|
||||
"From: trusted@example.com",
|
||||
"Authentication-Results: mx.example.com; dmarc=pass header.from=example.com",
|
||||
]);
|
||||
await expect(
|
||||
evaluateImapSender({ ...trusted, account: configured, authenticator }),
|
||||
).resolves.toMatchObject({
|
||||
accepted: true,
|
||||
strength: "asserted",
|
||||
reason: "trusted-authserv-dmarc-pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("admits stale mail with a sender-bound token without evaluating authentication", async () => {
|
||||
|
|
@ -283,13 +253,6 @@ describe("IMAP sender admission", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("caps rendered prompts and records truncation", async () => {
|
||||
const parsed = await message(["From: trusted@example.com", "Subject: Large"], "🙂".repeat(500));
|
||||
const prompt = renderImapPrompt(parsed.mail, { includeBody: true, maxBytes: 256 });
|
||||
expect(Buffer.byteLength(prompt)).toBeLessThanOrEqual(256);
|
||||
expect(prompt).toContain("[truncated:");
|
||||
});
|
||||
|
||||
it("keeps authenticator exceptions retryable without claiming a mutable identifier", async () => {
|
||||
const mail = await message(["From: trusted@example.com"]);
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveImapConfig } from "./config.js";
|
||||
import { createImapTestRuntime } from "./imap-test-support.js";
|
||||
import {
|
||||
advanceImapCursor,
|
||||
countImapSkip,
|
||||
initializeImapCursor,
|
||||
recordImapAttempt,
|
||||
rememberImapMessage,
|
||||
} from "./state.js";
|
||||
import { countImapSkip, rememberImapMessage } from "./state.js";
|
||||
|
||||
describe("IMAP durable watcher state", () => {
|
||||
it("keeps healthy accounts available when a sibling SecretRef could not resolve", () => {
|
||||
|
|
@ -30,28 +24,8 @@ describe("IMAP durable watcher state", () => {
|
|||
expect(Object.keys(config.accounts)).toEqual(["healthy"]);
|
||||
});
|
||||
|
||||
it("baselines existing mail, resumes its cursor, and resets after UIDVALIDITY changes", async () => {
|
||||
it("deduplicates logical Message-IDs without growing the account ring", async () => {
|
||||
const { state } = createImapTestRuntime();
|
||||
expect(await initializeImapCursor(state, "account", "17", 42)).toMatchObject({
|
||||
kind: "baseline",
|
||||
cursor: { uidValidity: "17", lastSeenUid: 41 },
|
||||
});
|
||||
await advanceImapCursor(state, "account", "17", 45);
|
||||
expect(await initializeImapCursor(state, "account", "17", 46)).toMatchObject({
|
||||
kind: "resume",
|
||||
cursor: { lastSeenUid: 45 },
|
||||
});
|
||||
expect(await initializeImapCursor(state, "account", "18", 8)).toMatchObject({
|
||||
kind: "reset",
|
||||
cursor: { uidValidity: "18", lastSeenUid: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates claims and logical Message-IDs without growing the account ring", async () => {
|
||||
const { state } = createImapTestRuntime();
|
||||
const claim = { accountId: "account", uid: 1, recordedAt: 0 };
|
||||
expect(await state.claims.registerIfAbsent("account:17:1", claim)).toBe(true);
|
||||
expect(await state.claims.registerIfAbsent("account:17:1", claim)).toBe(false);
|
||||
for (let index = 0; index < 101; index++) {
|
||||
expect(await rememberImapMessage(state, "account", `<${index}@example.com>`)).toBe(true);
|
||||
}
|
||||
|
|
@ -59,10 +33,8 @@ describe("IMAP durable watcher state", () => {
|
|||
expect((await state.messageIds.lookup("account"))?.messageIds).toHaveLength(100);
|
||||
});
|
||||
|
||||
it("records bounded retries separately from final account skip counters", async () => {
|
||||
it("increments final account skip counters", async () => {
|
||||
const { state } = createImapTestRuntime();
|
||||
expect(await recordImapAttempt(state, "account:17:2", "temperror")).toBe(1);
|
||||
expect(await recordImapAttempt(state, "account:17:2", "temperror")).toBe(2);
|
||||
await countImapSkip(state, "account", "temperror");
|
||||
await countImapSkip(state, "account", "temperror");
|
||||
expect(await state.skips.lookup("account:temperror")).toEqual({ count: 2 });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { once } from "node:events";
|
||||
import { createServer, type Server, type Socket } from "node:net";
|
||||
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
|
||||
import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -20,12 +19,9 @@ class ScriptedImapServer {
|
|||
fetchGate: Promise<void> | undefined;
|
||||
private readonly server: Server;
|
||||
|
||||
constructor(
|
||||
private readonly supportsIdle = true,
|
||||
private readonly beforeGreeting?: () => Promise<void>,
|
||||
) {
|
||||
constructor(private readonly supportsIdle = true) {
|
||||
this.server = createServer((socket) => {
|
||||
void this.accept(socket);
|
||||
this.accept(socket);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -64,13 +60,12 @@ class ScriptedImapServer {
|
|||
});
|
||||
}
|
||||
|
||||
private async accept(socket: Socket): Promise<void> {
|
||||
private accept(socket: Socket): void {
|
||||
this.connectionCount++;
|
||||
this.sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.once("close", () => this.sockets.delete(socket));
|
||||
const capabilities = `IMAP4rev1${this.supportsIdle ? " IDLE" : ""}`;
|
||||
await this.beforeGreeting?.();
|
||||
socket.write(`* OK [CAPABILITY ${capabilities}] scripted IMAP ready\r\n`);
|
||||
let buffered = "";
|
||||
let idleTag: string | undefined;
|
||||
|
|
@ -152,10 +147,9 @@ async function startWatcher(
|
|||
supportsIdle?: boolean;
|
||||
rejectAuthentication?: boolean;
|
||||
account?: Partial<ImapAccountConfig>;
|
||||
beforeGreeting?: () => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const server = new ScriptedImapServer(options.supportsIdle, options.beforeGreeting);
|
||||
const server = new ScriptedImapServer(options.supportsIdle);
|
||||
server.rejectAuthentication = options.rejectAuthentication ?? false;
|
||||
activeServers.push(server);
|
||||
server.append("From: trusted@example.com\r\nSubject: Existing\r\n\r\nExisting email");
|
||||
|
|
@ -221,7 +215,6 @@ async function startWatcher(
|
|||
describe("IMAP watcher protocol boundary", () => {
|
||||
it.each([
|
||||
["unverified", "none", "", "strength=unverified", "text/plain"],
|
||||
["verified", "pass", "", "strength=verified", "text/html"],
|
||||
[
|
||||
"asserted",
|
||||
"none",
|
||||
|
|
@ -244,7 +237,6 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
addressTokens: [{ token: "secret-token", senders: ["trusted@example.com"] }],
|
||||
},
|
||||
});
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
|
||||
authenticator.mockResolvedValue(createImapAuthResult(dmarc));
|
||||
const body = contentType === "text/html" ? "<p>Email <b>content</b></p>" : "Email content";
|
||||
server.append(
|
||||
|
|
@ -273,12 +265,12 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
|
||||
it.each([
|
||||
{ boundary: "snippet", body: `${"x".repeat(239)}🙂tail`, maxBytes: 20_000, truncated: false },
|
||||
...[400, 401, 402, 403].map((maxBytes) => ({
|
||||
boundary: `UTF-8 byte budget ${maxBytes}`,
|
||||
{
|
||||
boundary: "UTF-8 byte budget",
|
||||
body: `${"A".repeat(100)}${"🙂".repeat(50)}`,
|
||||
maxBytes,
|
||||
maxBytes: 403,
|
||||
truncated: true,
|
||||
})),
|
||||
},
|
||||
])(
|
||||
"preserves Unicode through fetched mail at the $boundary limit",
|
||||
async ({ body, maxBytes, truncated }) => {
|
||||
|
|
@ -322,7 +314,6 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
addressTokens: [{ token: "secret-token", senders: ["@evil.example"] }],
|
||||
},
|
||||
});
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
|
||||
server.append(`${from}\r\nTo: reader+secret-token@example.com\r\n\r\nRejected mail`);
|
||||
await waitForCursor(2);
|
||||
expect(authenticator).not.toHaveBeenCalled();
|
||||
|
|
@ -342,7 +333,6 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
await startWatcher({
|
||||
account: { watch: { mode: "auto", pollSeconds: 0.02 } },
|
||||
});
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
|
||||
if (failure === "rejected admission") {
|
||||
dispatchHookAgentTurn.mockResolvedValueOnce({ ok: false, reason: "Gateway unavailable" });
|
||||
} else if (failure === "throwing admission") {
|
||||
|
|
@ -370,7 +360,6 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher({
|
||||
account: { watch: { mode: "auto", pollSeconds: 0.02 } },
|
||||
});
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
|
||||
dispatchHookAgentTurn.mockRejectedValue(new Error("Gateway unavailable"));
|
||||
server.append("From: trusted@example.com\r\nSubject: Exhausted\r\n\r\nNo admission");
|
||||
await waitForCursor(2);
|
||||
|
|
@ -390,7 +379,6 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher({
|
||||
account: { watch: { mode: "auto", pollSeconds: 0.02 } },
|
||||
});
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
|
||||
dispatchHookAgentTurn.mockImplementationOnce(async () => {
|
||||
// Keep admission unresolved across subsequent mailbox notifications and polls.
|
||||
server.append("From: trusted@example.com\r\nSubject: Later\r\n\r\nWait for earlier mail");
|
||||
|
|
@ -414,10 +402,9 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
});
|
||||
|
||||
it("stops pending admission retries when the watcher is stopped", async () => {
|
||||
const { server, watcher, state, context, dispatchHookAgentTurn } = await startWatcher({
|
||||
const { server, watcher, context, dispatchHookAgentTurn } = await startWatcher({
|
||||
account: { watch: { mode: "auto", pollSeconds: 0.1 } },
|
||||
});
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
|
||||
dispatchHookAgentTurn.mockRejectedValue(new Error("Gateway unavailable"));
|
||||
server.append("From: trusted@example.com\r\nSubject: Stop\r\n\r\nDo not retry after stop");
|
||||
await vi.waitFor(() => expect(context.logger.warn).toHaveBeenCalled());
|
||||
|
|
@ -431,25 +418,7 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
});
|
||||
|
||||
it("sweeps a pushed message through the real IMAP connection into one isolated hook dispatch", async () => {
|
||||
const connected = createDeferred<void>();
|
||||
const greeting = createDeferred<void>();
|
||||
let initialized = false;
|
||||
const starting = startWatcher({
|
||||
beforeGreeting: async () => {
|
||||
connected.resolve();
|
||||
await greeting.promise;
|
||||
},
|
||||
}).then((fixture) => {
|
||||
initialized = true;
|
||||
return fixture;
|
||||
});
|
||||
try {
|
||||
await connected.promise;
|
||||
expect(initialized).toBe(false);
|
||||
} finally {
|
||||
greeting.resolve();
|
||||
}
|
||||
const { server, state, dispatchHookAgentTurn, waitForCursor } = await starting;
|
||||
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({
|
||||
uidValidity: "17",
|
||||
lastSeenUid: 1,
|
||||
|
|
@ -484,8 +453,7 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
});
|
||||
|
||||
it("delivers mail that arrived during an IDLE connection interruption", async () => {
|
||||
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
|
||||
const { server, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
|
||||
server.disconnect();
|
||||
server.messages.push({
|
||||
uid: 2,
|
||||
|
|
@ -500,8 +468,7 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
});
|
||||
|
||||
it("coalesces a wakeup that arrives during an active sweep", async () => {
|
||||
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
|
||||
const { server, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
|
||||
let releaseFetch = () => {};
|
||||
server.fetchGate = new Promise<void>((resolve) => {
|
||||
releaseFetch = resolve;
|
||||
|
|
@ -521,8 +488,7 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
});
|
||||
|
||||
it("re-baselines a rotated UIDVALIDITY without replaying existing mail", async () => {
|
||||
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
|
||||
expect(await state.cursors.lookup("inbox")).toMatchObject({ uidValidity: "17" });
|
||||
const { server, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
|
||||
server.uidValidity = "18";
|
||||
server.disconnect();
|
||||
server.messages.push({
|
||||
|
|
@ -534,11 +500,10 @@ describe("IMAP watcher protocol boundary", () => {
|
|||
});
|
||||
|
||||
it("polls when the IMAP server does not advertise IDLE", async () => {
|
||||
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher({
|
||||
const { server, dispatchHookAgentTurn, waitForCursor } = await startWatcher({
|
||||
supportsIdle: false,
|
||||
account: { watch: { mode: "auto", pollSeconds: 0.02 } },
|
||||
});
|
||||
expect(await state.cursors.lookup("inbox")).toBeDefined();
|
||||
server.messages.push({
|
||||
uid: 2,
|
||||
raw: "From: trusted@example.com\r\nSubject: Poll\r\n\r\nPolled",
|
||||
|
|
|
|||
|
|
@ -71,35 +71,6 @@ describe("kilocode provider plugin", () => {
|
|||
expect(capturedPayload).not.toHaveProperty("reasoning");
|
||||
});
|
||||
|
||||
it("normalizes string stop to array in plugin-owned stream hook", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const payloads: Array<Record<string, unknown>> = [];
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = { stop: "\n" };
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
payloads.push(payload);
|
||||
return {} as never;
|
||||
};
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "kilocode",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "kilocode",
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(payloads[0]?.stop).toEqual(["\n"]);
|
||||
});
|
||||
|
||||
it("normalizes string stop after caller payload hooks", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const payloads: Array<Record<string, unknown>> = [];
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
// Kilocode tests cover onboard plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildKilocodeModelDefinition,
|
||||
KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
||||
|
|
@ -13,168 +10,57 @@ import {
|
|||
import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import { KILOCODE_BASE_URL } from "./provider-models.js";
|
||||
|
||||
const emptyCfg: OpenClawConfig = {};
|
||||
const KILOCODE_MODEL_IDS = ["kilo-auto/balanced"];
|
||||
|
||||
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", () => {
|
||||
expect(KILOCODE_BASE_URL).toBe("https://api.kilo.ai/api/gateway/");
|
||||
});
|
||||
|
||||
it("KILOCODE_DEFAULT_MODEL_REF includes provider prefix", () => {
|
||||
expect(KILOCODE_DEFAULT_MODEL_REF).toBe("kilocode/kilo-auto/balanced");
|
||||
});
|
||||
|
||||
it("KILOCODE_DEFAULT_MODEL_ID is kilo-auto/balanced", () => {
|
||||
expect(KILOCODE_DEFAULT_MODEL_ID).toBe("kilo-auto/balanced");
|
||||
});
|
||||
it("builds the public default model definition", () => {
|
||||
const model = buildKilocodeModelDefinition();
|
||||
expect(model.id).toBe(KILOCODE_DEFAULT_MODEL_ID);
|
||||
expect(model.name).toBe("Auto Balanced");
|
||||
expect(model.reasoning).toBe(true);
|
||||
expect(model.input).toEqual(["text", "image"]);
|
||||
expect(model.contextWindow).toBe(KILOCODE_DEFAULT_CONTEXT_WINDOW);
|
||||
expect(model.maxTokens).toBe(KILOCODE_DEFAULT_MAX_TOKENS);
|
||||
expect(model.cost).toEqual(KILOCODE_DEFAULT_COST);
|
||||
});
|
||||
|
||||
describe("buildKilocodeModelDefinition", () => {
|
||||
it("returns correct model shape", () => {
|
||||
const model = buildKilocodeModelDefinition();
|
||||
expect(model.id).toBe(KILOCODE_DEFAULT_MODEL_ID);
|
||||
expect(model.name).toBe("Auto Balanced");
|
||||
expect(model.reasoning).toBe(true);
|
||||
expect(model.input).toEqual(["text", "image"]);
|
||||
expect(model.contextWindow).toBe(KILOCODE_DEFAULT_CONTEXT_WINDOW);
|
||||
expect(model.maxTokens).toBe(KILOCODE_DEFAULT_MAX_TOKENS);
|
||||
expect(model.cost).toEqual(KILOCODE_DEFAULT_COST);
|
||||
});
|
||||
it("seeds the default model in replace mode", () => {
|
||||
const result = applyKilocodeConfig({ models: { mode: "replace" } });
|
||||
expect(result.models?.providers?.kilocode?.models.map((model) => model.id)).toEqual([
|
||||
"kilo-auto/balanced",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("applyKilocodeConfig", () => {
|
||||
it("registers kilocode provider with correct baseUrl and api", () => {
|
||||
const result = applyKilocodeConfig(emptyCfg);
|
||||
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", () => {
|
||||
const result = applyKilocodeConfig({ models: { mode: "replace" } });
|
||||
const provider = result.models?.providers?.kilocode;
|
||||
const models = provider?.models;
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
const modelIds = models?.map((m) => m.id) ?? [];
|
||||
expect(modelIds).toContain(KILOCODE_DEFAULT_MODEL_ID);
|
||||
});
|
||||
|
||||
it("appends missing catalog models to existing Kilo provider config", () => {
|
||||
it.each([undefined, "merge"] as const)(
|
||||
"preserves authored rows without seeding %s config",
|
||||
(mode) => {
|
||||
expect(applyKilocodeConfig({ models: { mode } }).models?.providers?.kilocode?.models).toEqual(
|
||||
[],
|
||||
);
|
||||
const authored = {
|
||||
...buildKilocodeModelDefinition(),
|
||||
id: "operator-model",
|
||||
name: "My model",
|
||||
};
|
||||
const result = applyKilocodeConfig({
|
||||
models: {
|
||||
providers: {
|
||||
kilocode: {
|
||||
baseUrl: KILOCODE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [buildKilocodeModelDefinition()],
|
||||
},
|
||||
},
|
||||
mode,
|
||||
providers: { kilocode: { baseUrl: KILOCODE_BASE_URL, models: [authored] } },
|
||||
},
|
||||
});
|
||||
const modelIds = result.models?.providers?.kilocode?.models?.map((m) => m.id) ?? [];
|
||||
for (const modelId of KILOCODE_MODEL_IDS) {
|
||||
expect(modelIds).toContain(modelId);
|
||||
}
|
||||
});
|
||||
expect(result.models?.providers?.kilocode?.models).toEqual([authored]);
|
||||
},
|
||||
);
|
||||
|
||||
it("sets Kilo Gateway alias in agent default models", () => {
|
||||
const result = applyKilocodeConfig(emptyCfg);
|
||||
const agentModel = result.agents?.defaults?.models?.[KILOCODE_DEFAULT_MODEL_REF];
|
||||
expect(agentModel).toEqual({ alias: "Kilo Gateway" });
|
||||
});
|
||||
|
||||
it.each([undefined, "merge"] as const)(
|
||||
"preserves authored rows without seeding %s config",
|
||||
(mode) => {
|
||||
expect(
|
||||
applyKilocodeConfig({ models: { mode } }).models?.providers?.kilocode?.models,
|
||||
).toEqual([]);
|
||||
const authored = {
|
||||
...buildKilocodeModelDefinition(),
|
||||
id: "operator-model",
|
||||
name: "My model",
|
||||
};
|
||||
const result = applyKilocodeConfig({
|
||||
models: {
|
||||
mode,
|
||||
providers: { kilocode: { baseUrl: KILOCODE_BASE_URL, models: [authored] } },
|
||||
},
|
||||
});
|
||||
expect(result.models?.providers?.kilocode?.models).toEqual([authored]);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves existing alias if already set", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[KILOCODE_DEFAULT_MODEL_REF]: { alias: "My Custom Alias" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = applyKilocodeConfig(cfg);
|
||||
const agentModel = result.agents?.defaults?.models?.[KILOCODE_DEFAULT_MODEL_REF];
|
||||
expect(agentModel?.alias).toBe("My Custom Alias");
|
||||
});
|
||||
|
||||
it("does not change the default model selection", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = applyKilocodeConfig(cfg);
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe("openai/gpt-5");
|
||||
});
|
||||
});
|
||||
|
||||
it("sets kilocode as the default model", () => {
|
||||
const result = applyKilocodeConfig(emptyCfg);
|
||||
it("sets up the Kilo Gateway default for a new config", () => {
|
||||
const result = applyKilocodeConfig({});
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(
|
||||
KILOCODE_DEFAULT_MODEL_REF,
|
||||
"kilocode/kilo-auto/balanced",
|
||||
);
|
||||
const provider = requireKilocodeProvider(result);
|
||||
expect(provider.baseUrl).toBe(KILOCODE_BASE_URL);
|
||||
});
|
||||
|
||||
describe("env var resolution", () => {
|
||||
it("resolves KILOCODE_API_KEY from env", () => {
|
||||
vi.stubEnv("KILOCODE_API_KEY", "test-kilo-key");
|
||||
|
||||
try {
|
||||
const result = resolveEnvApiKey("kilocode");
|
||||
expect(result).toEqual({
|
||||
apiKey: "test-kilo-key",
|
||||
source: "env: KILOCODE_API_KEY",
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
expect(result.agents?.defaults?.models?.[KILOCODE_DEFAULT_MODEL_REF]).toEqual({
|
||||
alias: "Kilo Gateway",
|
||||
});
|
||||
|
||||
it("returns null when KILOCODE_API_KEY is not set", () => {
|
||||
vi.stubEnv("KILOCODE_API_KEY", "");
|
||||
|
||||
try {
|
||||
const result = resolveEnvApiKey("kilocode");
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
expect(result.models?.providers?.kilocode).toMatchObject({
|
||||
baseUrl: "https://api.kilo.ai/api/gateway/",
|
||||
api: "openai-completions",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { jsonResponse } from "openclaw/plugin-sdk/test-env";
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
|
|
@ -20,10 +20,6 @@ import {
|
|||
KILOCODE_MODELS_URL,
|
||||
} from "./provider-models.js";
|
||||
|
||||
type MockKilocodeFetch = ((url: string, init?: RequestInit) => Promise<Response>) & {
|
||||
mock: { calls: unknown[][] };
|
||||
};
|
||||
|
||||
function requireModelById(
|
||||
models: Awaited<ReturnType<typeof discoverKilocodeModels>>,
|
||||
id: string,
|
||||
|
|
@ -37,30 +33,16 @@ function requireModelById(
|
|||
|
||||
const requireRecord = createRequireRecord("record", "expected-label-record");
|
||||
|
||||
function requireFirstMockCall(mock: { mock: { calls: unknown[][] } }, label: string): unknown[] {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function makeGatewayModel(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
name: "Anthropic: Claude Sonnet 4",
|
||||
created: 1700000000,
|
||||
description: "A model",
|
||||
context_length: 200000,
|
||||
architecture: {
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
tokenizer: "Claude",
|
||||
},
|
||||
top_provider: {
|
||||
is_moderated: false,
|
||||
max_completion_tokens: 8192,
|
||||
},
|
||||
top_provider: { max_completion_tokens: 8192 },
|
||||
pricing: {
|
||||
prompt: "0.000003",
|
||||
completion: "0.000015",
|
||||
|
|
@ -77,15 +59,7 @@ function makeAutoModel(overrides: Record<string, unknown> = {}) {
|
|||
id: "kilo-auto/balanced",
|
||||
name: "Auto Balanced",
|
||||
context_length: 1000000,
|
||||
architecture: {
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
tokenizer: "Other",
|
||||
},
|
||||
top_provider: {
|
||||
is_moderated: false,
|
||||
max_completion_tokens: 65536,
|
||||
},
|
||||
top_provider: { max_completion_tokens: 65536 },
|
||||
pricing: {
|
||||
prompt: "0.000000325",
|
||||
completion: "0.00000195",
|
||||
|
|
@ -97,29 +71,17 @@ function makeAutoModel(overrides: Record<string, unknown> = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () => Promise<void>) {
|
||||
function stubResponse(response: Response) {
|
||||
const release = vi.fn(async () => {});
|
||||
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
const callMockFetch = mockFetch as unknown as (
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
) => Promise<unknown>;
|
||||
fetchWithSsrFGuardMock.mockImplementation(
|
||||
async (params: { url: string; init?: RequestInit }) => ({
|
||||
response: await callMockFetch(params.url, params.init),
|
||||
release,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await runAssertions();
|
||||
return release;
|
||||
} finally {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
}
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({ response, release });
|
||||
return release;
|
||||
}
|
||||
|
||||
function stubModels(data: unknown[]) {
|
||||
stubResponse(jsonResponse({ data }));
|
||||
}
|
||||
|
||||
afterEach(() => fetchWithSsrFGuardMock.mockReset());
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.resetModules();
|
||||
|
|
@ -129,62 +91,40 @@ describe("discoverKilocodeModels (fetch path)", () => {
|
|||
it.each([503, 200])(
|
||||
"preserves the public advisory builder for HTTP %s with no rows",
|
||||
async (status) => {
|
||||
await withFetchPathTest(
|
||||
vi.fn(async () => jsonResponse({ data: [] }, status)),
|
||||
async () => {
|
||||
await expect(buildKilocodeProviderWithDiscovery()).resolves.toEqual(
|
||||
buildKilocodeProvider(),
|
||||
);
|
||||
},
|
||||
);
|
||||
stubResponse(jsonResponse({ data: [] }, status));
|
||||
await expect(buildKilocodeProviderWithDiscovery()).resolves.toEqual(buildKilocodeProvider());
|
||||
},
|
||||
);
|
||||
|
||||
it("parses gateway models with correct pricing conversion", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [makeAutoModel(), makeGatewayModel()],
|
||||
}),
|
||||
stubModels([makeAutoModel(), makeGatewayModel()]);
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce();
|
||||
const guardedFetch = requireRecord(
|
||||
fetchWithSsrFGuardMock.mock.calls[0]?.[0],
|
||||
"guarded fetch params",
|
||||
);
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce();
|
||||
const [guardedFetchParams] = requireFirstMockCall(
|
||||
fetchWithSsrFGuardMock,
|
||||
"guarded fetch call",
|
||||
);
|
||||
const guardedFetch = requireRecord(guardedFetchParams, "guarded fetch params");
|
||||
expect(guardedFetch.url).toBe(KILOCODE_MODELS_URL);
|
||||
const guardedInit = requireRecord(guardedFetch.init, "guarded fetch init");
|
||||
expect(Object.fromEntries(new Headers(guardedInit.headers as HeadersInit))).toEqual({
|
||||
accept: "application/json",
|
||||
});
|
||||
expect(guardedFetch.policy).toEqual({ allowedHostnames: ["api.kilo.ai"] });
|
||||
expect(guardedFetch.timeoutMs).toBeGreaterThan(0);
|
||||
expect(guardedFetch.timeoutMs).toBeLessThanOrEqual(5000);
|
||||
expect(guardedFetch.auditContext).toBe("kilocode.model_discovery");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const [fetchUrl, fetchOptions] = requireFirstMockCall(mockFetch, "mock fetch call");
|
||||
expect(fetchUrl).toBe(KILOCODE_MODELS_URL);
|
||||
const fetchInit = requireRecord(fetchOptions, "mock fetch init");
|
||||
expect(Object.fromEntries(new Headers(fetchInit.headers as HeadersInit))).toEqual({
|
||||
accept: "application/json",
|
||||
});
|
||||
|
||||
expect(models.length).toBe(2);
|
||||
|
||||
const sonnet = requireModelById(models, "anthropic/claude-sonnet-4");
|
||||
expect(sonnet.cost.input).toBeCloseTo(3);
|
||||
expect(sonnet.cost.output).toBeCloseTo(15);
|
||||
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);
|
||||
expect(guardedFetch.url).toBe(KILOCODE_MODELS_URL);
|
||||
const guardedInit = requireRecord(guardedFetch.init, "guarded fetch init");
|
||||
expect(Object.fromEntries(new Headers(guardedInit.headers as HeadersInit))).toEqual({
|
||||
accept: "application/json",
|
||||
});
|
||||
expect(guardedFetch.policy).toEqual({ allowedHostnames: ["api.kilo.ai"] });
|
||||
expect(guardedFetch.timeoutMs).toBeGreaterThan(0);
|
||||
expect(guardedFetch.timeoutMs).toBeLessThanOrEqual(5000);
|
||||
expect(guardedFetch.auditContext).toBe("kilocode.model_discovery");
|
||||
expect(models).toHaveLength(2);
|
||||
|
||||
const sonnet = requireModelById(models, "anthropic/claude-sonnet-4");
|
||||
expect(sonnet.cost.input).toBeCloseTo(3);
|
||||
expect(sonnet.cost.output).toBeCloseTo(15);
|
||||
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);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -208,253 +148,181 @@ describe("discoverKilocodeModels (fetch path)", () => {
|
|||
])(
|
||||
"preserves known default-model pricing for $label",
|
||||
async ({ pricing, cacheRead, cacheWrite }) => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
makeAutoModel({ pricing }),
|
||||
makeGatewayModel({
|
||||
id: "kilo-auto/frontier",
|
||||
pricing: { prompt: "-1", completion: "-1" },
|
||||
}),
|
||||
makeGatewayModel({
|
||||
id: "kilo-auto/free",
|
||||
pricing: {
|
||||
prompt: "0",
|
||||
completion: "0",
|
||||
input_cache_read: "0",
|
||||
input_cache_write: "0",
|
||||
},
|
||||
}),
|
||||
],
|
||||
stubModels([
|
||||
makeAutoModel({ pricing }),
|
||||
makeGatewayModel({
|
||||
id: "kilo-auto/frontier",
|
||||
pricing: { prompt: "-1", completion: "-1" },
|
||||
}),
|
||||
);
|
||||
makeGatewayModel({
|
||||
id: "kilo-auto/free",
|
||||
pricing: {
|
||||
prompt: "0",
|
||||
completion: "0",
|
||||
input_cache_read: "0",
|
||||
input_cache_write: "0",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
expect(requireModelById(models, "kilo-auto/balanced").cost).toEqual({
|
||||
input: KILOCODE_DEFAULT_COST.input,
|
||||
output: KILOCODE_DEFAULT_COST.output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
});
|
||||
for (const id of ["kilo-auto/frontier", "kilo-auto/free"]) {
|
||||
expect(requireModelById(models, id).cost).toEqual({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
}
|
||||
expect(requireModelById(models, "kilo-auto/balanced").cost).toEqual({
|
||||
input: KILOCODE_DEFAULT_COST.input,
|
||||
output: KILOCODE_DEFAULT_COST.output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
});
|
||||
for (const id of ["kilo-auto/frontier", "kilo-auto/free"]) {
|
||||
expect(requireModelById(models, id).cost).toEqual({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("propagates network errors", async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
|
||||
"network error",
|
||||
);
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockRejectedValue(new Error("network error"));
|
||||
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
|
||||
"network error",
|
||||
);
|
||||
});
|
||||
|
||||
it("releases the response before propagating an HTTP error", async () => {
|
||||
const response = new Response("temporary failure", { status: 500 });
|
||||
const cancelSpy = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined);
|
||||
const mockFetch = vi.fn().mockResolvedValue(response);
|
||||
const release = stubResponse(response);
|
||||
|
||||
const release = await withFetchPathTest(mockFetch, async () => {
|
||||
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toMatchObject({
|
||||
status: 500,
|
||||
});
|
||||
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toMatchObject({
|
||||
status: 500,
|
||||
});
|
||||
|
||||
expect(cancelSpy).toHaveBeenCalledOnce();
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects malformed model list envelopes", async () => {
|
||||
for (const payload of [[], { data: {} }]) {
|
||||
const mockFetch = vi.fn().mockResolvedValue(jsonResponse(payload));
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
|
||||
"Kilocode model list: malformed JSON response",
|
||||
);
|
||||
});
|
||||
stubResponse(jsonResponse(payload));
|
||||
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
|
||||
"Kilocode model list: malformed JSON response",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([{ data: [] }, { data: [null] }])(
|
||||
"does not restore seed models when no usable live rows remain: %j",
|
||||
async (payload) => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(jsonResponse(payload));
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).resolves.toEqual([]);
|
||||
});
|
||||
stubResponse(jsonResponse(payload));
|
||||
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("falls back from malformed live token metadata", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
makeGatewayModel({
|
||||
id: "some/bad-window",
|
||||
context_length: -1,
|
||||
top_provider: { max_completion_tokens: 8192.5 },
|
||||
}),
|
||||
makeGatewayModel({
|
||||
id: "some/bad-output",
|
||||
context_length: Number.POSITIVE_INFINITY,
|
||||
top_provider: { max_completion_tokens: 0 },
|
||||
}),
|
||||
],
|
||||
stubModels([
|
||||
makeGatewayModel({
|
||||
id: "some/bad-window",
|
||||
context_length: -1,
|
||||
top_provider: { max_completion_tokens: 8192.5 },
|
||||
}),
|
||||
);
|
||||
makeGatewayModel({
|
||||
id: "some/bad-output",
|
||||
context_length: Number.POSITIVE_INFINITY,
|
||||
top_provider: { max_completion_tokens: 0 },
|
||||
}),
|
||||
]);
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
expect(requireModelById(models, "some/bad-window")).toMatchObject({
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
});
|
||||
expect(requireModelById(models, "some/bad-output")).toMatchObject({
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
});
|
||||
expect(requireModelById(models, "some/bad-window")).toMatchObject({
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
});
|
||||
expect(requireModelById(models, "some/bad-output")).toMatchObject({
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the primary provider context window over the catalog-wide value", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
makeGatewayModel({
|
||||
id: "minimax/minimax-m3",
|
||||
context_length: 1048576,
|
||||
top_provider: {
|
||||
is_moderated: false,
|
||||
context_length: 524288,
|
||||
max_completion_tokens: 512000,
|
||||
},
|
||||
}),
|
||||
],
|
||||
stubModels([
|
||||
makeGatewayModel({
|
||||
id: "minimax/minimax-m3",
|
||||
context_length: 1048576,
|
||||
top_provider: { context_length: 524288, max_completion_tokens: 512000 },
|
||||
}),
|
||||
);
|
||||
]);
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
expect(requireModelById(models, "minimax/minimax-m3")).toMatchObject({
|
||||
contextWindow: 524288,
|
||||
maxTokens: 512000,
|
||||
});
|
||||
expect(requireModelById(models, "minimax/minimax-m3")).toMatchObject({
|
||||
contextWindow: 524288,
|
||||
maxTokens: 512000,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the catalog window when the provider window is unusable", async () => {
|
||||
const unusable: unknown[] = [0, -1, 4096.5, Number.POSITIVE_INFINITY, null, "131072"];
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: unusable.map((context_length, index) =>
|
||||
makeGatewayModel({
|
||||
id: `some/provider-window-${index}`,
|
||||
context_length: 200000,
|
||||
top_provider: { is_moderated: false, context_length, max_completion_tokens: 8192 },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
stubModels(
|
||||
unusable.map((context_length, index) =>
|
||||
makeGatewayModel({
|
||||
id: `some/provider-window-${index}`,
|
||||
context_length: 200000,
|
||||
top_provider: { context_length, max_completion_tokens: 8192 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
for (let index = 0; index < unusable.length; index++) {
|
||||
expect(requireModelById(models, `some/provider-window-${index}`)).toMatchObject({
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("ensures kilo-auto/balanced is present even when API doesn't return it", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [makeGatewayModel()],
|
||||
}),
|
||||
);
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
expect(requireModelById(models, "kilo-auto/balanced").id).toBe("kilo-auto/balanced");
|
||||
expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe(
|
||||
"anthropic/claude-sonnet-4",
|
||||
);
|
||||
});
|
||||
for (let index = 0; index < unusable.length; index++) {
|
||||
expect(requireModelById(models, `some/provider-window-${index}`)).toMatchObject({
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("detects text-only models without image modality", async () => {
|
||||
const textOnlyModel = makeGatewayModel({
|
||||
id: "some/text-model",
|
||||
architecture: {
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
supported_parameters: ["max_tokens", "temperature"],
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [textOnlyModel] }));
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
const textModel = requireModelById(models, "some/text-model");
|
||||
expect(textModel.input).toEqual(["text"]);
|
||||
expect(textModel.reasoning).toBe(false);
|
||||
});
|
||||
stubModels([
|
||||
makeGatewayModel({
|
||||
id: "some/text-model",
|
||||
architecture: { input_modalities: ["text"], output_modalities: ["text"] },
|
||||
supported_parameters: ["max_tokens", "temperature"],
|
||||
}),
|
||||
]);
|
||||
const textModel = requireModelById(await discoverKilocodeModels(), "some/text-model");
|
||||
expect(textModel.input).toEqual(["text"]);
|
||||
expect(textModel.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes image-output models from the chat catalog", async () => {
|
||||
const imageOutputModel = makeGatewayModel({
|
||||
id: "google/gemini-3.1-flash-image",
|
||||
architecture: {
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["image", "text"],
|
||||
},
|
||||
});
|
||||
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ data: [imageOutputModel, makeGatewayModel()] }));
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
|
||||
expect(models.some((model) => model.id === "google/gemini-3.1-flash-image")).toBe(false);
|
||||
expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe(
|
||||
"anthropic/claude-sonnet-4",
|
||||
);
|
||||
});
|
||||
it("excludes image-output models while retaining chat and static routing entries", async () => {
|
||||
stubModels([
|
||||
makeGatewayModel({
|
||||
id: "google/gemini-3.1-flash-image",
|
||||
architecture: {
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["image", "text"],
|
||||
},
|
||||
}),
|
||||
makeGatewayModel(),
|
||||
]);
|
||||
expect((await discoverKilocodeModels()).map((model) => model.id)).toEqual([
|
||||
"kilo-auto/balanced",
|
||||
"anthropic/claude-sonnet-4",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a later valid duplicate when an earlier entry is malformed", async () => {
|
||||
const malformedAutoModel = makeAutoModel({
|
||||
name: "Broken Auto Balanced",
|
||||
pricing: undefined,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()],
|
||||
}),
|
||||
stubModels([
|
||||
makeAutoModel({ name: "Broken Auto Balanced", pricing: undefined }),
|
||||
makeAutoModel(),
|
||||
makeGatewayModel(),
|
||||
]);
|
||||
const models = await discoverKilocodeModels();
|
||||
const auto = requireModelById(models, "kilo-auto/balanced");
|
||||
expect(auto.name).toBe("Auto Balanced");
|
||||
expect(auto.cost.input).toBeCloseTo(0.325);
|
||||
expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe(
|
||||
"anthropic/claude-sonnet-4",
|
||||
);
|
||||
await withFetchPathTest(mockFetch, async () => {
|
||||
const models = await discoverKilocodeModels();
|
||||
const auto = requireModelById(models, "kilo-auto/balanced");
|
||||
expect(auto.name).toBe("Auto Balanced");
|
||||
expect(auto.cost.input).toBeCloseTo(0.325);
|
||||
expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe(
|
||||
"anthropic/claude-sonnet-4",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,18 +14,6 @@ describe("Raft channel plugin", () => {
|
|||
detectBinaryMock.mockReset();
|
||||
});
|
||||
|
||||
it("declares a wake-only direct channel", () => {
|
||||
expect(raftPlugin.meta).toMatchObject({
|
||||
id: "raft",
|
||||
docsPath: "/channels/raft",
|
||||
});
|
||||
expect(raftPlugin.capabilities).toEqual({
|
||||
chatTypes: ["direct"],
|
||||
});
|
||||
expect(raftPlugin.message).toBeUndefined();
|
||||
expect(raftPlugin.outbound).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
detected: true,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedRaftAccount } from "./accounts.js";
|
||||
import { startRaftGatewayAccount } from "./gateway.js";
|
||||
import { dispatchRaftWake } from "./inbound.js";
|
||||
|
||||
const processRuntimeMocks = vi.hoisted(() => ({
|
||||
killProcessTree: vi.fn(),
|
||||
|
|
@ -37,6 +36,7 @@ class FakeBridge extends EventEmitter {
|
|||
const tempWorkspaces: TempWorkspaceSync[] = [];
|
||||
|
||||
function createContext(accountId = "default") {
|
||||
const controller = new AbortController();
|
||||
const status = {
|
||||
accountId,
|
||||
running: false,
|
||||
|
|
@ -85,7 +85,7 @@ function createContext(accountId = "default") {
|
|||
profile: "openclaw",
|
||||
},
|
||||
runtime: {},
|
||||
abortSignal: new AbortController().signal,
|
||||
abortSignal: controller.signal,
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
|
|
@ -117,7 +117,7 @@ function createContext(accountId = "default") {
|
|||
};
|
||||
return {
|
||||
ctx: ctx as unknown as ChannelGatewayContext<ResolvedRaftAccount>,
|
||||
controller: new AbortController(),
|
||||
controller,
|
||||
run,
|
||||
buildContext,
|
||||
wakeDedupe: createChannelReplayGuard<{ accountId: string; key: string }>({
|
||||
|
|
@ -128,6 +128,40 @@ function createContext(accountId = "default") {
|
|||
};
|
||||
}
|
||||
|
||||
async function withGateway(
|
||||
{ ctx, controller, wakeDedupe }: ReturnType<typeof createContext>,
|
||||
test: (connection: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
post: (body?: unknown, authToken?: string) => Promise<Response>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const bridge = new FakeBridge();
|
||||
const start = startRaftGatewayAccount(ctx, { spawnBridge: bridge.spawn, wakeDedupe });
|
||||
void start.catch(bridge.started.reject);
|
||||
try {
|
||||
const { endpoint, token } = await withTimeout(
|
||||
bridge.started.promise,
|
||||
500,
|
||||
"Raft bridge startup",
|
||||
);
|
||||
await test({
|
||||
endpoint,
|
||||
token,
|
||||
post: (body, authToken = token) =>
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "x-raft-bridge-token": authToken },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
controller.abort();
|
||||
await start;
|
||||
}
|
||||
return bridge;
|
||||
}
|
||||
|
||||
function createPersistentWakeDedupe(stateDir: string) {
|
||||
return createChannelReplayGuard<{ accountId: string; key: string }>({
|
||||
dedupe: {
|
||||
|
|
@ -157,7 +191,6 @@ describe("Raft wake gateway", () => {
|
|||
"joins an admitted wake during shutdown while %s is pending",
|
||||
async (phase) => {
|
||||
const { ctx, controller, run, wakeDedupe } = createContext();
|
||||
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
|
||||
const bridge = new FakeBridge();
|
||||
const pending = createDeferred<void>();
|
||||
const reached = createDeferred<void>();
|
||||
|
|
@ -215,22 +248,9 @@ describe("Raft wake gateway", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("marks the internal wake path explicitly unsupported", async () => {
|
||||
const { ctx, buildContext } = createContext();
|
||||
await dispatchRaftWake({ ctx });
|
||||
expect(buildContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channelIngress: "unsupported" }),
|
||||
);
|
||||
});
|
||||
it("keeps a disabled account quiescent until shutdown", async () => {
|
||||
const { ctx, controller, wakeDedupe } = createContext();
|
||||
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
|
||||
Object.defineProperty(ctx, "account", {
|
||||
value: {
|
||||
...ctx.account,
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
ctx.account.enabled = false;
|
||||
const spawnBridge = vi.fn(() => new FakeBridge());
|
||||
let settled = false;
|
||||
const start = startRaftGatewayAccount(ctx, { spawnBridge, wakeDedupe }).then(() => {
|
||||
|
|
@ -249,32 +269,16 @@ describe("Raft wake gateway", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// Raft already answered this case through its own close-after-response teardown; the
|
||||
// wire behavior must survive replacing that teardown with the shared transport owner.
|
||||
it("keeps delivering 413 for an over-limit wake payload and closing the connection", async () => {
|
||||
const { ctx, controller, wakeDedupe } = createContext();
|
||||
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
|
||||
const bridge = new FakeBridge();
|
||||
const start = startRaftGatewayAccount(ctx, {
|
||||
spawnBridge: bridge.spawn,
|
||||
wakeDedupe,
|
||||
});
|
||||
void start.catch(bridge.started.reject);
|
||||
|
||||
try {
|
||||
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
|
||||
bridge.started.promise,
|
||||
500,
|
||||
"Raft bridge startup",
|
||||
);
|
||||
|
||||
const fixture = createContext();
|
||||
await withGateway(fixture, async ({ endpoint, token }) => {
|
||||
// Declared and sent in one write: the shape whose rejection used to race the flush.
|
||||
const result = await postRawWebhook({
|
||||
url: wakeEndpoint,
|
||||
url: endpoint,
|
||||
body: JSON.stringify({ deliveryId: "x".repeat(16 * 1024) }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
"x-raft-bridge-token": token,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -283,34 +287,15 @@ describe("Raft wake gateway", () => {
|
|||
error: "Wake payload exceeds the 16 KiB limit.",
|
||||
});
|
||||
expect(result.closedByServer).toBe(true);
|
||||
} finally {
|
||||
controller.abort();
|
||||
await start;
|
||||
}
|
||||
expect(fixture.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts authenticated content-free wake hints and dedupes retry delivery ids", async () => {
|
||||
const { ctx, controller, run, wakeDedupe } = createContext();
|
||||
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
|
||||
Object.defineProperty(ctx, "account", {
|
||||
value: {
|
||||
...ctx.account,
|
||||
profile: "main'; touch /tmp/pwn; echo '",
|
||||
},
|
||||
});
|
||||
const bridge = new FakeBridge();
|
||||
const start = startRaftGatewayAccount(ctx, {
|
||||
spawnBridge: bridge.spawn,
|
||||
wakeDedupe,
|
||||
});
|
||||
void start.catch(bridge.started.reject);
|
||||
|
||||
try {
|
||||
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
|
||||
bridge.started.promise,
|
||||
500,
|
||||
"Raft bridge startup",
|
||||
);
|
||||
const fixture = createContext();
|
||||
const { ctx, run, buildContext } = fixture;
|
||||
ctx.account.profile = "main'; touch /tmp/pwn; echo '";
|
||||
const bridge = await withGateway(fixture, async ({ endpoint, token, post }) => {
|
||||
expect(ctx.getStatus()).toMatchObject({
|
||||
running: true,
|
||||
connected: true,
|
||||
|
|
@ -319,99 +304,46 @@ describe("Raft wake gateway", () => {
|
|||
lastError: null,
|
||||
terminalDisconnect: undefined,
|
||||
});
|
||||
await expect(fetch(wakeEndpoint.replace("/wake", "/health"))).resolves.toMatchObject({
|
||||
await expect(fetch(endpoint.replace("/wake", "/health"))).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
await expect(fetch(wakeEndpoint, { method: "POST" })).resolves.toMatchObject({ status: 401 });
|
||||
await expect(
|
||||
fetch(wakeEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "x-raft-bridge-token": "x".repeat(bridgeToken.length) },
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 401 });
|
||||
await expect(
|
||||
fetch(wakeEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "x-raft-bridge-token": "short" },
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 401 });
|
||||
await expect(
|
||||
fetch(wakeEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
fetch(wakeEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
body: JSON.stringify({ metadata: { text: "not a wake hint" } }),
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
fetch(wakeEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
body: JSON.stringify({ eventId: "wake-1", timestamp: 1 }),
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 202 });
|
||||
await expect(
|
||||
fetch(wakeEndpoint.replace("/wake", "/activity/drain?max=50")),
|
||||
).resolves.toMatchObject({ status: 401 });
|
||||
await expect(
|
||||
fetch(wakeEndpoint.replace("/wake", "/activity/drain?max=50"), {
|
||||
headers: {
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 200,
|
||||
await expect(fetch(endpoint, { method: "POST" })).resolves.toMatchObject({ status: 401 });
|
||||
await expect(post(undefined, "x".repeat(token.length))).resolves.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
await expect(post(undefined, "short")).resolves.toMatchObject({ status: 401 });
|
||||
await expect(post()).resolves.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
fetch(wakeEndpoint.replace("/wake", "/activity/drain?max=50"), {
|
||||
headers: {
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
}).then((response) => response.json()),
|
||||
).resolves.toEqual({
|
||||
post({ eventId: "wake-content", metadata: { text: "not a wake hint" } }),
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
const accepted = await post({ eventId: "wake-1", timestamp: 1 });
|
||||
expect(accepted.status).toBe(202);
|
||||
await expect(accepted.json()).resolves.toMatchObject({
|
||||
accepted: true,
|
||||
ok: true,
|
||||
runtimeSession: expect.any(String),
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(buildContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channelIngress: "unsupported" }),
|
||||
);
|
||||
|
||||
const drainUrl = endpoint.replace("/wake", "/activity/drain?max=50");
|
||||
await expect(fetch(drainUrl)).resolves.toMatchObject({ status: 401 });
|
||||
const drain = await fetch(drainUrl, { headers: { "x-raft-bridge-token": token } });
|
||||
expect(drain.status).toBe(200);
|
||||
await expect(drain.json()).resolves.toEqual({
|
||||
dropped: 0,
|
||||
events: [],
|
||||
schema: "raft-activity-drain.v1",
|
||||
});
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
|
||||
await expect(
|
||||
fetch(wakeEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
body: JSON.stringify({ eventId: "wake-1", timestamp: 2 }),
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 202 });
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
await expect(post({ eventId: "wake-1", timestamp: 2 })).resolves.toMatchObject({
|
||||
status: 202,
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
await expect(
|
||||
fetch(wakeEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
sequence: 1,
|
||||
source: "bridge",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
await expect(post({ metadata: { sequence: 1, source: "bridge" } })).resolves.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
|
||||
const input = run.mock.calls[0]?.[0].adapter.ingest({ kind: "wake" });
|
||||
|
|
@ -419,10 +351,7 @@ describe("Raft wake gateway", () => {
|
|||
`raft --profile 'main'"'"'; touch /tmp/pwn; echo '"'"'' message check`,
|
||||
);
|
||||
expect(input?.rawText).not.toContain("wake-1");
|
||||
} finally {
|
||||
controller.abort();
|
||||
await start;
|
||||
}
|
||||
});
|
||||
expect(processRuntimeMocks.killProcessTree).toHaveBeenCalledOnce();
|
||||
expect(processRuntimeMocks.killProcessTree).toHaveBeenCalledWith(bridge.pid, {
|
||||
graceMs: 5_000,
|
||||
|
|
@ -430,105 +359,14 @@ describe("Raft wake gateway", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("returns the Raft bridge runtime session for accepted wakes", async () => {
|
||||
const { ctx, controller, wakeDedupe } = createContext();
|
||||
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
|
||||
const bridge = new FakeBridge();
|
||||
const start = startRaftGatewayAccount(ctx, {
|
||||
spawnBridge: bridge.spawn,
|
||||
wakeDedupe,
|
||||
});
|
||||
void start.catch(bridge.started.reject);
|
||||
|
||||
try {
|
||||
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
|
||||
bridge.started.promise,
|
||||
500,
|
||||
"Raft bridge startup",
|
||||
);
|
||||
const response = await fetch(wakeEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
body: JSON.stringify({ eventId: "wake-runtime-session" }),
|
||||
});
|
||||
expect(response).toMatchObject({ status: 202 });
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
accepted: true,
|
||||
ok: true,
|
||||
runtimeSession: expect.any(String),
|
||||
});
|
||||
} finally {
|
||||
controller.abort();
|
||||
await start;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized payloads before queueing a wake", async () => {
|
||||
const { ctx, controller, run, wakeDedupe } = createContext();
|
||||
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
|
||||
const bridge = new FakeBridge();
|
||||
const start = startRaftGatewayAccount(ctx, {
|
||||
spawnBridge: bridge.spawn,
|
||||
wakeDedupe,
|
||||
});
|
||||
void start.catch(bridge.started.reject);
|
||||
|
||||
try {
|
||||
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
|
||||
bridge.started.promise,
|
||||
500,
|
||||
"Raft bridge startup",
|
||||
);
|
||||
await expect(
|
||||
fetch(wakeEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
body: JSON.stringify({ event: "wake", padding: "x".repeat(17 * 1024) }),
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 413 });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
controller.abort();
|
||||
await start;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a failed delivery eligible for a bridge retry", async () => {
|
||||
const { ctx, controller, run, wakeDedupe } = createContext();
|
||||
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
|
||||
const bridge = new FakeBridge();
|
||||
const start = startRaftGatewayAccount(ctx, {
|
||||
spawnBridge: bridge.spawn,
|
||||
wakeDedupe,
|
||||
const fixture = createContext();
|
||||
await withGateway(fixture, async ({ post }) => {
|
||||
fixture.run.mockRejectedValueOnce(new Error("inbound runtime unavailable"));
|
||||
await expect(post({ eventId: "wake-retry" })).resolves.toMatchObject({ status: 500 });
|
||||
await expect(post({ eventId: "wake-retry" })).resolves.toMatchObject({ status: 202 });
|
||||
expect(fixture.run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
void start.catch(bridge.started.reject);
|
||||
|
||||
try {
|
||||
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
|
||||
bridge.started.promise,
|
||||
500,
|
||||
"Raft bridge startup",
|
||||
);
|
||||
run.mockRejectedValueOnce(new Error("inbound runtime unavailable"));
|
||||
const request = () => ({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-raft-bridge-token": bridgeToken,
|
||||
},
|
||||
body: JSON.stringify({ eventId: "wake-retry" }),
|
||||
});
|
||||
await expect(fetch(wakeEndpoint, request())).resolves.toMatchObject({ status: 500 });
|
||||
await expect(fetch(wakeEndpoint, request())).resolves.toMatchObject({ status: 202 });
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
controller.abort();
|
||||
await start;
|
||||
}
|
||||
});
|
||||
|
||||
it("persists accepted wake dedupe across restarts without crossing accounts", async () => {
|
||||
|
|
@ -537,92 +375,17 @@ describe("Raft wake gateway", () => {
|
|||
prefix: "openclaw-raft-wake-dedupe-",
|
||||
});
|
||||
tempWorkspaces.push(workspace);
|
||||
const stateDir = workspace.dir;
|
||||
try {
|
||||
const first = createContext();
|
||||
Object.defineProperty(first.ctx, "abortSignal", { value: first.controller.signal });
|
||||
const firstBridge = new FakeBridge();
|
||||
const firstStart = startRaftGatewayAccount(first.ctx, {
|
||||
wakeDedupe: createPersistentWakeDedupe(stateDir),
|
||||
spawnBridge: firstBridge.spawn,
|
||||
for (const [accountId, expectedCalls] of [
|
||||
["default", 1],
|
||||
["default", 0],
|
||||
["other", 1],
|
||||
] as const) {
|
||||
const fixture = createContext(accountId);
|
||||
fixture.wakeDedupe = createPersistentWakeDedupe(workspace.dir);
|
||||
await withGateway(fixture, async ({ post }) => {
|
||||
await expect(post({ eventId: "wake-persisted" })).resolves.toMatchObject({ status: 202 });
|
||||
expect(fixture.run).toHaveBeenCalledTimes(expectedCalls);
|
||||
});
|
||||
void firstStart.catch(firstBridge.started.reject);
|
||||
try {
|
||||
const { endpoint, token } = await withTimeout(
|
||||
firstBridge.started.promise,
|
||||
500,
|
||||
"Raft bridge startup",
|
||||
);
|
||||
await expect(
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "x-raft-bridge-token": token },
|
||||
body: JSON.stringify({ eventId: "wake-persisted" }),
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 202 });
|
||||
expect(first.run).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
first.controller.abort();
|
||||
await firstStart;
|
||||
}
|
||||
|
||||
const replay = createContext();
|
||||
Object.defineProperty(replay.ctx, "abortSignal", { value: replay.controller.signal });
|
||||
const replayBridge = new FakeBridge();
|
||||
const replayStart = startRaftGatewayAccount(replay.ctx, {
|
||||
wakeDedupe: createPersistentWakeDedupe(stateDir),
|
||||
spawnBridge: replayBridge.spawn,
|
||||
});
|
||||
void replayStart.catch(replayBridge.started.reject);
|
||||
try {
|
||||
const { endpoint, token } = await withTimeout(
|
||||
replayBridge.started.promise,
|
||||
500,
|
||||
"Raft bridge startup",
|
||||
);
|
||||
await expect(
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "x-raft-bridge-token": token },
|
||||
body: JSON.stringify({ eventId: "wake-persisted" }),
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 202 });
|
||||
expect(replay.run).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
replay.controller.abort();
|
||||
await replayStart;
|
||||
}
|
||||
|
||||
const otherAccount = createContext("other");
|
||||
Object.defineProperty(otherAccount.ctx, "abortSignal", {
|
||||
value: otherAccount.controller.signal,
|
||||
});
|
||||
const otherBridge = new FakeBridge();
|
||||
const otherStart = startRaftGatewayAccount(otherAccount.ctx, {
|
||||
wakeDedupe: createPersistentWakeDedupe(stateDir),
|
||||
spawnBridge: otherBridge.spawn,
|
||||
});
|
||||
void otherStart.catch(otherBridge.started.reject);
|
||||
try {
|
||||
const { endpoint, token } = await withTimeout(
|
||||
otherBridge.started.promise,
|
||||
500,
|
||||
"Raft bridge startup",
|
||||
);
|
||||
await expect(
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "x-raft-bridge-token": token },
|
||||
body: JSON.stringify({ eventId: "wake-persisted" }),
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 202 });
|
||||
expect(otherAccount.run).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
otherAccount.controller.abort();
|
||||
await otherStart;
|
||||
}
|
||||
} finally {
|
||||
resetPluginStateStoreForTests();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Talk Voice tests cover index plugin behavior.
|
||||
import type { OpenClawPluginCommandDefinition } from "openclaw/plugin-sdk/core";
|
||||
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
|
||||
import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
|
@ -16,7 +15,6 @@ function createHarness(initialConfig: Record<string, unknown>) {
|
|||
const runtime = {
|
||||
config: {
|
||||
current: vi.fn(() => config),
|
||||
loadConfig: vi.fn(() => config),
|
||||
mutateConfigFile: vi.fn(
|
||||
async ({
|
||||
mutate,
|
||||
|
|
@ -41,10 +39,6 @@ function createHarness(initialConfig: Record<string, unknown>) {
|
|||
};
|
||||
},
|
||||
),
|
||||
replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: Record<string, unknown> }) => {
|
||||
config = nextConfig;
|
||||
}),
|
||||
writeConfigFile: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
tts: {
|
||||
listVoices: vi.fn(),
|
||||
|
|
@ -60,15 +54,16 @@ function createHarness(initialConfig: Record<string, unknown>) {
|
|||
}),
|
||||
};
|
||||
register.register(api as never);
|
||||
if (!command) {
|
||||
throw new Error("talk-voice command not registered");
|
||||
}
|
||||
if (!tool) {
|
||||
throw new Error("talk_voice tool not registered");
|
||||
if (!command || !tool) {
|
||||
throw new Error("talk-voice command or tool not registered");
|
||||
}
|
||||
return { command, tool, runtime };
|
||||
}
|
||||
|
||||
function talkConfig(provider: string, config: Record<string, unknown> = {}) {
|
||||
return { talk: { provider, providers: { [provider]: config } } };
|
||||
}
|
||||
|
||||
function createCommandContext(
|
||||
args: string,
|
||||
channel = "discord",
|
||||
|
|
@ -98,7 +93,7 @@ describe("talk-voice plugin", () => {
|
|||
it.each([false, true])(
|
||||
"rechecks owner authority after voice lookup (gateway admin: %s)",
|
||||
async (gatewayAdmin) => {
|
||||
const initialConfig = { talk: { provider: "microsoft", providers: { microsoft: {} } } };
|
||||
const initialConfig = talkConfig("microsoft");
|
||||
const { command, runtime } = createHarness(initialConfig);
|
||||
let current = true;
|
||||
const ctx = {
|
||||
|
|
@ -106,7 +101,7 @@ describe("talk-voice plugin", () => {
|
|||
"set Ava",
|
||||
"discord",
|
||||
gatewayAdmin ? ["operator.admin"] : undefined,
|
||||
true,
|
||||
gatewayAdmin ? undefined : true,
|
||||
),
|
||||
assertOwnerCurrent: () => {
|
||||
if (!current) {
|
||||
|
|
@ -124,7 +119,12 @@ describe("talk-voice plugin", () => {
|
|||
await expect(pending).resolves.toMatchObject({
|
||||
text: expect.stringContaining("Talk voice set to Ava"),
|
||||
});
|
||||
expect(runtime.config.current()).not.toEqual(initialConfig);
|
||||
expect(runtime.config.current()).toStrictEqual({
|
||||
talk: {
|
||||
provider: "microsoft",
|
||||
providers: { microsoft: { voiceId: "en-US-AvaNeural" } },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await expect(pending).rejects.toThrow("original owner revoked");
|
||||
expect(runtime.config.current()).toEqual(initialConfig);
|
||||
|
|
@ -197,17 +197,8 @@ describe("talk-voice plugin", () => {
|
|||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: string[]) {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
function createElevenlabsVoiceSetHarness(channel: string, scopes?: string[]) {
|
||||
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
|
||||
return {
|
||||
runtime,
|
||||
|
|
@ -216,17 +207,9 @@ describe("talk-voice plugin", () => {
|
|||
}
|
||||
|
||||
it("reports active provider status", async () => {
|
||||
const { command } = createHarness({
|
||||
talk: {
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {
|
||||
voiceId: "en-US-AvaNeural",
|
||||
apiKey: "secret-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { command } = createHarness(
|
||||
talkConfig("microsoft", { voiceId: "en-US-AvaNeural", apiKey: "secret-token" }),
|
||||
);
|
||||
|
||||
const result = await command.handler(createCommandContext(""));
|
||||
|
||||
|
|
@ -239,24 +222,12 @@ describe("talk-voice plugin", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("exposes owner status for mutating voice commands", () => {
|
||||
const { command } = createHarness({});
|
||||
|
||||
expect(command.exposeSenderIsOwner).toBe(true);
|
||||
});
|
||||
|
||||
it("lists voices from the active provider", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
baseUrl: "https://voices.example.test",
|
||||
},
|
||||
},
|
||||
},
|
||||
const config = talkConfig("elevenlabs", {
|
||||
apiKey: "sk-eleven",
|
||||
baseUrl: "https://voices.example.test",
|
||||
});
|
||||
const { command, runtime } = createHarness(config);
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([
|
||||
{ id: "voice-a", name: "Claudia", category: "general" },
|
||||
{ id: "voice-b", name: "Bert" },
|
||||
|
|
@ -266,17 +237,7 @@ describe("talk-voice plugin", () => {
|
|||
|
||||
expect(runtime.tts.listVoices).toHaveBeenCalledWith({
|
||||
provider: "elevenlabs",
|
||||
cfg: {
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
baseUrl: "https://voices.example.test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg: config,
|
||||
apiKey: "sk-eleven",
|
||||
baseUrl: "https://voices.example.test",
|
||||
});
|
||||
|
|
@ -290,16 +251,7 @@ describe("talk-voice plugin", () => {
|
|||
});
|
||||
|
||||
it("does not coerce partial voice list limits", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue(
|
||||
Array.from({ length: 13 }, (_, index) => ({
|
||||
id: `voice-${index}`,
|
||||
|
|
@ -313,14 +265,7 @@ describe("talk-voice plugin", () => {
|
|||
});
|
||||
|
||||
it("surfaces richer provider voice metadata when available", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { command, runtime } = createHarness(talkConfig("microsoft"));
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([
|
||||
{
|
||||
id: "en-US-AvaNeural",
|
||||
|
|
@ -346,16 +291,7 @@ describe("talk-voice plugin", () => {
|
|||
});
|
||||
|
||||
it("writes only canonical provider-scoped voice config for elevenlabs", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
|
||||
|
||||
const result = await command.handler(
|
||||
|
|
@ -367,8 +303,7 @@ describe("talk-voice plugin", () => {
|
|||
writeOptions: { assertCurrent: undefined },
|
||||
mutate: expect.any(Function),
|
||||
});
|
||||
const updatedConfig = runtime.config.current() as { talk: Record<string, unknown> };
|
||||
expect(updatedConfig).toEqual({
|
||||
expect(runtime.config.current()).toStrictEqual({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
|
|
@ -379,96 +314,24 @@ describe("talk-voice plugin", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
expect(Object.hasOwn(updatedConfig.talk, "voiceId")).toBe(false);
|
||||
expect(result).toEqual({
|
||||
text: "✅ ElevenLabs Talk voice set to Claudia\nvoice-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes provider voice id without legacy top-level field for microsoft", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "en-US-AvaNeural", name: "Ava" }]);
|
||||
|
||||
await command.handler(createCommandContext("set Ava", "webchat", ["operator.admin"]));
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalledWith({
|
||||
afterWrite: { mode: "auto" },
|
||||
writeOptions: { assertCurrent: undefined },
|
||||
mutate: expect.any(Function),
|
||||
});
|
||||
expect(runtime.config.current()).toEqual({
|
||||
talk: {
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {
|
||||
voiceId: "en-US-AvaNeural",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects /voice set from gateway client with only operator.write scope", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.write"]);
|
||||
it.each([
|
||||
{ channel: "telegram", scopes: ["operator.write"] },
|
||||
{ channel: "discord", scopes: undefined },
|
||||
])("rejects unauthorized voice writes on $channel", async ({ channel, scopes }) => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness(channel, scopes);
|
||||
const result = await run();
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects /voice set from non-webchat gateway callers missing operator.admin", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.write"]);
|
||||
const result = await run();
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows /voice set from gateway client with operator.admin scope", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.admin"]);
|
||||
const result = await run();
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
});
|
||||
|
||||
it("rejects /voice set from webchat channel with no scopes (TUI/internal)", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness();
|
||||
const result = await run();
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["telegram", "discord"])(
|
||||
"rejects /voice set from %s channel without operator.admin",
|
||||
async (channel) => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness(channel);
|
||||
const result = await run();
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps read-only voice commands available without operator.admin", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
|
||||
|
||||
const status = await command.handler(createCommandContext("status", "telegram"));
|
||||
|
|
@ -479,25 +342,9 @@ describe("talk-voice plugin", () => {
|
|||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows /voice set when operator.admin is present on a non-webchat channel", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]);
|
||||
const result = await run();
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
});
|
||||
|
||||
it("allows /voice set from an owner non-gateway channel without scopes", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
|
||||
expect(command.exposeSenderIsOwner).toBe(true);
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
|
||||
|
||||
const result = await command.handler(
|
||||
|
|
@ -509,14 +356,7 @@ describe("talk-voice plugin", () => {
|
|||
});
|
||||
|
||||
it("returns provider lookup errors cleanly", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { command, runtime } = createHarness(talkConfig("microsoft"));
|
||||
vi.mocked(runtime.tts.listVoices).mockRejectedValue(
|
||||
new Error("speech provider microsoft does not support voice listing"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
import type { RemoteEmbeddingClient } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runVoyageEmbeddingBatches } from "./embedding-batch.js";
|
||||
import { createVoyageEmbeddingProvider } from "./embedding-provider.js";
|
||||
|
|
@ -8,14 +7,6 @@ import { createVoyageEmbeddingProvider } from "./embedding-provider.js";
|
|||
type VoyageBatchOptions = Parameters<typeof runVoyageEmbeddingBatches>[0];
|
||||
type BatchStage = "upload" | "create" | "status" | "output" | "error";
|
||||
|
||||
function buildClient(): RemoteEmbeddingClient {
|
||||
return {
|
||||
baseUrl: "https://api.voyageai.test/v1",
|
||||
headers: { authorization: "Bearer fixture-voyage" },
|
||||
model: "voyage-3",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBatchStage(url: string, init?: RequestInit): BatchStage {
|
||||
if (url.endsWith("/files") && init?.method === "POST") {
|
||||
return "upload";
|
||||
|
|
@ -51,13 +42,7 @@ function defaultBatchResponse(stage: BatchStage): Response {
|
|||
}),
|
||||
);
|
||||
case "error":
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
custom_id: "req-0",
|
||||
response: { status_code: 500, message: "provider rejected request" },
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw new Error("unexpected Voyage batch stage");
|
||||
}
|
||||
|
|
@ -80,7 +65,11 @@ function stubBatchFetch(
|
|||
|
||||
function runBatch(overrides: Partial<VoyageBatchOptions> = {}) {
|
||||
return runVoyageEmbeddingBatches({
|
||||
client: buildClient(),
|
||||
client: {
|
||||
baseUrl: "https://api.voyageai.test/v1",
|
||||
headers: { authorization: "Bearer fixture-voyage" },
|
||||
model: "voyage-3",
|
||||
},
|
||||
agentId: "main",
|
||||
requests: [{ custom_id: "req-0", body: { input: "hello" } }],
|
||||
wait: true,
|
||||
|
|
@ -129,15 +118,11 @@ afterEach(() => {
|
|||
|
||||
describe("voyage batch bounded reads", () => {
|
||||
it.each([
|
||||
{ operation: "single", inputType: "query", expectedInputs: [["first"]] },
|
||||
{ operation: "single", inputType: "document", expectedInputs: [["first"]] },
|
||||
{ operation: "single", inputType: undefined, expectedInputs: [["first"]] },
|
||||
{ operation: "batch", inputType: "query", expectedInputs: [["first"], ["second"]] },
|
||||
{ operation: "batch", inputType: "document", expectedInputs: [["first", "second"]] },
|
||||
{ operation: "batch", inputType: undefined, expectedInputs: [["first", "second"]] },
|
||||
{ inputType: "query", expectedInputs: [["first"], ["second"]] },
|
||||
{ inputType: undefined, expectedInputs: [["first", "second"]] },
|
||||
] as const)(
|
||||
"preserves real $operation $inputType requests, grouping, and configured query parameters",
|
||||
async ({ operation, inputType, expectedInputs }) => {
|
||||
"preserves real $inputType requests, grouping, and configured query parameters",
|
||||
async ({ inputType, expectedInputs }) => {
|
||||
const received: Array<{
|
||||
url: string;
|
||||
authorization: string | undefined;
|
||||
|
|
@ -184,16 +169,12 @@ describe("voyage batch bounded reads", () => {
|
|||
},
|
||||
});
|
||||
expect(provider.maxInputTokens).toBe(32000);
|
||||
if (operation === "single") {
|
||||
await expect(provider.embed({ text: "first" }, { inputType })).resolves.toEqual([7, 11]);
|
||||
} else {
|
||||
await expect(
|
||||
provider.embedBatch([{ text: "first" }, "second"], { inputType }),
|
||||
).resolves.toEqual([
|
||||
[7, 11],
|
||||
[13, 17],
|
||||
]);
|
||||
}
|
||||
await expect(
|
||||
provider.embedBatch([{ text: "first" }, "second"], { inputType }),
|
||||
).resolves.toEqual([
|
||||
[7, 11],
|
||||
[13, 17],
|
||||
]);
|
||||
expect(received).toHaveLength(expectedInputs.length);
|
||||
expect(received).toEqual(
|
||||
expect.arrayContaining(
|
||||
|
|
@ -285,10 +266,10 @@ describe("voyage batch bounded reads", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("clamps polling to the remaining batch timeout", async () => {
|
||||
it("clamps polling to the remaining timeout and stops before fetching expired status", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
stubBatchFetch();
|
||||
const fetchMock = stubBatchFetch();
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
const result = runBatch({
|
||||
|
|
@ -312,16 +293,6 @@ describe("voyage batch bounded reads", () => {
|
|||
nowSpy.mockReturnValue(1_000);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("does not poll status after the batch timeout expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const fetchMock = stubBatchFetch();
|
||||
const result = runBatch({ pollIntervalMs: 1_000, timeoutMs: 1_000 });
|
||||
const rejection = expect(result).rejects.toThrow("voyage batch batch-0 timed out after 1000ms");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await rejection;
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([url]) => fetchInputUrl(url).endsWith("/batches/batch-0")),
|
||||
).toBe(false);
|
||||
|
|
@ -377,52 +348,6 @@ describe("voyage batch bounded reads", () => {
|
|||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the shared output reader and stops after the expected result", async () => {
|
||||
let canceled = false;
|
||||
const encoder = new TextEncoder();
|
||||
const output = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`${JSON.stringify({
|
||||
custom_id: "req-0",
|
||||
response: { status_code: 200, body: { data: [{ embedding: [1, 2] }] } },
|
||||
})}\n`,
|
||||
),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
stubBatchFetch((stage) => (stage === "output" ? output : undefined));
|
||||
|
||||
await expect(runBatch()).resolves.toEqual(new Map([["req-0", [1, 2]]]));
|
||||
expect(canceled).toBe(true);
|
||||
});
|
||||
|
||||
it("reads a completed error file before downloading successful output", async () => {
|
||||
const fetchMock = stubBatchFetch((stage) =>
|
||||
stage === "status"
|
||||
? Response.json({
|
||||
id: "batch-0",
|
||||
status: "completed",
|
||||
output_file_id: "output-0",
|
||||
error_file_id: "error-0",
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
|
||||
await expect(runBatch()).rejects.toThrow(
|
||||
"voyage batch batch-0 completed: provider rejected request",
|
||||
);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([url]) => fetchInputUrl(url).includes("/files/output-0/")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves authentication and batch request details on the real fetch boundary", async () => {
|
||||
const fetchMock = stubBatchFetch();
|
||||
|
||||
|
|
@ -445,26 +370,4 @@ describe("voyage batch bounded reads", () => {
|
|||
);
|
||||
expect(status?.[1]?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("retries transient batch creation failures through the shared HTTP policy", async () => {
|
||||
let attempts = 0;
|
||||
stubBatchFetch((stage) => {
|
||||
if (stage !== "create" || ++attempts > 1) {
|
||||
return undefined;
|
||||
}
|
||||
return Response.json({ error: { message: "retry this request" } }, { status: 503 });
|
||||
});
|
||||
|
||||
await expect(runBatch()).resolves.toEqual(new Map([["req-0", [1, 2]]]));
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it("does not poll or download when waiting is disabled", async () => {
|
||||
const fetchMock = stubBatchFetch();
|
||||
|
||||
await expect(runBatch({ wait: false })).rejects.toThrow(
|
||||
"voyage batch batch-0 submitted; enable remote.batch.wait to await completion",
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,19 +7,12 @@ import {
|
|||
|
||||
describe("Control UI focus locations", () => {
|
||||
it.each([
|
||||
["dashboard main", "/focus/dashboard/roboclaw", undefined, "/dashboard/roboclaw"],
|
||||
[
|
||||
"dashboard short reference",
|
||||
"/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb",
|
||||
undefined,
|
||||
"/dashboard/roboclaw/the-daily-claw-6d7c9ccb",
|
||||
],
|
||||
[
|
||||
"dashboard literal key",
|
||||
"/focus/dashboard/roboclaw/~key/12345678",
|
||||
undefined,
|
||||
"/dashboard/roboclaw/~key/12345678",
|
||||
],
|
||||
[
|
||||
"base-path dashboard",
|
||||
"/openclaw/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb/",
|
||||
|
|
@ -49,29 +42,11 @@ describe("Control UI focus locations", () => {
|
|||
selector: { kind: "source", value: "environment:Mac Studio/QA & demo" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"desktop session",
|
||||
"/focus/desktop/session/agent%3Amain%3Amobile%20session",
|
||||
{
|
||||
kind: "desktop",
|
||||
control: false,
|
||||
selector: { kind: "session", value: "agent:main:mobile session" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"controlled desktop",
|
||||
"/focus/desktop/control",
|
||||
{ kind: "desktop", control: true, selector: null },
|
||||
],
|
||||
[
|
||||
"controlled source",
|
||||
"/focus/desktop/control/source/node%3Aworker-1",
|
||||
{
|
||||
kind: "desktop",
|
||||
control: true,
|
||||
selector: { kind: "source", value: "node:worker-1" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"controlled session",
|
||||
"/focus/desktop/control/session/agent%3Amain%3Amobile",
|
||||
|
|
@ -91,12 +66,9 @@ describe("Control UI focus locations", () => {
|
|||
|
||||
it.each([
|
||||
"/focus",
|
||||
"/focus/unknown",
|
||||
"/focus/terminal/extra",
|
||||
"/focus/desktop/source",
|
||||
"/focus/desktop/session/%",
|
||||
"/focus/desktop/control/unknown/value",
|
||||
"/focus/dashboard",
|
||||
])("rejects malformed or unsupported target %s", (pathname) => {
|
||||
expect(parseControlUiFocusLocation(pathname, "")).toEqual({
|
||||
status: "unsupported",
|
||||
|
|
@ -104,16 +76,12 @@ describe("Control UI focus locations", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/?view=dashboard&session=agent%3Amain%3Awork",
|
||||
"/?view=terminal",
|
||||
"/?view=desktop",
|
||||
"/terminal",
|
||||
"/desktop",
|
||||
"/focused/terminal",
|
||||
])("does not parse query aliases or lookalike location %s", (pathname) => {
|
||||
expect(parseControlUiFocusLocation(pathname, "")).toBeNull();
|
||||
});
|
||||
it.each(["/?view=dashboard&session=agent%3Amain%3Awork", "/focused/terminal"])(
|
||||
"does not parse query aliases or lookalike location %s",
|
||||
(pathname) => {
|
||||
expect(parseControlUiFocusLocation(pathname, "")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("infers focus-aware base paths without overriding an explicit base", () => {
|
||||
expect(inferControlUiFocusBasePath("/focus/terminal")).toBe("");
|
||||
|
|
@ -184,12 +152,6 @@ describe("buildControlUiFocusPath", () => {
|
|||
"",
|
||||
"/focus/desktop/control/source/node%3Aworker-1",
|
||||
],
|
||||
[
|
||||
"controlled session",
|
||||
{ kind: "desktop", control: true, session: "agent:main:mobile" },
|
||||
"",
|
||||
"/focus/desktop/control/session/agent%3Amain%3Amobile",
|
||||
],
|
||||
[
|
||||
"empty values",
|
||||
{ kind: "desktop", source: " ", session: "" },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildControlUiCatalogSessionUrl,
|
||||
buildControlUiSessionPath,
|
||||
controlUiSessionSlug,
|
||||
} from "./index.js";
|
||||
import { buildControlUiCatalogSessionUrl, buildControlUiSessionPath } from "./index.js";
|
||||
import { buildControlUiCatalogSharePath } from "./share-build.js";
|
||||
|
||||
const SHARE_ROUTE = {
|
||||
|
|
@ -22,32 +18,23 @@ type ChatParams = Omit<Parameters<typeof buildControlUiSessionPath>[0], "namespa
|
|||
const UUID_KEY = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef";
|
||||
const buildChatPath = (params: ChatParams) =>
|
||||
buildControlUiSessionPath({ namespace: "chat", ...params });
|
||||
const THREAD_ID = "0123456789abcdef0123456789abcdef";
|
||||
const buildSharePath = (
|
||||
params: Omit<Parameters<typeof buildControlUiCatalogSharePath>[0], "shareRoute" | "threadId">,
|
||||
) => buildControlUiCatalogSharePath({ shareRoute: SHARE_ROUTE, threadId: THREAD_ID, ...params });
|
||||
|
||||
describe("buildControlUiCatalogSessionUrl", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "root base path",
|
||||
agentId: "main",
|
||||
basePath: undefined,
|
||||
expected: "/chat/main?catalog=beam&host=gateway&thread=beam-1",
|
||||
},
|
||||
{
|
||||
label: "nested base path and non-main agent",
|
||||
agentId: "research",
|
||||
basePath: "/admin/openclaw/",
|
||||
expected: "/admin/openclaw/chat/research?catalog=beam&host=gateway&thread=beam-1",
|
||||
},
|
||||
])("builds a canonical URL for $label", ({ agentId, basePath, expected }) => {
|
||||
it("builds a canonical URL under a nested base path for a non-main agent", () => {
|
||||
expect(
|
||||
buildControlUiCatalogSessionUrl({
|
||||
namespace: "chat",
|
||||
agentId,
|
||||
basePath,
|
||||
agentId: "research",
|
||||
basePath: "/admin/openclaw/",
|
||||
catalog: "beam",
|
||||
host: "gateway",
|
||||
thread: "beam-1",
|
||||
}),
|
||||
).toBe(expected);
|
||||
).toBe("/admin/openclaw/chat/research?catalog=beam&host=gateway&thread=beam-1");
|
||||
});
|
||||
|
||||
it("encodes reserved query characters", () => {
|
||||
|
|
@ -84,80 +71,34 @@ describe("buildControlUiCatalogSessionUrl", () => {
|
|||
describe("buildControlUiCatalogSharePath", () => {
|
||||
it.each([
|
||||
["Fix: upload flow!", "fix-upload-flow-"],
|
||||
["Deploy face deadbeef", "deploy-"],
|
||||
["🦞", ""],
|
||||
["x".repeat(60), `${"x".repeat(48)}-`],
|
||||
])("uses the session title slug for %s", (displayName, prefix) => {
|
||||
expect(
|
||||
buildControlUiCatalogSharePath({
|
||||
shareRoute: SHARE_ROUTE,
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
displayName,
|
||||
}),
|
||||
).toBe(`/beam/${prefix}0123456789ab`);
|
||||
expect(buildSharePath({ displayName })).toBe(`/beam/${prefix}0123456789ab`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "root path",
|
||||
basePath: undefined,
|
||||
expected: "/beam/0123456789ab",
|
||||
},
|
||||
{
|
||||
label: "nested base path",
|
||||
basePath: "/admin/openclaw/",
|
||||
expected: "/admin/openclaw/beam/0123456789ab",
|
||||
},
|
||||
])("builds a lowercase 12-character share id for $label", ({ basePath, expected }) => {
|
||||
expect(
|
||||
buildControlUiCatalogSharePath({
|
||||
shareRoute: SHARE_ROUTE,
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
basePath,
|
||||
}),
|
||||
).toBe(expected);
|
||||
it("builds a lowercase 12-character share id under a nested base path", () => {
|
||||
expect(buildSharePath({ basePath: "/admin/openclaw/" })).toBe(
|
||||
"/admin/openclaw/beam/0123456789ab",
|
||||
);
|
||||
});
|
||||
|
||||
it("can retain the full id for an unambiguous fallback", () => {
|
||||
expect(
|
||||
buildControlUiCatalogSharePath({
|
||||
shareRoute: SHARE_ROUTE,
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
prefixLength: SHARE_ROUTE.fullLength,
|
||||
}),
|
||||
).toBe("/beam/0123456789abcdef0123456789abcdef");
|
||||
expect(buildSharePath({ prefixLength: SHARE_ROUTE.fullLength })).toBe(
|
||||
"/beam/0123456789abcdef0123456789abcdef",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
shareRoute: { ...SHARE_ROUTE, routeSegment: "chat" },
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
shareRoute: { ...SHARE_ROUTE, routeSegment: "focus" },
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
shareRoute: { ...SHARE_ROUTE, routeSegment: "plugin" },
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
shareRoute: { ...SHARE_ROUTE, routeSegment: "settings" },
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
shareRoute: { ...SHARE_ROUTE, routeSegment: "Beam" },
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
shareRoute: { ...SHARE_ROUTE, routeSegment: "beam/extra" },
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{ shareRoute: SHARE_ROUTE, threadId: "0123456789ab" },
|
||||
{ shareRoute: SHARE_ROUTE, threadId: "0123456789ABCDEF0123456789ABCDEF" },
|
||||
{ shareRoute: SHARE_ROUTE, threadId: "not-hex" },
|
||||
])("rejects invalid catalog share input %#", ({ shareRoute, threadId }) => {
|
||||
expect(buildControlUiCatalogSharePath({ shareRoute, threadId })).toBeNull();
|
||||
["chat", THREAD_ID],
|
||||
["Beam", THREAD_ID],
|
||||
["beam/extra", THREAD_ID],
|
||||
["beam", "0123456789ab"],
|
||||
["beam", "0123456789ABCDEF0123456789ABCDEF"],
|
||||
])("rejects invalid catalog share input %#", (routeSegment, threadId) => {
|
||||
expect(
|
||||
buildControlUiCatalogSharePath({ shareRoute: { ...SHARE_ROUTE, routeSegment }, threadId }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -185,7 +126,6 @@ describe("buildControlUiSessionPath", () => {
|
|||
{ sessionKey: "agent:research:main", mainKey: "workspace" },
|
||||
"/chat/research/main",
|
||||
],
|
||||
["global", { sessionKey: "global", fallbackAgentId: "ops" }, "/chat/ops"],
|
||||
[
|
||||
"global under a configured main key",
|
||||
{ sessionKey: "global", fallbackAgentId: "ops", mainKey: "workspace", exactKey: true },
|
||||
|
|
@ -246,11 +186,6 @@ describe("buildControlUiSessionPath", () => {
|
|||
`/chat/main/${surface}/incognito-12345678-90ab-cdef-1234-567890abcdef`,
|
||||
],
|
||||
),
|
||||
[
|
||||
"UUID slug",
|
||||
{ sessionKey: UUID_KEY, displayName: "Deploy Monitor" },
|
||||
"/chat/main/deploy-monitor-12345678",
|
||||
],
|
||||
[
|
||||
"reserved short ref",
|
||||
{
|
||||
|
|
@ -266,23 +201,19 @@ describe("buildControlUiSessionPath", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["agent:ops:telegram:12345", "/control/dashboard/ops/telegram/12345"],
|
||||
["agent:research:global", "/control/dashboard/research/~key/global"],
|
||||
])("preserves base paths and namespaces for %s", (sessionKey, expected) => {
|
||||
it("preserves base paths and namespaces for qualified global keys", () => {
|
||||
expect(
|
||||
buildControlUiSessionPath({
|
||||
namespace: "dashboard",
|
||||
sessionKey,
|
||||
sessionKey: "agent:research:global",
|
||||
basePath: " /control/// ",
|
||||
}),
|
||||
).toBe(expected);
|
||||
).toBe("/control/dashboard/research/~key/global");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["OPS_TEAM", "ops_team"],
|
||||
["Research Agent!", "research-agent"],
|
||||
["..", "main"],
|
||||
["Kelvin", "kelvin"],
|
||||
["ſ", "main"],
|
||||
])("normalizes fallback agent %j", (fallbackAgentId, expectedAgentId) => {
|
||||
|
|
@ -302,15 +233,12 @@ describe("buildControlUiSessionPath", () => {
|
|||
{ sessionKey: "agent::control-link" },
|
||||
{ sessionKey: "agent:main:" },
|
||||
{ sessionKey: "agent:main:telegram::12345" },
|
||||
{ sessionKey: "agent:ops:room::part" },
|
||||
{ sessionKey: "agent:ops::main" },
|
||||
{ sessionKey: "agent:ops:cron:" },
|
||||
] satisfies readonly ChatParams[])("rejects invalid input %#", (params) => {
|
||||
expect(buildChatPath(params)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes trailing hex tokens from UUID display slugs", () => {
|
||||
expect(controlUiSessionSlug("Deploy face deadbeef")).toBe("deploy");
|
||||
expect(buildChatPath({ sessionKey: UUID_KEY, displayName: "Deploy face deadbeef" })).toBe(
|
||||
"/chat/main/deploy-12345678",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,45 +6,33 @@ import {
|
|||
type ControlUiSessionPathTarget,
|
||||
} from "./parse.js";
|
||||
|
||||
type ParseCase = {
|
||||
name: string;
|
||||
pathname: string;
|
||||
expected: ControlUiSessionPathTarget;
|
||||
basePath?: string;
|
||||
};
|
||||
type BuildCase = readonly [
|
||||
Parameters<typeof buildControlUiSessionPath>[0],
|
||||
ControlUiSessionPathTarget,
|
||||
type ParseCase = readonly [
|
||||
pathname: string,
|
||||
expected: ControlUiSessionPathTarget,
|
||||
basePath?: string,
|
||||
];
|
||||
|
||||
describe("parseControlUiSessionPath", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "main",
|
||||
pathname: "/chat/main",
|
||||
expected: { namespace: "chat", kind: "main", agentId: "main" },
|
||||
},
|
||||
{
|
||||
name: "base path",
|
||||
pathname: "/control/dashboard/OPS-Team",
|
||||
expected: { namespace: "dashboard", kind: "main", agentId: "ops-team" },
|
||||
basePath: "/control",
|
||||
},
|
||||
{
|
||||
name: "short ref",
|
||||
pathname: "/dashboard/main/12345678",
|
||||
expected: {
|
||||
["/chat/main", { namespace: "chat", kind: "main", agentId: "main" }],
|
||||
[
|
||||
"/control/dashboard/OPS-Team",
|
||||
{ namespace: "dashboard", kind: "main", agentId: "ops-team" },
|
||||
"/control",
|
||||
],
|
||||
[
|
||||
"/dashboard/main/12345678",
|
||||
{
|
||||
namespace: "dashboard",
|
||||
kind: "short",
|
||||
agentId: "main",
|
||||
shortId: "12345678",
|
||||
literalSessionKey: "agent:main:12345678",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "slugged short ref",
|
||||
pathname: "/chat/wrong/wrong-slug-1234567890AB",
|
||||
expected: {
|
||||
],
|
||||
[
|
||||
"/chat/wrong/wrong-slug-1234567890AB",
|
||||
{
|
||||
namespace: "chat",
|
||||
kind: "short",
|
||||
agentId: "wrong",
|
||||
|
|
@ -52,66 +40,40 @@ describe("parseControlUiSessionPath", () => {
|
|||
literalSessionKey: "agent:wrong:wrong-slug-1234567890AB",
|
||||
slugHint: "wrong-slug",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "literal",
|
||||
pathname: "/chat/main/not-a-short-id",
|
||||
expected: {
|
||||
],
|
||||
[
|
||||
"/chat/main/not-a-short-id",
|
||||
{
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:not-a-short-id",
|
||||
slugCandidate: "not-a-short-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi-segment literal",
|
||||
pathname: "/chat/ops/cron/nightly/run/8821",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "ops",
|
||||
sessionKey: "agent:ops:cron:nightly:run:8821",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forced literal",
|
||||
pathname: "/chat/main/~key/release-deadbeef",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:release-deadbeef",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dot escapes",
|
||||
pathname: "/chat/main/cron/~dot/~dotdot/run",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:cron:.:..:run",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tilde escape",
|
||||
pathname: "/chat/main/channel/~~dot",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:channel:~dot",
|
||||
},
|
||||
},
|
||||
] satisfies readonly ParseCase[])("parses $name", ({ pathname, expected, basePath }) => {
|
||||
],
|
||||
] satisfies readonly ParseCase[])("parses %s", (pathname, expected, basePath = "") => {
|
||||
expect(parseControlUiSessionPath(pathname, basePath)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each(["main", "global", "boot", "sessions"])("keeps reserved %s literal", (reserved) => {
|
||||
expect(parseControlUiSessionPath(`/chat/main/${reserved}`)).toMatchObject({
|
||||
it.each([
|
||||
["/chat/main/~key/release-deadbeef", "agent:main:release-deadbeef"],
|
||||
["/chat/main/cron/~dot/~dotdot/run", "agent:main:cron:.:..:run"],
|
||||
["/chat/main/channel/~~dot", "agent:main:channel:~dot"],
|
||||
])("decodes literal path %s", (pathname, sessionKey) => {
|
||||
expect(parseControlUiSessionPath(pathname)).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
sessionKey: `agent:main:${reserved}`,
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps reserved boot literal", () => {
|
||||
expect(parseControlUiSessionPath("/chat/main/boot")).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:boot",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -131,8 +93,6 @@ describe("parseControlUiSessionPath", () => {
|
|||
it.each([
|
||||
["%C5%BF", "main"],
|
||||
["%E2%84%AAelvin", "kelvin"],
|
||||
["OPS-Team", "ops-team"],
|
||||
["..%21", "main"],
|
||||
])("normalizes URL agent %s", (encodedAgentId, agentId) => {
|
||||
expect(parseControlUiSessionPath(`/chat/${encodedAgentId}`)).toMatchObject({ agentId });
|
||||
});
|
||||
|
|
@ -140,7 +100,6 @@ describe("parseControlUiSessionPath", () => {
|
|||
it.each([
|
||||
"/chat/%",
|
||||
"/chat/main/%",
|
||||
"/chat/main/~key/%",
|
||||
"/chat/main/~key",
|
||||
"/chat/main/telegram//12345",
|
||||
"/other/main",
|
||||
|
|
@ -148,171 +107,56 @@ describe("parseControlUiSessionPath", () => {
|
|||
expect(parseControlUiSessionPath(pathname)).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips main, literal, and slugged UUID paths", () => {
|
||||
const cases: readonly BuildCase[] = [
|
||||
[
|
||||
{ namespace: "chat", sessionKey: "agent:research:workspace", mainKey: "workspace" },
|
||||
{ namespace: "chat", kind: "main", agentId: "research" },
|
||||
],
|
||||
[
|
||||
{ namespace: "dashboard", sessionKey: "agent:research:global", basePath: "/control" },
|
||||
{
|
||||
namespace: "dashboard",
|
||||
kind: "literal",
|
||||
agentId: "research",
|
||||
sessionKey: "agent:research:global",
|
||||
},
|
||||
],
|
||||
[
|
||||
{ namespace: "chat", sessionKey: "agent:main:telegram:group:12345" },
|
||||
{
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:group:12345",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
namespace: "dashboard",
|
||||
sessionKey: "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
|
||||
basePath: "/control",
|
||||
displayName: "Deploy Monitor",
|
||||
},
|
||||
{
|
||||
namespace: "dashboard",
|
||||
kind: "short",
|
||||
agentId: "main",
|
||||
shortId: "12345678",
|
||||
literalSessionKey: "agent:main:deploy-monitor-12345678",
|
||||
slugHint: "deploy-monitor",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
for (const [params, expected] of cases) {
|
||||
const path = buildControlUiSessionPath(params);
|
||||
expect(parseControlUiSessionPath(path ?? "", params.basePath, params.mainKey)).toEqual(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["agent:main:main", "/chat/main", "main"],
|
||||
["agent:research:global", "/chat/research/~key/global", "literal"],
|
||||
["agent:main:standup", "/chat/main/standup", "literal"],
|
||||
["agent:main:sessions", "/chat/main/~key/sessions", "literal"],
|
||||
["agent:main:12345678", "/chat/main/~key/12345678", "literal"],
|
||||
["agent:main:standup", "/chat/main/standup", "standup"],
|
||||
["agent:main:sessions", "/chat/main/~key/sessions", undefined],
|
||||
["agent:main:12345678", "/chat/main/~key/12345678", undefined],
|
||||
[
|
||||
"agent:main:12345678-90ab-cdef-1234-567890abcdef",
|
||||
"/chat/main/~key/12345678-90ab-cdef-1234-567890abcdef",
|
||||
"literal",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
|
||||
"/chat/main/dashboard/12345678-90ab-cdef-1234-567890abcdef",
|
||||
"literal",
|
||||
undefined,
|
||||
],
|
||||
] as const)("round-trips exact key %s", (sessionKey, expectedPath, expectedKind) => {
|
||||
] as const)("round-trips exact key %s", (sessionKey, expectedPath, slugCandidate) => {
|
||||
const path = buildControlUiSessionPath({ namespace: "chat", sessionKey, exactKey: true });
|
||||
|
||||
expect(path).toBe(expectedPath);
|
||||
const parsed = parseControlUiSessionPath(path ?? "");
|
||||
expect(parsed?.kind).toBe(expectedKind);
|
||||
if (parsed?.kind === "literal") {
|
||||
expect(parsed.sessionKey).toBe(sessionKey);
|
||||
}
|
||||
expect(parseControlUiSessionPath(path ?? "")).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
slugCandidate,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
expected: { namespace: "chat", kind: "main", agentId: "main" },
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:roboclaw:dashboard:2139bddb-3211-4641-b993-10f619f124e6",
|
||||
it("parses a tool-composed forced literal with multiple segments", () => {
|
||||
expect(
|
||||
parseControlUiSessionPath(
|
||||
"/control/chat/roboclaw/~key/dashboard/2139bddb-3211-4641-b993-10f619f124e6",
|
||||
"/control",
|
||||
),
|
||||
).toEqual({
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "roboclaw",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "roboclaw",
|
||||
sessionKey: "agent:roboclaw:dashboard:2139bddb-3211-4641-b993-10f619f124e6",
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:x:telegram:group:12345",
|
||||
agentId: "x",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "x",
|
||||
sessionKey: "agent:x:telegram:group:12345",
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:x:discord:direct:9",
|
||||
agentId: "x",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "x",
|
||||
sessionKey: "agent:x:discord:direct:9",
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:x:standup",
|
||||
agentId: "x",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "x",
|
||||
sessionKey: "agent:x:standup",
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionKey: "agent:main:2139bddb-3211-4641-b993-10f619f124e6",
|
||||
agentId: "main",
|
||||
expected: {
|
||||
namespace: "chat",
|
||||
kind: "literal",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:2139bddb-3211-4641-b993-10f619f124e6",
|
||||
},
|
||||
},
|
||||
] satisfies ReadonlyArray<{
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
expected: ControlUiSessionPathTarget;
|
||||
}>)("parses the tool-composed URL for $sessionKey", ({ sessionKey, agentId, expected }) => {
|
||||
const base = "https://gateway.example/control";
|
||||
const url =
|
||||
sessionKey === "agent:main:main"
|
||||
? `${base}/chat/main`
|
||||
: `${base}/chat/${agentId}/~key/${sessionKey
|
||||
.slice(`agent:${agentId}:`.length)
|
||||
.replaceAll(":", "/")}`;
|
||||
|
||||
expect(parseControlUiSessionPath(new URL(url).pathname, "/control")).toEqual(expected);
|
||||
sessionKey: "agent:roboclaw:dashboard:2139bddb-3211-4641-b993-10f619f124e6",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchControlUiCatalogSharePath", () => {
|
||||
it.each([
|
||||
["/beam/0123456789ab", undefined, "0123456789ab"],
|
||||
["/beam/fix-upload-flow-0123456789ab", undefined, "0123456789ab"],
|
||||
["/beam/old-title-0123456789ab", undefined, "0123456789ab"],
|
||||
[
|
||||
"/openclaw/beam/fix-upload-flow-0123456789abcdef0123456789abcdef",
|
||||
"/openclaw",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
],
|
||||
[
|
||||
"/openclaw/beam/0123456789abcdef0123456789abcdef",
|
||||
"/openclaw",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
],
|
||||
] as const)("parses %s", (pathname, basePath, shortId) => {
|
||||
expect(matchControlUiCatalogSharePath({ pathname, basePath })).toEqual({
|
||||
routeSegment: "beam",
|
||||
|
|
@ -320,37 +164,26 @@ describe("matchControlUiCatalogSharePath", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["/beam/0123456789AB", "/beam/0123456789abcdef0123456789abcdef0", "/beam/nothexvaluezz"])(
|
||||
"parses the route owner before descriptor validation for %s",
|
||||
(pathname) => {
|
||||
expect(matchControlUiCatalogSharePath({ pathname })).toEqual({
|
||||
routeSegment: "beam",
|
||||
shortId: pathname.slice("/beam/".length),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"/chat/0123456789ab",
|
||||
"/focus/0123456789ab",
|
||||
"/plugin/0123456789ab",
|
||||
"/settings/0123456789ab",
|
||||
"/systems/0123456789ab",
|
||||
"/ui/chat",
|
||||
"/ui/config",
|
||||
"/concepts/agent-workspace",
|
||||
"/control/avatar/main",
|
||||
"/beam/0123456789a",
|
||||
"/beam/not-hex-value",
|
||||
"/beam/0123456789ab/extra",
|
||||
])("rejects ordinary, resource, and implausible share paths for %s", (pathname) => {
|
||||
expect(matchControlUiCatalogSharePath({ pathname })).toBeNull();
|
||||
it("parses the route owner before descriptor validation", () => {
|
||||
expect(matchControlUiCatalogSharePath({ pathname: "/beam/nothexvaluezz" })).toEqual({
|
||||
routeSegment: "beam",
|
||||
shortId: "nothexvaluezz",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["/other/0123456789ab", "/beam/0123456789ab", "/wrong/openclaw/beam/0123456789ab"])(
|
||||
"ignores unrelated or outside-base path %s",
|
||||
it.each(["/chat/0123456789ab", "/ui/chat", "/beam/0123456789a", "/beam/0123456789ab/extra"])(
|
||||
"rejects ordinary, resource, and implausible share paths for %s",
|
||||
(pathname) => {
|
||||
expect(matchControlUiCatalogSharePath({ pathname, basePath: "/openclaw" })).toBeNull();
|
||||
expect(matchControlUiCatalogSharePath({ pathname })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores paths outside the configured base", () => {
|
||||
expect(
|
||||
matchControlUiCatalogSharePath({
|
||||
pathname: "/wrong/openclaw/beam/0123456789ab",
|
||||
basePath: "/openclaw",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Memory host dreaming tests cover dreaming artifact persistence and lookup.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
|
@ -25,10 +24,7 @@ describe("memory dreaming host helpers", () => {
|
|||
frequency: "0 */4 * * *",
|
||||
timezone: "Europe/London",
|
||||
model: " anthropic/claude-sonnet-4-6 ",
|
||||
storage: {
|
||||
mode: "both",
|
||||
separateReports: true,
|
||||
},
|
||||
storage: { mode: "inline", separateReports: true },
|
||||
phases: {
|
||||
deep: {
|
||||
limit: "5",
|
||||
|
|
@ -48,14 +44,11 @@ describe("memory dreaming host helpers", () => {
|
|||
expect(resolved.frequency).toBe("0 */4 * * *");
|
||||
expect(resolved.timezone).toBe("Europe/London");
|
||||
expect(resolved.execution.defaults.model).toBe("anthropic/claude-sonnet-4-6");
|
||||
expect(resolved.phases.light.execution.model).toBe("anthropic/claude-sonnet-4-6");
|
||||
expect(resolved.phases.deep.execution.model).toBe("anthropic/claude-sonnet-4-6");
|
||||
expect(resolved.phases.rem.execution.model).toBe("anthropic/claude-sonnet-4-6");
|
||||
expect(resolved.storage).toEqual({
|
||||
mode: "both",
|
||||
separateReports: true,
|
||||
});
|
||||
expect(resolved.phases.deep.cron).toBe("0 */4 * * *");
|
||||
for (const phase of Object.values(resolved.phases)) {
|
||||
expect(phase.execution.model).toBe("anthropic/claude-sonnet-4-6");
|
||||
expect(phase.cron).toBe("0 */4 * * *");
|
||||
}
|
||||
expect(resolved.storage).toEqual({ mode: "inline", separateReports: true });
|
||||
expect(resolved.phases.deep.limit).toBe(5);
|
||||
expect(resolved.phases.deep.minScore).toBe(0.9);
|
||||
expect(resolved.phases.deep.minRecallCount).toBe(4);
|
||||
|
|
@ -127,17 +120,9 @@ describe("memory dreaming host helpers", () => {
|
|||
pluginConfig: {
|
||||
dreaming: {
|
||||
model: "anthropic/claude-haiku-4-5",
|
||||
execution: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.4",
|
||||
},
|
||||
},
|
||||
execution: { defaults: { model: "openai/gpt-5.4" } },
|
||||
phases: {
|
||||
rem: {
|
||||
execution: {
|
||||
model: "xai/grok-4.1-fast",
|
||||
},
|
||||
},
|
||||
rem: { execution: { model: "xai/grok-4.1-fast" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -149,18 +134,10 @@ describe("memory dreaming host helpers", () => {
|
|||
expect(resolved.phases.rem.execution.model).toBe("xai/grok-4.1-fast");
|
||||
});
|
||||
|
||||
it("enables dreaming by default and falls back to cfg timezone and deep defaults", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
userTimezone: "America/Los_Angeles",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
it("defaults to enabled dreaming with separate storage and the cfg timezone", () => {
|
||||
const resolved = resolveMemoryDreamingConfig({
|
||||
pluginConfig: {},
|
||||
cfg,
|
||||
cfg: { agents: { defaults: { userTimezone: "America/Los_Angeles" } } },
|
||||
});
|
||||
|
||||
expect(resolved.enabled).toBe(true);
|
||||
|
|
@ -171,81 +148,18 @@ describe("memory dreaming host helpers", () => {
|
|||
expect(resolved.phases.deep.minScore).toBe(0.75);
|
||||
expect(resolved.phases.deep.recencyHalfLifeDays).toBe(14);
|
||||
expect(resolved.phases.deep.maxAgeDays).toBe(30);
|
||||
});
|
||||
|
||||
it("defaults storage mode to separate so phase blocks do not pollute daily memory files", () => {
|
||||
const resolved = resolveMemoryDreamingConfig({
|
||||
pluginConfig: {},
|
||||
});
|
||||
|
||||
expect(resolved.storage).toEqual({
|
||||
mode: "separate",
|
||||
separateReports: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit inline storage mode for callers that opt in", () => {
|
||||
const resolved = resolveMemoryDreamingConfig({
|
||||
pluginConfig: {
|
||||
dreaming: {
|
||||
storage: {
|
||||
mode: "inline",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.storage.mode).toBe("inline");
|
||||
});
|
||||
|
||||
it("applies top-level dreaming frequency across all phases", () => {
|
||||
const resolved = resolveMemoryDreamingConfig({
|
||||
pluginConfig: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 */8 * * *",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.frequency).toBe("15 */8 * * *");
|
||||
expect(resolved.phases.light.cron).toBe("15 */8 * * *");
|
||||
expect(resolved.phases.deep.cron).toBe("15 */8 * * *");
|
||||
expect(resolved.phases.rem.cron).toBe("15 */8 * * *");
|
||||
});
|
||||
|
||||
it("dedupes shared workspaces across all configured agents", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "alpha", default: true, workspace: "/workspace/shared" },
|
||||
{ id: "beta", workspace: "/workspace/beta" },
|
||||
{ id: "gamma", workspace: "/workspace/shared" },
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
|
||||
{
|
||||
workspaceDir: "/workspace/shared",
|
||||
agentIds: ["alpha", "gamma"],
|
||||
},
|
||||
{
|
||||
workspaceDir: "/workspace/beta",
|
||||
agentIds: ["beta"],
|
||||
},
|
||||
]);
|
||||
expect(resolved.storage).toEqual({ mode: "separate", separateReports: false });
|
||||
});
|
||||
|
||||
it("uses canonical roster identities when agent aliases share a workspace", () => {
|
||||
const cfg = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "Team Alpha", workspace: "/workspace/shared" },
|
||||
{ id: "team-alpha", workspace: "/workspace/shared" },
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
};
|
||||
|
||||
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
|
||||
{ workspaceDir: "/workspace/shared", agentIds: ["team-alpha"] },
|
||||
|
|
@ -253,7 +167,7 @@ describe("memory dreaming host helpers", () => {
|
|||
});
|
||||
|
||||
it("does not require a default owner when no primary workspace is supplied", () => {
|
||||
const cfg = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
ownership: "explicit",
|
||||
list: [
|
||||
|
|
@ -261,41 +175,38 @@ describe("memory dreaming host helpers", () => {
|
|||
{ id: "beta", workspace: "/workspace/beta" },
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
};
|
||||
|
||||
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
|
||||
{
|
||||
workspaceDir: "/workspace/alpha",
|
||||
agentIds: ["alpha"],
|
||||
},
|
||||
{
|
||||
workspaceDir: "/workspace/beta",
|
||||
agentIds: ["beta"],
|
||||
},
|
||||
{ workspaceDir: "/workspace/alpha", agentIds: ["alpha"] },
|
||||
{ workspaceDir: "/workspace/beta", agentIds: ["beta"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes configured workspace symlink aliases across agents", async () => {
|
||||
it("dedupes non-adjacent workspace symlink aliases across agents", async () => {
|
||||
const rootDir = tempDirs.make("openclaw-dreaming-workspace-");
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const workspaceAliasDir = path.join(rootDir, "workspace-alias");
|
||||
const otherWorkspaceDir = path.join(rootDir, "other-workspace");
|
||||
await fs.mkdir(workspaceDir);
|
||||
await fs.symlink(
|
||||
workspaceDir,
|
||||
workspaceAliasDir,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const cfg = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "alpha", default: true, workspace: workspaceDir },
|
||||
{ id: "gamma", workspace: otherWorkspaceDir },
|
||||
{ id: "beta", workspace: workspaceAliasDir },
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
};
|
||||
|
||||
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
|
||||
{ workspaceDir, agentIds: ["alpha", "beta"] },
|
||||
{ workspaceDir: otherWorkspaceDir, agentIds: ["gamma"] },
|
||||
]);
|
||||
expect(resolveMemoryDreamingWorkspace(cfg, workspaceAliasDir)).toEqual({
|
||||
workspaceDir,
|
||||
|
|
@ -304,14 +215,14 @@ describe("memory dreaming host helpers", () => {
|
|||
});
|
||||
|
||||
it("includes the runtime primary workspace alongside configured subagent workspaces", () => {
|
||||
const cfg = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "agi-ceo", default: true, workspace: "/workspace/agi-ceo" },
|
||||
{ id: "agi-cdo", workspace: "/workspace/agi-cdo" },
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveMemoryDreamingWorkspaces(cfg, {
|
||||
|
|
@ -319,36 +230,22 @@ describe("memory dreaming host helpers", () => {
|
|||
primaryAgentId: "main",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
workspaceDir: "/workspace/agi-ceo",
|
||||
agentIds: ["agi-ceo"],
|
||||
},
|
||||
{
|
||||
workspaceDir: "/workspace/agi-cdo",
|
||||
agentIds: ["agi-cdo"],
|
||||
},
|
||||
{
|
||||
workspaceDir: "/workspace/main",
|
||||
agentIds: ["main"],
|
||||
},
|
||||
{ workspaceDir: "/workspace/agi-ceo", agentIds: ["agi-ceo"] },
|
||||
{ workspaceDir: "/workspace/agi-cdo", agentIds: ["agi-cdo"] },
|
||||
{ workspaceDir: "/workspace/main", agentIds: ["main"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses default agent fallback", () => {
|
||||
const cfg = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace",
|
||||
},
|
||||
defaults: { workspace: "/workspace" },
|
||||
entries: { main: { default: true } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
};
|
||||
|
||||
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
|
||||
{
|
||||
workspaceDir: "/workspace",
|
||||
agentIds: ["main"],
|
||||
},
|
||||
{ workspaceDir: "/workspace", agentIds: ["main"] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -389,68 +286,19 @@ describe("memory dreaming host helpers", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves the configured memory-slot plugin id", () => {
|
||||
expect(
|
||||
resolveMemoryDreamingPluginId({
|
||||
plugins: {
|
||||
slots: {
|
||||
memory: "memos-local-openclaw-plugin",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
).toBe("memos-local-openclaw-plugin");
|
||||
});
|
||||
|
||||
it("reads dreaming config from the configured memory-slot owner", () => {
|
||||
expect(
|
||||
resolveMemoryDreamingPluginConfig({
|
||||
plugins: {
|
||||
slots: {
|
||||
memory: "memos-local-openclaw-plugin",
|
||||
},
|
||||
entries: {
|
||||
"memos-local-openclaw-plugin": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
).toEqual({
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reads dreaming config from memory-lancedb when it owns the memory slot", () => {
|
||||
expect(
|
||||
resolveMemoryDreamingPluginConfig({
|
||||
plugins: {
|
||||
slots: {
|
||||
memory: "memory-lancedb",
|
||||
},
|
||||
slots: { memory: "memory-lancedb" },
|
||||
entries: {
|
||||
"memory-lancedb": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 */6 * * *",
|
||||
},
|
||||
},
|
||||
config: { dreaming: { enabled: true, frequency: "0 */6 * * *" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
).toEqual({
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 */6 * * *",
|
||||
},
|
||||
});
|
||||
}),
|
||||
).toEqual({ dreaming: { enabled: true, frequency: "0 */6 * * *" } });
|
||||
});
|
||||
|
||||
it("falls back to memory-core when no memory slot override is configured", () => {
|
||||
|
|
@ -458,55 +306,29 @@ describe("memory dreaming host helpers", () => {
|
|||
resolveMemoryDreamingPluginConfig({
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"memory-core": { config: { dreaming: { enabled: true } } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
).toEqual({
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
).toEqual({ dreaming: { enabled: true } });
|
||||
});
|
||||
|
||||
it('falls back to memory-core when memory slot is "none" or blank', () => {
|
||||
expect(
|
||||
resolveMemoryDreamingPluginId({
|
||||
plugins: {
|
||||
slots: {
|
||||
memory: "none",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
plugins: { slots: { memory: "none" } },
|
||||
}),
|
||||
).toBe("memory-core");
|
||||
|
||||
expect(
|
||||
resolveMemoryDreamingPluginConfig({
|
||||
plugins: {
|
||||
slots: {
|
||||
memory: " ",
|
||||
},
|
||||
slots: { memory: " " },
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"memory-core": { config: { dreaming: { enabled: true } } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
).toEqual({
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
).toEqual({ dreaming: { enabled: true } });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue