test(providers,core): remove low-value tests (batch d048) (#159298)

* test(tencent): deslop s310 tests

* test(inworld): deslop s306 tests

* test(vydra): deslop s303 tests

* test(searxng): deslop s321 tests

* test(link-understanding): deslop s307 tests

* test(normalization-core): deslop s322 tests

* test(media-core): deslop s313 tests

* test(acp-core): deslop s320 tests

* test(deepgram): deslop s317 tests

* test(deepgram): keep promise executors side-effect only

* test(providers): retain distinct contract coverage after reduction
This commit is contained in:
Peter Steinberger 2026-09-26 18:26:27 -07:00 • committed by GitHub
parent 063b1971ec
commit cf877dba7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 997 additions and 3021 deletions

View file

@ -1,11 +1,10 @@
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import {
createAuthCaptureJsonFetch,
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "openclaw/plugin-sdk/test-media-understanding";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
const providers: MediaUnderstandingProvider[] = [];
@ -23,22 +22,25 @@ if (!transcribeDeepgramAudio) {
installPinnedHostnameTestHooks();
const audioRequest = {
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
};
describe("transcribeDeepgramAudio", () => {
it("respects lowercase authorization header overrides", async () => {
const { fetchFn, getAuthHeader } = createAuthCaptureJsonFetch({
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({
results: { channels: [{ alternatives: [{ transcript: "ok" }] }] },
});
const result = await transcribeDeepgramAudio({
buffer: Buffer.from("audio"),
fileName: "note.mp3",
apiKey: "test-key",
timeoutMs: 1000,
...audioRequest,
headers: { authorization: "Token override" },
fetchFn,
});
expect(getAuthHeader()).toBe("Token override");
expect(new Headers(getRequest().init?.headers).get("authorization")).toBe("Token override");
expect(result.text).toBe("ok");
});
@ -48,10 +50,7 @@ describe("transcribeDeepgramAudio", () => {
});
const result = await transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
...audioRequest,
baseUrl: "https://api.example.com/v1/",
model: " ",
language: " en ",
@ -108,10 +107,7 @@ describe("transcribeDeepgramAudio", () => {
},
});
const result = await transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
...audioRequest,
query: { multichannel: true },
fetchFn,
});
@ -130,61 +126,26 @@ describe("transcribeDeepgramAudio", () => {
await expect(
transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
...audioRequest,
fetchFn,
}),
).rejects.toThrow("Audio transcription response missing transcript");
});
it("rejects non-object successful transcription JSON with a stable provider error", async () => {
const fetchFn = vi.fn<typeof fetch>().mockResolvedValueOnce(new Response(JSON.stringify([])));
await expect(
transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
fetchFn,
}),
).rejects.toThrow("Audio transcription failed: malformed JSON response");
});
it("rejects wrong nested transcript shapes with a stable provider error", async () => {
const { fetchFn } = createRequestCaptureJsonFetch({
results: { channels: { alternatives: [{ transcript: "hello" }] } },
});
await expect(
transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
fetchFn,
}),
).rejects.toThrow("Audio transcription failed: malformed JSON response");
});
it.each([
{ name: "first channel", transcripts: [123] },
{ name: "later channel", transcripts: ["First track.", 123] },
])("rejects non-string transcript values in the $name", async ({ transcripts }) => {
const { fetchFn } = createRequestCaptureJsonFetch({
results: {
channels: transcripts.map((transcript) => ({ alternatives: [{ transcript }] })),
},
});
{
name: "wrong nested transcript shapes",
channels: { alternatives: [{ transcript: "hello" }] },
},
{
name: "non-string transcript values in a later channel",
channels: ["First track.", 123].map((transcript) => ({ alternatives: [{ transcript }] })),
},
])("rejects $name with a stable provider error", async ({ channels }) => {
const { fetchFn } = createRequestCaptureJsonFetch({ results: { channels } });
await expect(
transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
...audioRequest,
fetchFn,
}),
).rejects.toThrow("Audio transcription failed: malformed JSON response");

View file

@ -1,5 +1,3 @@
// Deepgram tests cover realtime transcription provider plugin behavior.
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
@ -10,41 +8,32 @@ import type { RawData } from "ws";
import { WebSocketServer } from "ws";
import { buildDeepgramRealtimeTranscriptionProvider } from "./realtime-transcription-provider-factory.js";
const transcriptionHost = { createRealtimeTranscriptionWebSocketSession };
const provider = buildDeepgramRealtimeTranscriptionProvider({
createRealtimeTranscriptionWebSocketSession,
});
let cleanup: (() => Promise<void>) | undefined;
async function createDeepgramRealtimeServer(params: {
onRequest: (url: URL, headers: Record<string, string | string[] | undefined>) => void;
onRequest?: (url: URL, headers: Record<string, string | string[] | undefined>) => void;
onConnection?: (ws: WebSocket) => void;
}) {
const server = createServer();
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
const clients = new Set<WebSocket>();
server.on("upgrade", (request, socket, head) => {
params.onRequest(new URL(request.url ?? "/", "http://127.0.0.1"), request.headers);
wss.handleUpgrade(request, socket, head, (ws) => {
clients.add(ws);
ws.on("close", () => clients.delete(ws));
params.onConnection?.(ws);
});
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1", maxPayload: 1024 * 1024 });
wss.on("connection", (ws, request) => {
params.onRequest?.(new URL(request.url ?? "/", "http://127.0.0.1"), request.headers);
params.onConnection?.(ws);
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
wss.once("listening", resolve);
});
const port = (server.address() as AddressInfo).port;
const port = (wss.address() as AddressInfo).port;
cleanup = async () => {
for (const ws of clients) {
for (const ws of wss.clients) {
ws.terminate();
}
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
};
return { baseUrl: `http://127.0.0.1:${port}/deepgram/v1` };
}
@ -87,7 +76,6 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
});
it("normalizes nested provider config", () => {
const provider = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost);
const resolved = provider.resolveConfig?.({
cfg: {} as OpenClawConfig,
rawConfig: {
@ -119,22 +107,19 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
it("requires an API key when creating sessions", () => {
vi.stubEnv("DEEPGRAM_API_KEY", "");
const provider = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost);
expect(() => provider.createSession({ providerConfig: {} })).toThrow(
"Deepgram API key missing",
);
});
it.each(["not a url", "ftp://files.example.com"])("rejects invalid endpoint %s", (baseUrl) => {
const provider = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost);
expect(() => provider.createSession({ providerConfig: { apiKey: "dg-key", baseUrl } })).toThrow(
/^Invalid Deepgram baseUrl:/,
);
it("rejects malformed endpoints", () => {
expect(() =>
provider.createSession({ providerConfig: { apiKey: "dg-key", baseUrl: "not a url" } }),
).toThrow(/^Invalid Deepgram baseUrl:/);
});
it("validates the environment override", () => {
vi.stubEnv("DEEPGRAM_BASE_URL", "not a url");
const provider = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost);
expect(() => provider.createSession({ providerConfig: { apiKey: "dg-key" } })).toThrow(
"Invalid Deepgram baseUrl: value is not a valid URL",
);
@ -143,13 +128,12 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
it("does not echo the configured URL in validation errors", () => {
const rawMarker = "configured-value-marker";
const nonHttp = `ftp://files.example.com/${rawMarker}`;
const provider = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost);
try {
provider.createSession({ providerConfig: { apiKey: "dg-key", baseUrl: nonHttp } });
throw new Error("expected rejection");
} catch (error) {
const message = (error as Error).message;
expect(message).toMatch(/unsupported scheme/);
expect(message).toMatch(/^Invalid Deepgram baseUrl: unsupported scheme/);
expect(message).not.toContain(rawMarker);
}
});
@ -162,7 +146,6 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
const server = await createDeepgramRealtimeServer({
onRequest: (url, headers) => requests.push({ url, headers }),
});
const provider = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost);
const session = provider.createSession({
providerConfig: {
apiKey: "dummy",
@ -181,65 +164,9 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
expect(requests[0]?.headers.authorization).toBe("Token dummy");
});
it("buffers finalized segments until the utterance is complete", async () => {
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
sendResult(ws, { text: "hello", isFinal: true });
sendResult(ws, { text: "world", isFinal: true, speechFinal: true });
},
});
const onPartial = vi.fn();
const transcriptReceived = createDeferred<string>();
const onTranscript = vi.fn(transcriptReceived.resolve);
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 1000 },
onPartial,
onTranscript,
});
try {
await session.connect();
await vi.waitFor(() => transcriptReceived.promise);
expect(onTranscript).toHaveBeenCalledWith("hello world");
} finally {
session.close();
}
expect(onPartial).toHaveBeenCalledWith("hello");
expect(onTranscript).toHaveBeenCalledTimes(1);
});
it("replaces the provisional tail with the text-bearing speech-final result", async () => {
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
sendResult(ws, { text: "hello" });
sendResult(ws, { text: "hello", isFinal: true, speechFinal: true });
},
});
const transcriptReceived = createDeferred<string>();
const onTranscript = vi.fn(transcriptReceived.resolve);
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 1000 },
onTranscript,
});
try {
await session.connect();
await vi.waitFor(() => transcriptReceived.promise);
expect(onTranscript).toHaveBeenCalledWith("hello");
} finally {
session.close();
}
expect(onTranscript).toHaveBeenCalledTimes(1);
});
it("does not promote a rejected provisional tail on an empty speech-final result", async () => {
const deliveryMarker = "rejected-tail frames delivered";
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
sendResult(ws, { text: "delete everything" });
sendResult(ws, { text: "", isFinal: true, speechFinal: true });
@ -257,7 +184,7 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
}
});
const onTranscript = vi.fn();
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
const session = provider.createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 1000 },
onPartial,
onError,
@ -283,7 +210,6 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
it("preserves identical transcripts from consecutive utterances", async () => {
const deliveryMarker = "consecutive-utterance frames delivered";
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
sendResult(ws, { text: "yes", isFinal: true, speechFinal: true });
sendResult(ws, { text: "yes", isFinal: true, speechFinal: true });
@ -297,7 +223,7 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
framesDelivered.resolve();
}
});
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
const session = provider.createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 1000 },
onTranscript,
onError,
@ -319,7 +245,6 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
it("flushes finalized text returned after a client finalize request", async () => {
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
sendResult(ws, { text: "good", isFinal: true });
sendResult(ws, { text: "bye" });
@ -336,7 +261,7 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
});
const transcriptReceived = createDeferred<string>();
const onTranscript = vi.fn(transcriptReceived.resolve);
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
const session = provider.createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 10_000 },
onTranscript,
});
@ -355,7 +280,6 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
it("flushes finalized text once when finalize produces no result", async () => {
let finalizeRequests = 0;
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
sendResult(ws, { text: "good", isFinal: true });
sendResult(ws, { text: "bye" });
@ -373,7 +297,7 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
}
});
const onTranscript = vi.fn();
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
const session = provider.createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 10_000 },
onPartial,
onTranscript,
@ -405,17 +329,18 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
it("does not commit a turn on an utterance-end gap before speech-final", async () => {
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
sendResult(ws, { text: "still", isFinal: true });
ws.send(JSON.stringify({ type: "UtteranceEnd" }));
sendResult(ws, { text: "speaking", isFinal: true, speechFinal: true });
},
});
const onPartial = vi.fn();
const transcriptReceived = createDeferred<string>();
const onTranscript = vi.fn(transcriptReceived.resolve);
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
const session = provider.createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 25 },
onPartial,
onTranscript,
});
@ -427,6 +352,7 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
session.close();
}
expect(onPartial).toHaveBeenCalledWith("still");
expect(onTranscript).toHaveBeenCalledTimes(1);
});
@ -434,7 +360,6 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
vi.useFakeTimers();
let socket: WebSocket | undefined;
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
socket = ws;
sendResult(ws, { text: "still speaking" });
@ -442,7 +367,7 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
});
const onPartial = vi.fn();
const onTranscript = vi.fn();
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
const session = provider.createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 25 },
onPartial,
onTranscript,
@ -456,17 +381,24 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
sendResult(socket!, { text: "continuous speech", isFinal: true, speechFinal: true });
await vi.waitFor(() => expect(onTranscript).toHaveBeenCalledWith("continuous speech"));
session.close();
expect(onTranscript).toHaveBeenCalledTimes(1);
});
it("does not merge an interrupted turn into a reconnected provider stream", async () => {
it.each([
{ name: "discards provisional speech", isFinal: false, expected: [["new"]] },
{
name: "preserves finalized speech as a separate turn",
isFinal: true,
expected: [["old"], ["new"]],
},
])("$name when the provider reconnects", async ({ isFinal, expected }) => {
vi.useFakeTimers();
let connectionCount = 0;
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
connectionCount += 1;
if (connectionCount === 1) {
sendResult(ws, { text: "old" });
sendResult(ws, { text: "old", isFinal });
ws.close();
return;
}
@ -474,7 +406,7 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
},
});
const onTranscript = vi.fn();
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
const session = provider.createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 10_000 },
onTranscript,
});
@ -483,49 +415,16 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
// Observe the real socket close before advancing the provider's retry delay.
await vi.waitFor(() => expect(session.isConnected()).toBe(false));
await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(onTranscript).toHaveBeenCalledWith("new"), {
await vi.waitFor(() => expect(onTranscript).toHaveBeenCalledTimes(expected.length), {
timeout: 3000,
});
session.close();
expect(onTranscript).toHaveBeenCalledTimes(1);
});
it("preserves finalized speech as a separate turn when the provider reconnects", async () => {
vi.useFakeTimers();
let connectionCount = 0;
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
connectionCount += 1;
if (connectionCount === 1) {
sendResult(ws, { text: "old", isFinal: true });
ws.close();
return;
}
sendResult(ws, { text: "new", isFinal: true, speechFinal: true });
},
});
const onTranscript = vi.fn();
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 10_000 },
onTranscript,
});
await session.connect();
await vi.waitFor(() => expect(session.isConnected()).toBe(false));
await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(onTranscript).toHaveBeenCalledTimes(2), {
timeout: 3000,
});
session.close();
expect(onTranscript.mock.calls).toEqual([["old"], ["new"]]);
expect(onTranscript.mock.calls).toEqual(expected);
});
it("terminates instead of retaining an oversized utterance", async () => {
const server = await createDeepgramRealtimeServer({
onRequest: () => undefined,
onConnection: (ws) => {
sendResult(ws, { text: "x".repeat(256 * 1024), isFinal: true });
sendResult(ws, { text: "y" });
@ -533,7 +432,7 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => {
});
const errorReceived = createDeferred<Error>();
const onError = vi.fn(errorReceived.resolve);
const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({
const session = provider.createSession({
providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 1000 },
onError,
});

View file

@ -1,4 +1,3 @@
// Inworld tests cover speech provider plugin behavior.
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
const { inworldTTSMock, listInworldVoicesMock } = vi.hoisted(() => ({
@ -17,12 +16,31 @@ vi.mock("./tts.js", async (importOriginal) => {
import { buildInworldSpeechProvider } from "./speech-provider.js";
const request = {
text: "Hello",
cfg: {},
providerConfig: { apiKey: "key", voiceId: "Sarah", modelId: "inworld-tts-1.5-max" },
timeoutMs: 30_000,
};
const policy = {
enabled: true,
allowText: true,
allowProvider: true,
allowVoice: true,
allowModelId: true,
allowVoiceSettings: true,
allowNormalization: true,
allowSeed: true,
};
afterAll(() => {
vi.doUnmock("./tts.js");
vi.resetModules();
});
describe("buildInworldSpeechProvider", () => {
const provider = buildInworldSpeechProvider();
afterEach(() => {
inworldTTSMock.mockReset();
listInworldVoicesMock.mockReset();
@ -30,47 +48,22 @@ describe("buildInworldSpeechProvider", () => {
vi.restoreAllMocks();
});
it("reports configured when INWORLD_API_KEY env var is set", () => {
vi.stubEnv("INWORLD_API_KEY", "test-key");
const provider = buildInworldSpeechProvider();
it.each([
{ source: "environment", env: "test-key", providerConfig: {} },
{ source: "config", env: "", providerConfig: { apiKey: "config-key" } },
])("reports configured with a key from $source", ({ env, providerConfig }) => {
vi.stubEnv("INWORLD_API_KEY", env);
expect(
provider.isConfigured({
providerConfig: {},
providerConfig,
timeoutMs: 30_000,
}),
).toBe(true);
});
it("reports configured when providerConfig apiKey is set", () => {
vi.stubEnv("INWORLD_API_KEY", "");
const provider = buildInworldSpeechProvider();
expect(
provider.isConfigured({
providerConfig: { apiKey: "config-key" },
timeoutMs: 30_000,
}),
).toBe(true);
});
it("reports not configured when no key is available", () => {
vi.stubEnv("INWORLD_API_KEY", "");
const provider = buildInworldSpeechProvider();
expect(
provider.isConfigured({
providerConfig: {},
timeoutMs: 30_000,
}),
).toBe(false);
});
it("rejects blank API keys across every request entrypoint", async () => {
vi.stubEnv("INWORLD_API_KEY", " ");
const provider = buildInworldSpeechProvider();
const listVoices = provider.listVoices;
const synthesizeTelephony = provider.synthesizeTelephony;
if (!listVoices || !synthesizeTelephony) {
throw new Error("expected Inworld voice listing and telephony synthesis");
}
const blankRequest = { ...request, providerConfig: {}, timeoutMs: 5_000 };
expect(
provider.isConfigured({
@ -79,47 +72,21 @@ describe("buildInworldSpeechProvider", () => {
}),
).toBe(false);
await expect(
listVoices({
providerConfig: {},
apiKey: " ",
timeoutMs: 5_000,
}),
).rejects.toThrow("Inworld API key missing");
await expect(
provider.synthesize({
text: "test",
cfg: {} as never,
providerConfig: {},
target: "audio-file",
timeoutMs: 5_000,
}),
).rejects.toThrow("Inworld API key missing");
await expect(
synthesizeTelephony({
text: "test",
cfg: {} as never,
providerConfig: {},
timeoutMs: 5_000,
}),
).rejects.toThrow("Inworld API key missing");
await expect(provider.listVoices?.({ ...blankRequest, apiKey: " " })).rejects.toThrow(
"Inworld API key missing",
);
await expect(provider.synthesize({ ...blankRequest, target: "audio-file" })).rejects.toThrow(
"Inworld API key missing",
);
await expect(provider.synthesizeTelephony?.(blankRequest)).rejects.toThrow(
"Inworld API key missing",
);
expect(listInworldVoicesMock).not.toHaveBeenCalled();
expect(inworldTTSMock).not.toHaveBeenCalled();
});
it("has correct provider metadata", () => {
const provider = buildInworldSpeechProvider();
expect(provider.id).toBe("inworld");
expect(provider.label).toBe("Inworld");
expect(provider.autoSelectOrder).toBe(30);
expect(provider.models).toContain("inworld-tts-1.5-max");
expect(provider.models).toContain("inworld-tts-1.5-mini");
});
it("forwards the core-resolved voice-list timeout", async () => {
const provider = buildInworldSpeechProvider();
await provider.listVoices?.({
providerConfig: { apiKey: "test-key" },
timeoutMs: 30_000,
@ -131,9 +98,8 @@ describe("buildInworldSpeechProvider", () => {
});
it("normalizes provider-owned speech config from raw provider config", () => {
const provider = buildInworldSpeechProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as never,
cfg: {},
timeoutMs: 30_000,
rawConfig: {
providers: {
@ -158,7 +124,6 @@ describe("buildInworldSpeechProvider", () => {
});
it("preserves inherited Talk settings when overrides are blank", () => {
const provider = buildInworldSpeechProvider();
const params = { voiceId: " ", modelId: " inworld-tts-1.5-mini ", temperature: 0.5 };
const talk = provider.resolveTalkConfig?.({
cfg: {},
@ -180,30 +145,12 @@ describe("buildInworldSpeechProvider", () => {
});
it("parses Inworld TTS directive overrides", () => {
const provider = buildInworldSpeechProvider();
const policy = {
enabled: true,
allowText: true,
allowProvider: true,
allowVoice: true,
allowModelId: true,
allowVoiceSettings: true,
allowNormalization: true,
allowSeed: true,
};
const parseDirectiveToken = provider.parseDirectiveToken;
expect(parseDirectiveToken).toBeTypeOf("function");
if (!parseDirectiveToken) {
throw new Error("expected Inworld directive parser");
}
expect(parseDirectiveToken({ key: "voice", value: "Ashley", policy })).toEqual({
expect(provider.parseDirectiveToken?.({ key: "voice", value: "Ashley", policy })).toEqual({
handled: true,
overrides: { voiceId: "Ashley" },
});
expect(
parseDirectiveToken({
provider.parseDirectiveToken?.({
key: "model",
value: "inworld-tts-1.5-mini",
policy,
@ -212,74 +159,33 @@ describe("buildInworldSpeechProvider", () => {
handled: true,
overrides: { modelId: "inworld-tts-1.5-mini" },
});
expect(parseDirectiveToken({ key: "temperature", value: "0.7", policy })).toEqual({
expect(provider.parseDirectiveToken?.({ key: "temperature", value: "0.7", policy })).toEqual({
handled: true,
overrides: { temperature: 0.7 },
});
});
it("warns on invalid directive temperature", () => {
const provider = buildInworldSpeechProvider();
it.each(["3", "0x1"])("warns on invalid directive temperature %s", (value) => {
expect(
provider.parseDirectiveToken?.({
key: "temperature",
value: "3",
policy: {
enabled: true,
allowText: true,
allowProvider: true,
allowVoice: true,
allowModelId: true,
allowVoiceSettings: true,
allowNormalization: true,
allowSeed: true,
},
value,
policy,
}),
).toEqual({
handled: true,
warnings: ['invalid Inworld temperature "3"'],
});
});
it("warns on non-decimal directive temperature", () => {
const provider = buildInworldSpeechProvider();
expect(
provider.parseDirectiveToken?.({
key: "temperature",
value: "0x1",
policy: {
enabled: true,
allowText: true,
allowProvider: true,
allowVoice: true,
allowModelId: true,
allowVoiceSettings: true,
allowNormalization: true,
allowSeed: true,
},
}),
).toEqual({
handled: true,
warnings: ['invalid Inworld temperature "0x1"'],
warnings: [`invalid Inworld temperature "${value}"`],
});
});
it("drops malformed temperature values before synthesis", async () => {
inworldTTSMock.mockResolvedValueOnce(Buffer.from("audio"));
const provider = buildInworldSpeechProvider();
await provider.synthesize?.({
text: "Hello",
cfg: {} as never,
providerConfig: {
apiKey: "key",
voiceId: "Sarah",
modelId: "inworld-tts-1.5-max",
temperature: 0,
},
await provider.synthesize({
...request,
providerConfig: { ...request.providerConfig, temperature: 0 },
providerOverrides: { temperature: 3 },
target: "audio-file",
timeoutMs: 30_000,
});
expect(inworldTTSMock).toHaveBeenCalledWith(
@ -289,15 +195,11 @@ describe("buildInworldSpeechProvider", () => {
it("synthesizes voice-note targets with native OGG_OPUS output", async () => {
inworldTTSMock.mockResolvedValueOnce(Buffer.from("opus"));
const provider = buildInworldSpeechProvider();
const result = await provider.synthesize?.({
text: "Hello",
cfg: {} as never,
providerConfig: { apiKey: "key", voiceId: "Sarah", modelId: "inworld-tts-1.5-max" },
const result = await provider.synthesize({
...request,
providerOverrides: { voice: "Ashley", model: "inworld-tts-1.5-mini", temperature: 0.6 },
target: "voice-note",
timeoutMs: 30_000,
});
expect(inworldTTSMock).toHaveBeenCalledWith({
@ -320,14 +222,10 @@ describe("buildInworldSpeechProvider", () => {
it("synthesizes telephony PCM at 22050 Hz", async () => {
inworldTTSMock.mockResolvedValueOnce(Buffer.from("pcm"));
const provider = buildInworldSpeechProvider();
const result = await provider.synthesizeTelephony?.({
text: "Hello",
cfg: {} as never,
providerConfig: { apiKey: "key", voiceId: "Sarah", modelId: "inworld-tts-1.5-max" },
...request,
providerOverrides: { voice: "Ashley", model: "inworld-tts-1.5-mini", temperature: 0.6 },
timeoutMs: 30_000,
});
expect(inworldTTSMock).toHaveBeenCalledWith({

View file

@ -1,4 +1,3 @@
import { expectDefined } from "@openclaw/normalization-core";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
@ -29,6 +28,12 @@ function queueGuardedResponse(response: Response): { release: ReturnType<typeof
return { release };
}
function queueAudioResponse() {
return queueGuardedResponse(
Response.json({ result: { audioContent: Buffer.from("audio").toString("base64") } }),
);
}
function lastGuardRequest(): GuardRequest {
const calls = fetchWithSsrFGuardMock.mock.calls;
const call = calls[calls.length - 1];
@ -57,31 +62,28 @@ afterAll(() => {
});
describe("listInworldVoices", () => {
it("maps Inworld voice metadata into speech voice options", async () => {
it("maps voice metadata and filters entries without an ID", async () => {
const { release } = queueGuardedResponse(
new Response(
JSON.stringify({
voices: [
{
voiceId: "Dennis",
displayName: "Dennis",
description: "Middle-aged man with a smooth, calm and friendly voice",
langCode: "EN_US",
tags: ["male", "middle-aged", "smooth", "calm", "friendly"],
source: "SYSTEM",
},
{
voiceId: "Ashley",
displayName: "Ashley",
description: "A warm, natural female voice",
langCode: "EN_US",
tags: ["female", "warm", "natural"],
source: "SYSTEM",
},
],
}),
{ status: 200 },
),
Response.json({
voices: [
{
voiceId: "Dennis",
displayName: "Dennis",
description: "Middle-aged man with a smooth, calm and friendly voice",
langCode: "EN_US",
tags: ["male", "middle-aged", "smooth", "calm", "friendly"],
source: "SYSTEM",
},
{
voiceId: "Ashley",
displayName: "Ashley",
langCode: "EN_US",
tags: ["female", "warm", "natural"],
source: "SYSTEM",
},
{ voiceId: "", displayName: "Empty" },
],
}),
);
const voices = await listInworldVoices({ apiKey: "test-key" });
@ -97,7 +99,7 @@ describe("listInworldVoices", () => {
{
id: "Ashley",
name: "Ashley",
description: "A warm, natural female voice",
description: undefined,
locale: "EN_US",
gender: "female",
},
@ -106,6 +108,7 @@ describe("listInworldVoices", () => {
expect(request.url).toBe("https://api.inworld.ai/voices/v1/voices");
expect(request.auditContext).toBe("inworld-voices");
expect(request.policy).toEqual({ hostnameAllowlist: ["api.inworld.ai"] });
expect(request.timeoutMs).toBe(30_000);
const headers = new Headers(request.init?.headers);
expect(headers.get("authorization")).toBe("Basic test-key");
expect(release).toHaveBeenCalledTimes(1);
@ -119,55 +122,33 @@ describe("listInworldVoices", () => {
);
});
it("filters out voices with empty voiceId", async () => {
queueGuardedResponse(
new Response(
JSON.stringify({
voices: [
{ voiceId: "", displayName: "Empty" },
{ voiceId: "Dennis", displayName: "Dennis" },
],
}),
{ status: 200 },
),
);
const voices = await listInworldVoices({ apiKey: "test-key" });
expect(voices).toHaveLength(1);
expect(expectDefined(voices[0], "Inworld voice").id).toBe("Dennis");
});
it("returns empty array when no voices present", async () => {
queueGuardedResponse(new Response(JSON.stringify({}), { status: 200 }));
queueGuardedResponse(Response.json({}));
const voices = await listInworldVoices({ apiKey: "test-key" });
expect(voices).toStrictEqual([]);
});
it("passes language filter as query parameter", async () => {
queueGuardedResponse(new Response(JSON.stringify({ voices: [] }), { status: 200 }));
queueGuardedResponse(Response.json({ voices: [] }));
await listInworldVoices({ apiKey: "test-key", language: "EN_US" });
expect(lastGuardRequest().url).toBe("https://api.inworld.ai/voices/v1/voices?languages=EN_US");
});
it("defaults to a bounded timeout for voice list requests", async () => {
queueGuardedResponse(new Response(JSON.stringify({ voices: [] }), { status: 200 }));
await listInworldVoices({ apiKey: "test-key" });
expect(lastGuardRequest().timeoutMs).toBe(30_000);
});
});
describe("inworldTTS", () => {
it("concatenates base64 audio chunks from streaming response", async () => {
const chunk1 = Buffer.from("audio-chunk-1").toString("base64");
it("concatenates an under-cap 1 MiB payload and skips blank stream lines", async () => {
const payload = "x".repeat(1024 * 1024);
const chunk1 = Buffer.from(payload).toString("base64");
const chunk2 = Buffer.from("audio-chunk-2").toString("base64");
const body = [
"",
JSON.stringify({ result: { audioContent: chunk1 } }),
"",
JSON.stringify({ result: { audioContent: chunk2 } }),
"",
].join("\n");
const { release } = queueGuardedResponse(new Response(body, { status: 200 }));
@ -177,9 +158,7 @@ describe("inworldTTS", () => {
apiKey: "test-key",
});
expect(buffer).toEqual(
Buffer.concat([Buffer.from("audio-chunk-1"), Buffer.from("audio-chunk-2")]),
);
expect(buffer.equals(Buffer.from(`${payload}audio-chunk-2`))).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
@ -192,21 +171,15 @@ describe("inworldTTS", () => {
);
});
it("throws on HTTP errors with response body", async () => {
const { release } = queueGuardedResponse(new Response("bad request body", { status: 400 }));
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
"Inworld TTS API error (400): bad request body",
);
expect(release).toHaveBeenCalledTimes(1);
});
it("keeps truncated HTTP error bodies UTF-16 safe", async () => {
queueGuardedResponse(new Response(`${"e".repeat(399)}😀tail`, { status: 400 }));
const { release } = queueGuardedResponse(
new Response(`${"e".repeat(399)}😀tail`, { status: 400 }),
);
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toMatchObject({
message: `Inworld TTS API error (400): ${"e".repeat(399)}…`,
});
expect(release).toHaveBeenCalledTimes(1);
});
it("throws on in-stream errors", async () => {
@ -238,10 +211,7 @@ describe("inworldTTS", () => {
});
it("sends correct request body with defaults", async () => {
const chunk = Buffer.from("audio").toString("base64");
queueGuardedResponse(
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
);
queueAudioResponse();
await inworldTTS({ text: "Hello", apiKey: "test-key" });
@ -265,10 +235,7 @@ describe("inworldTTS", () => {
});
it("includes temperature and sampleRateHertz when provided", async () => {
const chunk = Buffer.from("audio").toString("base64");
queueGuardedResponse(
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
);
queueAudioResponse();
await inworldTTS({
text: "Hello",
@ -289,10 +256,7 @@ describe("inworldTTS", () => {
});
it("uses custom base URL", async () => {
const chunk = Buffer.from("audio").toString("base64");
queueGuardedResponse(
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
);
queueAudioResponse();
await inworldTTS({
text: "Hello",
@ -305,24 +269,12 @@ describe("inworldTTS", () => {
hostnameAllowlist: ["custom.inworld.example.com"],
});
});
it("skips empty lines in streaming response", async () => {
const chunk = Buffer.from("audio").toString("base64");
const body = `\n${JSON.stringify({ result: { audioContent: chunk } })}\n\n`;
queueGuardedResponse(new Response(body, { status: 200 }));
const buffer = await inworldTTS({ text: "test", apiKey: "test-key" });
expect(buffer).toEqual(Buffer.from("audio"));
});
});
describe("Inworld response read bounding", () => {
const MiB = 1024 * 1024;
// A never-ending stream that enqueues one fixed-size chunk per pull. An
// unbounded reader (the previous `await response.text()` / `response.json()`)
// would buffer this forever and OOM; the bounded reader must stop at the cap
// and cancel the stream.
// An unbounded reader would never finish; the cap must cancel the stream.
function infiniteByteStream(chunkBytes: number): {
stream: ReadableStream<Uint8Array>;
state: { enqueued: number; cancelled: boolean };
@ -348,23 +300,10 @@ describe("Inworld response read bounding", () => {
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
/Inworld TTS audio stream too large: \d+ bytes \(limit: 33554432 bytes\)/,
);
// Enforced after a bounded number of 8 MiB chunks, never the full unbounded
// stream, and the stream is cancelled so the socket/buffers are released.
expect(state.enqueued).toBeLessThanOrEqual(8);
expect(state.cancelled).toBe(true);
});
it("edge: an under-cap ~1 MiB audio payload is read intact, not truncated", async () => {
const payload = "x".repeat(MiB);
const encoded = Buffer.from(payload).toString("base64");
const body = JSON.stringify({ result: { audioContent: encoded } });
queueGuardedResponse(new Response(body, { status: 200 }));
const audio = await inworldTTS({ text: "test", apiKey: "test-key" });
expect(audio.length).toBe(payload.length);
expect(audio.toString("utf8")).toBe(payload);
});
it("fail-closed: rejects decoded audio that exceeds the shared audio cap", async () => {
const decodedPayload = Buffer.alloc(16 * MiB + 1, 0x61);
const body = JSON.stringify({
@ -380,17 +319,11 @@ describe("Inworld response read bounding", () => {
it("fail-closed: truncates an oversized HTTP error body to a bounded marker", async () => {
queueGuardedResponse(new Response("E".repeat(64 * 1024), { status: 500 }));
let captured: unknown;
await inworldTTS({ text: "test", apiKey: "test-key" }).catch((error: unknown) => {
captured = error;
const result = inworldTTS({ text: "test", apiKey: "test-key" });
await expect(result).rejects.toBeInstanceOf(Error);
await expect(result).rejects.toMatchObject({
message: "Inworld TTS API error (500): (error body exceeded diagnostic limit; truncated)",
});
expect(captured).toBeInstanceOf(Error);
const message = (captured as Error).message;
expect(message.startsWith("Inworld TTS API error (500): ")).toBe(true);
// Never the full 64 KiB hostile body: it collapses to a fixed marker.
expect(message).toContain("(error body exceeded diagnostic limit; truncated)");
expect(message.length).toBeLessThan(512);
});
it("fail-closed: rejects and cancels an oversized voices JSON stream (16 MiB cap)", async () => {
@ -404,22 +337,6 @@ describe("Inworld response read bounding", () => {
expect(state.cancelled).toBe(true);
});
it("happy-path: a normal voices JSON list still parses unchanged", async () => {
queueGuardedResponse(
new Response(
JSON.stringify({
voices: [{ voiceId: "Sarah", displayName: "Sarah", langCode: "en-US", tags: ["female"] }],
}),
{ status: 200 },
),
);
const voices = await listInworldVoices({ apiKey: "test-key" });
expect(voices).toEqual([
{ id: "Sarah", name: "Sarah", description: undefined, locale: "en-US", gender: "female" },
]);
});
it("regression: malformed voices JSON under the cap throws descriptive error", async () => {
queueGuardedResponse(new Response("{not-json", { status: 200 }));
await expect(listInworldVoices({ apiKey: "test-key" })).rejects.toThrow(

View file

@ -1,15 +1,7 @@
// SearXNG contracts are exercised through the public search boundary.
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const endpointMockState = vi.hoisted(() => ({
calls: [] as Array<{
mode: "selfHosted" | "strict";
url: string;
timeoutSeconds: number;
init: RequestInit;
signal?: AbortSignal;
}>,
calls: [] as Array<{ mode: "selfHosted" | "strict"; url: string }>,
responses: [] as Response[],
}));
const ssrfMockState = vi.hoisted(() => ({ addresses: [] as string[] }));
@ -52,6 +44,10 @@ vi.mock("openclaw/plugin-sdk/provider-web-search", async (importOriginal) => {
import { runSearxngSearch, testing } from "./searxng-client.js";
function search(params: Partial<Parameters<typeof runSearxngSearch>[0]> = {}) {
return runSearxngSearch({ baseUrl: "http://127.0.0.1:8888", query: "openclaw", ...params });
}
describe("searxng client", () => {
beforeEach(() => {
endpointMockState.calls = [];
@ -64,23 +60,21 @@ describe("searxng client", () => {
[
"http://127.0.0.1:8888/searxng",
"http://127.0.0.1:8888/searxng/search?q=openclaw&format=json&categories=general%2Cnews&language=en",
false,
],
[
"http://127.0.0.1:8888/search/",
"http://127.0.0.1:8888/search?q=openclaw&format=json&categories=general%2Cnews&language=en",
true,
],
[
"http://127.0.0.1:8888/search",
"http://127.0.0.1:8888/search?q=openclaw&format=json&categories=general%2Cnews&language=en",
],
])("builds the public request URL from %s", async (baseUrl, expectedUrl) => {
] as const)("builds the public request URL from %s", async (baseUrl, expectedUrl, fromConfig) => {
endpointMockState.responses.push(Response.json({ results: [] }));
const webSearch = { baseUrl, categories: "general,news", language: "en" };
await runSearxngSearch({
baseUrl,
query: "openclaw",
categories: "general,news",
language: "en",
...(fromConfig
? { config: { plugins: { entries: { searxng: { config: { webSearch } } } } } }
: webSearch),
});
expect(endpointMockState.calls[0]?.url).toBe(expectedUrl);
@ -104,11 +98,7 @@ describe("searxng client", () => {
}),
);
const result = await runSearxngSearch({
baseUrl: "http://127.0.0.1:8888",
query: "kittens",
count: 2,
});
const result = await search({ query: "kittens", count: 2 });
expect(result.count).toBe(2);
const rows = result.results as Array<Record<string, unknown>>;
@ -119,84 +109,42 @@ describe("searxng client", () => {
expect(String(rows[0]?.title)).toContain("Kitten");
expect(String(rows[0]?.snippet)).toContain("A cute kitten");
expect(rows[0]?.img_src).toBe("https://cdn.example.com/kitten.jpg");
expect(rows[0]?.siteName).toBe("example.com");
});
it.each(["weather", "weather,news"])(
"retries an empty category search with general results (%s)",
async (categories) => {
endpointMockState.responses.push(
new Response(JSON.stringify({ results: [] }), { status: 200 }),
new Response(
JSON.stringify({
results: [
{
title: "Beijing hourly weather",
url: "https://example.com/weather",
content: "Hourly forecast",
},
],
}),
{ status: 200 },
),
);
const result = await runSearxngSearch({
baseUrl: "http://127.0.0.1:8888",
query: "beijing hourly weather",
categories,
count: 5,
});
it("retries an empty category search with general results", async () => {
const categories = "weather,news";
endpointMockState.responses.push(
Response.json({ results: [] }),
Response.json({
results: [{ title: "Weather", url: "https://example.com/weather" }],
}),
);
const result = await search({ categories });
expect(endpointMockState.calls).toHaveLength(2);
const firstCall = expectDefined(endpointMockState.calls[0], "first SearXNG endpoint call");
const secondCall = expectDefined(endpointMockState.calls[1], "second SearXNG endpoint call");
expect(new URL(firstCall.url).searchParams.get("categories")).toBe(categories);
expect(new URL(secondCall.url).searchParams.get("categories")).toBe("general");
expect(result.provider).toBe("searxng");
expect(result.query).toBe("beijing hourly weather");
expect(result.count).toBe(1);
const results = result.results as Array<{
url?: string;
siteName?: string;
title?: string;
snippet?: string;
}>;
expect(results).toHaveLength(1);
expect(results[0]?.url).toBe("https://example.com/weather");
expect(results[0]?.siteName).toBe("example.com");
expect(results[0]?.title).toContain("Beijing hourly weather");
expect(results[0]?.snippet).toContain("Hourly forecast");
expect(result.externalContent).toEqual({
provider: "searxng",
source: "web_search",
untrusted: true,
wrapped: true,
});
},
);
expect(
endpointMockState.calls.map(({ url }) => new URL(url).searchParams.get("categories")),
).toEqual([categories, "general"]);
expect(result).toMatchObject({
count: 1,
results: [{ url: "https://example.com/weather" }],
});
});
it.each(["general", "general,news", undefined])(
it.each(["general,news", undefined])(
"does not retry empty category searches containing general or no category (%s)",
async (categories) => {
endpointMockState.responses.push(
new Response(JSON.stringify({ results: [] }), { status: 200 }),
);
endpointMockState.responses.push(Response.json({ results: [] }));
const result = await search({ categories, count: 5 });
const result = await runSearxngSearch({
baseUrl: "http://127.0.0.1:8888",
query: "openclaw",
categories,
count: 5,
});
expect(endpointMockState.calls).toHaveLength(1);
const firstCall = expectDefined(endpointMockState.calls[0], "first SearXNG endpoint call");
expect(new URL(firstCall.url).searchParams.get("categories")).toBe(categories ?? null);
const { tookMs, ...stableResult } = result;
expect(typeof tookMs).toBe("number");
expect(stableResult).toEqual({
expect(
endpointMockState.calls.map(({ url }) => new URL(url).searchParams.get("categories")),
).toEqual([categories ?? null]);
expect(result).toEqual({
query: "openclaw",
provider: "searxng",
count: 0,
tookMs: expect.any(Number),
externalContent: {
provider: "searxng",
source: "web_search",
@ -208,43 +156,17 @@ describe("searxng client", () => {
},
);
it("forwards the abort signal to the guarded endpoint", async () => {
endpointMockState.responses.push(
new Response(JSON.stringify({ results: [] }), { status: 200 }),
);
const controller = new AbortController();
const result = await runSearxngSearch({
baseUrl: "http://127.0.0.1:8888",
query: "openclaw",
categories: "general",
signal: controller.signal,
it("preserves the upstream HTTP error status", async () => {
endpointMockState.responses.push(new Response("upstream rejected search", { status: 429 }));
await expect(search({ cacheTtlMinutes: 0 })).rejects.toMatchObject({
status: 429,
statusCode: 429,
});
expect(endpointMockState.calls).toHaveLength(1);
expect(endpointMockState.calls[0]?.signal).toBe(controller.signal);
expect(result.results).toEqual([]);
});
it.each([401, 403, 429])("preserves HTTP error status %i", async (status) => {
endpointMockState.responses.push(new Response("upstream rejected search", { status }));
await expect(
runSearxngSearch({
baseUrl: "http://127.0.0.1:8888",
query: `http-error-${status}`,
cacheTtlMinutes: 0,
}),
).rejects.toMatchObject({ status, statusCode: status });
});
it("rejects invalid and incomplete response bodies", async () => {
endpointMockState.responses.push(new Response("{", { status: 200 }));
await expect(
runSearxngSearch({
baseUrl: "http://127.0.0.1:8888",
query: "invalid",
}),
).rejects.toThrow("SearXNG returned invalid JSON.");
await expect(search({ query: "invalid" })).rejects.toThrow("SearXNG returned invalid JSON.");
const chunk = new TextEncoder().encode("partial");
let sentChunk = false;
@ -259,12 +181,9 @@ describe("searxng client", () => {
},
});
endpointMockState.responses.push(new Response(stream, { status: 200 }));
await expect(
runSearxngSearch({
baseUrl: "http://127.0.0.1:8888",
query: "partial",
}),
).rejects.toThrow("SearXNG response incomplete after 7 bytes.");
await expect(search({ query: "partial" })).rejects.toThrow(
"SearXNG response incomplete after 7 bytes.",
);
});
it.each([
@ -275,16 +194,14 @@ describe("searxng client", () => {
ssrfMockState.addresses = [address];
endpointMockState.responses.push(Response.json({ results: [] }));
await runSearxngSearch({ baseUrl, query: "routing" });
await search({ baseUrl });
expect(endpointMockState.calls[0]?.mode).toBe(expected);
});
it("rejects cleartext public hosts", async () => {
ssrfMockState.addresses = ["93.184.216.34"];
await expect(
runSearxngSearch({ baseUrl: "http://search.example.com:8080", query: "routing" }),
).rejects.toThrow(
await expect(search({ baseUrl: "http://search.example.com:8080" })).rejects.toThrow(
"SearXNG HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.",
);
});

View file

@ -1,10 +1,25 @@
import { createServer, type Server } from "node:http";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runSearxngSearch, testing } from "./searxng-client.js";
import { testing } from "./searxng-client.js";
import { createSearxngWebSearchProvider } from "./searxng-search-provider.js";
const servers = new Set<Server>();
function createTool(baseUrl: string, cacheTtlMinutes = 15) {
const searchConfig = { cacheTtlMinutes };
return expectDefined(
createSearxngWebSearchProvider().createTool({
config: {
tools: { web: { search: searchConfig } },
plugins: { entries: { searxng: { config: { webSearch: { baseUrl } } } } },
},
searchConfig,
}),
"SearXNG search tool",
);
}
async function listen(server: Server): Promise<string> {
servers.add(server);
await new Promise<void>((resolve, reject) => {
@ -52,20 +67,11 @@ describe("searxng real transport", () => {
);
}),
);
const search = async (ttl: number) => {
const searchConfig = { cacheTtlMinutes: ttl };
const tool = createSearxngWebSearchProvider().createTool({
config: {
tools: { web: { search: searchConfig } },
plugins: { entries: { searxng: { config: { webSearch: { baseUrl } } } } },
},
searchConfig,
});
if (!tool) {
throw new Error("Expected SearXNG search tool");
}
return await tool.execute({ query: "current search TTL" });
};
const search = (ttl: number) =>
createTool(baseUrl, ttl).execute(
{ query: "current search TTL" },
{ signal: new AbortController().signal },
);
const original = await search(15);
expect(await search(15)).toEqual({ ...original, cached: true });
@ -92,66 +98,27 @@ describe("searxng real transport", () => {
},
);
it("reads JSON results from a loopback endpoint", async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
results: [
{
title: "OpenClaw",
url: "https://docs.openclaw.ai/",
content: "OpenClaw documentation",
},
],
}),
);
});
const baseUrl = await listen(server);
await expect(
runSearxngSearch({
baseUrl,
query: "openclaw",
categories: "general",
}),
).resolves.toMatchObject({
provider: "searxng",
count: 1,
results: [{ url: "https://docs.openclaw.ai/" }],
});
});
it("aborts a stalled response body and closes the request", async () => {
let resolveRequestStarted: (() => void) | undefined;
const requestStarted = new Promise<void>((resolve) => {
resolveRequestStarted = resolve;
});
let resolveClientClosed: (() => void) | undefined;
const clientClosed = new Promise<void>((resolve) => {
resolveClientClosed = resolve;
});
it("aborts a provider's stalled response body and closes the request", async () => {
const requestStarted = Promise.withResolvers<void>();
const clientClosed = Promise.withResolvers<void>();
const server = createServer((request, response) => {
request.socket.once("close", () => resolveClientClosed?.());
request.socket.once("close", () => clientClosed.resolve());
response.writeHead(200, { "Content-Type": "application/json" });
response.write('{"results":[');
response.flushHeaders();
resolveRequestStarted?.();
requestStarted.resolve();
});
const baseUrl = await listen(server);
const controller = new AbortController();
const pending = runSearxngSearch({
baseUrl,
query: "stalled response",
categories: "general",
timeoutSeconds: 30,
signal: controller.signal,
});
const pending = createTool(baseUrl).execute(
{ query: "stalled response", categories: "general" },
{ signal: controller.signal },
);
await requestStarted;
await requestStarted.promise;
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
await expect(clientClosed).resolves.toBeUndefined();
await expect(clientClosed.promise).resolves.toBeUndefined();
});
});

View file

@ -1,11 +1,7 @@
// Searxng tests cover searxng search provider plugin behavior.
import { expectDefined } from "@openclaw/normalization-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
resolveSearxngBaseUrl,
resolveSearxngCategories,
resolveSearxngLanguage,
} from "./config.js";
import { resolveSearxngBaseUrl } from "./config.js";
const { runSearxngSearch } = vi.hoisted(() => ({
runSearxngSearch: vi.fn(async (params: Record<string, unknown>) => params),
@ -15,6 +11,10 @@ vi.mock("./searxng-client.js", () => ({
runSearxngSearch,
}));
function configWithBaseUrl(baseUrl: unknown): OpenClawConfig {
return { plugins: { entries: { searxng: { config: { webSearch: { baseUrl } } } } } };
}
describe("searxng web search provider", () => {
let createSearxngWebSearchProvider: typeof import("./searxng-search-provider.js").createSearxngWebSearchProvider;
let plugin: typeof import("../index.js").default;
@ -30,6 +30,13 @@ describe("searxng web search provider", () => {
runSearxngSearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
function createTool() {
return expectDefined(
createSearxngWebSearchProvider().createTool({ config: {} }),
"SearXNG search tool",
);
}
it("registers a setup-visible web search provider", () => {
const webSearchProviders: unknown[] = [];
@ -40,39 +47,26 @@ describe("searxng web search provider", () => {
} as never);
expect(plugin.id).toBe("searxng");
expect(webSearchProviders).toHaveLength(1);
const provider = webSearchProviders[0] as Record<string, unknown>;
expect(provider.id).toBe("searxng");
expect(provider.requiresCredential).toBe(true);
expect(provider.envVars).toEqual(["SEARXNG_BASE_URL"]);
expect(provider.onboardingScopes).toEqual(["text-inference"]);
expect(webSearchProviders).toEqual([
expect.objectContaining({
id: "searxng",
requiresCredential: true,
envVars: ["SEARXNG_BASE_URL"],
onboardingScopes: ["text-inference"],
}),
]);
});
it("exposes credential metadata and enables the plugin in config", () => {
it("enables the plugin in config when selected", () => {
const provider = createSearxngWebSearchProvider();
if (!provider.applySelectionConfig) {
throw new Error("Expected applySelectionConfig to be defined");
}
const applied = provider.applySelectionConfig({});
const applied = expectDefined(provider.applySelectionConfig, "selection handler")({});
expect(provider.id).toBe("searxng");
expect(provider.label).toBe("SearXNG Search");
expect(provider.requiresCredential).toBe(true);
expect(provider.credentialPath).toBe("plugins.entries.searxng.config.webSearch.baseUrl");
expect(applied.plugins?.entries?.searxng?.enabled).toBe(true);
});
it("maps generic tool arguments into SearXNG search params", async () => {
const provider = createSearxngWebSearchProvider();
const tool = provider.createTool({
config: { test: true },
} as never);
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
await createTool().execute({
query: "openclaw docs",
count: 4,
categories: "general,news",
@ -80,51 +74,16 @@ describe("searxng web search provider", () => {
});
expect(runSearxngSearch).toHaveBeenCalledWith({
config: { test: true },
config: {},
query: "openclaw docs",
count: 4,
categories: "general,news",
language: "en",
});
expect(result).toEqual({
config: { test: true },
query: "openclaw docs",
count: 4,
categories: "general,news",
language: "en",
});
});
it("forwards the execution abort signal to the SearXNG client", async () => {
const provider = createSearxngWebSearchProvider();
const tool = provider.createTool({
config: { test: true },
} as never);
if (!tool) {
throw new Error("Expected tool definition");
}
const controller = new AbortController();
await tool.execute({ query: "openclaw docs" }, { signal: controller.signal });
expect(runSearxngSearch).toHaveBeenCalledWith({
config: { test: true },
query: "openclaw docs",
count: undefined,
categories: undefined,
language: undefined,
signal: controller.signal,
});
});
it("rejects fractional and out-of-range counts before searching", async () => {
const provider = createSearxngWebSearchProvider();
const tool = provider.createTool({
config: { test: true },
} as never);
if (!tool) {
throw new Error("Expected tool definition");
}
const tool = createTool();
await expect(tool.execute({ query: "openclaw docs", count: 4.5 })).rejects.toThrow(
"count must be an integer from 1 to 10.",
@ -138,35 +97,26 @@ describe("searxng web search provider", () => {
it("reads base URL from plugin config SecretRef, then env var, stripping trailing slashes", () => {
vi.stubEnv("SEARXNG_BASE_URL", "http://localhost:8888/");
expect(
resolveSearxngBaseUrl({
plugins: {
entries: {
searxng: {
config: {
webSearch: {
baseUrl: {
source: "env",
provider: "default",
id: "SEARXNG_BASE_URL",
},
},
},
},
},
},
} as never),
resolveSearxngBaseUrl(
configWithBaseUrl({
source: "env",
provider: "default",
id: "SEARXNG_BASE_URL",
}),
),
).toBe("http://localhost:8888");
vi.stubEnv("SEARXNG_BASE_URL", "https://search.local/searxng///");
expect(resolveSearxngBaseUrl({} as never)).toBe("https://search.local/searxng");
expect(resolveSearxngBaseUrl({})).toBe("https://search.local/searxng");
vi.stubEnv("SEARXNG_BASE_URL", "");
expect(resolveSearxngBaseUrl({} as never)).toBeUndefined();
expect(resolveSearxngBaseUrl({})).toBeUndefined();
});
it("does not fall back to ambient env when an explicit SecretRef is blocked", () => {
vi.stubEnv("SEARXNG_BASE_URL", "https://ambient.example/");
const config = {
const config: OpenClawConfig = {
...configWithBaseUrl({ source: "env", provider: "restricted", id: "SEARXNG_BASE_URL" }),
secrets: {
providers: {
restricted: {
@ -175,62 +125,18 @@ describe("searxng web search provider", () => {
},
},
},
plugins: {
entries: {
searxng: {
config: {
webSearch: {
baseUrl: {
source: "env",
provider: "restricted",
id: "SEARXNG_BASE_URL",
},
},
},
},
},
},
} as OpenClawConfig;
};
expect(resolveSearxngBaseUrl(config)).toBeUndefined();
});
it("reads categories and language from plugin config", () => {
const config = {
plugins: {
entries: {
searxng: {
config: {
webSearch: {
categories: "general,news",
language: "de",
},
},
},
},
},
} as never;
expect(resolveSearxngCategories(config)).toBe("general,news");
expect(resolveSearxngLanguage(config)).toBe("de");
});
it("exposes a credentialNote with JSON format guidance", () => {
const provider = createSearxngWebSearchProvider();
expect(provider.credentialNote).toContain("json format enabled");
expect(provider.credentialNote).toContain("search.formats");
});
it("persists base URL to plugin config via setConfiguredCredentialValue", () => {
const provider = createSearxngWebSearchProvider();
const config: OpenClawConfig = {};
const setConfiguredCredentialValue = provider.setConfiguredCredentialValue;
if (!setConfiguredCredentialValue) {
throw new Error("Expected SearXNG provider setConfiguredCredentialValue");
}
setConfiguredCredentialValue(config, "http://search.local:9000");
expectDefined(provider.setConfiguredCredentialValue, "credential setter")(
config,
"http://search.local:9000",
);
expect(resolveSearxngBaseUrl(config)).toBe("http://search.local:9000");
});

View file

@ -1,15 +1,18 @@
// Tencent tests cover config compatibility repair behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { migrateTencentTokenHubModelDefaults } from "./config-compat.js";
const TENCENT_TOKENHUB_HY3_MODEL_REF = "tencent-tokenhub/hy3";
const TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF = "tencent-tokenhub/hy3-preview";
const TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF = "tencent-tokenhub/hy4-preview";
const HY3 = "tencent-tokenhub/hy3";
const HY3_PREVIEW = "tencent-tokenhub/hy3-preview";
const HY4_PREVIEW = "tencent-tokenhub/hy4-preview";
const DEFAULT_MODELS = {
[HY4_PREVIEW]: { alias: "Hy4 preview (TokenHub)" },
[HY3]: { alias: "Hy3 (TokenHub)" },
[HY3_PREVIEW]: { alias: "Hy3 preview (TokenHub)" },
};
const REPAIRED_ALLOWLIST_CHANGE =
`Updated Tencent TokenHub agent model defaults to include ${TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF}, ` +
`${TENCENT_TOKENHUB_HY3_MODEL_REF}, ${TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF}.`;
`Updated Tencent TokenHub agent model defaults to include ${HY4_PREVIEW}, ` +
`${HY3}, ${HY3_PREVIEW}.`;
describe("Tencent config compatibility", () => {
it.each(["string", "object"])("migrates a %s hy3-preview primary to hy3", (shape) => {
@ -19,46 +22,30 @@ describe("Tencent config compatibility", () => {
defaults: {
model:
shape === "string"
? TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF
: {
primary: TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF,
fallbacks: ["openai/gpt-5.5"],
},
? HY3_PREVIEW
: { primary: HY3_PREVIEW, fallbacks: ["openai/gpt-5.5"] },
maxConcurrent: 2,
models: {
[TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF]: {
alias: "Preview",
params: { temperature: 0.4 },
},
[HY3_PREVIEW]: { alias: "Preview", params: { temperature: 0.4 } },
"openai/gpt-5.5": { alias: "Other" },
},
},
},
} as OpenClawConfig;
const original = structuredClone(config);
const result = migrateTencentTokenHubModelDefaults(config);
expect(result.changes).toEqual([
REPAIRED_ALLOWLIST_CHANGE,
`Changed Tencent TokenHub primary default from ${TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF} to ${TENCENT_TOKENHUB_HY3_MODEL_REF}.`,
`Changed Tencent TokenHub primary default from ${HY3_PREVIEW} to ${HY3}.`,
]);
expect(result.config.agents?.defaults?.model).toEqual({
primary: TENCENT_TOKENHUB_HY3_MODEL_REF,
primary: HY3,
...(shape === "object" ? { fallbacks: ["openai/gpt-5.5"] } : {}),
});
expect(result.config.agents?.defaults?.models).toEqual({
[TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF]: {
alias: "Preview",
params: { temperature: 0.4 },
},
"openai/gpt-5.5": { alias: "Other" },
[TENCENT_TOKENHUB_HY3_MODEL_REF]: {
alias: "Hy3 (TokenHub)",
},
[TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF]: {
alias: "Hy4 preview (TokenHub)",
},
...DEFAULT_MODELS,
...config.agents?.defaults?.models,
});
expect(result.config.gateway).toEqual(config.gateway);
expect(result.config.agents?.defaults?.maxConcurrent).toBe(2);
@ -70,149 +57,76 @@ describe("Tencent config compatibility", () => {
});
it("backfills the allowlist without touching a working hy3 primary", () => {
// hy3 is GA while hy4-preview is a preview that also needs the API key's
// allowed-model scope to cover hy4, so a live hy3 primary must survive the
// migration untouched.
// Hy4 needs different API-key model access, so migration must preserve Hy3.
const config = {
agents: {
defaults: {
model: { primary: TENCENT_TOKENHUB_HY3_MODEL_REF },
model: { primary: HY3 },
models: {
[TENCENT_TOKENHUB_HY3_MODEL_REF]: {
alias: "Custom Hy3",
},
[TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF]: {
alias: "Hy3 preview (TokenHub)",
},
[HY3]: { alias: "Custom Hy3" },
[HY3_PREVIEW]: { alias: "Hy3 preview (TokenHub)" },
},
},
},
} as OpenClawConfig;
const result = migrateTencentTokenHubModelDefaults(config);
expect(result.changes).toEqual([REPAIRED_ALLOWLIST_CHANGE]);
expect(result.config.agents?.defaults?.model).toEqual({
primary: TENCENT_TOKENHUB_HY3_MODEL_REF,
});
expect(result.config.agents?.defaults?.model).toEqual({ primary: HY3 });
expect(result.config.agents?.defaults?.models).toEqual({
[TENCENT_TOKENHUB_HY3_MODEL_REF]: {
alias: "Custom Hy3",
},
[TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF]: {
alias: "Hy3 preview (TokenHub)",
},
[TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF]: {
alias: "Hy4 preview (TokenHub)",
},
...DEFAULT_MODELS,
[HY3]: { alias: "Custom Hy3" },
});
});
it.each([
TENCENT_TOKENHUB_HY3_MODEL_REF,
TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF,
"openai/gpt-5.5",
])("preserves an explicit string primary %s while repairing the allowlist", (primary) => {
const config = {
agents: {
defaults: {
model: primary,
models: {
[TENCENT_TOKENHUB_HY3_MODEL_REF]: {},
},
},
},
} as OpenClawConfig;
it.each([HY3, "openai/gpt-5.5"])(
"preserves an explicit string primary %s while repairing the allowlist",
(primary) => {
const config = {
agents: { defaults: { model: primary, models: { [HY3]: {} } } },
} as OpenClawConfig;
const result = migrateTencentTokenHubModelDefaults(config);
const result = migrateTencentTokenHubModelDefaults(config);
expect(result.config.agents?.defaults?.model).toBe(primary);
expect(result.config.agents?.defaults?.models).toEqual({
[TENCENT_TOKENHUB_HY3_MODEL_REF]: {
alias: "Hy3 (TokenHub)",
},
[TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF]: {
alias: "Hy3 preview (TokenHub)",
},
[TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF]: {
alias: "Hy4 preview (TokenHub)",
},
});
});
expect(result.config.agents?.defaults?.model).toBe(primary);
expect(result.config.agents?.defaults?.models).toEqual(DEFAULT_MODELS);
},
);
it("repairs configs that only pinned hy4-preview", () => {
const config = {
agents: {
defaults: {
model: { primary: TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF },
models: {
[TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF]: {},
},
},
},
agents: { defaults: { model: { primary: HY4_PREVIEW }, models: { [HY4_PREVIEW]: {} } } },
} as OpenClawConfig;
const result = migrateTencentTokenHubModelDefaults(config);
expect(result.changes).toEqual([REPAIRED_ALLOWLIST_CHANGE]);
expect(result.config.agents?.defaults?.model).toEqual({
primary: TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF,
});
expect(result.config.agents?.defaults?.models).toEqual({
[TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF]: {
alias: "Hy4 preview (TokenHub)",
},
[TENCENT_TOKENHUB_HY3_MODEL_REF]: {
alias: "Hy3 (TokenHub)",
},
[TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF]: {
alias: "Hy3 preview (TokenHub)",
},
});
expect(result.config.agents?.defaults?.model).toEqual({ primary: HY4_PREVIEW });
expect(result.config.agents?.defaults?.models).toEqual(DEFAULT_MODELS);
});
it("does not create a model allowlist when TokenHub models are not already configured", () => {
const config = {
models: {
providers: {
"tencent-tokenhub": {
baseUrl: "https://tokenhub.tencentmaas.com/v1",
models: [],
"tencent-tokenhub": { baseUrl: "https://tokenhub.tencentmaas.com/v1", models: [] },
},
},
} as OpenClawConfig;
expect(migrateTencentTokenHubModelDefaults(config)).toEqual({ config, changes: [] });
});
it("preserves custom aliases after defaults are repaired", () => {
const config = {
agents: {
defaults: {
model: { primary: HY3 },
models: {
[HY4_PREVIEW]: { alias: "My Hy4" },
[HY3]: { alias: "My Hy3" },
[HY3_PREVIEW]: { alias: "My preview" },
},
},
},
} as OpenClawConfig;
const result = migrateTencentTokenHubModelDefaults(config);
expect(result).toEqual({ config, changes: [] });
expect(migrateTencentTokenHubModelDefaults(config)).toEqual({ config, changes: [] });
});
it.each([TENCENT_TOKENHUB_HY3_MODEL_REF, TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF])(
"preserves custom aliases after defaults are repaired for %s",
(primary) => {
const config = {
agents: {
defaults: {
model: { primary },
models: {
[TENCENT_TOKENHUB_HY4_PREVIEW_MODEL_REF]: {
alias: "My Hy4",
},
[TENCENT_TOKENHUB_HY3_MODEL_REF]: {
alias: "My Hy3",
},
[TENCENT_TOKENHUB_HY3_PREVIEW_MODEL_REF]: {
alias: "My preview",
},
},
},
},
} as OpenClawConfig;
const result = migrateTencentTokenHubModelDefaults(config);
expect(result).toEqual({ config, changes: [] });
},
);
});

View file

@ -1,4 +1,3 @@
// Tencent tests cover index plugin behavior.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Context, Model } from "openclaw/plugin-sdk/llm";
import {
@ -32,18 +31,19 @@ async function getTokenPlanProvider() {
return requireRegisteredProvider(providers, "tencent-tokenplan");
}
function hyReasoningModel(params: {
provider: "tencent-tokenhub" | "tencent-tokenplan";
id: "hy3" | "hy3-preview" | "hy4-preview";
baseUrl: string;
supportedReasoningEfforts?: string[];
}): OpenAICompletionsModel {
function hyReasoningModel(
id: "hy3" | "hy3-preview" | "hy4-preview" = "hy3",
provider: "tencent-tokenhub" | "tencent-tokenplan" = "tencent-tokenhub",
): OpenAICompletionsModel {
return {
provider: params.provider,
id: params.id,
name: params.id,
provider,
id,
name: id,
api: "openai-completions",
baseUrl: params.baseUrl,
baseUrl:
provider === "tencent-tokenhub"
? "https://tokenhub.tencentmaas.com/v1"
: "https://api.lkeap.cloud.tencent.com/plan/v3",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@ -52,7 +52,7 @@ function hyReasoningModel(params: {
compat: {
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
supportedReasoningEfforts: params.supportedReasoningEfforts ?? ["none", "high"],
supportedReasoningEfforts: id === "hy3-preview" ? ["none", "low", "high"] : ["none", "high"],
},
} as OpenAICompletionsModel;
}
@ -95,86 +95,46 @@ function captureTencentPayload(params: {
describe("tencent provider plugin", () => {
it.each([
["tencent-tokenhub", "Tencent TokenHub", "TOKENHUB_API_KEY", "tokenhub-api-key"],
["tencent-tokenplan", "Tencent TokenPlan", "TOKENPLAN_API_KEY", "tokenplan-api-key"],
])("registers %s api-key auth metadata", async (providerId, label, envVar, choiceId) => {
const { providers } = await registerTencentPlugin();
const provider = requireRegisteredProvider(providers, providerId);
const resolved = resolveProviderPluginChoice({ providers, choice: choiceId });
expect(providers.map((entry) => entry.id)).toEqual(["tencent-tokenhub", "tencent-tokenplan"]);
expect(provider).toMatchObject({
id: providerId,
label,
docsPath: "/providers/tencent",
envVars: [envVar],
catalog: { order: "simple" },
staticCatalog: { order: "simple" },
});
expect(provider.auth).toHaveLength(1);
expect(resolved?.provider.id).toBe(providerId);
expect(resolved?.method).toMatchObject({
id: "api-key",
label,
hint: `Hy via ${label} Gateway`,
kind: "api_key",
starterModel: `${providerId}/hy4-preview`,
wizard: {
choiceId,
choiceLabel: label,
groupId: "tencent",
groupLabel: "Tencent Cloud",
groupHint: label,
{
providerId: "tencent-tokenhub",
choiceId: "tokenhub-api-key",
flagValue: "tokenhub-test-key",
envVar: "TOKENHUB_API_KEY",
aliases: {
"tencent-tokenhub/hy4-preview": { alias: "Hy4 preview (TokenHub)" },
"tencent-tokenhub/hy3": { alias: "Hy3 (TokenHub)" },
"tencent-tokenhub/hy3-preview": { alias: "Hy3 preview (TokenHub)" },
},
});
});
it.each(
(
[
{
providerId: "tencent-tokenhub",
choiceId: "tokenhub-api-key",
flagValue: "tokenhub-test-key",
envVar: "TOKENHUB_API_KEY",
aliases: {
"tencent-tokenhub/hy4-preview": { alias: "Hy4 preview (TokenHub)" },
"tencent-tokenhub/hy3": { alias: "Hy3 (TokenHub)" },
"tencent-tokenhub/hy3-preview": { alias: "Hy3 preview (TokenHub)" },
},
},
{
providerId: "tencent-tokenplan",
choiceId: "tokenplan-api-key",
flagValue: "tokenplan-test-key",
envVar: "TOKENPLAN_API_KEY",
aliases: {
"tencent-tokenplan/hy4-preview": { alias: "Hy4 preview (TokenPlan)" },
"tencent-tokenplan/hy3": { alias: "Hy3 (TokenPlan)" },
},
},
] as const
).flatMap((provider) =>
([undefined, "replace"] as const).map((mode) => Object.assign({}, provider, { mode })),
),
)(
"configures only $providerId through its registered auth method in $mode mode",
async ({ providerId, choiceId, flagValue, envVar, aliases, mode }) => {
},
{
providerId: "tencent-tokenplan",
choiceId: "tokenplan-api-key",
flagValue: "tokenplan-test-key",
envVar: "TOKENPLAN_API_KEY",
aliases: {
"tencent-tokenplan/hy4-preview": { alias: "Hy4 preview (TokenPlan)" },
"tencent-tokenplan/hy3": { alias: "Hy3 (TokenPlan)" },
},
},
] as const)(
"configures only $providerId through its registered auth choice in replace mode",
async ({ providerId, choiceId, flagValue, envVar, aliases }) => {
const { providers } = await registerTencentPlugin();
const provider = requireRegisteredProvider(providers, providerId);
const resolved = resolveProviderPluginChoice({ providers, choice: choiceId });
expect(resolved?.provider.id).toBe(providerId);
const resolveApiKey = vi.fn(async () => ({
key: "stored-test-key",
source: "profile" as const,
}));
const toApiKeyCredential = vi.fn(() => null);
const method = provider.auth[0];
const method = resolved?.method;
if (!method?.runNonInteractive) {
throw new Error("expected Tencent noninteractive auth method");
}
const config = await method.runNonInteractive({
authChoice: choiceId,
config: { models: { mode } },
baseConfig: { models: { mode } },
config: { models: { mode: "replace" } },
baseConfig: { models: { mode: "replace" } },
opts: { tokenhubApiKey: "tokenhub-test-key", tokenplanApiKey: "tokenplan-test-key" },
runtime: createRuntimeEnv(),
resolveApiKey,
@ -190,9 +150,7 @@ describe("tencent provider plugin", () => {
expect(toApiKeyCredential).not.toHaveBeenCalled();
expect(Object.keys(config?.models?.providers ?? {})).toEqual([providerId]);
expect(config?.models?.providers?.[providerId]?.models.map((model) => model.id)).toEqual(
mode === "replace"
? manifest.modelCatalog.providers[providerId].models.map((model) => model.id)
: [],
manifest.modelCatalog.providers[providerId].models.map((model) => model.id),
);
expect(config?.agents?.defaults?.model).toEqual({ primary: `${providerId}/hy4-preview` });
expect(config?.agents?.defaults?.models).toEqual(aliases);
@ -294,59 +252,8 @@ describe("tencent provider plugin", () => {
expect(hy4Preview?.compat?.supportedReasoningEfforts).toEqual(["none", "high"]);
});
it("injects reasoning_effort into TokenPlan hy3 chat-completions payload", async () => {
const model = hyReasoningModel({
provider: "tencent-tokenplan",
id: "hy3",
baseUrl: "https://api.lkeap.cloud.tencent.com/plan/v3",
});
const context = { messages: [{ role: "user", content: "hi", timestamp: 1 }] } as Context;
const payload = buildOpenAICompletionsParams(model, context, {
reasoning: "high",
} as never);
expect(payload.model).toBe("hy3");
expect(payload.reasoning_effort).toBe("high");
});
it("emits reasoning_effort=high when high effort is requested for TokenHub hy3", async () => {
const model = hyReasoningModel({
provider: "tencent-tokenhub",
id: "hy3",
baseUrl: "https://tokenhub.tencentmaas.com/v1",
});
const context = { messages: [{ role: "user", content: "hi", timestamp: 1 }] } as Context;
const payload = buildOpenAICompletionsParams(model, context, {
reasoning: "high",
} as never);
expect(payload.reasoning_effort).toBe("high");
});
it("emits reasoning_effort=none when none effort is requested for TokenHub hy3", async () => {
const model = hyReasoningModel({
provider: "tencent-tokenhub",
id: "hy3",
baseUrl: "https://tokenhub.tencentmaas.com/v1",
});
const context = { messages: [{ role: "user", content: "hi", timestamp: 1 }] } as Context;
const payload = buildOpenAICompletionsParams(model, context, {
reasoning: "none",
} as never);
expect(payload.reasoning_effort).toBe("none");
});
it("defaults hy3-preview reasoning_effort to high when no effort is provided", async () => {
const model = hyReasoningModel({
provider: "tencent-tokenhub",
id: "hy3-preview",
baseUrl: "https://tokenhub.tencentmaas.com/v1",
supportedReasoningEfforts: ["none", "low", "high"],
});
const model = hyReasoningModel("hy3-preview");
const context = { messages: [{ role: "user", content: "hi", timestamp: 1 }] } as Context;
const payload = buildOpenAICompletionsParams(model, context, undefined);
@ -356,11 +263,7 @@ describe("tencent provider plugin", () => {
it("keeps TokenHub hy3 explicit high and none reasoning_effort unchanged", async () => {
const provider = await getTokenHubProvider();
const model = hyReasoningModel({
provider: "tencent-tokenhub",
id: "hy3",
baseUrl: "https://tokenhub.tencentmaas.com/v1",
});
const model = hyReasoningModel();
model.compat = { ...model.compat, supportsStore: false };
const highPayload = captureTencentPayload({
@ -386,11 +289,7 @@ describe("tencent provider plugin", () => {
"does not treat inherited object key %s as a Tencent effort override",
async (reasoning) => {
const provider = await getTokenHubProvider();
const model = hyReasoningModel({
provider: "tencent-tokenhub",
id: "hy3",
baseUrl: "https://tokenhub.tencentmaas.com/v1",
});
const model = hyReasoningModel();
const payload = captureTencentPayload({ provider, model, reasoning });
expect(payload?.reasoning_effort).toBe("none");
},
@ -399,16 +298,8 @@ describe("tencent provider plugin", () => {
it("keeps minimal reasoning enabled for TokenHub and TokenPlan hy3", async () => {
const tokenHubProvider = await getTokenHubProvider();
const tokenPlanProvider = await getTokenPlanProvider();
const tokenHubModel = hyReasoningModel({
provider: "tencent-tokenhub",
id: "hy3",
baseUrl: "https://tokenhub.tencentmaas.com/v1",
});
const tokenPlanModel = hyReasoningModel({
provider: "tencent-tokenplan",
id: "hy3",
baseUrl: "https://api.lkeap.cloud.tencent.com/plan/v3",
});
const tokenHubModel = hyReasoningModel();
const tokenPlanModel = hyReasoningModel("hy3", "tencent-tokenplan");
const tokenHubPayload = captureTencentPayload({
provider: tokenHubProvider,
@ -427,12 +318,7 @@ describe("tencent provider plugin", () => {
it("keeps TokenHub hy3-preview unsupported efforts on the model fallback path", async () => {
const provider = await getTokenHubProvider();
const model = hyReasoningModel({
provider: "tencent-tokenhub",
id: "hy3-preview",
baseUrl: "https://tokenhub.tencentmaas.com/v1",
supportedReasoningEfforts: ["none", "low", "high"],
});
const model = hyReasoningModel("hy3-preview");
const minimalPayload = captureTencentPayload({
provider,
@ -452,16 +338,8 @@ describe("tencent provider plugin", () => {
it("collapses hy4-preview onto its two-rung ladder on both endpoints", async () => {
const tokenHubProvider = await getTokenHubProvider();
const tokenPlanProvider = await getTokenPlanProvider();
const tokenHubModel = hyReasoningModel({
provider: "tencent-tokenhub",
id: "hy4-preview",
baseUrl: "https://tokenhub.tencentmaas.com/v1",
});
const tokenPlanModel = hyReasoningModel({
provider: "tencent-tokenplan",
id: "hy4-preview",
baseUrl: "https://api.lkeap.cloud.tencent.com/plan/v3",
});
const tokenHubModel = hyReasoningModel("hy4-preview");
const tokenPlanModel = hyReasoningModel("hy4-preview", "tencent-tokenplan");
// Preserve OpenClaw's none/high policy: intermediate efforts become high
// and off becomes none. Raw API acceptance of low alone does not establish
@ -488,12 +366,7 @@ describe("tencent provider plugin", () => {
}
// hy3-preview keeps its three-rung ladder and stays on shared handling.
const hy3PreviewModel = hyReasoningModel({
provider: "tencent-tokenhub",
id: "hy3-preview",
baseUrl: "https://tokenhub.tencentmaas.com/v1",
supportedReasoningEfforts: ["none", "low", "high"],
});
const hy3PreviewModel = hyReasoningModel("hy3-preview");
expect(
captureTencentPayload({
provider: tokenHubProvider,

View file

@ -1,49 +1,8 @@
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
import { describe, expect, it } from "vitest";
import { buildTokenHubProvider, buildTokenPlanProvider } from "./api.js";
import {
applyTokenHubConfig,
applyTokenPlanConfig,
TOKENHUB_DEFAULT_MODEL_REF,
TOKENPLAN_DEFAULT_MODEL_REF,
} from "./onboard.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { applyTokenHubConfig, applyTokenPlanConfig } from "./onboard.js";
describe("Tencent onboarding", () => {
it("applies the TokenHub manifest catalog, default, and aliases", () => {
const config = applyTokenHubConfig({ models: { mode: "replace" } });
expect(config.models?.providers?.["tencent-tokenhub"]?.models.map((model) => model.id)).toEqual(
manifest.modelCatalog.providers["tencent-tokenhub"].models.map((model) => model.id),
);
expect(TOKENHUB_DEFAULT_MODEL_REF).toBe("tencent-tokenhub/hy4-preview");
expect(resolveAgentModelPrimaryValue(config.agents?.defaults?.model)).toBe(
TOKENHUB_DEFAULT_MODEL_REF,
);
// Each ref carries its own alias; none may piggyback on the default ref.
expect(config.agents?.defaults?.models).toEqual({
"tencent-tokenhub/hy4-preview": { alias: "Hy4 preview (TokenHub)" },
"tencent-tokenhub/hy3": { alias: "Hy3 (TokenHub)" },
"tencent-tokenhub/hy3-preview": { alias: "Hy3 preview (TokenHub)" },
});
});
it("applies the TokenPlan manifest catalog, default, and alias", () => {
const config = applyTokenPlanConfig({ models: { mode: "replace" } });
expect(
config.models?.providers?.["tencent-tokenplan"]?.models.map((model) => model.id),
).toEqual(manifest.modelCatalog.providers["tencent-tokenplan"].models.map((model) => model.id));
expect(TOKENPLAN_DEFAULT_MODEL_REF).toBe("tencent-tokenplan/hy4-preview");
expect(resolveAgentModelPrimaryValue(config.agents?.defaults?.model)).toBe(
TOKENPLAN_DEFAULT_MODEL_REF,
);
expect(config.agents?.defaults?.models).toEqual({
"tencent-tokenplan/hy4-preview": { alias: "Hy4 preview (TokenPlan)" },
"tencent-tokenplan/hy3": { alias: "Hy3 (TokenPlan)" },
});
});
it.each([
{ providerId: "tencent-tokenhub", apply: applyTokenHubConfig, build: buildTokenHubProvider },
{ providerId: "tencent-tokenplan", apply: applyTokenPlanConfig, build: buildTokenPlanProvider },

View file

@ -1,7 +1,6 @@
import { bufferedOversizedJsonResponse as oversizedJsonResponse } from "openclaw/plugin-sdk/test-fixtures";
// Vydra tests cover image generation provider plugin behavior.
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-media-understanding";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildVydraImageGenerationProvider } from "./image-generation-provider.js";
import {
binaryResponse,
@ -20,50 +19,32 @@ function fetchCall(fetchMock: ReturnType<typeof vi.fn>, index = 0): [string, Req
describe("vydra image-generation provider", () => {
installPinnedHostnameTestHooks();
const provider = buildVydraImageGenerationProvider();
const request = { provider: "vydra", model: "grok-imagine", prompt: "draw a cat", cfg: {} };
const completedImage = () =>
jsonResponse({
jobId: "job-123",
status: "completed",
imageUrl: "https://cdn.vydra.ai/generated/test.png",
});
beforeEach(stubVydraApiKey);
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("posts to the www api and downloads the generated image", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({
jobId: "job-123",
status: "completed",
imageUrl: "https://cdn.vydra.ai/generated/test.png",
}),
binaryResponse("png-data", "image/png"),
);
const provider = buildVydraImageGenerationProvider();
const result = await provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {},
});
const fetchMock = stubFetch(completedImage(), binaryResponse("png-data", "image/png"));
const result = await provider.generateImage(request);
const createCall = fetchCall(fetchMock);
expect(createCall[0]).toBe("https://www.vydra.ai/api/v1/models/grok-imagine");
expect(createCall[1].method).toBe("POST");
expect(createCall[1].body).toBe(
JSON.stringify({
prompt: "draw a cat",
model: "text-to-image",
}),
JSON.stringify({ prompt: "draw a cat", model: "text-to-image" }),
);
const headers = new Headers(createCall[1].headers);
expect(headers.get("authorization")).toBe("Bearer vydra-test-key");
expect(new Headers(createCall[1].headers).get("authorization")).toBe("Bearer vydra-test-key");
expect(result).toEqual({
images: [
{
buffer: Buffer.from("png-data"),
mimeType: "image/png",
fileName: "image-1.png",
},
],
images: [{ buffer: Buffer.from("png-data"), mimeType: "image/png", fileName: "image-1.png" }],
model: "grok-imagine",
metadata: {
jobId: "job-123",
@ -74,81 +55,54 @@ describe("vydra image-generation provider", () => {
});
it("rejects generated image downloads that exceed the configured media cap", async () => {
stubVydraApiKey();
stubFetch(
jsonResponse({
jobId: "job-123",
status: "completed",
imageUrl: "https://cdn.vydra.ai/generated/test.png",
}),
binaryResponse("too-large", "image/png"),
);
const provider = buildVydraImageGenerationProvider();
stubFetch(completedImage(), binaryResponse("too-large", "image/png"));
await expect(
provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
...request,
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
}),
).rejects.toThrow("Vydra image download exceeds 1 bytes");
});
it("rejects image creation JSON responses that exceed the provider cap", async () => {
stubVydraApiKey();
stubFetch(oversizedJsonResponse());
const provider = buildVydraImageGenerationProvider();
await expect(
provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {},
}),
).rejects.toThrow("vydra.image-generation: JSON response exceeds 16777216 bytes");
await expect(provider.generateImage(request)).rejects.toThrow(
"vydra.image-generation: JSON response exceeds 16777216 bytes",
);
});
it("passes request SSRF policy through image creation, polling, and download", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({
jobId: "job-123",
status: "queued",
}),
jsonResponse({ jobId: "job-123", status: "queued" }),
jsonResponse({
jobId: "job-123",
status: "completed",
imageUrl: "https://198.18.0.11/generated/test.png",
resultUrls: ["https://198.18.0.11/generated/test.png"],
}),
binaryResponse("png-data", "image/png"),
);
const provider = buildVydraImageGenerationProvider();
await provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
...request,
cfg: {
models: {
providers: {
vydra: {
baseUrl: "https://198.18.0.10/api/v1",
models: [],
request: { headers: { "X-Vydra-Policy": "cross-origin" } },
},
},
},
} as never,
},
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
const createCall = fetchCall(fetchMock);
expect(createCall[0]).toBe("https://198.18.0.10/api/v1/models/grok-imagine");
expect(createCall[1].method).toBe("POST");
expect(new Headers(createCall[1].headers).get("x-vydra-policy")).toBe("cross-origin");
const pollCall = fetchCall(fetchMock, 1);
expect(pollCall[0]).toBe("https://198.18.0.10/api/v1/jobs/job-123");
expect(pollCall[1].method).toBe("GET");
expect(new Headers(pollCall[1].headers).get("x-vydra-policy")).toBe("cross-origin");
const downloadCall = fetchCall(fetchMock, 2);
expect(downloadCall[0]).toBe("https://198.18.0.11/generated/test.png");
@ -157,57 +111,10 @@ describe("vydra image-generation provider", () => {
expect(downloadHeaders.get("x-vydra-policy")).toBeNull();
});
it("polls jobs when the create response is not completed yet", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({ jobId: "job-456", status: "queued" }),
jsonResponse({
jobId: "job-456",
status: "completed",
resultUrls: ["https://www.vydra.ai/generated/polled.png"],
}),
binaryResponse("png-data", "image/png"),
);
const provider = buildVydraImageGenerationProvider();
await provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {
models: {
providers: {
vydra: {
baseUrl: "https://www.vydra.ai/api/v1",
models: [],
request: { headers: { "X-Vydra-Policy": "same-origin" } },
},
},
},
},
});
const pollCall = fetchCall(fetchMock, 1);
expect(pollCall[0]).toBe("https://www.vydra.ai/api/v1/jobs/job-456");
expect(pollCall[1].method).toBe("GET");
expect(new Headers(pollCall[1].headers).get("x-vydra-policy")).toBe("same-origin");
const downloadHeaders = new Headers(fetchCall(fetchMock, 2)[1].headers);
expect(downloadHeaders.get("authorization")).toBe("Bearer vydra-test-key");
expect(downloadHeaders.get("x-vydra-policy")).toBe("same-origin");
});
it("rejects job poll JSON responses that exceed the provider cap", async () => {
stubVydraApiKey();
stubFetch(jsonResponse({ jobId: "job-456", status: "queued" }), oversizedJsonResponse());
const provider = buildVydraImageGenerationProvider();
await expect(
provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {},
}),
).rejects.toThrow("Vydra job status request failed: JSON response exceeds 16777216 bytes");
await expect(provider.generateImage(request)).rejects.toThrow(
"Vydra job status request failed: JSON response exceeds 16777216 bytes",
);
});
});

View file

@ -1,4 +1,3 @@
// Vydra tests cover shared URL extraction and download behavior.
import { once } from "node:events";
import http from "node:http";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
@ -66,7 +65,7 @@ describe("downloadVydraAsset", () => {
return address.port;
}
async function expectDrippingDownloadTimeout(statusCode: number, wallClockTrailsTimer = false) {
async function expectDrippingDownloadTimeout(statusCode: number) {
vi.useFakeTimers({ toFake: ["Date", "performance", "setTimeout", "clearTimeout"] });
const timeoutMs = 250;
const port = await listenDripServer({
@ -75,9 +74,7 @@ describe("downloadVydraAsset", () => {
chunk: statusCode === 200 ? Buffer.from([0x00]) : "e",
});
const wallClock = Date.now();
const dateNow = wallClockTrailsTimer
? vi.spyOn(Date, "now").mockReturnValue(wallClock)
: undefined;
const dateNow = vi.spyOn(Date, "now").mockReturnValue(wallClock);
const headersReceived = createDeferred<void>();
const startedAt = performance.now();
let settled = false;
@ -118,76 +115,20 @@ describe("downloadVydraAsset", () => {
expect(elapsedMs).toBeGreaterThanOrEqual(timeoutMs - 50);
expect(elapsedMs).toBeLessThan(timeoutMs + 1_500);
if (wallClockTrailsTimer) {
expect(Date.now()).toBe(wallClock);
}
expect(Date.now()).toBe(wallClock);
} finally {
dateNow?.mockRestore();
dateNow.mockRestore();
}
}
it("bounds a dripping download body with one wall-clock deadline", async () => {
await expectDrippingDownloadTimeout(200);
});
it("bounds a dripping non-2xx error body with one wall-clock deadline", async () => {
await expectDrippingDownloadTimeout(500);
});
it.each([200, 500])(
"preserves the request timeout when wall-clock time trails its timer (HTTP %i)",
async (statusCode) => {
// The request timer can fire before Date reaches the absolute deadline.
await expectDrippingDownloadTimeout(statusCode, true);
await expectDrippingDownloadTimeout(statusCode);
},
);
// Completed-response semantics must not race host time; real drip tests above own deadlines.
it("preserves normalized and redacted provider errors after the bounded read", async () => {
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
const result = await downloadVydraAsset({
url: "https://cdn.vydra.example/generated/test.png",
kind: "image",
timeoutMs: 250,
fetchFn: async () =>
new Response(
JSON.stringify({ message: "Authorization: Bearer test-token", code: "asset_failed" }),
{
status: 502,
headers: { "x-request-id": "req-vydra-test" },
},
),
maxBytes: 1024 * 1024,
requestPolicy: requestPolicyFor("https://cdn.vydra.example"),
}).catch((error: unknown) => error);
expect(vi.getTimerCount()).toBe(0);
expect(result).toMatchObject({
name: "ProviderHttpError",
status: 502,
statusCode: 502,
errorCode: "asset_failed",
requestId: "req-vydra-test",
});
expect(result).toBeInstanceOf(Error);
expect(result instanceof Error ? result.message : "").not.toContain("test-token");
});
it("normalizes null-body HTTP errors after the bounded read", async () => {
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
const result = await downloadVydraAsset({
url: "https://cdn.vydra.example/generated/test.png",
kind: "image",
timeoutMs: 250,
fetchFn: async () => new Response(null, { status: 304 }),
maxBytes: 1024 * 1024,
requestPolicy: requestPolicyFor("https://cdn.vydra.example"),
}).catch((error: unknown) => error);
expect(vi.getTimerCount()).toBe(0);
expect(result).toMatchObject({ name: "ProviderHttpError", status: 304, statusCode: 304 });
});
it("preserves HTTP metadata when the error body stream fails", async () => {
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
const result = await downloadVydraAsset({
@ -244,9 +185,7 @@ describe("downloadVydraAsset", () => {
});
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 a downloaded video", async ({ contentType, body }) => {
await expect(
@ -285,23 +224,6 @@ describe("downloadVydraAsset", () => {
await vi.advanceTimersByTimeAsync(31_000);
expect(await result).toMatchObject({ buffer: Buffer.from([1, 2, 3]) });
});
it("labels malformed download rejections with the requested media kind", async () => {
await expect(
downloadVydraAsset({
url: "https://cdn.vydra.example/generated/test.png",
kind: "image",
timeoutMs: 250,
fetchFn: async () =>
new Response('{"error":"denied"}', {
status: 200,
headers: { "content-type": "application/json" },
}),
maxBytes: 1024 * 1024,
requestPolicy: requestPolicyFor("https://cdn.vydra.example"),
}),
).rejects.toThrow("Vydra image download: malformed image response");
});
});
it("preserves URL priority, traversal bounds and independent result arrays", () => {

View file

@ -1,5 +1,4 @@
import { bufferedOversizedJsonResponse as oversizedJsonResponse } from "openclaw/plugin-sdk/test-fixtures";
// Vydra tests cover speech provider plugin behavior.
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-media-understanding";
import { afterEach, describe, expect, it, vi } from "vitest";
import { binaryResponse, jsonResponse, stubFetch } from "./provider-test-helpers.js";
@ -9,14 +8,16 @@ describe("vydra speech provider", () => {
installPinnedHostnameTestHooks();
const provider = buildVydraSpeechProvider();
const originalVydraApiKey = process.env.VYDRA_API_KEY;
const request = {
text: "OpenClaw test",
cfg: {},
providerConfig: { apiKey: "vydra-test-key" },
target: "audio-file",
timeoutMs: 30_000,
} satisfies Parameters<typeof provider.synthesize>[0];
afterEach(() => {
if (originalVydraApiKey === undefined) {
delete process.env.VYDRA_API_KEY;
} else {
process.env.VYDRA_API_KEY = originalVydraApiKey;
}
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
@ -24,12 +25,7 @@ describe("vydra speech provider", () => {
it("exposes the default voice and model", async () => {
expect(provider.models).toEqual(["elevenlabs/tts"]);
const voices = await provider.listVoices?.({});
expect(voices).toEqual([
{
id: "21m00Tcm4TlvDq8ikWAM",
name: "Rachel",
},
]);
expect(voices).toEqual([{ id: "21m00Tcm4TlvDq8ikWAM", name: "Rachel" }]);
});
it("posts to the tts endpoint and downloads the audio", async () => {
@ -38,13 +34,7 @@ describe("vydra speech provider", () => {
binaryResponse("mp3-data", "audio/mpeg"),
);
const result = await provider.synthesize({
text: "OpenClaw test",
cfg: {} as never,
providerConfig: { apiKey: "vydra-test-key" },
target: "audio-file",
timeoutMs: 30_000,
});
const result = await provider.synthesize(request);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
@ -66,25 +56,19 @@ describe("vydra speech provider", () => {
});
it("does not treat a blank environment API key as configured", () => {
process.env.VYDRA_API_KEY = " ";
vi.stubEnv("VYDRA_API_KEY", " ");
expect(provider.isConfigured?.({ providerConfig: {}, timeoutMs: 30_000 })).toBe(false);
});
it("rejects blank environment API keys before making requests", async () => {
process.env.VYDRA_API_KEY = "\t \n";
vi.stubEnv("VYDRA_API_KEY", "\t \n");
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
provider.synthesize({
text: "OpenClaw test",
cfg: {} as never,
providerConfig: {},
target: "audio-file",
timeoutMs: 30_000,
}),
).rejects.toThrow("Vydra API key missing");
await expect(provider.synthesize({ ...request, providerConfig: {} })).rejects.toThrow(
"Vydra API key missing",
);
expect(fetchMock).not.toHaveBeenCalled();
});
@ -97,26 +81,17 @@ describe("vydra speech provider", () => {
await expect(
provider.synthesize({
text: "OpenClaw test",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } } as never,
providerConfig: { apiKey: "vydra-test-key" },
target: "audio-file",
timeoutMs: 30_000,
...request,
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
}),
).rejects.toThrow("Vydra audio download exceeds 1 bytes");
});
it("rejects speech synthesis JSON responses that exceed the provider cap", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(oversizedJsonResponse()));
stubFetch(oversizedJsonResponse());
await expect(
provider.synthesize({
text: "OpenClaw test",
cfg: {} as never,
providerConfig: { apiKey: "vydra-test-key" },
target: "audio-file",
timeoutMs: 30_000,
}),
).rejects.toThrow("Vydra speech synthesis: JSON response exceeds 16777216 bytes");
await expect(provider.synthesize(request)).rejects.toThrow(
"Vydra speech synthesis: JSON response exceeds 16777216 bytes",
);
});
});

View file

@ -1,9 +1,7 @@
// Vydra tests cover video generation provider plugin behavior.
import * as providerHttp from "openclaw/plugin-sdk/provider-http";
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { bufferedOversizedJsonResponse as oversizedJsonResponse } from "openclaw/plugin-sdk/test-fixtures";
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-media-understanding";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
binaryResponse,
jsonResponse,
@ -22,61 +20,39 @@ function fetchCall(fetchMock: ReturnType<typeof vi.fn>, index: number) {
describe("vydra video-generation provider", () => {
installPinnedHostnameTestHooks();
const provider = buildVydraVideoGenerationProvider();
const request = { provider: "vydra", model: "kling", prompt: "animate this image", cfg: {} };
beforeEach(stubVydraApiKey);
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("declares explicit mode capabilities", () => {
expectExplicitVideoGenerationCapabilities(buildVydraVideoGenerationProvider());
expectExplicitVideoGenerationCapabilities(provider);
});
it("submits veo3 jobs and downloads the completed video", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
it("rejects generated video downloads that exceed the configured media cap", async () => {
stubFetch(
jsonResponse({ jobId: "job-123", status: "processing" }),
jsonResponse({
jobId: "job-123",
status: "completed",
videoUrl: "https://cdn.vydra.ai/generated/test.mp4",
}),
binaryResponse("webm-data", "video/webm"),
binaryResponse("too-large", "video/mp4"),
);
const provider = buildVydraVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "vydra",
model: "veo3",
prompt: "tiny city at sunrise",
cfg: {},
});
const createCall = fetchCall(fetchMock, 0);
expect(createCall[0]).toBe("https://www.vydra.ai/api/v1/models/veo3");
const createInit = createCall[1] as { method?: string; body?: unknown } | undefined;
expect(createInit?.method).toBe("POST");
expect(createInit?.body).toBe(JSON.stringify({ prompt: "tiny city at sunrise" }));
const pollCall = fetchCall(fetchMock, 1);
expect(pollCall[0]).toBe("https://www.vydra.ai/api/v1/jobs/job-123");
const pollInit = pollCall[1] as { method?: string } | undefined;
expect(pollInit?.method).toBe("GET");
expect(result.videos).toHaveLength(1);
const [video] = result.videos;
if (!video) {
throw new Error("Expected generated Vydra video");
}
expect(video.mimeType).toBe("video/webm");
expect(video.fileName).toBe("video-1.webm");
expect(result.metadata).toEqual({
jobId: "job-123",
videoUrl: "https://cdn.vydra.ai/generated/test.mp4",
status: "completed",
});
await expect(
provider.generateVideo({
...request,
model: "veo3",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
}),
).rejects.toThrow("Vydra video download exceeds 1 bytes");
});
it("carries configured request policy through video submit, poll, and download", async () => {
stubVydraApiKey();
it("submits, polls, and downloads veo3 video with the configured request policy", async () => {
const postJsonRequestSpy = vi.spyOn(providerHttp, "postJsonRequest");
const pollProviderOperationJsonSpy = vi.spyOn(providerHttp, "pollProviderOperationJson");
const fetchWithTimeoutGuardedSpy = vi.spyOn(providerHttp, "fetchWithTimeoutGuarded");
@ -87,12 +63,10 @@ describe("vydra video-generation provider", () => {
status: "completed",
videoUrl: "https://198.18.0.10/generated/policy.mp4",
}),
binaryResponse("mp4-data", "video/mp4"),
binaryResponse("webm-data", "video/webm"),
);
const provider = buildVydraVideoGenerationProvider();
await provider.generateVideo({
provider: "vydra",
const result = await provider.generateVideo({
...request,
model: "veo3",
prompt: "policy proof",
cfg: {
@ -111,12 +85,26 @@ describe("vydra video-generation provider", () => {
},
},
});
expect(fetchCall(fetchMock, 0)).toEqual([
"https://198.18.0.10/api/v1/models/veo3",
expect.objectContaining({ method: "POST", body: JSON.stringify({ prompt: "policy proof" }) }),
]);
expect(fetchCall(fetchMock, 1)).toEqual([
"https://198.18.0.10/api/v1/jobs/job-policy",
expect.objectContaining({ method: "GET" }),
]);
expect(result.videos).toEqual([
{ buffer: Buffer.from("webm-data"), mimeType: "video/webm", fileName: "video-1.webm" },
]);
expect(result.metadata).toEqual({
jobId: "job-policy",
videoUrl: "https://198.18.0.10/generated/policy.mp4",
status: "completed",
});
const submitRequest = postJsonRequestSpy.mock.calls[0]?.[0];
expect(submitRequest?.allowPrivateNetwork).toBe(true);
expect(submitRequest?.dispatcherPolicy).toMatchObject({ mode: "env-proxy" });
expect(submitRequest?.headers.get("x-vydra-policy")).toBe("video-policy");
const pollRequest = pollProviderOperationJsonSpy.mock.calls[0]?.[0];
expect(pollRequest?.allowPrivateNetwork).toBe(true);
expect(pollRequest?.dispatcherPolicy).toBe(submitRequest?.dispatcherPolicy);
@ -124,7 +112,6 @@ describe("vydra video-generation provider", () => {
typeof pollRequest?.headers === "function" ? pollRequest.headers() : pollRequest?.headers,
);
expect(pollHeaders.get("x-vydra-policy")).toBe("video-policy");
const downloadRequest = fetchWithTimeoutGuardedSpy.mock.calls.find(
([url]) => url === "https://198.18.0.10/generated/policy.mp4",
);
@ -133,7 +120,6 @@ describe("vydra video-generation provider", () => {
dispatcherPolicy: submitRequest?.dispatcherPolicy,
auditContext: "vydra-media-download",
});
for (const index of [0, 1, 2]) {
const headers = new Headers((fetchCall(fetchMock, index)[1] as RequestInit).headers);
expect(headers.get("authorization")).toBe("Bearer vydra-test-key");
@ -141,62 +127,17 @@ describe("vydra video-generation provider", () => {
}
});
it("rejects generated video downloads that exceed the configured media cap", async () => {
stubVydraApiKey();
stubFetch(
jsonResponse({ jobId: "job-123", status: "processing" }),
jsonResponse({
jobId: "job-123",
status: "completed",
videoUrl: "https://cdn.vydra.ai/generated/test.mp4",
}),
binaryResponse("too-large", "video/mp4"),
);
const provider = buildVydraVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "vydra",
model: "veo3",
prompt: "tiny city at sunrise",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
}),
).rejects.toThrow("Vydra video download exceeds 1 bytes");
});
it("rejects video creation JSON responses that exceed the provider cap", async () => {
stubVydraApiKey();
stubFetch(oversizedJsonResponse());
const provider = buildVydraVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "vydra",
model: "veo3",
prompt: "tiny city at sunrise",
cfg: {},
}),
).rejects.toThrow("Vydra video generation: JSON response exceeds 16777216 bytes");
});
it("requires a remote image url for kling", async () => {
stubVydraApiKey();
vi.stubGlobal("fetch", vi.fn());
const provider = buildVydraVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "vydra",
model: "kling",
prompt: "animate this image",
cfg: {},
...request,
inputImages: [{ buffer: Buffer.from("png"), mimeType: "image/png" }],
}),
).rejects.toThrow("Vydra kling currently requires a remote image URL reference.");
});
it("submits kling jobs with a remote image url", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({ jobId: "job-kling", status: "processing" }),
jsonResponse({
@ -206,16 +147,10 @@ describe("vydra video-generation provider", () => {
}),
binaryResponse("mp4-data", "video/mp4"),
);
const provider = buildVydraVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "vydra",
model: "kling",
prompt: "animate this image",
cfg: {},
...request,
inputImages: [{ url: "https://example.com/reference.png" }],
});
const createCall = fetchCall(fetchMock, 0);
expect(createCall[0]).toBe("https://www.vydra.ai/api/v1/models/kling");
const createInit = createCall[1] as { method?: string; body?: unknown } | undefined;
@ -228,11 +163,7 @@ describe("vydra video-generation provider", () => {
}),
);
expect(result.videos).toHaveLength(1);
const [video] = result.videos;
if (!video) {
throw new Error("Expected generated Vydra kling video");
}
expect(video.mimeType).toBe("video/mp4");
expect(result.videos[0]?.mimeType).toBe("video/mp4");
expect(result.metadata).toEqual({
jobId: "job-kling",
videoUrl: "https://cdn.vydra.ai/generated/kling.mp4",

View file

@ -54,13 +54,6 @@ describe("redactSensitiveText", () => {
);
});
it("redacts escaped structured authorization fields", () => {
const response = ["escaped", "digest", "response", "1234567890abcdef"].join("-");
const input = `Authorization: Digest realm=\\"Example Realm\\", response=\\"${response}\\"; status=401`;
expect(redactSensitiveText(input)).toBe("Authorization: Digest [REDACTED]; status=401");
});
it("redacts consecutive, prefixed, and serialized auth headers", () => {
const proxyValue = ["cHJveH", "k6cGFz", "cw=="].join("");
const customValue = ["Y3VzdG", "9tOnBh", "c3M="].join("");

View file

@ -1,7 +1,6 @@
// ACP Core tests cover error text behavior.
import { describe, expect, it } from "vitest";
import { formatAcpRuntimeErrorText, toAcpRuntimeErrorText } from "./error-text.js";
import { AcpRuntimeError, toAcpRuntimeError } from "./errors.js";
import { AcpRuntimeError } from "./errors.js";
describe("formatAcpRuntimeErrorText", () => {
it("adds actionable next steps for known ACP runtime error codes", () => {
@ -13,13 +12,6 @@ describe("formatAcpRuntimeErrorText", () => {
);
});
it("returns consistent ACP error envelope for runtime failures", () => {
const text = formatAcpRuntimeErrorText(new AcpRuntimeError("ACP_TURN_FAILED", "turn failed"));
expect(text).toBe(
"ACP error (ACP_TURN_FAILED): turn failed\nnext: Retry, or use `/acp cancel` and send the message again.",
);
});
it("surfaces redacted numeric RequestError details in runtime failure text", () => {
const token = "sk-abcdefghijklmnopqrstuvwxyz123456";
const requestError = Object.assign(new Error("Internal error"), {
@ -30,38 +22,15 @@ describe("formatAcpRuntimeErrorText", () => {
},
});
const text = formatAcpRuntimeErrorText(
toAcpRuntimeError({
error: requestError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
);
expect(text).toContain(
"ACP error (ACP_TURN_FAILED): Internal error: Unknown config option: timeout",
);
expect(text).toContain("next: Retry");
expect(text).not.toContain(token);
});
it("applies the same RequestError details normalization through text conversion", () => {
const requestError = Object.assign(new Error("Internal error"), {
name: "RequestError",
code: -32603,
data: {
details: "Unknown config option: timeout",
},
});
const text = toAcpRuntimeErrorText({
error: requestError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(text).toContain(
"ACP error (ACP_TURN_FAILED): Internal error: Unknown config option: timeout",
expect(text).toBe(
"ACP error (ACP_TURN_FAILED): Internal error: Unknown config option: timeout; token=[REDACTED]\nnext: Retry, or use `/acp cancel` and send the message again.",
);
expect(text).not.toContain(token);
});
});

View file

@ -1,4 +1,3 @@
// ACP Core tests cover errors behavior.
import { afterEach, describe, expect, it } from "vitest";
import { configureAcpErrorRedactor } from "../error-format.js";
import {
@ -19,23 +18,24 @@ async function expectRejectedAcpRuntimeError(promise: Promise<unknown>): Promise
throw new Error("expected ACP runtime error rejection");
}
function rejectThroughBoundary(error: unknown) {
return withAcpRuntimeErrorBoundary({
run: async () => {
throw error;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
}
afterEach(() => {
configureAcpErrorRedactor(undefined);
});
describe("withAcpRuntimeErrorBoundary", () => {
it("wraps generic errors with fallback code and source message", async () => {
const sourceError = new Error("boom");
const error = await expectRejectedAcpRuntimeError(
withAcpRuntimeErrorBoundary({
run: async () => {
throw sourceError;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
);
const sourceError = Object.assign(new Error("boom"), { data: { details: "extra diagnostic" } });
const error = await expectRejectedAcpRuntimeError(rejectThroughBoundary(sourceError));
expect(error.name).toBe("AcpRuntimeError");
expect(error.code).toBe("ACP_TURN_FAILED");
@ -45,33 +45,18 @@ describe("withAcpRuntimeErrorBoundary", () => {
it("passes through existing ACP runtime errors", async () => {
const existing = new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing");
await expect(
withAcpRuntimeErrorBoundary({
run: async () => {
throw existing;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
).rejects.toBe(existing);
await expect(rejectThroughBoundary(existing)).rejects.toBe(existing);
});
it("preserves ACP runtime codes from foreign package errors", async () => {
class ForeignAcpRuntimeError extends Error {
readonly code = "ACP_BACKEND_MISSING" as const;
readonly data = { details: "extra backend diagnostic" };
}
const foreignError = new ForeignAcpRuntimeError("backend missing");
const error = await expectRejectedAcpRuntimeError(
withAcpRuntimeErrorBoundary({
run: async () => {
throw foreignError;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
);
const error = await expectRejectedAcpRuntimeError(rejectThroughBoundary(foreignError));
expect(error.name).toBe("AcpRuntimeError");
expect(error.code).toBe("ACP_BACKEND_MISSING");
@ -101,43 +86,6 @@ describe("withAcpRuntimeErrorBoundary", () => {
expect(error.message).not.toContain(token);
expect(error.cause).toBe(requestError);
});
it("keeps foreign OpenClaw ACP string code behavior unchanged", () => {
const foreignError = Object.assign(new Error("backend missing"), {
code: "ACP_BACKEND_MISSING",
data: {
details: "extra backend diagnostic",
},
});
const error = toAcpRuntimeError({
error: foreignError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(error.code).toBe("ACP_BACKEND_MISSING");
expect(error.message).toBe("backend missing");
expect(error.cause).toBe(foreignError);
});
it("keeps generic non-RequestError messages unchanged", () => {
const sourceError = Object.assign(new Error("boom"), {
data: {
details: "extra diagnostic",
},
});
const error = toAcpRuntimeError({
error: sourceError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(error.code).toBe("ACP_TURN_FAILED");
expect(error.message).toBe("boom");
expect(error.cause).toBe(sourceError);
});
});
describe("formatAcpErrorChain redaction", () => {

View file

@ -1,117 +1,78 @@
// ACP Core tests cover session identifiers behavior.
import { describe, expect, it } from "vitest";
import type { SessionAcpMeta } from "../types.js";
import {
resolveAcpSessionCwd,
resolveAcpSessionIdentifierLinesFromIdentity,
resolveAcpThreadSessionDetailLines,
} from "./session-identifiers.js";
const meta = {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
lastUpdatedAt: 1,
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: 1,
} satisfies SessionAcpMeta;
describe("session identifier helpers", () => {
it("hides unresolved identifiers from thread intro details while pending", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:codex:acp:pending-1",
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "pending",
source: "ensure",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toStrictEqual([]);
expect(
resolveAcpThreadSessionDetailLines({
sessionKey: "agent:codex:acp:pending-1",
meta: { ...meta, identity: { ...meta.identity, state: "pending", source: "ensure" } },
}),
).toStrictEqual([]);
});
it("adds a Codex resume hint when agent identity is resolved", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:codex:acp:resolved-1",
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toStrictEqual([
"agent session id: inner-123",
"acpx session id: acpx-123",
"resume in Codex CLI: `codex resume inner-123` (continues this conversation).",
]);
});
it("adds a Kimi resume hint when agent identity is resolved", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:kimi:acp:resolved-1",
meta: {
backend: "acpx",
agent: "kimi",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-kimi-123",
agentSessionId: "kimi-inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toStrictEqual([
"agent session id: kimi-inner-123",
"acpx session id: acpx-kimi-123",
"resume in Kimi CLI: `kimi resume kimi-inner-123` (continues this conversation).",
]);
});
it.each([
["codex", "Codex", "inner-123", "acpx-123"],
["kimi", "Kimi", "kimi-inner-123", "acpx-kimi-123"],
])(
"adds a %s resume hint when agent identity is resolved",
(agent, label, agentSessionId, acpxSessionId) => {
expect(
resolveAcpThreadSessionDetailLines({
sessionKey: `agent:${agent}:acp:resolved-1`,
meta: { ...meta, agent, identity: { ...meta.identity, agentSessionId, acpxSessionId } },
}),
).toStrictEqual([
`agent session id: ${agentSessionId}`,
`acpx session id: ${acpxSessionId}`,
`resume in ${label} CLI: \`${agent} resume ${agentSessionId}\` (continues this conversation).`,
]);
},
);
it("shows pending identity text for status rendering", () => {
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
backend: "acpx",
mode: "status",
identity: {
state: "pending",
source: "status",
lastUpdatedAt: Date.now(),
agentSessionId: "inner-123",
},
});
expect(lines).toEqual(["session ids: pending (available after the first reply)"]);
expect(
resolveAcpSessionIdentifierLinesFromIdentity({
backend: "acpx",
mode: "status",
identity: {
state: "pending",
source: "status",
lastUpdatedAt: 1,
agentSessionId: "inner-123",
},
}),
).toEqual(["session ids: pending (available after the first reply)"]);
});
it("prefers runtimeOptions.cwd over legacy meta.cwd", () => {
const cwd = resolveAcpSessionCwd({
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
runtimeOptions: {
cwd: "/repo/new",
},
cwd: "/repo/old",
state: "idle",
lastActivityAt: Date.now(),
});
expect(cwd).toBe("/repo/new");
expect(
resolveAcpSessionCwd({
...meta,
runtimeOptions: { cwd: "/repo/new" },
cwd: "/repo/old",
}),
).toBe("/repo/new");
});
});

View file

@ -1,4 +1,3 @@
// ACP Core tests cover session interaction mode behavior.
import { describe, expect, it } from "vitest";
import {
isParentOwnedBackgroundAcpSession,
@ -13,45 +12,10 @@ describe("isParentOwnedBackgroundAcpSession", () => {
expect(isParentOwnedBackgroundAcpSession(undefined)).toBe(false);
});
it("returns parent-owned-background for persistent sessions with spawnedBy set", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "persistent" } as never,
spawnedBy: parentKey,
}),
).toBe(true);
});
it("returns interactive for persistent ACP sessions without parent linkage", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "persistent" } as never,
}),
).toBe(false);
});
it("returns parent-owned-background for oneshot sessions with spawnedBy set", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "oneshot" } as never,
spawnedBy: parentKey,
}),
).toBe(true);
});
it("returns parent-owned-background for oneshot sessions with parentSessionKey set", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "oneshot" } as never,
parentSessionKey: parentKey,
}),
).toBe(true);
});
it("returns interactive for a oneshot session without any parent linkage", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "oneshot" } as never,
acp: { mode: "persistent" },
}),
).toBe(false);
});
@ -59,7 +23,7 @@ describe("isParentOwnedBackgroundAcpSession", () => {
describe("isRequesterParentOfBackgroundAcpSession", () => {
const backgroundEntry = {
acp: { mode: "oneshot" } as never,
acp: { mode: "oneshot" },
spawnedBy: parentKey,
parentSessionKey: parentKey,
};
@ -67,7 +31,7 @@ describe("isRequesterParentOfBackgroundAcpSession", () => {
it("returns true when requester matches spawnedBy", () => {
expect(
isRequesterParentOfBackgroundAcpSession(
{ acp: { mode: "oneshot" } as never, spawnedBy: parentKey },
{ acp: { mode: "oneshot" }, spawnedBy: parentKey },
parentKey,
),
).toBe(true);
@ -76,7 +40,7 @@ describe("isRequesterParentOfBackgroundAcpSession", () => {
it("returns true when requester matches parentSessionKey", () => {
expect(
isRequesterParentOfBackgroundAcpSession(
{ acp: { mode: "oneshot" } as never, parentSessionKey: parentKey },
{ acp: { mode: "oneshot" }, parentSessionKey: parentKey },
parentKey,
),
).toBe(true);
@ -94,7 +58,7 @@ describe("isRequesterParentOfBackgroundAcpSession", () => {
it("returns true when target is parent-owned persistent ACP session", () => {
expect(
isRequesterParentOfBackgroundAcpSession(
{ acp: { mode: "persistent" } as never, spawnedBy: parentKey },
{ acp: { mode: "persistent" }, spawnedBy: parentKey },
parentKey,
),
).toBe(true);

View file

@ -1,4 +1,3 @@
// ACP Core tests cover session lineage meta behavior.
import { describe, expect, it } from "vitest";
import { toAcpSessionLineageMeta, type AcpSessionLineageRow } from "./session-lineage-meta.js";
@ -15,29 +14,6 @@ describe("toAcpSessionLineageMeta", () => {
kind: "direct",
channel: "telegram",
});
expect(Object.keys(meta)).toEqual(["sessionKey", "kind", "channel"]);
});
it("maps a one-level child parent key into parentSessionId", () => {
const meta = toAcpSessionLineageMeta({
key: "agent:main:subagent:child",
kind: "direct",
parentSessionKey: "agent:main:main",
spawnedBy: "agent:main:main",
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
});
expect(meta).toEqual({
sessionKey: "agent:main:subagent:child",
kind: "direct",
parentSessionId: "agent:main:main",
spawnedBy: "agent:main:main",
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
});
});
it("keeps multi-level child lineage and workspace metadata", () => {

View file

@ -1,14 +1,12 @@
// ACP Core tests cover session behavior.
import { beforeEach, describe, expect, it } from "vitest";
import { createInMemorySessionStore } from "./session.js";
describe("acp session manager", () => {
let nowMs = 0;
const now = () => nowMs;
const advance = (ms: number) => {
nowMs += ms;
};
let store = createInMemorySessionStore({ now });
const createSession = (sessionId: string, target = store) =>
target.createSession({ sessionId, sessionKey: `acp:${sessionId}`, cwd: "/tmp" });
beforeEach(() => {
nowMs = 1_000;
@ -56,11 +54,7 @@ describe("acp session manager", () => {
);
it("deletes sessions and aborts active runs on close", () => {
const session = store.createSession({
sessionId: "close-me",
sessionKey: "acp:close",
cwd: "/tmp",
});
const session = createSession("close-me");
const controller = new AbortController();
store.setActiveRun(session.sessionId, "run-close", controller);
@ -80,7 +74,7 @@ describe("acp session manager", () => {
sessionKey: "acp:one",
cwd: "/tmp/one",
});
advance(500);
nowMs += 500;
const refreshed = store.createSession({
sessionId: "existing",
@ -102,17 +96,9 @@ describe("acp session manager", () => {
idleTtlMs: Number.NaN,
now,
});
boundedStore.createSession({
sessionId: "first",
sessionKey: "acp:first",
cwd: "/tmp",
});
advance(1);
boundedStore.createSession({
sessionId: "second",
sessionKey: "acp:second",
cwd: "/tmp",
});
createSession("first", boundedStore);
nowMs += 1;
createSession("second", boundedStore);
expect(boundedStore.hasSession("first")).toBe(true);
expect(boundedStore.hasSession("second")).toBe(true);
@ -125,44 +111,11 @@ describe("acp session manager", () => {
now,
});
for (let index = 0; index < 5_000; index += 1) {
const session = boundedStore.createSession({
sessionId: `session-${index}`,
sessionKey: `acp:${index}`,
cwd: "/tmp",
});
const session = createSession(`session-${index}`, boundedStore);
boundedStore.setActiveRun(session.sessionId, `run-${index}`, new AbortController());
}
expect(() =>
boundedStore.createSession({
sessionId: "overflow",
sessionKey: "acp:overflow",
cwd: "/tmp",
}),
).toThrow(/session limit reached/i);
});
it("reaps idle sessions before enforcing the max session cap", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: 1,
idleTtlMs: 1_000,
now,
});
boundedStore.createSession({
sessionId: "old",
sessionKey: "acp:old",
cwd: "/tmp",
});
advance(2_000);
const fresh = boundedStore.createSession({
sessionId: "fresh",
sessionKey: "acp:fresh",
cwd: "/tmp",
});
expect(fresh.sessionId).toBe("fresh");
expect(boundedStore.getSession("old")).toBeUndefined();
expect(boundedStore.hasSession("old")).toBe(false);
expect(() => createSession("overflow", boundedStore)).toThrow(/session limit reached/i);
});
it("uses soft-cap eviction for the oldest idle session when full", () => {
@ -171,26 +124,14 @@ describe("acp session manager", () => {
idleTtlMs: 24 * 60 * 60 * 1_000,
now,
});
const first = boundedStore.createSession({
sessionId: "first",
sessionKey: "acp:first",
cwd: "/tmp",
});
advance(100);
const second = boundedStore.createSession({
sessionId: "second",
sessionKey: "acp:second",
cwd: "/tmp",
});
const first = createSession("first", boundedStore);
nowMs += 100;
const second = createSession("second", boundedStore);
const controller = new AbortController();
boundedStore.setActiveRun(second.sessionId, "run-2", controller);
advance(100);
nowMs += 100;
const third = boundedStore.createSession({
sessionId: "third",
sessionKey: "acp:third",
cwd: "/tmp",
});
const third = createSession("third", boundedStore);
expect(third.sessionId).toBe("third");
expect(boundedStore.getSession(first.sessionId)).toBeUndefined();
@ -204,20 +145,10 @@ describe("acp session manager", () => {
idleTtlMs: 24 * 60 * 60 * 1_000,
now,
});
const only = boundedStore.createSession({
sessionId: "only",
sessionKey: "acp:only",
cwd: "/tmp",
});
const only = createSession("only", boundedStore);
boundedStore.setActiveRun(only.sessionId, "run-only", new AbortController());
expect(() =>
boundedStore.createSession({
sessionId: "next",
sessionKey: "acp:next",
cwd: "/tmp",
}),
).toThrow(/session limit reached/i);
expect(() => createSession("next", boundedStore)).toThrow(/session limit reached/i);
});
it("reports every removal path through onSessionRemoved", () => {
@ -229,19 +160,19 @@ describe("acp session manager", () => {
onSessionRemoved: (sessionId) => removed.push(sessionId),
});
reportingStore.createSession({ sessionKey: "k", cwd: "/", sessionId: "deleted" });
createSession("deleted", reportingStore);
expect(reportingStore.deleteSession("deleted")).toBe(true);
expect(removed).toEqual(["deleted"]);
// Idle reaping: the session ages past the TTL and is swept on the next create.
reportingStore.createSession({ sessionKey: "k", cwd: "/", sessionId: "stale" });
createSession("stale", reportingStore);
nowMs += 5_000;
reportingStore.createSession({ sessionKey: "k", cwd: "/", sessionId: "fresh" });
createSession("fresh", reportingStore);
expect(removed).toEqual(["deleted", "stale"]);
// Capacity eviction: at maxSessions the oldest idle session makes room.
reportingStore.createSession({ sessionKey: "k", cwd: "/", sessionId: "second" });
reportingStore.createSession({ sessionKey: "k", cwd: "/", sessionId: "third" });
createSession("second", reportingStore);
createSession("third", reportingStore);
expect(removed).toEqual(["deleted", "stale", "fresh"]);
// Dispose reports whatever was still held.

View file

@ -4,15 +4,11 @@ import { normalizeMimeType } from "./mime.js";
describe("attachmentClassFromMime", () => {
it.each([
["text/plain", "text"],
["application/vnd.api+json", "text"],
["application/pdf", "document"],
["application/msword", "document"],
["image/png", "image"],
["audio/mpeg", "audio"],
["video/mp4", "video"],
["application/zip", "archive"],
["application/octet-stream", "binary"],
] as const)("classifies %s as %s", (mime, expected) => {
expect(attachmentClassFromMime(mime)).toBe(expected);
});
@ -22,42 +18,30 @@ describe("classifyAttachmentBytes", () => {
const completeUtf8 = Buffer.from("验证".repeat(700), "utf8");
it.each([
{
name: "complete 4,092-byte UTF-8 text",
buffer: completeUtf8.subarray(0, 4092),
expectedClass: "text",
},
{
name: "complete 4,200-byte UTF-8 text with a split sniff prefix",
buffer: completeUtf8,
expectedClass: "text",
},
{
name: "a complete input truncated mid-character at 4,096 bytes",
buffer: completeUtf8.subarray(0, 4096),
expectedClass: "binary",
},
{
name: "an invalid continuation after the sniff boundary",
buffer: Buffer.concat([completeUtf8.subarray(0, 4095), Buffer.from([0xe2, 0x28])]),
expectedClass: "binary",
},
{
name: "an incomplete sequence at the actual 4,097-byte EOF",
buffer: Buffer.concat([completeUtf8.subarray(0, 4095), Buffer.from([0xe2, 0x82])]),
expectedClass: "binary",
},
{
name: "an invalid byte before the sniff boundary",
buffer: Buffer.concat([
["complete 4,092-byte UTF-8 text", completeUtf8.subarray(0, 4092), "text"],
["complete 4,200-byte UTF-8 text with a split sniff prefix", completeUtf8, "text"],
["input truncated mid-character at 4,096 bytes", completeUtf8.subarray(0, 4096), "binary"],
[
"an invalid continuation after the sniff boundary",
Buffer.concat([completeUtf8.subarray(0, 4095), Buffer.from([0xe2, 0x28])]),
"binary",
],
[
"an incomplete sequence at the actual 4,097-byte EOF",
Buffer.concat([completeUtf8.subarray(0, 4095), Buffer.from([0xe2, 0x82])]),
"binary",
],
[
"an invalid byte before the sniff boundary",
Buffer.concat([
completeUtf8.subarray(0, 1200),
Buffer.from([0xff]),
completeUtf8.subarray(1200),
]),
expectedClass: "binary",
},
{ name: "empty input", buffer: Buffer.alloc(0), expectedClass: "binary" },
] as const)("classifies $name", async ({ buffer, expectedClass }) => {
"binary",
],
["empty input", Buffer.alloc(0), "binary"],
] as const)("classifies %s", async (_name, buffer, expectedClass) => {
await expect(classifyAttachmentBytes({ buffer, name: "notes" })).resolves.toEqual({
mime: expectedClass === "text" ? "text/plain" : undefined,
class: expectedClass,
@ -65,60 +49,27 @@ describe("classifyAttachmentBytes", () => {
});
it.each([
{ name: "two-byte sequence", prefixLength: 4095, bytes: [0xc2, 0xa3], expectedClass: "text" },
{
name: "three-byte sequence",
prefixLength: 4095,
bytes: [0xe2, 0x82, 0xac],
expectedClass: "text",
["two-byte sequence", 4095, [0xc2, 0xa3], "text"],
["three-byte sequence", 4095, [0xe2, 0x82, 0xac], "text"],
["four-byte sequence after its first byte", 4095, [0xf0, 0x9f, 0xa6, 0x80], "text"],
["four-byte sequence after its second byte", 4094, [0xf0, 0x9f, 0xa6, 0x80], "text"],
["four-byte sequence after its third byte", 4093, [0xf0, 0x9f, 0xa6, 0x80], "text"],
["overlong sequence crossing the boundary", 4095, [0xe0, 0x80, 0x80], "binary"],
["invalid byte outside a complete sample", 4096, [0xff], "text"],
] as const)(
"bounds UTF-8 completion for a %s",
async (_name, prefixLength, bytes, expectedClass) => {
const buffer = Buffer.concat([
completeUtf8.subarray(0, 4092),
Buffer.alloc(prefixLength - 4092, 0x61),
Buffer.from(bytes),
]);
await expect(classifyAttachmentBytes({ buffer, name: "notes" })).resolves.toEqual({
mime: expectedClass === "text" ? "text/plain" : undefined,
class: expectedClass,
});
},
{
name: "four-byte sequence after its first byte",
prefixLength: 4095,
bytes: [0xf0, 0x9f, 0xa6, 0x80],
expectedClass: "text",
},
{
name: "four-byte sequence after its second byte",
prefixLength: 4094,
bytes: [0xf0, 0x9f, 0xa6, 0x80],
expectedClass: "text",
},
{
name: "four-byte sequence after its third byte",
prefixLength: 4093,
bytes: [0xf0, 0x9f, 0xa6, 0x80],
expectedClass: "text",
},
{
name: "overlong sequence crossing the boundary",
prefixLength: 4095,
bytes: [0xe0, 0x80, 0x80],
expectedClass: "binary",
},
{
name: "new sequence outside a complete sample",
prefixLength: 4096,
bytes: [0xf0, 0x9f, 0xa6, 0x80],
expectedClass: "text",
},
{
name: "invalid byte outside a complete sample",
prefixLength: 4096,
bytes: [0xff],
expectedClass: "text",
},
])("bounds UTF-8 completion for a $name", async ({ prefixLength, bytes, expectedClass }) => {
const buffer = Buffer.concat([
completeUtf8.subarray(0, 4092),
Buffer.alloc(prefixLength - 4092, 0x61),
Buffer.from(bytes),
]);
await expect(classifyAttachmentBytes({ buffer, name: "notes" })).resolves.toEqual({
mime: expectedClass === "text" ? "text/plain" : undefined,
class: expectedClass,
});
});
);
it("infers delimited text from otherwise untyped bytes", async () => {
await expect(
@ -172,10 +123,8 @@ describe("classifyAttachmentBytes", () => {
});
it.each([
["config.yaml", "application/yaml"],
["payload.xml", "text/xml"],
["debug.log", "text/plain"],
["settings.ini", "text/plain"],
] as const)("uses the canonical extension MIME for %s", async (name, mime) => {
await expect(
classifyAttachmentBytes({ buffer: Buffer.from("key=value"), name }),

View file

@ -5,31 +5,14 @@ import { canonicalizeBase64, estimateBase64DecodedBytes, isValidBase64 } from ".
import { measureBase64Memory } from "./base64.memory.test-support.js";
describe("base64 helpers", () => {
it("canonicalizeBase64 validates large payloads without cons-string overflow", () => {
const encoded = Buffer.alloc(1_900_000).toString("base64");
expect(canonicalizeBase64(encoded)).toBe(encoded);
});
it("canonicalizeBase64 handles attachment-sized payloads without heap blow-up", async ({
signal,
}) => {
// Regression guard: the previous per-character append built one cons-string
// node per input character (~25 bytes each, all live at once), so this
// 16 MiB payload (21.3 M base64 chars) transiently needed >500 MB of heap.
// The threshold is deliberately generous; the bounded-buffer implementation
// returns already-canonical input unchanged.
// Per-character concatenation previously used >500 MiB for this 16 MiB payload.
const memory = await measureBase64Memory("canonical", signal);
expect(memory.vmDelta).toBeLessThan(100 * 1024 * 1024);
});
it("canonicalizeBase64 cleans whitespace inside large payloads", () => {
const encoded = Buffer.alloc(1_000_000, 0xab).toString("base64");
const wrapped = encoded.replace(/(.{76})/g, "$1\r\n");
expect(canonicalizeBase64(wrapped)).toBe(encoded);
});
it("canonicalizeBase64 handles one whitespace per character without heap blow-up", async ({
signal,
}) => {
@ -60,7 +43,7 @@ describe("base64 helpers", () => {
expect(isValidBase64(alphabet)).toBe(true);
});
it.each(["*", ",", ".", ":", "@", "[", "`", "{", "-", "_", "\u007f", "é", "\ud800", "\udc00"])(
it.each([":", "@", "[", "`", "{", "-", "_", "é", "\ud800"])(
"base64 helpers reject non-alphabet glyph %j",
(glyph) => {
expect(canonicalizeBase64("AA" + glyph + "A")).toBeUndefined();
@ -68,91 +51,39 @@ describe("base64 helpers", () => {
},
);
it.each(Array.from(alphabet))(
"canonicalizeBase64 validates terminal pad bits for %s",
(glyph) => {
const paddedByte = `A${glyph}==`;
const paddedPair = `AA${glyph}=`;
expect(canonicalizeBase64(paddedByte)).toBe("AQgw".includes(glyph) ? paddedByte : undefined);
expect(canonicalizeBase64("A" + glyph)).toBe("AQgw".includes(glyph) ? paddedByte : undefined);
expect(canonicalizeBase64(paddedPair)).toBe(
"AEIMQUYcgkosw048".includes(glyph) ? paddedPair : undefined,
);
expect(canonicalizeBase64("AA" + glyph)).toBe(
"AEIMQUYcgkosw048".includes(glyph) ? paddedPair : undefined,
);
},
);
it.each([
["Q", "AQ==", "AAQ="],
["E", undefined, "AAE="],
["B", undefined, undefined],
["g", "Ag==", "AAg="],
["c", undefined, "AAc="],
["0", undefined, "AA0="],
["+", undefined, undefined],
["/", undefined, undefined],
] as const)("validates padded and unpadded terminal bits for %s", (glyph, byte, pair) => {
expect(canonicalizeBase64(`A${glyph}==`)).toBe(byte);
expect(canonicalizeBase64(`A${glyph}`)).toBe(byte);
expect(canonicalizeBase64(`AA${glyph}=`)).toBe(pair);
expect(canonicalizeBase64(`AA${glyph}`)).toBe(pair);
});
it.each([
{
name: "canonicalizeBase64 normalizes whitespace and keeps valid base64",
actual: canonicalizeBase64(" SGV s bG8= \n"),
expected: "SGVsbG8=",
},
{
name: "canonicalizeBase64 pads valid unpadded base64",
actual: canonicalizeBase64("SGVsbG8"),
expected: "SGVsbG8=",
},
{
name: "canonicalizeBase64 rejects impossible unpadded length",
actual: canonicalizeBase64("S"),
expected: undefined,
},
{
name: "canonicalizeBase64 rejects invalid base64 characters",
actual: canonicalizeBase64('SGVsbG8=" onerror="alert(1)'),
expected: undefined,
},
{
name: "canonicalizeBase64 rejects nonzero pad bits",
actual: canonicalizeBase64("ZE=="),
expected: undefined,
},
{
name: "canonicalizeBase64 rejects nonzero pad bits on auto-padded input",
actual: canonicalizeBase64("ZE"),
expected: undefined,
},
{
name: "canonicalizeBase64 trims leading and trailing whitespace",
actual: canonicalizeBase64("\n\tSGVsbG8= "),
expected: "SGVsbG8=",
},
{
name: "canonicalizeBase64 rejects data chars after padding",
actual: canonicalizeBase64("QQ==QQ=="),
expected: undefined,
},
{
name: "canonicalizeBase64 rejects more than two padding chars",
actual: canonicalizeBase64("===="),
expected: undefined,
},
{
name: "canonicalizeBase64 rejects a data: URL prefix",
actual: canonicalizeBase64("data:image/png;base64,QUJD"),
expected: undefined,
},
{
name: "canonicalizeBase64 rejects whitespace-only input",
actual: canonicalizeBase64(" \r\n\t"),
expected: undefined,
},
{
name: "estimateBase64DecodedBytes handles whitespace",
actual: estimateBase64DecodedBytes("SGV s bG8= \n"),
expected: 5,
},
{
name: "estimateBase64DecodedBytes handles empty input",
actual: estimateBase64DecodedBytes(""),
expected: 0,
},
] as const)("$name", ({ actual, expected }) => {
expect(actual).toBe(expected);
[" SGV s bG8= \n", "SGVsbG8="],
["S", undefined],
['SGVsbG8=" onerror="alert(1)', undefined],
["QQ==QQ==", undefined],
["====", undefined],
["data:image/png;base64,QUJD", undefined],
[" \r\n\t", undefined],
] as const)("canonicalizes %j", (input, expected) => {
expect(canonicalizeBase64(input)).toBe(expected);
});
it.each([
["SGV s bG8= \n", 5],
["", 0],
] as const)("estimates decoded bytes for %j", (input, expected) => {
expect(estimateBase64DecodedBytes(input)).toBe(expected);
});
});

View file

@ -1,5 +1,5 @@
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { mediaKindFromMime } from "./constants.js";
import {
detectMime,
@ -15,372 +15,179 @@ import {
sliceMimeSniffBuffer,
} from "./mime.js";
async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise<Buffer> {
const zip = new JSZip();
zip.file(
"[Content_Types].xml",
`<Types><Override PartName="${opts.partPath}" ContentType="${opts.mainMime}.main+xml"/></Types>`,
);
zip.file(opts.partPath.slice(1), "<xml/>");
return await zip.generateAsync({ type: "nodebuffer" });
}
// file-type classifies this generic ISO-BMFF brand as video/mp4 without track metadata.
const ISOM_BRAND_BUFFER = Buffer.from(
"0000001c6674797069736f6d0000000069736f6d0000000000000000",
"hex",
);
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
describe("mime detection", () => {
it.each([{ filePath: "clip.avi" }, {}, { filePath: "clip.bin", headerMime: "video/x-msvideo" }])(
"normalizes byte-detected AVI independently of filename/header hints %#",
async (hints) => {
const buffer = Buffer.from("524946463800000041564920" + "00".repeat(52), "hex");
const detected = await detectMime({ buffer, ...hints });
let zipBuffer: Buffer;
beforeAll(async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
zipBuffer = await zip.generateAsync({ type: "nodebuffer" });
});
expect(detected).toBe("video/x-msvideo");
expect(extensionForMime(detected)).toBe(".avi");
},
);
it("normalizes byte-detected AVI without filename or header hints", async () => {
const buffer = Buffer.from("524946463800000041564920" + "00".repeat(52), "hex");
const detected = await detectMime({ buffer });
expect(detected).toBe("video/x-msvideo");
expect(extensionForMime(detected)).toBe(".avi");
});
it("normalizes byte-detected Matroska to the filename MIME spelling", async () => {
const buffer = Buffer.from("1a45dfa38b4282886d6174726f736b61", "hex");
const detected = await detectMime({ buffer, filePath: "clip.bin" });
expect(detected).toBe("video/x-matroska");
expect(extensionForMime(detected)).toBe(".mkv");
});
it.each([
{ format: "avif", expected: "image/avif" },
{ format: "jpg", expected: "image/jpeg" },
{ format: "jpeg", expected: "image/jpeg" },
{ format: "png", expected: "image/png" },
{ format: "webp", expected: "image/webp" },
{ format: "gif", expected: "image/gif" },
{ format: "unknown", expected: undefined },
])("maps $format image format", ({ format, expected }) => {
["avif", "image/avif"],
["jpg", "image/jpeg"],
["jpeg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
["gif", "image/gif"],
["unknown", undefined],
])("maps %s image format", (format, expected) => {
expect(imageMimeFromFormat(format)).toBe(expected);
});
it.each([
{
name: "detects docx from buffer",
mainMime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
partPath: "/word/document.xml",
expected: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
{
name: "detects pptx from buffer",
mainMime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
partPath: "/ppt/presentation.xml",
expected: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
},
] as const)("$name", async ({ mainMime, partPath, expected }) => {
expect(
await detectMime({
buffer: await makeOoxmlZip({ mainMime, partPath }),
filePath: "/tmp/file.bin",
}),
).toBe(expected);
});
it.each([
{
name: "prefers extension mapping over generic zip",
input: async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
return {
buffer: await zip.generateAsync({ type: "nodebuffer" }),
filePath: "/tmp/file.xlsx",
};
},
expected: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
name: "does not let image extensions override generic zip bytes",
input: async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
return {
buffer: await zip.generateAsync({ type: "nodebuffer" }),
filePath: "/tmp/fake.png",
};
},
expected: "application/zip",
},
{
name: "does not let image headers override generic zip bytes",
input: async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
return {
buffer: await zip.generateAsync({ type: "nodebuffer" }),
headerMime: "image/png",
};
},
expected: "application/zip",
},
{
name: "uses extension mapping for JavaScript assets",
input: async () => ({
filePath: "/tmp/a2ui.bundle.js",
}),
expected: "text/javascript",
},
{
name: "uses extension mapping for YAML assets",
input: async () => ({
filePath: "/tmp/config.yml",
}),
expected: "application/yaml",
},
{
name: "uses extension mapping for YAML documents",
input: async () => ({
filePath: "/tmp/config.yaml",
}),
expected: "application/yaml",
},
] as const)("$name", async ({ input, expected }) => {
expect(await detectMime(await input())).toBe(expected);
});
it.each([
"application/epub+zip",
"application/java-archive",
"application/vnd.apple.pages",
"application/vnd.google-earth.kmz",
"application/vnd.ms-word.document.macroenabled.12",
"application/vnd.ms-visio.drawing",
"application/vnd.oasis.opendocument.text",
"application/vnd.openxmlformats-officedocument.wordprocessingml.template",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
])("uses %s metadata to refine extensionless generic ZIP bytes", async (headerMime) => {
["/word/document.xml", DOCX_MIME],
[
"/ppt/presentation.xml",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
],
])("detects OOXML from %s package metadata", async (partPath, mainMime) => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
expect(
await detectMime({ buffer: await zip.generateAsync({ type: "nodebuffer" }), headerMime }),
).toBe(headerMime);
});
it("does not let unrelated document metadata override generic ZIP bytes", async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
zip.file(
"[Content_Types].xml",
`<Types><Override PartName="${partPath}" ContentType="${mainMime}.main+xml"/></Types>`,
);
zip.file(partPath.slice(1), "<xml/>");
expect(
await detectMime({
buffer: await zip.generateAsync({ type: "nodebuffer" }),
headerMime: "application/pdf",
filePath: "file.bin",
}),
).toBe("application/zip");
).toBe(mainMime);
});
it.each(["application/vnd.oasis.opendocument.text-flat-xml", "application/vnd.visio"])(
"does not let non-ZIP %s metadata override generic ZIP bytes",
async (headerMime) => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
expect(
await detectMime({ buffer: await zip.generateAsync({ type: "nodebuffer" }), headerMime }),
).toBe("application/zip");
it.each([
{ hints: { filePath: "file.xlsx" }, expected: XLSX_MIME },
{ hints: { filePath: "fake.png" }, expected: "application/zip" },
{ hints: { headerMime: "image/png" }, expected: "application/zip" },
{ hints: { headerMime: "application/epub+zip" }, expected: "application/epub+zip" },
{ hints: { headerMime: "application/java-archive" }, expected: "application/java-archive" },
{ hints: { headerMime: "application/pdf" }, expected: "application/zip" },
{
hints: { headerMime: "application/vnd.oasis.opendocument.text-flat-xml" },
expected: "application/zip",
},
);
it("prefers ZIP-compatible metadata over an incompatible filename extension", async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
const docxMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
expect(
await detectMime({
buffer: await zip.generateAsync({ type: "nodebuffer" }),
filePath: "upload.pdf",
headerMime: docxMime,
}),
).toBe(docxMime);
});
it("preserves audio metadata for ambiguous WebM container bytes", async () => {
// Minimal EBML header declaring WebM; file-type correctly recognizes the container
// but defaults it to video/webm because no track metadata is present.
const webm = Buffer.from("1a45dfa3874282847765626d", "hex");
expect(
await detectMime({ buffer: webm, filePath: "voice.webm", headerMime: "audio/webm" }),
).toBe("audio/webm");
});
it("uses a secondary audio hint when primary metadata is stale", async () => {
const webm = Buffer.from("1a45dfa3874282847765626d", "hex");
expect(
await detectMime({
buffer: webm,
filePath: "voice.webm",
headerMime: "application/pdf",
additionalMimeHints: ["audio/webm"],
}),
).toBe("audio/webm");
});
it.each(["audio/webm", "audio/mp4"])(
"preserves the declared %s hint when bytes and extension are inconclusive",
async (headerMime) => {
expect(
await detectMime({
buffer: Buffer.alloc(16),
headerMime,
additionalMimeHints: ["application/octet-stream"],
}),
).toBe(headerMime);
{ hints: { headerMime: "application/vnd.visio" }, expected: "application/zip" },
{ hints: { filePath: "upload.pdf", headerMime: DOCX_MIME }, expected: DOCX_MIME },
])(
"refines generic ZIP bytes only with compatible metadata: $hints",
async ({ hints, expected }) => {
expect(await detectMime({ buffer: zipBuffer, ...hints })).toBe(expected);
},
);
it.each([
{
name: "audio/mp4 header",
filePath: "voice.mp4",
headerMime: "audio/mp4",
expected: "audio/mp4",
},
{
name: "audio/x-m4a header",
filePath: "voice.m4a",
headerMime: "audio/x-m4a",
expected: "audio/x-m4a",
},
{
name: "audio/m4a header",
filePath: "voice.m4a",
headerMime: "audio/m4a",
expected: "audio/m4a",
},
{
name: "m4a extension",
filePath: "voice.m4a",
headerMime: undefined,
expected: "audio/x-m4a",
},
{
name: "mp4 extension without an audio hint",
filePath: "clip.mp4",
headerMime: undefined,
expected: "video/mp4",
},
{
name: "audio/aac elementary-stream metadata",
filePath: "voice.aac",
headerMime: "audio/aac",
expected: "video/mp4",
},
] as const)("resolves ambiguous isom-brand bytes from $name", async (testCase) => {
expect(
await detectMime({
buffer: ISOM_BRAND_BUFFER,
filePath: testCase.filePath,
headerMime: testCase.headerMime,
}),
).toBe(testCase.expected);
["a2ui.bundle.js", "text/javascript"],
["config.yml", "application/yaml"],
["config.yaml", "application/yaml"],
["report.html", "text/html"],
["page.htm", "text/html"],
["data.xml", "text/xml"],
["style.css", "text/css"],
["voice.aac", "audio/aac"],
])("uses the extension when byte sniffing is inconclusive: %s", async (filePath, expected) => {
expect(await detectMime({ buffer: Buffer.alloc(16), filePath })).toBe(expected);
});
it.each([
{ brand: "avif", expected: "image/avif" },
{ brand: "avis", expected: "image/avif" },
{ brand: "M4B ", expected: "audio/mp4" },
{ brand: "M4V ", expected: "video/x-m4v" },
{ brand: "hevc", expected: "image/heic-sequence" },
{ brand: "msf1", expected: "image/heif-sequence" },
] as const)("preserves the file-type MIME for ISO-BMFF $brand media", async (testCase) => {
{ headerMime: "audio/webm" },
{ headerMime: "application/pdf", additionalMimeHints: ["audio/webm"] },
])("preserves primary or fallback audio hints for ambiguous WebM bytes: %j", async (hints) => {
const buffer = Buffer.from("1a45dfa3874282847765626d", "hex");
expect(await detectMime({ buffer, filePath: "voice.webm", ...hints })).toBe("audio/webm");
});
it("preserves the declared hint ahead of a generic fallback when bytes are inconclusive", async () => {
expect(
await detectMime({
buffer: Buffer.alloc(16),
headerMime: "audio/mp4",
additionalMimeHints: ["application/octet-stream"],
}),
).toBe("audio/mp4");
});
it.each([
["voice.mp4", "audio/mp4", "audio/mp4"],
["voice.m4a", "audio/x-m4a", "audio/x-m4a"],
["voice.m4a", "audio/m4a", "audio/m4a"],
["voice.m4a", undefined, "audio/x-m4a"],
["clip.mp4", undefined, "video/mp4"],
["voice.aac", "audio/aac", "video/mp4"],
])(
"resolves ambiguous isom-brand bytes with %s and %s",
async (filePath, headerMime, expected) => {
expect(await detectMime({ buffer: ISOM_BRAND_BUFFER, filePath, headerMime })).toBe(expected);
},
);
it.each([
["avif", "image/avif"],
["avis", "image/avif"],
["M4B ", "audio/mp4"],
["M4V ", "video/x-m4v"],
["hevc", "image/heic-sequence"],
["msf1", "image/heif-sequence"],
])("preserves the file-type MIME for ISO-BMFF %s media", async (brand, expected) => {
const buffer = Buffer.alloc(24);
buffer.writeUInt32BE(buffer.length, 0);
buffer.write("ftyp", 4, "ascii");
buffer.write(testCase.brand, 8, "ascii");
expect(await detectMime({ buffer })).toBe(testCase.expected);
buffer.write(brand, 8, "ascii");
expect(await detectMime({ buffer })).toBe(expected);
});
it("does not let conflicting audio metadata override MPEG video bytes", async () => {
const mpegProgramStream = Buffer.from([0x00, 0x00, 0x01, 0xba, 0x00, 0x00, 0x00, 0x00]);
expect(await detectMime({ buffer: mpegProgramStream, headerMime: "audio/mpeg" })).toBe(
"video/mpeg",
);
});
it("detects HTML files by extension (no magic bytes)", async () => {
const buf = Buffer.from("<!DOCTYPE html><html><body>test</body></html>");
const mime = await detectMime({ buffer: buf, filePath: "/tmp/report.html" });
expect(mime).toBe("text/html");
});
it("detects .htm files by extension", async () => {
const buf = Buffer.from("<html><body>test</body></html>");
const mime = await detectMime({ buffer: buf, filePath: "/tmp/page.htm" });
expect(mime).toBe("text/html");
});
it("detects XML files by extension", async () => {
const mime = await detectMime({ filePath: "/tmp/data.xml" });
expect(mime).toBe("text/xml");
});
it("detects CSS files by extension", async () => {
const mime = await detectMime({ filePath: "/tmp/style.css" });
expect(mime).toBe("text/css");
const buffer = Buffer.from([0x00, 0x00, 0x01, 0xba, 0x00, 0x00, 0x00, 0x00]);
expect(await detectMime({ buffer, headerMime: "audio/mpeg" })).toBe("video/mpeg");
});
it("detects MIME types from encoded URL extensions", async () => {
const mime = await detectMime({
filePath: "https://cdn.example.com/render%2Emp4?download=1#preview",
});
expect(mime).toBe("video/mp4");
});
it("detects AAC from a bare filename when buffer sniffing is inconclusive", async () => {
const mime = await detectMime({ buffer: Buffer.alloc(16), filePath: "voice.aac" });
expect(mime).toBe("audio/aac");
expect(
await detectMime({ filePath: "https://cdn.example.com/render%2Emp4?download=1#preview" }),
).toBe("video/mp4");
});
it.each([
{ form: "AIFF", fileName: "voice.aiff" },
{ form: "AIFC", fileName: "voice.aifc" },
])("detects $form audio from its authentic container signature", async ({ form, fileName }) => {
["AIFF", "voice.aiff"],
["AIFC", "voice.aifc"],
])("detects %s audio from its authentic container signature", async (form, filePath) => {
const buffer = Buffer.alloc(64);
buffer.write("FORM", 0, "ascii");
buffer.writeUInt32BE(buffer.length - 8, 4);
buffer.write(form, 8, "ascii");
expect(await detectMime({ buffer, filePath: fileName })).toBe("audio/aiff");
expect(await detectMime({ buffer, filePath })).toBe("audio/aiff");
});
it("detects Apple CAF audio by magic bytes when file-type does not recognize the container", async () => {
// CAF files start with the four-byte ASCII tag "caff". `file-type` v22 has
// no native CAF detector, so without the manual magic-byte fallback the
// host-local-media validator drops `afconvert`-produced voice-memo CAFs as
// unknown binary blobs. Regression guard for the iMessage voice-memo
// pre-transcode path.
const buf = Buffer.concat([Buffer.from("caff", "ascii"), Buffer.alloc(60)]);
const mime = await detectMime({ buffer: buf });
expect(mime).toBe("audio/x-caf");
});
it("returns audio/x-caf when extension and CAF magic bytes both agree", async () => {
const buf = Buffer.concat([Buffer.from("caff", "ascii"), Buffer.alloc(60)]);
const mime = await detectMime({ buffer: buf, filePath: "/tmp/voice.caf" });
expect(mime).toBe("audio/x-caf");
it("detects CAF voice memos by magic bytes without file-type support", async () => {
const buffer = Buffer.concat([Buffer.from("caff", "ascii"), Buffer.alloc(60)]);
expect(await detectMime({ buffer })).toBe("audio/x-caf");
});
it("caps dependency sniffing to a bounded prefix", () => {
const small = Buffer.alloc(32);
const large = Buffer.alloc(FILE_TYPE_SNIFF_MAX_BYTES + 16);
expect(sliceMimeSniffBuffer(small)).toBe(small);
expect(sliceMimeSniffBuffer(large)).toHaveLength(FILE_TYPE_SNIFF_MAX_BYTES);
});
@ -388,205 +195,106 @@ describe("mime detection", () => {
describe("getFileExtension", () => {
it.each([
{ filePath: "https://cdn.example.com/render.mp4", expected: ".mp4" },
{ filePath: "https://cdn.example.com/render.mp4/", expected: undefined },
{
filePath: "https://cdn.example.com/render.mp4%2Fpreview",
expected: ".mp4%2fpreview",
},
{
filePath: "https://cdn.example.com/render.mp4%5Cpreview",
expected: ".mp4%5cpreview",
},
{ filePath: "https://cdn.example.com/bad%ZZ%2Emp4", expected: undefined },
{ filePath: "https://cdn.example.com/render%2Emp4/", expected: undefined },
{ filePath: String.raw`C:\media\clip.MP4`, expected: ".mp4" },
{ filePath: String.raw`C:\media.folder\clip`, expected: undefined },
{ filePath: String.raw`C:\media.folder\clip.MP4`, expected: ".mp4" },
] as const)("extracts $expected from $filePath", ({ filePath, expected }) => {
["https://cdn.example.com/render.mp4/", undefined],
["https://cdn.example.com/render.mp4%2Fpreview", ".mp4%2fpreview"],
["https://cdn.example.com/render.mp4%5Cpreview", ".mp4%5cpreview"],
["https://cdn.example.com/bad%ZZ%2Emp4", undefined],
[String.raw`C:\media.folder\clip`, undefined],
[String.raw`C:\media.folder\clip.MP4`, ".mp4"],
])("extracts extensions from %s", (filePath, expected) => {
expect(getFileExtension(filePath)).toBe(expected);
});
});
describe("mimeTypeFromFilePath", () => {
it.each([
{ filePath: "photo.avif", expected: "image/avif" },
{ filePath: "image.bmp", expected: "image/bmp" },
{ filePath: "photo.heic", expected: "image/heic" },
{ filePath: "photo.heif", expected: "image/heif" },
{ filePath: "photo.jpg", expected: "image/jpeg" },
{ filePath: "photo.JPG", expected: "image/jpeg" },
{ filePath: "voice.mp3", expected: "audio/mpeg" },
{ filePath: "voice.aiff", expected: "audio/aiff" },
{ filePath: "voice.AIFF", expected: "audio/aiff" },
{ filePath: "voice.aif", expected: "audio/aiff" },
{ filePath: "voice.AIF", expected: "audio/aiff" },
{ filePath: "voice.aifc", expected: "audio/aiff" },
{ filePath: "voice.AIFC", expected: "audio/aiff" },
{ filePath: "voice.m2a", expected: "audio/mpeg" },
{ filePath: "audiobook.m4b", expected: "audio/mp4" },
{ filePath: "voice.oga", expected: "audio/ogg" },
{ filePath: "voice.amr", expected: "audio/amr" },
{ filePath: "voice.wav", expected: "audio/wav" },
{ filePath: "clip.avi", expected: "video/x-msvideo" },
{ filePath: "clip.m4v", expected: "video/x-m4v" },
{ filePath: "clip.mkv", expected: "video/x-matroska" },
{ filePath: "clip.webm", expected: "video/webm" },
{
filePath: "https://cdn.example.com/render%2Emp4?download=1#preview",
expected: "video/mp4",
},
{ filePath: "https://cdn.example.com/render%2Em%70%34", expected: "video/mp4" },
{ filePath: "https://cdn.example.com/render%2EMP4", expected: "video/mp4" },
{ filePath: "https://cdn.example.com/clip%2Ewebm", expected: "video/webm" },
{ filePath: "https://cdn.example.com/bad%ZZ/render%2Emp4", expected: "video/mp4" },
{ filePath: "https://cdn.example.com/archive%2Fclip.mp4", expected: "video/mp4" },
{ filePath: "https://cdn.example.com/archive%2Fclip%2Emp4", expected: "video/mp4" },
{ filePath: "https://cdn.example.com/archive%5Cclip%2Emp4", expected: "video/mp4" },
{ filePath: "https://cdn.example.com/render.mp4%2Fpreview", expected: undefined },
{ filePath: "https://cdn.example.com/render.mp4%5Cpreview", expected: undefined },
{ filePath: "clip.flv", expected: "video/x-flv" },
{ filePath: "clip.wmv", expected: "video/x-ms-wmv" },
{ filePath: "https://cdn.example.com/bad%E0%A4%A%2Emp4", expected: undefined },
{ filePath: "debug.log", expected: "text/plain" },
{ filePath: "config.yml", expected: "application/yaml" },
{ filePath: "config.yaml", expected: "application/yaml" },
{ filePath: "page.xml", expected: "text/xml" },
{ filePath: "unknown.bin", expected: undefined },
] as const)("maps $filePath", ({ filePath, expected }) => {
["photo.JPG", "image/jpeg"],
["voice.mp3", "audio/mpeg"],
["voice.AIFF", "audio/aiff"],
["voice.aif", "audio/aiff"],
["voice.aifc", "audio/aiff"],
["voice.m2a", "audio/mpeg"],
["voice.oga", "audio/ogg"],
["voice.wav", "audio/wav"],
["clip.avi", "video/x-msvideo"],
["clip.mkv", "video/x-matroska"],
["clip.webm", "video/webm"],
["https://cdn.example.com/render%2Em%70%34", "video/mp4"],
["https://cdn.example.com/bad%ZZ/render%2Emp4", "video/mp4"],
["https://cdn.example.com/archive%2Fclip%2Emp4", "video/mp4"],
["https://cdn.example.com/archive%5Cclip%2Emp4", "video/mp4"],
["https://cdn.example.com/render.mp4%2Fpreview", undefined],
["https://cdn.example.com/render.mp4%5Cpreview", undefined],
["https://cdn.example.com/bad%E0%A4%A%2Emp4", undefined],
["unknown.bin", undefined],
])("maps %s", (filePath, expected) => {
expect(mimeTypeFromFilePath(filePath)).toBe(expected);
});
});
describe("extensionForMime", () => {
it.each([
{ mime: "image/avif", expected: ".avif" },
{ mime: "image/jpeg", expected: ".jpg" },
{ mime: "image/jpg", expected: ".jpg" },
{ mime: "image/bmp", expected: ".bmp" },
{ mime: "image/png", expected: ".png" },
{ mime: "image/svg+xml", expected: ".svg" },
{ mime: "image/webp", expected: ".webp" },
{ mime: "image/gif", expected: ".gif" },
{ mime: "image/heic", expected: ".heic" },
{ mime: "image/heic-sequence", expected: ".heic" },
{ mime: "image/heif", expected: ".heif" },
{ mime: "image/heif-sequence", expected: ".heif" },
{ mime: "audio/aiff", expected: ".aiff" },
{ mime: "audio/x-aiff", expected: ".aiff" },
{ mime: "Audio/AIFF", expected: ".aiff" },
{ mime: "AUDIO/X-AIFF; codecs=pcm", expected: ".aiff" },
{ mime: "audio/mpeg", expected: ".mp3" },
{ mime: "audio/mp3", expected: ".mp3" },
{ mime: "audio/ogg", expected: ".ogg" },
{ mime: "audio/amr", expected: ".amr" },
{ mime: "audio/x-wav", expected: ".wav" },
{ mime: "audio/webm", expected: ".webm" },
{ mime: "audio/x-m4a", expected: ".m4a" },
{ mime: "audio/m4a", expected: ".m4a" },
{ mime: "audio/mp4", expected: ".m4a" },
{ mime: "video/x-msvideo", expected: ".avi" },
{ mime: "video/vnd.avi", expected: ".avi" },
{ mime: " VIDEO/VND.AVI; codec=DIVX ", expected: ".avi" },
{ mime: "video/x-m4v", expected: ".m4v" },
{ mime: "video/mp4", expected: ".mp4" },
{ mime: "video/x-matroska", expected: ".mkv" },
{ mime: "video/webm", expected: ".webm" },
{ mime: "video/x-flv", expected: ".flv" },
{ mime: "video/x-ms-wmv", expected: ".wmv" },
{ mime: "video/quicktime", expected: ".mov" },
{ mime: "application/pdf", expected: ".pdf" },
{ mime: "application/yaml", expected: ".yaml" },
{ mime: "text/plain", expected: ".txt" },
{ mime: "text/markdown", expected: ".md" },
{ mime: "text/html", expected: ".html" },
{ mime: "text/xml", expected: ".xml" },
{ mime: "text/css", expected: ".css" },
{ mime: "application/xml", expected: ".xml" },
{ mime: "IMAGE/JPEG", expected: ".jpg" },
{ mime: "Audio/X-M4A", expected: ".m4a" },
{ mime: "Video/QuickTime", expected: ".mov" },
{ mime: "video/unknown", expected: undefined },
{ mime: "application/x-custom", expected: undefined },
{ mime: null, expected: undefined },
{ mime: undefined, expected: undefined },
] as const)("maps $mime to extension", ({ mime, expected }) => {
["image/jpeg", ".jpg"],
["image/jpg", ".jpg"],
["image/heic-sequence", ".heic"],
["image/heif-sequence", ".heif"],
["audio/aiff", ".aiff"],
["AUDIO/X-AIFF; codecs=pcm", ".aiff"],
["audio/mpeg", ".mp3"],
["audio/mp3", ".mp3"],
["audio/x-wav", ".wav"],
["audio/x-m4a", ".m4a"],
["audio/m4a", ".m4a"],
["audio/mp4", ".m4a"],
[" VIDEO/VND.AVI; codec=DIVX ", ".avi"],
["application/xml", ".xml"],
["video/unknown", undefined],
[undefined, undefined],
])("maps %s to extension", (mime, expected) => {
expect(extensionForMime(mime)).toBe(expected);
});
});
describe("isAudioFileName", () => {
it.each([
{ fileName: "audiobook.M4B", expected: true },
{ fileName: "voice.mp3", expected: true },
{ fileName: "voice.aiff", expected: true },
{ fileName: "voice.AIFF", expected: true },
{ fileName: "voice.aif", expected: true },
{ fileName: "voice.AIF", expected: true },
{ fileName: "voice.aifc", expected: true },
{ fileName: "voice.AIFC", expected: true },
{ fileName: "voice.caf", expected: true },
{ fileName: "voice.M2A", expected: true },
{ fileName: "voice.oga", expected: true },
{ fileName: "voice.webm", expected: false },
{ fileName: "voice.bin", expected: false },
] as const)("matches audio extension for $fileName", ({ fileName, expected }) => {
["audiobook.M4B", true],
["voice.caf", true],
["voice.webm", false],
["voice.bin", false],
] as const)("matches audio extension for %s", (fileName, expected) => {
expect(isAudioFileName(fileName)).toBe(expected);
});
});
describe("isGifMedia", () => {
it.each([
{
opts: { contentType: "image/gif; charset=binary" },
expected: true,
},
{
opts: { contentType: " IMAGE/GIF " },
expected: true,
},
{
opts: { contentType: "image/png" },
expected: false,
},
{
opts: { fileName: "animation.GIF" },
expected: true,
},
] as const)("detects GIF media from normalized metadata %#", ({ opts, expected }) => {
[{ contentType: " IMAGE/GIF; charset=binary " }, true],
[{ contentType: "image/png" }, false],
[{ fileName: "animation.GIF" }, true],
] as const)("detects GIF media from normalized metadata %j", (opts, expected) => {
expect(isGifMedia(opts)).toBe(expected);
});
});
describe("normalizeMimeType", () => {
it.each([
{ input: "Audio/MP4; codecs=mp4a.40.2", expected: "audio/mp4" },
{ input: "image/apng", expected: "image/png" },
{ input: " ", expected: undefined },
{ input: null, expected: undefined },
{ input: undefined, expected: undefined },
] as const)("normalizes $input", ({ input, expected }) => {
["Audio/MP4; codecs=mp4a.40.2", "audio/mp4"],
["image/apng", "image/png"],
[" ", undefined],
[undefined, undefined],
])("normalizes %s", (input, expected) => {
expect(normalizeMimeType(input)).toBe(expected);
});
});
describe("prototype-named mime keys", () => {
// Remote senders control Content-Type headers; object-literal lookups must
// not resolve inherited Object.prototype members or downstream string ops throw.
it.each([
{ input: "__proto__", expected: "__proto__" },
{ input: "constructor", expected: "constructor" },
] as const)("normalizeMimeType($input) stays a plain string", ({ input, expected }) => {
expect(normalizeMimeType(input)).toBe(expected);
// Untrusted headers must not resolve inherited Object.prototype members.
it.each(["__proto__", "constructor"])("normalizeMimeType(%s) stays a plain string", (input) => {
expect(normalizeMimeType(input)).toBe(input);
});
it.each(["__proto__", "constructor"])("kindFromMime(%s) returns undefined", (input) => {
expect(kindFromMime(input)).toBeUndefined();
});
it.each(["__proto__", "constructor"])(
"kindFromMime(%s) returns undefined, not a throw",
(input) => {
expect(kindFromMime(input)).toBeUndefined();
},
);
it.each(["__proto__", "constructor"])("extensionForMime(%s) returns undefined", (input) => {
expect(extensionForMime(input)).toBeUndefined();
});
@ -594,21 +302,18 @@ describe("prototype-named mime keys", () => {
describe("mediaKindFromMime", () => {
it.each([
{ mime: "text/plain", expected: "document" },
{ mime: "text/csv", expected: "document" },
{ mime: "text/html; charset=utf-8", expected: "document" },
{ mime: "model/gltf+json", expected: undefined },
{ mime: null, expected: undefined },
{ mime: undefined, expected: undefined },
] as const)("classifies $mime", ({ mime, expected }) => {
["text/html; charset=utf-8", "document"],
["model/gltf+json", undefined],
[undefined, undefined],
])("classifies %s", (mime, expected) => {
expect(mediaKindFromMime(mime)).toBe(expected);
});
it.each([
{ mime: " Audio/Ogg; codecs=opus ", expected: "audio" },
{ mime: undefined, expected: undefined },
{ mime: "model/gltf+json", expected: undefined },
] as const)("maps kindFromMime($mime) => $expected", ({ mime, expected }) => {
[" Audio/Ogg; codecs=opus ", "audio"],
[undefined, undefined],
["model/gltf+json", undefined],
])("maps kindFromMime(%s) => %s", (mime, expected) => {
expect(kindFromMime(mime)).toBe(expected);
});
});

View file

@ -1,4 +1,3 @@
// Normalization Core tests cover boolean coerce behavior.
import { parseBoolean } from "@openclaw/normalization-core/boolean-coercion";
import { describe, expect, it } from "vitest";
@ -6,17 +5,13 @@ describe("normalization-core/boolean-coercion", () => {
it.each([
[true, true],
[false, false],
["true", true],
[" FALSE ", false],
["TrUe", true],
])("parses %j as %s", (value, expected) => {
expect(parseBoolean(value)).toBe(expected);
});
it.each([undefined, null, 0, 1, "", "yes", "no", "on", "off", "1", "0"])(
"rejects unsupported value %j",
(value) => {
expect(parseBoolean(value)).toBeUndefined();
},
);
it.each([undefined, 1, "yes", "1"])("rejects unsupported value %j", (value) => {
expect(parseBoolean(value)).toBeUndefined();
});
});

View file

@ -9,7 +9,6 @@ describe("normalization-core/cjk-chars", () => {
it("keeps Latin text on the regular chars-per-token heuristic", () => {
expect(estimateStringChars("")).toBe(0);
expect(estimateStringChars("hello world")).toBe(11);
expect(estimateStringChars("123.45, hello! @#$%")).toBe(19);
});
it("weights common CJK text as roughly one token per character", () => {
@ -19,12 +18,9 @@ describe("normalization-core/cjk-chars", () => {
it.each([
["hiragana", "こんにちは", 20],
["katakana", "カタカナ", 16],
["Hangul", "안녕하세요", 20],
["fullwidth letters and numbers", "ABC123", 24],
["fullwidth punctuation with Latin text", "hello,world", 14],
["mixed BMP and supplementary CJK", "你𠀀好", 24],
["mixed CJK and emoji", "你😀", 6],
])("weights %s", (_label, text, expected) => {
expect(estimateStringChars(text)).toBe(expected);
});
@ -65,7 +61,7 @@ describe("normalization-core/cjk-chars", () => {
},
);
it.each([0x16fe3, 0x1aff0, 0x1b001, 0x1b11f, 0x1b132, 0x1f200])(
it.each([0x16fe3, 0x1aff0, 0x1b001, 0x1f200])(
"uses a conservative supplementary-CJK weight for U+%s",
(codePoint) => {
expect(estimateStringChars(String.fromCodePoint(codePoint))).toBe(
@ -75,7 +71,6 @@ describe("normalization-core/cjk-chars", () => {
);
it("covers CJK script-extension marks with measured weights", () => {
expect(estimateStringChars(String.fromCodePoint(0x00b7))).toBe(CHARS_PER_TOKEN_ESTIMATE);
expect(estimateStringChars("·".repeat(32))).toBe(32 * CHARS_PER_TOKEN_ESTIMATE);
expect(estimateStringChars(String.fromCodePoint(0x02ca))).toBe(CHARS_PER_TOKEN_ESTIMATE * 2);
expect(estimateStringChars(String.fromCodePoint(0xa700))).toBe(CHARS_PER_TOKEN_ESTIMATE * 3);
@ -87,10 +82,7 @@ describe("normalization-core/cjk-chars", () => {
});
it.each([
["\ud800", 1],
["\udfff", 1],
["\ud800a\udfff", 3],
["\u{1D360}\u{20000}", 28],
["\u{20000}\u{20000}", 32],
["\u{3347F}\u{33480}\u{10FFFF}", 20],
["\ud800\ud800\udc00", 3],

View file

@ -4,11 +4,7 @@ import { truncateCodePoints } from "./code-points.js";
const samples = [
["empty", ""],
["ASCII", "ordinary text ".repeat(128)],
["CJK", "中文かな한글".repeat(128)],
["astral", "a🦞b🙂".repeat(128)],
["combining marks", "é".repeat(128)],
["ZWJ families", "👨‍👩‍👧‍👦".repeat(64)],
["flags", "🇹🇼🇯🇵".repeat(64)],
["lone surrogates", "\ud83da\ude00b😀\ud83d"],
] as const;

View file

@ -1,4 +1,3 @@
// Normalization core tests cover shared error coercion and formatting behavior.
import { runInNewContext } from "node:vm";
import { describe, expect, it, vi } from "vitest";
import {
@ -37,7 +36,7 @@ describe("formatErrorMessage", () => {
expect(redact).toHaveBeenCalledOnce();
});
it.each([0, false, null, undefined])("retains a downlevel suppressed value %s", (suppressed) => {
it.each([0, null, undefined])("retains a downlevel suppressed value %s", (suppressed) => {
const failure = Object.assign(new Error("disposal failed"), {
name: "SuppressedError",
error: new Error("cleanup failed"),
@ -48,16 +47,9 @@ describe("formatErrorMessage", () => {
});
it.each(
["native", "vm", "tagged"].flatMap((kind) =>
["message", "name"].map((field) => ({ kind, field })),
),
["native", "vm"].flatMap((kind) => ["message", "name"].map((field) => ({ kind, field }))),
)("isolates inaccessible $field on $kind errors", ({ kind, field }) => {
const error: unknown =
kind === "vm"
? runInNewContext("new Error('')")
: kind === "native"
? new Error("")
: { [Symbol.toStringTag]: "Error" };
const error: unknown = kind === "vm" ? runInNewContext("new Error('')") : new Error("");
Object.defineProperty(error, field, {
get() {
throw new Error("diagnostic field unavailable");
@ -162,11 +154,7 @@ describe("formatErrorMessage", () => {
expect(format(new Error("request failed", { cause: { status: 429 } }))).toBe(
"request failed | status=429 code=unknown",
);
// A non-Error cause carrying recognized status/code fields alongside extra
// keys used to be dropped entirely: formatStatusAndCode returns undefined
// for any object with keys beyond status/code, and the cause-chain branch
// had no stringifyUnknown fallback (unlike the top-level branch). The
// structured detail now survives instead of being swallowed.
// Extra fields must survive when status/code formatting declines the cause.
expect(format(new Error("request failed", { cause: { statusCode: 429 } }))).toBe(
'request failed | {"statusCode":429}',
);
@ -188,10 +176,6 @@ describe("formatErrorMessage", () => {
expect(format(123n)).toBe("123");
expect(format(circular)).toBe("[object Object]");
});
it("requires an owner-supplied redactor", () => {
expect(formatErrorMessage("sensitive", { redact: () => "redacted" })).toBe("redacted");
});
});
describe("collectNestedErrorCandidates", () => {
@ -222,7 +206,7 @@ describe("toErrorObject", () => {
it("preserves structured details from non-Error objects", () => {
const value = { code: "EPIPE", status: 500 };
const error = toErrorObject(value, "request failed") as Error & typeof value;
const error = toErrorObject(value, "request failed");
expect(error).toMatchObject({ message: "request failed", code: "EPIPE", status: 500 });
expect(error.cause).toBe(value);
@ -352,26 +336,19 @@ describe("toStructuredErrorObject", () => {
it("protects Error-owned and prototype-mutating fields without reading them", () => {
let protectedReads = 0;
const cause = {
get name() {
protectedReads += 1;
return "SpoofedError";
},
get message() {
protectedReads += 1;
return "spoofed message";
},
get cause() {
protectedReads += 1;
return "spoofed cause";
},
get stack() {
protectedReads += 1;
return "spoofed stack";
},
constructor: { polluted: true },
prototype: { polluted: true },
code: "EIO",
};
for (const key of ["name", "message", "cause", "stack"]) {
Object.defineProperty(cause, key, {
enumerable: true,
get() {
protectedReads += 1;
return "spoofed";
},
});
}
Object.defineProperty(cause, "__proto__", {
value: { polluted: true },
enumerable: true,

View file

@ -12,7 +12,6 @@ describe("json-coercion", () => {
});
it.each<[string, unknown]>([
['{"ok":true}', { ok: true }],
["[1]", [1]],
['"text"', "text"],
["null", null],
@ -26,7 +25,6 @@ describe("json-coercion", () => {
});
it.each([
{ name: "an object", value: '{"ok":true}', expected: { ok: true } },
{
name: "JSON whitespace before an object",
value: ' \t\r\n{"ok":true}',
@ -40,7 +38,6 @@ describe("json-coercion", () => {
{ name: "a BOM before an object", value: '\ufeff{"ok":true}', expected: undefined },
{ name: "null", value: "null", expected: undefined },
{ name: "an array", value: "[1]", expected: undefined },
{ name: "a scalar", value: '"text"', expected: undefined },
{ name: "malformed JSON", value: "{", expected: undefined },
{
name: "an own __proto__ data key",

View file

@ -23,7 +23,6 @@ describe("flattenMarkdownToPlainText", () => {
"Use foo_bar_baz from ~/.openclaw",
],
["multiline whitespace", "First\n\n second\t third", "First second third"],
["plain text", "Already plain text.", "Already plain text."],
])("flattens %s", (_label, input, expected) => {
expect(flattenMarkdownToPlainText(input)).toBe(expected);
});

View file

@ -1,7 +1,5 @@
// Normalization Core tests cover number coercion behavior.
import { describe, expect, test } from "vitest";
import {
asDateTimestampMs,
asFiniteNumber,
asFiniteNumberInRange,
asNonNegativeFiniteNumber,
@ -141,11 +139,6 @@ describe("number-coercion", () => {
});
test("timestamp ISO helper rejects Date-invalid timestamps", () => {
expect(asDateTimestampMs(0)).toBe(0);
expect(asDateTimestampMs(8_640_000_000_000_000)).toBe(8_640_000_000_000_000);
expect(asDateTimestampMs(8_640_000_000_000_001)).toBeUndefined();
expect(asDateTimestampMs(Number.POSITIVE_INFINITY)).toBeUndefined();
expect(asDateTimestampMs("0")).toBeUndefined();
expect(timestampMsToIsoString(0)).toBe("1970-01-01T00:00:00.000Z");
expect(timestampMsToIsoString(8_640_000_000_000_000)).toBe("+275760-09-13T00:00:00.000Z");
expect(timestampMsToIsoString(8_640_000_000_000_001)).toBeUndefined();
@ -155,7 +148,6 @@ describe("number-coercion", () => {
test.each([
{ value: "0", expected: Date.parse("0") },
{ value: "2026", expected: Date.parse("2026") },
{ value: "2026-07-13T10:00:00.000Z", expected: 1_783_936_800_000 },
{ value: 0, expected: undefined },
{ value: "123ms", expected: undefined },
@ -166,9 +158,7 @@ describe("number-coercion", () => {
test.each([
{ value: 0, expected: 0 },
{ value: 1_700_000_000_000, expected: 1_700_000_000_000 },
{ value: "0", expected: Date.parse("0") },
{ value: "2026", expected: Date.parse("2026") },
{ value: Number.POSITIVE_INFINITY, expected: undefined },
{ value: MAX_DATE_TIMESTAMP_MS + 1, expected: MAX_DATE_TIMESTAMP_MS + 1 },
{ value: true, expected: undefined },
@ -179,7 +169,6 @@ describe("number-coercion", () => {
test("future timestamp helper rejects invalid Date timestamps", () => {
expect(isFutureDateTimestampMs(1_001, { nowMs: 1_000 })).toBe(true);
expect(isFutureDateTimestampMs(1_000, { nowMs: 1_000 })).toBe(false);
expect(isFutureDateTimestampMs(999, { nowMs: 1_000 })).toBe(false);
expect(isFutureDateTimestampMs(8_640_000_000_000_001, { nowMs: 1_000 })).toBe(false);
expect(isFutureDateTimestampMs(1_001, { nowMs: Number.NaN })).toBe(false);
});
@ -204,27 +193,16 @@ describe("number-coercion", () => {
});
test("expiry helpers resolve safe absolute timestamps", () => {
expect(resolveExpiresAtMsFromDurationMs(600_000, { nowMs: 1_000 })).toBe(601_000);
expect(
resolveExpiresAtMsFromDurationMs(600_000, {
nowMs: 1_000,
}),
).toBe(601_000);
expect(
resolveExpiresAtMsFromDurationMs(600_000, {
nowMs: 8_640_000_000_000_000,
}),
resolveExpiresAtMsFromDurationMs(600_000, { nowMs: 8_640_000_000_000_000 }),
).toBeUndefined();
expect(
resolveExpiresAtMsFromDurationMs(600_000, {
nowMs: 8_640_000_000_000_001,
}),
resolveExpiresAtMsFromDurationMs(600_000, { nowMs: 8_640_000_000_000_001 }),
).toBeUndefined();
expect(
resolveExpiresAtMsFromDurationSeconds("3600", {
nowMs: 1_000,
bufferMs: 300,
}),
).toBe(3_600_700);
expect(resolveExpiresAtMsFromDurationSeconds("3600", { nowMs: 1_000, bufferMs: 300 })).toBe(
3_600_700,
);
expect(
resolveExpiresAtMsFromDurationSeconds("10", {
nowMs: 1_000,
@ -233,9 +211,7 @@ describe("number-coercion", () => {
}),
).toBe(31_000);
expect(
resolveExpiresAtMsFromDurationSeconds("3600", {
nowMs: 8_640_000_000_000_000,
}),
resolveExpiresAtMsFromDurationSeconds("3600", { nowMs: 8_640_000_000_000_000 }),
).toBeUndefined();
expect(resolveExpiresAtMsFromDurationSeconds("1e309", { nowMs: 1_000 })).toBeUndefined();
expect(resolveExpiresAtMsFromEpochSeconds(1234.9)).toBe(1_234_000);
@ -260,7 +236,6 @@ describe("number-coercion", () => {
test("integer option helpers floor finite values and fall back for non-finite values", () => {
expect(resolveIntegerOption(7.9, 1, { min: 1, max: 10 })).toBe(7);
expect(resolveIntegerOption(Number.NaN, 4.9, { min: 1 })).toBe(4);
expect(resolveIntegerOption(Number.NEGATIVE_INFINITY, 4, { min: 1 })).toBe(4);
expect(resolveIntegerOption(-4, 1, { min: 0 })).toBe(0);
expect(resolveIntegerOption(40, 1, { max: 10 })).toBe(10);
expect(resolveNonNegativeIntegerOption(Number.NaN, 3.9)).toBe(3);
@ -269,7 +244,6 @@ describe("number-coercion", () => {
test("optional integer option helper rejects non-finite values", () => {
expect(resolveOptionalIntegerOption(7.9, { min: 1, max: 10 })).toBe(7);
expect(resolveOptionalIntegerOption(Number.NaN, { min: 1 })).toBeUndefined();
expect(resolveOptionalIntegerOption(Number.POSITIVE_INFINITY, { min: 1 })).toBeUndefined();
expect(resolveOptionalIntegerOption(-4, { min: 0 })).toBe(0);
expect(resolveOptionalIntegerOption(40, { max: 10 })).toBe(10);
});

View file

@ -3,7 +3,6 @@ import { formatInternationalPhoneNumberForDisplay } from "./phone-presentation.j
describe("formatInternationalPhoneNumberForDisplay", () => {
it.each([
["+4930123456", "Germany · +49 30 123456"],
[" +4930123456 ", "Germany · +49 30 123456"],
["+15551234567", "+1 555 123 4567"],
])("formats %s for display without requiring assignment validity", (raw, expected) => {
@ -12,11 +11,7 @@ describe("formatInternationalPhoneNumberForDisplay", () => {
it.each([
["NANPA US", "+12133734253", "+1 213 373 4253"],
["NANPA Canada", "+16045551234", "+1 604 555 1234"],
["NANPA toll-free", "+18005551234", "+1 800 555 1234"],
["United Kingdom", "+442079460018", "+44 20 7946 0018"],
["Finland and Åland", "+358412345678", "+358 41 2345678"],
["Australia and external territories", "+61412345678", "+61 412 345 678"],
])("does not claim a country for shared calling codes: %s", (_name, raw, expected) => {
expect(formatInternationalPhoneNumberForDisplay(raw, "en")).toBe(expected);
});
@ -29,10 +24,6 @@ describe("formatInternationalPhoneNumberForDisplay", () => {
["malformed", "+not-a-number"],
["short", "+123"],
["national", "020 7946 0018"],
["token", "bot-token"],
["JID", "15551234567@s.whatsapp.net"],
["email", "person@example.com"],
["whitespace", " "],
])("returns undefined for %s input", (_kind, raw) => {
expect(formatInternationalPhoneNumberForDisplay(raw, "en")).toBeUndefined();
});

View file

@ -1,4 +1,3 @@
// Normalization Core tests cover record coerce behavior.
import { describe, expect, it } from "vitest";
import {
asNonArrayRecord,
@ -45,7 +44,6 @@ describe("record-coerce", () => {
});
it.each([
{ value: {}, expected: true },
{ value: { first: "one", second: "two" }, expected: true },
{ value: Object.assign(Object.create(null), { first: "one" }), expected: true },
{ value: new Date(), expected: true },
@ -69,7 +67,6 @@ describe("record-coerce", () => {
it.each([
{ value: null, expected: undefined },
{ value: ["value"], expected: undefined },
{ value: {}, expected: undefined },
{ value: { count: 1, enabled: true }, expected: undefined },
{
value: inheritedAndHidden,

View file

@ -1,9 +0,0 @@
import { describe, expect, it } from "vitest";
import { err, ok } from "./result.js";
describe("Result constructors", () => {
it("creates discriminated success and failure arms", () => {
expect(ok("value")).toEqual({ ok: true, value: "value" });
expect(err("failure")).toEqual({ ok: false, error: "failure" });
});
});

View file

@ -1,7 +1,3 @@
/**
* Regression coverage for deterministic unknown-value stringification.
* Verifies sorted keys, repeated references, cycles, binary data, and errors.
*/
import { describe, expect, it } from "vitest";
import { sha256Hex, sha256StableValue } from "./node-crypto.js";
import { stableStringify, writeStableStringify } from "./stable-stringify.js";
@ -19,19 +15,9 @@ const serializers: Record<string, typeof stableStringify> = {
};
describe.each(Object.entries(serializers))("%s", (_name, serialize) => {
it.each([
['{"z":1,"a":2}', '{"a":2,"z":1}'],
[
'{"items":[3,null,{"z":false,"a":1.5}],"enabled":true}',
'{"enabled":true,"items":[3,null,{"a":1.5,"z":false}]}',
],
['["text",0,-2.5,null,false]', '["text",0,-2.5,null,false]'],
])("preserves deterministic bytes for parsed JSON %#", (json, expected) => {
expect(serialize(JSON.parse(json))).toBe(expected);
});
it("sorts object keys recursively", () => {
expect(serialize({ b: { d: 4, c: 3 }, a: 1 })).toBe('{"a":1,"b":{"c":3,"d":4}}');
it("preserves parsed JSON values and sorts keys recursively", () => {
const value = JSON.parse('{"items":[3,null,{"z":false,"a":1.5}],"enabled":true}');
expect(serialize(value)).toBe('{"enabled":true,"items":[3,null,{"a":1.5,"z":false}]}');
});
it("marks true circular references without collapsing repeated references", () => {
@ -71,11 +57,6 @@ describe.each(Object.entries(serializers))("%s", (_name, serialize) => {
it("sorts normalized keys before serializing them", () => {
const high = String.fromCharCode(0xd83d);
const malformed = { ba: 2, [`b${high}`]: 1 };
const normalized = { ba: 2, b: 1 };
expect(serialize(malformed, sanitizeSurrogates)).toBe(
serialize(normalized, sanitizeSurrogates),
);
expect(serialize(malformed, sanitizeSurrogates)).toBe('{"b":1,"ba":2}');
});

View file

@ -1,4 +1,3 @@
// Normalization Core tests cover string normalization behavior.
import { describe, expect, it } from "vitest";
import {
containsAsciiControlCharacter,
@ -10,14 +9,12 @@ import {
normalizeSortedUniqueStringEntries,
normalizeSortedUniqueTrimmedStringList,
normalizeStringEntries,
normalizeStringEntriesLower,
normalizeTrimmedStringList,
normalizeUniqueSingleOrTrimmedStringList,
normalizeUniqueStringEntries,
normalizeUniqueStringEntriesLower,
normalizeUniqueTrimmedStringList,
sortUniqueStrings,
uniqueStrings,
} from "./string-normalization.js";
describe("normalization-core/string-normalization", () => {
@ -28,13 +25,11 @@ describe("normalization-core/string-normalization", () => {
{ label: "NUL", value: `branch${String.fromCharCode(0)}name`, expected: true },
{ label: "unit separator", value: `branch${String.fromCharCode(0x1f)}`, expected: true },
{ label: "DEL", value: `branch${String.fromCharCode(0x7f)}name`, expected: true },
{ label: "line feed", value: "main\n", expected: true },
])("detects only ASCII controls: $label", ({ value, expected }) => {
expect(containsAsciiControlCharacter(value)).toBe(expected);
});
it.each([
{ value: undefined, expected: [] },
{ value: "value", expected: [] },
{ value: { 0: "value" }, expected: [] },
{
@ -55,13 +50,7 @@ describe("normalization-core/string-normalization", () => {
expect(normalizeStringEntries(undefined)).toStrictEqual([]);
});
it("normalizes mixed allow-list entries to lowercase", () => {
expect(normalizeStringEntriesLower([" A ", "MiXeD", 7])).toEqual(["a", "mixed", "7"]);
});
it.each([
{ label: "empty", values: [], expected: [] },
{ label: "duplicates", values: ["b", "a", "b"], expected: ["a", "b"] },
{
label: "case and numeric text",
values: ["a", "Z", "10", "2", "A", ""],
@ -81,10 +70,6 @@ describe("normalization-core/string-normalization", () => {
expect(sortUniqueStrings(input.values())).toEqual(expected);
});
it("deduplicates string values while preserving first-seen order", () => {
expect(uniqueStrings(["b", "a", "b", "c", "a"])).toEqual(["b", "a", "c"]);
});
it("normalizes unique string entries", () => {
expect(normalizeUniqueStringEntries([" b ", "a", "b", "", 4, "a"])).toEqual(["b", "a", "4"]);
});
@ -118,16 +103,14 @@ describe("normalization-core/string-normalization", () => {
{ value: " first, second, , first ", expected: ["first", "second", "first"] },
{ value: [" first ", 42, "", " ", 7], expected: ["first", "42", "7"] },
{ value: null, expected: [] },
{ value: { value: "first" }, expected: [] },
])("normalizes CSV or loose string-list input", ({ value, expected }) => {
expect(normalizeCsvOrLooseStringList(value)).toEqual(expected);
});
it("normalizes sorted unique trimmed string lists", () => {
expect(normalizeSortedUniqueTrimmedStringList([" b ", "a", "b", "", "a"])).toEqual(["a", "b"]);
expect(normalizeSortedUniqueTrimmedStringList(["z", 1, " a "] as unknown[])).toEqual([
expect(normalizeSortedUniqueTrimmedStringList([" b ", 1, "a", "b", "", "a"])).toEqual([
"a",
"z",
"b",
]);
});
@ -140,11 +123,8 @@ describe("normalization-core/string-normalization", () => {
});
it("normalizes slug-like labels while preserving supported symbols", () => {
expect(normalizeHyphenSlug(" Team Room ")).toBe("team-room");
expect(normalizeHyphenSlug(" #My_Channel + Alerts ")).toBe("#my_channel-+-alerts");
expect(normalizeHyphenSlug("..foo---bar..")).toBe("foo-bar");
expect(normalizeHyphenSlug(undefined)).toBe("");
expect(normalizeHyphenSlug(null)).toBe("");
});
it("collapses repeated separators and trims leading/trailing punctuation", () => {
@ -156,21 +136,14 @@ describe("normalization-core/string-normalization", () => {
expect(normalizeAtHashSlug(" #My_Channel + Alerts ")).toBe("my-channel-alerts");
expect(normalizeAtHashSlug("@@Room___Name")).toBe("room-name");
expect(normalizeAtHashSlug(undefined)).toBe("");
expect(normalizeAtHashSlug(null)).toBe("");
});
it("strips repeated prefixes and collapses separator-only results", () => {
expect(normalizeAtHashSlug("###__Room Name__")).toBe("room-name");
it("collapses separator-only results", () => {
expect(normalizeAtHashSlug("@@@___")).toBe("");
});
it.each([
["技术讨论组", "技术讨论组"],
[" AI 助手群 ", "ai-助手群"],
["友達グループ", "友達グループ"],
["개발자 모임", "개발자-모임"],
["Team 技术讨论", "team-技术讨论"],
["#OpenClaw中文群", "#openclaw中文群"],
["Команда разработки", "команда-разработки"],
["فريق التطوير", "فريق-التطوير"],
])("preserves Unicode letters in normalizeHyphenSlug: %s", (input, expected) => {
@ -180,16 +153,11 @@ describe("normalization-core/string-normalization", () => {
it.each([
["Cafe\u0301 Team", "café-team"],
["हिन्दी चर्चा", "हिन्दी-चर्चा"],
["ห้อง แช็ต", "ห้อง-แช็ต"],
])("preserves combining marks in normalizeHyphenSlug: %s", (input, expected) => {
expect(normalizeHyphenSlug(input)).toBe(expected);
});
it.each([
["#技术频道", "技术频道"],
["@中文群组", "中文群组"],
["#日本語チャンネル", "日本語チャンネル"],
["#한국어채널", "한국어채널"],
["#Команда разработки", "команда-разработки"],
["@فريق التطوير", "فريق-التطوير"],
["#OpenClaw中文群", "openclaw中文群"],
@ -200,7 +168,6 @@ describe("normalization-core/string-normalization", () => {
it.each([
["#Cafe\u0301_Team", "café-team"],
["@हिन्दी चर्चा", "हिन्दी-चर्चा"],
["#ห้อง แช็ต", "ห้อง-แช็ต"],
])("preserves combining marks in normalizeAtHashSlug: %s", (input, expected) => {
expect(normalizeAtHashSlug(input)).toBe(expected);
});

View file

@ -1,4 +1,3 @@
// Tests for surrogate-safe UTF-16 string slicing helpers.
import { describe, expect, it } from "vitest";
import { findGraphemeChunkEnd } from "./grapheme.js";
import {
@ -26,7 +25,6 @@ describe("avoidTrailingHighSurrogateBreak", () => {
describe("sliceUtf16Safe", () => {
it.each<[string, Parameters<typeof sliceUtf16Safe>, string]>([
["slices ASCII string normally", ["hello world", 0, 5], "hello"],
["handles negative start", ["hello world", -5], "world"],
["handles negative end", ["hello world", 0, -6], "hello"],
["handles start beyond length", ["hello", 10], ""],
@ -35,7 +33,6 @@ describe("sliceUtf16Safe", () => {
["preserves emoji with surrogate pairs", ["👨‍👩‍👧‍👦", 0], "👨‍👩‍👧‍👦"],
["returns empty string when slicing middle of surrogate pair", ["👨👩", 1, 3], ""],
["returns empty string when slicing at start of surrogate pair", ["👨👩", 0, 1], ""],
["handles empty string", ["", 0], ""],
["handles undefined end", ["hello", 2], "llo"],
])("%s", (_name, args, expected) => {
expect(sliceUtf16Safe(...args)).toBe(expected);
@ -45,7 +42,6 @@ describe("sliceUtf16Safe", () => {
describe("truncateUtf16Safe", () => {
it.each<[string, Parameters<typeof truncateUtf16Safe>, string]>([
["returns input when shorter than limit", ["hello", 10], "hello"],
["truncates when longer than limit", ["hello world", 5], "hello"],
["handles zero limit", ["hello", 0], ""],
["handles negative limit", ["hello", -1], ""],
["floors decimal limit", ["hello world", 5.7], "hello"],
@ -56,51 +52,39 @@ describe("truncateUtf16Safe", () => {
});
describe("truncateWithMarker", () => {
it.each([
{
name: "returns values at the boundary unchanged",
value: "hello",
max: 5,
options: { marker: "...", reserve: 3, trimEnd: false },
expected: "hello",
},
{
name: "reserves marker width",
value: "hello world",
max: 8,
options: { marker: "...", reserve: 3, trimEnd: false },
expected: "hello...",
},
{
name: "supports markers outside the limit",
value: "hello world",
max: 5,
options: { marker: "...", reserve: 0, trimEnd: false },
expected: "hello...",
},
{
name: "trims only the truncated prefix",
value: "hello world",
max: 9,
options: { marker: "...", reserve: 3, trimEnd: true },
expected: "hello...",
},
{
name: "keeps surrogate pairs well formed",
value: "ab🚀tail",
max: 4,
options: { marker: "…", reserve: 1, trimEnd: false },
expected: "ab…",
},
{
name: "preserves marker output at zero limits",
value: "hello",
max: 0,
options: { marker: "…", reserve: 1, trimEnd: false },
expected: "…",
},
] as const)("$name", ({ value, max, options, expected }) => {
expect(truncateWithMarker(value, max, options)).toBe(expected);
it.each<[string, Parameters<typeof truncateWithMarker>, string]>([
[
"keeps the boundary unchanged",
["hello", 5, { marker: "...", reserve: 3, trimEnd: false }],
"hello",
],
[
"reserves marker width",
["hello world", 8, { marker: "...", reserve: 3, trimEnd: false }],
"hello...",
],
[
"supports markers outside the limit",
["hello world", 5, { marker: "...", reserve: 0, trimEnd: false }],
"hello...",
],
[
"trims only the prefix",
["hello world", 9, { marker: "...", reserve: 3, trimEnd: true }],
"hello...",
],
[
"keeps surrogate pairs whole",
["ab🚀tail", 4, { marker: "…", reserve: 1, trimEnd: false }],
"ab…",
],
[
"keeps the marker at zero limits",
["hello", 0, { marker: "…", reserve: 1, trimEnd: false }],
"…",
],
])("%s", (_name, args, expected) => {
expect(truncateWithMarker(...args)).toBe(expected);
});
});

View file

@ -1,23 +1,13 @@
// Link detection tests cover ordering, dedupe, markdown suppression, and SSRF hostname filtering.
import { describe, expect, it } from "vitest";
import { extractLinksFromMessage } from "./detect.js";
describe("extractLinksFromMessage", () => {
it("extracts bare http/https URLs in order", () => {
const links = extractLinksFromMessage("see https://a.example and http://b.test");
expect(links).toEqual(["https://a.example", "http://b.test"]);
});
it("dedupes links and enforces maxLinks", () => {
const links = extractLinksFromMessage("https://a.example https://a.example https://b.test", {
maxLinks: 1,
});
expect(links).toEqual(["https://a.example"]);
});
it("ignores markdown links", () => {
const links = extractLinksFromMessage("[doc](https://docs.example) https://bare.example");
expect(links).toEqual(["https://bare.example"]);
const links = extractLinksFromMessage(
"https://a.example https://a.example https://b.test https://c.test",
{ maxLinks: 2 },
);
expect(links).toEqual(["https://a.example", "https://b.test"]);
});
it("ignores markdown links whose label contains brackets", () => {
@ -30,9 +20,6 @@ describe("extractLinksFromMessage", () => {
});
it.each([
["double-quoted title", '[doc](https://docs.example "Docs")'],
["single-quoted title", "[doc](https://docs.example 'Docs')"],
["parenthesized title", "[doc](https://docs.example (Docs))"],
["escaped double quote", '[doc](https://docs.example "A \\"quoted\\" title")'],
["escaped single quote", "[doc](https://docs.example 'A \\'quoted\\' title')"],
["escaped parenthesis", "[doc](https://docs.example (a \\(paren\\) title))"],
@ -46,11 +33,10 @@ describe("extractLinksFromMessage", () => {
]);
});
it.each([
["unterminated title", '[doc](https://docs.example "Docs)'],
["escaped closing delimiter", '[doc](https://docs.example "t\\")'],
])("does not strip a link with an %s", (_name, message) => {
expect(extractLinksFromMessage(message)).toStrictEqual(["https://docs.example"]);
it("does not strip a link with an escaped closing delimiter", () => {
expect(extractLinksFromMessage('[doc](https://docs.example "t\\")')).toStrictEqual([
"https://docs.example",
]);
});
it("blocks 127.0.0.1", () => {
@ -58,47 +44,9 @@ describe("extractLinksFromMessage", () => {
expect(links).toEqual(["https://ok.test"]);
});
it("blocks localhost and common loopback addresses", () => {
expect(extractLinksFromMessage("http://localhost/secret")).toStrictEqual([]);
expect(extractLinksFromMessage("http://localhost.localdomain/secret")).toStrictEqual([]);
expect(extractLinksFromMessage("http://foo.localhost/secret")).toStrictEqual([]);
expect(extractLinksFromMessage("http://service.local/secret")).toStrictEqual([]);
expect(extractLinksFromMessage("http://service.internal/secret")).toStrictEqual([]);
expect(extractLinksFromMessage("http://0.0.0.0/secret")).toStrictEqual([]);
expect(extractLinksFromMessage("http://[::1]/secret")).toStrictEqual([]);
});
it("blocks private network ranges", () => {
expect(extractLinksFromMessage("http://10.0.0.1/internal")).toStrictEqual([]);
expect(extractLinksFromMessage("http://172.16.0.1/internal")).toStrictEqual([]);
expect(extractLinksFromMessage("http://192.168.1.1/internal")).toStrictEqual([]);
});
it("blocks link-local and cloud metadata addresses", () => {
expect(extractLinksFromMessage("http://169.254.169.254/latest/meta-data/")).toStrictEqual([]);
expect(extractLinksFromMessage("http://169.254.1.1/test")).toStrictEqual([]);
expect(extractLinksFromMessage("http://metadata.google.internal/computeMetadata/v1/")).toEqual(
[],
);
});
it("blocks CGNAT range used by Tailscale", () => {
expect(extractLinksFromMessage("http://100.100.50.1/test")).toStrictEqual([]);
});
it("blocks private and mapped IPv6 addresses", () => {
expect(extractLinksFromMessage("http://[::ffff:127.0.0.1]/secret")).toStrictEqual([]);
it("blocks private IPv4 embedded in an ISATAP URL", () => {
expect(extractLinksFromMessage("http://[2001:db8:1234::5efe:127.0.0.1]/secret")).toStrictEqual(
[],
);
expect(extractLinksFromMessage("http://[fe80::1]/secret")).toStrictEqual([]);
expect(extractLinksFromMessage("http://[fc00::1]/secret")).toStrictEqual([]);
});
it("allows legitimate public URLs", () => {
expect(extractLinksFromMessage("https://example.com/page")).toEqual([
"https://example.com/page",
]);
expect(extractLinksFromMessage("https://8.8.8.8/dns")).toEqual(["https://8.8.8.8/dns"]);
});
});

View file

@ -1,8 +1,7 @@
// Link-understanding runner tests cover guarded fetches, command execution, scoping, and template behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../auto-reply/templating.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { LinkModelConfig } from "../config/types.tools.js";
import type { LinkModelConfig, LinkToolsConfig } from "../config/types.tools.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { applyLinkUnderstanding } from "./apply.js";
@ -13,37 +12,33 @@ const mocks = vi.hoisted(() => ({
runCommandWithTimeout: vi.fn(),
}));
vi.mock("../infra/net/fetch-guard.js", async () => {
const actual = await vi.importActual<typeof import("../infra/net/fetch-guard.js")>(
"../infra/net/fetch-guard.js",
);
return {
...actual,
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
};
});
vi.mock("../infra/net/fetch-guard.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/net/fetch-guard.js")>()),
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
}));
vi.mock("../process/exec.js", async () => {
const actual = await vi.importActual<typeof import("../process/exec.js")>("../process/exec.js");
return {
...actual,
runCommandWithTimeout: mocks.runCommandWithTimeout,
};
});
vi.mock("../process/exec.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../process/exec.js")>()),
runCommandWithTimeout: mocks.runCommandWithTimeout,
}));
function cfg(entry: LinkModelConfig) {
function cfg(
entry: LinkModelConfig | LinkModelConfig[] = { type: "cli", command: "summarize" },
options: Omit<LinkToolsConfig, "models"> = {},
): OpenClawConfig {
return {
tools: {
links: {
enabled: true,
models: [entry],
...options,
models: Array.isArray(entry) ? entry : [entry],
},
},
} as OpenClawConfig;
};
}
function ctx(body: string): MsgContext {
return { Body: body } as MsgContext;
function ctx(body = "see https://example.com/page"): MsgContext {
return { Body: body };
}
function mockGuardedFetch(body = "guarded content", finalUrl = "https://example.com/final") {
@ -75,30 +70,24 @@ describe("runLinkUnderstanding", () => {
it("applies shared media scope rules to link message context", async () => {
const result = await runLinkUnderstanding({
cfg: {
tools: {
links: {
enabled: true,
scope: {
default: "allow",
rules: [
{
action: "deny",
match: { channel: "slack", chatType: "channel", keyPrefix: "agent:main:" },
},
],
cfg: cfg(undefined, {
scope: {
default: "allow",
rules: [
{
action: "deny",
match: { channel: "slack", chatType: "channel", keyPrefix: "agent:main:" },
},
models: [{ type: "cli", command: "summarize" }],
},
],
},
} as OpenClawConfig,
}),
ctx: {
Body: "see https://example.com/page",
ChatType: "channel",
Provider: "discord",
SessionKey: "agent:main:slack:channel:C123",
Surface: "slack",
} as MsgContext,
},
});
expect(result).toEqual([]);
@ -113,7 +102,7 @@ describe("runLinkUnderstanding", () => {
const result = await runLinkUnderstanding({
cfg: cfg({ type: "cli", command: "summarize", args: ["--source", "{{LinkUrl}}"] }),
ctx: ctx("see https://example.com/page"),
ctx: ctx(),
signal: controller.signal,
});
@ -160,10 +149,7 @@ describe("runLinkUnderstanding", () => {
...text,
};
await applyLinkUnderstanding({
cfg: cfg({ type: "cli", command: "summarize" }),
ctx: context,
});
await applyLinkUnderstanding({ cfg: cfg(), ctx: context });
expect(context.Body).toBe("transport envelope\n\nsummarized page");
expect(context.agentText).toBe(
@ -190,10 +176,7 @@ describe("runLinkUnderstanding", () => {
};
const before = structuredClone(context);
await applyLinkUnderstanding({
cfg: cfg({ type: "cli", command: "summarize" }),
ctx: context,
});
await applyLinkUnderstanding({ cfg: cfg(), ctx: context });
expect(context).toEqual(before);
expect(runCommandWithTimeout).not.toHaveBeenCalled();
@ -216,29 +199,6 @@ describe("runLinkUnderstanding", () => {
expect(runCommandWithTimeout).not.toHaveBeenCalled();
});
it.each([
[
"skips links rejected by the guarded fetch DNS policy",
"http://169.254.169.254.nip.io/latest/meta-data/",
"Blocked: resolves to private/internal/special-use IP address",
],
[
"skips links rejected by the guarded fetch redirect policy",
"https://public.example/redirect-to-metadata",
"redirect target resolves to private network",
],
])("%s", async (_name, url, errorMessage) => {
mocks.fetchWithSsrFGuard.mockRejectedValueOnce(new Error(errorMessage));
const result = await runLinkUnderstanding({
cfg: cfg({ type: "cli", command: "summarize" }),
ctx: ctx(`see ${url}`),
});
expect(result).toEqual([]);
expect(runCommandWithTimeout).not.toHaveBeenCalled();
});
it.each([
[
"uses the global link-tools timeout for fetches when configured",
@ -251,19 +211,14 @@ describe("runLinkUnderstanding", () => {
mockCommand("summarized page");
await runLinkUnderstanding({
cfg: {
tools: {
links: {
enabled: true,
...timeoutConfig,
models: [
{ type: "cli", command: "summarize-fast", timeoutSeconds: 1 },
{ type: "cli", command: "summarize-slow", timeoutSeconds: 9 },
],
},
},
} as OpenClawConfig,
ctx: ctx("see https://example.com/page"),
cfg: cfg(
[
{ type: "cli", command: "summarize-fast", timeoutSeconds: 1 },
{ type: "cli", command: "summarize-slow", timeoutSeconds: 9 },
],
timeoutConfig,
),
ctx: ctx(),
});
expect(fetchWithSsrFGuard).toHaveBeenCalledWith(
@ -277,13 +232,13 @@ describe("runLinkUnderstanding", () => {
it("skips pre-aborted work without changing inbound context", async () => {
const controller = new AbortController();
controller.abort();
const context = ctx("see https://example.com/page");
const context = ctx();
const original = { ...context };
await expect(
applyLinkUnderstanding({
ctx: context,
cfg: cfg({ type: "cli", command: "summarize" }),
cfg: cfg(),
signal: controller.signal,
}),
).resolves.toBeUndefined();
@ -300,14 +255,8 @@ describe("runLinkUnderstanding", () => {
mockCommand("second summary");
const result = await runLinkUnderstanding({
cfg: {
tools: {
links: {
models: [{ command: "summarize-a" }, { command: "summarize-b" }],
},
},
},
ctx: ctx("see https://example.com/page"),
cfg: cfg([{ command: "summarize-a" }, { command: "summarize-b" }]),
ctx: ctx(),
signal: controller.signal,
});
@ -321,30 +270,27 @@ describe("runLinkUnderstanding", () => {
}
});
it.each([
{ outcome: "successful exit", code: 0, stdout: "late summary", termination: "exit" },
{ outcome: "failed exit", code: 1, stdout: "", termination: "exit" },
{ outcome: "signal termination", code: null, stdout: "", termination: "signal" },
])("cancellation overrides $outcome without fallback or context changes", async (result) => {
it("cancellation overrides successful exit without fallback or context changes", async () => {
const controller = new AbortController();
const reason = new Error("reply canceled");
mockGuardedFetch("first body", "https://example.com/first");
mocks.runCommandWithTimeout.mockImplementationOnce(async () => {
controller.abort(reason);
return { ...result, killed: false, signal: null, stderr: "" };
return {
code: 0,
stdout: "late summary",
termination: "exit",
killed: false,
signal: null,
stderr: "",
};
});
const context = ctx("see https://example.com/first and https://example.com/second");
const original = { ...context };
await expect(
applyLinkUnderstanding({
cfg: {
tools: {
links: {
models: [{ command: "summarize-a" }, { command: "summarize-b" }],
},
},
},
cfg: cfg([{ command: "summarize-a" }, { command: "summarize-b" }]),
ctx: context,
signal: controller.signal,
}),
@ -363,8 +309,8 @@ describe("runLinkUnderstanding", () => {
await expect(
runLinkUnderstanding({
cfg: cfg({ type: "cli", command: "summarize" }),
ctx: ctx("see https://example.com/page"),
cfg: cfg(),
ctx: ctx(),
signal: new AbortController().signal,
}),
).rejects.toBe(abortError);
@ -381,8 +327,8 @@ describe("runLinkUnderstanding", () => {
await expect(
runLinkUnderstanding({
cfg: cfg({ type: "cli", command: "summarize" }),
ctx: ctx("see https://example.com/page"),
cfg: cfg(),
ctx: ctx(),
signal: controller.signal,
}),
).rejects.toMatchObject({ name: "AbortError", cause: reason });

View file

@ -1,9 +1,10 @@
import { createServer, type RequestListener } from "node:http";
import type { AddressInfo, Socket } from "node:net";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, expect, it, vi } from "vitest";
import type { MsgContext } from "../auto-reply/templating.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createDeferredCore as deferred } from "../shared/deferred.js";
import { isPidAlive } from "../shared/pid-alive.js";
import { killPidIfAlive, waitForPidFile, waitForPidToExit } from "../test-utils/process-tree.js";
import { withTempDir } from "../test-utils/temp-dir.js";
@ -57,14 +58,6 @@ vi.mock("../process/exec.js", async () => {
};
});
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
async function within<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
@ -115,11 +108,10 @@ beforeEach(() => {
vi.clearAllMocks();
});
describe("runLinkUnderstanding transport cleanup", () => {
it("cancels a non-OK response body before releasing its guarded transport", async () => {
const sockets = new Set<Socket>();
const requestSocketClosed = deferred();
const server = createServer((request, response) => {
it("cancels a non-OK response body before releasing its guarded transport", async () => {
const requestSocketClosed = deferred();
await withServer(
(request, response) => {
request.socket.once("close", requestSocketClosed.resolve);
response.writeHead(500, {
"content-length": "1000000",
@ -127,19 +119,8 @@ describe("runLinkUnderstanding transport cleanup", () => {
});
// Leave the declared body unfinished so cleanup must actively cancel it.
response.write("error");
});
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
try {
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as AddressInfo).port;
const url = `http://loopback.test:${port}/error`;
},
async (base) => {
const resultPromise = runLinkUnderstanding({
cfg: {
tools: {
@ -148,8 +129,8 @@ describe("runLinkUnderstanding transport cleanup", () => {
models: [{ type: "cli", command: "summarize" }],
},
},
} as OpenClawConfig,
ctx: { Body: `see ${url}` } as MsgContext,
},
ctx: { Body: `see ${base}/error` },
});
const result = await within(resultPromise, 1000, "link understanding did not finish");
@ -159,15 +140,8 @@ describe("runLinkUnderstanding transport cleanup", () => {
expect(mocks.bodyCancel).toHaveBeenCalledOnce();
expect(mocks.releaseAfterCancel).toHaveBeenCalledWith(true);
expect(mocks.runCommandWithTimeout).not.toHaveBeenCalled();
} finally {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
},
);
});
it("cancels a streaming response and preserves the unmodified inbound context", async () => {
@ -224,7 +198,7 @@ it("skips a timed-out streaming link and processes the next link", async () => {
);
});
it("fetches only bare URLs from messages that also contain titled markdown links", async () => {
it("fetches bare URLs in order while ignoring titled markdown links", async () => {
const requests: string[] = [];
await withServer(
(req, res) => {
@ -233,24 +207,18 @@ it("fetches only bare URLs from messages that also contain titled markdown links
res.end(requestPath);
},
async (base) => {
const firstBare = `${base}/bare-one`;
const secondBare = `${base}/bare-two`;
const ctx: MsgContext = {
Body: [
`[quoted](${base}/quoted "Docs")`,
`[parenthesized](${base}/parenthesized (Docs))`,
`[escaped](${base}/escaped "A \\"quoted\\" title")`,
firstBare,
`${base}/bare-one`,
`[angle](<${base}/angle> 'Docs')`,
secondBare,
`${base}/bare-two`,
].join(" "),
};
await applyLinkUnderstanding({
cfg: config(["-e", "process.stdin.pipe(process.stdout)"]),
ctx,
});
expect(requests).toEqual(["/bare-one", "/bare-two"]);
expect(ctx.LinkUnderstanding).toEqual(["/bare-one", "/bare-two"]);
},