mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 20:50:52 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
56
openclaw/test/appcast.test.ts
Normal file
56
openclaw/test/appcast.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalSparkleBuildFromVersion } from "../scripts/sparkle-build.ts";
|
||||
|
||||
const APPCAST_URL = new URL("../appcast.xml", import.meta.url);
|
||||
|
||||
type AppcastItem = {
|
||||
raw: string;
|
||||
shortVersion: string | null;
|
||||
sparkleVersion: number | null;
|
||||
};
|
||||
|
||||
function parseItems(appcast: string): AppcastItem[] {
|
||||
return [...appcast.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((match) => {
|
||||
const raw = match[1] ?? "";
|
||||
const shortVersion =
|
||||
raw.match(/<sparkle:shortVersionString>([^<]+)<\/sparkle:shortVersionString>/)?.[1] ?? null;
|
||||
const sparkleVersionText = raw.match(/<sparkle:version>([^<]+)<\/sparkle:version>/)?.[1] ?? "";
|
||||
const sparkleVersion = Number.parseInt(sparkleVersionText, 10);
|
||||
return {
|
||||
raw,
|
||||
shortVersion,
|
||||
sparkleVersion: Number.isFinite(sparkleVersion) ? sparkleVersion : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("appcast.xml", () => {
|
||||
it("keeps every appcast entry on the canonical sparkle build for its version", () => {
|
||||
const appcast = readFileSync(APPCAST_URL, "utf8");
|
||||
const items = parseItems(appcast);
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.shortVersion, item.raw).not.toBeNull();
|
||||
expect(item.sparkleVersion, item.raw).not.toBeNull();
|
||||
expect(item.sparkleVersion).toBe(canonicalSparkleBuildFromVersion(item.shortVersion!));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the first stable appcast entry aligned with the newest stable build", () => {
|
||||
const appcast = readFileSync(APPCAST_URL, "utf8");
|
||||
const stableItems = parseItems(appcast).filter(
|
||||
(item) => item.sparkleVersion !== null && item.sparkleVersion % 100 === 90,
|
||||
);
|
||||
|
||||
expect(stableItems.length).toBeGreaterThan(0);
|
||||
const firstStable = stableItems[0];
|
||||
const newestStable = [...stableItems].toSorted(
|
||||
(left, right) => (right.sparkleVersion ?? 0) - (left.sparkleVersion ?? 0),
|
||||
)[0];
|
||||
|
||||
expect(firstStable.sparkleVersion).toBe(newestStable.sparkleVersion);
|
||||
expect(firstStable.shortVersion).toBe(newestStable.shortVersion);
|
||||
});
|
||||
});
|
||||
32
openclaw/test/architecture-smells.test.ts
Normal file
32
openclaw/test/architecture-smells.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { collectArchitectureSmells, main } from "../scripts/check-architecture-smells.mjs";
|
||||
import { createCapturedIo } from "./helpers/captured-io.js";
|
||||
|
||||
describe("architecture smell inventory", () => {
|
||||
it("produces stable sorted output", async () => {
|
||||
const first = await collectArchitectureSmells();
|
||||
const second = await collectArchitectureSmells();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(
|
||||
[...first].toSorted(
|
||||
(left, right) =>
|
||||
left.category.localeCompare(right.category) ||
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.kind.localeCompare(right.kind) ||
|
||||
left.specifier.localeCompare(right.specifier) ||
|
||||
left.reason.localeCompare(right.reason),
|
||||
),
|
||||
).toEqual(first);
|
||||
});
|
||||
|
||||
it("script json output matches the collector", async () => {
|
||||
const captured = createCapturedIo();
|
||||
const exitCode = await main(["--json"], captured.io);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(captured.readStderr()).toBe("");
|
||||
expect(JSON.parse(captured.readStdout())).toEqual(await collectArchitectureSmells());
|
||||
});
|
||||
});
|
||||
44
openclaw/test/cli-json-stdout.e2e.test.ts
Normal file
44
openclaw/test/cli-json-stdout.e2e.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "./helpers/temp-home.ts";
|
||||
|
||||
describe("cli json stdout contract", () => {
|
||||
it("keeps `update status --json` stdout parseable even with legacy doctor preflight inputs", async () => {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
const legacyDir = path.join(tempHome, ".clawdbot");
|
||||
await fs.mkdir(legacyDir, { recursive: true });
|
||||
await fs.writeFile(path.join(legacyDir, "clawdbot.json"), "{}", "utf8");
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: tempHome,
|
||||
USERPROFILE: tempHome,
|
||||
OPENCLAW_TEST_FAST: "1",
|
||||
};
|
||||
delete env.OPENCLAW_HOME;
|
||||
delete env.OPENCLAW_STATE_DIR;
|
||||
delete env.OPENCLAW_CONFIG_PATH;
|
||||
delete env.VITEST;
|
||||
|
||||
const entry = path.resolve(process.cwd(), "openclaw.mjs");
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[entry, "update", "status", "--json", "--timeout", "1"],
|
||||
{ cwd: process.cwd(), env, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const stdout = result.stdout.trim();
|
||||
expect(stdout.length).toBeGreaterThan(0);
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
expect(stdout).not.toContain("Doctor warnings");
|
||||
expect(stdout).not.toContain("Doctor changes");
|
||||
expect(stdout).not.toContain("Config invalid");
|
||||
},
|
||||
{ prefix: "openclaw-json-e2e-" },
|
||||
);
|
||||
});
|
||||
});
|
||||
142
openclaw/test/extension-import-boundaries.test.ts
Normal file
142
openclaw/test/extension-import-boundaries.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectExtensionPluginSdkBoundaryInventory,
|
||||
main as extensionPluginSdkMain,
|
||||
} from "../scripts/check-extension-plugin-sdk-boundary.mjs";
|
||||
import {
|
||||
collectSdkPackageExtensionImportBoundaryInventory,
|
||||
main as sdkPackageMain,
|
||||
} from "../scripts/check-sdk-package-extension-import-boundary.mjs";
|
||||
import {
|
||||
collectSrcExtensionImportBoundaryInventory,
|
||||
main as srcExtensionMain,
|
||||
} from "../scripts/check-src-extension-import-boundary.mjs";
|
||||
import { createCapturedIo } from "./helpers/captured-io.js";
|
||||
|
||||
const srcInventoryPromise = collectSrcExtensionImportBoundaryInventory();
|
||||
const srcJsonOutputPromise = getJsonOutput(srcExtensionMain, ["--json"]);
|
||||
const sdkPackageInventoryPromise = collectSdkPackageExtensionImportBoundaryInventory();
|
||||
const sdkPackageJsonOutputPromise = getJsonOutput(sdkPackageMain, ["--json"]);
|
||||
const srcOutsideInventoryPromise =
|
||||
collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk");
|
||||
const pluginSdkInternalInventoryPromise =
|
||||
collectExtensionPluginSdkBoundaryInventory("plugin-sdk-internal");
|
||||
const relativeOutsidePackageInventoryPromise = collectExtensionPluginSdkBoundaryInventory(
|
||||
"relative-outside-package",
|
||||
);
|
||||
const srcOutsideJsonOutputPromise = getJsonOutput(extensionPluginSdkMain, [
|
||||
"--mode=src-outside-plugin-sdk",
|
||||
"--json",
|
||||
]);
|
||||
const pluginSdkInternalJsonOutputPromise = getJsonOutput(extensionPluginSdkMain, [
|
||||
"--mode=plugin-sdk-internal",
|
||||
"--json",
|
||||
]);
|
||||
const relativeOutsidePackageJsonOutputPromise = getJsonOutput(extensionPluginSdkMain, [
|
||||
"--mode=relative-outside-package",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
type CapturedIo = ReturnType<typeof createCapturedIo>["io"];
|
||||
|
||||
async function getJsonOutput(
|
||||
main: (argv: string[], io: CapturedIo) => Promise<number>,
|
||||
argv: string[],
|
||||
) {
|
||||
const captured = createCapturedIo();
|
||||
const exitCode = await main(argv, captured.io);
|
||||
return {
|
||||
exitCode,
|
||||
stderr: captured.readStderr(),
|
||||
json: JSON.parse(captured.readStdout()),
|
||||
};
|
||||
}
|
||||
|
||||
describe("src extension import boundary inventory", () => {
|
||||
it("stays empty", async () => {
|
||||
expect(await srcInventoryPromise).toEqual([]);
|
||||
});
|
||||
|
||||
it("produces stable sorted output", async () => {
|
||||
const first = await srcInventoryPromise;
|
||||
const second = await collectSrcExtensionImportBoundaryInventory();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it("script json output stays empty", async () => {
|
||||
const jsonOutput = await srcJsonOutputPromise;
|
||||
|
||||
expect(jsonOutput.exitCode).toBe(0);
|
||||
expect(jsonOutput.stderr).toBe("");
|
||||
expect(jsonOutput.json).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdk/package extension import boundary inventory", () => {
|
||||
it("stays empty", async () => {
|
||||
expect(await sdkPackageInventoryPromise).toEqual([]);
|
||||
});
|
||||
|
||||
it("produces stable sorted output", async () => {
|
||||
const first = await sdkPackageInventoryPromise;
|
||||
const second = await collectSdkPackageExtensionImportBoundaryInventory();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it("script json output stays empty", async () => {
|
||||
const jsonOutput = await sdkPackageJsonOutputPromise;
|
||||
|
||||
expect(jsonOutput.exitCode).toBe(0);
|
||||
expect(jsonOutput.stderr).toBe("");
|
||||
expect(jsonOutput.json).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension src outside plugin-sdk boundary inventory", () => {
|
||||
it("stays empty and sorted", async () => {
|
||||
const inventory = await srcOutsideInventoryPromise;
|
||||
const jsonResult = await srcOutsideJsonOutputPromise;
|
||||
|
||||
expect(inventory).toEqual([]);
|
||||
expect(
|
||||
[...inventory].toSorted(
|
||||
(left, right) =>
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.kind.localeCompare(right.kind) ||
|
||||
left.specifier.localeCompare(right.specifier) ||
|
||||
left.resolvedPath.localeCompare(right.resolvedPath) ||
|
||||
left.reason.localeCompare(right.reason),
|
||||
),
|
||||
).toEqual(inventory);
|
||||
expect(jsonResult.exitCode).toBe(0);
|
||||
expect(jsonResult.stderr).toBe("");
|
||||
expect(jsonResult.json).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension plugin-sdk-internal boundary inventory", () => {
|
||||
it("stays empty", async () => {
|
||||
const inventory = await pluginSdkInternalInventoryPromise;
|
||||
const jsonResult = await pluginSdkInternalJsonOutputPromise;
|
||||
|
||||
expect(inventory).toEqual([]);
|
||||
expect(jsonResult.exitCode).toBe(0);
|
||||
expect(jsonResult.stderr).toBe("");
|
||||
expect(jsonResult.json).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension relative-outside-package boundary inventory", () => {
|
||||
it("stays empty", async () => {
|
||||
const inventory = await relativeOutsidePackageInventoryPromise;
|
||||
const jsonResult = await relativeOutsidePackageJsonOutputPromise;
|
||||
|
||||
expect(inventory).toEqual([]);
|
||||
expect(jsonResult.exitCode).toBe(0);
|
||||
expect(jsonResult.stderr).toBe("");
|
||||
expect(jsonResult.json).toEqual([]);
|
||||
});
|
||||
});
|
||||
38
openclaw/test/extension-package-tsc-boundary.test.ts
Normal file
38
openclaw/test/extension-package-tsc-boundary.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dirname, "..");
|
||||
const CHECK_EXTENSION_PACKAGE_BOUNDARY_BIN = resolve(
|
||||
REPO_ROOT,
|
||||
"scripts/check-extension-package-tsc-boundary.mjs",
|
||||
);
|
||||
const SHOULD_RUN_BOUNDARY_SCRIPT_WRAPPER =
|
||||
process.env.GITHUB_ACTIONS !== "true" ||
|
||||
process.env.OPENCLAW_RUN_EXTENSION_PACKAGE_BOUNDARY_TEST === "1";
|
||||
|
||||
function runNode(args: string[], timeout: number) {
|
||||
return spawnSync(process.execPath, args, {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
// The CI check-additional job runs this script directly. Avoid duplicating the cold
|
||||
// 97-extension compile inside the full node test shard.
|
||||
describe.skipIf(!SHOULD_RUN_BOUNDARY_SCRIPT_WRAPPER)(
|
||||
"opt-in extension package TypeScript boundaries",
|
||||
() => {
|
||||
it("typechecks each opt-in extension cleanly through @openclaw/plugin-sdk", () => {
|
||||
const result = runNode([CHECK_EXTENSION_PACKAGE_BOUNDARY_BIN, "--mode=compile"], 420_000);
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
||||
}, 300_000);
|
||||
|
||||
it("fails when opt-in extensions import src/cli through a relative path", () => {
|
||||
const result = runNode([CHECK_EXTENSION_PACKAGE_BOUNDARY_BIN, "--mode=canary"], 180_000);
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
||||
});
|
||||
},
|
||||
);
|
||||
174
openclaw/test/extension-test-boundary.test.ts
Normal file
174
openclaw/test/extension-test-boundary.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./helpers/bundled-plugin-paths.js";
|
||||
import { GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES } from "./helpers/plugins/public-artifacts.js";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const ALLOWED_EXTENSION_PUBLIC_SURFACE_BASENAMES = new Set(
|
||||
GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES,
|
||||
);
|
||||
|
||||
function walk(dir: string, entries: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") {
|
||||
continue;
|
||||
}
|
||||
walk(fullPath, entries);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith(".test.ts") && !entry.name.endsWith(".test.tsx")) {
|
||||
continue;
|
||||
}
|
||||
entries.push(path.relative(repoRoot, fullPath).replaceAll(path.sep, "/"));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function walkCode(dir: string, entries: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") {
|
||||
continue;
|
||||
}
|
||||
walkCode(fullPath, entries);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) {
|
||||
continue;
|
||||
}
|
||||
entries.push(path.relative(repoRoot, fullPath).replaceAll(path.sep, "/"));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function findExtensionImports(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(/from\s+["']((?:\.\.\/)+extensions\/[^"']+)["']/g),
|
||||
...source.matchAll(/import\(\s*["']((?:\.\.\/)+extensions\/[^"']+)["']\s*\)/g),
|
||||
].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function isAllowedExtensionPublicImport(specifier: string): boolean {
|
||||
return /(?:^|\/)extensions\/[^/]+\/(?:api|index|runtime-api|setup-entry|login-qr-api)\.js$/u.test(
|
||||
specifier,
|
||||
);
|
||||
}
|
||||
|
||||
function findPluginSdkImports(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(/from\s+["']((?:\.\.\/)+plugin-sdk\/[^"']+)["']/g),
|
||||
...source.matchAll(/import\(\s*["']((?:\.\.\/)+plugin-sdk\/[^"']+)["']\s*\)/g),
|
||||
].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function findBundledPluginPublicSurfaceImports(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(/from\s+["'](?:\.\.\/)+test-utils\/bundled-plugin-public-surface\.js["']/g),
|
||||
...source.matchAll(
|
||||
/import\(\s*["'](?:\.\.\/)+test-utils\/bundled-plugin-public-surface\.js["']\s*\)/g,
|
||||
),
|
||||
].map((match) => match[0]);
|
||||
}
|
||||
|
||||
function getImportBasename(importPath: string): string {
|
||||
return importPath.split("/").at(-1) ?? importPath;
|
||||
}
|
||||
|
||||
function isAllowedCoreContractSuite(file: string, imports: readonly string[]): boolean {
|
||||
return (
|
||||
file.startsWith("src/channels/plugins/contracts/") &&
|
||||
file.endsWith(".contract.test.ts") &&
|
||||
imports.every((entry) =>
|
||||
ALLOWED_EXTENSION_PUBLIC_SURFACE_BASENAMES.has(getImportBasename(entry)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
describe("non-extension test boundaries", () => {
|
||||
it("keeps plugin-owned behavior suites under the bundled plugin tree", () => {
|
||||
const testFiles = [
|
||||
...walk(path.join(repoRoot, "src")),
|
||||
...walk(path.join(repoRoot, "test")),
|
||||
...walk(path.join(repoRoot, "packages")),
|
||||
].filter(
|
||||
(file) =>
|
||||
!file.startsWith(BUNDLED_PLUGIN_PATH_PREFIX) &&
|
||||
!file.startsWith("test/helpers/") &&
|
||||
!file.startsWith("ui/"),
|
||||
);
|
||||
|
||||
const offenders = testFiles
|
||||
.map((file) => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
|
||||
const imports = findExtensionImports(source).filter(
|
||||
(specifier) => !isAllowedExtensionPublicImport(specifier),
|
||||
);
|
||||
if (imports.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (isAllowedCoreContractSuite(file, imports)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
file,
|
||||
imports,
|
||||
};
|
||||
})
|
||||
.filter((value): value is { file: string; imports: string[] } => value !== null);
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps extension-owned onboard helper coverage out of the core onboard auth suite", () => {
|
||||
const bannedPluginSdkModules = new Set<string>([
|
||||
"../plugin-sdk/litellm.js",
|
||||
"../plugin-sdk/minimax.js",
|
||||
"../plugin-sdk/mistral.js",
|
||||
"../plugin-sdk/opencode-go.js",
|
||||
"../plugin-sdk/opencode.js",
|
||||
"../plugin-sdk/openrouter.js",
|
||||
"../plugin-sdk/synthetic.js",
|
||||
"../plugin-sdk/xai.js",
|
||||
"../plugin-sdk/xiaomi.js",
|
||||
]);
|
||||
const file = "src/commands/onboard-auth.test.ts";
|
||||
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
|
||||
const imports = findPluginSdkImports(source).filter((entry) =>
|
||||
bannedPluginSdkModules.has(entry),
|
||||
);
|
||||
|
||||
expect(imports).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bundled plugin public-surface imports out of core source", () => {
|
||||
const files = walkCode(path.join(repoRoot, "src"));
|
||||
|
||||
const offenders = files.filter((file) => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
|
||||
return findBundledPluginPublicSurfaceImports(source).length > 0;
|
||||
});
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bundled plugin sync test-api loaders out of core tests", () => {
|
||||
const files = [
|
||||
...walkCode(path.join(repoRoot, "src")),
|
||||
...walkCode(path.join(repoRoot, "test")),
|
||||
]
|
||||
.filter((file) => !file.startsWith(BUNDLED_PLUGIN_PATH_PREFIX))
|
||||
.filter((file) => !file.startsWith("test/helpers/"))
|
||||
.filter((file) => file !== "test/extension-test-boundary.test.ts");
|
||||
|
||||
const offenders = files.filter((file) => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
|
||||
return source.includes("loadBundledPluginTestApiSync(");
|
||||
});
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
11
openclaw/test/fixtures/child-process-bridge/child.js
vendored
Normal file
11
openclaw/test/fixtures/child-process-bridge/child.js
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
process.stdout.write("ready\n");
|
||||
|
||||
const keepAlive = setInterval(() => {}, 1000);
|
||||
|
||||
const shutdown = () => {
|
||||
clearInterval(keepAlive);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
729
openclaw/test/fixtures/cli-startup-bench.json
vendored
Normal file
729
openclaw/test/fixtures/cli-startup-bench.json
vendored
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
{
|
||||
"node": "v25.6.1",
|
||||
"runs": 5,
|
||||
"warmup": 1,
|
||||
"timeoutMs": 30000,
|
||||
"cpuProfDir": null,
|
||||
"heapProfDir": null,
|
||||
"primary": {
|
||||
"entry": "openclaw.mjs",
|
||||
"cases": [
|
||||
{
|
||||
"id": "version",
|
||||
"name": "--version",
|
||||
"args": ["--version"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 96.717625,
|
||||
"maxRssMb": 62.140625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 95.282458,
|
||||
"maxRssMb": 63.609375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 98.361042,
|
||||
"maxRssMb": 62.265625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 100.737375,
|
||||
"maxRssMb": 62.65625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 98.220583,
|
||||
"maxRssMb": 62.484375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 97.86381659999999,
|
||||
"p50": 98.220583,
|
||||
"p95": 100.737375,
|
||||
"min": 95.282458,
|
||||
"max": 100.737375
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 62.63125,
|
||||
"p50": 62.484375,
|
||||
"p95": 63.609375,
|
||||
"min": 62.140625,
|
||||
"max": 63.609375
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "help",
|
||||
"name": "--help",
|
||||
"args": ["--help"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 51.178583,
|
||||
"maxRssMb": 44.671875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 51.062334,
|
||||
"maxRssMb": 44.578125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 51.028625,
|
||||
"maxRssMb": 45.171875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 54.476708,
|
||||
"maxRssMb": 45.578125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 57.570875,
|
||||
"maxRssMb": 45.828125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 53.06342500000001,
|
||||
"p50": 51.178583,
|
||||
"p95": 57.570875,
|
||||
"min": 51.028625,
|
||||
"max": 57.570875
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 45.165625,
|
||||
"p50": 45.171875,
|
||||
"p95": 45.828125,
|
||||
"min": 44.578125,
|
||||
"max": 45.828125
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "health",
|
||||
"name": "health",
|
||||
"args": ["health"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 9451.69,
|
||||
"maxRssMb": 61.046875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 9327.157,
|
||||
"maxRssMb": 61.484375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 9328.53775,
|
||||
"maxRssMb": 61.5,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 9377.894208,
|
||||
"maxRssMb": 61.28125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 9362.753917,
|
||||
"maxRssMb": 61.234375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 9369.606575000002,
|
||||
"p50": 9362.753917,
|
||||
"p95": 9451.69,
|
||||
"min": 9327.157,
|
||||
"max": 9451.69
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.309375,
|
||||
"p50": 61.28125,
|
||||
"p95": 61.5,
|
||||
"min": 61.046875,
|
||||
"max": 61.5
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "healthJson",
|
||||
"name": "health --json",
|
||||
"args": ["health", "--json"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 893.690834,
|
||||
"maxRssMb": 61.4375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 888.014083,
|
||||
"maxRssMb": 60.640625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 876.364291,
|
||||
"maxRssMb": 61.328125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 895.80775,
|
||||
"maxRssMb": 60.984375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 892.019584,
|
||||
"maxRssMb": 61.296875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 889.1793083999999,
|
||||
"p50": 892.019584,
|
||||
"p95": 895.80775,
|
||||
"min": 876.364291,
|
||||
"max": 895.80775
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.1375,
|
||||
"p50": 61.296875,
|
||||
"p95": 61.4375,
|
||||
"min": 60.640625,
|
||||
"max": 61.4375
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "statusJson",
|
||||
"name": "status --json",
|
||||
"args": ["status", "--json"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 10240.30475,
|
||||
"maxRssMb": 61.15625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 9992.483416,
|
||||
"maxRssMb": 61.71875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 9871.820125,
|
||||
"maxRssMb": 61.703125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 9989.689625,
|
||||
"maxRssMb": 61.265625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 10314.48525,
|
||||
"maxRssMb": 61.140625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 10081.756633199999,
|
||||
"p50": 9992.483416,
|
||||
"p95": 10314.48525,
|
||||
"min": 9871.820125,
|
||||
"max": 10314.48525
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.396875,
|
||||
"p50": 61.265625,
|
||||
"p95": 61.71875,
|
||||
"min": 61.140625,
|
||||
"max": 61.71875
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "status",
|
||||
"name": "status",
|
||||
"args": ["status"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 13175.219042,
|
||||
"maxRssMb": 61.546875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 13352.02925,
|
||||
"maxRssMb": 61.359375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 13328.865583,
|
||||
"maxRssMb": 61.34375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 13178.18525,
|
||||
"maxRssMb": 61.609375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 13336.7915,
|
||||
"maxRssMb": 61.765625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 13274.218125000003,
|
||||
"p50": 13328.865583,
|
||||
"p95": 13352.02925,
|
||||
"min": 13175.219042,
|
||||
"max": 13352.02925
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.525,
|
||||
"p50": 61.546875,
|
||||
"p95": 61.765625,
|
||||
"min": 61.34375,
|
||||
"max": 61.765625
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sessions",
|
||||
"name": "sessions",
|
||||
"args": ["sessions"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 7118.772542,
|
||||
"maxRssMb": 61.109375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 6319.220166,
|
||||
"maxRssMb": 61.515625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 8390.35675,
|
||||
"maxRssMb": 62.046875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 6280.020209,
|
||||
"maxRssMb": 61.21875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 5861.898708,
|
||||
"maxRssMb": 61.578125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 6794.053675,
|
||||
"p50": 6319.220166,
|
||||
"p95": 8390.35675,
|
||||
"min": 5861.898708,
|
||||
"max": 8390.35675
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.49375,
|
||||
"p50": 61.515625,
|
||||
"p95": 62.046875,
|
||||
"min": 61.109375,
|
||||
"max": 62.046875
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sessionsJson",
|
||||
"name": "sessions --json",
|
||||
"args": ["sessions", "--json"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 7152.919625,
|
||||
"maxRssMb": 62.625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 6019.41,
|
||||
"maxRssMb": 61.40625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 6917.366666,
|
||||
"maxRssMb": 61.890625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 5592.944167,
|
||||
"maxRssMb": 61.34375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 5591.436042,
|
||||
"maxRssMb": 61.65625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 6254.8153,
|
||||
"p50": 6019.41,
|
||||
"p95": 7152.919625,
|
||||
"min": 5591.436042,
|
||||
"max": 7152.919625
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.784375,
|
||||
"p50": 61.65625,
|
||||
"p95": 62.625,
|
||||
"min": 61.34375,
|
||||
"max": 62.625
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "agentsListJson",
|
||||
"name": "agents list --json",
|
||||
"args": ["agents", "list", "--json"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 974.9935,
|
||||
"maxRssMb": 61.203125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1011.803583,
|
||||
"maxRssMb": 61.375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1008.81175,
|
||||
"maxRssMb": 61.65625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 968.630125,
|
||||
"maxRssMb": 61.40625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1000.931541,
|
||||
"maxRssMb": 62.03125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 993.0340997999999,
|
||||
"p50": 1000.931541,
|
||||
"p95": 1011.803583,
|
||||
"min": 968.630125,
|
||||
"max": 1011.803583
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.534375,
|
||||
"p50": 61.40625,
|
||||
"p95": 62.03125,
|
||||
"min": 61.203125,
|
||||
"max": 62.03125
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gatewayStatus",
|
||||
"name": "gateway status",
|
||||
"args": ["gateway", "status"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 1075.603667,
|
||||
"maxRssMb": 61.1875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1108.246417,
|
||||
"maxRssMb": 61.15625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1071.274458,
|
||||
"maxRssMb": 61.0625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1084.775291,
|
||||
"maxRssMb": 61.46875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1088.898833,
|
||||
"maxRssMb": 61.53125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 1085.7597332,
|
||||
"p50": 1084.775291,
|
||||
"p95": 1108.246417,
|
||||
"min": 1071.274458,
|
||||
"max": 1108.246417
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.28125,
|
||||
"p50": 61.1875,
|
||||
"p95": 61.53125,
|
||||
"min": 61.0625,
|
||||
"max": 61.53125
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gatewayStatusJson",
|
||||
"name": "gateway status --json",
|
||||
"args": ["gateway", "status", "--json"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 1116.443292,
|
||||
"maxRssMb": 61.5,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1076.623208,
|
||||
"maxRssMb": 61.28125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1059.811167,
|
||||
"maxRssMb": 61.78125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1091.463375,
|
||||
"maxRssMb": 61.875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 1080.338709,
|
||||
"maxRssMb": 61.0625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 1084.9359502,
|
||||
"p50": 1080.338709,
|
||||
"p95": 1116.443292,
|
||||
"min": 1059.811167,
|
||||
"max": 1116.443292
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.5,
|
||||
"p50": 61.5,
|
||||
"p95": 61.875,
|
||||
"min": 61.0625,
|
||||
"max": 61.875
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gatewayHealthJson",
|
||||
"name": "gateway health --json",
|
||||
"args": ["gateway", "health", "--json"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 5477.276208,
|
||||
"maxRssMb": 62.65625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 5507.074708,
|
||||
"maxRssMb": 61.453125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 5496.467084,
|
||||
"maxRssMb": 61.375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 5519.668917,
|
||||
"maxRssMb": 61.703125,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 5498.737875,
|
||||
"maxRssMb": 62.015625,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 5499.844958399999,
|
||||
"p50": 5498.737875,
|
||||
"p95": 5519.668917,
|
||||
"min": 5477.276208,
|
||||
"max": 5519.668917
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.840625,
|
||||
"p50": 61.703125,
|
||||
"p95": 62.65625,
|
||||
"min": 61.375,
|
||||
"max": 62.65625
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "configGetGatewayPort",
|
||||
"name": "config get gateway.port",
|
||||
"args": ["config", "get", "gateway.port"],
|
||||
"samples": [
|
||||
{
|
||||
"ms": 614.115833,
|
||||
"maxRssMb": 61.375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 581.650375,
|
||||
"maxRssMb": 61.59375,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 590.865625,
|
||||
"maxRssMb": 62.421875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 612.827458,
|
||||
"maxRssMb": 61.1875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"ms": 605.788458,
|
||||
"maxRssMb": 61.21875,
|
||||
"exitCode": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"sampleCount": 5,
|
||||
"durationMs": {
|
||||
"avg": 601.0495498,
|
||||
"p50": 605.788458,
|
||||
"p95": 614.115833,
|
||||
"min": 581.650375,
|
||||
"max": 614.115833
|
||||
},
|
||||
"maxRssMb": {
|
||||
"avg": 61.559375,
|
||||
"p50": 61.375,
|
||||
"p95": 62.421875,
|
||||
"min": 61.1875,
|
||||
"max": 62.421875
|
||||
},
|
||||
"exitSummary": "code:0x5"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"secondary": null
|
||||
}
|
||||
82
openclaw/test/fixtures/exec-allowlist-shell-parser-parity.json
vendored
Normal file
82
openclaw/test/fixtures/exec-allowlist-shell-parser-parity.json
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "simple-pipeline",
|
||||
"command": "echo ok | jq .foo",
|
||||
"ok": true,
|
||||
"executables": ["echo", "jq"]
|
||||
},
|
||||
{
|
||||
"id": "chained-commands",
|
||||
"command": "ls && rm -rf /tmp/openclaw-allowlist",
|
||||
"ok": true,
|
||||
"executables": ["ls", "rm"]
|
||||
},
|
||||
{
|
||||
"id": "quoted-chain-operators-remain-literal",
|
||||
"command": "echo \"a && b\"",
|
||||
"ok": true,
|
||||
"executables": ["echo"]
|
||||
},
|
||||
{
|
||||
"id": "reject-command-substitution-unquoted",
|
||||
"command": "echo $(whoami)",
|
||||
"ok": false,
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"id": "reject-command-substitution-double-quoted",
|
||||
"command": "echo \"output: $(whoami)\"",
|
||||
"ok": false,
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"id": "allow-command-substitution-literal-in-single-quotes",
|
||||
"command": "echo 'output: $(whoami)'",
|
||||
"ok": true,
|
||||
"executables": ["echo"]
|
||||
},
|
||||
{
|
||||
"id": "allow-escaped-command-substitution-double-quoted",
|
||||
"command": "echo \"output: \\$(whoami)\"",
|
||||
"ok": true,
|
||||
"executables": ["echo"]
|
||||
},
|
||||
{
|
||||
"id": "reject-backticks-unquoted",
|
||||
"command": "echo `id`",
|
||||
"ok": false,
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"id": "reject-backticks-double-quoted",
|
||||
"command": "echo \"output: `id`\"",
|
||||
"ok": false,
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"id": "reject-process-substitution-unquoted-input",
|
||||
"command": "cat <(echo ok)",
|
||||
"ok": false,
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"id": "reject-process-substitution-unquoted-output",
|
||||
"command": "echo >(cat)",
|
||||
"ok": false,
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"id": "allow-process-substitution-literal-double-quoted-input",
|
||||
"command": "echo \"<(echo ok)\"",
|
||||
"ok": true,
|
||||
"executables": ["echo"]
|
||||
},
|
||||
{
|
||||
"id": "allow-process-substitution-literal-double-quoted-output",
|
||||
"command": "echo \">(cat)\"",
|
||||
"ok": true,
|
||||
"executables": ["echo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
39
openclaw/test/fixtures/exec-wrapper-resolution-parity.json
vendored
Normal file
39
openclaw/test/fixtures/exec-wrapper-resolution-parity.json
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "direct-absolute-executable",
|
||||
"argv": ["/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/printf"
|
||||
},
|
||||
{
|
||||
"id": "env-assignment-prefix",
|
||||
"argv": ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
},
|
||||
{
|
||||
"id": "env-option-with-separate-value",
|
||||
"argv": ["/usr/bin/env", "-u", "HOME", "/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
},
|
||||
{
|
||||
"id": "env-option-with-inline-value",
|
||||
"argv": ["/usr/bin/env", "-uHOME", "/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
},
|
||||
{
|
||||
"id": "nested-env-wrappers",
|
||||
"argv": ["/usr/bin/env", "/usr/bin/env", "FOO=bar", "printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
},
|
||||
{
|
||||
"id": "env-shell-wrapper-stops-at-shell",
|
||||
"argv": ["/usr/bin/env", "bash", "-lc", "echo ok"],
|
||||
"expectedRawExecutable": "bash"
|
||||
},
|
||||
{
|
||||
"id": "env-missing-effective-command",
|
||||
"argv": ["/usr/bin/env", "FOO=bar"],
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
}
|
||||
]
|
||||
}
|
||||
202
openclaw/test/fixtures/extension-relative-outside-package-inventory.json
vendored
Normal file
202
openclaw/test/fixtures/extension-relative-outside-package-inventory.json
vendored
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
[
|
||||
{
|
||||
"file": "extensions/bluebubbles/src/runtime-api.ts",
|
||||
"line": 1,
|
||||
"kind": "export",
|
||||
"specifier": "../../../src/plugin-sdk/bluebubbles.js",
|
||||
"resolvedPath": "src/plugin-sdk/bluebubbles.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/bluebubbles/src/targets.ts",
|
||||
"line": 8,
|
||||
"kind": "import",
|
||||
"specifier": "../../imessage/api.js",
|
||||
"resolvedPath": "extensions/imessage/api.js",
|
||||
"reason": "imports another extension via relative path outside the extension package"
|
||||
},
|
||||
{
|
||||
"file": "extensions/feishu/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/feishu.js",
|
||||
"resolvedPath": "src/plugin-sdk/feishu.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/googlechat/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/googlechat.js",
|
||||
"resolvedPath": "src/plugin-sdk/googlechat.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/imessage/runtime-api.ts",
|
||||
"line": 16,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/imessage.js",
|
||||
"resolvedPath": "src/plugin-sdk/imessage.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/irc/src/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../../src/plugin-sdk/irc.js",
|
||||
"resolvedPath": "src/plugin-sdk/irc.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/line/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/line.js",
|
||||
"resolvedPath": "src/plugin-sdk/line.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/line/runtime-api.ts",
|
||||
"line": 13,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/line-core.js",
|
||||
"resolvedPath": "src/plugin-sdk/line-core.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/matrix/runtime-api.ts",
|
||||
"line": 19,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/json-store.js",
|
||||
"resolvedPath": "src/plugin-sdk/json-store.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/matrix/runtime-api.ts",
|
||||
"line": 28,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/matrix.js",
|
||||
"resolvedPath": "src/plugin-sdk/matrix.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/matrix/runtime-api.ts",
|
||||
"line": 29,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/matrix.js",
|
||||
"resolvedPath": "src/plugin-sdk/matrix.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/matrix/src/runtime-api.ts",
|
||||
"line": 1,
|
||||
"kind": "export",
|
||||
"specifier": "../../../src/plugin-sdk/matrix.js",
|
||||
"resolvedPath": "src/plugin-sdk/matrix.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/mattermost/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/mattermost.js",
|
||||
"resolvedPath": "src/plugin-sdk/mattermost.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/msteams/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/msteams.js",
|
||||
"resolvedPath": "src/plugin-sdk/msteams.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/nextcloud-talk/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/nextcloud-talk.js",
|
||||
"resolvedPath": "src/plugin-sdk/nextcloud-talk.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/nostr/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/nostr.js",
|
||||
"resolvedPath": "src/plugin-sdk/nostr.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/telegram/runtime-api.ts",
|
||||
"line": 10,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/telegram.js",
|
||||
"resolvedPath": "src/plugin-sdk/telegram.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/telegram/runtime-api.ts",
|
||||
"line": 40,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/telegram.js",
|
||||
"resolvedPath": "src/plugin-sdk/telegram.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/tlon/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/tlon.js",
|
||||
"resolvedPath": "src/plugin-sdk/tlon.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/twitch/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/twitch.js",
|
||||
"resolvedPath": "src/plugin-sdk/twitch.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/voice-call/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/voice-call.js",
|
||||
"resolvedPath": "src/plugin-sdk/voice-call.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/whatsapp/src/runtime-api.ts",
|
||||
"line": 19,
|
||||
"kind": "export",
|
||||
"specifier": "../../../src/plugin-sdk/whatsapp-core.js",
|
||||
"resolvedPath": "src/plugin-sdk/whatsapp-core.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/whatsapp/src/runtime-api.ts",
|
||||
"line": 34,
|
||||
"kind": "export",
|
||||
"specifier": "../../../src/plugin-sdk/whatsapp-shared.js",
|
||||
"resolvedPath": "src/plugin-sdk/whatsapp-shared.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/zalo/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/zalo.js",
|
||||
"resolvedPath": "src/plugin-sdk/zalo.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
},
|
||||
{
|
||||
"file": "extensions/zalouser/runtime-api.ts",
|
||||
"line": 4,
|
||||
"kind": "export",
|
||||
"specifier": "../../src/plugin-sdk/zalouser.js",
|
||||
"resolvedPath": "src/plugin-sdk/zalouser.js",
|
||||
"reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>"
|
||||
}
|
||||
]
|
||||
BIN
openclaw/test/fixtures/hooks-install/npm-pack-hooks.tgz
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/npm-pack-hooks.tgz
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/tar-evil-id.tar
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/tar-evil-id.tar
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/tar-hooks.tar
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/tar-hooks.tar
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/tar-reserved-id.tar
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/tar-reserved-id.tar
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/tar-traversal.tar
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/tar-traversal.tar
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/zip-hooks.zip
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/zip-hooks.zip
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/zip-traversal.zip
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/zip-traversal.zip
vendored
Normal file
Binary file not shown.
1
openclaw/test/fixtures/plugin-extension-import-boundary-inventory.json
vendored
Normal file
1
openclaw/test/fixtures/plugin-extension-import-boundary-inventory.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
BIN
openclaw/test/fixtures/plugins-install/voice-call-0.0.1.tgz
vendored
Normal file
BIN
openclaw/test/fixtures/plugins-install/voice-call-0.0.1.tgz
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/plugins-install/voice-call-0.0.2.tgz
vendored
Normal file
BIN
openclaw/test/fixtures/plugins-install/voice-call-0.0.2.tgz
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/plugins-install/zipper-0.0.1.zip
vendored
Normal file
BIN
openclaw/test/fixtures/plugins-install/zipper-0.0.1.zip
vendored
Normal file
Binary file not shown.
139
openclaw/test/fixtures/system-run-approval-binding-contract.json
vendored
Normal file
139
openclaw/test/fixtures/system-run-approval-binding-contract.json
vendored
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "binding matches when env key order changes",
|
||||
"request": {
|
||||
"host": "node",
|
||||
"command": "git diff",
|
||||
"binding": {
|
||||
"argv": ["git", "diff"],
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null,
|
||||
"env": { "SAFE_A": "1", "SAFE_B": "2" }
|
||||
}
|
||||
},
|
||||
"invoke": {
|
||||
"argv": ["git", "diff"],
|
||||
"binding": {
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null,
|
||||
"env": { "SAFE_B": "2", "SAFE_A": "1" }
|
||||
}
|
||||
},
|
||||
"expected": { "ok": true }
|
||||
},
|
||||
{
|
||||
"name": "binding rejects env mismatch",
|
||||
"request": {
|
||||
"host": "node",
|
||||
"command": "git diff",
|
||||
"binding": {
|
||||
"argv": ["git", "diff"],
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null,
|
||||
"env": { "SAFE": "1" }
|
||||
}
|
||||
},
|
||||
"invoke": {
|
||||
"argv": ["git", "diff"],
|
||||
"binding": {
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null,
|
||||
"env": { "SAFE": "2" }
|
||||
}
|
||||
},
|
||||
"expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" }
|
||||
},
|
||||
{
|
||||
"name": "binding rejects mismatched Windows-compatible env values",
|
||||
"request": {
|
||||
"host": "node",
|
||||
"command": "cmd.exe /c echo ok",
|
||||
"binding": {
|
||||
"argv": ["cmd.exe", "/c", "echo", "ok"],
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null,
|
||||
"env": { "ProgramFiles(x86)": "C:\\Program Files (x86)" }
|
||||
}
|
||||
},
|
||||
"invoke": {
|
||||
"argv": ["cmd.exe", "/c", "echo", "ok"],
|
||||
"binding": {
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null,
|
||||
"env": { "ProgramFiles(x86)": "D:\\malicious" }
|
||||
}
|
||||
},
|
||||
"expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" }
|
||||
},
|
||||
{
|
||||
"name": "binding rejects unbound env overrides",
|
||||
"request": {
|
||||
"host": "node",
|
||||
"command": "git diff",
|
||||
"binding": {
|
||||
"argv": ["git", "diff"],
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null
|
||||
}
|
||||
},
|
||||
"invoke": {
|
||||
"argv": ["git", "diff"],
|
||||
"binding": {
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null,
|
||||
"env": { "GIT_EXTERNAL_DIFF": "/tmp/pwn.sh" }
|
||||
}
|
||||
},
|
||||
"expected": { "ok": false, "code": "APPROVAL_ENV_BINDING_MISSING" }
|
||||
},
|
||||
{
|
||||
"name": "missing binding rejects requests even with matching argv",
|
||||
"request": {
|
||||
"host": "node",
|
||||
"command": "echo SAFE",
|
||||
"commandArgv": ["echo", "SAFE"]
|
||||
},
|
||||
"invoke": {
|
||||
"argv": ["echo", "SAFE"],
|
||||
"binding": {
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null
|
||||
}
|
||||
},
|
||||
"expected": { "ok": false, "code": "APPROVAL_REQUEST_MISMATCH" }
|
||||
},
|
||||
{
|
||||
"name": "binding stays authoritative when legacy command text diverges",
|
||||
"request": {
|
||||
"host": "node",
|
||||
"command": "echo STALE",
|
||||
"commandArgv": ["echo", "STALE"],
|
||||
"binding": {
|
||||
"argv": ["echo", "SAFE"],
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null
|
||||
}
|
||||
},
|
||||
"invoke": {
|
||||
"argv": ["echo", "SAFE"],
|
||||
"binding": {
|
||||
"cwd": null,
|
||||
"agentId": null,
|
||||
"sessionKey": null
|
||||
}
|
||||
},
|
||||
"expected": { "ok": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
67
openclaw/test/fixtures/system-run-approval-mismatch-contract.json
vendored
Normal file
67
openclaw/test/fixtures/system-run-approval-mismatch-contract.json
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "request mismatch preserves base details",
|
||||
"runId": "approval-req-1",
|
||||
"match": {
|
||||
"ok": false,
|
||||
"code": "APPROVAL_REQUEST_MISMATCH",
|
||||
"message": "approval id does not match request"
|
||||
},
|
||||
"expected": {
|
||||
"ok": false,
|
||||
"message": "approval id does not match request",
|
||||
"details": {
|
||||
"code": "APPROVAL_REQUEST_MISMATCH",
|
||||
"runId": "approval-req-1"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "missing env binding keeps env key details",
|
||||
"runId": "approval-env-missing",
|
||||
"match": {
|
||||
"ok": false,
|
||||
"code": "APPROVAL_ENV_BINDING_MISSING",
|
||||
"message": "approval id missing env binding for requested env overrides",
|
||||
"details": {
|
||||
"envKeys": ["GIT_EXTERNAL_DIFF"]
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"ok": false,
|
||||
"message": "approval id missing env binding for requested env overrides",
|
||||
"details": {
|
||||
"code": "APPROVAL_ENV_BINDING_MISSING",
|
||||
"runId": "approval-env-missing",
|
||||
"envKeys": ["GIT_EXTERNAL_DIFF"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "env mismatch preserves hash diagnostics",
|
||||
"runId": "approval-env-mismatch",
|
||||
"match": {
|
||||
"ok": false,
|
||||
"code": "APPROVAL_ENV_MISMATCH",
|
||||
"message": "approval id env binding mismatch",
|
||||
"details": {
|
||||
"envKeys": ["SAFE_A"],
|
||||
"expectedEnvHash": "expected-hash",
|
||||
"actualEnvHash": "actual-hash"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"ok": false,
|
||||
"message": "approval id env binding mismatch",
|
||||
"details": {
|
||||
"code": "APPROVAL_ENV_MISMATCH",
|
||||
"runId": "approval-env-mismatch",
|
||||
"envKeys": ["SAFE_A"],
|
||||
"expectedEnvHash": "expected-hash",
|
||||
"actualEnvHash": "actual-hash"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
84
openclaw/test/fixtures/system-run-command-contract.json
vendored
Normal file
84
openclaw/test/fixtures/system-run-command-contract.json
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "direct argv infers display command",
|
||||
"command": ["echo", "hi there"],
|
||||
"expected": {
|
||||
"valid": true,
|
||||
"displayCommand": "echo \"hi there\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "direct argv rejects mismatched raw command",
|
||||
"command": ["uname", "-a"],
|
||||
"rawCommand": "echo hi",
|
||||
"expected": {
|
||||
"valid": false,
|
||||
"errorContains": "rawCommand does not match command"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shell wrapper accepts shell payload raw command at ingress",
|
||||
"command": ["/bin/sh", "-lc", "echo hi"],
|
||||
"rawCommand": "echo hi",
|
||||
"expected": {
|
||||
"valid": true,
|
||||
"displayCommand": "/bin/sh -lc \"echo hi\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shell wrapper positional argv carrier requires full argv display binding",
|
||||
"command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"],
|
||||
"rawCommand": "$0 \"$1\"",
|
||||
"expected": {
|
||||
"valid": false,
|
||||
"errorContains": "rawCommand does not match command"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shell wrapper positional argv carrier accepts canonical full argv raw command",
|
||||
"command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"],
|
||||
"rawCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker",
|
||||
"expected": {
|
||||
"valid": true,
|
||||
"displayCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "env wrapper shell payload accepted at ingress when prelude has no env modifiers",
|
||||
"command": ["/usr/bin/env", "bash", "-lc", "echo hi"],
|
||||
"rawCommand": "echo hi",
|
||||
"expected": {
|
||||
"valid": true,
|
||||
"displayCommand": "/usr/bin/env bash -lc \"echo hi\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "env wrapper accepts canonical full argv raw command",
|
||||
"command": ["/usr/bin/env", "bash", "-lc", "echo hi"],
|
||||
"rawCommand": "/usr/bin/env bash -lc \"echo hi\"",
|
||||
"expected": {
|
||||
"valid": true,
|
||||
"displayCommand": "/usr/bin/env bash -lc \"echo hi\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "env assignment prelude requires full argv display binding",
|
||||
"command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
|
||||
"rawCommand": "echo hi",
|
||||
"expected": {
|
||||
"valid": false,
|
||||
"errorContains": "rawCommand does not match command"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "env assignment prelude accepts canonical full argv raw command",
|
||||
"command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
|
||||
"rawCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\"",
|
||||
"expected": {
|
||||
"valid": true,
|
||||
"displayCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
13
openclaw/test/fixtures/ts-topology/basic/extensions/alpha/src/use.ts
vendored
Normal file
13
openclaw/test/fixtures/ts-topology/basic/extensions/alpha/src/use.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { aliasedThing as renamedThing, sharedThing, singleOwnerHelper } from "fixture-sdk";
|
||||
import type { SharedType } from "fixture-sdk";
|
||||
import * as extra from "fixture-sdk/extra";
|
||||
|
||||
export function alphaUse(input: SharedType) {
|
||||
return [
|
||||
sharedThing(),
|
||||
singleOwnerHelper(),
|
||||
renamedThing(),
|
||||
extra.sharedThing(),
|
||||
input.value,
|
||||
].join(":");
|
||||
}
|
||||
6
openclaw/test/fixtures/ts-topology/basic/extensions/beta/src/use.ts
vendored
Normal file
6
openclaw/test/fixtures/ts-topology/basic/extensions/beta/src/use.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { sharedThing } from "fixture-sdk";
|
||||
import type { SharedType } from "fixture-sdk";
|
||||
|
||||
export function betaUse(input: SharedType) {
|
||||
return `${sharedThing()}:${input.value}`;
|
||||
}
|
||||
5
openclaw/test/fixtures/ts-topology/basic/src/internal/use.ts
vendored
Normal file
5
openclaw/test/fixtures/ts-topology/basic/src/internal/use.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { sharedThing } from "fixture-sdk";
|
||||
|
||||
export function internalUse() {
|
||||
return sharedThing();
|
||||
}
|
||||
23
openclaw/test/fixtures/ts-topology/basic/src/lib/shared.ts
vendored
Normal file
23
openclaw/test/fixtures/ts-topology/basic/src/lib/shared.ts
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export function sharedThing() {
|
||||
return "shared";
|
||||
}
|
||||
|
||||
export function singleOwnerHelper() {
|
||||
return "single-owner";
|
||||
}
|
||||
|
||||
export function aliasedThing() {
|
||||
return "aliased";
|
||||
}
|
||||
|
||||
export function testOnlyThing() {
|
||||
return "test-only";
|
||||
}
|
||||
|
||||
export function unusedThing() {
|
||||
return "unused";
|
||||
}
|
||||
|
||||
export type SharedType = {
|
||||
value: string;
|
||||
};
|
||||
1
openclaw/test/fixtures/ts-topology/basic/src/public/extra.ts
vendored
Normal file
1
openclaw/test/fixtures/ts-topology/basic/src/public/extra.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { sharedThing } from "../lib/shared.js";
|
||||
9
openclaw/test/fixtures/ts-topology/basic/src/public/index.ts
vendored
Normal file
9
openclaw/test/fixtures/ts-topology/basic/src/public/index.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
aliasedThing,
|
||||
sharedThing,
|
||||
singleOwnerHelper,
|
||||
testOnlyThing,
|
||||
unusedThing,
|
||||
} from "../lib/shared.js";
|
||||
export { sharedThing as aliasedSharedThing } from "../lib/shared.js";
|
||||
export type { SharedType } from "../lib/shared.js";
|
||||
5
openclaw/test/fixtures/ts-topology/basic/tests/public.test.ts
vendored
Normal file
5
openclaw/test/fixtures/ts-topology/basic/tests/public.test.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { testOnlyThing } from "fixture-sdk";
|
||||
|
||||
export function testUse() {
|
||||
return testOnlyThing();
|
||||
}
|
||||
14
openclaw/test/fixtures/ts-topology/basic/tsconfig.json
vendored
Normal file
14
openclaw/test/fixtures/ts-topology/basic/tsconfig.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"paths": {
|
||||
"fixture-sdk": ["./src/public/index.ts"],
|
||||
"fixture-sdk/*": ["./src/public/*"]
|
||||
},
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "extensions/**/*.ts", "packages/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
125
openclaw/test/gateway.multi.e2e.test.ts
Normal file
125
openclaw/test/gateway.multi.e2e.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { GatewayClient } from "../src/gateway/client.js";
|
||||
import { connectGatewayClient } from "../src/gateway/test-helpers.e2e.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
|
||||
import {
|
||||
type ChatEventPayload,
|
||||
type GatewayInstance,
|
||||
connectNode,
|
||||
extractFirstTextBlock,
|
||||
postJson,
|
||||
spawnGatewayInstance,
|
||||
stopGatewayInstance,
|
||||
waitForChatFinalEvent,
|
||||
waitForNodeStatus,
|
||||
} from "./helpers/gateway-e2e-harness.js";
|
||||
|
||||
const E2E_TIMEOUT_MS = 120_000;
|
||||
|
||||
describe("gateway multi-instance e2e", () => {
|
||||
const instances: GatewayInstance[] = [];
|
||||
const nodeClients: GatewayClient[] = [];
|
||||
const chatClients: GatewayClient[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
for (const client of nodeClients) {
|
||||
client.stop();
|
||||
}
|
||||
for (const client of chatClients) {
|
||||
client.stop();
|
||||
}
|
||||
for (const inst of instances) {
|
||||
await stopGatewayInstance(inst);
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"spins up two gateways and exercises WS + HTTP + node pairing",
|
||||
{ timeout: E2E_TIMEOUT_MS },
|
||||
async () => {
|
||||
const [gwA, gwB] = await Promise.all([spawnGatewayInstance("a"), spawnGatewayInstance("b")]);
|
||||
instances.push(gwA, gwB);
|
||||
|
||||
const [hookResA, hookResB] = await Promise.all([
|
||||
postJson(
|
||||
`http://127.0.0.1:${gwA.port}/hooks/wake`,
|
||||
{
|
||||
text: "wake a",
|
||||
mode: "now",
|
||||
},
|
||||
{ "x-openclaw-token": gwA.hookToken },
|
||||
),
|
||||
postJson(
|
||||
`http://127.0.0.1:${gwB.port}/hooks/wake`,
|
||||
{
|
||||
text: "wake b",
|
||||
mode: "now",
|
||||
},
|
||||
{ "x-openclaw-token": gwB.hookToken },
|
||||
),
|
||||
]);
|
||||
expect(hookResA.status).toBe(200);
|
||||
expect((hookResA.json as { ok?: boolean } | undefined)?.ok).toBe(true);
|
||||
expect(hookResB.status).toBe(200);
|
||||
expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true);
|
||||
|
||||
const [nodeA, nodeB] = await Promise.all([
|
||||
connectNode(gwA, "node-a"),
|
||||
connectNode(gwB, "node-b"),
|
||||
]);
|
||||
nodeClients.push(nodeA.client, nodeB.client);
|
||||
|
||||
await Promise.all([
|
||||
waitForNodeStatus(gwA, nodeA.nodeId),
|
||||
waitForNodeStatus(gwB, nodeB.nodeId),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"delivers final chat event for telegram-shaped session keys",
|
||||
{ timeout: E2E_TIMEOUT_MS },
|
||||
async () => {
|
||||
const gw = await spawnGatewayInstance("chat-telegram-fixture");
|
||||
instances.push(gw);
|
||||
|
||||
const chatEvents: ChatEventPayload[] = [];
|
||||
const chatClient = await connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${gw.port}`,
|
||||
token: gw.gatewayToken,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
clientDisplayName: "chat-e2e-cli",
|
||||
clientVersion: "1.0.0",
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
onEvent: (evt) => {
|
||||
if (evt.event === "chat" && evt.payload && typeof evt.payload === "object") {
|
||||
chatEvents.push(evt.payload as ChatEventPayload);
|
||||
}
|
||||
},
|
||||
});
|
||||
chatClients.push(chatClient);
|
||||
|
||||
const sessionKey = "agent:main:telegram:direct:123456";
|
||||
const idempotencyKey = `idem-${randomUUID()}`;
|
||||
const sendRes = await chatClient.request("chat.send", {
|
||||
sessionKey,
|
||||
message: "/context list",
|
||||
idempotencyKey,
|
||||
});
|
||||
expect(sendRes.status).toBe("started");
|
||||
const runId = sendRes.runId;
|
||||
expect(typeof runId).toBe("string");
|
||||
|
||||
const finalEvent = await waitForChatFinalEvent({
|
||||
events: chatEvents,
|
||||
runId: String(runId),
|
||||
sessionKey,
|
||||
});
|
||||
const finalText = extractFirstTextBlock(finalEvent.message);
|
||||
expect(typeof finalText).toBe("string");
|
||||
expect(finalText?.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
111
openclaw/test/git-hooks-pre-commit.test.ts
Normal file
111
openclaw/test/git-hooks-pre-commit.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanupTempDirs, makeTempRepoRoot } from "./helpers/temp-repo.js";
|
||||
|
||||
const baseGitEnv = {
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
};
|
||||
const baseRunEnv: NodeJS.ProcessEnv = { ...process.env, ...baseGitEnv };
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const run = (cwd: string, cmd: string, args: string[] = [], env?: NodeJS.ProcessEnv) => {
|
||||
return execFileSync(cmd, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
env: env ? { ...baseRunEnv, ...env } : baseRunEnv,
|
||||
}).trim();
|
||||
};
|
||||
|
||||
function writeExecutable(dir: string, name: string, contents: string): void {
|
||||
writeFileSync(path.join(dir, name), contents, {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
}
|
||||
|
||||
function installPreCommitFixture(dir: string): string {
|
||||
mkdirSync(path.join(dir, "git-hooks"), { recursive: true });
|
||||
mkdirSync(path.join(dir, "scripts", "pre-commit"), { recursive: true });
|
||||
symlinkSync(
|
||||
path.join(process.cwd(), "git-hooks", "pre-commit"),
|
||||
path.join(dir, "git-hooks", "pre-commit"),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"),
|
||||
"#!/usr/bin/env bash\nexit 0\n",
|
||||
{
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
},
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(dir, "scripts", "pre-commit", "filter-staged-files.mjs"),
|
||||
"process.exit(0);\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const fakeBinDir = path.join(dir, "bin");
|
||||
mkdirSync(fakeBinDir, { recursive: true });
|
||||
writeExecutable(fakeBinDir, "node", "#!/usr/bin/env bash\nexit 0\n");
|
||||
return fakeBinDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
describe("git-hooks/pre-commit (integration)", () => {
|
||||
it("does not treat staged filenames as git-add flags (e.g. --all)", () => {
|
||||
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-");
|
||||
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
|
||||
|
||||
// Use the real hook script and lightweight helper stubs.
|
||||
const fakeBinDir = installPreCommitFixture(dir);
|
||||
// The hook ends with `pnpm check`, but this fixture is only exercising staged-file handling.
|
||||
// Stub pnpm too so Windows CI does not invoke a real package-manager command in the temp repo.
|
||||
writeExecutable(fakeBinDir, "pnpm", "#!/usr/bin/env bash\nexit 0\n");
|
||||
|
||||
// Create an untracked file that should NOT be staged by the hook.
|
||||
writeFileSync(path.join(dir, "secret.txt"), "do-not-stage\n", "utf8");
|
||||
|
||||
// Stage a maliciously-named file. Older hooks using `xargs git add` could run `git add --all`.
|
||||
writeFileSync(path.join(dir, "--all"), "flag\n", "utf8");
|
||||
run(dir, "git", ["add", "--", "--all"]);
|
||||
|
||||
// Run the hook directly (same logic as when installed via core.hooksPath).
|
||||
run(dir, "bash", ["git-hooks/pre-commit"], {
|
||||
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
|
||||
});
|
||||
|
||||
const staged = run(dir, "git", ["diff", "--cached", "--name-only"]).split("\n").filter(Boolean);
|
||||
expect(staged).toEqual(["--all"]);
|
||||
});
|
||||
|
||||
it("skips pnpm check when FAST_COMMIT is enabled", () => {
|
||||
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-yolo-");
|
||||
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
|
||||
|
||||
const fakeBinDir = installPreCommitFixture(dir);
|
||||
writeFileSync(path.join(dir, "package.json"), '{"name":"tmp"}\n', "utf8");
|
||||
writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8");
|
||||
|
||||
writeExecutable(
|
||||
fakeBinDir,
|
||||
"pnpm",
|
||||
"#!/usr/bin/env bash\necho 'pnpm should not run when FAST_COMMIT is enabled' >&2\nexit 99\n",
|
||||
);
|
||||
|
||||
writeFileSync(path.join(dir, "tracked.txt"), "hello\n", "utf8");
|
||||
run(dir, "git", ["add", "--", "tracked.txt"]);
|
||||
|
||||
const output = run(dir, "bash", ["git-hooks/pre-commit"], {
|
||||
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
|
||||
FAST_COMMIT: "1",
|
||||
});
|
||||
|
||||
expect(output).toContain("FAST_COMMIT enabled: skipping pnpm check in pre-commit hook.");
|
||||
});
|
||||
});
|
||||
6
openclaw/test/global-setup.ts
Normal file
6
openclaw/test/global-setup.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { installTestEnv } from "./test-env";
|
||||
|
||||
export default async () => {
|
||||
const { cleanup } = installTestEnv();
|
||||
return () => cleanup();
|
||||
};
|
||||
31
openclaw/test/helpers/AGENTS.md
Normal file
31
openclaw/test/helpers/AGENTS.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Shared Test Helper Boundary
|
||||
|
||||
This directory holds shared test helpers reused by core and bundled plugin
|
||||
tests.
|
||||
|
||||
## Bundled Plugin Imports
|
||||
|
||||
- Shared helpers in this tree must not hardcode repo-relative imports into
|
||||
`extensions/**`.
|
||||
- When a helper needs a bundled plugin public surface, go through
|
||||
`src/test-utils/bundled-plugin-public-surface.ts`.
|
||||
- Prefer `loadBundledPluginApiSync(...)`,
|
||||
`loadBundledPluginRuntimeApiSync(...)`,
|
||||
`loadBundledPluginContractApiSync(...)`, and
|
||||
`loadBundledPluginTestApiSync(...)` for eager access to exported surfaces.
|
||||
- Prefer `resolveRelativeBundledPluginPublicModuleId(...)` or
|
||||
`resolveBundledPluginPublicModulePath(...)` when a helper needs a module id
|
||||
or filesystem path for dynamic import, mocking, or loading a plugin entrypoint
|
||||
such as `index.js`.
|
||||
- If `vi.hoisted(...)` is involved, do not call imported helper functions from
|
||||
inside the hoisted callback. Resolve the module id outside the callback or
|
||||
switch to `vi.doMock(...)`.
|
||||
- Do not keep plugin-local deep mocks or private `src/**` knowledge in shared
|
||||
helpers. Move those helpers into the owning bundled plugin package instead.
|
||||
|
||||
## Intent
|
||||
|
||||
- Keep shared helpers aligned with the same public/plugin boundary that
|
||||
production code uses.
|
||||
- Avoid shared helper debt that makes core test lanes depend on bundled plugin
|
||||
private layout.
|
||||
1
openclaw/test/helpers/CLAUDE.md
Normal file
1
openclaw/test/helpers/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
19
openclaw/test/helpers/agents/pi-ai-stream-simple-mock.ts
Normal file
19
openclaw/test/helpers/agents/pi-ai-stream-simple-mock.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { vi } from "vitest";
|
||||
|
||||
type PiAiMockModule = Record<string, unknown>;
|
||||
|
||||
export async function createPiAiStreamSimpleMock(
|
||||
loadActual: () => Promise<PiAiMockModule>,
|
||||
): Promise<PiAiMockModule> {
|
||||
const original = await loadActual();
|
||||
return {
|
||||
...original,
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(async () => undefined),
|
||||
[Symbol.asyncIterator]: vi.fn(async function* () {
|
||||
// Minimal async stream shape for wrappers that patch iteration/result.
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
689
openclaw/test/helpers/agents/prompt-composition-scenarios.ts
Normal file
689
openclaw/test/helpers/agents/prompt-composition-scenarios.ts
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
appendBootstrapPromptWarning,
|
||||
analyzeBootstrapBudget,
|
||||
buildBootstrapInjectionStats,
|
||||
buildBootstrapPromptWarning,
|
||||
} from "../../../src/agents/bootstrap-budget.js";
|
||||
import { resolveBootstrapContextForRun } from "../../../src/agents/bootstrap-files.js";
|
||||
import { buildEmbeddedSystemPrompt } from "../../../src/agents/pi-embedded-runner/system-prompt.js";
|
||||
import { buildAgentSystemPrompt } from "../../../src/agents/system-prompt.js";
|
||||
import { createStubTool } from "../../../src/agents/test-helpers/pi-tool-stubs.js";
|
||||
import { buildGroupChatContext, buildGroupIntro } from "../../../src/auto-reply/reply/groups.js";
|
||||
import {
|
||||
buildInboundMetaSystemPrompt,
|
||||
buildInboundUserContextPrefix,
|
||||
} from "../../../src/auto-reply/reply/inbound-meta.js";
|
||||
import type { TemplateContext } from "../../../src/auto-reply/templating.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../../../src/test-helpers/workspace.js";
|
||||
|
||||
export type PromptScenarioTurn = {
|
||||
id: string;
|
||||
label: string;
|
||||
systemPrompt: string;
|
||||
bodyPrompt: string;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type PromptScenario = {
|
||||
scenario: string;
|
||||
focus: string;
|
||||
expectedStableSystemAfterTurnIds: string[];
|
||||
turns: PromptScenarioTurn[];
|
||||
};
|
||||
|
||||
function buildCommonSystemParams(workspaceDir: string) {
|
||||
const toolNames = [
|
||||
"bash",
|
||||
"read",
|
||||
"edit",
|
||||
"grep",
|
||||
"glob",
|
||||
"message",
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
"web_search",
|
||||
"x_search",
|
||||
"web_fetch",
|
||||
];
|
||||
return {
|
||||
runtimeInfo: {
|
||||
agentId: "main",
|
||||
host: "cache-lab",
|
||||
repoRoot: workspaceDir,
|
||||
os: "Darwin 24.0.0",
|
||||
arch: "arm64",
|
||||
node: process.version,
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
defaultModel: "anthropic/claude-sonnet-4-5",
|
||||
shell: "zsh",
|
||||
},
|
||||
userTimezone: "America/Los_Angeles",
|
||||
userTime: "Monday, March 16th, 2026 - 9:00 PM",
|
||||
userTimeFormat: "12" as const,
|
||||
toolNames,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
extraSystemPrompt?: string;
|
||||
skillsPrompt?: string;
|
||||
reactionGuidance?: { level: "minimal" | "extensive"; channel: string };
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
}) {
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat, toolNames } =
|
||||
buildCommonSystemParams(params.workspaceDir);
|
||||
return buildAgentSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
runtimeInfo,
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
toolNames,
|
||||
modelAliasLines: [],
|
||||
promptMode: "full",
|
||||
acpEnabled: true,
|
||||
skillsPrompt: params.skillsPrompt,
|
||||
reactionGuidance: params.reactionGuidance,
|
||||
contextFiles: params.contextFiles,
|
||||
});
|
||||
}
|
||||
|
||||
function buildAutoReplyBody(params: { ctx: TemplateContext; body: string; eventLine?: string }) {
|
||||
return [params.eventLine, buildInboundUserContextPrefix(params.ctx), params.body]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
async function readContextFiles(workspaceDir: string, fileNames: string[]) {
|
||||
return Promise.all(
|
||||
fileNames.map(async (fileName) => ({
|
||||
path: fileName,
|
||||
content: await fs.readFile(path.join(workspaceDir, fileName), "utf-8"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAutoReplySystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
sessionCtx: TemplateContext;
|
||||
includeGroupChatContext?: boolean;
|
||||
includeGroupIntro?: boolean;
|
||||
groupSystemPrompt?: string;
|
||||
}) {
|
||||
const extraSystemPromptParts = [
|
||||
buildInboundMetaSystemPrompt(params.sessionCtx),
|
||||
params.includeGroupChatContext ? buildGroupChatContext({ sessionCtx: params.sessionCtx }) : "",
|
||||
params.includeGroupIntro
|
||||
? buildGroupIntro({
|
||||
cfg: {} as OpenClawConfig,
|
||||
sessionCtx: params.sessionCtx,
|
||||
defaultActivation: "mention",
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
})
|
||||
: "",
|
||||
params.groupSystemPrompt?.trim() ?? "",
|
||||
].filter(Boolean);
|
||||
return buildSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function buildToolRichSystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
skillsPrompt: string;
|
||||
contextFiles: Array<{ path: string; content: string }>;
|
||||
}) {
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildCommonSystemParams(
|
||||
params.workspaceDir,
|
||||
);
|
||||
const tools = [
|
||||
"bash",
|
||||
"read",
|
||||
"edit",
|
||||
"grep",
|
||||
"glob",
|
||||
"message",
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
"web_search",
|
||||
"x_search",
|
||||
"web_fetch",
|
||||
].map((name) => Object.assign({}, createStubTool(name), { description: `${name} tool` }));
|
||||
return buildEmbeddedSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
reasoningTagHint: false,
|
||||
runtimeInfo,
|
||||
tools,
|
||||
modelAliasLines: [],
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
acpEnabled: true,
|
||||
skillsPrompt: params.skillsPrompt,
|
||||
reactionGuidance: { level: "extensive", channel: "Telegram" },
|
||||
contextFiles: params.contextFiles,
|
||||
});
|
||||
}
|
||||
|
||||
function createDirectScenario(workspaceDir: string): PromptScenario {
|
||||
const baseCtx: TemplateContext = {
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "D123",
|
||||
AccountId: "A1",
|
||||
ChatType: "direct",
|
||||
SenderId: "U1",
|
||||
SenderName: "Alice",
|
||||
Body: "hi",
|
||||
BodyStripped: "hi",
|
||||
};
|
||||
return {
|
||||
scenario: "auto-reply-direct",
|
||||
focus:
|
||||
"Normal direct-chat turns with ids, reply context, think hint, and runtime event body injection",
|
||||
expectedStableSystemAfterTurnIds: ["t2", "t3", "t4"],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "Direct turn with reply context",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m1",
|
||||
ReplyToId: "r1",
|
||||
ReplyToBody: "prior message",
|
||||
WasMentioned: true,
|
||||
},
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m1",
|
||||
ReplyToId: "r1",
|
||||
ReplyToBody: "prior message",
|
||||
WasMentioned: true,
|
||||
},
|
||||
body: "Please summarize yesterday's decision.",
|
||||
}),
|
||||
notes: ["Direct chat baseline", "Per-message ids and reply context change in body only"],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Direct turn with new message id",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m2",
|
||||
ReplyToId: "r2",
|
||||
},
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m2",
|
||||
ReplyToId: "r2",
|
||||
},
|
||||
body: "Now open the read tool and inspect AGENTS.md.",
|
||||
}),
|
||||
notes: ["Steady-state direct turn", "No runtime event"],
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
label: "Direct turn with runtime event and think hint",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m3",
|
||||
ReplyToId: "r3",
|
||||
},
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m3",
|
||||
ReplyToId: "r3",
|
||||
},
|
||||
eventLine: "System: [t] Model switched.",
|
||||
body: "low use tools if needed and tell me which file controls startup behavior",
|
||||
}),
|
||||
notes: ["Touches runtime event body path", "Touches think-hint parsing path"],
|
||||
},
|
||||
{
|
||||
id: "t4",
|
||||
label: "Direct turn after runtime event",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m4",
|
||||
ReplyToId: "r4",
|
||||
},
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m4",
|
||||
ReplyToId: "r4",
|
||||
},
|
||||
body: "Repeat the startup file path only.",
|
||||
}),
|
||||
notes: ["Checks steady-state after event turn"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupScenario(workspaceDir: string): PromptScenario {
|
||||
const baseCtx: TemplateContext = {
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "C123",
|
||||
AccountId: "A1",
|
||||
ChatType: "group",
|
||||
GroupSubject: "ops",
|
||||
GroupChannel: "#ops",
|
||||
GroupMembers: "Bob, Cara, Dan, Eve",
|
||||
SenderId: "U2",
|
||||
SenderName: "Bob",
|
||||
Body: "hi",
|
||||
BodyStripped: "hi",
|
||||
};
|
||||
return {
|
||||
scenario: "auto-reply-group",
|
||||
focus: "Group chat bootstrap, steady state, and runtime event turns",
|
||||
expectedStableSystemAfterTurnIds: ["t3"],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "First group turn with one-time intro",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g1",
|
||||
WasMentioned: true,
|
||||
InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }],
|
||||
},
|
||||
includeGroupChatContext: true,
|
||||
includeGroupIntro: true,
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g1",
|
||||
WasMentioned: true,
|
||||
InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }],
|
||||
},
|
||||
body: "Can you investigate this issue?",
|
||||
}),
|
||||
notes: ["Expected first-turn bootstrap churn", "Not steady-state"],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Steady-state group turn",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g2",
|
||||
WasMentioned: false,
|
||||
InboundHistory: [
|
||||
{ sender: "Cara", timestamp: 1, body: "status?" },
|
||||
{ sender: "Dan", timestamp: 2, body: "please help" },
|
||||
],
|
||||
},
|
||||
includeGroupChatContext: true,
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g2",
|
||||
WasMentioned: false,
|
||||
InboundHistory: [
|
||||
{ sender: "Cara", timestamp: 1, body: "status?" },
|
||||
{ sender: "Dan", timestamp: 2, body: "please help" },
|
||||
],
|
||||
},
|
||||
body: "Give a short update.",
|
||||
}),
|
||||
notes: ["One-time intro gone", "Should settle afterward"],
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
label: "Group turn with runtime event",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g2",
|
||||
WasMentioned: false,
|
||||
InboundHistory: [
|
||||
{ sender: "Cara", timestamp: 1, body: "status?" },
|
||||
{ sender: "Dan", timestamp: 2, body: "please help" },
|
||||
],
|
||||
},
|
||||
includeGroupChatContext: true,
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g3",
|
||||
WasMentioned: true,
|
||||
InboundHistory: [
|
||||
{ sender: "Cara", timestamp: 1, body: "status?" },
|
||||
{ sender: "Dan", timestamp: 2, body: "please help" },
|
||||
{ sender: "Eve", timestamp: 3, body: "what changed?" },
|
||||
],
|
||||
},
|
||||
eventLine: "System: [t] Node connected.",
|
||||
body: "Tell the room whether tools are available.",
|
||||
}),
|
||||
notes: ["Runtime event lands in body", "System prompt should stay stable vs t2"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function createToolRichScenario(workspaceDir: string): Promise<PromptScenario> {
|
||||
const skillsPrompt = [
|
||||
"<available_skills>",
|
||||
"<skill><name>checks</name><description>Run checks before landing changes.</description><location>/skills/checks/SKILL.md</location></skill>",
|
||||
"<skill><name>release</name><description>Release OpenClaw safely.</description><location>/skills/release/SKILL.md</location></skill>",
|
||||
"</available_skills>",
|
||||
].join("\n");
|
||||
const contextFiles = await readContextFiles(workspaceDir, ["AGENTS.md", "TOOLS.md", "SOUL.md"]);
|
||||
const systemPrompt = buildToolRichSystemPrompt({
|
||||
workspaceDir,
|
||||
skillsPrompt,
|
||||
contextFiles,
|
||||
});
|
||||
return {
|
||||
scenario: "tool-rich-agent-run",
|
||||
focus:
|
||||
"Tool-enabled system prompt with skills, reactions, workspace bootstrap, and a follow-up after fictional tool calls",
|
||||
expectedStableSystemAfterTurnIds: ["t2"],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "Tool-rich turn asking for search, read, and file edits",
|
||||
systemPrompt,
|
||||
bodyPrompt: [
|
||||
"Conversation info (untrusted metadata):",
|
||||
"```json",
|
||||
JSON.stringify({ message_id: "tool-1", sender_id: "U9", was_mentioned: true }, null, 2),
|
||||
"```",
|
||||
"",
|
||||
"high Search the workspace, read AGENTS.md, inspect the failing test, and propose a patch.",
|
||||
].join("\n"),
|
||||
notes: ["Touches tool list in system prompt", "Touches high-thinking hint in body"],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Follow-up after a fictional tool call",
|
||||
systemPrompt,
|
||||
bodyPrompt: [
|
||||
"Conversation info (untrusted metadata):",
|
||||
"```json",
|
||||
JSON.stringify({ message_id: "tool-2", sender_id: "U9" }, null, 2),
|
||||
"```",
|
||||
"",
|
||||
"Tool transcript summary (untrusted, for context):",
|
||||
"```json",
|
||||
JSON.stringify(
|
||||
[
|
||||
{ role: "assistant", action: "tool_use", name: "read", target: "AGENTS.md" },
|
||||
{ role: "tool", name: "read", result: "Loaded AGENTS.md" },
|
||||
{ role: "assistant", action: "tool_use", name: "grep", target: "failing test" },
|
||||
{ role: "tool", name: "grep", result: "Matched src/foo.ts:42" },
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"```",
|
||||
"",
|
||||
"Continue and explain the root cause.",
|
||||
].join("\n"),
|
||||
notes: ["Simulates tool-call-heavy conversation", "System prompt should stay stable"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function createBootstrapWarningScenario(workspaceDir: string): Promise<PromptScenario> {
|
||||
const bootstrapConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 1_500,
|
||||
bootstrapTotalMaxChars: 2_200,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const largeAgents = "# AGENTS.md\n\n" + "Rules.\n".repeat(5_000);
|
||||
const largeTools = "# TOOLS.md\n\n" + "Notes.\n".repeat(3_000);
|
||||
await writeWorkspaceFile({ dir: workspaceDir, name: "AGENTS.md", content: largeAgents });
|
||||
await writeWorkspaceFile({ dir: workspaceDir, name: "TOOLS.md", content: largeTools });
|
||||
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({
|
||||
workspaceDir,
|
||||
config: bootstrapConfig,
|
||||
});
|
||||
const analysis = analyzeBootstrapBudget({
|
||||
files: buildBootstrapInjectionStats({
|
||||
bootstrapFiles,
|
||||
injectedFiles: contextFiles,
|
||||
}),
|
||||
bootstrapMaxChars: bootstrapConfig.agents.defaults.bootstrapMaxChars,
|
||||
bootstrapTotalMaxChars: bootstrapConfig.agents.defaults.bootstrapTotalMaxChars,
|
||||
});
|
||||
if (!analysis.hasTruncation) {
|
||||
throw new Error("bootstrap-warning scenario expected truncated bootstrap context");
|
||||
}
|
||||
const warningFirst = buildBootstrapPromptWarning({
|
||||
analysis,
|
||||
mode: "once",
|
||||
seenSignatures: [],
|
||||
});
|
||||
const warningSeen = buildBootstrapPromptWarning({
|
||||
analysis,
|
||||
mode: "once",
|
||||
seenSignatures: warningFirst.warningSignaturesSeen,
|
||||
previousSignature: warningFirst.signature,
|
||||
});
|
||||
const warningAlways = buildBootstrapPromptWarning({
|
||||
analysis,
|
||||
mode: "always",
|
||||
seenSignatures: warningFirst.warningSignaturesSeen,
|
||||
previousSignature: warningFirst.signature,
|
||||
});
|
||||
return {
|
||||
scenario: "bootstrap-warning",
|
||||
focus: "Workspace bootstrap truncation warnings inside # Project Context",
|
||||
expectedStableSystemAfterTurnIds: ["t2", "t3"],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "First warning emission",
|
||||
systemPrompt: buildSystemPrompt({
|
||||
workspaceDir,
|
||||
contextFiles,
|
||||
}),
|
||||
bodyPrompt: appendBootstrapPromptWarning("hello", warningFirst.lines),
|
||||
notes: ["Warning is appended to the turn body", "System prompt should stay stable"],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Same truncation signature after once-mode dedupe",
|
||||
systemPrompt: buildSystemPrompt({
|
||||
workspaceDir,
|
||||
contextFiles,
|
||||
}),
|
||||
bodyPrompt: appendBootstrapPromptWarning("hello again", warningSeen.lines),
|
||||
notes: ["Once-mode removes warning lines", "Only the body tail changes now"],
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
label: "Always-mode warning",
|
||||
systemPrompt: buildSystemPrompt({
|
||||
workspaceDir,
|
||||
contextFiles,
|
||||
}),
|
||||
bodyPrompt: appendBootstrapPromptWarning("one more turn", warningAlways.lines),
|
||||
notes: [
|
||||
"Always-mode keeps warning in the body prompt tail",
|
||||
"System prompt remains stable",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function createMaintenanceScenario(workspaceDir: string): Promise<PromptScenario> {
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: "AGENTS.md",
|
||||
content: [
|
||||
"## Session Startup",
|
||||
"Read AGENTS.md and MEMORY.md before responding.",
|
||||
"",
|
||||
"## Red Lines",
|
||||
"Do not delete production data.",
|
||||
"",
|
||||
"## Safety",
|
||||
"Never reveal secrets.",
|
||||
].join("\n"),
|
||||
});
|
||||
const memoryFlushPrompt = [
|
||||
"Pre-compaction memory flush.",
|
||||
"Store durable memories only in memory/2026-03-15.md (create memory/ if needed).",
|
||||
"Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.",
|
||||
"If nothing to store, reply with NO_REPLY.",
|
||||
"Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC",
|
||||
].join("\n");
|
||||
const memoryFlushSystemPrompt = buildSystemPrompt({
|
||||
workspaceDir,
|
||||
extraSystemPrompt: [
|
||||
"Pre-compaction memory flush turn.",
|
||||
"The session is near auto-compaction; capture durable memories to disk.",
|
||||
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).",
|
||||
"You may reply, but usually NO_REPLY is correct.",
|
||||
].join(" "),
|
||||
});
|
||||
const postCompaction = [
|
||||
"[Post-compaction context refresh]",
|
||||
"",
|
||||
"Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence.",
|
||||
"",
|
||||
"Critical rules from AGENTS.md:",
|
||||
"",
|
||||
"## Session Startup",
|
||||
"Read AGENTS.md and MEMORY.md before responding.",
|
||||
"",
|
||||
"## Red Lines",
|
||||
"Do not delete production data.",
|
||||
"",
|
||||
"Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC",
|
||||
].join("\n");
|
||||
const postCompactionSystemPrompt = buildSystemPrompt({
|
||||
workspaceDir,
|
||||
extraSystemPrompt: buildInboundMetaSystemPrompt({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "D123",
|
||||
AccountId: "A1",
|
||||
ChatType: "direct",
|
||||
}),
|
||||
});
|
||||
return {
|
||||
scenario: "maintenance-prompts",
|
||||
focus: "Memory flush and post-compaction maintenance prompts",
|
||||
expectedStableSystemAfterTurnIds: [],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "Pre-compaction memory flush run",
|
||||
systemPrompt: memoryFlushSystemPrompt,
|
||||
bodyPrompt: memoryFlushPrompt,
|
||||
notes: [
|
||||
"Writes to memory/2026-03-15.md",
|
||||
"Separate maintenance run; expected to differ from normal user turns",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Post-compaction refresh context run",
|
||||
systemPrompt: postCompactionSystemPrompt,
|
||||
bodyPrompt: postCompaction,
|
||||
notes: [
|
||||
"Separate maintenance context payload",
|
||||
"Expected to differ from normal user turns",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWorkspaceWithPromptCompositionFiles(): Promise<string> {
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-prompt-cache-");
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: "AGENTS.md",
|
||||
content: [
|
||||
"# AGENTS.md",
|
||||
"",
|
||||
"## Session Startup",
|
||||
"Read AGENTS.md and TOOLS.md before making changes.",
|
||||
"",
|
||||
"## Red Lines",
|
||||
"Do not rewrite user commits.",
|
||||
].join("\n"),
|
||||
});
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: "TOOLS.md",
|
||||
content: "# TOOLS.md\n\nUse rg before grep.\n",
|
||||
});
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: "SOUL.md",
|
||||
content: "# SOUL.md\n\nBe concise but kind.\n",
|
||||
});
|
||||
return workspaceDir;
|
||||
}
|
||||
|
||||
export async function createPromptCompositionScenarios(): Promise<{
|
||||
workspaceDir: string;
|
||||
warningWorkspaceDir: string;
|
||||
scenarios: PromptScenario[];
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const workspaceDir = await createWorkspaceWithPromptCompositionFiles();
|
||||
const warningWorkspaceDir = await makeTempWorkspace("openclaw-prompt-cache-warning-");
|
||||
const scenarios = [
|
||||
createDirectScenario(workspaceDir),
|
||||
createGroupScenario(workspaceDir),
|
||||
await createToolRichScenario(workspaceDir),
|
||||
await createBootstrapWarningScenario(warningWorkspaceDir),
|
||||
await createMaintenanceScenario(workspaceDir),
|
||||
];
|
||||
return {
|
||||
workspaceDir,
|
||||
warningWorkspaceDir,
|
||||
scenarios,
|
||||
cleanup: async () => {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
await fs.rm(warningWorkspaceDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
92
openclaw/test/helpers/auth-wizard.ts
Normal file
92
openclaw/test/helpers/auth-wizard.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../src/runtime.js";
|
||||
import { makeTempWorkspace } from "../../src/test-helpers/workspace.js";
|
||||
import { captureEnv } from "../../src/test-utils/env.js";
|
||||
import type { WizardPrompter } from "../../src/wizard/prompts.js";
|
||||
|
||||
export const noopAsync = async () => {};
|
||||
export const noop = () => {};
|
||||
|
||||
export function createExitThrowingRuntime(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createWizardPrompter(
|
||||
overrides: Partial<WizardPrompter>,
|
||||
options?: { defaultSelect?: string },
|
||||
): WizardPrompter {
|
||||
return {
|
||||
intro: vi.fn(noopAsync),
|
||||
outro: vi.fn(noopAsync),
|
||||
note: vi.fn(noopAsync),
|
||||
select: vi.fn(async () => (options?.defaultSelect ?? "") as never),
|
||||
multiselect: vi.fn(async () => []),
|
||||
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: noop, stop: noop })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setupAuthTestEnv(
|
||||
prefix = "openclaw-auth-",
|
||||
options?: { agentSubdir?: string },
|
||||
): Promise<{
|
||||
stateDir: string;
|
||||
agentDir: string;
|
||||
}> {
|
||||
const stateDir = await makeTempWorkspace(prefix);
|
||||
const agentDir = path.join(stateDir, options?.agentSubdir ?? "agent");
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
return { stateDir, agentDir };
|
||||
}
|
||||
|
||||
export type AuthTestLifecycle = {
|
||||
setStateDir: (stateDir: string) => void;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createAuthTestLifecycle(envKeys: string[]): AuthTestLifecycle {
|
||||
const envSnapshot = captureEnv(envKeys);
|
||||
let stateDir: string | null = null;
|
||||
return {
|
||||
setStateDir(nextStateDir: string) {
|
||||
stateDir = nextStateDir;
|
||||
},
|
||||
async cleanup() {
|
||||
if (stateDir) {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
stateDir = null;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function requireOpenClawAgentDir(): string {
|
||||
const agentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
if (!agentDir) {
|
||||
throw new Error("OPENCLAW_AGENT_DIR not set");
|
||||
}
|
||||
return agentDir;
|
||||
}
|
||||
|
||||
export function authProfilePathForAgent(agentDir: string): string {
|
||||
return path.join(agentDir, "auth-profiles.json");
|
||||
}
|
||||
|
||||
export async function readAuthProfilesForAgent<T>(agentDir: string): Promise<T> {
|
||||
const raw = await fs.readFile(authProfilePathForAgent(agentDir), "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
|
@ -0,0 +1,483 @@
|
|||
import { rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, expect, vi } from "vitest";
|
||||
import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles.js";
|
||||
import { withFastReplyConfig } from "../../../src/auto-reply/reply/get-reply-fast-path.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/types.openclaw.js";
|
||||
import { resetProviderRuntimeHookCacheForTest } from "../../../src/plugins/provider-runtime.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||
type AnyMock = any;
|
||||
type AnyMocks = Record<string, any>;
|
||||
|
||||
function getSharedMocks<T>(key: string, create: () => T): T {
|
||||
const symbol = Symbol.for(key);
|
||||
const store = globalThis as Record<symbol, T | undefined>;
|
||||
if (!store[symbol]) {
|
||||
store[symbol] = create();
|
||||
}
|
||||
return store[symbol];
|
||||
}
|
||||
|
||||
const piEmbeddedMocks = getSharedMocks("openclaw.trigger-handling.pi-embedded-mocks", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
compactEmbeddedPiSession: vi.fn(),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
export function getAbortEmbeddedPiRunMock(): AnyMock {
|
||||
return piEmbeddedMocks.abortEmbeddedPiRun;
|
||||
}
|
||||
|
||||
export function getCompactEmbeddedPiSessionMock(): AnyMock {
|
||||
return piEmbeddedMocks.compactEmbeddedPiSession;
|
||||
}
|
||||
|
||||
export function getRunEmbeddedPiAgentMock(): AnyMock {
|
||||
return piEmbeddedMocks.runEmbeddedPiAgent;
|
||||
}
|
||||
|
||||
export function getQueueEmbeddedPiMessageMock(): AnyMock {
|
||||
return piEmbeddedMocks.queueEmbeddedPiMessage;
|
||||
}
|
||||
|
||||
const installPiEmbeddedMock = () =>
|
||||
vi.doMock("../../../src/agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: (...args: unknown[]) => piEmbeddedMocks.abortEmbeddedPiRun(...args),
|
||||
compactEmbeddedPiSession: (...args: unknown[]) =>
|
||||
piEmbeddedMocks.compactEmbeddedPiSession(...args),
|
||||
runEmbeddedPiAgent: (...args: unknown[]) => piEmbeddedMocks.runEmbeddedPiAgent(...args),
|
||||
queueEmbeddedPiMessage: (...args: unknown[]) => piEmbeddedMocks.queueEmbeddedPiMessage(...args),
|
||||
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
|
||||
resolveActiveEmbeddedRunSessionId: (...args: unknown[]) =>
|
||||
piEmbeddedMocks.resolveActiveEmbeddedRunSessionId(...args),
|
||||
isEmbeddedPiRunActive: (...args: unknown[]) => piEmbeddedMocks.isEmbeddedPiRunActive(...args),
|
||||
isEmbeddedPiRunStreaming: (...args: unknown[]) =>
|
||||
piEmbeddedMocks.isEmbeddedPiRunStreaming(...args),
|
||||
}));
|
||||
|
||||
installPiEmbeddedMock();
|
||||
|
||||
vi.doMock("../../../src/agents/pi-embedded-runner/runs.js", () => ({
|
||||
abortEmbeddedPiRun: (...args: unknown[]) => piEmbeddedMocks.abortEmbeddedPiRun(...args),
|
||||
}));
|
||||
|
||||
const providerUsageMocks = vi.hoisted(() => ({
|
||||
loadProviderUsageSummary: vi.fn().mockResolvedValue({
|
||||
updatedAt: 0,
|
||||
providers: [],
|
||||
}),
|
||||
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
|
||||
formatUsageWindowSummary: vi.fn().mockReturnValue("Claude 80% left"),
|
||||
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
|
||||
}));
|
||||
|
||||
export function getProviderUsageMocks(): AnyMocks {
|
||||
return providerUsageMocks;
|
||||
}
|
||||
|
||||
vi.mock("../../../src/infra/provider-usage.js", () => providerUsageMocks);
|
||||
|
||||
const modelCatalogMocks = getSharedMocks("openclaw.trigger-handling.model-catalog-mocks", () => ({
|
||||
loadModelCatalog: vi.fn().mockResolvedValue([
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.5",
|
||||
contextWindow: 200000,
|
||||
},
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "anthropic/claude-opus-4-6",
|
||||
name: "Claude Opus 4.5 (OpenRouter)",
|
||||
contextWindow: 200000,
|
||||
},
|
||||
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
|
||||
{ provider: "openai", id: "gpt-5.4", name: "GPT-5.2" },
|
||||
{ provider: "openai-codex", id: "gpt-5.4", name: "GPT-5.2 (Codex)" },
|
||||
{ provider: "minimax", id: "MiniMax-M2.7", name: "MiniMax M2.7" },
|
||||
]),
|
||||
resetModelCatalogCacheForTest: vi.fn(),
|
||||
}));
|
||||
|
||||
export function getModelCatalogMocks(): AnyMocks {
|
||||
return modelCatalogMocks;
|
||||
}
|
||||
|
||||
const installModelCatalogMock = () =>
|
||||
vi.doMock("../../../src/agents/model-catalog.js", () => modelCatalogMocks);
|
||||
|
||||
installModelCatalogMock();
|
||||
|
||||
vi.doMock("../../../src/agents/model-catalog.runtime.js", () => ({
|
||||
loadModelCatalog: (...args: unknown[]) => modelCatalogMocks.loadModelCatalog(...args),
|
||||
}));
|
||||
|
||||
vi.doMock("../../../src/plugins/provider-runtime.runtime.js", () => ({
|
||||
augmentModelCatalogWithProviderPlugins: async (params: { catalog?: unknown[] }) =>
|
||||
params.catalog ?? [],
|
||||
buildProviderAuthDoctorHintWithPlugin: () => undefined,
|
||||
buildProviderMissingAuthMessageWithPlugin: () => undefined,
|
||||
formatProviderAuthProfileApiKeyWithPlugin: (params: { apiKey?: string }) => params.apiKey,
|
||||
prepareProviderRuntimeAuth: async () => undefined,
|
||||
refreshProviderOAuthCredentialWithPlugin: async () => undefined,
|
||||
}));
|
||||
|
||||
const modelFallbackMocks = getSharedMocks("openclaw.trigger-handling.model-fallback-mocks", () => ({
|
||||
runWithModelFallback: vi.fn(
|
||||
async (params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string, runOptions?: unknown) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await params.run(params.provider, params.model),
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
attempts: [],
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
export function getModelFallbackMocks(): AnyMocks {
|
||||
return modelFallbackMocks;
|
||||
}
|
||||
|
||||
const installModelFallbackMock = () =>
|
||||
vi.doMock("../../../src/agents/model-fallback.js", () => modelFallbackMocks);
|
||||
|
||||
installModelFallbackMock();
|
||||
|
||||
vi.doMock("../../../src/infra/git-commit.js", () => ({
|
||||
resolveCommitHash: vi.fn(() => "abcdef0"),
|
||||
}));
|
||||
|
||||
const webSessionMocks = getSharedMocks("openclaw.trigger-handling.web-session-mocks", () => ({
|
||||
webAuthExists: vi.fn().mockResolvedValue(true),
|
||||
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
|
||||
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
|
||||
}));
|
||||
|
||||
const whatsappRuntimeApiModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "whatsapp",
|
||||
artifactBasename: "runtime-api.js",
|
||||
});
|
||||
|
||||
export function getWebSessionMocks(): AnyMocks {
|
||||
return webSessionMocks;
|
||||
}
|
||||
|
||||
const installWebSessionMock = () => vi.doMock(whatsappRuntimeApiModuleId, () => webSessionMocks);
|
||||
|
||||
installWebSessionMock();
|
||||
|
||||
export const MAIN_SESSION_KEY = "agent:main:main";
|
||||
|
||||
type TempHomeEnvSnapshot = {
|
||||
home: string | undefined;
|
||||
userProfile: string | undefined;
|
||||
homeDrive: string | undefined;
|
||||
homePath: string | undefined;
|
||||
openclawHome: string | undefined;
|
||||
stateDir: string | undefined;
|
||||
};
|
||||
|
||||
let suiteTempHomeRoot = "";
|
||||
let suiteTempHomeId = 0;
|
||||
|
||||
function snapshotTempHomeEnv(): TempHomeEnvSnapshot {
|
||||
return {
|
||||
home: process.env.HOME,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
homeDrive: process.env.HOMEDRIVE,
|
||||
homePath: process.env.HOMEPATH,
|
||||
openclawHome: process.env.OPENCLAW_HOME,
|
||||
stateDir: process.env.OPENCLAW_STATE_DIR,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreTempHomeEnv(snapshot: TempHomeEnvSnapshot): void {
|
||||
const restoreKey = (key: string, value: string | undefined) => {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
process.env[key] = value;
|
||||
};
|
||||
|
||||
restoreKey("HOME", snapshot.home);
|
||||
restoreKey("USERPROFILE", snapshot.userProfile);
|
||||
restoreKey("HOMEDRIVE", snapshot.homeDrive);
|
||||
restoreKey("HOMEPATH", snapshot.homePath);
|
||||
restoreKey("OPENCLAW_HOME", snapshot.openclawHome);
|
||||
restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir);
|
||||
}
|
||||
|
||||
function setTempHomeEnv(home: string): void {
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
process.env.OPENCLAW_STATE_DIR = join(home, ".openclaw");
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return;
|
||||
}
|
||||
const match = home.match(/^([A-Za-z]:)(.*)$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
process.env.HOMEDRIVE = match[1];
|
||||
process.env.HOMEPATH = match[2] || "\\";
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
suiteTempHomeRoot = await fs.mkdtemp(join(os.tmpdir(), "openclaw-triggers-suite-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!suiteTempHomeRoot) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rmSync(suiteTempHomeRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort temp cleanup only.
|
||||
}
|
||||
suiteTempHomeRoot = "";
|
||||
suiteTempHomeId = 0;
|
||||
});
|
||||
|
||||
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const home = join(suiteTempHomeRoot, `case-${++suiteTempHomeId}`);
|
||||
const snapshot = snapshotTempHomeEnv();
|
||||
await fs.mkdir(join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
|
||||
setTempHomeEnv(home);
|
||||
|
||||
try {
|
||||
// Hard reset shared mocks so non-isolated runs don't inherit prior behavior.
|
||||
piEmbeddedMocks.runEmbeddedPiAgent.mockReset();
|
||||
piEmbeddedMocks.abortEmbeddedPiRun.mockReset().mockReturnValue(false);
|
||||
piEmbeddedMocks.compactEmbeddedPiSession.mockReset();
|
||||
piEmbeddedMocks.queueEmbeddedPiMessage.mockReset().mockReturnValue(false);
|
||||
piEmbeddedMocks.isEmbeddedPiRunActive.mockReset().mockReturnValue(false);
|
||||
piEmbeddedMocks.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false);
|
||||
modelFallbackMocks.runWithModelFallback.mockClear();
|
||||
return await fn(home);
|
||||
} finally {
|
||||
restoreTempHomeEnv(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
export function makeCfg(home: string): OpenClawConfig {
|
||||
return withFastReplyConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-6" },
|
||||
workspace: join(home, "openclaw"),
|
||||
// Test harness: avoid 1s coalescer idle sleeps that dominate trigger suites.
|
||||
blockStreamingCoalesce: { idleMs: 1 },
|
||||
// Trigger tests assert routing/authorization behavior, not delivery pacing.
|
||||
humanDelay: { mode: "off" },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
queue: {
|
||||
debounceMs: 0,
|
||||
},
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
} as OpenClawConfig);
|
||||
}
|
||||
|
||||
export async function loadGetReplyFromConfig() {
|
||||
return (await import("../../../src/auto-reply/reply.js")).getReplyFromConfig;
|
||||
}
|
||||
|
||||
export function installTriggerHandlingReplyHarness(
|
||||
setGetReplyFromConfig: (
|
||||
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig,
|
||||
) => void,
|
||||
): void {
|
||||
beforeAll(async () => {
|
||||
setGetReplyFromConfig(await loadGetReplyFromConfig());
|
||||
});
|
||||
installTriggerHandlingE2eTestHooks();
|
||||
}
|
||||
|
||||
export function requireSessionStorePath(cfg: { session?: { store?: string } }): string {
|
||||
const storePath = cfg.session?.store;
|
||||
if (!storePath) {
|
||||
throw new Error("expected session store path");
|
||||
}
|
||||
return storePath;
|
||||
}
|
||||
|
||||
export async function readSessionStore(cfg: {
|
||||
session?: { store?: string };
|
||||
}): Promise<Record<string, { elevatedLevel?: string }>> {
|
||||
const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8");
|
||||
return JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
|
||||
}
|
||||
|
||||
export function makeWhatsAppElevatedCfg(
|
||||
home: string,
|
||||
opts?: { elevatedEnabled?: boolean; requireMentionInGroups?: boolean },
|
||||
): OpenClawConfig {
|
||||
const cfg = makeCfg(home);
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.whatsapp = {
|
||||
...cfg.channels.whatsapp,
|
||||
allowFrom: ["+1000"],
|
||||
};
|
||||
if (opts?.requireMentionInGroups !== undefined) {
|
||||
cfg.channels.whatsapp.groups = { "*": { requireMention: opts.requireMentionInGroups } };
|
||||
}
|
||||
|
||||
cfg.tools = {
|
||||
...cfg.tools,
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
...(opts?.elevatedEnabled === false ? { enabled: false } : {}),
|
||||
},
|
||||
};
|
||||
return cfg;
|
||||
}
|
||||
|
||||
export async function runDirectElevatedToggleAndLoadStore(params: {
|
||||
cfg: OpenClawConfig;
|
||||
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
|
||||
body?: string;
|
||||
}): Promise<{
|
||||
text: string | undefined;
|
||||
store: Record<string, { elevatedLevel?: string }>;
|
||||
}> {
|
||||
const res = await params.getReplyFromConfig(
|
||||
{
|
||||
Body: params.body ?? "/elevated on",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
params.cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const storePath = params.cfg.session?.store;
|
||||
if (!storePath) {
|
||||
throw new Error("session.store is required in test config");
|
||||
}
|
||||
const store = await readSessionStore(params.cfg);
|
||||
return { text, store };
|
||||
}
|
||||
|
||||
export async function expectInlineCommandHandledAndStripped(params: {
|
||||
home: string;
|
||||
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
|
||||
body: string;
|
||||
stripToken: string;
|
||||
blockReplyContains: string;
|
||||
requestOverrides?: Record<string, unknown>;
|
||||
}) {
|
||||
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk();
|
||||
runEmbeddedPiAgentMock.mockClear();
|
||||
const { blockReplies, handlers } = createBlockReplyCollector();
|
||||
const res = await params.getReplyFromConfig(
|
||||
{
|
||||
Body: params.body,
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
...params.requestOverrides,
|
||||
},
|
||||
handlers,
|
||||
makeCfg(params.home),
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(blockReplies.length).toBe(1);
|
||||
expect(blockReplies[0]?.text).toContain(params.blockReplyContains);
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalled();
|
||||
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
|
||||
expect(prompt).not.toContain(params.stripToken);
|
||||
expect(text).toBe("ok");
|
||||
}
|
||||
|
||||
export async function runGreetingPromptForBareNewOrReset(params: {
|
||||
home: string;
|
||||
body: "/new" | "/reset";
|
||||
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
|
||||
}) {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
runEmbeddedPiAgentMock.mockClear();
|
||||
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await params.getReplyFromConfig(
|
||||
{
|
||||
Body: params.body,
|
||||
From: "+1003",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeCfg(params.home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("hello");
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
|
||||
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
|
||||
expect(prompt).toContain("A new session was started via /new or /reset");
|
||||
expect(prompt).toContain("Execute your Session Startup sequence now");
|
||||
expect(prompt).toContain("read the required files before responding to the user");
|
||||
}
|
||||
|
||||
export function installTriggerHandlingE2eTestHooks() {
|
||||
afterEach(() => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
resetProviderRuntimeHookCacheForTest();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
}
|
||||
|
||||
export function mockRunEmbeddedPiAgentOk(text = "ok"): AnyMock {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
return runEmbeddedPiAgentMock;
|
||||
}
|
||||
|
||||
export function createBlockReplyCollector() {
|
||||
const blockReplies: Array<{ text?: string }> = [];
|
||||
return {
|
||||
blockReplies,
|
||||
handlers: {
|
||||
onBlockReply: async (payload: { text?: string }) => {
|
||||
blockReplies.push(payload);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
71
openclaw/test/helpers/browser-bundled-plugin-fixture.ts
Normal file
71
openclaw/test/helpers/browser-bundled-plugin-fixture.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const BROWSER_FIXTURE_MANIFEST = {
|
||||
id: "browser",
|
||||
enabledByDefault: true,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
const BROWSER_FIXTURE_ENTRY = `module.exports = {
|
||||
id: "browser",
|
||||
name: "Browser",
|
||||
description: "Bundled browser fixture plugin",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
register(api) {
|
||||
api.registerTool((ctx) => ({
|
||||
name: "browser",
|
||||
label: "browser",
|
||||
description: "browser fixture tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
async execute() {
|
||||
return {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {
|
||||
workspaceOnly: ctx.fsPolicy?.workspaceOnly ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
api.registerCli(({ program }) => {
|
||||
program.command("browser");
|
||||
}, { commands: ["browser"] });
|
||||
api.registerGatewayMethod("browser.request", async () => ({ ok: true }), {
|
||||
scope: "operator.write",
|
||||
});
|
||||
api.registerService({
|
||||
id: "browser-control",
|
||||
start() {},
|
||||
});
|
||||
},
|
||||
};`;
|
||||
|
||||
export function createBundledBrowserPluginFixture(): { rootDir: string; cleanup: () => void } {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-browser-bundled-"));
|
||||
const pluginDir = path.join(rootDir, "browser");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify(BROWSER_FIXTURE_MANIFEST, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), BROWSER_FIXTURE_ENTRY, "utf8");
|
||||
return {
|
||||
rootDir,
|
||||
cleanup() {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
38
openclaw/test/helpers/bundled-channel-entry.ts
Normal file
38
openclaw/test/helpers/bundled-channel-entry.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
type BundledChannelEntry = {
|
||||
id: string;
|
||||
kind?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type BundledChannelSetupEntry = {
|
||||
kind?: string;
|
||||
loadSetupPlugin?: unknown;
|
||||
};
|
||||
|
||||
export function assertBundledChannelEntries(params: {
|
||||
entry: BundledChannelEntry;
|
||||
expectedId: string;
|
||||
expectedName: string;
|
||||
setupEntry: BundledChannelSetupEntry;
|
||||
channelMessage?: string;
|
||||
setupMessage?: string;
|
||||
}) {
|
||||
it(
|
||||
params.channelMessage ?? "declares the channel plugin without importing the broad api barrel",
|
||||
() => {
|
||||
expect(params.entry.kind).toBe("bundled-channel-entry");
|
||||
expect(params.entry.id).toBe(params.expectedId);
|
||||
expect(params.entry.name).toBe(params.expectedName);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
params.setupMessage ?? "declares the setup plugin without importing the broad api barrel",
|
||||
() => {
|
||||
expect(params.setupEntry.kind).toBe("bundled-channel-setup-entry");
|
||||
expect(typeof params.setupEntry.loadSetupPlugin).toBe("function");
|
||||
},
|
||||
);
|
||||
}
|
||||
59
openclaw/test/helpers/bundled-plugin-paths.ts
Normal file
59
openclaw/test/helpers/bundled-plugin-paths.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export const BUNDLED_PLUGIN_ROOT_DIR = "extensions";
|
||||
export const BUNDLED_PLUGIN_PATH_PREFIX = `${BUNDLED_PLUGIN_ROOT_DIR}/`;
|
||||
export const BUNDLED_PLUGIN_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.test.ts`;
|
||||
|
||||
export function bundledPluginRoot(pluginId: string): string {
|
||||
return `${BUNDLED_PLUGIN_PATH_PREFIX}${pluginId}`;
|
||||
}
|
||||
|
||||
export function bundledPluginFile(pluginId: string, relativePath: string): string {
|
||||
return `${bundledPluginRoot(pluginId)}/${relativePath}`;
|
||||
}
|
||||
|
||||
function joinRoot(baseDir: string, relativePath: string): string {
|
||||
return `${baseDir.replace(/\/$/, "")}/${relativePath}`;
|
||||
}
|
||||
|
||||
export function bundledPluginDirPrefix(pluginId: string, relativeDir: string): string {
|
||||
return `${bundledPluginRoot(pluginId)}/${relativeDir.replace(/\/$/, "")}/`;
|
||||
}
|
||||
|
||||
export function bundledPluginRootAt(baseDir: string, pluginId: string): string {
|
||||
return joinRoot(baseDir, bundledPluginRoot(pluginId));
|
||||
}
|
||||
|
||||
export function bundledPluginFileAt(
|
||||
baseDir: string,
|
||||
pluginId: string,
|
||||
relativePath: string,
|
||||
): string {
|
||||
return joinRoot(baseDir, bundledPluginFile(pluginId, relativePath));
|
||||
}
|
||||
|
||||
export function bundledDistPluginRoot(pluginId: string): string {
|
||||
return `dist/${bundledPluginRoot(pluginId)}`;
|
||||
}
|
||||
|
||||
export function bundledDistPluginFile(pluginId: string, relativePath: string): string {
|
||||
return `${bundledDistPluginRoot(pluginId)}/${relativePath}`;
|
||||
}
|
||||
|
||||
export function bundledDistPluginRootAt(baseDir: string, pluginId: string): string {
|
||||
return joinRoot(baseDir, bundledDistPluginRoot(pluginId));
|
||||
}
|
||||
|
||||
export function bundledDistPluginFileAt(
|
||||
baseDir: string,
|
||||
pluginId: string,
|
||||
relativePath: string,
|
||||
): string {
|
||||
return joinRoot(baseDir, bundledDistPluginFile(pluginId, relativePath));
|
||||
}
|
||||
|
||||
export function installedPluginRoot(baseDir: string, pluginId: string): string {
|
||||
return bundledPluginRootAt(baseDir, pluginId);
|
||||
}
|
||||
|
||||
export function repoInstallSpec(pluginId: string): string {
|
||||
return `./${bundledPluginRoot(pluginId)}`;
|
||||
}
|
||||
5
openclaw/test/helpers/bundled-runtime-sidecars.ts
Normal file
5
openclaw/test/helpers/bundled-runtime-sidecars.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [
|
||||
"dist/extensions/discord/runtime-api.js",
|
||||
"dist/extensions/slack/helper-api.js",
|
||||
"dist/extensions/telegram/thread-bindings-runtime.js",
|
||||
] as const;
|
||||
20
openclaw/test/helpers/captured-io.ts
Normal file
20
openclaw/test/helpers/captured-io.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export function createCapturedIo() {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write(chunk: unknown) {
|
||||
stdout += String(chunk);
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write(chunk: unknown) {
|
||||
stderr += String(chunk);
|
||||
},
|
||||
},
|
||||
},
|
||||
readStdout: () => stdout,
|
||||
readStderr: () => stderr,
|
||||
};
|
||||
}
|
||||
34
openclaw/test/helpers/channels/AGENTS.md
Normal file
34
openclaw/test/helpers/channels/AGENTS.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Test Helper Boundary
|
||||
|
||||
This directory holds shared channel test helpers used by core and bundled plugin
|
||||
tests.
|
||||
|
||||
This file adds channel-specific rules on top of `test/helpers/AGENTS.md`.
|
||||
|
||||
## Bundled Plugin Imports
|
||||
|
||||
- Core test helpers in this directory must not hardcode repo-relative imports
|
||||
into `extensions/**`.
|
||||
- When a helper needs a bundled plugin public/test surface, go through
|
||||
`src/test-utils/bundled-plugin-public-surface.ts`.
|
||||
- Prefer `loadBundledPluginTestApiSync(...)` for eager access to exported test
|
||||
helpers.
|
||||
- Prefer `resolveRelativeBundledPluginPublicModuleId(...)` when a test needs a
|
||||
module id for dynamic import or mocking.
|
||||
- If `vi.mock(...)` hoisting would evaluate the module id too early, use
|
||||
`vi.doMock(...)` with the resolved module id instead of falling back to a
|
||||
hardcoded path.
|
||||
- For contract helpers, prefer minimal in-memory channel/plugin fixtures when
|
||||
the contract only needs capabilities, session binding hooks, routing metadata,
|
||||
or outbound payload helpers. Do not load broad `api.ts`, `runtime-api.ts`, or
|
||||
`test-api.ts` barrels for incidental setup.
|
||||
- If a bundled plugin parser is the contract under test, load the narrow module
|
||||
that owns that parser or promote a small public artifact. Avoid pulling a full
|
||||
extension barrel just to parse a target id.
|
||||
|
||||
## Intent
|
||||
|
||||
- Keep shared test helpers aligned with the same public/plugin boundary that
|
||||
production code uses.
|
||||
- Avoid drift where core test helpers start reaching into bundled plugin private
|
||||
files by path because it is convenient in one test.
|
||||
1
openclaw/test/helpers/channels/CLAUDE.md
Normal file
1
openclaw/test/helpers/channels/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
213
openclaw/test/helpers/channels/channel-catalog-contract.ts
Normal file
213
openclaw/test/helpers/channels/channel-catalog-contract.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getChannelPluginCatalogEntry,
|
||||
listChannelPluginCatalogEntries,
|
||||
} from "../../../src/channels/plugins/catalog.js";
|
||||
|
||||
type CatalogEntryMeta = {
|
||||
id: string;
|
||||
label: string;
|
||||
selectionLabel: string;
|
||||
docsPath: string;
|
||||
blurb: string;
|
||||
detailLabel?: string;
|
||||
aliases?: string[];
|
||||
};
|
||||
|
||||
export function describeChannelCatalogEntryContract(params: {
|
||||
channelId: string;
|
||||
npmSpec: string;
|
||||
alias?: string;
|
||||
}) {
|
||||
describe(`${params.channelId} channel catalog contract`, () => {
|
||||
it("keeps the shipped catalog entry aligned", () => {
|
||||
const entry = getChannelPluginCatalogEntry(params.channelId);
|
||||
expect(entry?.install.npmSpec).toBe(params.npmSpec);
|
||||
if (params.alias) {
|
||||
expect(entry?.meta.aliases).toContain(params.alias);
|
||||
}
|
||||
});
|
||||
|
||||
it("appears in the channel catalog listing", () => {
|
||||
const ids = listChannelPluginCatalogEntries().map((entry) => entry.id);
|
||||
expect(ids).toContain(params.channelId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeBundledMetadataOnlyChannelCatalogContract(params: {
|
||||
pluginId: string;
|
||||
packageName: string;
|
||||
npmSpec: string;
|
||||
meta: CatalogEntryMeta;
|
||||
defaultChoice?: string;
|
||||
}) {
|
||||
describe(`${params.pluginId} bundled metadata-only channel catalog contract`, () => {
|
||||
it("includes the bundled metadata-only channel entry when the runtime entrypoint is omitted", () => {
|
||||
const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-catalog-"));
|
||||
const bundledDir = path.join(packageRoot, "dist", "extensions", params.pluginId);
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw" }),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bundledDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
extensions: ["./index.js"],
|
||||
channel: params.meta,
|
||||
install: {
|
||||
npmSpec: params.npmSpec,
|
||||
defaultChoice: params.defaultChoice,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(bundledDir, "index.js"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(bundledDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({ id: params.pluginId, channels: [params.meta.id], configSchema: {} }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const entry = listChannelPluginCatalogEntries({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"),
|
||||
},
|
||||
}).find((item) => item.id === params.meta.id);
|
||||
|
||||
expect(entry?.install.npmSpec).toBe(params.npmSpec);
|
||||
expect(entry?.pluginId).toBe(params.pluginId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOfficialFallbackChannelCatalogContract(params: {
|
||||
channelId: string;
|
||||
npmSpec: string;
|
||||
meta: CatalogEntryMeta;
|
||||
packageName: string;
|
||||
pluginId: string;
|
||||
externalNpmSpec: string;
|
||||
externalLabel: string;
|
||||
}) {
|
||||
describe(`${params.channelId} official fallback channel catalog contract`, () => {
|
||||
it("includes shipped official channel catalog entries when bundled metadata is omitted", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-official-catalog-"));
|
||||
const catalogPath = path.join(dir, "channel-catalog.json");
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
channel: params.meta,
|
||||
install: {
|
||||
npmSpec: params.npmSpec,
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const entry = listChannelPluginCatalogEntries({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
officialCatalogPaths: [catalogPath],
|
||||
}).find((item) => item.id === params.channelId);
|
||||
|
||||
expect(entry?.install.npmSpec).toBe(params.npmSpec);
|
||||
expect(entry?.pluginId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets external catalogs override shipped fallback channel metadata", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-fallback-catalog-"));
|
||||
const bundledDir = path.join(dir, "dist", "extensions", params.pluginId);
|
||||
const officialCatalogPath = path.join(dir, "channel-catalog.json");
|
||||
const externalCatalogPath = path.join(dir, "catalog.json");
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(bundledDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
channel: {
|
||||
...params.meta,
|
||||
label: `${params.meta.label} Bundled`,
|
||||
selectionLabel: `${params.meta.label} Bundled`,
|
||||
blurb: "bundled fallback",
|
||||
},
|
||||
install: { npmSpec: params.npmSpec },
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
officialCatalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
channel: {
|
||||
...params.meta,
|
||||
label: `${params.meta.label} Official`,
|
||||
selectionLabel: `${params.meta.label} Official`,
|
||||
blurb: "official fallback",
|
||||
},
|
||||
install: { npmSpec: params.npmSpec },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
externalCatalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: params.externalNpmSpec,
|
||||
openclaw: {
|
||||
channel: {
|
||||
...params.meta,
|
||||
label: params.externalLabel,
|
||||
selectionLabel: params.externalLabel,
|
||||
blurb: "external override",
|
||||
},
|
||||
install: { npmSpec: params.externalNpmSpec },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const entry = listChannelPluginCatalogEntries({
|
||||
catalogPaths: [externalCatalogPath],
|
||||
officialCatalogPaths: [officialCatalogPath],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(dir, "dist", "extensions"),
|
||||
},
|
||||
}).find((item) => item.id === params.channelId);
|
||||
|
||||
expect(entry?.install.npmSpec).toBe(params.externalNpmSpec);
|
||||
expect(entry?.meta.label).toBe(params.externalLabel);
|
||||
expect(entry?.pluginId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type IMessageContractSurface = {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS: string[];
|
||||
resolveIMessageAttachmentRoots: (params: unknown) => string[];
|
||||
resolveIMessageRemoteAttachmentRoots: (params: unknown) => string[];
|
||||
};
|
||||
|
||||
const {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} = (await import(
|
||||
resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "imessage",
|
||||
artifactBasename: "contract-api.js",
|
||||
})
|
||||
)) as IMessageContractSurface;
|
||||
|
||||
export {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
};
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listChannelPluginCatalogEntries } from "../../../src/channels/plugins/catalog.js";
|
||||
|
||||
function createCatalogEntry(params: {
|
||||
packageName: string;
|
||||
channelId: string;
|
||||
label: string;
|
||||
blurb: string;
|
||||
order?: number;
|
||||
}) {
|
||||
return {
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: params.channelId,
|
||||
label: params.label,
|
||||
selectionLabel: params.label,
|
||||
docsPath: `/channels/${params.channelId}`,
|
||||
blurb: params.blurb,
|
||||
...(params.order === undefined ? {} : { order: params.order }),
|
||||
},
|
||||
install: {
|
||||
npmSpec: params.packageName,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeCatalogFile(catalogPath: string, entry: Record<string, unknown>) {
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [entry],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function writeDiscoveredChannelPlugin(params: {
|
||||
stateDir: string;
|
||||
packageName: string;
|
||||
channelLabel: string;
|
||||
pluginId: string;
|
||||
blurb: string;
|
||||
}) {
|
||||
const pluginDir = path.join(params.stateDir, "extensions", "demo-channel-plugin");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: params.packageName,
|
||||
openclaw: {
|
||||
extensions: ["./index.js"],
|
||||
channel: {
|
||||
id: "demo-channel",
|
||||
label: params.channelLabel,
|
||||
selectionLabel: params.channelLabel,
|
||||
docsPath: "/channels/demo-channel",
|
||||
blurb: params.blurb,
|
||||
},
|
||||
install: {
|
||||
npmSpec: params.packageName,
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: params.pluginId,
|
||||
configSchema: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8");
|
||||
}
|
||||
|
||||
function expectCatalogIdsContain(params: {
|
||||
expectedId: string;
|
||||
catalogPaths?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
const ids = listChannelPluginCatalogEntries({
|
||||
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
}).map((entry) => entry.id);
|
||||
expect(ids).toContain(params.expectedId);
|
||||
}
|
||||
|
||||
function findCatalogEntry(params: {
|
||||
channelId: string;
|
||||
catalogPaths?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
return listChannelPluginCatalogEntries({
|
||||
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
}).find((entry) => entry.id === params.channelId);
|
||||
}
|
||||
|
||||
function expectCatalogEntryMatch(params: {
|
||||
channelId: string;
|
||||
expected: Record<string, unknown>;
|
||||
catalogPaths?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
expect(
|
||||
findCatalogEntry({
|
||||
channelId: params.channelId,
|
||||
...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
}),
|
||||
).toMatchObject(params.expected);
|
||||
}
|
||||
|
||||
export function describeChannelPluginCatalogEntriesContract() {
|
||||
describe("channel plugin catalog entries contract", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "includes external catalog entries",
|
||||
setup: () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-"));
|
||||
const catalogPath = path.join(dir, "catalog.json");
|
||||
writeCatalogFile(
|
||||
catalogPath,
|
||||
createCatalogEntry({
|
||||
packageName: "@openclaw/demo-channel",
|
||||
channelId: "demo-channel",
|
||||
label: "Demo Channel",
|
||||
blurb: "Demo entry",
|
||||
order: 999,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
channelId: "demo-channel",
|
||||
catalogPaths: [catalogPath],
|
||||
expected: { id: "demo-channel" },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preserves plugin ids when they differ from channel ids",
|
||||
setup: () => {
|
||||
const stateDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-channel-catalog-state-"),
|
||||
);
|
||||
writeDiscoveredChannelPlugin({
|
||||
stateDir,
|
||||
packageName: "@vendor/demo-channel-plugin",
|
||||
channelLabel: "Demo Channel",
|
||||
pluginId: "@vendor/demo-runtime",
|
||||
blurb: "Demo channel",
|
||||
});
|
||||
return {
|
||||
channelId: "demo-channel",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
expected: { pluginId: "@vendor/demo-runtime" },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps discovered plugins ahead of external catalog overrides",
|
||||
setup: () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
|
||||
const catalogPath = path.join(stateDir, "catalog.json");
|
||||
writeDiscoveredChannelPlugin({
|
||||
stateDir,
|
||||
packageName: "@vendor/demo-channel-plugin",
|
||||
channelLabel: "Demo Channel Runtime",
|
||||
pluginId: "@vendor/demo-channel-runtime",
|
||||
blurb: "discovered plugin",
|
||||
});
|
||||
writeCatalogFile(
|
||||
catalogPath,
|
||||
createCatalogEntry({
|
||||
packageName: "@vendor/demo-channel-catalog",
|
||||
channelId: "demo-channel",
|
||||
label: "Demo Channel Catalog",
|
||||
blurb: "external catalog",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
channelId: "demo-channel",
|
||||
catalogPaths: [catalogPath],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
expected: {
|
||||
install: { npmSpec: "@vendor/demo-channel-plugin" },
|
||||
meta: { label: "Demo Channel Runtime" },
|
||||
pluginId: "@vendor/demo-channel-runtime",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
] as const)("$name", ({ setup }) => {
|
||||
const setupResult = setup();
|
||||
const { channelId, expected } = setupResult;
|
||||
expectCatalogEntryMatch({
|
||||
channelId,
|
||||
expected,
|
||||
...("catalogPaths" in setupResult ? { catalogPaths: setupResult.catalogPaths } : {}),
|
||||
...("env" in setupResult ? { env: setupResult.env } : {}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeChannelPluginCatalogPathResolutionContract() {
|
||||
describe("channel plugin catalog path resolution contract", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "uses the provided env for external catalog path resolution",
|
||||
setup: () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-"));
|
||||
const catalogPath = path.join(home, "catalog.json");
|
||||
writeCatalogFile(
|
||||
catalogPath,
|
||||
createCatalogEntry({
|
||||
packageName: "@openclaw/env-demo-channel",
|
||||
channelId: "env-demo-channel",
|
||||
label: "Env Demo Channel",
|
||||
blurb: "Env demo entry",
|
||||
order: 1000,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json",
|
||||
OPENCLAW_HOME: home,
|
||||
HOME: home,
|
||||
},
|
||||
expectedId: "env-demo-channel",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses the provided env for default catalog paths",
|
||||
setup: () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
|
||||
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
|
||||
fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
|
||||
writeCatalogFile(
|
||||
catalogPath,
|
||||
createCatalogEntry({
|
||||
packageName: "@openclaw/default-env-demo",
|
||||
channelId: "default-env-demo",
|
||||
label: "Default Env Demo",
|
||||
blurb: "Default env demo entry",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
},
|
||||
expectedId: "default-env-demo",
|
||||
};
|
||||
},
|
||||
},
|
||||
] as const)("$name", ({ setup }) => {
|
||||
const { env, expectedId } = setup();
|
||||
expectCatalogIdsContain({ env, expectedId });
|
||||
});
|
||||
});
|
||||
}
|
||||
60
openclaw/test/helpers/channels/command-contract.ts
Normal file
60
openclaw/test/helpers/channels/command-contract.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
loadBundledPluginApiSync,
|
||||
loadBundledPluginContractApiSync,
|
||||
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type TelegramContractSurface = {
|
||||
buildTelegramModelsProviderChannelData: (...args: unknown[]) => unknown;
|
||||
};
|
||||
type WhatsAppApiSurface = {
|
||||
isWhatsAppGroupJid: (...args: unknown[]) => boolean;
|
||||
normalizeWhatsAppTarget: (...args: unknown[]) => string | null;
|
||||
whatsappCommandPolicy: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let telegramContractSurface: TelegramContractSurface | undefined;
|
||||
let whatsappApiSurface: WhatsAppApiSurface | undefined;
|
||||
|
||||
function createLazyObjectSurface<T extends object>(loadSurface: () => T): T {
|
||||
return new Proxy({} as T, {
|
||||
get(_target, property) {
|
||||
const surface = loadSurface();
|
||||
const value = Reflect.get(surface, property, surface);
|
||||
return typeof value === "function" ? value.bind(surface) : value;
|
||||
},
|
||||
has(_target, property) {
|
||||
return property in loadSurface();
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(loadSurface());
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, property) {
|
||||
return Reflect.getOwnPropertyDescriptor(loadSurface(), property);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getTelegramContractSurface(): TelegramContractSurface {
|
||||
telegramContractSurface ??= loadBundledPluginContractApiSync<TelegramContractSurface>("telegram");
|
||||
return telegramContractSurface;
|
||||
}
|
||||
|
||||
function getWhatsAppApiSurface(): WhatsAppApiSurface {
|
||||
whatsappApiSurface ??= loadBundledPluginApiSync<WhatsAppApiSurface>("whatsapp");
|
||||
return whatsappApiSurface;
|
||||
}
|
||||
|
||||
export const buildTelegramModelsProviderChannelData = (
|
||||
...args: Parameters<TelegramContractSurface["buildTelegramModelsProviderChannelData"]>
|
||||
) => getTelegramContractSurface().buildTelegramModelsProviderChannelData(...args);
|
||||
|
||||
export const isWhatsAppGroupJid = (...args: Parameters<WhatsAppApiSurface["isWhatsAppGroupJid"]>) =>
|
||||
getWhatsAppApiSurface().isWhatsAppGroupJid(...args);
|
||||
|
||||
export const normalizeWhatsAppTarget = (
|
||||
...args: Parameters<WhatsAppApiSurface["normalizeWhatsAppTarget"]>
|
||||
) => getWhatsAppApiSurface().normalizeWhatsAppTarget(...args);
|
||||
|
||||
export const whatsappCommandPolicy = createLazyObjectSurface(
|
||||
() => getWhatsAppApiSurface().whatsappCommandPolicy,
|
||||
);
|
||||
193
openclaw/test/helpers/channels/config-write-contract-suites.ts
Normal file
193
openclaw/test/helpers/channels/config-write-contract-suites.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
authorizeConfigWrite,
|
||||
canBypassConfigWritePolicy,
|
||||
formatConfigWriteDeniedMessage,
|
||||
resolveExplicitConfigWriteTarget,
|
||||
resolveConfigWriteTargetFromPath,
|
||||
} from "../../../src/channels/plugins/config-writes.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../../src/utils/message-channel.js";
|
||||
|
||||
const demoOriginChannelId = "demo-origin";
|
||||
const demoTargetChannelId = "demo-target";
|
||||
|
||||
function makeDemoConfigWritesCfg(accountIdKey: string) {
|
||||
return {
|
||||
channels: {
|
||||
[demoOriginChannelId]: {
|
||||
configWrites: true,
|
||||
accounts: {
|
||||
[accountIdKey]: { configWrites: false },
|
||||
},
|
||||
},
|
||||
[demoTargetChannelId]: {
|
||||
configWrites: true,
|
||||
accounts: {
|
||||
[accountIdKey]: { configWrites: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectConfigWriteBlocked(params: {
|
||||
disabledAccountId: string;
|
||||
reason: "target-disabled" | "origin-disabled";
|
||||
blockedScope: "target" | "origin";
|
||||
}) {
|
||||
expect(
|
||||
authorizeConfigWrite({
|
||||
cfg: makeDemoConfigWritesCfg(params.disabledAccountId),
|
||||
origin: { channelId: demoOriginChannelId, accountId: "default" },
|
||||
target: resolveExplicitConfigWriteTarget({
|
||||
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
|
||||
accountId: "work",
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
reason: params.reason,
|
||||
blockedScope: {
|
||||
kind: params.blockedScope,
|
||||
scope: {
|
||||
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
|
||||
accountId: params.blockedScope === "target" ? "work" : "default",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expectAuthorizedConfigWriteCase(
|
||||
input: Parameters<typeof authorizeConfigWrite>[0],
|
||||
expected: ReturnType<typeof authorizeConfigWrite>,
|
||||
) {
|
||||
expect(authorizeConfigWrite(input)).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectResolvedConfigWriteTargetCase(pathSegments: readonly string[], expected: unknown) {
|
||||
expect(resolveConfigWriteTargetFromPath([...pathSegments])).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectExplicitConfigWriteTargetCase(
|
||||
input: Parameters<typeof resolveExplicitConfigWriteTarget>[0],
|
||||
expected: ReturnType<typeof resolveExplicitConfigWriteTarget>,
|
||||
) {
|
||||
expect(resolveExplicitConfigWriteTarget(input)).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectFormattedDeniedMessage(
|
||||
result: Exclude<ReturnType<typeof authorizeConfigWrite>, { allowed: true }>,
|
||||
) {
|
||||
expect(
|
||||
formatConfigWriteDeniedMessage({
|
||||
result,
|
||||
}),
|
||||
).toContain(`channels.${demoTargetChannelId}.accounts.work.configWrites=true`);
|
||||
}
|
||||
|
||||
export function describeChannelConfigWritePolicyContract() {
|
||||
describe("authorizeConfigWrite policy contract", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "blocks when a target account disables writes",
|
||||
disabledAccountId: "work",
|
||||
reason: "target-disabled",
|
||||
blockedScope: "target",
|
||||
},
|
||||
{
|
||||
name: "blocks when the origin account disables writes",
|
||||
disabledAccountId: "default",
|
||||
reason: "origin-disabled",
|
||||
blockedScope: "origin",
|
||||
},
|
||||
] as const)("$name", (testCase) => {
|
||||
expectConfigWriteBlocked(testCase);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "allows bypass for internal operator.admin writes",
|
||||
input: {
|
||||
cfg: makeDemoConfigWritesCfg("work"),
|
||||
origin: { channelId: demoOriginChannelId, accountId: "default" },
|
||||
target: resolveExplicitConfigWriteTarget({
|
||||
channelId: demoTargetChannelId,
|
||||
accountId: "work",
|
||||
}),
|
||||
allowBypass: canBypassConfigWritePolicy({
|
||||
channel: INTERNAL_MESSAGE_CHANNEL,
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
},
|
||||
expected: { allowed: true },
|
||||
},
|
||||
{
|
||||
name: "treats non-channel config paths as global writes",
|
||||
input: {
|
||||
cfg: makeDemoConfigWritesCfg("work"),
|
||||
origin: { channelId: demoOriginChannelId, accountId: "default" },
|
||||
target: resolveConfigWriteTargetFromPath(["messages", "ackReaction"]),
|
||||
},
|
||||
expected: { allowed: true },
|
||||
},
|
||||
] as const)("$name", ({ input, expected }) => {
|
||||
expectAuthorizedConfigWriteCase(input, expected);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeChannelConfigWriteTargetContract() {
|
||||
describe("authorizeConfigWrite target contract", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "rejects bare channel collection writes",
|
||||
pathSegments: ["channels", "demo-channel"],
|
||||
expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] },
|
||||
},
|
||||
{
|
||||
name: "rejects account collection writes",
|
||||
pathSegments: ["channels", "demo-channel", "accounts"],
|
||||
expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] },
|
||||
},
|
||||
] as const)("$name", ({ pathSegments, expected }) => {
|
||||
expectResolvedConfigWriteTargetCase(pathSegments, expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "resolves explicit channel target",
|
||||
input: { channelId: demoOriginChannelId },
|
||||
expected: {
|
||||
kind: "channel",
|
||||
scope: { channelId: demoOriginChannelId },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolves explicit account target",
|
||||
input: { channelId: demoTargetChannelId, accountId: "work" },
|
||||
expected: {
|
||||
kind: "account",
|
||||
scope: { channelId: demoTargetChannelId, accountId: "work" },
|
||||
},
|
||||
},
|
||||
] as const)("$name", ({ input, expected }) => {
|
||||
expectExplicitConfigWriteTargetCase(input, expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "formats denied messages consistently",
|
||||
result: {
|
||||
allowed: false,
|
||||
reason: "target-disabled",
|
||||
blockedScope: {
|
||||
kind: "target",
|
||||
scope: { channelId: demoTargetChannelId, accountId: "work" },
|
||||
},
|
||||
} as const,
|
||||
},
|
||||
] as const)("$name", ({ result }) => {
|
||||
expectFormattedDeniedMessage(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
26
openclaw/test/helpers/channels/dm-policy-contract.ts
Normal file
26
openclaw/test/helpers/channels/dm-policy-contract.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
export type SignalSender = {
|
||||
kind: string;
|
||||
raw: string;
|
||||
e164?: string;
|
||||
uuid?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
type SignalContractApiSurface = {
|
||||
isSignalSenderAllowed: (...args: unknown[]) => boolean;
|
||||
};
|
||||
|
||||
let signalContractSurface: Promise<SignalContractApiSurface> | undefined;
|
||||
|
||||
export function getSignalContractSurface(): Promise<SignalContractApiSurface> {
|
||||
signalContractSurface ??= import(
|
||||
resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "signal",
|
||||
artifactBasename: "contract-api.js",
|
||||
})
|
||||
) as Promise<SignalContractApiSurface>;
|
||||
return signalContractSurface;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
|
||||
|
||||
type ResolvedGroupPolicy = ReturnType<typeof resolveOpenProviderRuntimeGroupPolicy>;
|
||||
|
||||
export type RuntimeGroupPolicyResolver = (
|
||||
params: Parameters<typeof resolveOpenProviderRuntimeGroupPolicy>[0],
|
||||
) => ReturnType<typeof resolveOpenProviderRuntimeGroupPolicy>;
|
||||
|
||||
export function installChannelRuntimeGroupPolicyFallbackSuite(params: {
|
||||
configuredLabel: string;
|
||||
defaultGroupPolicyUnderTest: "allowlist" | "disabled" | "open";
|
||||
missingConfigLabel: string;
|
||||
missingDefaultLabel: string;
|
||||
resolve: RuntimeGroupPolicyResolver;
|
||||
}) {
|
||||
it(params.missingConfigLabel, () => {
|
||||
const resolved = params.resolve({
|
||||
providerConfigPresent: false,
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("allowlist");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(true);
|
||||
});
|
||||
|
||||
it(params.configuredLabel, () => {
|
||||
const resolved = params.resolve({
|
||||
providerConfigPresent: true,
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("open");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(false);
|
||||
});
|
||||
|
||||
it(params.missingDefaultLabel, () => {
|
||||
const resolved = params.resolve({
|
||||
providerConfigPresent: false,
|
||||
defaultGroupPolicy: params.defaultGroupPolicyUnderTest,
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("allowlist");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
export function expectResolvedGroupPolicyCase(
|
||||
resolved: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
|
||||
expected: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
|
||||
) {
|
||||
expect(resolved.groupPolicy).toBe(expected.groupPolicy);
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(expected.providerMissingFallbackApplied);
|
||||
}
|
||||
6
openclaw/test/helpers/channels/group-policy-contract.ts
Normal file
6
openclaw/test/helpers/channels/group-policy-contract.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
|
||||
|
||||
const resolveWhatsAppRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy;
|
||||
const resolveZaloRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy;
|
||||
|
||||
export { resolveWhatsAppRuntimeGroupPolicy, resolveZaloRuntimeGroupPolicy };
|
||||
19
openclaw/test/helpers/channels/imessage-test-plugin.ts
Normal file
19
openclaw/test/helpers/channels/imessage-test-plugin.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type CreateIMessageTestPlugin = (params?: { outbound?: ChannelOutboundAdapter }) => ChannelPlugin;
|
||||
|
||||
let createIMessageTestPluginCache: CreateIMessageTestPlugin | undefined;
|
||||
|
||||
function getCreateIMessageTestPlugin(): CreateIMessageTestPlugin {
|
||||
if (!createIMessageTestPluginCache) {
|
||||
({ createIMessageTestPlugin: createIMessageTestPluginCache } = loadBundledPluginTestApiSync<{
|
||||
createIMessageTestPlugin: CreateIMessageTestPlugin;
|
||||
}>("imessage"));
|
||||
}
|
||||
return createIMessageTestPluginCache;
|
||||
}
|
||||
|
||||
export const createIMessageTestPlugin: CreateIMessageTestPlugin = (...args) =>
|
||||
getCreateIMessageTestPlugin()(...args);
|
||||
28
openclaw/test/helpers/channels/inbound-contract.discord.ts
Normal file
28
openclaw/test/helpers/channels/inbound-contract.discord.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { it } from "vitest";
|
||||
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type BuildFinalizedDiscordDirectInboundContext =
|
||||
() => import("../../../src/auto-reply/templating.js").MsgContext;
|
||||
|
||||
const discordInboundContextHarnessModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "discord",
|
||||
artifactBasename: "src/monitor/inbound-context.test-helpers.js",
|
||||
});
|
||||
|
||||
async function getBuildFinalizedDiscordDirectInboundContext(): Promise<BuildFinalizedDiscordDirectInboundContext> {
|
||||
const module = (await import(discordInboundContextHarnessModuleId)) as {
|
||||
buildFinalizedDiscordDirectInboundContext: BuildFinalizedDiscordDirectInboundContext;
|
||||
};
|
||||
return module.buildFinalizedDiscordDirectInboundContext;
|
||||
}
|
||||
|
||||
export function installDiscordInboundContractSuite() {
|
||||
it("keeps inbound context finalized", async () => {
|
||||
const buildContext = await getBuildFinalizedDiscordDirectInboundContext();
|
||||
const ctx = buildContext();
|
||||
|
||||
expectChannelInboundContextContract(ctx);
|
||||
});
|
||||
}
|
||||
32
openclaw/test/helpers/channels/inbound-contract.signal.ts
Normal file
32
openclaw/test/helpers/channels/inbound-contract.signal.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { it } from "vitest";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
||||
|
||||
export function installSignalInboundContractSuite() {
|
||||
it("keeps inbound context finalized", () => {
|
||||
const ctx = finalizeInboundContext({
|
||||
Body: "Alice: hi",
|
||||
BodyForAgent: "hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
BodyForCommands: "hi",
|
||||
From: "group:g1",
|
||||
To: "group:g1",
|
||||
SessionKey: "agent:main:signal:group:g1",
|
||||
AccountId: "default",
|
||||
ChatType: "group",
|
||||
ConversationLabel: "Alice",
|
||||
GroupSubject: "Test Group",
|
||||
SenderName: "Alice",
|
||||
SenderId: "+15550001111",
|
||||
Provider: "signal",
|
||||
Surface: "signal",
|
||||
MessageSid: "1700000000000",
|
||||
OriginatingChannel: "signal",
|
||||
OriginatingTo: "group:g1",
|
||||
CommandAuthorized: true,
|
||||
});
|
||||
|
||||
expectChannelInboundContextContract(ctx);
|
||||
});
|
||||
}
|
||||
108
openclaw/test/helpers/channels/inbound-contract.slack.ts
Normal file
108
openclaw/test/helpers/channels/inbound-contract.slack.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { expect, it } from "vitest";
|
||||
import type { MsgContext } from "../../../src/auto-reply/templating.js";
|
||||
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
import { withTempHome } from "../temp-home.js";
|
||||
|
||||
type ResolvedSlackAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
botTokenSource: string;
|
||||
appTokenSource: string;
|
||||
userTokenSource: string;
|
||||
config: {
|
||||
replyToMode?: unknown;
|
||||
replyToModeByChatType?: unknown;
|
||||
dm?: unknown;
|
||||
};
|
||||
replyToMode?: unknown;
|
||||
replyToModeByChatType?: unknown;
|
||||
dm?: unknown;
|
||||
};
|
||||
|
||||
type SlackMessageEvent = {
|
||||
channel: string;
|
||||
channel_type?: string;
|
||||
user?: string;
|
||||
text?: string;
|
||||
ts: string;
|
||||
};
|
||||
|
||||
type SlackPrepareResult = { ctxPayload: MsgContext } | null | undefined;
|
||||
|
||||
type SlackTestApi = {
|
||||
createInboundSlackTestContext: (params: { cfg: OpenClawConfig }) => {
|
||||
resolveUserName?: () => Promise<unknown>;
|
||||
};
|
||||
prepareSlackMessage: (params: {
|
||||
ctx: {
|
||||
resolveUserName?: () => Promise<unknown>;
|
||||
};
|
||||
account: ResolvedSlackAccount;
|
||||
message: SlackMessageEvent;
|
||||
opts: { source: string };
|
||||
}) => Promise<SlackPrepareResult>;
|
||||
};
|
||||
|
||||
const slackPrepareTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "slack",
|
||||
artifactBasename: "inbound-contract-test-api.js",
|
||||
});
|
||||
|
||||
let slackTestApiPromise: Promise<SlackTestApi> | undefined;
|
||||
|
||||
async function loadSlackTestApi(): Promise<SlackTestApi> {
|
||||
slackTestApiPromise ??= import(slackPrepareTestApiModuleId) as Promise<SlackTestApi>;
|
||||
return await slackTestApiPromise;
|
||||
}
|
||||
|
||||
function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
userTokenSource: "none",
|
||||
config,
|
||||
replyToMode: config.replyToMode,
|
||||
replyToModeByChatType: config.replyToModeByChatType,
|
||||
dm: config.dm,
|
||||
};
|
||||
}
|
||||
|
||||
function createSlackMessage(overrides: Partial<SlackMessageEvent>): SlackMessageEvent {
|
||||
return {
|
||||
channel: "D123",
|
||||
channel_type: "im",
|
||||
user: "U1",
|
||||
text: "hi",
|
||||
ts: "1.000",
|
||||
...overrides,
|
||||
} as SlackMessageEvent;
|
||||
}
|
||||
|
||||
export function installSlackInboundContractSuite() {
|
||||
it("keeps inbound context finalized", async () => {
|
||||
await withTempHome(async () => {
|
||||
const { createInboundSlackTestContext, prepareSlackMessage } = await loadSlackTestApi();
|
||||
const ctx = createInboundSlackTestContext({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true } },
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
ctx.resolveUserName = async () => ({ name: "Alice" }) as never;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx,
|
||||
account: createSlackAccount(),
|
||||
message: createSlackMessage({}),
|
||||
opts: { source: "message" },
|
||||
});
|
||||
|
||||
expect(prepared).toBeTruthy();
|
||||
expectChannelInboundContextContract(prepared!.ctxPayload);
|
||||
});
|
||||
});
|
||||
}
|
||||
63
openclaw/test/helpers/channels/inbound-contract.telegram.ts
Normal file
63
openclaw/test/helpers/channels/inbound-contract.telegram.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { it } from "vitest";
|
||||
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
const telegramHarnessModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "telegram",
|
||||
artifactBasename: "src/bot-message-context.test-harness.js",
|
||||
});
|
||||
|
||||
async function buildTelegramMessageContextForTest(params: {
|
||||
cfg: OpenClawConfig;
|
||||
message: Record<string, unknown>;
|
||||
}) {
|
||||
const telegramHarnessModule = (await import(telegramHarnessModuleId)) as {
|
||||
buildTelegramMessageContextForTest: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
message: Record<string, unknown>;
|
||||
}) => Promise<
|
||||
{ ctxPayload: import("../../../src/auto-reply/templating.js").MsgContext } | null | undefined
|
||||
>;
|
||||
};
|
||||
return await telegramHarnessModule.buildTelegramMessageContextForTest(params);
|
||||
}
|
||||
|
||||
export function installTelegramInboundContractSuite() {
|
||||
it("keeps inbound context finalized", async () => {
|
||||
const context = await buildTelegramMessageContextForTest({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = context?.ctxPayload;
|
||||
if (!payload) {
|
||||
throw new Error("expected telegram inbound payload");
|
||||
}
|
||||
expectChannelInboundContextContract(payload);
|
||||
});
|
||||
}
|
||||
33
openclaw/test/helpers/channels/inbound-contract.whatsapp.ts
Normal file
33
openclaw/test/helpers/channels/inbound-contract.whatsapp.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { it } from "vitest";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
||||
|
||||
export function installWhatsAppInboundContractSuite() {
|
||||
it("keeps inbound context finalized", () => {
|
||||
const ctx = finalizeInboundContext({
|
||||
Body: "Alice: hi",
|
||||
BodyForAgent: "hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
BodyForCommands: "hi",
|
||||
From: "123@g.us",
|
||||
To: "+15550001111",
|
||||
SessionKey: "agent:main:whatsapp:group:123",
|
||||
AccountId: "default",
|
||||
ChatType: "group",
|
||||
ConversationLabel: "123@g.us",
|
||||
GroupSubject: "Test Group",
|
||||
SenderName: "Alice",
|
||||
SenderId: "alice@s.whatsapp.net",
|
||||
SenderE164: "+15550002222",
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
MessageSid: "msg1",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "123@g.us",
|
||||
CommandAuthorized: true,
|
||||
});
|
||||
|
||||
expectChannelInboundContextContract(ctx);
|
||||
});
|
||||
}
|
||||
78
openclaw/test/helpers/channels/interactive-contract.ts
Normal file
78
openclaw/test/helpers/channels/interactive-contract.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
type ConversationBindingHelpers = {
|
||||
requestConversationBinding: (...args: unknown[]) => unknown;
|
||||
detachConversationBinding: (...args: unknown[]) => unknown;
|
||||
getCurrentConversationBinding: (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
type InteractiveHandlerRegistration<
|
||||
TChannel extends string,
|
||||
TContext,
|
||||
> = ConversationBindingHelpers & {
|
||||
channel: TChannel;
|
||||
namespace: string;
|
||||
handler: (ctx: TContext) => unknown;
|
||||
};
|
||||
|
||||
type BaseInteractiveContext<TChannel extends string> = ConversationBindingHelpers & {
|
||||
channel: TChannel;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
senderId: string;
|
||||
senderUsername?: string;
|
||||
auth?: unknown;
|
||||
};
|
||||
|
||||
export type TelegramInteractiveHandlerContext = BaseInteractiveContext<"telegram"> & {
|
||||
callbackId: string;
|
||||
senderUsername?: string;
|
||||
threadId?: number;
|
||||
isGroup?: boolean;
|
||||
isForum?: boolean;
|
||||
callback: {
|
||||
data: string;
|
||||
namespace: string;
|
||||
payload: string;
|
||||
messageId: number;
|
||||
chatId: string;
|
||||
messageText?: string;
|
||||
};
|
||||
respond: Record<string, (...args: unknown[]) => unknown>;
|
||||
};
|
||||
|
||||
export type DiscordInteractiveHandlerContext = BaseInteractiveContext<"discord"> & {
|
||||
interactionId: string;
|
||||
guildId?: string;
|
||||
interaction: {
|
||||
data: string;
|
||||
namespace: string;
|
||||
payload: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
respond: Record<string, (...args: unknown[]) => unknown>;
|
||||
};
|
||||
|
||||
export type SlackInteractiveHandlerContext = BaseInteractiveContext<"slack"> & {
|
||||
interactionId: string;
|
||||
threadId?: string;
|
||||
interaction: {
|
||||
data: string;
|
||||
namespace: string;
|
||||
payload: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
respond: Record<string, (...args: unknown[]) => unknown>;
|
||||
};
|
||||
|
||||
export type TelegramInteractiveHandlerRegistration = InteractiveHandlerRegistration<
|
||||
"telegram",
|
||||
TelegramInteractiveHandlerContext
|
||||
>;
|
||||
export type DiscordInteractiveHandlerRegistration = InteractiveHandlerRegistration<
|
||||
"discord",
|
||||
DiscordInteractiveHandlerContext
|
||||
>;
|
||||
export type SlackInteractiveHandlerRegistration = InteractiveHandlerRegistration<
|
||||
"slack",
|
||||
SlackInteractiveHandlerContext
|
||||
>;
|
||||
23
openclaw/test/helpers/channels/manifest.ts
Normal file
23
openclaw/test/helpers/channels/manifest.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export const channelPluginSurfaceKeys = [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
] as const;
|
||||
|
||||
export type ChannelPluginSurface = (typeof channelPluginSurfaceKeys)[number];
|
||||
|
||||
export const sessionBindingContractChannelIds = [
|
||||
"bluebubbles",
|
||||
"discord",
|
||||
"feishu",
|
||||
"imessage",
|
||||
"matrix",
|
||||
"telegram",
|
||||
] as const;
|
||||
|
||||
export type SessionBindingContractChannelId = (typeof sessionBindingContractChannelIds)[number];
|
||||
40
openclaw/test/helpers/channels/matrix-setup-contract.ts
Normal file
40
openclaw/test/helpers/channels/matrix-setup-contract.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type MatrixContractSurface = {
|
||||
matrixSetupAdapter: Record<string, unknown>;
|
||||
matrixSetupWizard: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let matrixContractSurface: MatrixContractSurface | undefined;
|
||||
|
||||
function createLazyObjectSurface<T extends object>(loadSurface: () => T): T {
|
||||
return new Proxy({} as T, {
|
||||
get(_target, property) {
|
||||
const surface = loadSurface();
|
||||
const value = Reflect.get(surface, property, surface);
|
||||
return typeof value === "function" ? value.bind(surface) : value;
|
||||
},
|
||||
has(_target, property) {
|
||||
return property in loadSurface();
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(loadSurface());
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, property) {
|
||||
return Reflect.getOwnPropertyDescriptor(loadSurface(), property);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getMatrixContractSurface(): MatrixContractSurface {
|
||||
matrixContractSurface ??= loadBundledPluginContractApiSync<MatrixContractSurface>("matrix");
|
||||
return matrixContractSurface;
|
||||
}
|
||||
|
||||
export const matrixSetupAdapter = createLazyObjectSurface(
|
||||
() => getMatrixContractSurface().matrixSetupAdapter,
|
||||
);
|
||||
|
||||
export const matrixSetupWizard = createLazyObjectSurface(
|
||||
() => getMatrixContractSurface().matrixSetupWizard,
|
||||
);
|
||||
409
openclaw/test/helpers/channels/outbound-payload-contract.ts
Normal file
409
openclaw/test/helpers/channels/outbound-payload-contract.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import { beforeEach, expect, it, type Mock, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
||||
import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||
import { sendPayloadWithChunkedTextAndMedia } from "../../../src/plugin-sdk/reply-payload.js";
|
||||
import { chunkTextForOutbound } from "../../../src/plugin-sdk/text-chunking.js";
|
||||
import { resetGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
type CreateSlackOutboundPayloadHarness = (params: PayloadHarnessParams) => {
|
||||
run: () => Promise<Record<string, unknown>>;
|
||||
sendMock: Mock;
|
||||
to: string;
|
||||
};
|
||||
|
||||
const discordOutboundAdapterModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "discord",
|
||||
artifactBasename: "src/outbound-adapter.js",
|
||||
});
|
||||
const slackTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "slack",
|
||||
artifactBasename: "outbound-payload-test-api.js",
|
||||
});
|
||||
const whatsappTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "whatsapp",
|
||||
artifactBasename: "outbound-payload-test-api.js",
|
||||
});
|
||||
|
||||
let discordOutboundCache: Promise<ChannelOutboundAdapter> | undefined;
|
||||
let slackTestApiPromise:
|
||||
| Promise<{
|
||||
createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness;
|
||||
}>
|
||||
| undefined;
|
||||
let whatsappTestApiPromise:
|
||||
| Promise<{
|
||||
whatsappOutbound: ChannelOutboundAdapter;
|
||||
}>
|
||||
| undefined;
|
||||
|
||||
async function getDiscordOutbound(): Promise<ChannelOutboundAdapter> {
|
||||
discordOutboundCache ??= (async () => {
|
||||
const module = (await import(discordOutboundAdapterModuleId)) as {
|
||||
discordOutbound: ChannelOutboundAdapter;
|
||||
};
|
||||
return module.discordOutbound;
|
||||
})();
|
||||
return await discordOutboundCache;
|
||||
}
|
||||
|
||||
async function getCreateSlackOutboundPayloadHarness(): Promise<CreateSlackOutboundPayloadHarness> {
|
||||
slackTestApiPromise ??= import(slackTestApiModuleId) as Promise<{
|
||||
createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness;
|
||||
}>;
|
||||
const { createSlackOutboundPayloadHarness } = await slackTestApiPromise;
|
||||
return createSlackOutboundPayloadHarness;
|
||||
}
|
||||
|
||||
async function getWhatsAppOutboundAsync(): Promise<ChannelOutboundAdapter> {
|
||||
whatsappTestApiPromise ??= import(whatsappTestApiModuleId) as Promise<{
|
||||
whatsappOutbound: ChannelOutboundAdapter;
|
||||
}>;
|
||||
const { whatsappOutbound } = await whatsappTestApiPromise;
|
||||
return whatsappOutbound;
|
||||
}
|
||||
|
||||
type PayloadHarnessParams = {
|
||||
payload: ReplyPayload;
|
||||
sendResults?: Array<{ messageId: string }>;
|
||||
};
|
||||
|
||||
type PayloadLike = {
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
text?: string;
|
||||
};
|
||||
|
||||
type SendResultLike = {
|
||||
messageId: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ChunkingMode =
|
||||
| {
|
||||
longTextLength: number;
|
||||
maxChunkLength: number;
|
||||
mode: "split";
|
||||
}
|
||||
| {
|
||||
longTextLength: number;
|
||||
mode: "passthrough";
|
||||
};
|
||||
|
||||
function installChannelOutboundPayloadContractSuite(params: {
|
||||
channel: string;
|
||||
chunking: ChunkingMode;
|
||||
createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) =>
|
||||
| {
|
||||
run: () => Promise<Record<string, unknown>>;
|
||||
sendMock: Mock;
|
||||
to: string;
|
||||
}
|
||||
| Promise<{
|
||||
run: () => Promise<Record<string, unknown>>;
|
||||
sendMock: Mock;
|
||||
to: string;
|
||||
}>;
|
||||
}) {
|
||||
beforeEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
});
|
||||
|
||||
it("text-only delegates to sendText", async () => {
|
||||
const { run, sendMock, to } = await params.createHarness({
|
||||
payload: { text: "hello" },
|
||||
});
|
||||
const result = await run();
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object));
|
||||
expect(result).toMatchObject({ channel: params.channel });
|
||||
});
|
||||
|
||||
it("single media delegates to sendMedia", async () => {
|
||||
const { run, sendMock, to } = await params.createHarness({
|
||||
payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" },
|
||||
});
|
||||
const result = await run();
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
to,
|
||||
"cap",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: params.channel });
|
||||
});
|
||||
|
||||
it("multi-media iterates URLs with caption on first", async () => {
|
||||
const { run, sendMock, to } = await params.createHarness({
|
||||
payload: {
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||
},
|
||||
sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }],
|
||||
});
|
||||
const result = await run();
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
to,
|
||||
"caption",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
|
||||
);
|
||||
expect(sendMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
to,
|
||||
"",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" });
|
||||
});
|
||||
|
||||
it("empty payload returns no-op", async () => {
|
||||
const { run, sendMock } = await params.createHarness({ payload: {} });
|
||||
const result = await run();
|
||||
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ channel: params.channel, messageId: "" });
|
||||
});
|
||||
|
||||
if (params.chunking.mode === "passthrough") {
|
||||
it("text exceeding chunk limit is sent as-is when chunker is null", async () => {
|
||||
const text = "a".repeat(params.chunking.longTextLength);
|
||||
const { run, sendMock, to } = await params.createHarness({ payload: { text } });
|
||||
const result = await run();
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object));
|
||||
expect(result).toMatchObject({ channel: params.channel });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chunking = params.chunking;
|
||||
|
||||
it("chunking splits long text", async () => {
|
||||
const text = "a".repeat(chunking.longTextLength);
|
||||
const { run, sendMock } = await params.createHarness({
|
||||
payload: { text },
|
||||
sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }],
|
||||
});
|
||||
const result = await run();
|
||||
|
||||
expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
for (const call of sendMock.mock.calls) {
|
||||
expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength);
|
||||
}
|
||||
expect(result).toMatchObject({ channel: params.channel });
|
||||
});
|
||||
}
|
||||
|
||||
function buildChannelSendResult(channel: string, result: Record<string, unknown>) {
|
||||
return {
|
||||
channel,
|
||||
messageId: typeof result.messageId === "string" ? result.messageId : "",
|
||||
};
|
||||
}
|
||||
|
||||
function createDiscordHarness(params: PayloadHarnessParams) {
|
||||
const sendDiscord = vi.fn();
|
||||
primeChannelOutboundSendMock(
|
||||
sendDiscord,
|
||||
{ messageId: "dc-1", channelId: "123456" },
|
||||
params.sendResults,
|
||||
);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
deps: {
|
||||
sendDiscord,
|
||||
},
|
||||
};
|
||||
return {
|
||||
run: async () => await (await getDiscordOutbound()).sendPayload!(ctx),
|
||||
sendMock: sendDiscord,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
function createWhatsAppHarness(params: PayloadHarnessParams) {
|
||||
const sendWhatsApp = vi.fn();
|
||||
primeChannelOutboundSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "5511999999999@c.us",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
deps: {
|
||||
whatsapp: sendWhatsApp,
|
||||
},
|
||||
};
|
||||
return {
|
||||
run: async () => await (await getWhatsAppOutboundAsync()).sendPayload!(ctx),
|
||||
sendMock: sendWhatsApp,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
function createDirectTextMediaHarness(params: PayloadHarnessParams) {
|
||||
const sendFn = vi.fn();
|
||||
primeChannelOutboundSendMock(sendFn, { messageId: "m1" }, params.sendResults);
|
||||
const outbound = createDirectTextMediaOutbound({
|
||||
channel: "imessage",
|
||||
resolveSender: () => sendFn,
|
||||
resolveMaxBytes: () => undefined,
|
||||
buildTextOptions: (opts) => opts as never,
|
||||
buildMediaOptions: (opts) => opts as never,
|
||||
});
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "user1",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
};
|
||||
return {
|
||||
run: async () => await outbound.sendPayload!(ctx),
|
||||
sendMock: sendFn,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
function createZaloHarness(params: PayloadHarnessParams) {
|
||||
const sendZalo = vi.fn();
|
||||
primeChannelOutboundSendMock(sendZalo, { ok: true, messageId: "zl-1" }, params.sendResults);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "123456789",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
};
|
||||
return {
|
||||
run: async () =>
|
||||
await sendPayloadWithChunkedTextAndMedia({
|
||||
ctx,
|
||||
textChunkLimit: 2000,
|
||||
chunker: chunkTextForOutbound,
|
||||
sendText: async (nextCtx) =>
|
||||
buildChannelSendResult(
|
||||
"zalo",
|
||||
await sendZalo(nextCtx.to, nextCtx.text, {
|
||||
accountId: undefined,
|
||||
cfg: nextCtx.cfg,
|
||||
}),
|
||||
),
|
||||
sendMedia: async (nextCtx) =>
|
||||
buildChannelSendResult(
|
||||
"zalo",
|
||||
await sendZalo(nextCtx.to, nextCtx.text, {
|
||||
accountId: undefined,
|
||||
cfg: nextCtx.cfg,
|
||||
mediaUrl: nextCtx.mediaUrl,
|
||||
}),
|
||||
),
|
||||
emptyResult: { channel: "zalo", messageId: "" },
|
||||
}),
|
||||
sendMock: sendZalo,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
function createZalouserHarness(params: PayloadHarnessParams) {
|
||||
const sendZalouser = vi.fn();
|
||||
primeChannelOutboundSendMock(sendZalouser, { ok: true, messageId: "zlu-1" }, params.sendResults);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "987654321",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
};
|
||||
return {
|
||||
run: async () =>
|
||||
await sendPayloadWithChunkedTextAndMedia({
|
||||
ctx,
|
||||
sendText: async (nextCtx) => {
|
||||
return buildChannelSendResult(
|
||||
"zalouser",
|
||||
await sendZalouser(nextCtx.to, nextCtx.text, {
|
||||
profile: "default",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "length",
|
||||
textChunkLimit: 1200,
|
||||
}),
|
||||
);
|
||||
},
|
||||
sendMedia: async (nextCtx) => {
|
||||
return buildChannelSendResult(
|
||||
"zalouser",
|
||||
await sendZalouser(nextCtx.to, nextCtx.text, {
|
||||
profile: "default",
|
||||
isGroup: false,
|
||||
mediaUrl: nextCtx.mediaUrl,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "length",
|
||||
textChunkLimit: 1200,
|
||||
}),
|
||||
);
|
||||
},
|
||||
emptyResult: { channel: "zalouser", messageId: "" },
|
||||
}),
|
||||
sendMock: sendZalouser,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
export function installSlackOutboundPayloadContractSuite() {
|
||||
installChannelOutboundPayloadContractSuite({
|
||||
channel: "slack",
|
||||
chunking: { mode: "passthrough", longTextLength: 5000 },
|
||||
createHarness: async (params) => (await getCreateSlackOutboundPayloadHarness())(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function installDiscordOutboundPayloadContractSuite() {
|
||||
installChannelOutboundPayloadContractSuite({
|
||||
channel: "discord",
|
||||
chunking: { mode: "passthrough", longTextLength: 3000 },
|
||||
createHarness: createDiscordHarness,
|
||||
});
|
||||
}
|
||||
|
||||
export function installWhatsAppOutboundPayloadContractSuite() {
|
||||
installChannelOutboundPayloadContractSuite({
|
||||
channel: "whatsapp",
|
||||
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
|
||||
createHarness: createWhatsAppHarness,
|
||||
});
|
||||
}
|
||||
|
||||
export function installZaloOutboundPayloadContractSuite() {
|
||||
installChannelOutboundPayloadContractSuite({
|
||||
channel: "zalo",
|
||||
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
|
||||
createHarness: createZaloHarness,
|
||||
});
|
||||
}
|
||||
|
||||
export function installZalouserOutboundPayloadContractSuite() {
|
||||
installChannelOutboundPayloadContractSuite({
|
||||
channel: "zalouser",
|
||||
chunking: { mode: "passthrough", longTextLength: 3000 },
|
||||
createHarness: createZalouserHarness,
|
||||
});
|
||||
}
|
||||
|
||||
export function installDirectTextMediaOutboundPayloadContractSuite() {
|
||||
installChannelOutboundPayloadContractSuite({
|
||||
channel: "imessage",
|
||||
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
|
||||
createHarness: createDirectTextMediaHarness,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import type {
|
||||
BaseProbeResult,
|
||||
BaseTokenResolution,
|
||||
ChannelDirectoryEntry,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { LineProbeResult } from "../../../src/plugin-sdk/line.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
import { withEnvAsync } from "../../../src/test-utils/env.js";
|
||||
|
||||
type DiscordDirectoryContractApiSurface = {
|
||||
listDiscordDirectoryPeersFromConfig: DirectoryListFn;
|
||||
listDiscordDirectoryGroupsFromConfig: DirectoryListFn;
|
||||
};
|
||||
type DiscordProbe = BaseProbeResult;
|
||||
type DiscordTokenResolution = BaseTokenResolution;
|
||||
type IMessageProbe = BaseProbeResult;
|
||||
type SignalProbe = BaseProbeResult;
|
||||
type SlackDirectoryContractApiSurface = {
|
||||
listSlackDirectoryPeersFromConfig: DirectoryListFn;
|
||||
listSlackDirectoryGroupsFromConfig: DirectoryListFn;
|
||||
};
|
||||
type SlackProbe = BaseProbeResult;
|
||||
type TelegramDirectoryContractApiSurface = {
|
||||
listTelegramDirectoryPeersFromConfig: DirectoryListFn;
|
||||
listTelegramDirectoryGroupsFromConfig: DirectoryListFn;
|
||||
};
|
||||
type TelegramProbe = BaseProbeResult;
|
||||
type TelegramTokenResolution = BaseTokenResolution;
|
||||
type WhatsAppDirectoryContractApiSurface = {
|
||||
listWhatsAppDirectoryPeersFromConfig: DirectoryListFn;
|
||||
listWhatsAppDirectoryGroupsFromConfig: DirectoryListFn;
|
||||
};
|
||||
|
||||
let discordDirectoryContractApi: Promise<DiscordDirectoryContractApiSurface> | undefined;
|
||||
let slackDirectoryContractApi: Promise<SlackDirectoryContractApiSurface> | undefined;
|
||||
let telegramDirectoryContractApi: Promise<TelegramDirectoryContractApiSurface> | undefined;
|
||||
let whatsappDirectoryContractApi: Promise<WhatsAppDirectoryContractApiSurface> | undefined;
|
||||
|
||||
async function importDirectoryContractApi<T extends object>(pluginId: string): Promise<T> {
|
||||
const moduleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId,
|
||||
artifactBasename: "directory-contract-api.js",
|
||||
});
|
||||
return (await import(moduleId)) as T;
|
||||
}
|
||||
|
||||
function getDiscordDirectoryContractApi(): Promise<DiscordDirectoryContractApiSurface> {
|
||||
discordDirectoryContractApi ??=
|
||||
importDirectoryContractApi<DiscordDirectoryContractApiSurface>("discord");
|
||||
return discordDirectoryContractApi;
|
||||
}
|
||||
|
||||
function getSlackDirectoryContractApi(): Promise<SlackDirectoryContractApiSurface> {
|
||||
slackDirectoryContractApi ??=
|
||||
importDirectoryContractApi<SlackDirectoryContractApiSurface>("slack");
|
||||
return slackDirectoryContractApi;
|
||||
}
|
||||
|
||||
function getTelegramDirectoryContractApi(): Promise<TelegramDirectoryContractApiSurface> {
|
||||
telegramDirectoryContractApi ??=
|
||||
importDirectoryContractApi<TelegramDirectoryContractApiSurface>("telegram");
|
||||
return telegramDirectoryContractApi;
|
||||
}
|
||||
|
||||
function getWhatsAppDirectoryContractApi(): Promise<WhatsAppDirectoryContractApiSurface> {
|
||||
whatsappDirectoryContractApi ??=
|
||||
importDirectoryContractApi<WhatsAppDirectoryContractApiSurface>("whatsapp");
|
||||
return whatsappDirectoryContractApi;
|
||||
}
|
||||
|
||||
type DirectoryListFn = (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
}) => Promise<ChannelDirectoryEntry[]>;
|
||||
|
||||
async function listDirectoryEntriesWithDefaults(listFn: DirectoryListFn, cfg: OpenClawConfig) {
|
||||
return await listFn({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: null,
|
||||
limit: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectDirectoryIds(
|
||||
listFn: DirectoryListFn,
|
||||
cfg: OpenClawConfig,
|
||||
expected: string[],
|
||||
options?: { sorted?: boolean },
|
||||
) {
|
||||
const entries = await listDirectoryEntriesWithDefaults(listFn, cfg);
|
||||
const ids = entries.map((entry) => entry.id);
|
||||
expect(options?.sorted ? ids.toSorted() : ids).toEqual(expected);
|
||||
}
|
||||
|
||||
export function describeDiscordPluginsCoreExtensionContract() {
|
||||
describe("discord plugins-core extension contract", () => {
|
||||
it("DiscordProbe satisfies BaseProbeResult", () => {
|
||||
expectTypeOf<DiscordProbe>().toMatchTypeOf<BaseProbeResult>();
|
||||
});
|
||||
|
||||
it("Discord token resolution satisfies BaseTokenResolution", () => {
|
||||
expectTypeOf<DiscordTokenResolution>().toMatchTypeOf<BaseTokenResolution>();
|
||||
});
|
||||
|
||||
it("lists peers/groups from config (numeric ids only)", async () => {
|
||||
const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } =
|
||||
await getDiscordDirectoryContractApi();
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "discord-test",
|
||||
dm: { allowFrom: ["<@111>", "<@!333>", "nope"] },
|
||||
dms: { "222": {} },
|
||||
guilds: {
|
||||
"123": {
|
||||
users: ["<@12345>", " discord:444 ", "not-an-id"],
|
||||
channels: {
|
||||
"555": {},
|
||||
"<#777>": {},
|
||||
"channel:666": {},
|
||||
general: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await expectDirectoryIds(
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
cfg,
|
||||
["user:111", "user:12345", "user:222", "user:333", "user:444"],
|
||||
{ sorted: true },
|
||||
);
|
||||
await expectDirectoryIds(
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
cfg,
|
||||
["channel:555", "channel:666", "channel:777"],
|
||||
{
|
||||
sorted: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
|
||||
const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } =
|
||||
await getDiscordDirectoryContractApi();
|
||||
const envSecret = {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_TEST_SECRET",
|
||||
} as const;
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: envSecret,
|
||||
dm: { allowFrom: ["<@111>"] },
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
"555": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]);
|
||||
await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]);
|
||||
});
|
||||
|
||||
it("applies query and limit filtering for config-backed directories", async () => {
|
||||
const { listDiscordDirectoryGroupsFromConfig } = await getDiscordDirectoryContractApi();
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "discord-test",
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
"555": {},
|
||||
"666": {},
|
||||
"777": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const groups = await listDiscordDirectoryGroupsFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: "666",
|
||||
limit: 5,
|
||||
});
|
||||
expect(groups.map((entry) => entry.id)).toEqual(["channel:666"]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeSlackPluginsCoreExtensionContract() {
|
||||
describe("slack plugins-core extension contract", () => {
|
||||
it("SlackProbe satisfies BaseProbeResult", () => {
|
||||
expectTypeOf<SlackProbe>().toMatchTypeOf<BaseProbeResult>();
|
||||
});
|
||||
|
||||
it("lists peers/groups from config", async () => {
|
||||
const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } =
|
||||
await getSlackDirectoryContractApi();
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
dm: { allowFrom: ["U123", "user:U999"] },
|
||||
dms: { U234: {} },
|
||||
channels: { C111: { users: ["U777"] } },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await expectDirectoryIds(
|
||||
listSlackDirectoryPeersFromConfig,
|
||||
cfg,
|
||||
["user:u123", "user:u234", "user:u777", "user:u999"],
|
||||
{ sorted: true },
|
||||
);
|
||||
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
|
||||
});
|
||||
|
||||
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
|
||||
const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } =
|
||||
await getSlackDirectoryContractApi();
|
||||
const envSecret = {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_TEST_SECRET",
|
||||
} as const;
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: envSecret,
|
||||
appToken: envSecret,
|
||||
dm: { allowFrom: ["U123"] },
|
||||
channels: { C111: {} },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]);
|
||||
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
|
||||
});
|
||||
|
||||
it("applies query and limit filtering for config-backed directories", async () => {
|
||||
const { listSlackDirectoryPeersFromConfig } = await getSlackDirectoryContractApi();
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
dm: { allowFrom: ["U100", "U200"] },
|
||||
dms: { U300: {} },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const peers = await listSlackDirectoryPeersFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: "user:u",
|
||||
limit: 2,
|
||||
});
|
||||
expect(peers).toHaveLength(2);
|
||||
expect(peers.every((entry) => entry.id.startsWith("user:u"))).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeTelegramPluginsCoreExtensionContract() {
|
||||
describe("telegram plugins-core extension contract", () => {
|
||||
it("TelegramProbe satisfies BaseProbeResult", () => {
|
||||
expectTypeOf<TelegramProbe>().toMatchTypeOf<BaseProbeResult>();
|
||||
});
|
||||
|
||||
it("Telegram token resolution satisfies BaseTokenResolution", () => {
|
||||
expectTypeOf<TelegramTokenResolution>().toMatchTypeOf<BaseTokenResolution>();
|
||||
});
|
||||
|
||||
it("lists peers/groups from config", async () => {
|
||||
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
|
||||
await getTelegramDirectoryContractApi();
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "telegram-test",
|
||||
allowFrom: ["123", "alice", "tg:@bob"],
|
||||
dms: { "456": {} },
|
||||
groups: { "-1001": {}, "*": {} },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await expectDirectoryIds(
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
cfg,
|
||||
["123", "456", "@alice", "@bob"],
|
||||
{
|
||||
sorted: true,
|
||||
},
|
||||
);
|
||||
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
|
||||
});
|
||||
|
||||
it("keeps fallback semantics when accountId is omitted", async () => {
|
||||
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
|
||||
await getTelegramDirectoryContractApi();
|
||||
await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["alice"],
|
||||
groups: { "-1001": {} },
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "tok-work",
|
||||
allowFrom: ["bob"],
|
||||
groups: { "-2002": {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
|
||||
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
|
||||
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
|
||||
await getTelegramDirectoryContractApi();
|
||||
const envSecret = {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_TEST_SECRET",
|
||||
} as const;
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: envSecret,
|
||||
allowFrom: ["alice"],
|
||||
groups: { "-1001": {} },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
|
||||
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
|
||||
});
|
||||
|
||||
it("applies query and limit filtering for config-backed directories", async () => {
|
||||
const { listTelegramDirectoryGroupsFromConfig } = await getTelegramDirectoryContractApi();
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "telegram-test",
|
||||
groups: { "-1001": {}, "-1002": {}, "-2001": {} },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const groups = await listTelegramDirectoryGroupsFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: "-100",
|
||||
limit: 1,
|
||||
});
|
||||
expect(groups.map((entry) => entry.id)).toEqual(["-1001"]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeWhatsAppPluginsCoreExtensionContract() {
|
||||
describe("whatsapp plugins-core extension contract", () => {
|
||||
it("lists peers/groups from config", async () => {
|
||||
const { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig } =
|
||||
await getWhatsAppDirectoryContractApi();
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15550000000", "*", "123@g.us"],
|
||||
groups: { "999@g.us": { requireMention: true }, "*": {} },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await expectDirectoryIds(listWhatsAppDirectoryPeersFromConfig, cfg, ["+15550000000"]);
|
||||
await expectDirectoryIds(listWhatsAppDirectoryGroupsFromConfig, cfg, ["999@g.us"]);
|
||||
});
|
||||
|
||||
it("applies query and limit filtering for config-backed directories", async () => {
|
||||
const { listWhatsAppDirectoryGroupsFromConfig } = await getWhatsAppDirectoryContractApi();
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: { "111@g.us": {}, "222@g.us": {}, "333@s.whatsapp.net": {} },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const groups = await listWhatsAppDirectoryGroupsFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: "@g.us",
|
||||
limit: 1,
|
||||
});
|
||||
expect(groups.map((entry) => entry.id)).toEqual(["111@g.us"]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeSignalPluginsCoreExtensionContract() {
|
||||
describe("signal plugins-core extension contract", () => {
|
||||
it("SignalProbe satisfies BaseProbeResult", () => {
|
||||
expectTypeOf<SignalProbe>().toMatchTypeOf<BaseProbeResult>();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeIMessagePluginsCoreExtensionContract() {
|
||||
describe("imessage plugins-core extension contract", () => {
|
||||
it("IMessageProbe satisfies BaseProbeResult", () => {
|
||||
expectTypeOf<IMessageProbe>().toMatchTypeOf<BaseProbeResult>();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeLinePluginsCoreExtensionContract() {
|
||||
describe("line plugins-core extension contract", () => {
|
||||
it("LineProbeResult satisfies BaseProbeResult", () => {
|
||||
expectTypeOf<LineProbeResult>().toMatchTypeOf<BaseProbeResult>();
|
||||
});
|
||||
});
|
||||
}
|
||||
213
openclaw/test/helpers/channels/registry-actions.ts
Normal file
213
openclaw/test/helpers/channels/registry-actions.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { requireBundledChannelPlugin } from "../../../src/channels/plugins/bundled.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
|
||||
type ActionsContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "actions">;
|
||||
unsupportedAction?: string;
|
||||
cases: Array<{
|
||||
name: string;
|
||||
cfg: OpenClawConfig;
|
||||
expectedActions: string[];
|
||||
expectedCapabilities?: string[];
|
||||
beforeTest?: () => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
let actionContractRegistryCache: ActionsContractEntry[] | undefined;
|
||||
|
||||
export function getActionContractRegistry(): ActionsContractEntry[] {
|
||||
actionContractRegistryCache ??= [
|
||||
{
|
||||
id: "slack",
|
||||
plugin: requireBundledChannelPlugin("slack"),
|
||||
unsupportedAction: "poll",
|
||||
cases: [
|
||||
{
|
||||
name: "configured account exposes default Slack actions",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: [
|
||||
"send",
|
||||
"react",
|
||||
"reactions",
|
||||
"read",
|
||||
"edit",
|
||||
"delete",
|
||||
"download-file",
|
||||
"upload-file",
|
||||
"pin",
|
||||
"unpin",
|
||||
"list-pins",
|
||||
"member-info",
|
||||
"emoji-list",
|
||||
],
|
||||
expectedCapabilities: ["blocks"],
|
||||
},
|
||||
{
|
||||
name: "interactive replies add the shared interactive capability",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
capabilities: {
|
||||
interactiveReplies: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: [
|
||||
"send",
|
||||
"react",
|
||||
"reactions",
|
||||
"read",
|
||||
"edit",
|
||||
"delete",
|
||||
"download-file",
|
||||
"upload-file",
|
||||
"pin",
|
||||
"unpin",
|
||||
"list-pins",
|
||||
"member-info",
|
||||
"emoji-list",
|
||||
],
|
||||
expectedCapabilities: ["blocks", "interactive"],
|
||||
},
|
||||
{
|
||||
name: "missing tokens disables the actions surface",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: [],
|
||||
expectedCapabilities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mattermost",
|
||||
plugin: requireBundledChannelPlugin("mattermost"),
|
||||
unsupportedAction: "poll",
|
||||
cases: [
|
||||
{
|
||||
name: "configured account exposes send and react",
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: ["send", "react"],
|
||||
expectedCapabilities: ["buttons"],
|
||||
},
|
||||
{
|
||||
name: "reactions can be disabled while send stays available",
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
actions: { reactions: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: ["send"],
|
||||
expectedCapabilities: ["buttons"],
|
||||
},
|
||||
{
|
||||
name: "missing bot credentials disables the actions surface",
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: [],
|
||||
expectedCapabilities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "telegram",
|
||||
plugin: requireBundledChannelPlugin("telegram"),
|
||||
cases: [
|
||||
{
|
||||
name: "exposes configured Telegram actions and capabilities",
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123:telegram-test-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: [
|
||||
"send",
|
||||
"poll",
|
||||
"react",
|
||||
"delete",
|
||||
"edit",
|
||||
"topic-create",
|
||||
"topic-edit",
|
||||
],
|
||||
expectedCapabilities: ["interactive", "buttons"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
plugin: requireBundledChannelPlugin("discord"),
|
||||
cases: [
|
||||
{
|
||||
name: "describes configured Discord actions and capabilities",
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "Bot token-main",
|
||||
actions: {
|
||||
polls: true,
|
||||
reactions: true,
|
||||
permissions: false,
|
||||
messages: false,
|
||||
pins: false,
|
||||
threads: false,
|
||||
search: false,
|
||||
stickers: false,
|
||||
memberInfo: false,
|
||||
roleInfo: false,
|
||||
emojiUploads: false,
|
||||
stickerUploads: false,
|
||||
channelInfo: false,
|
||||
channels: false,
|
||||
voiceStatus: false,
|
||||
events: false,
|
||||
roles: false,
|
||||
moderation: false,
|
||||
presence: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: ["send", "poll", "react", "reactions", "emoji-list"],
|
||||
expectedCapabilities: ["interactive", "components"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return actionContractRegistryCache;
|
||||
}
|
||||
238
openclaw/test/helpers/channels/registry-contract-suites.ts
Normal file
238
openclaw/test/helpers/channels/registry-contract-suites.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { expect, it } from "vitest";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelAccountState,
|
||||
ChannelSetupInput,
|
||||
} from "../../../src/channels/plugins/types.core.js";
|
||||
import type {
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageCapability,
|
||||
ChannelPlugin,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
|
||||
function sortStrings(values: readonly string[]) {
|
||||
return [...values].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveContractMessageDiscovery(params: {
|
||||
plugin: Pick<ChannelPlugin, "actions">;
|
||||
cfg: OpenClawConfig;
|
||||
}) {
|
||||
const actions = params.plugin.actions;
|
||||
if (!actions) {
|
||||
return {
|
||||
actions: [] as ChannelMessageActionName[],
|
||||
capabilities: [] as readonly ChannelMessageCapability[],
|
||||
};
|
||||
}
|
||||
const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null;
|
||||
return {
|
||||
actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [],
|
||||
capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function installChannelPluginContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
|
||||
}) {
|
||||
it("satisfies the base channel plugin contract", () => {
|
||||
const { plugin } = params;
|
||||
|
||||
expect(typeof plugin.id).toBe("string");
|
||||
expect(plugin.id.trim()).not.toBe("");
|
||||
|
||||
expect(plugin.meta.id).toBe(plugin.id);
|
||||
expect(plugin.meta.label.trim()).not.toBe("");
|
||||
expect(plugin.meta.selectionLabel.trim()).not.toBe("");
|
||||
expect(plugin.meta.docsPath).toMatch(/^\/channels\//);
|
||||
expect(plugin.meta.blurb.trim()).not.toBe("");
|
||||
|
||||
expect(plugin.capabilities.chatTypes.length).toBeGreaterThan(0);
|
||||
|
||||
expect(typeof plugin.config.listAccountIds).toBe("function");
|
||||
expect(typeof plugin.config.resolveAccount).toBe("function");
|
||||
});
|
||||
}
|
||||
|
||||
type ChannelActionsContractCase = {
|
||||
name: string;
|
||||
cfg: OpenClawConfig;
|
||||
expectedActions: readonly ChannelMessageActionName[];
|
||||
expectedCapabilities?: readonly ChannelMessageCapability[];
|
||||
beforeTest?: () => void;
|
||||
};
|
||||
|
||||
export function installChannelActionsContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "actions">;
|
||||
cases: readonly ChannelActionsContractCase[];
|
||||
unsupportedAction?: ChannelMessageActionName;
|
||||
}) {
|
||||
it("exposes the base message actions contract", () => {
|
||||
expect(params.plugin.actions).toBeDefined();
|
||||
expect(typeof params.plugin.actions?.describeMessageTool).toBe("function");
|
||||
});
|
||||
|
||||
for (const testCase of params.cases) {
|
||||
it(`actions contract: ${testCase.name}`, () => {
|
||||
testCase.beforeTest?.();
|
||||
|
||||
const discovery = resolveContractMessageDiscovery({
|
||||
plugin: params.plugin,
|
||||
cfg: testCase.cfg,
|
||||
});
|
||||
const actions = discovery.actions;
|
||||
const capabilities = discovery.capabilities;
|
||||
|
||||
expect(actions).toEqual([...new Set(actions)]);
|
||||
expect(capabilities).toEqual([...new Set(capabilities)]);
|
||||
expect(sortStrings(actions)).toEqual(sortStrings(testCase.expectedActions));
|
||||
expect(sortStrings(capabilities)).toEqual(sortStrings(testCase.expectedCapabilities ?? []));
|
||||
|
||||
if (params.plugin.actions?.supportsAction) {
|
||||
for (const action of testCase.expectedActions) {
|
||||
expect(params.plugin.actions.supportsAction({ action })).toBe(true);
|
||||
}
|
||||
if (
|
||||
params.unsupportedAction &&
|
||||
!testCase.expectedActions.includes(params.unsupportedAction)
|
||||
) {
|
||||
expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type ChannelSetupContractCase<ResolvedAccount> = {
|
||||
name: string;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
input: ChannelSetupInput;
|
||||
expectedAccountId?: string;
|
||||
expectedValidation?: string | null;
|
||||
beforeTest?: () => void;
|
||||
assertPatchedConfig?: (cfg: OpenClawConfig) => void;
|
||||
assertResolvedAccount?: (account: ResolvedAccount, cfg: OpenClawConfig) => void;
|
||||
};
|
||||
|
||||
export function installChannelSetupContractSuite<ResolvedAccount>(params: {
|
||||
plugin: Pick<ChannelPlugin<ResolvedAccount>, "id" | "config" | "setup">;
|
||||
cases: readonly ChannelSetupContractCase<ResolvedAccount>[];
|
||||
}) {
|
||||
it("exposes the base setup contract", () => {
|
||||
expect(params.plugin.setup).toBeDefined();
|
||||
expect(typeof params.plugin.setup?.applyAccountConfig).toBe("function");
|
||||
});
|
||||
|
||||
for (const testCase of params.cases) {
|
||||
it(`setup contract: ${testCase.name}`, () => {
|
||||
testCase.beforeTest?.();
|
||||
|
||||
const resolvedAccountId =
|
||||
params.plugin.setup?.resolveAccountId?.({
|
||||
cfg: testCase.cfg,
|
||||
accountId: testCase.accountId,
|
||||
input: testCase.input,
|
||||
}) ??
|
||||
testCase.accountId ??
|
||||
"default";
|
||||
|
||||
expect(resolvedAccountId).toBe(testCase.expectedAccountId ?? resolvedAccountId);
|
||||
|
||||
const validation =
|
||||
params.plugin.setup?.validateInput?.({
|
||||
cfg: testCase.cfg,
|
||||
accountId: resolvedAccountId,
|
||||
input: testCase.input,
|
||||
}) ?? null;
|
||||
expect(validation).toBe(testCase.expectedValidation ?? null);
|
||||
|
||||
const nextCfg = params.plugin.setup?.applyAccountConfig({
|
||||
cfg: testCase.cfg,
|
||||
accountId: resolvedAccountId,
|
||||
input: testCase.input,
|
||||
});
|
||||
expect(nextCfg).toBeDefined();
|
||||
|
||||
const account = params.plugin.config.resolveAccount(nextCfg!, resolvedAccountId);
|
||||
testCase.assertPatchedConfig?.(nextCfg!);
|
||||
testCase.assertResolvedAccount?.(account, nextCfg!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type ChannelStatusContractCase<Probe> = {
|
||||
name: string;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
runtime?: ChannelAccountSnapshot;
|
||||
probe?: Probe;
|
||||
beforeTest?: () => void;
|
||||
expectedState?: ChannelAccountState;
|
||||
resolveStateInput?: {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
assertSnapshot?: (snapshot: ChannelAccountSnapshot) => void;
|
||||
assertSummary?: (summary: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export function installChannelStatusContractSuite<ResolvedAccount, Probe = unknown>(params: {
|
||||
plugin: Pick<ChannelPlugin<ResolvedAccount, Probe>, "id" | "config" | "status">;
|
||||
cases: readonly ChannelStatusContractCase<Probe>[];
|
||||
}) {
|
||||
it("exposes the base status contract", () => {
|
||||
expect(params.plugin.status).toBeDefined();
|
||||
expect(typeof params.plugin.status?.buildAccountSnapshot).toBe("function");
|
||||
});
|
||||
|
||||
if (params.plugin.status?.defaultRuntime) {
|
||||
it("status contract: default runtime is shaped like an account snapshot", () => {
|
||||
expect(typeof params.plugin.status?.defaultRuntime?.accountId).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
for (const testCase of params.cases) {
|
||||
it(`status contract: ${testCase.name}`, async () => {
|
||||
testCase.beforeTest?.();
|
||||
|
||||
const account = params.plugin.config.resolveAccount(testCase.cfg, testCase.accountId);
|
||||
const snapshot = await params.plugin.status!.buildAccountSnapshot!({
|
||||
account,
|
||||
cfg: testCase.cfg,
|
||||
runtime: testCase.runtime,
|
||||
probe: testCase.probe,
|
||||
});
|
||||
|
||||
expect(typeof snapshot.accountId).toBe("string");
|
||||
expect(snapshot.accountId.trim()).not.toBe("");
|
||||
testCase.assertSnapshot?.(snapshot);
|
||||
|
||||
if (params.plugin.status?.buildChannelSummary) {
|
||||
const defaultAccountId =
|
||||
params.plugin.config.defaultAccountId?.(testCase.cfg) ?? testCase.accountId ?? "default";
|
||||
const summary = await params.plugin.status.buildChannelSummary({
|
||||
account,
|
||||
cfg: testCase.cfg,
|
||||
defaultAccountId,
|
||||
snapshot,
|
||||
});
|
||||
expect(summary).toEqual(expect.any(Object));
|
||||
testCase.assertSummary?.(summary);
|
||||
}
|
||||
|
||||
if (testCase.expectedState && params.plugin.status?.resolveAccountState) {
|
||||
const state = params.plugin.status.resolveAccountState({
|
||||
account,
|
||||
cfg: testCase.cfg,
|
||||
configured: testCase.resolveStateInput?.configured ?? true,
|
||||
enabled: testCase.resolveStateInput?.enabled ?? true,
|
||||
});
|
||||
expect(state).toBe(testCase.expectedState);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
18
openclaw/test/helpers/channels/registry-plugin.ts
Normal file
18
openclaw/test/helpers/channels/registry-plugin.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { listBundledChannelPlugins } from "../../../src/channels/plugins/bundled.js";
|
||||
import { normalizeChannelMeta } from "../../../src/channels/plugins/meta-normalization.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
|
||||
type PluginContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
|
||||
};
|
||||
|
||||
export function getPluginContractRegistry(): PluginContractEntry[] {
|
||||
return listBundledChannelPlugins().map((plugin) => ({
|
||||
id: plugin.id,
|
||||
plugin: {
|
||||
...plugin,
|
||||
meta: normalizeChannelMeta({ id: plugin.id, meta: plugin.meta }),
|
||||
},
|
||||
}));
|
||||
}
|
||||
630
openclaw/test/helpers/channels/registry-session-binding.ts
Normal file
630
openclaw/test/helpers/channels/registry-session-binding.ts
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { createChannelConversationBindingManager } from "../../../src/channels/plugins/conversation-bindings.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
type SessionBindingCapabilities,
|
||||
type SessionBindingRecord,
|
||||
} from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";
|
||||
import {
|
||||
sessionBindingContractChannelIds,
|
||||
type SessionBindingContractChannelId,
|
||||
} from "./manifest.js";
|
||||
import { importBundledChannelContractArtifact } from "./runtime-artifacts.js";
|
||||
import "../../../src/channels/plugins/registry.js";
|
||||
|
||||
type SessionBindingContractEntry = {
|
||||
id: string;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
beforeEach?: () => Promise<void> | void;
|
||||
};
|
||||
const contractApiPromises = new Map<string, Promise<Record<string, unknown>>>();
|
||||
|
||||
const matrixSessionBindingStateDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"),
|
||||
);
|
||||
const matrixSessionBindingAuth = {
|
||||
accountId: "ops",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
} as const;
|
||||
|
||||
async function getContractApi<T extends Record<string, unknown>>(pluginId: string): Promise<T> {
|
||||
const existing = contractApiPromises.get(pluginId);
|
||||
if (existing) {
|
||||
return (await existing) as T;
|
||||
}
|
||||
const next = importBundledChannelContractArtifact<T>(pluginId, "contract-api");
|
||||
contractApiPromises.set(pluginId, next);
|
||||
return await next;
|
||||
}
|
||||
|
||||
function expectResolvedSessionBinding(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
targetSessionKey: string;
|
||||
}) {
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
async function unbindAndExpectClearedSessionBinding(binding: SessionBindingRecord) {
|
||||
const service = getSessionBindingService();
|
||||
const removed = await service.unbind({
|
||||
bindingId: binding.bindingId,
|
||||
reason: "contract-test",
|
||||
});
|
||||
expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId);
|
||||
expect(service.resolveByConversation(binding.conversation)).toBeNull();
|
||||
}
|
||||
|
||||
function expectClearedSessionBinding(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
}) {
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
|
||||
function resetMatrixSessionBindingStateDir() {
|
||||
fs.rmSync(matrixSessionBindingStateDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(matrixSessionBindingStateDir, { recursive: true });
|
||||
}
|
||||
|
||||
async function createContractMatrixThreadBindingManager() {
|
||||
resetMatrixSessionBindingStateDir();
|
||||
const { setMatrixRuntime, createMatrixThreadBindingManager } =
|
||||
await getContractApi<MatrixContractApi>("matrix");
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: () => matrixSessionBindingStateDir,
|
||||
},
|
||||
} as never);
|
||||
return await createMatrixThreadBindingManager({
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
auth: matrixSessionBindingAuth,
|
||||
client: {} as never,
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
enableSweeper: false,
|
||||
});
|
||||
}
|
||||
|
||||
const baseSessionBindingCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
type ChannelConversationBindingManagerFactory = NonNullable<
|
||||
NonNullable<ChannelPlugin["conversationBindings"]>["createManager"]
|
||||
>;
|
||||
|
||||
type BlueBubblesContractApi = {
|
||||
blueBubblesConversationBindingTesting: {
|
||||
resetBlueBubblesConversationBindingsForTests: () => void;
|
||||
};
|
||||
createBlueBubblesConversationBindingManager: ChannelConversationBindingManagerFactory;
|
||||
};
|
||||
|
||||
type DiscordContractApi = {
|
||||
createThreadBindingManager: (params: {
|
||||
accountId: string;
|
||||
cfg?: OpenClawConfig;
|
||||
persist: boolean;
|
||||
enableSweeper: boolean;
|
||||
}) => unknown;
|
||||
discordThreadBindingTesting: {
|
||||
resetThreadBindingsForTests: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type FeishuContractApi = {
|
||||
createFeishuThreadBindingManager: (params: {
|
||||
accountId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
}) => unknown;
|
||||
feishuThreadBindingTesting: {
|
||||
resetFeishuThreadBindingsForTests: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type IMessageContractApi = {
|
||||
createIMessageConversationBindingManager: ChannelConversationBindingManagerFactory;
|
||||
imessageConversationBindingTesting: {
|
||||
resetIMessageConversationBindingsForTests: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type MatrixContractApi = {
|
||||
createMatrixThreadBindingManager: (params: {
|
||||
accountId: string;
|
||||
auth: typeof matrixSessionBindingAuth;
|
||||
client: unknown;
|
||||
idleTimeoutMs: number;
|
||||
maxAgeMs: number;
|
||||
enableSweeper: boolean;
|
||||
}) => Promise<unknown>;
|
||||
resetMatrixThreadBindingsForTests: () => void;
|
||||
setMatrixRuntime: (runtime: unknown) => void;
|
||||
};
|
||||
|
||||
type TelegramContractApi = {
|
||||
createTelegramThreadBindingManager: (params: {
|
||||
accountId: string;
|
||||
persist: boolean;
|
||||
enableSweeper: boolean;
|
||||
}) => unknown;
|
||||
resetTelegramThreadBindingsForTests: () => Promise<void>;
|
||||
};
|
||||
|
||||
function setRegistryBackedConversationBindingPlugin(params: {
|
||||
id: SessionBindingContractChannelId;
|
||||
createManager: ChannelConversationBindingManagerFactory;
|
||||
}) {
|
||||
const plugin = {
|
||||
id: params.id,
|
||||
meta: {
|
||||
id: params.id,
|
||||
label: params.id,
|
||||
selectionLabel: params.id,
|
||||
blurb: "session binding contract fixture",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
createManager: params.createManager,
|
||||
},
|
||||
} as unknown as ChannelPlugin;
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: params.id,
|
||||
plugin,
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareBlueBubblesSessionBindingContract() {
|
||||
const api = await getContractApi<BlueBubblesContractApi>("bluebubbles");
|
||||
api.blueBubblesConversationBindingTesting.resetBlueBubblesConversationBindingsForTests();
|
||||
setRegistryBackedConversationBindingPlugin({
|
||||
id: "bluebubbles",
|
||||
createManager: api.createBlueBubblesConversationBindingManager,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareDiscordSessionBindingContract() {
|
||||
const api = await getContractApi<DiscordContractApi>("discord");
|
||||
api.discordThreadBindingTesting.resetThreadBindingsForTests();
|
||||
}
|
||||
|
||||
async function prepareFeishuSessionBindingContract() {
|
||||
const api = await getContractApi<FeishuContractApi>("feishu");
|
||||
api.feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
}
|
||||
|
||||
async function prepareIMessageSessionBindingContract() {
|
||||
const api = await getContractApi<IMessageContractApi>("imessage");
|
||||
api.imessageConversationBindingTesting.resetIMessageConversationBindingsForTests();
|
||||
setRegistryBackedConversationBindingPlugin({
|
||||
id: "imessage",
|
||||
createManager: api.createIMessageConversationBindingManager,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareMatrixSessionBindingContract() {
|
||||
const api = await getContractApi<MatrixContractApi>("matrix");
|
||||
api.resetMatrixThreadBindingsForTests();
|
||||
}
|
||||
|
||||
async function prepareTelegramSessionBindingContract() {
|
||||
const api = await getContractApi<TelegramContractApi>("telegram");
|
||||
await api.resetTelegramThreadBindingsForTests();
|
||||
}
|
||||
|
||||
const sessionBindingContractEntries: Record<
|
||||
SessionBindingContractChannelId,
|
||||
Omit<SessionBindingContractEntry, "id">
|
||||
> = {
|
||||
bluebubbles: {
|
||||
beforeEach: prepareBlueBubblesSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
void createChannelConversationBindingManager({
|
||||
channelId: "bluebubbles",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
await createChannelConversationBindingManager({
|
||||
channelId: "bluebubbles",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550123",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
label: "codex-main",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550123",
|
||||
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
const manager = await createChannelConversationBindingManager({
|
||||
channelId: "bluebubbles",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
await manager?.stop();
|
||||
expectClearedSessionBinding({
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550123",
|
||||
});
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
beforeEach: prepareDiscordSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
|
||||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: baseSessionBindingCfg,
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
|
||||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: baseSessionBindingCfg,
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:discord:child:thread-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:123456789012345678",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "discord",
|
||||
label: "discord-child",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:123456789012345678",
|
||||
targetSessionKey: "agent:discord:child:thread-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
expectClearedSessionBinding({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:123456789012345678",
|
||||
});
|
||||
},
|
||||
},
|
||||
feishu: {
|
||||
beforeEach: prepareFeishuSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
const { createFeishuThreadBindingManager } =
|
||||
await getContractApi<FeishuContractApi>("feishu");
|
||||
createFeishuThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: baseSessionBindingCfg,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
const { createFeishuThreadBindingManager } =
|
||||
await getContractApi<FeishuContractApi>("feishu");
|
||||
createFeishuThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: baseSessionBindingCfg,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:feishu:child:thread-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "feishu",
|
||||
label: "feishu-child",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetSessionKey: "agent:feishu:child:thread-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
expectClearedSessionBinding({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
});
|
||||
},
|
||||
},
|
||||
imessage: {
|
||||
beforeEach: prepareIMessageSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
void createChannelConversationBindingManager({
|
||||
channelId: "imessage",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
await createChannelConversationBindingManager({
|
||||
channelId: "imessage",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:imessage:current",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550124",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "imessage",
|
||||
label: "imessage-main",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550124",
|
||||
targetSessionKey: "agent:imessage:current",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
const manager = await createChannelConversationBindingManager({
|
||||
channelId: "imessage",
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
await manager?.stop();
|
||||
expectClearedSessionBinding({
|
||||
channel: "imessage",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550124",
|
||||
});
|
||||
},
|
||||
},
|
||||
matrix: {
|
||||
beforeEach: prepareMatrixSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
await createContractMatrixThreadBindingManager();
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "matrix",
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
await createContractMatrixThreadBindingManager();
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:matrix:thread",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "matrix",
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
conversationId: "$thread",
|
||||
parentConversationId: "!room:example.org",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "matrix",
|
||||
label: "matrix-thread",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "matrix",
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
conversationId: "$thread",
|
||||
parentConversationId: "!room:example.org",
|
||||
targetSessionKey: "agent:matrix:thread",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
expectClearedSessionBinding({
|
||||
channel: "matrix",
|
||||
accountId: matrixSessionBindingAuth.accountId,
|
||||
conversationId: "$thread",
|
||||
});
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
beforeEach: prepareTelegramSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
const { createTelegramThreadBindingManager } =
|
||||
await getContractApi<TelegramContractApi>("telegram");
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
const { createTelegramThreadBindingManager } =
|
||||
await getContractApi<TelegramContractApi>("telegram");
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:telegram:child:thread-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "telegram",
|
||||
label: "telegram-topic",
|
||||
},
|
||||
});
|
||||
expectResolvedSessionBinding({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
targetSessionKey: "agent:telegram:child:thread-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
||||
cleanup: async () => {
|
||||
expectClearedSessionBinding({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let sessionBindingContractRegistryCache: SessionBindingContractEntry[] | undefined;
|
||||
|
||||
export function getSessionBindingContractRegistry(): SessionBindingContractEntry[] {
|
||||
sessionBindingContractRegistryCache ??= sessionBindingContractChannelIds.map((id) =>
|
||||
Object.assign({ id }, sessionBindingContractEntries[id]),
|
||||
);
|
||||
return sessionBindingContractRegistryCache;
|
||||
}
|
||||
227
openclaw/test/helpers/channels/registry-setup-status.ts
Normal file
227
openclaw/test/helpers/channels/registry-setup-status.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { expect } from "vitest";
|
||||
import { requireBundledChannelPlugin } from "../../../src/channels/plugins/bundled.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
|
||||
type SetupContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "config" | "setup">;
|
||||
cases: Array<{
|
||||
name: string;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
input: Record<string, unknown>;
|
||||
expectedAccountId?: string;
|
||||
expectedValidation?: string | null;
|
||||
beforeTest?: () => void;
|
||||
assertPatchedConfig?: (cfg: OpenClawConfig) => void;
|
||||
assertResolvedAccount?: (account: unknown, cfg: OpenClawConfig) => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
type StatusContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "config" | "status">;
|
||||
cases: Array<{
|
||||
name: string;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
runtime?: Record<string, unknown>;
|
||||
probe?: unknown;
|
||||
beforeTest?: () => void;
|
||||
assertSnapshot?: (snapshot: Record<string, unknown>) => void;
|
||||
assertSummary?: (summary: Record<string, unknown>) => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
let setupContractRegistryCache: SetupContractEntry[] | undefined;
|
||||
let statusContractRegistryCache: StatusContractEntry[] | undefined;
|
||||
|
||||
export function getSetupContractRegistry(): SetupContractEntry[] {
|
||||
setupContractRegistryCache ??= [
|
||||
{
|
||||
id: "slack",
|
||||
plugin: requireBundledChannelPlugin("slack"),
|
||||
cases: [
|
||||
{
|
||||
name: "default account stores tokens and enables the channel",
|
||||
cfg: {} as OpenClawConfig,
|
||||
input: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
},
|
||||
expectedAccountId: "default",
|
||||
assertPatchedConfig: (cfg) => {
|
||||
expect(cfg.channels?.slack?.enabled).toBe(true);
|
||||
expect(cfg.channels?.slack?.botToken).toBe("xoxb-test");
|
||||
expect(cfg.channels?.slack?.appToken).toBe("xapp-test");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-default env setup is rejected",
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "ops",
|
||||
input: {
|
||||
useEnv: true,
|
||||
},
|
||||
expectedAccountId: "ops",
|
||||
expectedValidation: "Slack env tokens can only be used for the default account.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mattermost",
|
||||
plugin: requireBundledChannelPlugin("mattermost"),
|
||||
cases: [
|
||||
{
|
||||
name: "default account stores token and normalized base URL",
|
||||
cfg: {} as OpenClawConfig,
|
||||
input: {
|
||||
botToken: "test-token",
|
||||
httpUrl: "https://chat.example.com/",
|
||||
},
|
||||
expectedAccountId: "default",
|
||||
assertPatchedConfig: (cfg) => {
|
||||
expect(cfg.channels?.mattermost?.enabled).toBe(true);
|
||||
expect(cfg.channels?.mattermost?.botToken).toBe("test-token");
|
||||
expect(cfg.channels?.mattermost?.baseUrl).toBe("https://chat.example.com");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing credentials are rejected",
|
||||
cfg: {} as OpenClawConfig,
|
||||
input: {
|
||||
httpUrl: "",
|
||||
},
|
||||
expectedAccountId: "default",
|
||||
expectedValidation: "Mattermost requires --bot-token and --http-url (or --use-env).",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "line",
|
||||
plugin: requireBundledChannelPlugin("line"),
|
||||
cases: [
|
||||
{
|
||||
name: "default account stores token and secret",
|
||||
cfg: {} as OpenClawConfig,
|
||||
input: {
|
||||
channelAccessToken: "line-token",
|
||||
channelSecret: "line-secret",
|
||||
},
|
||||
expectedAccountId: "default",
|
||||
assertPatchedConfig: (cfg) => {
|
||||
expect(cfg.channels?.line?.enabled).toBe(true);
|
||||
expect(cfg.channels?.line?.channelAccessToken).toBe("line-token");
|
||||
expect(cfg.channels?.line?.channelSecret).toBe("line-secret");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-default env setup is rejected",
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "ops",
|
||||
input: {
|
||||
useEnv: true,
|
||||
},
|
||||
expectedAccountId: "ops",
|
||||
expectedValidation: "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return setupContractRegistryCache;
|
||||
}
|
||||
|
||||
export function getStatusContractRegistry(): StatusContractEntry[] {
|
||||
statusContractRegistryCache ??= [
|
||||
{
|
||||
id: "slack",
|
||||
plugin: requireBundledChannelPlugin("slack"),
|
||||
cases: [
|
||||
{
|
||||
name: "configured account produces a configured status snapshot",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime: {
|
||||
accountId: "default",
|
||||
connected: true,
|
||||
running: true,
|
||||
},
|
||||
probe: { ok: true },
|
||||
assertSnapshot: (snapshot) => {
|
||||
expect(snapshot.accountId).toBe("default");
|
||||
expect(snapshot.enabled).toBe(true);
|
||||
expect(snapshot.configured).toBe(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mattermost",
|
||||
plugin: requireBundledChannelPlugin("mattermost"),
|
||||
cases: [
|
||||
{
|
||||
name: "configured account preserves connectivity details in the snapshot",
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime: {
|
||||
accountId: "default",
|
||||
connected: true,
|
||||
lastConnectedAt: 1234,
|
||||
},
|
||||
probe: { ok: true },
|
||||
assertSnapshot: (snapshot) => {
|
||||
expect(snapshot.accountId).toBe("default");
|
||||
expect(snapshot.enabled).toBe(true);
|
||||
expect(snapshot.configured).toBe(true);
|
||||
expect(snapshot.connected).toBe(true);
|
||||
expect(snapshot.baseUrl).toBe("https://chat.example.com");
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "line",
|
||||
plugin: requireBundledChannelPlugin("line"),
|
||||
cases: [
|
||||
{
|
||||
name: "configured account produces a webhook status snapshot",
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
channelAccessToken: "line-token",
|
||||
channelSecret: "line-secret",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
},
|
||||
probe: { ok: true },
|
||||
assertSnapshot: (snapshot) => {
|
||||
expect(snapshot.accountId).toBe("default");
|
||||
expect(snapshot.enabled).toBe(true);
|
||||
expect(snapshot.configured).toBe(true);
|
||||
expect(snapshot.mode).toBe("webhook");
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return statusContractRegistryCache;
|
||||
}
|
||||
58
openclaw/test/helpers/channels/runtime-artifacts.ts
Normal file
58
openclaw/test/helpers/channels/runtime-artifacts.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { resolveBundledChannelWorkspacePath } from "../../../src/plugins/bundled-channel-runtime.js";
|
||||
import {
|
||||
resolvePluginRuntimeModulePath,
|
||||
resolvePluginRuntimeRecord,
|
||||
} from "../../../src/plugins/runtime/runtime-plugin-boundary.js";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
|
||||
function resolveBundledChannelWorkspaceArtifactPath(
|
||||
pluginId: string,
|
||||
entryBaseName: string,
|
||||
): string | null {
|
||||
const normalizedEntryBaseName = entryBaseName.replace(/\.(?:[cm]?js|ts)$/u, "");
|
||||
const pluginRoot = resolveBundledChannelWorkspacePath({
|
||||
rootDir: REPO_ROOT,
|
||||
pluginId,
|
||||
});
|
||||
if (!pluginRoot) {
|
||||
return null;
|
||||
}
|
||||
for (const extension of ["js", "ts"]) {
|
||||
const candidate = path.join(pluginRoot, `${normalizedEntryBaseName}.${extension}`);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveBundledChannelContractArtifactUrl(
|
||||
pluginId: string,
|
||||
entryBaseName: string,
|
||||
): string {
|
||||
const normalizedEntryBaseName = entryBaseName.replace(/\.(?:[cm]?js|ts)$/u, "");
|
||||
const record = resolvePluginRuntimeRecord(pluginId, () => {
|
||||
throw new Error(`missing bundled channel plugin '${pluginId}'`);
|
||||
});
|
||||
if (!record) {
|
||||
throw new Error(`missing bundled channel plugin '${pluginId}'`);
|
||||
}
|
||||
const modulePath =
|
||||
resolvePluginRuntimeModulePath(record, normalizedEntryBaseName) ??
|
||||
resolveBundledChannelWorkspaceArtifactPath(pluginId, entryBaseName);
|
||||
if (!modulePath) {
|
||||
throw new Error(`missing ${entryBaseName} for bundled channel plugin '${pluginId}'`);
|
||||
}
|
||||
return pathToFileURL(modulePath).href;
|
||||
}
|
||||
|
||||
export async function importBundledChannelContractArtifact<T extends object>(
|
||||
pluginId: string,
|
||||
entryBaseName: string,
|
||||
): Promise<T> {
|
||||
return (await import(resolveBundledChannelContractArtifactUrl(pluginId, entryBaseName))) as T;
|
||||
}
|
||||
146
openclaw/test/helpers/channels/security-audit-contract.ts
Normal file
146
openclaw/test/helpers/channels/security-audit-contract.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { SecurityAuditFinding } from "../../../src/security/audit.types.js";
|
||||
import {
|
||||
loadBundledPluginPublicSurfaceSync,
|
||||
resolveRelativeBundledPluginPublicModuleId,
|
||||
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type SecurityAuditAccount = {
|
||||
accountId: string;
|
||||
enabled?: boolean;
|
||||
token?: unknown;
|
||||
tokenSource?: string;
|
||||
config?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type FlexibleSecurityAuditParams = {
|
||||
cfg?: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
account: SecurityAuditAccount;
|
||||
accountId?: string | null;
|
||||
orderedAccountIds?: string[];
|
||||
hasExplicitAccountPath?: boolean;
|
||||
};
|
||||
type ConfigSecurityAuditParams = {
|
||||
cfg: OpenClawConfig;
|
||||
};
|
||||
type AsyncChannelSecurityAuditCollector = (
|
||||
params: FlexibleSecurityAuditParams,
|
||||
) => Promise<SecurityAuditFinding[]>;
|
||||
type SyncChannelSecurityAuditCollector = (
|
||||
params: FlexibleSecurityAuditParams,
|
||||
) => SecurityAuditFinding[];
|
||||
type ConfigSecurityAuditCollector = (params: ConfigSecurityAuditParams) => SecurityAuditFinding[];
|
||||
type DiscordSecurityAuditSurface = {
|
||||
collectDiscordSecurityAuditFindings: AsyncChannelSecurityAuditCollector;
|
||||
};
|
||||
type FeishuSecuritySurface = {
|
||||
collectFeishuSecurityAuditFindings: ConfigSecurityAuditCollector;
|
||||
};
|
||||
type SlackSecuritySurface = {
|
||||
collectSlackSecurityAuditFindings: AsyncChannelSecurityAuditCollector;
|
||||
};
|
||||
type SynologyChatSecuritySurface = {
|
||||
collectSynologyChatSecurityAuditFindings: SyncChannelSecurityAuditCollector;
|
||||
};
|
||||
type TelegramSecuritySurface = {
|
||||
collectTelegramSecurityAuditFindings: AsyncChannelSecurityAuditCollector;
|
||||
};
|
||||
type ZalouserSecuritySurface = {
|
||||
collectZalouserSecurityAuditFindings: SyncChannelSecurityAuditCollector;
|
||||
};
|
||||
|
||||
const discordSecurityAuditModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "discord",
|
||||
artifactBasename: "security-audit-contract-api.js",
|
||||
});
|
||||
const slackSecurityModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "slack",
|
||||
artifactBasename: "security-contract-api.js",
|
||||
});
|
||||
const telegramSecurityModuleId = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "telegram",
|
||||
artifactBasename: "security-audit-contract-api.js",
|
||||
});
|
||||
let discordSecurityAuditSurfacePromise: Promise<DiscordSecurityAuditSurface> | undefined;
|
||||
let slackSecuritySurfacePromise: Promise<SlackSecuritySurface> | undefined;
|
||||
let telegramSecuritySurfacePromise: Promise<TelegramSecuritySurface> | undefined;
|
||||
|
||||
function loadDiscordSecurityAuditSurface(): Promise<DiscordSecurityAuditSurface> {
|
||||
discordSecurityAuditSurfacePromise ??= import(
|
||||
discordSecurityAuditModuleId
|
||||
) as Promise<DiscordSecurityAuditSurface>;
|
||||
return discordSecurityAuditSurfacePromise;
|
||||
}
|
||||
|
||||
function loadFeishuSecuritySurface(): FeishuSecuritySurface {
|
||||
return loadBundledPluginPublicSurfaceSync<FeishuSecuritySurface>({
|
||||
pluginId: "feishu",
|
||||
artifactBasename: "security-contract-api.js",
|
||||
});
|
||||
}
|
||||
|
||||
function loadSlackSecuritySurface(): Promise<SlackSecuritySurface> {
|
||||
slackSecuritySurfacePromise ??= import(slackSecurityModuleId) as Promise<SlackSecuritySurface>;
|
||||
return slackSecuritySurfacePromise;
|
||||
}
|
||||
|
||||
function loadSynologyChatSecuritySurface(): SynologyChatSecuritySurface {
|
||||
return loadBundledPluginPublicSurfaceSync<SynologyChatSecuritySurface>({
|
||||
pluginId: "synology-chat",
|
||||
artifactBasename: "contract-api.js",
|
||||
});
|
||||
}
|
||||
|
||||
function loadTelegramSecuritySurface(): Promise<TelegramSecuritySurface> {
|
||||
telegramSecuritySurfacePromise ??= import(
|
||||
telegramSecurityModuleId
|
||||
) as Promise<TelegramSecuritySurface>;
|
||||
return telegramSecuritySurfacePromise;
|
||||
}
|
||||
|
||||
function loadZalouserSecuritySurface(): ZalouserSecuritySurface {
|
||||
return loadBundledPluginPublicSurfaceSync<ZalouserSecuritySurface>({
|
||||
pluginId: "zalouser",
|
||||
artifactBasename: "contract-api.js",
|
||||
});
|
||||
}
|
||||
|
||||
export const collectDiscordSecurityAuditFindings: DiscordSecurityAuditSurface["collectDiscordSecurityAuditFindings"] =
|
||||
(async (...args) =>
|
||||
(await loadDiscordSecurityAuditSurface()).collectDiscordSecurityAuditFindings(
|
||||
...args,
|
||||
)) as DiscordSecurityAuditSurface["collectDiscordSecurityAuditFindings"];
|
||||
|
||||
export const collectFeishuSecurityAuditFindings: FeishuSecuritySurface["collectFeishuSecurityAuditFindings"] =
|
||||
((...args) =>
|
||||
loadFeishuSecuritySurface().collectFeishuSecurityAuditFindings(
|
||||
...args,
|
||||
)) as FeishuSecuritySurface["collectFeishuSecurityAuditFindings"];
|
||||
|
||||
export const collectSlackSecurityAuditFindings: SlackSecuritySurface["collectSlackSecurityAuditFindings"] =
|
||||
(async (...args) =>
|
||||
(await loadSlackSecuritySurface()).collectSlackSecurityAuditFindings(
|
||||
...args,
|
||||
)) as SlackSecuritySurface["collectSlackSecurityAuditFindings"];
|
||||
|
||||
export const collectSynologyChatSecurityAuditFindings: SynologyChatSecuritySurface["collectSynologyChatSecurityAuditFindings"] =
|
||||
((...args) =>
|
||||
loadSynologyChatSecuritySurface().collectSynologyChatSecurityAuditFindings(
|
||||
...args,
|
||||
)) as SynologyChatSecuritySurface["collectSynologyChatSecurityAuditFindings"];
|
||||
|
||||
export const collectTelegramSecurityAuditFindings: TelegramSecuritySurface["collectTelegramSecurityAuditFindings"] =
|
||||
(async (...args) =>
|
||||
(await loadTelegramSecuritySurface()).collectTelegramSecurityAuditFindings(
|
||||
...args,
|
||||
)) as TelegramSecuritySurface["collectTelegramSecurityAuditFindings"];
|
||||
|
||||
export const collectZalouserSecurityAuditFindings: ZalouserSecuritySurface["collectZalouserSecurityAuditFindings"] =
|
||||
((...args) =>
|
||||
loadZalouserSecuritySurface().collectZalouserSecurityAuditFindings(
|
||||
...args,
|
||||
)) as ZalouserSecuritySurface["collectZalouserSecurityAuditFindings"];
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "../../../src/config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
type SessionBindingCapabilities,
|
||||
type SessionBindingRecord,
|
||||
} from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { resetPluginRuntimeStateForTest } from "../../../src/plugins/runtime.js";
|
||||
import { getSessionBindingContractRegistry } from "./registry-session-binding.js";
|
||||
|
||||
function resolveSessionBindingContractRuntimeConfig(id: string) {
|
||||
if (id !== "discord" && id !== "matrix") {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
plugins: {
|
||||
entries: {
|
||||
[id]: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installSessionBindingContractSuite(params: {
|
||||
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
}) {
|
||||
it("registers, binds, unbinds, and cleans up session bindings", async () => {
|
||||
expect(await Promise.resolve(params.getCapabilities())).toEqual(params.expectedCapabilities);
|
||||
const binding = await params.bindAndResolve();
|
||||
try {
|
||||
expect(typeof binding.bindingId).toBe("string");
|
||||
expect(binding.bindingId.trim()).not.toBe("");
|
||||
expect(typeof binding.targetSessionKey).toBe("string");
|
||||
expect(binding.targetSessionKey.trim()).not.toBe("");
|
||||
expect(["session", "subagent"]).toContain(binding.targetKind);
|
||||
expect(typeof binding.conversation.channel).toBe("string");
|
||||
expect(typeof binding.conversation.accountId).toBe("string");
|
||||
expect(typeof binding.conversation.conversationId).toBe("string");
|
||||
expect(["active", "ending", "ended"]).toContain(binding.status);
|
||||
expect(typeof binding.boundAt).toBe("number");
|
||||
await params.unbindAndVerify(binding);
|
||||
} finally {
|
||||
await params.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function describeSessionBindingRegistryBackedContract(id: string) {
|
||||
const entry = getSessionBindingContractRegistry().find((item) => item.id === id);
|
||||
if (!entry) {
|
||||
throw new Error(`missing session binding contract entry for ${id}`);
|
||||
}
|
||||
|
||||
describe(`${entry.id} session binding contract`, () => {
|
||||
beforeEach(async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
clearRuntimeConfigSnapshot();
|
||||
// Keep the suite hermetic; some contract helpers resolve runtime artifacts through config-aware
|
||||
// plugin boundaries, so never fall back to the developer's real ~/.openclaw/openclaw.json here.
|
||||
const runtimeConfig = resolveSessionBindingContractRuntimeConfig(entry.id);
|
||||
// These registry-backed contract suites intentionally exercise bundled runtime facades.
|
||||
// Opt the bundled-runtime cases in so the activation boundary behaves like real runtime usage.
|
||||
setRuntimeConfigSnapshot(runtimeConfig);
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
await entry.beforeEach?.();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
installSessionBindingContractSuite({
|
||||
expectedCapabilities: entry.expectedCapabilities,
|
||||
getCapabilities: entry.getCapabilities,
|
||||
bindAndResolve: entry.bindAndResolve,
|
||||
unbindAndVerify: entry.unbindAndVerify,
|
||||
cleanup: entry.cleanup,
|
||||
});
|
||||
});
|
||||
}
|
||||
110
openclaw/test/helpers/channels/surface-contract-registry.ts
Normal file
110
openclaw/test/helpers/channels/surface-contract-registry.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { vi } from "vitest";
|
||||
import {
|
||||
listBundledChannelPlugins,
|
||||
setBundledChannelRuntime,
|
||||
} from "../../../src/channels/plugins/bundled.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
listLineAccountIds,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "../../../src/plugin-sdk/line.js";
|
||||
import { channelPluginSurfaceKeys, type ChannelPluginSurface } from "./manifest.js";
|
||||
|
||||
function buildBundledPluginModuleId(pluginId: string, artifactBasename: string): string {
|
||||
return ["..", "..", "..", "extensions", pluginId, artifactBasename].join("/");
|
||||
}
|
||||
|
||||
type SurfaceContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<
|
||||
ChannelPlugin,
|
||||
| "id"
|
||||
| "actions"
|
||||
| "setup"
|
||||
| "status"
|
||||
| "outbound"
|
||||
| "messaging"
|
||||
| "threading"
|
||||
| "directory"
|
||||
| "gateway"
|
||||
>;
|
||||
surfaces: readonly ChannelPluginSurface[];
|
||||
};
|
||||
|
||||
type ThreadingContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "threading">;
|
||||
};
|
||||
|
||||
type DirectoryContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||
coverage: "lookups" | "presence";
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
const sendMessageMatrixMock = vi.hoisted(() =>
|
||||
vi.fn(async (to: string, _message: string, opts?: { threadId?: string }) => ({
|
||||
messageId: opts?.threadId ? "$matrix-thread" : "$matrix-root",
|
||||
roomId: to.replace(/^room:/, ""),
|
||||
})),
|
||||
);
|
||||
|
||||
setBundledChannelRuntime("line", {
|
||||
channel: {
|
||||
line: {
|
||||
listLineAccountIds,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) =>
|
||||
resolveLineAccount({ cfg, accountId }),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
vi.mock(buildBundledPluginModuleId("matrix", "runtime-api.js"), async () => {
|
||||
const matrixRuntimeApiModuleId = buildBundledPluginModuleId("matrix", "runtime-api.js");
|
||||
const actual = await vi.importActual(matrixRuntimeApiModuleId);
|
||||
return {
|
||||
...actual,
|
||||
sendMessageMatrix: sendMessageMatrixMock,
|
||||
};
|
||||
});
|
||||
|
||||
let surfaceContractRegistryCache: SurfaceContractEntry[] | undefined;
|
||||
let threadingContractRegistryCache: ThreadingContractEntry[] | undefined;
|
||||
let directoryContractRegistryCache: DirectoryContractEntry[] | undefined;
|
||||
|
||||
export function getSurfaceContractRegistry(): SurfaceContractEntry[] {
|
||||
surfaceContractRegistryCache ??= listBundledChannelPlugins().map((plugin) => ({
|
||||
id: plugin.id,
|
||||
plugin,
|
||||
surfaces: channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface])),
|
||||
}));
|
||||
return surfaceContractRegistryCache;
|
||||
}
|
||||
|
||||
export function getThreadingContractRegistry(): ThreadingContractEntry[] {
|
||||
threadingContractRegistryCache ??= getSurfaceContractRegistry()
|
||||
.filter((entry) => entry.surfaces.includes("threading"))
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
plugin: entry.plugin,
|
||||
}));
|
||||
return threadingContractRegistryCache;
|
||||
}
|
||||
|
||||
const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]);
|
||||
|
||||
export function getDirectoryContractRegistry(): DirectoryContractEntry[] {
|
||||
directoryContractRegistryCache ??= getSurfaceContractRegistry()
|
||||
.filter((entry) => entry.surfaces.includes("directory"))
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
plugin: entry.plugin,
|
||||
coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups",
|
||||
}));
|
||||
return directoryContractRegistryCache;
|
||||
}
|
||||
138
openclaw/test/helpers/channels/surface-contract-suite.ts
Normal file
138
openclaw/test/helpers/channels/surface-contract-suite.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
|
||||
export function installChannelSurfaceContractSuite(params: {
|
||||
plugin: Pick<
|
||||
ChannelPlugin,
|
||||
| "id"
|
||||
| "actions"
|
||||
| "setup"
|
||||
| "status"
|
||||
| "outbound"
|
||||
| "messaging"
|
||||
| "threading"
|
||||
| "directory"
|
||||
| "gateway"
|
||||
>;
|
||||
surface:
|
||||
| "actions"
|
||||
| "setup"
|
||||
| "status"
|
||||
| "outbound"
|
||||
| "messaging"
|
||||
| "threading"
|
||||
| "directory"
|
||||
| "gateway";
|
||||
}) {
|
||||
const { plugin, surface } = params;
|
||||
|
||||
it(`exposes the ${surface} surface contract`, () => {
|
||||
if (surface === "actions") {
|
||||
expect(plugin.actions).toBeDefined();
|
||||
expect(typeof plugin.actions?.describeMessageTool).toBe("function");
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "setup") {
|
||||
expect(plugin.setup).toBeDefined();
|
||||
expect(typeof plugin.setup?.applyAccountConfig).toBe("function");
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "status") {
|
||||
expect(plugin.status).toBeDefined();
|
||||
expect(typeof plugin.status?.buildAccountSnapshot).toBe("function");
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "outbound") {
|
||||
const outbound = plugin.outbound;
|
||||
expect(outbound).toBeDefined();
|
||||
expect(["direct", "gateway", "hybrid"]).toContain(outbound?.deliveryMode);
|
||||
expect(
|
||||
[
|
||||
outbound?.sendPayload,
|
||||
outbound?.sendFormattedText,
|
||||
outbound?.sendFormattedMedia,
|
||||
outbound?.sendText,
|
||||
outbound?.sendMedia,
|
||||
outbound?.sendPoll,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "messaging") {
|
||||
const messaging = plugin.messaging;
|
||||
expect(messaging).toBeDefined();
|
||||
expect(
|
||||
[
|
||||
messaging?.normalizeTarget,
|
||||
messaging?.parseExplicitTarget,
|
||||
messaging?.inferTargetChatType,
|
||||
messaging?.buildCrossContextComponents,
|
||||
messaging?.enableInteractiveReplies,
|
||||
messaging?.hasStructuredReplyPayload,
|
||||
messaging?.formatTargetDisplay,
|
||||
messaging?.resolveOutboundSessionRoute,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
if (messaging?.targetResolver) {
|
||||
if (messaging.targetResolver.looksLikeId) {
|
||||
expect(typeof messaging.targetResolver.looksLikeId).toBe("function");
|
||||
}
|
||||
if (messaging.targetResolver.hint !== undefined) {
|
||||
expect(typeof messaging.targetResolver.hint).toBe("string");
|
||||
expect(messaging.targetResolver.hint.trim()).not.toBe("");
|
||||
}
|
||||
if (messaging.targetResolver.resolveTarget) {
|
||||
expect(typeof messaging.targetResolver.resolveTarget).toBe("function");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "threading") {
|
||||
const threading = plugin.threading;
|
||||
expect(threading).toBeDefined();
|
||||
expect(
|
||||
[
|
||||
threading?.resolveReplyToMode,
|
||||
threading?.buildToolContext,
|
||||
threading?.resolveAutoThreadId,
|
||||
threading?.resolveReplyTransport,
|
||||
threading?.resolveFocusedBinding,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (surface === "directory") {
|
||||
const directory = plugin.directory;
|
||||
expect(directory).toBeDefined();
|
||||
expect(
|
||||
[
|
||||
directory?.self,
|
||||
directory?.listPeers,
|
||||
directory?.listPeersLive,
|
||||
directory?.listGroups,
|
||||
directory?.listGroupsLive,
|
||||
directory?.listGroupMembers,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const gateway = plugin.gateway;
|
||||
expect(gateway).toBeDefined();
|
||||
expect(
|
||||
[
|
||||
gateway?.startAccount,
|
||||
gateway?.stopAccount,
|
||||
gateway?.loginWithQrStart,
|
||||
gateway?.loginWithQrWait,
|
||||
gateway?.logoutAccount,
|
||||
].some((value) => typeof value === "function"),
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import { expect, it } from "vitest";
|
||||
import type {
|
||||
ChannelDirectoryEntry,
|
||||
ChannelFocusedBindingContext,
|
||||
ChannelReplyTransport,
|
||||
ChannelThreadingToolContext,
|
||||
} from "../../../src/channels/plugins/types.core.js";
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { createNonExitingRuntime } from "../../../src/runtime.js";
|
||||
|
||||
const contractRuntime = createNonExitingRuntime();
|
||||
|
||||
function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) {
|
||||
expect(["user", "group", "channel"]).toContain(entry.kind);
|
||||
expect(typeof entry.id).toBe("string");
|
||||
expect(entry.id.trim()).not.toBe("");
|
||||
if (entry.name !== undefined) {
|
||||
expect(typeof entry.name).toBe("string");
|
||||
}
|
||||
if (entry.handle !== undefined) {
|
||||
expect(typeof entry.handle).toBe("string");
|
||||
}
|
||||
if (entry.avatarUrl !== undefined) {
|
||||
expect(typeof entry.avatarUrl).toBe("string");
|
||||
}
|
||||
if (entry.rank !== undefined) {
|
||||
expect(typeof entry.rank).toBe("number");
|
||||
}
|
||||
}
|
||||
|
||||
function expectThreadingToolContextShape(context: ChannelThreadingToolContext) {
|
||||
if (context.currentChannelId !== undefined) {
|
||||
expect(typeof context.currentChannelId).toBe("string");
|
||||
}
|
||||
if (context.currentChannelProvider !== undefined) {
|
||||
expect(typeof context.currentChannelProvider).toBe("string");
|
||||
}
|
||||
if (context.currentThreadTs !== undefined) {
|
||||
expect(typeof context.currentThreadTs).toBe("string");
|
||||
}
|
||||
if (context.currentMessageId !== undefined) {
|
||||
expect(["string", "number"]).toContain(typeof context.currentMessageId);
|
||||
}
|
||||
if (context.replyToMode !== undefined) {
|
||||
expect(["off", "first", "all"]).toContain(context.replyToMode);
|
||||
}
|
||||
if (context.hasRepliedRef !== undefined) {
|
||||
expect(typeof context.hasRepliedRef).toBe("object");
|
||||
}
|
||||
if (context.skipCrossContextDecoration !== undefined) {
|
||||
expect(typeof context.skipCrossContextDecoration).toBe("boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function expectReplyTransportShape(transport: ChannelReplyTransport) {
|
||||
if (transport.replyToId !== undefined && transport.replyToId !== null) {
|
||||
expect(typeof transport.replyToId).toBe("string");
|
||||
}
|
||||
if (transport.threadId !== undefined && transport.threadId !== null) {
|
||||
expect(["string", "number"]).toContain(typeof transport.threadId);
|
||||
}
|
||||
}
|
||||
|
||||
function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) {
|
||||
expect(typeof binding.conversationId).toBe("string");
|
||||
expect(binding.conversationId.trim()).not.toBe("");
|
||||
if (binding.parentConversationId !== undefined) {
|
||||
expect(typeof binding.parentConversationId).toBe("string");
|
||||
}
|
||||
expect(["current", "child"]).toContain(binding.placement);
|
||||
expect(typeof binding.labelNoun).toBe("string");
|
||||
expect(binding.labelNoun.trim()).not.toBe("");
|
||||
}
|
||||
|
||||
export function installChannelThreadingContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "threading">;
|
||||
}) {
|
||||
it("exposes the base threading contract", () => {
|
||||
expect(params.plugin.threading).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps threading return values normalized", () => {
|
||||
const threading = params.plugin.threading;
|
||||
expect(threading).toBeDefined();
|
||||
|
||||
if (threading?.resolveReplyToMode) {
|
||||
expect(
|
||||
["off", "first", "all"].includes(
|
||||
threading.resolveReplyToMode({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
chatType: "group",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
const repliedRef = { value: false };
|
||||
const toolContext = threading?.buildToolContext?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
context: {
|
||||
Channel: "group:test",
|
||||
From: "user:test",
|
||||
To: "group:test",
|
||||
ChatType: "group",
|
||||
CurrentMessageId: "msg-1",
|
||||
ReplyToId: "msg-0",
|
||||
ReplyToIdFull: "thread-0",
|
||||
MessageThreadId: "thread-0",
|
||||
NativeChannelId: "native:test",
|
||||
},
|
||||
hasRepliedRef: repliedRef,
|
||||
});
|
||||
|
||||
if (toolContext) {
|
||||
expectThreadingToolContextShape(toolContext);
|
||||
if (toolContext.hasRepliedRef) {
|
||||
expect(toolContext.hasRepliedRef).toBe(repliedRef);
|
||||
}
|
||||
}
|
||||
|
||||
const autoThreadId = threading?.resolveAutoThreadId?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
to: "group:test",
|
||||
toolContext,
|
||||
replyToId: null,
|
||||
});
|
||||
if (autoThreadId !== undefined) {
|
||||
expect(typeof autoThreadId).toBe("string");
|
||||
expect(autoThreadId.trim()).not.toBe("");
|
||||
}
|
||||
|
||||
const replyTransport = threading?.resolveReplyTransport?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
threadId: "thread-0",
|
||||
replyToId: "msg-0",
|
||||
});
|
||||
if (replyTransport) {
|
||||
expectReplyTransportShape(replyTransport);
|
||||
}
|
||||
|
||||
const focusedBinding = threading?.resolveFocusedBinding?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
context: {
|
||||
Channel: "group:test",
|
||||
From: "user:test",
|
||||
To: "group:test",
|
||||
ChatType: "group",
|
||||
CurrentMessageId: "msg-1",
|
||||
ReplyToId: "msg-0",
|
||||
ReplyToIdFull: "thread-0",
|
||||
MessageThreadId: "thread-0",
|
||||
NativeChannelId: "native:test",
|
||||
},
|
||||
});
|
||||
if (focusedBinding) {
|
||||
expectFocusedBindingShape(focusedBinding);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installChannelDirectoryContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||
coverage?: "lookups" | "presence";
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}) {
|
||||
it("exposes the base directory contract", async () => {
|
||||
const directory = params.plugin.directory;
|
||||
expect(directory).toBeDefined();
|
||||
|
||||
if (params.coverage === "presence") {
|
||||
return;
|
||||
}
|
||||
const self = await directory?.self?.({
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
runtime: contractRuntime,
|
||||
});
|
||||
if (self) {
|
||||
expectDirectoryEntryShape(self);
|
||||
}
|
||||
|
||||
const peers =
|
||||
(await directory?.listPeers?.({
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
query: "",
|
||||
limit: 5,
|
||||
runtime: contractRuntime,
|
||||
})) ?? [];
|
||||
expect(Array.isArray(peers)).toBe(true);
|
||||
for (const peer of peers) {
|
||||
expectDirectoryEntryShape(peer);
|
||||
}
|
||||
|
||||
const groups =
|
||||
(await directory?.listGroups?.({
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
query: "",
|
||||
limit: 5,
|
||||
runtime: contractRuntime,
|
||||
})) ?? [];
|
||||
expect(Array.isArray(groups)).toBe(true);
|
||||
for (const group of groups) {
|
||||
expectDirectoryEntryShape(group);
|
||||
}
|
||||
|
||||
if (directory?.listGroupMembers && groups[0]?.id) {
|
||||
const members = await directory.listGroupMembers({
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
groupId: groups[0].id,
|
||||
limit: 5,
|
||||
runtime: contractRuntime,
|
||||
});
|
||||
expect(Array.isArray(members)).toBe(true);
|
||||
for (const member of members) {
|
||||
expectDirectoryEntryShape(member);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import * as bundledChannelModule from "../../../src/channels/plugins/bundled.js";
|
||||
import type {
|
||||
ChannelConfigRuntimeSchema,
|
||||
ChannelConfigSchema,
|
||||
} from "../../../src/channels/plugins/types.plugin.js";
|
||||
import { listBundledPluginMetadata } from "../../../src/plugins/bundled-plugin-metadata.js";
|
||||
|
||||
type BundledChannelRuntimeMap = ReadonlyMap<string, ChannelConfigRuntimeSchema>;
|
||||
type BundledChannelConfigSchemaMap = ReadonlyMap<string, ChannelConfigSchema>;
|
||||
type BundledChannelPluginShape = {
|
||||
id: string;
|
||||
configSchema?: ChannelConfigSchema;
|
||||
};
|
||||
type BundledChannelMaps = {
|
||||
runtimeMap: Map<string, ChannelConfigRuntimeSchema>;
|
||||
configSchemaMap: Map<string, ChannelConfigSchema>;
|
||||
};
|
||||
|
||||
let cachedBundledChannelMaps: BundledChannelMaps | undefined;
|
||||
|
||||
function buildBundledChannelMaps(
|
||||
plugins: readonly BundledChannelPluginShape[],
|
||||
): BundledChannelMaps {
|
||||
const runtimeMap = new Map<string, ChannelConfigRuntimeSchema>();
|
||||
const configSchemaMap = new Map<string, ChannelConfigSchema>();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const channelSchema = plugin.configSchema;
|
||||
if (!channelSchema) {
|
||||
continue;
|
||||
}
|
||||
configSchemaMap.set(plugin.id, channelSchema);
|
||||
if (channelSchema.runtime) {
|
||||
runtimeMap.set(plugin.id, channelSchema.runtime);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of listBundledPluginMetadata({ includeChannelConfigs: true })) {
|
||||
const channelConfigs = entry.manifest.channelConfigs;
|
||||
if (!channelConfigs) {
|
||||
continue;
|
||||
}
|
||||
for (const [channelId, channelConfig] of Object.entries(channelConfigs)) {
|
||||
const channelSchema = channelConfig?.schema as Record<string, unknown> | undefined;
|
||||
if (!channelSchema) {
|
||||
continue;
|
||||
}
|
||||
if (!configSchemaMap.has(channelId)) {
|
||||
configSchemaMap.set(channelId, {
|
||||
schema: channelSchema,
|
||||
...(channelConfig.runtime ? { runtime: channelConfig.runtime } : {}),
|
||||
...(channelConfig.uiHints ? { uiHints: channelConfig.uiHints } : {}),
|
||||
});
|
||||
}
|
||||
if (channelConfig.runtime && !runtimeMap.has(channelId)) {
|
||||
runtimeMap.set(channelId, channelConfig.runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { runtimeMap, configSchemaMap };
|
||||
}
|
||||
|
||||
function readBundledChannelPlugins(): readonly BundledChannelPluginShape[] | undefined {
|
||||
try {
|
||||
if (typeof bundledChannelModule.listBundledChannelPlugins !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
const plugins = bundledChannelModule.listBundledChannelPlugins();
|
||||
return Array.isArray(plugins) ? (plugins as readonly BundledChannelPluginShape[]) : undefined;
|
||||
} catch (error) {
|
||||
if (error instanceof ReferenceError) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getBundledChannelMaps(): BundledChannelMaps {
|
||||
const plugins = readBundledChannelPlugins();
|
||||
if (plugins && cachedBundledChannelMaps) {
|
||||
return cachedBundledChannelMaps;
|
||||
}
|
||||
|
||||
const maps = buildBundledChannelMaps(plugins ?? []);
|
||||
if (plugins) {
|
||||
cachedBundledChannelMaps = maps;
|
||||
}
|
||||
return maps;
|
||||
}
|
||||
|
||||
export function getBundledChannelRuntimeMap(): BundledChannelRuntimeMap {
|
||||
return getBundledChannelMaps().runtimeMap;
|
||||
}
|
||||
|
||||
export function getBundledChannelConfigSchemaMap(): BundledChannelConfigSchemaMap {
|
||||
return getBundledChannelMaps().configSchemaMap;
|
||||
}
|
||||
139
openclaw/test/helpers/config/config-honor-audit.ts
Normal file
139
openclaw/test/helpers/config/config-honor-audit.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { GENERATED_BASE_CONFIG_SCHEMA } from "../../../src/config/schema.base.generated.js";
|
||||
|
||||
export type ConfigHonorInventoryRow = {
|
||||
key: string;
|
||||
schemaPaths: string[];
|
||||
typePaths: string[];
|
||||
mergePaths: string[];
|
||||
consumerPaths: string[];
|
||||
reloadPaths: string[];
|
||||
testPaths: string[];
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
type ConfigHonorProofKey =
|
||||
| "schemaPaths"
|
||||
| "typePaths"
|
||||
| "mergePaths"
|
||||
| "consumerPaths"
|
||||
| "reloadPaths"
|
||||
| "testPaths";
|
||||
|
||||
export type ConfigHonorAuditResult = {
|
||||
schemaKeys: string[];
|
||||
missingKeys: string[];
|
||||
extraKeys: string[];
|
||||
missingSchemaPaths: string[];
|
||||
missingFiles: string[];
|
||||
missingProofs: Array<{
|
||||
key: string;
|
||||
missing: ConfigHonorProofKey[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
|
||||
function hasSchemaPath(schemaPath: string): boolean {
|
||||
const segments = schemaPath.split(".");
|
||||
let current: unknown = GENERATED_BASE_CONFIG_SCHEMA.schema;
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (segment === "*") {
|
||||
const items = (current as { items?: unknown }).items;
|
||||
if (!items || typeof items !== "object") {
|
||||
return false;
|
||||
}
|
||||
current = items;
|
||||
continue;
|
||||
}
|
||||
const properties = (current as { properties?: Record<string, unknown> }).properties;
|
||||
if (!properties || !Object.hasOwn(properties, segment)) {
|
||||
return false;
|
||||
}
|
||||
current = properties[segment];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function listSchemaLeafKeysForPrefixes(prefixes: string[]): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const prefix of prefixes) {
|
||||
const segments = prefix.split(".");
|
||||
let current: unknown = GENERATED_BASE_CONFIG_SCHEMA.schema;
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== "object") {
|
||||
current = null;
|
||||
break;
|
||||
}
|
||||
if (segment === "*") {
|
||||
current = (current as { items?: unknown }).items ?? null;
|
||||
continue;
|
||||
}
|
||||
current = (current as { properties?: Record<string, unknown> }).properties?.[segment] ?? null;
|
||||
}
|
||||
const properties = (current as { properties?: Record<string, unknown> } | null)?.properties;
|
||||
if (!properties) {
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(properties)) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return [...keys].toSorted();
|
||||
}
|
||||
|
||||
export function auditConfigHonorInventory(params: {
|
||||
prefixes: string[];
|
||||
rows: ConfigHonorInventoryRow[];
|
||||
expectedKeys?: string[];
|
||||
repoRoot?: string;
|
||||
}): ConfigHonorAuditResult {
|
||||
const repoRoot = params.repoRoot ?? REPO_ROOT;
|
||||
const schemaKeys = listSchemaLeafKeysForPrefixes(params.prefixes);
|
||||
const expectedKeys = new Set(params.expectedKeys ?? schemaKeys);
|
||||
const rowKeys = new Set(params.rows.map((row) => row.key));
|
||||
const missingKeys = [...expectedKeys].filter((key) => !rowKeys.has(key)).toSorted();
|
||||
const extraKeys = params.rows
|
||||
.map((row) => row.key)
|
||||
.filter((key) => !expectedKeys.has(key))
|
||||
.toSorted();
|
||||
|
||||
const missingSchemaPaths = params.rows.flatMap((row) =>
|
||||
row.schemaPaths.filter((schemaPath) => !hasSchemaPath(schemaPath)),
|
||||
);
|
||||
|
||||
const missingFiles = params.rows.flatMap((row) => {
|
||||
const files = [...row.typePaths, ...row.mergePaths, ...row.consumerPaths, ...row.testPaths];
|
||||
return files
|
||||
.filter((relativePath) => !fs.existsSync(path.join(repoRoot, relativePath)))
|
||||
.map((relativePath) => `${row.key}:${relativePath}`);
|
||||
});
|
||||
|
||||
const missingProofs = params.rows
|
||||
.map((row) => {
|
||||
const missing: ConfigHonorProofKey[] = [
|
||||
row.schemaPaths.length === 0 ? "schemaPaths" : null,
|
||||
row.typePaths.length === 0 ? "typePaths" : null,
|
||||
row.mergePaths.length === 0 ? "mergePaths" : null,
|
||||
row.consumerPaths.length === 0 ? "consumerPaths" : null,
|
||||
row.reloadPaths.length === 0 ? "reloadPaths" : null,
|
||||
row.testPaths.length === 0 ? "testPaths" : null,
|
||||
].filter((value): value is ConfigHonorProofKey => value !== null);
|
||||
return missing.length > 0 ? { key: row.key, missing } : null;
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
|
||||
return {
|
||||
schemaKeys,
|
||||
missingKeys,
|
||||
extraKeys,
|
||||
missingSchemaPaths,
|
||||
missingFiles,
|
||||
missingProofs,
|
||||
};
|
||||
}
|
||||
172
openclaw/test/helpers/config/heartbeat-config-honor.inventory.ts
Normal file
172
openclaw/test/helpers/config/heartbeat-config-honor.inventory.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import type { ConfigHonorInventoryRow } from "./config-honor-audit.js";
|
||||
|
||||
export const HEARTBEAT_CONFIG_PREFIXES = [
|
||||
"agents.defaults.heartbeat",
|
||||
"agents.list.*.heartbeat",
|
||||
] as const;
|
||||
|
||||
export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [
|
||||
{
|
||||
key: "every",
|
||||
schemaPaths: ["agents.defaults.heartbeat.every", "agents.list.*.heartbeat.every"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
|
||||
"src/gateway/config-reload.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
schemaPaths: ["agents.defaults.heartbeat.model", "agents.list.*.heartbeat.model"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.model-override.test.ts",
|
||||
"src/gateway/config-reload.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "prompt",
|
||||
schemaPaths: ["agents.defaults.heartbeat.prompt", "agents.list.*.heartbeat.prompt"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "includeSystemPromptSection",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.includeSystemPromptSection",
|
||||
"agents.list.*.heartbeat.includeSystemPromptSection",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/agents/heartbeat-system-prompt.ts"],
|
||||
consumerPaths: [
|
||||
"src/agents/heartbeat-system-prompt.ts",
|
||||
"src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts",
|
||||
],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/agents/heartbeat-system-prompt.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "ackMaxChars",
|
||||
schemaPaths: ["agents.defaults.heartbeat.ackMaxChars", "agents.list.*.heartbeat.ackMaxChars"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "suppressToolErrorWarnings",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.suppressToolErrorWarnings",
|
||||
"agents.list.*.heartbeat.suppressToolErrorWarnings",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "timeoutSeconds",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.timeoutSeconds",
|
||||
"agents.list.*.heartbeat.timeoutSeconds",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/auto-reply/reply/get-reply.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/config/zod-schema.agent-defaults.test.ts",
|
||||
"src/infra/heartbeat-runner.model-override.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "lightContext",
|
||||
schemaPaths: ["agents.defaults.heartbeat.lightContext", "agents.list.*.heartbeat.lightContext"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/bootstrap-files.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.model-override.test.ts",
|
||||
"src/agents/bootstrap-files.test.ts",
|
||||
"src/gateway/config-reload.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "isolatedSession",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.isolatedSession",
|
||||
"agents.list.*.heartbeat.isolatedSession",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
schemaPaths: ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
|
||||
consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
|
||||
"src/cron/service.main-job-passes-heartbeat-target-last.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "to",
|
||||
schemaPaths: ["agents.defaults.heartbeat.to", "agents.list.*.heartbeat.to"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
|
||||
consumerPaths: ["src/infra/outbound/targets.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "accountId",
|
||||
schemaPaths: ["agents.defaults.heartbeat.accountId", "agents.list.*.heartbeat.accountId"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
|
||||
consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
|
||||
"src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "directPolicy",
|
||||
schemaPaths: ["agents.defaults.heartbeat.directPolicy", "agents.list.*.heartbeat.directPolicy"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
|
||||
consumerPaths: ["src/infra/outbound/targets.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "includeReasoning",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.includeReasoning",
|
||||
"agents.list.*.heartbeat.includeReasoning",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
|
||||
},
|
||||
];
|
||||
17
openclaw/test/helpers/config/redact-snapshot-test-hints.ts
Normal file
17
openclaw/test/helpers/config/redact-snapshot-test-hints.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { ConfigUiHints } from "../../../src/config/schema.js";
|
||||
|
||||
// Keep this fixture minimal so redaction tests exercise the hint-matching
|
||||
// behavior they care about without paying to build the full config schema graph.
|
||||
export const redactSnapshotTestHints: ConfigUiHints = {
|
||||
"agents.defaults.memorySearch.remote.apiKey": { sensitive: true },
|
||||
"agents.list[].memorySearch.remote.apiKey": { sensitive: true },
|
||||
"broadcast.apiToken[]": { sensitive: true },
|
||||
"env.GROQ_API_KEY": { sensitive: true },
|
||||
"gateway.auth.password": { sensitive: true },
|
||||
"models.providers.*.apiKey": { sensitive: true },
|
||||
"models.providers.*.baseUrl": { sensitive: true },
|
||||
"models.providers.*.request.headers.*": { sensitive: true },
|
||||
"models.providers.*.request.auth.token": { sensitive: true },
|
||||
"models.providers.*.request.proxy.url": { sensitive: true },
|
||||
"skills.entries.*.env.GEMINI_API_KEY": { sensitive: true },
|
||||
};
|
||||
188
openclaw/test/helpers/cron/service-regression-fixtures.ts
Normal file
188
openclaw/test/helpers/cron/service-regression-fixtures.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { clearAllBootstrapSnapshots } from "../../../src/agents/bootstrap-cache.js";
|
||||
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions/store.js";
|
||||
import { createCronServiceState, type CronServiceDeps } from "../../../src/cron/service/state.js";
|
||||
import type { CronJob, CronJobState } from "../../../src/cron/types.js";
|
||||
import { resetAgentRunContextForTest } from "../../../src/infra/agent-events.js";
|
||||
import {
|
||||
resetCommandQueueStateForTest,
|
||||
waitForActiveTasks,
|
||||
} from "../../../src/process/command-queue.js";
|
||||
import { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js";
|
||||
|
||||
const TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1_000;
|
||||
|
||||
export const noopLogger = {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
trace: () => {},
|
||||
};
|
||||
|
||||
export function setupCronRegressionFixtures(options?: { prefix?: string; baseTimeIso?: string }) {
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "cron-issues-"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetCommandQueueStateForTest();
|
||||
useFrozenTime(options?.baseTimeIso ?? "2026-02-06T10:05:00.000Z");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllTimers();
|
||||
vi.restoreAllMocks();
|
||||
useRealTime();
|
||||
await waitForActiveTasks(250);
|
||||
resetCommandQueueStateForTest();
|
||||
clearSessionStoreCacheForTest();
|
||||
resetAgentRunContextForTest();
|
||||
clearAllBootstrapSnapshots();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
useRealTime();
|
||||
await waitForActiveTasks(250);
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
return {
|
||||
makeStorePath() {
|
||||
return {
|
||||
storePath: path.join(fixtureRoot, `case-${fixtureCount++}.jobs.json`),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
export function createRunningCronServiceState(params: {
|
||||
storePath: string;
|
||||
log: CronServiceDeps["log"];
|
||||
nowMs: () => number;
|
||||
jobs: CronJob[];
|
||||
}) {
|
||||
const state = createCronServiceState({
|
||||
cronEnabled: true,
|
||||
storePath: params.storePath,
|
||||
log: params.log,
|
||||
nowMs: params.nowMs,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
|
||||
});
|
||||
state.running = true;
|
||||
state.store = {
|
||||
version: 1,
|
||||
jobs: params.jobs,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
export function topOfHourOffsetMs(jobId: string) {
|
||||
const digest = crypto.createHash("sha256").update(jobId).digest();
|
||||
return digest.readUInt32BE(0) % TOP_OF_HOUR_STAGGER_MS;
|
||||
}
|
||||
|
||||
export function createDueIsolatedJob(params: {
|
||||
id: string;
|
||||
nowMs: number;
|
||||
nextRunAtMs: number;
|
||||
deleteAfterRun?: boolean;
|
||||
}): CronJob {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.id,
|
||||
enabled: true,
|
||||
deleteAfterRun: params.deleteAfterRun ?? false,
|
||||
createdAtMs: params.nowMs,
|
||||
updatedAtMs: params.nowMs,
|
||||
schedule: { kind: "at", at: new Date(params.nextRunAtMs).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "agentTurn", message: params.id },
|
||||
delivery: { mode: "none" },
|
||||
state: { nextRunAtMs: params.nextRunAtMs },
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultIsolatedRunner(): CronServiceDeps["runIsolatedAgentJob"] {
|
||||
return vi.fn().mockResolvedValue({
|
||||
status: "ok",
|
||||
summary: "ok",
|
||||
}) as CronServiceDeps["runIsolatedAgentJob"];
|
||||
}
|
||||
|
||||
export function createAbortAwareIsolatedRunner(summary = "late") {
|
||||
let observedAbortSignal: AbortSignal | undefined;
|
||||
const started = createDeferred<void>();
|
||||
const runIsolatedAgentJob = vi.fn(async ({ abortSignal }) => {
|
||||
observedAbortSignal = abortSignal;
|
||||
started.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!abortSignal) {
|
||||
return;
|
||||
}
|
||||
if (abortSignal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
return { status: "ok" as const, summary };
|
||||
}) as CronServiceDeps["runIsolatedAgentJob"];
|
||||
|
||||
return {
|
||||
runIsolatedAgentJob,
|
||||
getObservedAbortSignal: () => observedAbortSignal,
|
||||
waitForStart: () => started.promise,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIsolatedRegressionJob(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledAt: number;
|
||||
schedule: CronJob["schedule"];
|
||||
payload: CronJob["payload"];
|
||||
state?: CronJobState;
|
||||
}): CronJob {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
createdAtMs: params.scheduledAt - 86_400_000,
|
||||
updatedAtMs: params.scheduledAt - 86_400_000,
|
||||
schedule: params.schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: params.payload,
|
||||
delivery: { mode: "announce" },
|
||||
state: params.state ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCronJobs(storePath: string, jobs: CronJob[]) {
|
||||
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }), "utf-8");
|
||||
}
|
||||
|
||||
export async function writeCronStoreSnapshot(storePath: string, jobs: unknown[]) {
|
||||
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }), "utf-8");
|
||||
}
|
||||
43
openclaw/test/helpers/envelope-timestamp.ts
Normal file
43
openclaw/test/helpers/envelope-timestamp.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
formatUtcTimestamp,
|
||||
formatZonedTimestamp,
|
||||
} from "../../src/infra/format-time/format-datetime.js";
|
||||
|
||||
export { escapeRegExp } from "../../src/utils.js";
|
||||
|
||||
type EnvelopeTimestampZone = string;
|
||||
|
||||
export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string {
|
||||
const trimmedZone = zone.trim();
|
||||
const normalized = trimmedZone.toLowerCase();
|
||||
const weekday = (() => {
|
||||
try {
|
||||
if (normalized === "utc" || normalized === "gmt") {
|
||||
return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", weekday: "short" }).format(date);
|
||||
}
|
||||
if (normalized === "local" || normalized === "host") {
|
||||
return new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat("en-US", { timeZone: trimmedZone, weekday: "short" }).format(
|
||||
date,
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
if (normalized === "utc" || normalized === "gmt") {
|
||||
const ts = formatUtcTimestamp(date);
|
||||
return weekday ? `${weekday} ${ts}` : ts;
|
||||
}
|
||||
if (normalized === "local" || normalized === "host") {
|
||||
const ts = formatZonedTimestamp(date) ?? formatUtcTimestamp(date);
|
||||
return weekday ? `${weekday} ${ts}` : ts;
|
||||
}
|
||||
const ts = formatZonedTimestamp(date, { timeZone: trimmedZone }) ?? formatUtcTimestamp(date);
|
||||
return weekday ? `${weekday} ${ts}` : ts;
|
||||
}
|
||||
|
||||
export function formatLocalEnvelopeTimestamp(date: Date): string {
|
||||
return formatEnvelopeTimestamp(date, "local");
|
||||
}
|
||||
17
openclaw/test/helpers/fast-short-timeouts.ts
Normal file
17
openclaw/test/helpers/fast-short-timeouts.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { setTimeout as nativeSetTimeout } from "node:timers";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function useFastShortTimeouts(maxDelayMs = 2000): () => void {
|
||||
const spy = vi.spyOn(global, "setTimeout").mockImplementation(((
|
||||
handler: TimerHandler,
|
||||
timeout?: number,
|
||||
...args: unknown[]
|
||||
) => {
|
||||
const delay = typeof timeout === "number" ? timeout : 0;
|
||||
if (delay > 0 && delay <= maxDelayMs) {
|
||||
return nativeSetTimeout(handler, 0, ...args);
|
||||
}
|
||||
return nativeSetTimeout(handler, delay, ...args);
|
||||
}) as typeof setTimeout);
|
||||
return () => spy.mockRestore();
|
||||
}
|
||||
378
openclaw/test/helpers/gateway-e2e-harness.ts
Normal file
378
openclaw/test/helpers/gateway-e2e-harness.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { GatewayClient } from "../../src/gateway/client.js";
|
||||
import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js";
|
||||
import { extractFirstTextBlock } from "../../src/shared/chat-message-content.js";
|
||||
import { sleep } from "../../src/utils.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../src/utils/message-channel.js";
|
||||
|
||||
export { extractFirstTextBlock };
|
||||
|
||||
export type ChatEventPayload = {
|
||||
runId?: string;
|
||||
sessionKey?: string;
|
||||
state?: string;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
export type GatewayInstance = {
|
||||
name: string;
|
||||
port: number;
|
||||
hookToken: string;
|
||||
gatewayToken: string;
|
||||
homeDir: string;
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
};
|
||||
|
||||
const GATEWAY_START_TIMEOUT_MS = 60_000;
|
||||
const GATEWAY_STOP_TIMEOUT_MS = 1_500;
|
||||
const GATEWAY_CONNECT_STATUS_TIMEOUT_MS = 2_000;
|
||||
const GATEWAY_NODE_STATUS_TIMEOUT_MS = 4_000;
|
||||
const GATEWAY_NODE_STATUS_POLL_MS = 20;
|
||||
|
||||
const getFreePort = async () => {
|
||||
const srv = net.createServer();
|
||||
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
srv.close();
|
||||
throw new Error("failed to bind ephemeral port");
|
||||
}
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
return addr.port;
|
||||
};
|
||||
|
||||
async function waitForPortOpen(
|
||||
proc: ChildProcessWithoutNullStreams,
|
||||
chunksOut: string[],
|
||||
chunksErr: string[],
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (proc.exitCode !== null) {
|
||||
const stdout = chunksOut.join("");
|
||||
const stderr = chunksErr.join("");
|
||||
throw new Error(
|
||||
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
|
||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", (err) => {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
|
||||
await sleep(10);
|
||||
}
|
||||
const stdout = chunksOut.join("");
|
||||
const stderr = chunksErr.join("");
|
||||
throw new Error(
|
||||
`timeout waiting for gateway to listen on port ${port}\n` +
|
||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function spawnGatewayInstance(name: string): Promise<GatewayInstance> {
|
||||
const port = await getFreePort();
|
||||
const hookToken = `token-${name}-${randomUUID()}`;
|
||||
const gatewayToken = `gateway-${name}-${randomUUID()}`;
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-e2e-${name}-`));
|
||||
const configDir = path.join(homeDir, ".openclaw");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
const stateDir = path.join(configDir, "state");
|
||||
const config = {
|
||||
gateway: {
|
||||
port,
|
||||
auth: { mode: "token", token: gatewayToken },
|
||||
controlUi: { enabled: false },
|
||||
},
|
||||
hooks: { enabled: true, token: hookToken, path: "/hooks" },
|
||||
};
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
let child: ChildProcessWithoutNullStreams | null = null;
|
||||
|
||||
try {
|
||||
child = spawn(
|
||||
"node",
|
||||
[
|
||||
"dist/index.js",
|
||||
"gateway",
|
||||
"--port",
|
||||
String(port),
|
||||
"--bind",
|
||||
"loopback",
|
||||
"--allow-unconfigured",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_GATEWAY_TOKEN: "",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "",
|
||||
OPENCLAW_SKIP_CHANNELS: "1",
|
||||
OPENCLAW_SKIP_PROVIDERS: "1",
|
||||
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
|
||||
OPENCLAW_SKIP_CRON: "1",
|
||||
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
||||
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
|
||||
VITEST: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (d) => stdout.push(String(d)));
|
||||
child.stderr?.on("data", (d) => stderr.push(String(d)));
|
||||
|
||||
await waitForPortOpen(child, stdout, stderr, port, GATEWAY_START_TIMEOUT_MS);
|
||||
|
||||
return {
|
||||
name,
|
||||
port,
|
||||
hookToken,
|
||||
gatewayToken,
|
||||
homeDir,
|
||||
stateDir,
|
||||
configPath,
|
||||
child,
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} catch (err) {
|
||||
if (child && child.exitCode === null && !child.killed) {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopGatewayInstance(inst: GatewayInstance) {
|
||||
if (inst.child.exitCode === null && !inst.child.killed) {
|
||||
try {
|
||||
inst.child.kill("SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const exited = await Promise.race([
|
||||
new Promise<boolean>((resolve) => {
|
||||
if (inst.child.exitCode !== null) {
|
||||
return resolve(true);
|
||||
}
|
||||
inst.child.once("exit", () => resolve(true));
|
||||
}),
|
||||
sleep(GATEWAY_STOP_TIMEOUT_MS).then(() => false),
|
||||
]);
|
||||
if (!exited && inst.child.exitCode === null && !inst.child.killed) {
|
||||
try {
|
||||
inst.child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await fs.rm(inst.homeDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function postJson(
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ status: number; json: unknown }> {
|
||||
const payload = JSON.stringify(body);
|
||||
const parsed = new URL(url);
|
||||
return await new Promise<{ status: number; json: unknown }>((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
method: "POST",
|
||||
hostname: parsed.hostname,
|
||||
port: Number(parsed.port),
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(payload),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
let json: unknown = null;
|
||||
if (data.trim()) {
|
||||
try {
|
||||
json = JSON.parse(data);
|
||||
} catch {
|
||||
json = data;
|
||||
}
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, json });
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectNode(
|
||||
inst: GatewayInstance,
|
||||
label: string,
|
||||
): Promise<{ client: GatewayClient; nodeId: string }> {
|
||||
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
|
||||
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
|
||||
const nodeId = deviceIdentity.deviceId;
|
||||
const client = await connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${inst.port}`,
|
||||
token: inst.gatewayToken,
|
||||
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientDisplayName: label,
|
||||
clientVersion: "1.0.0",
|
||||
platform: "ios",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["system"],
|
||||
commands: ["system.run"],
|
||||
deviceIdentity,
|
||||
timeoutMessage: `timeout waiting for ${label} to connect`,
|
||||
});
|
||||
return { client, nodeId };
|
||||
}
|
||||
|
||||
async function connectStatusClient(
|
||||
inst: GatewayInstance,
|
||||
timeoutMs = GATEWAY_CONNECT_STATUS_TIMEOUT_MS,
|
||||
): Promise<GatewayClient> {
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
return await new Promise<GatewayClient>((resolve, reject) => {
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(client);
|
||||
};
|
||||
|
||||
const client = new GatewayClient({
|
||||
url: `ws://127.0.0.1:${inst.port}`,
|
||||
connectChallengeTimeoutMs: 0,
|
||||
token: inst.gatewayToken,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
clientDisplayName: `status-${inst.name}`,
|
||||
clientVersion: "1.0.0",
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
onHelloOk: () => {
|
||||
finish();
|
||||
},
|
||||
onConnectError: (err) => finish(err),
|
||||
onClose: (code, reason) => {
|
||||
finish(new Error(`gateway closed (${code}): ${reason}`));
|
||||
},
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
finish(new Error("timeout waiting for node.list"));
|
||||
}, timeoutMs);
|
||||
|
||||
client.start();
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForNodeStatus(
|
||||
inst: GatewayInstance,
|
||||
nodeId: string,
|
||||
timeoutMs = GATEWAY_NODE_STATUS_TIMEOUT_MS,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const client = await connectStatusClient(
|
||||
inst,
|
||||
Math.min(GATEWAY_CONNECT_STATUS_TIMEOUT_MS, timeoutMs),
|
||||
);
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const list = await client.request("node.list", {});
|
||||
const match = list.nodes?.find((n) => n.nodeId === nodeId);
|
||||
if (match?.connected && match?.paired) {
|
||||
return;
|
||||
}
|
||||
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
|
||||
}
|
||||
} finally {
|
||||
client.stop();
|
||||
}
|
||||
throw new Error(`timeout waiting for node status for ${nodeId}`);
|
||||
}
|
||||
|
||||
export async function waitForChatFinalEvent(params: {
|
||||
events: ChatEventPayload[];
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<ChatEventPayload> {
|
||||
const deadline = Date.now() + (params.timeoutMs ?? 15_000);
|
||||
while (Date.now() < deadline) {
|
||||
const match = params.events.find(
|
||||
(evt) =>
|
||||
evt.runId === params.runId && evt.sessionKey === params.sessionKey && evt.state === "final",
|
||||
);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error(`timeout waiting for final chat event (runId=${params.runId})`);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export function unwrapRemoteConfigSnapshot(raw: unknown): OpenClawConfig {
|
||||
const rawObj = asRecord(raw);
|
||||
const resolved = asRecord(rawObj.resolved);
|
||||
if (Object.keys(resolved).length > 0) {
|
||||
return resolved as OpenClawConfig;
|
||||
}
|
||||
|
||||
const wrapped = asRecord(rawObj.config);
|
||||
if (Object.keys(wrapped).length > 0) {
|
||||
return wrapped as OpenClawConfig;
|
||||
}
|
||||
|
||||
const legacyPayload = asRecord(rawObj.payload);
|
||||
const legacyResolved = asRecord(legacyPayload.resolved);
|
||||
if (Object.keys(legacyResolved).length > 0) {
|
||||
return legacyResolved as OpenClawConfig;
|
||||
}
|
||||
|
||||
const legacyConfig = asRecord(legacyPayload.config);
|
||||
if (Object.keys(legacyConfig).length > 0) {
|
||||
return legacyConfig as OpenClawConfig;
|
||||
}
|
||||
|
||||
if (Object.keys(rawObj).length > 0 && !Object.prototype.hasOwnProperty.call(rawObj, "payload")) {
|
||||
return rawObj as OpenClawConfig;
|
||||
}
|
||||
|
||||
throw new Error("remote gateway config.get returned empty config payload");
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { GatewayConnectionDetails } from "../../../src/gateway/call.js";
|
||||
|
||||
export function shouldFetchRemotePolicyConfig(details: GatewayConnectionDetails): boolean {
|
||||
return details.urlSource !== "local loopback";
|
||||
}
|
||||
18
openclaw/test/helpers/http-test-server.ts
Normal file
18
openclaw/test/helpers/http-test-server.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createServer, type RequestListener } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise<void>) {
|
||||
const server = createServer(handler);
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address() as AddressInfo | null;
|
||||
if (!address) {
|
||||
throw new Error("missing server address");
|
||||
}
|
||||
try {
|
||||
await fn(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
8
openclaw/test/helpers/import-fresh.ts
Normal file
8
openclaw/test/helpers/import-fresh.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export async function importFreshModule<TModule>(
|
||||
from: string,
|
||||
specifier: string,
|
||||
): Promise<TModule> {
|
||||
// Vitest keys module instances by the full URL string, including the query
|
||||
// suffix. These tests rely on that behavior to emulate code-split chunks.
|
||||
return (await import(/* @vite-ignore */ new URL(specifier, from).href)) as TModule;
|
||||
}
|
||||
269
openclaw/test/helpers/infra/deliver-test-outbounds.ts
Normal file
269
openclaw/test/helpers/infra/deliver-test-outbounds.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { chunkMarkdownTextWithMode, chunkText } from "../../../src/auto-reply/chunk.js";
|
||||
import { resolveChannelMediaMaxBytes } from "../../../src/channels/plugins/media-limits.js";
|
||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../src/infra/outbound/send-deps.js";
|
||||
import { sanitizeForPlainText } from "../../../src/plugin-sdk/outbound-runtime.js";
|
||||
|
||||
type SignalSendFn = (
|
||||
to: string,
|
||||
text: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => Promise<{ messageId: string } & Record<string, unknown>>;
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function resolveSignalMaxBytes(cfg: OpenClawConfig, accountId?: string): number | undefined {
|
||||
const signalCfg = cfg.channels?.signal as
|
||||
| {
|
||||
mediaMaxMb?: number;
|
||||
accounts?: Record<string, { mediaMaxMb?: number }>;
|
||||
}
|
||||
| undefined;
|
||||
const accountMb = accountId ? signalCfg?.accounts?.[accountId]?.mediaMaxMb : undefined;
|
||||
const mediaMaxMb = accountMb ?? signalCfg?.mediaMaxMb;
|
||||
return typeof mediaMaxMb === "number" ? mediaMaxMb * MB : undefined;
|
||||
}
|
||||
|
||||
function resolveSignalSender(deps: OutboundSendDeps | undefined): SignalSendFn {
|
||||
const sender = resolveOutboundSendDep<SignalSendFn>(deps, "signal");
|
||||
if (!sender) {
|
||||
throw new Error("missing sendSignal dep");
|
||||
}
|
||||
return sender;
|
||||
}
|
||||
|
||||
function resolveSignalTextChunkLimit(cfg: OpenClawConfig, accountId?: string | null): number {
|
||||
const signalCfg = cfg.channels?.signal as
|
||||
| {
|
||||
textChunkLimit?: number;
|
||||
accounts?: Record<string, { textChunkLimit?: number }>;
|
||||
}
|
||||
| undefined;
|
||||
const accountLimit = accountId ? signalCfg?.accounts?.[accountId]?.textChunkLimit : undefined;
|
||||
if (typeof accountLimit === "number") {
|
||||
return accountLimit;
|
||||
}
|
||||
return typeof signalCfg?.textChunkLimit === "number" ? signalCfg.textChunkLimit : 4000;
|
||||
}
|
||||
|
||||
function withSignalChannel(result: Awaited<ReturnType<SignalSendFn>>) {
|
||||
return {
|
||||
channel: "signal" as const,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
export const signalOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 4000,
|
||||
sanitizeText: ({ text }) => sanitizeForPlainText(text),
|
||||
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
|
||||
const limit = resolveSignalTextChunkLimit(cfg, accountId);
|
||||
const chunks = chunkMarkdownTextWithMode(text, limit, "length");
|
||||
const outputChunks = chunks.length === 0 && text ? [text] : chunks;
|
||||
const results = [];
|
||||
for (const chunk of outputChunks) {
|
||||
abortSignal?.throwIfAborted();
|
||||
results.push(
|
||||
withSignalChannel(
|
||||
await send(to, chunk, {
|
||||
cfg,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
sendFormattedMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
accountId,
|
||||
deps,
|
||||
abortSignal,
|
||||
}) => {
|
||||
abortSignal?.throwIfAborted();
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
|
||||
return withSignalChannel(
|
||||
await send(to, text, {
|
||||
cfg,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: [],
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
}),
|
||||
);
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
|
||||
return withSignalChannel(
|
||||
await send(to, text, {
|
||||
cfg,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
accountId,
|
||||
deps,
|
||||
}) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
|
||||
return withSignalChannel(
|
||||
await send(to, text, {
|
||||
cfg,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
type WhatsAppSendFn = (
|
||||
to: string,
|
||||
text: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => Promise<{ messageId: string } & Record<string, unknown>>;
|
||||
|
||||
function resolveWhatsAppSender(deps: OutboundSendDeps | undefined): WhatsAppSendFn {
|
||||
const sender = resolveOutboundSendDep<WhatsAppSendFn>(deps, "whatsapp");
|
||||
if (!sender) {
|
||||
throw new Error("missing whatsapp dep");
|
||||
}
|
||||
return sender;
|
||||
}
|
||||
|
||||
function withWhatsAppChannel(result: Awaited<ReturnType<WhatsAppSendFn>>) {
|
||||
return {
|
||||
channel: "whatsapp" as const,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
export const whatsappOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "gateway",
|
||||
chunker: chunkText,
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sanitizeText: ({ text }) => sanitizeForPlainText(text),
|
||||
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
|
||||
const send = resolveWhatsAppSender(deps);
|
||||
return withWhatsAppChannel(
|
||||
await send(to, text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
gifPlayback,
|
||||
}),
|
||||
);
|
||||
},
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
accountId,
|
||||
deps,
|
||||
gifPlayback,
|
||||
}) => {
|
||||
const send = resolveWhatsAppSender(deps);
|
||||
return withWhatsAppChannel(
|
||||
await send(to, text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
accountId: accountId ?? undefined,
|
||||
gifPlayback,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function resolveIMessageSender(deps: OutboundSendDeps | undefined) {
|
||||
const sender = resolveOutboundSendDep<
|
||||
(
|
||||
to: string,
|
||||
text: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => Promise<{ messageId: string; chatId?: string }>
|
||||
>(deps, "imessage");
|
||||
if (!sender) {
|
||||
throw new Error("missing sendIMessage dep");
|
||||
}
|
||||
return sender;
|
||||
}
|
||||
|
||||
function withIMessageChannel(
|
||||
result: Awaited<ReturnType<ReturnType<typeof resolveIMessageSender>>>,
|
||||
) {
|
||||
return {
|
||||
channel: "imessage" as const,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveIMessageMaxBytes(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): number | undefined {
|
||||
return resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
|
||||
cfg.channels?.imessage?.mediaMaxMb,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export const imessageOutboundForTest: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
sanitizeText: ({ text }) => text,
|
||||
sendText: async ({ to, text, accountId, deps }) =>
|
||||
withIMessageChannel(
|
||||
await resolveIMessageSender(deps)(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
),
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, mediaReadFile, accountId, deps }) =>
|
||||
withIMessageChannel(
|
||||
await resolveIMessageSender(deps)(to, text, {
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
maxBytes: resolveIMessageMaxBytes(cfg, accountId),
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import type {
|
||||
ChannelId,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../src/infra/outbound/send-deps.js";
|
||||
import { createOutboundTestPlugin } from "../../../src/test-utils/channel-plugins.js";
|
||||
|
||||
type HeartbeatSendChannelId = "slack" | "telegram" | "whatsapp";
|
||||
type HeartbeatSendFn = (
|
||||
to: string,
|
||||
text: string,
|
||||
opts?: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
|
||||
function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): ChannelOutboundAdapter {
|
||||
return {
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ to, text, deps, cfg, accountId, replyToId, threadId, ...opts }) => {
|
||||
const send = resolveOutboundSendDep<HeartbeatSendFn>(deps as OutboundSendDeps, channelId);
|
||||
if (!send) {
|
||||
throw new Error(`Missing ${channelId} outbound send dependency`);
|
||||
}
|
||||
const baseOptions = {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId,
|
||||
};
|
||||
const sendOptions =
|
||||
channelId === "telegram"
|
||||
? {
|
||||
...baseOptions,
|
||||
...(typeof threadId === "number" ? { messageThreadId: threadId } : {}),
|
||||
...(typeof replyToId === "string" ? { replyToMessageId: Number(replyToId) } : {}),
|
||||
}
|
||||
: {
|
||||
...baseOptions,
|
||||
...opts,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
...(threadId !== undefined ? { threadId } : {}),
|
||||
};
|
||||
return (await send(to, text, sendOptions)) as never;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHeartbeatChannelPlugin(params: {
|
||||
id: HeartbeatSendChannelId;
|
||||
label: string;
|
||||
docsPath: string;
|
||||
heartbeat?: ChannelPlugin["heartbeat"];
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
...createOutboundTestPlugin({
|
||||
id: params.id as ChannelId,
|
||||
label: params.label,
|
||||
docsPath: params.docsPath,
|
||||
outbound: createHeartbeatOutboundAdapter(params.id),
|
||||
}),
|
||||
...(params.heartbeat ? { heartbeat: params.heartbeat } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const heartbeatRunnerSlackPlugin = createHeartbeatChannelPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
});
|
||||
|
||||
export const heartbeatRunnerTelegramPlugin = createHeartbeatChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
});
|
||||
|
||||
export const heartbeatRunnerWhatsAppPlugin = createHeartbeatChannelPlugin({
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
heartbeat: {
|
||||
checkReady: async ({ cfg, deps }) => {
|
||||
if (cfg.web?.enabled === false) {
|
||||
return { ok: false, reason: "whatsapp-disabled" };
|
||||
}
|
||||
const authExists = await (deps?.webAuthExists ?? (async () => true))();
|
||||
if (!authExists) {
|
||||
return { ok: false, reason: "whatsapp-not-linked" };
|
||||
}
|
||||
const listenerActive = deps?.hasActiveWebListener ? deps.hasActiveWebListener() : true;
|
||||
if (!listenerActive) {
|
||||
return { ok: false, reason: "whatsapp-not-running" };
|
||||
}
|
||||
return { ok: true, reason: "ok" };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
|
||||
import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type BundledPluginEntryModule = {
|
||||
default: {
|
||||
register(api: OpenClawPluginApi): void;
|
||||
};
|
||||
};
|
||||
|
||||
export function loadBundledProviderPlugin(pluginId: string): BundledPluginEntryModule["default"] {
|
||||
return loadBundledPluginPublicSurfaceSync<BundledPluginEntryModule>({
|
||||
pluginId,
|
||||
artifactBasename: "index.js",
|
||||
}).default;
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import type { VideoGenerationResult } from "openclaw/plugin-sdk/video-generation";
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
type ClearableMock = {
|
||||
mockClear(): unknown;
|
||||
};
|
||||
|
||||
type ResettableMock = {
|
||||
mockReset(): unknown;
|
||||
};
|
||||
|
||||
type ResolvableMock = {
|
||||
mockResolvedValue(value: unknown): unknown;
|
||||
};
|
||||
|
||||
type ChainableResolvedValueMock = ResettableMock & {
|
||||
mockResolvedValueOnce(value: unknown): ChainableResolvedValueMock;
|
||||
};
|
||||
|
||||
export type DashscopeVideoProviderMocks = {
|
||||
resolveApiKeyForProviderMock: ClearableMock;
|
||||
postJsonRequestMock: ResettableMock & ResolvableMock;
|
||||
fetchWithTimeoutMock: ChainableResolvedValueMock;
|
||||
assertOkOrThrowHttpErrorMock: ClearableMock;
|
||||
resolveProviderHttpRequestConfigMock: ClearableMock;
|
||||
};
|
||||
|
||||
export function resetDashscopeVideoProviderMocks(mocks: DashscopeVideoProviderMocks): void {
|
||||
mocks.resolveApiKeyForProviderMock.mockClear();
|
||||
mocks.postJsonRequestMock.mockReset();
|
||||
mocks.fetchWithTimeoutMock.mockReset();
|
||||
mocks.assertOkOrThrowHttpErrorMock.mockClear();
|
||||
mocks.resolveProviderHttpRequestConfigMock.mockClear();
|
||||
}
|
||||
|
||||
export function mockSuccessfulDashscopeVideoTask(
|
||||
mocks: Pick<DashscopeVideoProviderMocks, "postJsonRequestMock" | "fetchWithTimeoutMock">,
|
||||
params: {
|
||||
requestId?: string;
|
||||
taskId?: string;
|
||||
taskStatus?: string;
|
||||
videoUrl?: string;
|
||||
} = {},
|
||||
): void {
|
||||
const {
|
||||
requestId = "req-1",
|
||||
taskId = "task-1",
|
||||
taskStatus = "SUCCEEDED",
|
||||
videoUrl = "https://example.com/out.mp4",
|
||||
} = params;
|
||||
mocks.postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
request_id: requestId,
|
||||
output: {
|
||||
task_id: taskId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
mocks.fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
output: {
|
||||
task_status: taskStatus,
|
||||
results: [{ video_url: videoUrl }],
|
||||
},
|
||||
}),
|
||||
headers: new Headers(),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
arrayBuffer: async () => Buffer.from("mp4-bytes"),
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
});
|
||||
}
|
||||
|
||||
export function expectDashscopeVideoTaskPoll(
|
||||
fetchWithTimeoutMock: ChainableResolvedValueMock,
|
||||
params: {
|
||||
baseUrl?: string;
|
||||
taskId?: string;
|
||||
timeoutMs?: number;
|
||||
} = {},
|
||||
): void {
|
||||
const {
|
||||
baseUrl = "https://dashscope-intl.aliyuncs.com",
|
||||
taskId = "task-1",
|
||||
timeoutMs = 120_000,
|
||||
} = params;
|
||||
expect(fetchWithTimeoutMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`${baseUrl}/api/v1/tasks/${taskId}`,
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
timeoutMs,
|
||||
fetch,
|
||||
);
|
||||
}
|
||||
|
||||
export function expectSuccessfulDashscopeVideoResult(
|
||||
result: VideoGenerationResult,
|
||||
params: {
|
||||
requestId?: string;
|
||||
taskId?: string;
|
||||
taskStatus?: string;
|
||||
} = {},
|
||||
): void {
|
||||
const { requestId = "req-1", taskId = "task-1", taskStatus = "SUCCEEDED" } = params;
|
||||
expect(result.videos).toHaveLength(1);
|
||||
expect(result.videos[0]?.mimeType).toBe("video/mp4");
|
||||
expect(result.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
requestId,
|
||||
taskId,
|
||||
taskStatus,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { expect } from "vitest";
|
||||
import { listSupportedMusicGenerationModes } from "../../../src/music-generation/capabilities.js";
|
||||
import type {
|
||||
MusicGenerationProviderPlugin,
|
||||
VideoGenerationProviderPlugin,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import { listSupportedVideoGenerationModes } from "../../../src/video-generation/capabilities.js";
|
||||
|
||||
export function expectExplicitVideoGenerationCapabilities(
|
||||
provider: VideoGenerationProviderPlugin,
|
||||
): void {
|
||||
expect(
|
||||
provider.capabilities.generate,
|
||||
`${provider.id} missing generate capabilities`,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
provider.capabilities.imageToVideo,
|
||||
`${provider.id} missing imageToVideo capabilities`,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
provider.capabilities.videoToVideo,
|
||||
`${provider.id} missing videoToVideo capabilities`,
|
||||
).toBeDefined();
|
||||
|
||||
const supportedModes = listSupportedVideoGenerationModes(provider);
|
||||
const imageToVideo = provider.capabilities.imageToVideo;
|
||||
const videoToVideo = provider.capabilities.videoToVideo;
|
||||
|
||||
if (imageToVideo?.enabled) {
|
||||
expect(
|
||||
imageToVideo.maxInputImages ?? 0,
|
||||
`${provider.id} imageToVideo.enabled requires maxInputImages`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(supportedModes).toContain("imageToVideo");
|
||||
}
|
||||
if (videoToVideo?.enabled) {
|
||||
expect(
|
||||
videoToVideo.maxInputVideos ?? 0,
|
||||
`${provider.id} videoToVideo.enabled requires maxInputVideos`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(supportedModes).toContain("videoToVideo");
|
||||
}
|
||||
}
|
||||
|
||||
export function expectExplicitMusicGenerationCapabilities(
|
||||
provider: MusicGenerationProviderPlugin,
|
||||
): void {
|
||||
expect(
|
||||
provider.capabilities.generate,
|
||||
`${provider.id} missing generate capabilities`,
|
||||
).toBeDefined();
|
||||
expect(provider.capabilities.edit, `${provider.id} missing edit capabilities`).toBeDefined();
|
||||
|
||||
const edit = provider.capabilities.edit;
|
||||
if (!edit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (edit.enabled) {
|
||||
expect(
|
||||
edit.maxInputImages ?? 0,
|
||||
`${provider.id} edit.enabled requires maxInputImages`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(listSupportedMusicGenerationModes(provider)).toContain("edit");
|
||||
} else {
|
||||
expect(listSupportedMusicGenerationModes(provider)).toEqual(["generate"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
type ResolveProviderHttpRequestConfigParams = Parameters<
|
||||
typeof resolveProviderHttpRequestConfig
|
||||
>[0];
|
||||
|
||||
const providerHttpMocks = vi.hoisted(() => ({
|
||||
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })),
|
||||
postJsonRequestMock: vi.fn(),
|
||||
fetchWithTimeoutMock: vi.fn(),
|
||||
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
|
||||
resolveProviderHttpRequestConfigMock: vi.fn((params: ResolveProviderHttpRequestConfigParams) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers(params.defaultHeaders),
|
||||
dispatcherPolicy: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
|
||||
resolveApiKeyForProvider: providerHttpMocks.resolveApiKeyForProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
|
||||
assertOkOrThrowHttpError: providerHttpMocks.assertOkOrThrowHttpErrorMock,
|
||||
createProviderOperationDeadline: ({
|
||||
label,
|
||||
timeoutMs,
|
||||
}: {
|
||||
label: string;
|
||||
timeoutMs?: number;
|
||||
}) => ({
|
||||
label,
|
||||
timeoutMs,
|
||||
}),
|
||||
fetchWithTimeout: providerHttpMocks.fetchWithTimeoutMock,
|
||||
postJsonRequest: providerHttpMocks.postJsonRequestMock,
|
||||
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
|
||||
defaultTimeoutMs,
|
||||
resolveProviderHttpRequestConfig: providerHttpMocks.resolveProviderHttpRequestConfigMock,
|
||||
waitProviderOperationPollInterval: async () => {},
|
||||
}));
|
||||
|
||||
export function getProviderHttpMocks() {
|
||||
return providerHttpMocks;
|
||||
}
|
||||
|
||||
export function installProviderHttpMockCleanup(): void {
|
||||
afterEach(() => {
|
||||
providerHttpMocks.resolveApiKeyForProviderMock.mockClear();
|
||||
providerHttpMocks.postJsonRequestMock.mockReset();
|
||||
providerHttpMocks.fetchWithTimeoutMock.mockReset();
|
||||
providerHttpMocks.assertOkOrThrowHttpErrorMock.mockClear();
|
||||
providerHttpMocks.resolveProviderHttpRequestConfigMock.mockClear();
|
||||
});
|
||||
}
|
||||
156
openclaw/test/helpers/media-generation/runtime-module-mocks.ts
Normal file
156
openclaw/test/helpers/media-generation/runtime-module-mocks.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ImageGenerationProvider } from "../../../src/image-generation/types.js";
|
||||
import type { MusicGenerationProvider } from "../../../src/music-generation/types.js";
|
||||
import type { VideoGenerationProvider } from "../../../src/video-generation/types.js";
|
||||
import { resetGenerationRuntimeMocks } from "./runtime-test-mocks.js";
|
||||
|
||||
type ModelRef = { provider: string; model: string };
|
||||
|
||||
const mediaRuntimeMocks = vi.hoisted(() => {
|
||||
const debug = vi.fn();
|
||||
const warn = vi.fn();
|
||||
const parseGenerationModelRef = (raw?: string): ModelRef | undefined => {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash <= 0 || slash === trimmed.length - 1) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
provider: trimmed.slice(0, slash),
|
||||
model: trimmed.slice(slash + 1),
|
||||
};
|
||||
};
|
||||
return {
|
||||
createSubsystemLogger: vi.fn(() => ({ debug, warn: vi.fn() })),
|
||||
describeFailoverError: vi.fn(),
|
||||
getImageGenerationProvider: vi.fn<
|
||||
(providerId: string, config?: OpenClawConfig) => ImageGenerationProvider | undefined
|
||||
>(() => undefined),
|
||||
getMusicGenerationProvider: vi.fn<
|
||||
(providerId: string, config?: OpenClawConfig) => MusicGenerationProvider | undefined
|
||||
>(() => undefined),
|
||||
getProviderEnvVars: vi.fn<(providerId: string) => string[]>(() => []),
|
||||
getVideoGenerationProvider: vi.fn<
|
||||
(providerId: string, config?: OpenClawConfig) => VideoGenerationProvider | undefined
|
||||
>(() => undefined),
|
||||
isFailoverError: vi.fn<(err: unknown) => boolean>(() => false),
|
||||
listImageGenerationProviders: vi.fn<(config?: OpenClawConfig) => ImageGenerationProvider[]>(
|
||||
() => [],
|
||||
),
|
||||
listMusicGenerationProviders: vi.fn<(config?: OpenClawConfig) => MusicGenerationProvider[]>(
|
||||
() => [],
|
||||
),
|
||||
listVideoGenerationProviders: vi.fn<(config?: OpenClawConfig) => VideoGenerationProvider[]>(
|
||||
() => [],
|
||||
),
|
||||
parseImageGenerationModelRef:
|
||||
vi.fn<(raw?: string) => ModelRef | undefined>(parseGenerationModelRef),
|
||||
parseMusicGenerationModelRef:
|
||||
vi.fn<(raw?: string) => ModelRef | undefined>(parseGenerationModelRef),
|
||||
parseVideoGenerationModelRef:
|
||||
vi.fn<(raw?: string) => ModelRef | undefined>(parseGenerationModelRef),
|
||||
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
listProfilesForProvider: vi.fn(() => []),
|
||||
resolveEnvApiKey: vi.fn(() => undefined),
|
||||
resolveAgentModelFallbackValues: vi.fn<(value: unknown) => string[]>(() => []),
|
||||
resolveAgentModelPrimaryValue: vi.fn<(value: unknown) => string | undefined>(() => undefined),
|
||||
resolveProviderAuthEnvVarCandidates: vi.fn(() => ({})),
|
||||
debug,
|
||||
warn,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/agents/auth-profiles.js", () => ({
|
||||
ensureAuthProfileStore: mediaRuntimeMocks.ensureAuthProfileStore,
|
||||
listProfilesForProvider: mediaRuntimeMocks.listProfilesForProvider,
|
||||
}));
|
||||
vi.mock("../../../src/agents/defaults.js", () => ({
|
||||
DEFAULT_PROVIDER: "openai",
|
||||
}));
|
||||
vi.mock("../../../src/agents/failover-error.js", () => ({
|
||||
describeFailoverError: mediaRuntimeMocks.describeFailoverError,
|
||||
isFailoverError: mediaRuntimeMocks.isFailoverError,
|
||||
}));
|
||||
vi.mock("../../../src/agents/model-auth-env.js", () => ({
|
||||
resolveEnvApiKey: mediaRuntimeMocks.resolveEnvApiKey,
|
||||
}));
|
||||
vi.mock("../../../src/config/model-input.js", () => ({
|
||||
resolveAgentModelFallbackValues: mediaRuntimeMocks.resolveAgentModelFallbackValues,
|
||||
resolveAgentModelPrimaryValue: mediaRuntimeMocks.resolveAgentModelPrimaryValue,
|
||||
}));
|
||||
vi.mock("../../../src/logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: mediaRuntimeMocks.createSubsystemLogger,
|
||||
}));
|
||||
vi.mock("../../../src/secrets/provider-env-vars.js", () => ({
|
||||
getProviderEnvVars: mediaRuntimeMocks.getProviderEnvVars,
|
||||
resolveProviderAuthEnvVarCandidates: mediaRuntimeMocks.resolveProviderAuthEnvVarCandidates,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/image-generation/model-ref.js", () => ({
|
||||
parseImageGenerationModelRef: mediaRuntimeMocks.parseImageGenerationModelRef,
|
||||
}));
|
||||
vi.mock("../../../src/image-generation/provider-registry.js", () => ({
|
||||
getImageGenerationProvider: mediaRuntimeMocks.getImageGenerationProvider,
|
||||
listImageGenerationProviders: mediaRuntimeMocks.listImageGenerationProviders,
|
||||
}));
|
||||
vi.mock("../../../src/music-generation/model-ref.js", () => ({
|
||||
parseMusicGenerationModelRef: mediaRuntimeMocks.parseMusicGenerationModelRef,
|
||||
}));
|
||||
vi.mock("../../../src/music-generation/provider-registry.js", () => ({
|
||||
getMusicGenerationProvider: mediaRuntimeMocks.getMusicGenerationProvider,
|
||||
listMusicGenerationProviders: mediaRuntimeMocks.listMusicGenerationProviders,
|
||||
}));
|
||||
vi.mock("../../../src/video-generation/model-ref.js", () => ({
|
||||
parseVideoGenerationModelRef: mediaRuntimeMocks.parseVideoGenerationModelRef,
|
||||
}));
|
||||
vi.mock("../../../src/video-generation/provider-registry.js", () => ({
|
||||
getVideoGenerationProvider: mediaRuntimeMocks.getVideoGenerationProvider,
|
||||
listVideoGenerationProviders: mediaRuntimeMocks.listVideoGenerationProviders,
|
||||
}));
|
||||
|
||||
export function getMediaGenerationRuntimeMocks() {
|
||||
return mediaRuntimeMocks;
|
||||
}
|
||||
|
||||
export function resetImageGenerationRuntimeMocks(): void {
|
||||
resetSharedRuntimeImportMocks();
|
||||
resetGenerationRuntimeMocks({
|
||||
...mediaRuntimeMocks,
|
||||
getProvider: mediaRuntimeMocks.getImageGenerationProvider,
|
||||
listProviders: mediaRuntimeMocks.listImageGenerationProviders,
|
||||
parseModelRef: mediaRuntimeMocks.parseImageGenerationModelRef,
|
||||
});
|
||||
}
|
||||
|
||||
export function resetMusicGenerationRuntimeMocks(): void {
|
||||
resetSharedRuntimeImportMocks();
|
||||
resetGenerationRuntimeMocks({
|
||||
...mediaRuntimeMocks,
|
||||
getProvider: mediaRuntimeMocks.getMusicGenerationProvider,
|
||||
listProviders: mediaRuntimeMocks.listMusicGenerationProviders,
|
||||
parseModelRef: mediaRuntimeMocks.parseMusicGenerationModelRef,
|
||||
});
|
||||
}
|
||||
|
||||
export function resetVideoGenerationRuntimeMocks(): void {
|
||||
resetSharedRuntimeImportMocks();
|
||||
resetGenerationRuntimeMocks({
|
||||
...mediaRuntimeMocks,
|
||||
getProvider: mediaRuntimeMocks.getVideoGenerationProvider,
|
||||
listProviders: mediaRuntimeMocks.listVideoGenerationProviders,
|
||||
parseModelRef: mediaRuntimeMocks.parseVideoGenerationModelRef,
|
||||
});
|
||||
}
|
||||
|
||||
function resetSharedRuntimeImportMocks(): void {
|
||||
mediaRuntimeMocks.ensureAuthProfileStore.mockReset();
|
||||
mediaRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
|
||||
mediaRuntimeMocks.listProfilesForProvider.mockReset();
|
||||
mediaRuntimeMocks.listProfilesForProvider.mockReturnValue([]);
|
||||
mediaRuntimeMocks.resolveEnvApiKey.mockReset();
|
||||
mediaRuntimeMocks.resolveEnvApiKey.mockReturnValue(undefined);
|
||||
}
|
||||
45
openclaw/test/helpers/media-generation/runtime-test-mocks.ts
Normal file
45
openclaw/test/helpers/media-generation/runtime-test-mocks.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
type ClearableMock = {
|
||||
mockClear(): unknown;
|
||||
};
|
||||
|
||||
type ResettableMock = {
|
||||
mockReset(): unknown;
|
||||
};
|
||||
|
||||
type ResettableReturnMock = ResettableMock & {
|
||||
mockReturnValue(value: unknown): unknown;
|
||||
};
|
||||
|
||||
export type GenerationRuntimeMocks = {
|
||||
createSubsystemLogger: ClearableMock;
|
||||
describeFailoverError: ResettableMock;
|
||||
getProvider: ResettableReturnMock;
|
||||
getProviderEnvVars: ResettableReturnMock;
|
||||
resolveProviderAuthEnvVarCandidates: ResettableReturnMock;
|
||||
isFailoverError: ResettableReturnMock;
|
||||
listProviders: ResettableReturnMock;
|
||||
parseModelRef: ClearableMock;
|
||||
resolveAgentModelFallbackValues: ResettableReturnMock;
|
||||
resolveAgentModelPrimaryValue: ResettableReturnMock;
|
||||
debug: ResettableMock;
|
||||
};
|
||||
|
||||
export function resetGenerationRuntimeMocks(mocks: GenerationRuntimeMocks): void {
|
||||
mocks.createSubsystemLogger.mockClear();
|
||||
mocks.describeFailoverError.mockReset();
|
||||
mocks.getProvider.mockReset();
|
||||
mocks.getProviderEnvVars.mockReset();
|
||||
mocks.getProviderEnvVars.mockReturnValue([]);
|
||||
mocks.resolveProviderAuthEnvVarCandidates.mockReset();
|
||||
mocks.resolveProviderAuthEnvVarCandidates.mockReturnValue({});
|
||||
mocks.isFailoverError.mockReset();
|
||||
mocks.isFailoverError.mockReturnValue(false);
|
||||
mocks.listProviders.mockReset();
|
||||
mocks.listProviders.mockReturnValue([]);
|
||||
mocks.parseModelRef.mockClear();
|
||||
mocks.resolveAgentModelFallbackValues.mockReset();
|
||||
mocks.resolveAgentModelFallbackValues.mockReturnValue([]);
|
||||
mocks.resolveAgentModelPrimaryValue.mockReset();
|
||||
mocks.resolveAgentModelPrimaryValue.mockReturnValue(undefined);
|
||||
mocks.debug.mockReset();
|
||||
}
|
||||
27
openclaw/test/helpers/mock-incoming-request.ts
Normal file
27
openclaw/test/helpers/mock-incoming-request.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
export function createMockIncomingRequest(chunks: string[]): IncomingMessage {
|
||||
const req = new EventEmitter() as IncomingMessage & {
|
||||
destroyed?: boolean;
|
||||
destroy: (error?: Error) => IncomingMessage;
|
||||
};
|
||||
req.destroyed = false;
|
||||
req.headers = {};
|
||||
req.destroy = () => {
|
||||
req.destroyed = true;
|
||||
return req;
|
||||
};
|
||||
|
||||
void Promise.resolve().then(() => {
|
||||
for (const chunk of chunks) {
|
||||
req.emit("data", Buffer.from(chunk, "utf-8"));
|
||||
if (req.destroyed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
req.emit("end");
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue