From 8806ef804e7f9b055e4568fed48597345e438f4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 20:48:00 +0100 Subject: [PATCH] refactor: remove remaining channel and gateway boundary leaks --- .../channel-import-guardrails.test.ts | 61 ++-- ...sion-conversation.bundled-fallback.test.ts | 11 - src/channels/plugins/session-conversation.ts | 14 - ...server.agent.gateway-server-agent.mocks.ts | 2 +- .../server.chat.gateway-server-chat.test.ts | 2 +- src/gateway/server.preauth-hardening.test.ts | 2 +- src/gateway/session-message-events.test.ts | 2 +- src/gateway/sessions-history-http.test.ts | 2 +- src/gateway/test-helpers.config-runtime.ts | 295 ++++++++++++++++++ src/gateway/test-helpers.mocks.ts | 290 +---------------- src/gateway/test-helpers.server.ts | 4 +- src/gateway/test-helpers.ts | 2 + 12 files changed, 344 insertions(+), 343 deletions(-) create mode 100644 src/gateway/test-helpers.config-runtime.ts diff --git a/src/channels/plugins/contracts/channel-import-guardrails.test.ts b/src/channels/plugins/contracts/channel-import-guardrails.test.ts index be0bc50c7c..053d8ee665 100644 --- a/src/channels/plugins/contracts/channel-import-guardrails.test.ts +++ b/src/channels/plugins/contracts/channel-import-guardrails.test.ts @@ -3,21 +3,24 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { classifyBundledExtensionSourcePath } from "../../../../scripts/lib/extension-source-classifier.mjs"; -import { - BUNDLED_PLUGIN_PATH_PREFIX, - BUNDLED_PLUGIN_ROOT_DIR, - bundledPluginFile, -} from "../../../../test/helpers/bundled-plugin-paths.js"; +import { loadPluginManifestRegistry } from "../../../plugins/manifest-registry.js"; import { GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES } from "../../../plugins/public-artifacts.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const REPO_ROOT = resolve(ROOT_DIR, ".."); const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set(GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES); ALLOWED_EXTENSION_PUBLIC_SURFACES.add("test-api.js"); -const BUNDLED_EXTENSION_IDS = readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && entry.name !== "shared") - .map((entry) => entry.name) - .toSorted((left, right) => right.length - left.length); +const BUNDLED_PLUGIN_ROOT_DIR = "extensions"; +const bundledPluginRecords = loadPluginManifestRegistry({ + cache: true, + config: {}, +}).plugins.filter((plugin) => plugin.origin === "bundled"); +const bundledPluginRoots = new Map( + bundledPluginRecords.map((plugin) => [plugin.id, plugin.rootDir] as const), +); +const BUNDLED_EXTENSION_IDS = [...bundledPluginRoots.keys()].toSorted( + (left, right) => right.length - left.length, +); const GUARDED_CHANNEL_EXTENSIONS = new Set([ "bluebubbles", "discord", @@ -44,6 +47,14 @@ const GUARDED_CHANNEL_EXTENSIONS = new Set([ // Shared config validation intentionally consumes this curated Telegram contract. const ALLOWED_CORE_CHANNEL_SDK_SUBPATHS = new Set(["telegram-command-config"]); +function bundledPluginFile(pluginId: string, relativePath: string): string { + const rootDir = bundledPluginRoots.get(pluginId); + if (!rootDir) { + throw new Error(`missing bundled plugin root for ${pluginId}`); + } + return normalizePath(resolve(rootDir, relativePath)); +} + type GuardedSource = { path: string; forbiddenPatterns: RegExp[]; @@ -320,18 +331,18 @@ function readSetupBarrelImportBlock(path: string): string { } function collectExtensionSourceFiles(): string[] { - const extensionsDir = normalizePath(resolve(ROOT_DIR, "..", "extensions")); - const sharedExtensionsDir = normalizePath(resolve(extensionsDir, "shared")); - extensionSourceFilesCache = collectSourceFiles(extensionSourceFilesCache, { - rootDir: resolve(ROOT_DIR, "..", "extensions"), - shouldSkipPath: (normalizedFullPath) => - normalizedFullPath.includes(sharedExtensionsDir) || - normalizedFullPath.includes(`${extensionsDir}/shared/`), - shouldSkipEntry: ({ entryName, normalizedFullPath }) => - classifyBundledExtensionSourcePath(normalizedFullPath).isTestLike || - entryName === "api.ts" || - entryName === "runtime-api.ts", - }); + if (extensionSourceFilesCache) { + return extensionSourceFilesCache; + } + extensionSourceFilesCache = bundledPluginRecords.flatMap((plugin) => + collectSourceFiles(undefined, { + rootDir: plugin.rootDir, + shouldSkipEntry: ({ entryName, normalizedFullPath }) => + classifyBundledExtensionSourcePath(normalizedFullPath).isTestLike || + entryName === "api.ts" || + entryName === "runtime-api.ts", + }), + ); return extensionSourceFilesCache; } @@ -361,8 +372,12 @@ function collectCoreSourceFiles(): string[] { function collectExtensionFiles(extensionId: string): string[] { const cached = extensionFilesCache.get(extensionId); + const rootDir = bundledPluginRoots.get(extensionId); + if (!rootDir) { + return []; + } const files = collectSourceFiles(cached, { - rootDir: resolve(ROOT_DIR, "..", "extensions", extensionId), + rootDir, shouldSkipEntry: ({ entryName, normalizedFullPath }) => classifyBundledExtensionSourcePath(normalizedFullPath).isTestLike || entryName === "runtime-api.ts", @@ -406,7 +421,7 @@ function getSourceAnalysis(path: string): SourceAnalysis { text, importSpecifiers, extensionImports: importSpecifiers.filter((specifier) => - specifier.includes(BUNDLED_PLUGIN_PATH_PREFIX), + specifier.includes(`/${BUNDLED_PLUGIN_ROOT_DIR}/`), ), } satisfies SourceAnalysis; sourceAnalysisCache.set(fullPath, analysis); diff --git a/src/channels/plugins/session-conversation.bundled-fallback.test.ts b/src/channels/plugins/session-conversation.bundled-fallback.test.ts index 3bfa9d8aff..d1c6f99cde 100644 --- a/src/channels/plugins/session-conversation.bundled-fallback.test.ts +++ b/src/channels/plugins/session-conversation.bundled-fallback.test.ts @@ -27,17 +27,6 @@ vi.mock("../../plugin-sdk/facade-runtime.js", async () => { }; }); -vi.mock("../../plugins/bundled-plugin-metadata.js", async () => { - const actual = await vi.importActual( - "../../plugins/bundled-plugin-metadata.js", - ); - return { - ...actual, - resolveBundledPluginPublicSurfacePath: ({ dirName }: { dirName: string }) => - dirName === fallbackState.activeDirName ? `/tmp/${dirName}/session-key-api.js` : null, - }; -}); - import { resolveSessionConversationRef } from "./session-conversation.js"; describe("session conversation bundled fallback", () => { diff --git a/src/channels/plugins/session-conversation.ts b/src/channels/plugins/session-conversation.ts index e1190cdcc8..5e5644290e 100644 --- a/src/channels/plugins/session-conversation.ts +++ b/src/channels/plugins/session-conversation.ts @@ -1,7 +1,4 @@ -import { fileURLToPath } from "node:url"; import { tryLoadActivatedBundledPluginPublicSurfaceModuleSync } from "../../plugin-sdk/facade-runtime.js"; -import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js"; -import { resolveBundledPluginPublicSurfacePath } from "../../plugins/bundled-plugin-metadata.js"; import { parseRawSessionConversationRef, parseThreadSessionSuffix, @@ -47,7 +44,6 @@ type BundledSessionKeyModule = { ) => SessionConversationHookResult | null; }; -const OPENCLAW_PACKAGE_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const SESSION_KEY_API_ARTIFACT_BASENAME = "session-key-api.js"; type NormalizedSessionConversationResolution = ResolvedSessionConversation & { @@ -140,16 +136,6 @@ function resolveBundledSessionConversationFallback(params: { rawId: string; }): NormalizedSessionConversationResolution | null { const dirName = normalizeResolvedChannel(params.channel); - if ( - !resolveBundledPluginPublicSurfacePath({ - rootDir: OPENCLAW_PACKAGE_ROOT, - bundledPluginsDir: resolveBundledPluginsDir(), - dirName, - artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME, - }) - ) { - return null; - } let resolveSessionConversation: BundledSessionKeyModule["resolveSessionConversation"]; try { resolveSessionConversation = diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index 3bf5b8bd0d..acb5970daf 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -1,7 +1,7 @@ import { vi } from "vitest"; import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { setTestPluginRegistry } from "./test-helpers.mocks.js"; +import { setTestPluginRegistry } from "./test-helpers.plugin-registry.js"; export const registryState: { registry: PluginRegistry } = { registry: createEmptyPluginRegistry(), diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 060a110d66..c2bed9917f 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -18,7 +18,7 @@ import { withGatewayServer, writeSessionStore, } from "./test-helpers.js"; -import { agentCommand } from "./test-helpers.mocks.js"; +import { agentCommand } from "./test-helpers.runtime-state.js"; import { installConnectedControlUiServerSuite } from "./test-with-server.js"; installGatewayTestHooks({ scope: "suite" }); diff --git a/src/gateway/server.preauth-hardening.test.ts b/src/gateway/server.preauth-hardening.test.ts index 3796eda50d..70cbec2388 100644 --- a/src/gateway/server.preauth-hardening.test.ts +++ b/src/gateway/server.preauth-hardening.test.ts @@ -6,7 +6,7 @@ import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js"; import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js"; import { createPreauthConnectionBudget } from "./server/preauth-connection-budget.js"; import type { GatewayWsClient } from "./server/ws-types.js"; -import { testState } from "./test-helpers.mocks.js"; +import { testState } from "./test-helpers.runtime-state.js"; import { createGatewaySuiteHarness, installGatewayTestHooks, diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index 7bc3d7b353..02001c6b43 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js"; import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; -import { testState } from "./test-helpers.mocks.js"; +import { testState } from "./test-helpers.runtime-state.js"; import { connectOk, createGatewaySuiteHarness, diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index c8dfc74b3d..1574bab799 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js"; -import { testState } from "./test-helpers.mocks.js"; +import { testState } from "./test-helpers.runtime-state.js"; import { connectReq, createGatewaySuiteHarness, diff --git a/src/gateway/test-helpers.config-runtime.ts b/src/gateway/test-helpers.config-runtime.ts new file mode 100644 index 0000000000..7f3f62f2f0 --- /dev/null +++ b/src/gateway/test-helpers.config-runtime.ts @@ -0,0 +1,295 @@ +import crypto from "node:crypto"; +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { vi } from "vitest"; +import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import type { AgentBinding } from "../config/types.agents.js"; +import type { OpenClawConfig } from "../config/types.js"; +import { testConfigRoot, testIsNixMode, testState } from "./test-helpers.runtime-state.js"; + +type GatewayConfigModule = typeof import("../config/config.js"); + +export function createGatewayConfigModuleMock(actual: GatewayConfigModule): GatewayConfigModule { + const resolveConfigPath = () => path.join(testConfigRoot.value, "openclaw.json"); + const hashConfigRaw = (raw: string | null) => + crypto + .createHash("sha256") + .update(raw ?? "") + .digest("hex"); + + const composeTestConfig = (baseConfig: Record) => { + const fileAgents = + baseConfig.agents && + typeof baseConfig.agents === "object" && + !Array.isArray(baseConfig.agents) + ? (baseConfig.agents as Record) + : {}; + const fileDefaults = + fileAgents.defaults && + typeof fileAgents.defaults === "object" && + !Array.isArray(fileAgents.defaults) + ? (fileAgents.defaults as Record) + : {}; + const defaults = { + model: { primary: "anthropic/claude-opus-4-6" }, + workspace: path.join(os.tmpdir(), "openclaw-gateway-test"), + ...fileDefaults, + ...testState.agentConfig, + }; + const agents = testState.agentsConfig + ? { ...fileAgents, ...testState.agentsConfig, defaults } + : { ...fileAgents, defaults }; + + const fileBindings = Array.isArray(baseConfig.bindings) + ? (baseConfig.bindings as AgentBinding[]) + : undefined; + + const fileChannels = + baseConfig.channels && + typeof baseConfig.channels === "object" && + !Array.isArray(baseConfig.channels) + ? ({ ...(baseConfig.channels as Record) } as Record) + : {}; + const overrideChannels = + testState.channelsConfig && typeof testState.channelsConfig === "object" + ? { ...testState.channelsConfig } + : {}; + const mergedChannels = { ...fileChannels, ...overrideChannels }; + if (testState.allowFrom !== undefined) { + const existing = + mergedChannels.whatsapp && + typeof mergedChannels.whatsapp === "object" && + !Array.isArray(mergedChannels.whatsapp) + ? (mergedChannels.whatsapp as Record) + : {}; + mergedChannels.whatsapp = { + ...existing, + allowFrom: testState.allowFrom, + }; + } + const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined; + + const fileSession = + baseConfig.session && + typeof baseConfig.session === "object" && + !Array.isArray(baseConfig.session) + ? (baseConfig.session as Record) + : {}; + const session: Record = { + ...fileSession, + mainKey: fileSession.mainKey ?? "main", + }; + if (typeof testState.sessionStorePath === "string") { + session.store = testState.sessionStorePath; + } + if (testState.sessionConfig) { + Object.assign(session, testState.sessionConfig); + } + + const fileGateway = + baseConfig.gateway && + typeof baseConfig.gateway === "object" && + !Array.isArray(baseConfig.gateway) + ? ({ ...(baseConfig.gateway as Record) } as Record) + : {}; + if (testState.gatewayBind) { + fileGateway.bind = testState.gatewayBind; + } + if (testState.gatewayAuth) { + fileGateway.auth = testState.gatewayAuth; + } + if (testState.gatewayControlUi) { + const fileControlUi = + fileGateway.controlUi && + typeof fileGateway.controlUi === "object" && + !Array.isArray(fileGateway.controlUi) + ? (fileGateway.controlUi as Record) + : {}; + fileGateway.controlUi = { + ...fileControlUi, + ...testState.gatewayControlUi, + }; + } + const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined; + + const fileCanvasHost = + baseConfig.canvasHost && + typeof baseConfig.canvasHost === "object" && + !Array.isArray(baseConfig.canvasHost) + ? ({ ...(baseConfig.canvasHost as Record) } as Record) + : {}; + if (typeof testState.canvasHostPort === "number") { + fileCanvasHost.port = testState.canvasHostPort; + } + const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined; + + const hooks = testState.hooksConfig ?? baseConfig.hooks; + + const fileCron = + baseConfig.cron && typeof baseConfig.cron === "object" && !Array.isArray(baseConfig.cron) + ? ({ ...(baseConfig.cron as Record) } as Record) + : {}; + if (typeof testState.cronEnabled === "boolean") { + fileCron.enabled = testState.cronEnabled; + } + if (typeof testState.cronStorePath === "string") { + fileCron.store = testState.cronStorePath; + } + const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined; + + return { + ...baseConfig, + agents, + bindings: testState.bindingsConfig ?? fileBindings, + channels, + session, + gateway, + canvasHost, + hooks, + cron, + } as OpenClawConfig; + }; + + const readConfigFileSnapshot = async () => { + if (testState.legacyIssues.length > 0) { + const raw = JSON.stringify(testState.legacyParsed ?? {}); + return { + path: resolveConfigPath(), + exists: true, + raw, + parsed: testState.legacyParsed ?? {}, + valid: false, + config: {}, + hash: hashConfigRaw(raw), + issues: testState.legacyIssues.map((issue) => ({ + path: issue.path, + message: issue.message, + })), + legacyIssues: testState.legacyIssues, + }; + } + const configPath = resolveConfigPath(); + try { + await fs.access(configPath); + } catch { + return { + path: configPath, + exists: false, + raw: null, + parsed: {}, + valid: true, + config: composeTestConfig({}), + hash: hashConfigRaw(null), + issues: [], + legacyIssues: [], + }; + } + try { + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + return { + path: configPath, + exists: true, + raw, + parsed, + valid: true, + config: composeTestConfig(parsed), + hash: hashConfigRaw(raw), + issues: [], + legacyIssues: [], + }; + } catch (err) { + return { + path: configPath, + exists: true, + raw: null, + parsed: {}, + valid: false, + config: {}, + hash: hashConfigRaw(null), + issues: [{ path: "", message: `read failed: ${String(err)}` }], + legacyIssues: [], + }; + } + }; + + const writeConfigFile = vi.fn(async (cfg: Record) => { + const configPath = resolveConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); + await fs.writeFile(configPath, raw, "utf-8"); + actual.resetConfigRuntimeState(); + }); + + const readConfigFileSnapshotForWrite = async () => ({ + snapshot: await readConfigFileSnapshot(), + writeOptions: { + expectedConfigPath: resolveConfigPath(), + }, + }); + + const loadTestConfig = () => { + const configPath = resolveConfigPath(); + let fileConfig: Record = {}; + try { + if (fsSync.existsSync(configPath)) { + const raw = fsSync.readFileSync(configPath, "utf-8"); + fileConfig = JSON.parse(raw) as Record; + } + } catch { + fileConfig = {}; + } + return applyPluginAutoEnable({ + config: composeTestConfig(fileConfig), + env: process.env, + }).config; + }; + + const loadRuntimeAwareTestConfig = () => { + const runtimeSnapshot = actual.getRuntimeConfigSnapshot(); + if (runtimeSnapshot) { + return runtimeSnapshot; + } + const config = loadTestConfig(); + actual.setRuntimeConfigSnapshot(config); + return config; + }; + + return { + ...actual, + get CONFIG_PATH() { + return resolveConfigPath(); + }, + get STATE_DIR() { + return path.dirname(resolveConfigPath()); + }, + get isNixMode() { + return testIsNixMode.value; + }, + migrateLegacyConfig: (raw: unknown) => ({ + config: testState.migrationConfig ?? (raw as Record), + changes: testState.migrationChanges, + }), + applyConfigOverrides: (cfg: OpenClawConfig) => + composeTestConfig(cfg as Record), + loadConfig: loadRuntimeAwareTestConfig, + getRuntimeConfig: loadRuntimeAwareTestConfig, + parseConfigJson5: (raw: string) => { + try { + return { ok: true, parsed: JSON.parse(raw) as unknown }; + } catch (err) { + return { ok: false, error: String(err) }; + } + }, + validateConfigObject: (parsed: unknown) => ({ + ok: true, + config: parsed as Record, + issues: [], + }), + readConfigFileSnapshot, + readConfigFileSnapshotForWrite, + writeConfigFile, + }; +} diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index ab010a41ae..d44e543351 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -1,12 +1,6 @@ -import crypto from "node:crypto"; -import fsSync from "node:fs"; -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; -import type { AgentBinding } from "../config/types.agents.js"; +import { createGatewayConfigModuleMock } from "./test-helpers.config-runtime.js"; import { getTestPluginRegistry, resetTestPluginRegistry, @@ -26,7 +20,6 @@ import { sendWhatsAppMock, sessionStoreSaveDelayMs, setTestConfigRoot, - testConfigRoot, testIsNixMode, testState, testTailnetIPv4, @@ -194,286 +187,7 @@ vi.mock("../config/sessions.js", async () => { vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); - const resolveConfigPath = () => path.join(testConfigRoot.value, "openclaw.json"); - const hashConfigRaw = (raw: string | null) => - crypto - .createHash("sha256") - .update(raw ?? "") - .digest("hex"); - - const composeTestConfig = (baseConfig: Record) => { - const fileAgents = - baseConfig.agents && - typeof baseConfig.agents === "object" && - !Array.isArray(baseConfig.agents) - ? (baseConfig.agents as Record) - : {}; - const fileDefaults = - fileAgents.defaults && - typeof fileAgents.defaults === "object" && - !Array.isArray(fileAgents.defaults) - ? (fileAgents.defaults as Record) - : {}; - const defaults = { - model: { primary: "anthropic/claude-opus-4-6" }, - workspace: path.join(os.tmpdir(), "openclaw-gateway-test"), - ...fileDefaults, - ...testState.agentConfig, - }; - const agents = testState.agentsConfig - ? { ...fileAgents, ...testState.agentsConfig, defaults } - : { ...fileAgents, defaults }; - - const fileBindings = Array.isArray(baseConfig.bindings) - ? (baseConfig.bindings as AgentBinding[]) - : undefined; - - const fileChannels = - baseConfig.channels && - typeof baseConfig.channels === "object" && - !Array.isArray(baseConfig.channels) - ? ({ ...(baseConfig.channels as Record) } as Record) - : {}; - const overrideChannels = - testState.channelsConfig && typeof testState.channelsConfig === "object" - ? { ...testState.channelsConfig } - : {}; - const mergedChannels = { ...fileChannels, ...overrideChannels }; - if (testState.allowFrom !== undefined) { - const existing = - mergedChannels.whatsapp && - typeof mergedChannels.whatsapp === "object" && - !Array.isArray(mergedChannels.whatsapp) - ? (mergedChannels.whatsapp as Record) - : {}; - mergedChannels.whatsapp = { - ...existing, - allowFrom: testState.allowFrom, - }; - } - const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined; - - const fileSession = - baseConfig.session && - typeof baseConfig.session === "object" && - !Array.isArray(baseConfig.session) - ? (baseConfig.session as Record) - : {}; - const session: Record = { - ...fileSession, - mainKey: fileSession.mainKey ?? "main", - }; - if (typeof testState.sessionStorePath === "string") { - session.store = testState.sessionStorePath; - } - if (testState.sessionConfig) { - Object.assign(session, testState.sessionConfig); - } - - const fileGateway = - baseConfig.gateway && - typeof baseConfig.gateway === "object" && - !Array.isArray(baseConfig.gateway) - ? ({ ...(baseConfig.gateway as Record) } as Record) - : {}; - if (testState.gatewayBind) { - fileGateway.bind = testState.gatewayBind; - } - if (testState.gatewayAuth) { - fileGateway.auth = testState.gatewayAuth; - } - if (testState.gatewayControlUi) { - const fileControlUi = - fileGateway.controlUi && - typeof fileGateway.controlUi === "object" && - !Array.isArray(fileGateway.controlUi) - ? (fileGateway.controlUi as Record) - : {}; - fileGateway.controlUi = { - ...fileControlUi, - ...testState.gatewayControlUi, - }; - } - const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined; - - const fileCanvasHost = - baseConfig.canvasHost && - typeof baseConfig.canvasHost === "object" && - !Array.isArray(baseConfig.canvasHost) - ? ({ ...(baseConfig.canvasHost as Record) } as Record) - : {}; - if (typeof testState.canvasHostPort === "number") { - fileCanvasHost.port = testState.canvasHostPort; - } - const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined; - - const hooks = testState.hooksConfig ?? baseConfig.hooks; - - const fileCron = - baseConfig.cron && typeof baseConfig.cron === "object" && !Array.isArray(baseConfig.cron) - ? ({ ...(baseConfig.cron as Record) } as Record) - : {}; - if (typeof testState.cronEnabled === "boolean") { - fileCron.enabled = testState.cronEnabled; - } - if (typeof testState.cronStorePath === "string") { - fileCron.store = testState.cronStorePath; - } - const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined; - - return { - ...baseConfig, - agents, - bindings: testState.bindingsConfig ?? fileBindings, - channels, - session, - gateway, - canvasHost, - hooks, - cron, - } as OpenClawConfig; - }; - - const readConfigFileSnapshot = async () => { - if (testState.legacyIssues.length > 0) { - const raw = JSON.stringify(testState.legacyParsed ?? {}); - return { - path: resolveConfigPath(), - exists: true, - raw, - parsed: testState.legacyParsed ?? {}, - valid: false, - config: {}, - hash: hashConfigRaw(raw), - issues: testState.legacyIssues.map((issue) => ({ - path: issue.path, - message: issue.message, - })), - legacyIssues: testState.legacyIssues, - }; - } - const configPath = resolveConfigPath(); - try { - await fs.access(configPath); - } catch { - return { - path: configPath, - exists: false, - raw: null, - parsed: {}, - valid: true, - config: composeTestConfig({}), - hash: hashConfigRaw(null), - issues: [], - legacyIssues: [], - }; - } - try { - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as Record; - return { - path: configPath, - exists: true, - raw, - parsed, - valid: true, - config: composeTestConfig(parsed), - hash: hashConfigRaw(raw), - issues: [], - legacyIssues: [], - }; - } catch (err) { - return { - path: configPath, - exists: true, - raw: null, - parsed: {}, - valid: false, - config: {}, - hash: hashConfigRaw(null), - issues: [{ path: "", message: `read failed: ${String(err)}` }], - legacyIssues: [], - }; - } - }; - - const writeConfigFile = vi.fn(async (cfg: Record) => { - const configPath = resolveConfigPath(); - await fs.mkdir(path.dirname(configPath), { recursive: true }); - const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); - await fs.writeFile(configPath, raw, "utf-8"); - actual.resetConfigRuntimeState(); - }); - - const readConfigFileSnapshotForWrite = async () => ({ - snapshot: await readConfigFileSnapshot(), - writeOptions: { - expectedConfigPath: resolveConfigPath(), - }, - }); - - const loadTestConfig = () => { - const configPath = resolveConfigPath(); - let fileConfig: Record = {}; - try { - if (fsSync.existsSync(configPath)) { - const raw = fsSync.readFileSync(configPath, "utf-8"); - fileConfig = JSON.parse(raw) as Record; - } - } catch { - fileConfig = {}; - } - return applyPluginAutoEnable({ - config: composeTestConfig(fileConfig), - env: process.env, - }).config; - }; - - const loadRuntimeAwareTestConfig = () => { - const runtimeSnapshot = actual.getRuntimeConfigSnapshot(); - if (runtimeSnapshot) { - return runtimeSnapshot; - } - const config = loadTestConfig(); - actual.setRuntimeConfigSnapshot(config); - return config; - }; - - return { - ...actual, - get CONFIG_PATH() { - return resolveConfigPath(); - }, - get STATE_DIR() { - return path.dirname(resolveConfigPath()); - }, - get isNixMode() { - return testIsNixMode.value; - }, - migrateLegacyConfig: (raw: unknown) => ({ - config: testState.migrationConfig ?? (raw as Record), - changes: testState.migrationChanges, - }), - applyConfigOverrides: (cfg: OpenClawConfig) => - composeTestConfig(cfg as Record), - loadConfig: loadRuntimeAwareTestConfig, - getRuntimeConfig: loadRuntimeAwareTestConfig, - parseConfigJson5: (raw: string) => { - try { - return { ok: true, parsed: JSON.parse(raw) as unknown }; - } catch (err) { - return { ok: false, error: String(err) }; - } - }, - validateConfigObject: (parsed: unknown) => ({ - ok: true, - config: parsed as Record, - issues: [], - }), - readConfigFileSnapshot, - readConfigFileSnapshotForWrite, - writeConfigFile, - }; + return createGatewayConfigModuleMock(actual); }); vi.mock("../agents/pi-embedded.js", async () => { diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 7c8bc5a1a9..65b8ac03b0 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -32,13 +32,13 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-cha import { buildDeviceAuthPayloadV3 } from "./device-auth.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import type { GatewayServerOptions } from "./server.js"; +import { resetTestPluginRegistry } from "./test-helpers.plugin-registry.js"; import { agentCommand, cronIsolatedRun, embeddedRunMock, getReplyFromConfig, piSdkMock, - resetTestPluginRegistry, sendWhatsAppMock, sessionStoreSaveDelayMs, setTestConfigRoot, @@ -46,7 +46,7 @@ import { testTailscaleWhois, testState, testTailnetIPv4, -} from "./test-helpers.mocks.js"; +} from "./test-helpers.runtime-state.js"; // Import lazily after test env/home setup so config/session paths resolve to test dirs. // Keep one cached module per worker for speed. diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index 408e7c2ad1..975817ebc1 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -1,2 +1,4 @@ +export * from "./test-helpers.runtime-state.js"; +export * from "./test-helpers.plugin-registry.js"; export * from "./test-helpers.mocks.js"; export * from "./test-helpers.server.js";