From 073b3fbf889dc454dde501282bc0e2ee5260d538 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 04:23:42 +0100 Subject: [PATCH] test: move more runtime specs to fast lane --- src/acp/translator.set-session-mode.test.ts | 45 ++-- src/image-generation/runtime.ts | 38 ++- src/media-generation/runtime-shared.ts | 4 +- src/music-generation/runtime.test.ts | 241 ++++++++++---------- src/music-generation/runtime.ts | 28 ++- src/proxy-capture/runtime.test.ts | 157 ++++++------- src/proxy-capture/runtime.ts | 180 +++++++++------ src/video-generation/runtime.ts | 45 +++- test/vitest/vitest.unit-fast-paths.mjs | 3 + 9 files changed, 431 insertions(+), 310 deletions(-) diff --git a/src/acp/translator.set-session-mode.test.ts b/src/acp/translator.set-session-mode.test.ts index 53e8db0e5e5..ce98973233c 100644 --- a/src/acp/translator.set-session-mode.test.ts +++ b/src/acp/translator.set-session-mode.test.ts @@ -1,5 +1,5 @@ import type { SetSessionModeRequest } from "@agentclientprotocol/sdk"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; @@ -24,38 +24,55 @@ function createAgentWithSession(request: GatewayClient["request"]) { }); } +function createRequestRecorder( + handler: (...args: Parameters) => Promise, +) { + const calls: Parameters[] = []; + const request = (async (...args: Parameters) => { + calls.push(args); + return handler(...args); + }) as GatewayClient["request"]; + return { calls, request }; +} + describe("acp setSessionMode", () => { it("setSessionMode propagates gateway error", async () => { - const request = vi.fn(async () => { + const { calls, request } = createRequestRecorder(async () => { throw new Error("gateway rejected mode change"); - }) as GatewayClient["request"]; + }); const agent = createAgentWithSession(request); await expect(agent.setSessionMode(createSetSessionModeRequest("high"))).rejects.toThrow( "gateway rejected mode change", ); - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "agent:main:main", - thinkingLevel: "high", - }); + expect(calls).toContainEqual([ + "sessions.patch", + { + key: "agent:main:main", + thinkingLevel: "high", + }, + ]); }); it("setSessionMode succeeds when gateway accepts", async () => { - const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; + const { calls, request } = createRequestRecorder(async () => ({ ok: true })); const agent = createAgentWithSession(request); await expect(agent.setSessionMode(createSetSessionModeRequest("low"))).resolves.toEqual({}); - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "agent:main:main", - thinkingLevel: "low", - }); + expect(calls).toContainEqual([ + "sessions.patch", + { + key: "agent:main:main", + thinkingLevel: "low", + }, + ]); }); it("setSessionMode returns early for empty modeId", async () => { - const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; + const { calls, request } = createRequestRecorder(async () => ({ ok: true })); const agent = createAgentWithSession(request); await expect(agent.setSessionMode(createSetSessionModeRequest(""))).resolves.toEqual({}); - expect(request).not.toHaveBeenCalled(); + expect(calls).toEqual([]); }); }); diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index 04a68f0b86b..fd290167202 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -10,6 +10,7 @@ import { resolveCapabilityModelCandidates, throwCapabilityGenerationFailure, } from "../media-generation/runtime-shared.js"; +import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; import { parseImageGenerationModelRef } from "./model-ref.js"; import { resolveImageGenerationOverrides } from "./normalization.js"; import { getImageGenerationProvider, listImageGenerationProviders } from "./provider-registry.js"; @@ -18,23 +19,42 @@ import type { ImageGenerationResult } from "./types.js"; const log = createSubsystemLogger("image-generation"); +export type ImageGenerationRuntimeDeps = { + getProvider?: typeof getImageGenerationProvider; + listProviders?: typeof listImageGenerationProviders; + getProviderEnvVars?: typeof getProviderEnvVars; + log?: Pick; +}; + export type { GenerateImageParams, GenerateImageRuntimeResult } from "./runtime-types.js"; -function buildNoImageGenerationModelConfiguredMessage(cfg: OpenClawConfig): string { +function buildNoImageGenerationModelConfiguredMessage( + cfg: OpenClawConfig, + deps: ImageGenerationRuntimeDeps, +): string { + const listProviders = deps.listProviders ?? listImageGenerationProviders; return buildNoCapabilityModelConfiguredMessage({ capabilityLabel: "image-generation", modelConfigKey: "imageGenerationModel", - providers: listImageGenerationProviders(cfg), + providers: listProviders(cfg), + getProviderEnvVars: deps.getProviderEnvVars, }); } -export function listRuntimeImageGenerationProviders(params?: { config?: OpenClawConfig }) { - return listImageGenerationProviders(params?.config); +export function listRuntimeImageGenerationProviders( + params?: { config?: OpenClawConfig }, + deps: ImageGenerationRuntimeDeps = {}, +) { + return (deps.listProviders ?? listImageGenerationProviders)(params?.config); } export async function generateImage( params: GenerateImageParams, + deps: ImageGenerationRuntimeDeps = {}, ): Promise { + const getProvider = deps.getProvider ?? getImageGenerationProvider; + const listProviders = deps.listProviders ?? listImageGenerationProviders; + const logger = deps.log ?? log; const timeoutMs = params.timeoutMs ?? resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.imageGenerationModel); @@ -44,17 +64,17 @@ export async function generateImage( modelOverride: params.modelOverride, parseModelRef: parseImageGenerationModelRef, agentDir: params.agentDir, - listProviders: listImageGenerationProviders, + listProviders, }); if (candidates.length === 0) { - throw new Error(buildNoImageGenerationModelConfiguredMessage(params.cfg)); + throw new Error(buildNoImageGenerationModelConfiguredMessage(params.cfg, deps)); } const attempts: FallbackAttempt[] = []; let lastError: unknown; for (const candidate of candidates) { - const provider = getImageGenerationProvider(candidate.provider, params.cfg); + const provider = getProvider(candidate.provider, params.cfg); if (!provider) { const error = `No image-generation provider registered for ${candidate.provider}`; attempts.push({ @@ -63,7 +83,7 @@ export async function generateImage( error, }); lastError = new Error(error); - log.warn( + logger.warn( `image-generation candidate failed: ${candidate.provider}/${candidate.model}: ${error}`, ); continue; @@ -127,7 +147,7 @@ export async function generateImage( status: described?.status, code: described?.code, }); - log.warn( + logger.warn( `image-generation candidate failed: ${candidate.provider}/${candidate.model}: ${ described?.message ?? formatErrorMessage(err) }`, diff --git a/src/media-generation/runtime-shared.ts b/src/media-generation/runtime-shared.ts index 0b27dc84baf..affc58768e1 100644 --- a/src/media-generation/runtime-shared.ts +++ b/src/media-generation/runtime-shared.ts @@ -11,7 +11,7 @@ import { import type { AgentModelConfig } from "../config/types.agents-shared.js"; import type { OpenClawConfig } from "../config/types.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; +import { getProviderEnvVars as getDefaultProviderEnvVars } from "../secrets/provider-env-vars.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { MediaGenerationNormalizationMetadataInput, @@ -504,7 +504,9 @@ export function buildNoCapabilityModelConfiguredMessage(params: { modelConfigKey: string; providers: Array<{ id: string; defaultModel?: string | null }>; fallbackSampleRef?: string; + getProviderEnvVars?: typeof getDefaultProviderEnvVars; }): string { + const getProviderEnvVars = params.getProviderEnvVars ?? getDefaultProviderEnvVars; const sampleModel = params.providers.find( (provider) => normalizeOptionalString(provider.id) && normalizeOptionalString(provider.defaultModel), diff --git a/src/music-generation/runtime.test.ts b/src/music-generation/runtime.test.ts index 0b818e1a20d..0180e2ced91 100644 --- a/src/music-generation/runtime.test.ts +++ b/src/music-generation/runtime.test.ts @@ -1,24 +1,41 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { - getMediaGenerationRuntimeMocks, - resetMusicGenerationRuntimeMocks, -} from "../../test/helpers/media-generation/runtime-module-mocks.js"; import type { OpenClawConfig } from "../config/types.js"; -import { generateMusic, listRuntimeMusicGenerationProviders } from "./runtime.js"; +import { + generateMusic, + listRuntimeMusicGenerationProviders, + type GenerateMusicParams, + type MusicGenerationRuntimeDeps, +} from "./runtime.js"; import type { MusicGenerationProvider } from "./types.js"; -const mocks = getMediaGenerationRuntimeMocks(); +let providers: MusicGenerationProvider[] = []; +let listedConfigs: Array = []; + +const runtimeDeps: MusicGenerationRuntimeDeps = { + getProvider: (providerId) => providers.find((provider) => provider.id === providerId), + listProviders: (config) => { + listedConfigs.push(config); + return providers; + }, + log: { + debug: () => {}, + }, +}; + +function runGenerateMusic(params: GenerateMusicParams) { + return generateMusic(params, runtimeDeps); +} describe("music-generation runtime", () => { beforeEach(() => { - resetMusicGenerationRuntimeMocks(); + providers = []; + listedConfigs = []; }); it("generates tracks through the active music-generation provider", async () => { const authStore = { version: 1, profiles: {} } as const; let seenAuthStore: unknown; let seenTimeoutMs: number | undefined; - mocks.resolveAgentModelPrimaryValue.mockReturnValue("music-plugin/track-v1"); const provider: MusicGenerationProvider = { id: "music-plugin", capabilities: {}, @@ -37,9 +54,9 @@ describe("music-generation runtime", () => { }; }, }; - mocks.getMusicGenerationProvider.mockReturnValue(provider); + providers = [provider]; - const result = await generateMusic({ + const result = await runGenerateMusic({ cfg: { agents: { defaults: { @@ -69,52 +86,31 @@ describe("music-generation runtime", () => { }); it("auto-detects and falls through to another configured music-generation provider by default", async () => { - mocks.getMusicGenerationProvider.mockImplementation((providerId: string) => { - if (providerId === "google") { - return { - id: "google", - defaultModel: "lyria-3-clip-preview", - capabilities: {}, - isConfigured: () => true, - async generateMusic() { - throw new Error("Google music generation response missing audio data"); - }, - }; - } - if (providerId === "minimax") { - return { - id: "minimax", - defaultModel: "music-2.6", - capabilities: {}, - isConfigured: () => true, - async generateMusic() { - return { - tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], - model: "music-2.6", - }; - }, - }; - } - return undefined; - }); - mocks.listMusicGenerationProviders.mockReturnValue([ + providers = [ { id: "google", defaultModel: "lyria-3-clip-preview", capabilities: {}, isConfigured: () => true, - generateMusic: async () => ({ tracks: [] }), + async generateMusic() { + throw new Error("Google music generation response missing audio data"); + }, }, { id: "minimax", defaultModel: "music-2.6", capabilities: {}, isConfigured: () => true, - generateMusic: async () => ({ tracks: [] }), + async generateMusic() { + return { + tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], + model: "music-2.6", + }; + }, }, - ]); + ]; - const result = await generateMusic({ + const result = await runGenerateMusic({ cfg: {} as OpenClawConfig, prompt: "play a synth line", }); @@ -131,7 +127,7 @@ describe("music-generation runtime", () => { }); it("lists runtime music-generation providers through the provider registry", () => { - const providers: MusicGenerationProvider[] = [ + const registryProviders: MusicGenerationProvider[] = [ { id: "music-plugin", defaultModel: "track-v1", @@ -146,12 +142,12 @@ describe("music-generation runtime", () => { }), }, ]; - mocks.listMusicGenerationProviders.mockReturnValue(providers); + providers = registryProviders; - expect(listRuntimeMusicGenerationProviders({ config: {} as OpenClawConfig })).toEqual( - providers, - ); - expect(mocks.listMusicGenerationProviders).toHaveBeenCalledWith({} as OpenClawConfig); + expect( + listRuntimeMusicGenerationProviders({ config: {} as OpenClawConfig }, runtimeDeps), + ).toEqual(registryProviders); + expect(listedConfigs).toEqual([{} as OpenClawConfig]); }); it("ignores unsupported optional overrides per provider and model", async () => { @@ -163,34 +159,35 @@ describe("music-generation runtime", () => { format?: string; } | undefined; - mocks.resolveAgentModelPrimaryValue.mockReturnValue("google/lyria-3-clip-preview"); - mocks.getMusicGenerationProvider.mockReturnValue({ - id: "google", - capabilities: { - generate: { - supportsLyrics: true, - supportsInstrumental: true, - supportsFormat: true, - supportedFormatsByModel: { - "lyria-3-clip-preview": ["mp3"], + providers = [ + { + id: "google", + capabilities: { + generate: { + supportsLyrics: true, + supportsInstrumental: true, + supportsFormat: true, + supportedFormatsByModel: { + "lyria-3-clip-preview": ["mp3"], + }, }, }, + generateMusic: async (req) => { + seenRequest = { + lyrics: req.lyrics, + instrumental: req.instrumental, + durationSeconds: req.durationSeconds, + format: req.format, + }; + return { + tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], + model: "lyria-3-clip-preview", + }; + }, }, - generateMusic: async (req) => { - seenRequest = { - lyrics: req.lyrics, - instrumental: req.instrumental, - durationSeconds: req.durationSeconds, - format: req.format, - }; - return { - tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], - model: "lyria-3-clip-preview", - }; - }, - }); + ]; - const result = await generateMusic({ + const result = await runGenerateMusic({ cfg: { agents: { defaults: { @@ -226,40 +223,41 @@ describe("music-generation runtime", () => { format?: string; } | undefined; - mocks.resolveAgentModelPrimaryValue.mockReturnValue("google/lyria-3-pro-preview"); - mocks.getMusicGenerationProvider.mockReturnValue({ - id: "google", - capabilities: { - generate: { - supportsLyrics: false, - supportsInstrumental: false, - supportsFormat: true, - supportedFormats: ["mp3"], + providers = [ + { + id: "google", + capabilities: { + generate: { + supportsLyrics: false, + supportsInstrumental: false, + supportsFormat: true, + supportedFormats: ["mp3"], + }, + edit: { + enabled: true, + maxInputImages: 1, + supportsLyrics: true, + supportsInstrumental: true, + supportsDuration: false, + supportsFormat: false, + }, }, - edit: { - enabled: true, - maxInputImages: 1, - supportsLyrics: true, - supportsInstrumental: true, - supportsDuration: false, - supportsFormat: false, + generateMusic: async (req) => { + seenRequest = { + lyrics: req.lyrics, + instrumental: req.instrumental, + durationSeconds: req.durationSeconds, + format: req.format, + }; + return { + tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], + model: "lyria-3-pro-preview", + }; }, }, - generateMusic: async (req) => { - seenRequest = { - lyrics: req.lyrics, - instrumental: req.instrumental, - durationSeconds: req.durationSeconds, - format: req.format, - }; - return { - tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], - model: "lyria-3-pro-preview", - }; - }, - }); + ]; - const result = await generateMusic({ + const result = await runGenerateMusic({ cfg: { agents: { defaults: { @@ -293,27 +291,28 @@ describe("music-generation runtime", () => { durationSeconds?: number; } | undefined; - mocks.resolveAgentModelPrimaryValue.mockReturnValue("minimax/music-2.6"); - mocks.getMusicGenerationProvider.mockReturnValue({ - id: "minimax", - capabilities: { - generate: { - supportsDuration: true, - maxDurationSeconds: 30, + providers = [ + { + id: "minimax", + capabilities: { + generate: { + supportsDuration: true, + maxDurationSeconds: 30, + }, + }, + generateMusic: async (req) => { + seenRequest = { + durationSeconds: req.durationSeconds, + }; + return { + tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], + model: "music-2.6", + }; }, }, - generateMusic: async (req) => { - seenRequest = { - durationSeconds: req.durationSeconds, - }; - return { - tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }], - model: "music-2.6", - }; - }, - }); + ]; - const result = await generateMusic({ + const result = await runGenerateMusic({ cfg: { agents: { defaults: { diff --git a/src/music-generation/runtime.ts b/src/music-generation/runtime.ts index 07700802ca3..e597e8e8a06 100644 --- a/src/music-generation/runtime.ts +++ b/src/music-generation/runtime.ts @@ -8,6 +8,7 @@ import { resolveCapabilityModelCandidates, throwCapabilityGenerationFailure, } from "../media-generation/runtime-shared.js"; +import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; import { parseMusicGenerationModelRef } from "./model-ref.js"; import { resolveMusicGenerationOverrides } from "./normalization.js"; import { getMusicGenerationProvider, listMusicGenerationProviders } from "./provider-registry.js"; @@ -16,30 +17,45 @@ import type { MusicGenerationResult } from "./types.js"; const log = createSubsystemLogger("music-generation"); +export type MusicGenerationRuntimeDeps = { + getProvider?: typeof getMusicGenerationProvider; + listProviders?: typeof listMusicGenerationProviders; + getProviderEnvVars?: typeof getProviderEnvVars; + log?: Pick; +}; + export type { GenerateMusicParams, GenerateMusicRuntimeResult } from "./runtime-types.js"; -export function listRuntimeMusicGenerationProviders(params?: { config?: OpenClawConfig }) { - return listMusicGenerationProviders(params?.config); +export function listRuntimeMusicGenerationProviders( + params?: { config?: OpenClawConfig }, + deps: MusicGenerationRuntimeDeps = {}, +) { + return (deps.listProviders ?? listMusicGenerationProviders)(params?.config); } export async function generateMusic( params: GenerateMusicParams, + deps: MusicGenerationRuntimeDeps = {}, ): Promise { + const getProvider = deps.getProvider ?? getMusicGenerationProvider; + const listProviders = deps.listProviders ?? listMusicGenerationProviders; + const logger = deps.log ?? log; const candidates = resolveCapabilityModelCandidates({ cfg: params.cfg, modelConfig: params.cfg.agents?.defaults?.musicGenerationModel, modelOverride: params.modelOverride, parseModelRef: parseMusicGenerationModelRef, agentDir: params.agentDir, - listProviders: listMusicGenerationProviders, + listProviders, }); if (candidates.length === 0) { throw new Error( buildNoCapabilityModelConfiguredMessage({ capabilityLabel: "music-generation", modelConfigKey: "musicGenerationModel", - providers: listMusicGenerationProviders(params.cfg), + providers: listProviders(params.cfg), fallbackSampleRef: "google/lyria-3-clip-preview", + getProviderEnvVars: deps.getProviderEnvVars, }), ); } @@ -48,7 +64,7 @@ export async function generateMusic( let lastError: unknown; for (const candidate of candidates) { - const provider = getMusicGenerationProvider(candidate.provider, params.cfg); + const provider = getProvider(candidate.provider, params.cfg); if (!provider) { const error = `No music-generation provider registered for ${candidate.provider}`; attempts.push({ @@ -110,7 +126,7 @@ export async function generateMusic( model: candidate.model, error: err, }); - log.debug(`music-generation candidate failed: ${candidate.provider}/${candidate.model}`); + logger.debug(`music-generation candidate failed: ${candidate.provider}/${candidate.model}`); } } diff --git a/src/proxy-capture/runtime.test.ts b/src/proxy-capture/runtime.test.ts index 40f81d30782..8024cd3320f 100644 --- a/src/proxy-capture/runtime.test.ts +++ b/src/proxy-capture/runtime.test.ts @@ -1,29 +1,49 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { DebugProxySettings } from "./env.js"; import { captureHttpExchange, finalizeDebugProxyCapture, initializeDebugProxyCapture, + type DebugProxyCaptureRuntimeDeps, } from "./runtime.js"; -const storeState = vi.hoisted(() => { - const events: Record[] = []; - const store = { - upsertSession: vi.fn(), - endSession: vi.fn(), - recordEvent: vi.fn((event: Record) => { - events.push(event); - }), - }; - return { - events, - store, - closeDebugProxyCaptureStore: vi.fn(), - }; -}); +type StoreCall = { name: string; args: unknown[] }; -vi.mock("./store.sqlite.js", () => ({ - closeDebugProxyCaptureStore: storeState.closeDebugProxyCaptureStore, - getDebugProxyCaptureStore: () => storeState.store, +const settings: DebugProxySettings = { + enabled: true, + required: false, + dbPath: "/tmp/openclaw-proxy-runtime-test.sqlite", + blobDir: "/tmp/openclaw-proxy-runtime-test-blobs", + certDir: "/tmp/openclaw-proxy-runtime-test-certs", + sessionId: "runtime-test-session", + sourceProcess: "runtime-test", +}; + +const fetchTarget: typeof globalThis = { + ...globalThis, + fetch: async () => new Response("{}", { status: 200 }), +}; + +const events: Record[] = []; +const calls: StoreCall[] = []; +const store = { + upsertSession: (...args: unknown[]) => { + calls.push({ name: "upsertSession", args }); + }, + endSession: (...args: unknown[]) => { + calls.push({ name: "endSession", args }); + }, + recordEvent: (event: Record) => { + events.push(event); + }, +}; + +const deps: DebugProxyCaptureRuntimeDeps = { + fetchTarget, + getStore: () => store, + closeStore: () => { + calls.push({ name: "closeStore", args: [] }); + }, persistEventPayload: ( _store: unknown, payload: { data?: Buffer | string | null; contentType?: string }, @@ -32,87 +52,60 @@ vi.mock("./store.sqlite.js", () => ({ ...(typeof payload.data === "string" ? { dataText: payload.data } : {}), }), safeJsonString: (value: unknown) => (value == null ? undefined : JSON.stringify(value)), -})); +}; describe("debug proxy runtime", () => { - const envKeys = [ - "OPENCLAW_DEBUG_PROXY_ENABLED", - "OPENCLAW_DEBUG_PROXY_DB_PATH", - "OPENCLAW_DEBUG_PROXY_BLOB_DIR", - "OPENCLAW_DEBUG_PROXY_SESSION_ID", - "OPENCLAW_DEBUG_PROXY_SOURCE_PROCESS", - ] as const; - const savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); - const originalFetch = globalThis.fetch; - beforeEach(() => { - storeState.events.length = 0; - storeState.store.upsertSession.mockClear(); - storeState.store.endSession.mockClear(); - storeState.store.recordEvent.mockClear(); - storeState.closeDebugProxyCaptureStore.mockClear(); - process.env.OPENCLAW_DEBUG_PROXY_ENABLED = "1"; - process.env.OPENCLAW_DEBUG_PROXY_DB_PATH = "/tmp/openclaw-proxy-runtime-test.sqlite"; - process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR = "/tmp/openclaw-proxy-runtime-test-blobs"; - process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID = "runtime-test-session"; - process.env.OPENCLAW_DEBUG_PROXY_SOURCE_PROCESS = "runtime-test"; - }); - - afterEach(() => { - finalizeDebugProxyCapture(); - globalThis.fetch = originalFetch; - for (const key of envKeys) { - const value = savedEnv[key]; - if (value == null) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } + finalizeDebugProxyCapture(settings, deps); + events.length = 0; + calls.length = 0; + fetchTarget.fetch = async () => new Response("{}", { status: 200 }); }); it("captures ambient global fetch calls when debug proxy mode is enabled", async () => { - globalThis.fetch = vi.fn(async () => new Response("{}", { status: 200 })) as typeof fetch; - - initializeDebugProxyCapture("test"); - await globalThis.fetch("https://api.minimax.io/anthropic/messages", { + initializeDebugProxyCapture("test", settings, deps); + await fetchTarget.fetch("https://api.minimax.io/anthropic/messages", { method: "POST", headers: { "content-type": "application/json" }, body: '{"input":"hello"}', }); await new Promise((resolve) => setImmediate(resolve)); - finalizeDebugProxyCapture(); + finalizeDebugProxyCapture(settings, deps); - const events = storeState.events.filter((event) => event.sessionId === "runtime-test-session"); - expect(events.some((event) => event.host === "api.minimax.io")).toBe(true); - expect(events.some((event) => event.kind === "request")).toBe(true); - expect(events.some((event) => event.kind === "response")).toBe(true); + const sessionEvents = events.filter((event) => event.sessionId === "runtime-test-session"); + expect(sessionEvents.some((event) => event.host === "api.minimax.io")).toBe(true); + expect(sessionEvents.some((event) => event.kind === "request")).toBe(true); + expect(sessionEvents.some((event) => event.kind === "response")).toBe(true); }); it("redacts sensitive request and response headers before persistence", async () => { - initializeDebugProxyCapture("test"); - captureHttpExchange({ - url: "https://discord.com/api/v10/gateway/bot", - method: "GET", - requestHeaders: { - Authorization: "Bot discord-token", - Cookie: "sid=session-token", - "x-api-key": "provider-key", - "content-type": "application/json", - "x-safe": "visible", - }, - response: new Response("{}", { - status: 200, - headers: { + initializeDebugProxyCapture("test", settings, deps); + captureHttpExchange( + { + url: "https://discord.com/api/v10/gateway/bot", + method: "GET", + requestHeaders: { + Authorization: "Bot discord-token", + Cookie: "sid=session-token", + "x-api-key": "provider-key", "content-type": "application/json", - "set-cookie": "sid=response-token", + "x-safe": "visible", }, - }), - }); + response: new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "set-cookie": "sid=response-token", + }, + }), + }, + settings, + deps, + ); await new Promise((resolve) => setImmediate(resolve)); - finalizeDebugProxyCapture(); + finalizeDebugProxyCapture(settings, deps); - const request = storeState.events.find((event) => event.kind === "request"); + const request = events.find((event) => event.kind === "request"); expect(JSON.parse(String(request?.headersJson))).toMatchObject({ Authorization: "[REDACTED]", Cookie: "[REDACTED]", @@ -120,7 +113,7 @@ describe("debug proxy runtime", () => { "content-type": "application/json", "x-safe": "visible", }); - const response = storeState.events.find((event) => event.kind === "response"); + const response = events.find((event) => event.kind === "response"); expect(JSON.parse(String(response?.headersJson))).toMatchObject({ "content-type": "application/json", "set-cookie": "[REDACTED]", diff --git a/src/proxy-capture/runtime.ts b/src/proxy-capture/runtime.ts index 33b3ed8f8bc..4ae326a8118 100644 --- a/src/proxy-capture/runtime.ts +++ b/src/proxy-capture/runtime.ts @@ -47,6 +47,35 @@ type GlobalFetchPatchTarget = typeof globalThis & { [DEBUG_PROXY_FETCH_PATCH_KEY]?: GlobalFetchPatchedState; }; +type DebugProxyCaptureStoreLike = Pick< + ReturnType, + "upsertSession" | "endSession" | "recordEvent" +>; + +export type DebugProxyCaptureRuntimeDeps = { + getStore?: (dbPath: string, blobDir: string) => DebugProxyCaptureStoreLike; + closeStore?: typeof closeDebugProxyCaptureStore; + persistEventPayload?: ( + store: DebugProxyCaptureStoreLike, + payload: Parameters[1], + ) => ReturnType; + safeJsonString?: typeof safeJsonString; + fetchTarget?: typeof globalThis; +}; + +function resolveRuntimeDeps(deps: DebugProxyCaptureRuntimeDeps = {}) { + return { + getStore: deps.getStore ?? getDebugProxyCaptureStore, + closeStore: deps.closeStore ?? closeDebugProxyCaptureStore, + persistEventPayload: + deps.persistEventPayload ?? + ((store, payload) => + persistEventPayload(store as ReturnType, payload)), + safeJsonString: deps.safeJsonString ?? safeJsonString, + fetchTarget: deps.fetchTarget ?? globalThis, + }; +} + function protocolFromUrl(rawUrl: string): CaptureProtocol { try { const url = new URL(rawUrl); @@ -129,51 +158,59 @@ function createHttpCaptureEventBase(params: { }; } -function installDebugProxyGlobalFetchPatch(settings: DebugProxySettings): void { - if (typeof globalThis.fetch !== "function") { +function installDebugProxyGlobalFetchPatch( + settings: DebugProxySettings, + deps: DebugProxyCaptureRuntimeDeps = {}, +): void { + const runtime = resolveRuntimeDeps(deps); + const fetchTarget = runtime.fetchTarget as GlobalFetchPatchTarget; + if (typeof fetchTarget.fetch !== "function") { return; } - const patched = globalThis as GlobalFetchPatchTarget; - if (patched[DEBUG_PROXY_FETCH_PATCH_KEY]) { + if (fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY]) { return; } - const originalFetch = globalThis.fetch.bind(globalThis); - patched[DEBUG_PROXY_FETCH_PATCH_KEY] = { originalFetch }; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const originalFetch = fetchTarget.fetch.bind(fetchTarget); + fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY] = { originalFetch }; + fetchTarget.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = resolveUrlString(input); try { const response = await originalFetch(input, init); if (url && /^https?:/i.test(url)) { - captureHttpExchange({ - url, - method: - (typeof Request !== "undefined" && input instanceof Request - ? input.method - : undefined) ?? - init?.method ?? - "GET", - requestHeaders: - (typeof Request !== "undefined" && input instanceof Request - ? input.headers - : undefined) ?? (init?.headers as Headers | Record | undefined), - requestBody: - (typeof Request !== "undefined" && input instanceof Request - ? (input as Request & { body?: BodyInit | null }).body - : undefined) ?? - (init as (RequestInit & { body?: BodyInit | null }) | undefined)?.body ?? - null, - response, - transport: "http", - meta: { - captureOrigin: "global-fetch", - source: settings.sourceProcess, + captureHttpExchange( + { + url, + method: + (typeof Request !== "undefined" && input instanceof Request + ? input.method + : undefined) ?? + init?.method ?? + "GET", + requestHeaders: + (typeof Request !== "undefined" && input instanceof Request + ? input.headers + : undefined) ?? (init?.headers as Headers | Record | undefined), + requestBody: + (typeof Request !== "undefined" && input instanceof Request + ? (input as Request & { body?: BodyInit | null }).body + : undefined) ?? + (init as (RequestInit & { body?: BodyInit | null }) | undefined)?.body ?? + null, + response, + transport: "http", + meta: { + captureOrigin: "global-fetch", + source: settings.sourceProcess, + }, }, - }); + settings, + deps, + ); } return response; } catch (error) { if (url && /^https?:/i.test(url)) { - const store = getDebugProxyCaptureStore(settings.dbPath, settings.blobDir); + const store = runtime.getStore(settings.dbPath, settings.blobDir); const parsed = new URL(url); store.recordEvent({ sessionId: settings.sessionId, @@ -193,7 +230,7 @@ function installDebugProxyGlobalFetchPatch(settings: DebugProxySettings): void { host: parsed.host, path: `${parsed.pathname}${parsed.search}`, errorText: error instanceof Error ? error.message : String(error), - metaJson: safeJsonString({ captureOrigin: "global-fetch" }), + metaJson: runtime.safeJsonString({ captureOrigin: "global-fetch" }), }); } throw error; @@ -201,26 +238,30 @@ function installDebugProxyGlobalFetchPatch(settings: DebugProxySettings): void { }) as typeof globalThis.fetch; } -function uninstallDebugProxyGlobalFetchPatch(): void { - const patched = globalThis as GlobalFetchPatchTarget; - const state = patched[DEBUG_PROXY_FETCH_PATCH_KEY]; +function uninstallDebugProxyGlobalFetchPatch(deps: DebugProxyCaptureRuntimeDeps = {}): void { + const fetchTarget = resolveRuntimeDeps(deps).fetchTarget as GlobalFetchPatchTarget; + const state = fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY]; if (!state) { return; } - globalThis.fetch = state.originalFetch; - delete patched[DEBUG_PROXY_FETCH_PATCH_KEY]; + fetchTarget.fetch = state.originalFetch; + delete fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY]; } export function isDebugProxyGlobalFetchPatchInstalled(): boolean { return Boolean((globalThis as GlobalFetchPatchTarget)[DEBUG_PROXY_FETCH_PATCH_KEY]); } -export function initializeDebugProxyCapture(mode: string, resolved?: DebugProxySettings): void { +export function initializeDebugProxyCapture( + mode: string, + resolved?: DebugProxySettings, + deps: DebugProxyCaptureRuntimeDeps = {}, +): void { const settings = resolved ?? resolveDebugProxySettings(); if (!settings.enabled) { return; } - getDebugProxyCaptureStore(settings.dbPath, settings.blobDir).upsertSession({ + resolveRuntimeDeps(deps).getStore(settings.dbPath, settings.blobDir).upsertSession({ id: settings.sessionId, startedAt: Date.now(), mode, @@ -230,41 +271,50 @@ export function initializeDebugProxyCapture(mode: string, resolved?: DebugProxyS dbPath: settings.dbPath, blobDir: settings.blobDir, }); - installDebugProxyGlobalFetchPatch(settings); + installDebugProxyGlobalFetchPatch(settings, deps); } -export function finalizeDebugProxyCapture(resolved?: DebugProxySettings): void { +export function finalizeDebugProxyCapture( + resolved?: DebugProxySettings, + deps: DebugProxyCaptureRuntimeDeps = {}, +): void { const settings = resolved ?? resolveDebugProxySettings(); if (!settings.enabled) { return; } - getDebugProxyCaptureStore(settings.dbPath, settings.blobDir).endSession(settings.sessionId); - uninstallDebugProxyGlobalFetchPatch(); - closeDebugProxyCaptureStore(); + const runtime = resolveRuntimeDeps(deps); + runtime.getStore(settings.dbPath, settings.blobDir).endSession(settings.sessionId); + uninstallDebugProxyGlobalFetchPatch(deps); + runtime.closeStore(); } -export function captureHttpExchange(params: { - url: string; - method: string; - requestHeaders?: Headers | Record | undefined; - requestBody?: BodyInit | Buffer | string | null; - response: Response; - transport?: "http" | "sse"; - flowId?: string; - meta?: Record; -}): void { - const settings = resolveDebugProxySettings(); +export function captureHttpExchange( + params: { + url: string; + method: string; + requestHeaders?: Headers | Record | undefined; + requestBody?: BodyInit | Buffer | string | null; + response: Response; + transport?: "http" | "sse"; + flowId?: string; + meta?: Record; + }, + resolved?: DebugProxySettings, + deps: DebugProxyCaptureRuntimeDeps = {}, +): void { + const settings = resolved ?? resolveDebugProxySettings(); if (!settings.enabled) { return; } - const store = getDebugProxyCaptureStore(settings.dbPath, settings.blobDir); + const runtime = resolveRuntimeDeps(deps); + const store = runtime.getStore(settings.dbPath, settings.blobDir); const flowId = params.flowId ?? randomUUID(); const url = new URL(params.url); const requestBody = typeof params.requestBody === "string" || Buffer.isBuffer(params.requestBody) ? params.requestBody : null; - const requestPayload = persistEventPayload(store, { + const requestPayload = runtime.persistEventPayload(store, { data: requestBody, contentType: params.requestHeaders instanceof Headers @@ -286,8 +336,8 @@ export function captureHttpExchange(params: { params.requestHeaders instanceof Headers ? (params.requestHeaders.get("content-type") ?? undefined) : params.requestHeaders?.["content-type"], - headersJson: safeJsonString(redactedCaptureHeaders(params.requestHeaders)), - metaJson: safeJsonString(params.meta), + headersJson: runtime.safeJsonString(redactedCaptureHeaders(params.requestHeaders)), + metaJson: runtime.safeJsonString(params.meta), ...requestPayload, }); const cloneable = @@ -313,9 +363,9 @@ export function captureHttpExchange(params: { : undefined, headersJson: params.response.headers && typeof params.response.headers.entries === "function" - ? safeJsonString(redactedCaptureHeaders(params.response.headers)) + ? runtime.safeJsonString(redactedCaptureHeaders(params.response.headers)) : undefined, - metaJson: safeJsonString({ ...params.meta, bodyCapture: "unavailable" }), + metaJson: runtime.safeJsonString({ ...params.meta, bodyCapture: "unavailable" }), }); return; } @@ -323,7 +373,7 @@ export function captureHttpExchange(params: { .clone() .arrayBuffer() .then((buffer) => { - const responsePayload = persistEventPayload(store, { + const responsePayload = runtime.persistEventPayload(store, { data: Buffer.from(buffer), contentType: params.response.headers.get("content-type") ?? undefined, }); @@ -340,8 +390,8 @@ export function captureHttpExchange(params: { }), status: params.response.status, contentType: params.response.headers.get("content-type") ?? undefined, - headersJson: safeJsonString(redactedCaptureHeaders(params.response.headers)), - metaJson: safeJsonString(params.meta), + headersJson: runtime.safeJsonString(redactedCaptureHeaders(params.response.headers)), + metaJson: runtime.safeJsonString(params.meta), ...responsePayload, }); }) diff --git a/src/video-generation/runtime.ts b/src/video-generation/runtime.ts index 192f2837636..783bf5fba47 100644 --- a/src/video-generation/runtime.ts +++ b/src/video-generation/runtime.ts @@ -8,6 +8,7 @@ import { resolveCapabilityModelCandidates, throwCapabilityGenerationFailure, } from "../media-generation/runtime-shared.js"; +import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; import { resolveVideoGenerationModeCapabilities } from "./capabilities.js"; import { resolveVideoGenerationSupportedDurations } from "./duration-support.js"; import { parseVideoGenerationModelRef } from "./model-ref.js"; @@ -17,6 +18,14 @@ import type { GenerateVideoParams, GenerateVideoRuntimeResult } from "./runtime- import type { VideoGenerationProviderOptionType, VideoGenerationResult } from "./types.js"; const log = createSubsystemLogger("video-generation"); + +export type VideoGenerationRuntimeDeps = { + getProvider?: typeof getVideoGenerationProvider; + listProviders?: typeof listVideoGenerationProviders; + getProviderEnvVars?: typeof getProviderEnvVars; + log?: Pick; +}; + export type { GenerateVideoParams, GenerateVideoRuntimeResult } from "./runtime-types.js"; /** @@ -73,31 +82,43 @@ function validateProviderOptionsAgainstDeclaration(params: { return undefined; } -function buildNoVideoGenerationModelConfiguredMessage(cfg: OpenClawConfig): string { +function buildNoVideoGenerationModelConfiguredMessage( + cfg: OpenClawConfig, + deps: VideoGenerationRuntimeDeps, +): string { + const listProviders = deps.listProviders ?? listVideoGenerationProviders; return buildNoCapabilityModelConfiguredMessage({ capabilityLabel: "video-generation", modelConfigKey: "videoGenerationModel", - providers: listVideoGenerationProviders(cfg), + providers: listProviders(cfg), + getProviderEnvVars: deps.getProviderEnvVars, }); } -export function listRuntimeVideoGenerationProviders(params?: { config?: OpenClawConfig }) { - return listVideoGenerationProviders(params?.config); +export function listRuntimeVideoGenerationProviders( + params?: { config?: OpenClawConfig }, + deps: VideoGenerationRuntimeDeps = {}, +) { + return (deps.listProviders ?? listVideoGenerationProviders)(params?.config); } export async function generateVideo( params: GenerateVideoParams, + deps: VideoGenerationRuntimeDeps = {}, ): Promise { + const getProvider = deps.getProvider ?? getVideoGenerationProvider; + const listProviders = deps.listProviders ?? listVideoGenerationProviders; + const logger = deps.log ?? log; const candidates = resolveCapabilityModelCandidates({ cfg: params.cfg, modelConfig: params.cfg.agents?.defaults?.videoGenerationModel, modelOverride: params.modelOverride, parseModelRef: parseVideoGenerationModelRef, agentDir: params.agentDir, - listProviders: listVideoGenerationProviders, + listProviders, }); if (candidates.length === 0) { - throw new Error(buildNoVideoGenerationModelConfiguredMessage(params.cfg)); + throw new Error(buildNoVideoGenerationModelConfiguredMessage(params.cfg, deps)); } const attempts: FallbackAttempt[] = []; @@ -110,12 +131,12 @@ export async function generateVideo( // passed over without flooding logs on long fallback chains. if (!skipWarnEmitted) { skipWarnEmitted = true; - log.warn(`video-generation candidate skipped: ${reason}`); + logger.warn(`video-generation candidate skipped: ${reason}`); } }; for (const candidate of candidates) { - const provider = getVideoGenerationProvider(candidate.provider, params.cfg); + const provider = getProvider(candidate.provider, params.cfg); if (!provider) { const error = `No video-generation provider registered for ${candidate.provider}`; attempts.push({ @@ -151,7 +172,7 @@ export async function generateVideo( attempts.push({ provider: candidate.provider, model: candidate.model, error }); lastError = new Error(error); warnOnFirstSkip(error); - log.debug( + logger.debug( `video-generation candidate skipped (audio capability): ${candidate.provider}/${candidate.model}`, ); continue; @@ -188,7 +209,7 @@ export async function generateVideo( attempts.push({ provider: candidate.provider, model: candidate.model, error: mismatch }); lastError = new Error(mismatch); warnOnFirstSkip(mismatch); - log.debug( + logger.debug( `video-generation candidate skipped (providerOptions): ${candidate.provider}/${candidate.model}`, ); continue; @@ -226,7 +247,7 @@ export async function generateVideo( attempts.push({ provider: candidate.provider, model: candidate.model, error }); lastError = new Error(error); warnOnFirstSkip(error); - log.debug( + logger.debug( `video-generation candidate skipped (duration capability): ${candidate.provider}/${candidate.model}`, ); continue; @@ -299,7 +320,7 @@ export async function generateVideo( model: candidate.model, error: err, }); - log.debug(`video-generation candidate failed: ${candidate.provider}/${candidate.model}`); + logger.debug(`video-generation candidate failed: ${candidate.provider}/${candidate.model}`); } } diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index 17f10e272c0..dede296ea15 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -68,6 +68,7 @@ export const forcedUnitFastTestFiles = [ "src/acp/persistent-bindings.test.ts", "src/acp/server.startup.test.ts", "src/acp/translator.session-rate-limit.test.ts", + "src/acp/translator.set-session-mode.test.ts", "src/browser-lifecycle-cleanup.test.ts", "src/canvas-host/server.test.ts", "src/crestodian/audit.test.ts", @@ -96,6 +97,7 @@ export const forcedUnitFastTestFiles = [ "src/memory-host-sdk/host/embeddings-remote-fetch.test.ts", "src/memory-host-sdk/host/post-json.test.ts", "src/memory-host-sdk/host/session-files.test.ts", + "src/music-generation/runtime.test.ts", "src/mcp/channel-server.shutdown-unhandled-rejection.test.ts", "src/node-host/invoke-system-run-plan.test.ts", "src/node-host/invoke-system-run.test.ts", @@ -104,6 +106,7 @@ export const forcedUnitFastTestFiles = [ "src/pairing/setup-code.test.ts", "src/plugin-activation-boundary.test.ts", "src/plugin-sdk/memory-host-events.test.ts", + "src/proxy-capture/runtime.test.ts", "src/proxy-capture/store.sqlite.test.ts", "src/security/audit-exec-surface.test.ts", "src/security/audit-extra.async.test.ts",