mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-17 12:11:20 +00:00
refactor: remove remaining channel and gateway boundary leaks
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<typeof import("../../plugins/bundled-plugin-metadata.js")>(
|
||||
"../../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", () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
295
src/gateway/test-helpers.config-runtime.ts
Normal file
295
src/gateway/test-helpers.config-runtime.ts
Normal file
@@ -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<string, unknown>) => {
|
||||
const fileAgents =
|
||||
baseConfig.agents &&
|
||||
typeof baseConfig.agents === "object" &&
|
||||
!Array.isArray(baseConfig.agents)
|
||||
? (baseConfig.agents as Record<string, unknown>)
|
||||
: {};
|
||||
const fileDefaults =
|
||||
fileAgents.defaults &&
|
||||
typeof fileAgents.defaults === "object" &&
|
||||
!Array.isArray(fileAgents.defaults)
|
||||
? (fileAgents.defaults as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
const session: Record<string, unknown> = {
|
||||
...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<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>;
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown> = {};
|
||||
try {
|
||||
if (fsSync.existsSync(configPath)) {
|
||||
const raw = fsSync.readFileSync(configPath, "utf-8");
|
||||
fileConfig = JSON.parse(raw) as Record<string, unknown>;
|
||||
}
|
||||
} 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<string, unknown>),
|
||||
changes: testState.migrationChanges,
|
||||
}),
|
||||
applyConfigOverrides: (cfg: OpenClawConfig) =>
|
||||
composeTestConfig(cfg as Record<string, unknown>),
|
||||
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<string, unknown>,
|
||||
issues: [],
|
||||
}),
|
||||
readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite,
|
||||
writeConfigFile,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof import("../config/config.js")>("../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<string, unknown>) => {
|
||||
const fileAgents =
|
||||
baseConfig.agents &&
|
||||
typeof baseConfig.agents === "object" &&
|
||||
!Array.isArray(baseConfig.agents)
|
||||
? (baseConfig.agents as Record<string, unknown>)
|
||||
: {};
|
||||
const fileDefaults =
|
||||
fileAgents.defaults &&
|
||||
typeof fileAgents.defaults === "object" &&
|
||||
!Array.isArray(fileAgents.defaults)
|
||||
? (fileAgents.defaults as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
const session: Record<string, unknown> = {
|
||||
...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<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>;
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown> = {};
|
||||
try {
|
||||
if (fsSync.existsSync(configPath)) {
|
||||
const raw = fsSync.readFileSync(configPath, "utf-8");
|
||||
fileConfig = JSON.parse(raw) as Record<string, unknown>;
|
||||
}
|
||||
} 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<string, unknown>),
|
||||
changes: testState.migrationChanges,
|
||||
}),
|
||||
applyConfigOverrides: (cfg: OpenClawConfig) =>
|
||||
composeTestConfig(cfg as Record<string, unknown>),
|
||||
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<string, unknown>,
|
||||
issues: [],
|
||||
}),
|
||||
readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite,
|
||||
writeConfigFile,
|
||||
};
|
||||
return createGatewayConfigModuleMock(actual);
|
||||
});
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", async () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user