From cf877dba7a23fdaa1b570e0656e8da94ac432aae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 26 Sep 2026 18:26:27 -0700 Subject: [PATCH] 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 --- extensions/deepgram/audio.test.ts | 89 +-- .../realtime-transcription-provider.test.ts | 179 +---- extensions/inworld/speech-provider.test.ts | 200 ++---- extensions/inworld/tts.test.ts | 183 ++--- extensions/searxng/src/searxng-client.test.ts | 183 ++--- .../src/searxng-client.transport.test.ts | 97 +-- .../src/searxng-search-provider.test.ts | 178 ++--- extensions/tencent/config-compat.test.ts | 196 ++--- extensions/tencent/index.test.ts | 227 ++---- extensions/tencent/onboard.test.ts | 43 +- .../vydra/image-generation-provider.test.ts | 151 +--- extensions/vydra/shared.test.ts | 88 +-- extensions/vydra/speech-provider.test.ts | 67 +- .../vydra/video-generation-provider.test.ts | 145 +--- packages/acp-core/src/error-format.test.ts | 7 - .../acp-core/src/runtime/error-text.test.ts | 39 +- packages/acp-core/src/runtime/errors.test.ts | 82 +-- .../src/runtime/session-identifiers.test.ts | 159 ++--- .../src/session-interaction-mode.test.ts | 46 +- .../acp-core/src/session-lineage-meta.test.ts | 24 - packages/acp-core/src/session.test.ts | 111 +-- .../src/attachment-classify.test.ts | 131 ++-- packages/media-core/src/base64.test.ts | 135 +--- packages/media-core/src/mime.test.ts | 669 +++++------------- .../src/boolean-coercion.test.ts | 11 +- .../normalization-core/src/cjk-chars.test.ts | 10 +- .../src/code-points.test.ts | 4 - .../src/error-coercion.test.ts | 51 +- .../src/json-coercion.test.ts | 3 - .../src/markdown-plain-text.test.ts | 1 - .../src/number-coercion.test.ts | 40 +- .../src/phone-presentation.test.ts | 9 - .../src/record-coerce.test.ts | 3 - .../normalization-core/src/result.test.ts | 9 - .../src/stable-stringify.test.ts | 25 +- .../src/string-normalization.test.ts | 39 +- .../src/utf16-slice.test.ts | 82 +-- src/link-understanding/detect.test.ts | 72 +- src/link-understanding/runner.test.ts | 168 ++--- .../runner.transport.test.ts | 62 +- 40 files changed, 997 insertions(+), 3021 deletions(-) delete mode 100644 packages/normalization-core/src/result.test.ts diff --git a/extensions/deepgram/audio.test.ts b/extensions/deepgram/audio.test.ts index e0da841d081d..d708af6171f5 100644 --- a/extensions/deepgram/audio.test.ts +++ b/extensions/deepgram/audio.test.ts @@ -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().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"); diff --git a/extensions/deepgram/realtime-transcription-provider.test.ts b/extensions/deepgram/realtime-transcription-provider.test.ts index d09f0a515ed6..349398de9f16 100644 --- a/extensions/deepgram/realtime-transcription-provider.test.ts +++ b/extensions/deepgram/realtime-transcription-provider.test.ts @@ -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) | undefined; async function createDeepgramRealtimeServer(params: { - onRequest: (url: URL, headers: Record) => void; + onRequest?: (url: URL, headers: Record) => void; onConnection?: (ws: WebSocket) => void; }) { - const server = createServer(); - const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); - const clients = new Set(); - - 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((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((resolve) => { wss.close(() => resolve()); }); - await new Promise((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(); - 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(); - 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(); 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(); 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(); const onError = vi.fn(errorReceived.resolve); - const session = buildDeepgramRealtimeTranscriptionProvider(transcriptionHost).createSession({ + const session = provider.createSession({ providerConfig: { apiKey: "dummy", baseUrl: server.baseUrl, endpointingMs: 1000 }, onError, }); diff --git a/extensions/inworld/speech-provider.test.ts b/extensions/inworld/speech-provider.test.ts index 96271e8e394c..befd7974f8aa 100644 --- a/extensions/inworld/speech-provider.test.ts +++ b/extensions/inworld/speech-provider.test.ts @@ -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({ diff --git a/extensions/inworld/tts.test.ts b/extensions/inworld/tts.test.ts index 08bc0be1f17b..daa623e3391d 100644 --- a/extensions/inworld/tts.test.ts +++ b/extensions/inworld/tts.test.ts @@ -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 { }); 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; 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( diff --git a/extensions/searxng/src/searxng-client.test.ts b/extensions/searxng/src/searxng-client.test.ts index 608fbdfd270c..0f0c016a2358 100644 --- a/extensions/searxng/src/searxng-client.test.ts +++ b/extensions/searxng/src/searxng-client.test.ts @@ -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[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>; @@ -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.", ); }); diff --git a/extensions/searxng/src/searxng-client.transport.test.ts b/extensions/searxng/src/searxng-client.transport.test.ts index f48394b64d1b..330d7684c856 100644 --- a/extensions/searxng/src/searxng-client.transport.test.ts +++ b/extensions/searxng/src/searxng-client.transport.test.ts @@ -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(); +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 { servers.add(server); await new Promise((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((resolve) => { - resolveRequestStarted = resolve; - }); - let resolveClientClosed: (() => void) | undefined; - const clientClosed = new Promise((resolve) => { - resolveClientClosed = resolve; - }); + it("aborts a provider's stalled response body and closes the request", async () => { + const requestStarted = Promise.withResolvers(); + const clientClosed = Promise.withResolvers(); 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(); }); }); diff --git a/extensions/searxng/src/searxng-search-provider.test.ts b/extensions/searxng/src/searxng-search-provider.test.ts index 4ed415a259a8..218cbbe620af 100644 --- a/extensions/searxng/src/searxng-search-provider.test.ts +++ b/extensions/searxng/src/searxng-search-provider.test.ts @@ -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) => 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) => 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; - 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"); }); diff --git a/extensions/tencent/config-compat.test.ts b/extensions/tencent/config-compat.test.ts index e5898861b0ce..72a4aa227f4d 100644 --- a/extensions/tencent/config-compat.test.ts +++ b/extensions/tencent/config-compat.test.ts @@ -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: [] }); - }, - ); }); diff --git a/extensions/tencent/index.test.ts b/extensions/tencent/index.test.ts index b827a696f64b..7cf2c9f98e0f 100644 --- a/extensions/tencent/index.test.ts +++ b/extensions/tencent/index.test.ts @@ -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, diff --git a/extensions/tencent/onboard.test.ts b/extensions/tencent/onboard.test.ts index 8f1fcb10c630..ee0b88902f42 100644 --- a/extensions/tencent/onboard.test.ts +++ b/extensions/tencent/onboard.test.ts @@ -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 }, diff --git a/extensions/vydra/image-generation-provider.test.ts b/extensions/vydra/image-generation-provider.test.ts index 3f99ef939c14..2260e42ab227 100644 --- a/extensions/vydra/image-generation-provider.test.ts +++ b/extensions/vydra/image-generation-provider.test.ts @@ -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, 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", + ); }); }); diff --git a/extensions/vydra/shared.test.ts b/extensions/vydra/shared.test.ts index f2dcfaae507b..438d50bad87a 100644 --- a/extensions/vydra/shared.test.ts +++ b/extensions/vydra/shared.test.ts @@ -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(); 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: "sign in" }, { 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", () => { diff --git a/extensions/vydra/speech-provider.test.ts b/extensions/vydra/speech-provider.test.ts index 1a34c01a7b3c..71a155f93634 100644 --- a/extensions/vydra/speech-provider.test.ts +++ b/extensions/vydra/speech-provider.test.ts @@ -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[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", + ); }); }); diff --git a/extensions/vydra/video-generation-provider.test.ts b/extensions/vydra/video-generation-provider.test.ts index d4455bf49192..771598d60b66 100644 --- a/extensions/vydra/video-generation-provider.test.ts +++ b/extensions/vydra/video-generation-provider.test.ts @@ -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, 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", diff --git a/packages/acp-core/src/error-format.test.ts b/packages/acp-core/src/error-format.test.ts index 2eca0f5e6e08..419547d3c181 100644 --- a/packages/acp-core/src/error-format.test.ts +++ b/packages/acp-core/src/error-format.test.ts @@ -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(""); diff --git a/packages/acp-core/src/runtime/error-text.test.ts b/packages/acp-core/src/runtime/error-text.test.ts index 20e39d2cff20..3d00e0176041 100644 --- a/packages/acp-core/src/runtime/error-text.test.ts +++ b/packages/acp-core/src/runtime/error-text.test.ts @@ -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); }); }); diff --git a/packages/acp-core/src/runtime/errors.test.ts b/packages/acp-core/src/runtime/errors.test.ts index d0143fa6017e..ae1f2320a29b 100644 --- a/packages/acp-core/src/runtime/errors.test.ts +++ b/packages/acp-core/src/runtime/errors.test.ts @@ -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): 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", () => { diff --git a/packages/acp-core/src/runtime/session-identifiers.test.ts b/packages/acp-core/src/runtime/session-identifiers.test.ts index b18bf42784ee..cf95c1d1a989 100644 --- a/packages/acp-core/src/runtime/session-identifiers.test.ts +++ b/packages/acp-core/src/runtime/session-identifiers.test.ts @@ -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"); }); }); diff --git a/packages/acp-core/src/session-interaction-mode.test.ts b/packages/acp-core/src/session-interaction-mode.test.ts index b20ecd433f2d..e4f2f124a203 100644 --- a/packages/acp-core/src/session-interaction-mode.test.ts +++ b/packages/acp-core/src/session-interaction-mode.test.ts @@ -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); diff --git a/packages/acp-core/src/session-lineage-meta.test.ts b/packages/acp-core/src/session-lineage-meta.test.ts index a441929f6332..3b197a5101b3 100644 --- a/packages/acp-core/src/session-lineage-meta.test.ts +++ b/packages/acp-core/src/session-lineage-meta.test.ts @@ -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", () => { diff --git a/packages/acp-core/src/session.test.ts b/packages/acp-core/src/session.test.ts index 87281bd03105..cd053846c5c2 100644 --- a/packages/acp-core/src/session.test.ts +++ b/packages/acp-core/src/session.test.ts @@ -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. diff --git a/packages/media-core/src/attachment-classify.test.ts b/packages/media-core/src/attachment-classify.test.ts index b3586214a3d9..651a6954e09c 100644 --- a/packages/media-core/src/attachment-classify.test.ts +++ b/packages/media-core/src/attachment-classify.test.ts @@ -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 }), diff --git a/packages/media-core/src/base64.test.ts b/packages/media-core/src/base64.test.ts index 984613cbc410..584572bead2b 100644 --- a/packages/media-core/src/base64.test.ts +++ b/packages/media-core/src/base64.test.ts @@ -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); }); }); diff --git a/packages/media-core/src/mime.test.ts b/packages/media-core/src/mime.test.ts index ce893784741f..3bc38f062042 100644 --- a/packages/media-core/src/mime.test.ts +++ b/packages/media-core/src/mime.test.ts @@ -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 { - const zip = new JSZip(); - zip.file( - "[Content_Types].xml", - ``, - ); - zip.file(opts.partPath.slice(1), ""); - 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", + ``, + ); + zip.file(partPath.slice(1), ""); 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("test"); - 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("test"); - 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); }); }); diff --git a/packages/normalization-core/src/boolean-coercion.test.ts b/packages/normalization-core/src/boolean-coercion.test.ts index a659dd29f2a8..932a49db09c4 100644 --- a/packages/normalization-core/src/boolean-coercion.test.ts +++ b/packages/normalization-core/src/boolean-coercion.test.ts @@ -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(); + }); }); diff --git a/packages/normalization-core/src/cjk-chars.test.ts b/packages/normalization-core/src/cjk-chars.test.ts index 8fcdb5f11733..3c0b337cf445 100644 --- a/packages/normalization-core/src/cjk-chars.test.ts +++ b/packages/normalization-core/src/cjk-chars.test.ts @@ -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], diff --git a/packages/normalization-core/src/code-points.test.ts b/packages/normalization-core/src/code-points.test.ts index 0639d11273b0..001a8ce7b5b3 100644 --- a/packages/normalization-core/src/code-points.test.ts +++ b/packages/normalization-core/src/code-points.test.ts @@ -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; diff --git a/packages/normalization-core/src/error-coercion.test.ts b/packages/normalization-core/src/error-coercion.test.ts index a25626c8bea9..0bd6207fe284 100644 --- a/packages/normalization-core/src/error-coercion.test.ts +++ b/packages/normalization-core/src/error-coercion.test.ts @@ -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, diff --git a/packages/normalization-core/src/json-coercion.test.ts b/packages/normalization-core/src/json-coercion.test.ts index b763d5afab0b..5d3851a010e6 100644 --- a/packages/normalization-core/src/json-coercion.test.ts +++ b/packages/normalization-core/src/json-coercion.test.ts @@ -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", diff --git a/packages/normalization-core/src/markdown-plain-text.test.ts b/packages/normalization-core/src/markdown-plain-text.test.ts index 0586778593db..d656c6e40770 100644 --- a/packages/normalization-core/src/markdown-plain-text.test.ts +++ b/packages/normalization-core/src/markdown-plain-text.test.ts @@ -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); }); diff --git a/packages/normalization-core/src/number-coercion.test.ts b/packages/normalization-core/src/number-coercion.test.ts index ff5e24f454eb..8c0b6b594d02 100644 --- a/packages/normalization-core/src/number-coercion.test.ts +++ b/packages/normalization-core/src/number-coercion.test.ts @@ -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); }); diff --git a/packages/normalization-core/src/phone-presentation.test.ts b/packages/normalization-core/src/phone-presentation.test.ts index 220887548e6b..f496aa4a259c 100644 --- a/packages/normalization-core/src/phone-presentation.test.ts +++ b/packages/normalization-core/src/phone-presentation.test.ts @@ -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(); }); diff --git a/packages/normalization-core/src/record-coerce.test.ts b/packages/normalization-core/src/record-coerce.test.ts index 5161d5a33d6a..a8330850defc 100644 --- a/packages/normalization-core/src/record-coerce.test.ts +++ b/packages/normalization-core/src/record-coerce.test.ts @@ -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, diff --git a/packages/normalization-core/src/result.test.ts b/packages/normalization-core/src/result.test.ts deleted file mode 100644 index 3ceb70fdd779..000000000000 --- a/packages/normalization-core/src/result.test.ts +++ /dev/null @@ -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" }); - }); -}); diff --git a/packages/normalization-core/src/stable-stringify.test.ts b/packages/normalization-core/src/stable-stringify.test.ts index 391e66dd34a1..a67a8d39b8ff 100644 --- a/packages/normalization-core/src/stable-stringify.test.ts +++ b/packages/normalization-core/src/stable-stringify.test.ts @@ -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 = { }; 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}'); }); diff --git a/packages/normalization-core/src/string-normalization.test.ts b/packages/normalization-core/src/string-normalization.test.ts index 4808c5924d4e..ab1f57e9f836 100644 --- a/packages/normalization-core/src/string-normalization.test.ts +++ b/packages/normalization-core/src/string-normalization.test.ts @@ -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); }); diff --git a/packages/normalization-core/src/utf16-slice.test.ts b/packages/normalization-core/src/utf16-slice.test.ts index 4921f2b6bb7b..6c74a13c1959 100644 --- a/packages/normalization-core/src/utf16-slice.test.ts +++ b/packages/normalization-core/src/utf16-slice.test.ts @@ -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, 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, 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, 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); }); }); diff --git a/src/link-understanding/detect.test.ts b/src/link-understanding/detect.test.ts index e612eb935bf1..7d2f299d8c11 100644 --- a/src/link-understanding/detect.test.ts +++ b/src/link-understanding/detect.test.ts @@ -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"]); }); }); diff --git a/src/link-understanding/runner.test.ts b/src/link-understanding/runner.test.ts index d2b889ae02a5..0524de7cbbbf 100644 --- a/src/link-understanding/runner.test.ts +++ b/src/link-understanding/runner.test.ts @@ -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( - "../infra/net/fetch-guard.js", - ); - return { - ...actual, - fetchWithSsrFGuard: mocks.fetchWithSsrFGuard, - }; -}); +vi.mock("../infra/net/fetch-guard.js", async (importOriginal) => ({ + ...(await importOriginal()), + fetchWithSsrFGuard: mocks.fetchWithSsrFGuard, +})); -vi.mock("../process/exec.js", async () => { - const actual = await vi.importActual("../process/exec.js"); - return { - ...actual, - runCommandWithTimeout: mocks.runCommandWithTimeout, - }; -}); +vi.mock("../process/exec.js", async (importOriginal) => ({ + ...(await importOriginal()), + runCommandWithTimeout: mocks.runCommandWithTimeout, +})); -function cfg(entry: LinkModelConfig) { +function cfg( + entry: LinkModelConfig | LinkModelConfig[] = { type: "cli", command: "summarize" }, + options: Omit = {}, +): 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 }); diff --git a/src/link-understanding/runner.transport.test.ts b/src/link-understanding/runner.transport.test.ts index 7e2ae372adf7..e97de0cf82c3 100644 --- a/src/link-understanding/runner.transport.test.ts +++ b/src/link-understanding/runner.transport.test.ts @@ -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((done) => { - resolve = done; - }); - return { promise, resolve }; -} - async function within(promise: Promise, timeoutMs: number, message: string): Promise { let timer: ReturnType | 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(); - 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((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((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"]); },