mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
063b1971ec
commit
cf877dba7a
40 changed files with 997 additions and 3021 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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}');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue